forked from EduCraft/curriculum-project-hub
fix: prove Linux Agent sandbox boundary
This commit is contained in:
@@ -44,7 +44,7 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Install Linux sandbox dependency
|
- name: Install Linux sandbox dependency
|
||||||
run: sudo apt-get update && sudo apt-get install --yes bubblewrap
|
run: sudo apt-get update && sudo apt-get install --yes bubblewrap socat
|
||||||
|
|
||||||
- name: Wait for Postgres
|
- name: Wait for Postgres
|
||||||
run: |
|
run: |
|
||||||
@@ -89,7 +89,11 @@ jobs:
|
|||||||
cargo install --path crates/cph-cli --locked
|
cargo install --path crates/cph-cli --locked
|
||||||
|
|
||||||
- name: Prove real Claude SDK Bash sandbox boundary
|
- name: Prove real Claude SDK Bash sandbox boundary
|
||||||
run: /usr/bin/setpriv --no-new-privs npx vitest run test/integration/agent-sandbox-linux.test.ts
|
run: |
|
||||||
|
sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /var/lib/cph-test
|
||||||
|
CPH_SANDBOX_TEST_ROOT=/var/lib/cph-test \
|
||||||
|
/usr/bin/setpriv --no-new-privs \
|
||||||
|
npx vitest run test/integration/agent-sandbox-linux.test.ts
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: npx vitest run test/unit
|
run: npx vitest run test/unit
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ unit is written or enabled.
|
|||||||
Preflight validates the configured bind host/port, authenticated PostgreSQL
|
Preflight validates the configured bind host/port, authenticated PostgreSQL
|
||||||
access, Node/cph/Prisma, configured and canonical path traversal, and a real
|
access, Node/cph/Prisma, configured and canonical path traversal, and a real
|
||||||
bubblewrap namespace. The post-provision phase runs the built Hub dependency
|
bubblewrap namespace. The post-provision phase runs the built Hub dependency
|
||||||
graph, a database query, cph and bubblewrap as the service user under
|
graph, a database query, cph, socat and bubblewrap as the service user under
|
||||||
`no_new_privs`, matching the systemd security context. HTTP binding is awaited
|
`no_new_privs`, matching the systemd security context. HTTP binding is awaited
|
||||||
before the Feishu listener starts, so bind failure rejects startup instead of
|
before the Feishu listener starts, so bind failure rejects startup instead of
|
||||||
leaving a listener-only process alive.
|
leaving a listener-only process alive.
|
||||||
|
|||||||
+27
-1
@@ -1,7 +1,7 @@
|
|||||||
# Confine the agent runtime and protect service credentials
|
# Confine the agent runtime and protect service credentials
|
||||||
|
|
||||||
Type: task
|
Type: task
|
||||||
Status: claimed
|
Status: resolved
|
||||||
|
|
||||||
## Question
|
## Question
|
||||||
|
|
||||||
@@ -10,3 +10,29 @@ deny sibling tenant workspaces and service files, expose only the minimum safe
|
|||||||
process environment, use SDK credential protection for secrets, and add an
|
process environment, use SDK credential protection for secrets, and add an
|
||||||
actual Linux sandbox test proving another workspace and every non-provider
|
actual Linux sandbox test proving another workspace and every non-provider
|
||||||
credential are unreadable while required tools still work.
|
credential are unreadable while required tools still work.
|
||||||
|
|
||||||
|
## Answer
|
||||||
|
|
||||||
|
The Agent subprocess now receives a replacement environment containing only
|
||||||
|
named runtime values and the selected provider protocol values; PostgreSQL,
|
||||||
|
Feishu and Hub session credentials never cross the process boundary. Provider
|
||||||
|
credentials are additionally denied inside Bash. The SDK must sandbox every
|
||||||
|
command (`failIfUnavailable=true`, `allowUnsandboxedCommands=false`), denies
|
||||||
|
host reads and writes from root, and re-opens only the canonical project
|
||||||
|
workspace plus the reviewed read-only system runtime needed by `cph`.
|
||||||
|
|
||||||
|
SDK config/cache/home and general temp storage live inside the workspace. A
|
||||||
|
short relative `CLAUDE_CODE_TMPDIR` prevents the pinned SDK from falling back
|
||||||
|
to host `/tmp`, while absolute workspace-local `TMPDIR`/`TMP`/`TEMP` remain
|
||||||
|
stable after Bash changes directory. Linux production and CI now require both
|
||||||
|
bubblewrap and `socat`; preflight probes both as the service user. Bounded,
|
||||||
|
credential-redacted SDK stderr is logged with run and project correlation.
|
||||||
|
|
||||||
|
The real Linux proof runs the actual Claude SDK, Bash, bubblewrap, `socat` and
|
||||||
|
release `cph` as UID 1000 with no capabilities and `no_new_privs`. It proves
|
||||||
|
parent, sibling-project, `/tmp` and `/var/tmp` writes are rejected and have no
|
||||||
|
host effect; sibling/service-secret reads fail; platform and provider secrets
|
||||||
|
are absent from Bash; workspace temp remains anchored after `cd`; and `cph`
|
||||||
|
still executes. Final local gates passed: TypeScript check/build, Prisma schema,
|
||||||
|
173 unit tests, 85 mock-provider integration tests, deployment shell syntax,
|
||||||
|
and the dedicated Linux sandbox test.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Close MCP context and file-delivery escape paths
|
# Close MCP context and file-delivery escape paths
|
||||||
|
|
||||||
Type: task
|
Type: task
|
||||||
Status: claimed
|
Status: resolved
|
||||||
|
|
||||||
## Question
|
## Question
|
||||||
|
|
||||||
@@ -9,3 +9,20 @@ Restrict file delivery to a realpath/no-follow target inside the current project
|
|||||||
workspace, remove Git-root access, make inbound Hub writes symlink-safe, and
|
workspace, remove Git-root access, make inbound Hub writes symlink-safe, and
|
||||||
validate every Feishu message/thread result against the run's bound chat before
|
validate every Feishu message/thread result against the run's bound chat before
|
||||||
returning data to the model.
|
returning data to the model.
|
||||||
|
|
||||||
|
## Answer
|
||||||
|
|
||||||
|
All Agent-facing workspace file reads and writes are anchored to the configured
|
||||||
|
workspace root and current project. Linux uses descriptor-relative, no-follow
|
||||||
|
traversal so a symlink swap cannot escape after validation; unsupported unsafe
|
||||||
|
paths fail closed. Inbound Feishu files use the same boundary. Outbound file
|
||||||
|
delivery no longer accepts the repository Git root or arbitrary absolute
|
||||||
|
paths: it reads a no-follow snapshot from the current project and uploads that
|
||||||
|
snapshot, so the checked object cannot be swapped before delivery.
|
||||||
|
|
||||||
|
The Feishu MCP read surface carries the run's project and bound-chat context.
|
||||||
|
Message and thread results are rejected unless their returned chat matches that
|
||||||
|
binding, and file-delivery MCP calls remain scoped to the same run/project/chat
|
||||||
|
tuple. Unit and integration regressions cover symlink paths, sibling projects,
|
||||||
|
Git-root delivery attempts, swapped files, and cross-chat results; they are
|
||||||
|
included in the final 173-unit/85-integration green gate reported by Issue 16.
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ rollback and recovery, and no known critical security or data-integrity gaps.
|
|||||||
- [Decide the platform-administrator identity and audit boundary](issues/42-decide-platform-admin-identity-audit.md) — use a dedicated platform Feishu issuer, one peer administrator role, guarded bootstrap and identity-bound invitations, revocable server-side sessions, atomic append-only Platform Audit, and dual-factor offline recovery without a standing break-glass account.
|
- [Decide the platform-administrator identity and audit boundary](issues/42-decide-platform-admin-identity-audit.md) — use a dedicated platform Feishu issuer, one peer administrator role, guarded bootstrap and identity-bound invitations, revocable server-side sessions, atomic append-only Platform Audit, and dual-factor offline recovery without a standing break-glass account.
|
||||||
- [Restore the checker formatting gate](issues/09-restore-checker-format-gate.md) — pin local and CI Rust/rustfmt/clippy to 1.92.0, apply the canonical workspace format, and keep the full fmt + clippy + 53-test checker gate green.
|
- [Restore the checker formatting gate](issues/09-restore-checker-format-gate.md) — pin local and CI Rust/rustfmt/clippy to 1.92.0, apply the canonical workspace format, and keep the full fmt + clippy + 53-test checker gate green.
|
||||||
- [Provision a runnable non-root Hub service](issues/10-provision-nonroot-service.md) — install a strictly validated service identity and persistent layout, reject release/workspace overlap, validate complete production configuration and real bind settings, and execute Hub/Prisma/cph/bubblewrap probes as the service user under `no_new_privs` before writing the unit.
|
- [Provision a runnable non-root Hub service](issues/10-provision-nonroot-service.md) — install a strictly validated service identity and persistent layout, reject release/workspace overlap, validate complete production configuration and real bind settings, and execute Hub/Prisma/cph/bubblewrap probes as the service user under `no_new_privs` before writing the unit.
|
||||||
|
- [Confine the agent runtime and protect service credentials](issues/16-confine-agent-and-protect-credentials.md) — replace the Agent environment, deny host reads/writes from root, keep every writable SDK path inside the canonical project, require bubblewrap plus socat, emit bounded correlated diagnostics, and prove the boundary with the real Linux SDK under a capability-free non-root identity.
|
||||||
|
- [Close MCP context and file-delivery escape paths](issues/17-close-mcp-data-egress-escapes.md) — use no-follow project-rooted file operations and delivery snapshots, remove Git-root delivery, and bind every Feishu MCP read to the run's project and chat.
|
||||||
|
|
||||||
## Fog
|
## Fog
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ discipline).
|
|||||||
### The mechanism: SDK sandbox (OS-level, per agent process)
|
### The mechanism: SDK sandbox (OS-level, per agent process)
|
||||||
|
|
||||||
The boundary is enforced by the Claude Code SDK's built-in sandbox
|
The boundary is enforced by the Claude Code SDK's built-in sandbox
|
||||||
(bubblewrap on Linux, seatbelt on macOS), configured via `query()` options in
|
(bubblewrap plus `socat` on Linux, seatbelt on macOS), configured via `query()` options in
|
||||||
`hub/src/agent/runner.ts`:
|
`hub/src/agent/runner.ts`:
|
||||||
|
|
||||||
- `sandbox.enabled: true` + `failIfUnavailable: true` — the agent process and
|
- `sandbox.enabled: true` + `failIfUnavailable: true` — the agent process and
|
||||||
@@ -99,9 +99,16 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
|
|||||||
in the run's workspace while `/bin`, shared libraries, CA certificates,
|
in the run's workspace while `/bin`, shared libraries, CA certificates,
|
||||||
fonts and the configured `cph` executable remain available as the external
|
fonts and the configured `cph` executable remain available as the external
|
||||||
tool exception described above.
|
tool exception described above.
|
||||||
- `sandbox.filesystem.allowWrite: [workspaceDir]` — writes are confined to the
|
- `sandbox.filesystem.allowWrite: [workspaceDir]` confines every write to the
|
||||||
canonical ADR-0007 workspace; SDK temp/config/cache/home paths are relocated
|
canonical ADR-0007 workspace. Config/cache/home stay beneath
|
||||||
beneath `.cph/agent-runtime/` in that same workspace.
|
`.cph/agent-runtime/`. `TMPDIR`, `TMP`, and `TEMP` use the absolute
|
||||||
|
workspace-local `.cph/t/` path so tools remain anchored after `cd`;
|
||||||
|
`CLAUDE_CODE_TMPDIR` uses the short relative `.cph/t` prefix, resolved from
|
||||||
|
the canonical workspace cwd, because the SDK's socat bridge otherwise falls
|
||||||
|
back to a host temp directory when its Unix-socket prefix is too long.
|
||||||
|
Root is denied for writes and only the canonical workspace is re-opened, so
|
||||||
|
`/tmp`, `/var/tmp`, sibling projects and every other host path are rejected.
|
||||||
|
There is no writable scratch exception outside the project directory.
|
||||||
- The SDK subprocess environment replaces rather than spreads `process.env`.
|
- The SDK subprocess environment replaces rather than spreads `process.env`.
|
||||||
Only provider protocol variables and non-secret runtime variables cross the
|
Only provider protocol variables and non-secret runtime variables cross the
|
||||||
boundary; database, Feishu and Hub session credentials never enter it.
|
boundary; database, Feishu and Hub session credentials never enter it.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Hub service installation
|
# Hub service installation
|
||||||
|
|
||||||
The initial production target is a supported Linux host with systemd,
|
The initial production target is a supported Linux host with systemd,
|
||||||
PostgreSQL, Node.js 20 or newer, `pg_isready`, `runuser`, `setpriv`, bubblewrap
|
PostgreSQL, Node.js 20 or newer, `pg_isready`, `runuser`, `setpriv`, bubblewrap,
|
||||||
and `cph`. The Hub runs as the dedicated non-login `cph-hub` user; do not
|
`socat` and `cph`. The Hub runs as the dedicated non-login `cph-hub` user; do not
|
||||||
create or run the unit as root.
|
create or run the unit as root.
|
||||||
|
|
||||||
Build or deploy the Hub under `/srv/curriculum-project-hub`, then run:
|
Build or deploy the Hub under `/srv/curriculum-project-hub`, then run:
|
||||||
@@ -18,13 +18,13 @@ On a clean host the first invocation creates
|
|||||||
production key and exits with status 78. Fill every required blank, set
|
production key and exits with status 78. Fill every required blank, set
|
||||||
`CPH_BIN` to an executable compatible `cph`, keep the file mode at `0600`, and
|
`CPH_BIN` to an executable compatible `cph`, keep the file mode at `0600`, and
|
||||||
run the same command again. No systemd unit is installed until configuration,
|
run the same command again. No systemd unit is installed until configuration,
|
||||||
paths, Node, bubblewrap, `cph --version`, PostgreSQL connectivity and directory
|
paths, Node, bubblewrap, `socat`, `cph --version`, PostgreSQL connectivity and directory
|
||||||
ownership all pass preflight.
|
ownership all pass preflight.
|
||||||
|
|
||||||
An existing service account is accepted only when its primary group, home and
|
An existing service account is accepted only when its primary group, home and
|
||||||
non-login shell match the requested configuration and it has no supplementary
|
non-login shell match the requested configuration and it has no supplementary
|
||||||
groups. After provisioning, the installer executes path-access, built Hub,
|
groups. After provisioning, the installer executes path-access, built Hub,
|
||||||
Prisma, an authenticated database query, `cph` and bubblewrap namespace probes
|
Prisma, an authenticated database query, `cph`, `socat` and bubblewrap namespace probes
|
||||||
as that account with `no_new_privs` set. Inaccessible parents, UID-specific
|
as that account with `no_new_privs` set. Inaccessible parents, UID-specific
|
||||||
database/network policy, or a host that forbids unprivileged user namespaces
|
database/network policy, or a host that forbids unprivileged user namespaces
|
||||||
therefore fail before unit installation.
|
therefore fail before unit installation.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Wants=network-online.target
|
|||||||
# after installation. Deployment preflight validates the executable itself;
|
# after installation. Deployment preflight validates the executable itself;
|
||||||
# this start-time assertion keeps later package/host changes fail-closed.
|
# this start-time assertion keeps later package/host changes fail-closed.
|
||||||
AssertPathExists=__BWRAP_BIN__
|
AssertPathExists=__BWRAP_BIN__
|
||||||
|
AssertPathExists=__SOCAT_BIN__
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub
|
# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub
|
||||||
# PLATFORM_DEPLOY_HEALTH_URL optional, defaults to http://127.0.0.1:8788/api/healthz
|
# PLATFORM_DEPLOY_HEALTH_URL optional, defaults to http://127.0.0.1:8788/api/healthz
|
||||||
# The target host must have Node.js 20+, npm, rsync, PostgreSQL client tools
|
# The target host must have Node.js 20+, npm, rsync, PostgreSQL client tools
|
||||||
# (`pg_isready`), systemd, bubblewrap (`bwrap`, for the ADR-0018 agent sandbox),
|
# (`pg_isready`), systemd, bubblewrap (`bwrap`) and `socat` (for the ADR-0018 agent sandbox),
|
||||||
# and a compatible `cph` binary named by platform.env. The service installer
|
# and a compatible `cph` binary named by platform.env. The service installer
|
||||||
# provisions the dedicated non-root identity and persistent directories.
|
# provisions the dedicated non-root identity and persistent directories.
|
||||||
# Use a dedicated `deploy` user that has passwordless sudo for systemctl and
|
# Use a dedicated `deploy` user that has passwordless sudo for systemctl and
|
||||||
|
|||||||
@@ -24,13 +24,14 @@ ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}"
|
|||||||
SYSTEMD_UNIT_DIR="${SYSTEMD_UNIT_DIR:-/etc/systemd/system}"
|
SYSTEMD_UNIT_DIR="${SYSTEMD_UNIT_DIR:-/etc/systemd/system}"
|
||||||
NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
|
NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
|
||||||
BWRAP_BIN="${BWRAP_BIN:-$(command -v bwrap || true)}"
|
BWRAP_BIN="${BWRAP_BIN:-$(command -v bwrap || true)}"
|
||||||
|
SOCAT_BIN="${SOCAT_BIN:-$(command -v socat || true)}"
|
||||||
PG_ISREADY_BIN="${PG_ISREADY_BIN:-$(command -v pg_isready || true)}"
|
PG_ISREADY_BIN="${PG_ISREADY_BIN:-$(command -v pg_isready || true)}"
|
||||||
RUNUSER_BIN="${RUNUSER_BIN:-$(command -v runuser || true)}"
|
RUNUSER_BIN="${RUNUSER_BIN:-$(command -v runuser || true)}"
|
||||||
SETPRIV_BIN="${SETPRIV_BIN:-$(command -v setpriv || true)}"
|
SETPRIV_BIN="${SETPRIV_BIN:-$(command -v setpriv || true)}"
|
||||||
CPH_BIN_DEFAULT="${CPH_BIN_DEFAULT:-/usr/local/bin/cph}"
|
CPH_BIN_DEFAULT="${CPH_BIN_DEFAULT:-/usr/local/bin/cph}"
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PREFLIGHT_JS="$HUB_DIR/dist/deployment/preflight-cli.js"
|
PREFLIGHT_JS="$HUB_DIR/dist/deployment/preflight-cli.js"
|
||||||
RUNTIME_PATH="$(dirname "$NODE_BIN"):$(dirname "$BWRAP_BIN"):$(dirname "$SETPRIV_BIN"):/usr/local/bin:/usr/bin:/bin"
|
RUNTIME_PATH="$(dirname "$NODE_BIN"):$(dirname "$BWRAP_BIN"):$(dirname "$SOCAT_BIN"):$(dirname "$SETPRIV_BIN"):/usr/local/bin:/usr/bin:/bin"
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
echo "[install] error: $*" >&2
|
echo "[install] error: $*" >&2
|
||||||
@@ -68,6 +69,7 @@ done
|
|||||||
for pair in \
|
for pair in \
|
||||||
"NODE_BIN:$NODE_BIN" \
|
"NODE_BIN:$NODE_BIN" \
|
||||||
"BWRAP_BIN:$BWRAP_BIN" \
|
"BWRAP_BIN:$BWRAP_BIN" \
|
||||||
|
"SOCAT_BIN:$SOCAT_BIN" \
|
||||||
"PG_ISREADY_BIN:$PG_ISREADY_BIN"; do
|
"PG_ISREADY_BIN:$PG_ISREADY_BIN"; do
|
||||||
label="${pair%%:*}"
|
label="${pair%%:*}"
|
||||||
value="${pair#*:}"
|
value="${pair#*:}"
|
||||||
@@ -98,6 +100,7 @@ for pair in \
|
|||||||
"SYSTEMD_UNIT_DIR:$SYSTEMD_UNIT_DIR" \
|
"SYSTEMD_UNIT_DIR:$SYSTEMD_UNIT_DIR" \
|
||||||
"NODE_BIN:$NODE_BIN" \
|
"NODE_BIN:$NODE_BIN" \
|
||||||
"BWRAP_BIN:$BWRAP_BIN" \
|
"BWRAP_BIN:$BWRAP_BIN" \
|
||||||
|
"SOCAT_BIN:$SOCAT_BIN" \
|
||||||
"RUNUSER_BIN:$RUNUSER_BIN" \
|
"RUNUSER_BIN:$RUNUSER_BIN" \
|
||||||
"SETPRIV_BIN:$SETPRIV_BIN" \
|
"SETPRIV_BIN:$SETPRIV_BIN" \
|
||||||
"RUNTIME_PATH:$RUNTIME_PATH"; do
|
"RUNTIME_PATH:$RUNTIME_PATH"; do
|
||||||
@@ -152,6 +155,7 @@ PREFLIGHT_ARGS=(
|
|||||||
--cache-dir "$CACHE_DIR"
|
--cache-dir "$CACHE_DIR"
|
||||||
--workspace-root "$WORKSPACE_ROOT"
|
--workspace-root "$WORKSPACE_ROOT"
|
||||||
--bwrap-bin "$BWRAP_BIN"
|
--bwrap-bin "$BWRAP_BIN"
|
||||||
|
--socat-bin "$SOCAT_BIN"
|
||||||
--pg-isready-bin "$PG_ISREADY_BIN"
|
--pg-isready-bin "$PG_ISREADY_BIN"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -247,6 +251,7 @@ sed \
|
|||||||
-e "s|__ENV_FILE__|$ENV_FILE|g" \
|
-e "s|__ENV_FILE__|$ENV_FILE|g" \
|
||||||
-e "s|__NODE_BIN__|$NODE_BIN|g" \
|
-e "s|__NODE_BIN__|$NODE_BIN|g" \
|
||||||
-e "s|__BWRAP_BIN__|$BWRAP_BIN|g" \
|
-e "s|__BWRAP_BIN__|$BWRAP_BIN|g" \
|
||||||
|
-e "s|__SOCAT_BIN__|$SOCAT_BIN|g" \
|
||||||
-e "s|__RUNTIME_PATH__|$RUNTIME_PATH|g" \
|
-e "s|__RUNTIME_PATH__|$RUNTIME_PATH|g" \
|
||||||
"$SCRIPT_DIR/cph-hub.service" >"$TMP_UNIT"
|
"$SCRIPT_DIR/cph-hub.service" >"$TMP_UNIT"
|
||||||
install -o root -g root -m 0644 "$TMP_UNIT" "$UNIT"
|
install -o root -g root -m 0644 "$TMP_UNIT" "$UNIT"
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
const MAX_LOGGED_CHARS = 8_000;
|
||||||
|
const MAX_LOGGED_LINES = 20;
|
||||||
|
const MAX_LINE_CHARS = 2_000;
|
||||||
|
const MAX_PENDING_LINE_CHARS = 16_000;
|
||||||
|
|
||||||
|
interface DiagnosticLogger {
|
||||||
|
warn(bindings: Readonly<Record<string, unknown>>, message: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentSdkStderrSink {
|
||||||
|
readonly write: (data: string) => void;
|
||||||
|
readonly flush: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the SDK subprocess stderr stream into bounded, run-correlated logs.
|
||||||
|
* Complete lines are buffered so a credential split across chunks is still
|
||||||
|
* removed before anything reaches the logger.
|
||||||
|
*/
|
||||||
|
export function createAgentSdkStderrSink(input: {
|
||||||
|
readonly logger: DiagnosticLogger;
|
||||||
|
readonly runId: string;
|
||||||
|
readonly projectId: string;
|
||||||
|
readonly sensitiveValues: readonly (string | undefined)[];
|
||||||
|
}): AgentSdkStderrSink {
|
||||||
|
const secrets = [...new Set(input.sensitiveValues.filter((value): value is string => value !== undefined && value !== ""))]
|
||||||
|
.sort((left, right) => right.length - left.length);
|
||||||
|
let pending = "";
|
||||||
|
let droppingOversizedLine = false;
|
||||||
|
let remainingChars = MAX_LOGGED_CHARS;
|
||||||
|
let remainingLines = MAX_LOGGED_LINES;
|
||||||
|
let limitReported = false;
|
||||||
|
|
||||||
|
const reportLimit = (): void => {
|
||||||
|
if (limitReported) return;
|
||||||
|
limitReported = true;
|
||||||
|
input.logger.warn(
|
||||||
|
{ runId: input.runId, projectId: input.projectId },
|
||||||
|
"agent SDK stderr log limit reached; further diagnostics omitted",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const emit = (line: string): void => {
|
||||||
|
if (line.trim() === "") return;
|
||||||
|
if (remainingChars <= 0 || remainingLines <= 0) {
|
||||||
|
reportLimit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const redacted = redactDiagnostic(line, secrets);
|
||||||
|
const allowed = Math.min(MAX_LINE_CHARS, remainingChars);
|
||||||
|
const diagnostic = boundedDiagnostic(redacted, allowed);
|
||||||
|
remainingChars -= diagnostic.length;
|
||||||
|
remainingLines--;
|
||||||
|
input.logger.warn(
|
||||||
|
{ runId: input.runId, projectId: input.projectId, sdkStderr: diagnostic },
|
||||||
|
"agent SDK stderr",
|
||||||
|
);
|
||||||
|
if (redacted.length > allowed || remainingChars <= 0 || remainingLines <= 0) reportLimit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const append = (fragment: string): void => {
|
||||||
|
if (fragment === "" || droppingOversizedLine) return;
|
||||||
|
if (pending.length + fragment.length > MAX_PENDING_LINE_CHARS) {
|
||||||
|
pending = "";
|
||||||
|
droppingOversizedLine = true;
|
||||||
|
emit("[oversized stderr line omitted]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending += fragment;
|
||||||
|
};
|
||||||
|
|
||||||
|
const write = (data: string): void => {
|
||||||
|
let lineStart = 0;
|
||||||
|
for (let index = 0; index < data.length; index++) {
|
||||||
|
const character = data[index];
|
||||||
|
if (character !== "\n" && character !== "\r") continue;
|
||||||
|
append(data.slice(lineStart, index));
|
||||||
|
if (!droppingOversizedLine) emit(pending);
|
||||||
|
pending = "";
|
||||||
|
droppingOversizedLine = false;
|
||||||
|
if (character === "\r" && data[index + 1] === "\n") index++;
|
||||||
|
lineStart = index + 1;
|
||||||
|
}
|
||||||
|
append(data.slice(lineStart));
|
||||||
|
};
|
||||||
|
|
||||||
|
const flush = (): void => {
|
||||||
|
if (droppingOversizedLine) {
|
||||||
|
droppingOversizedLine = false;
|
||||||
|
} else {
|
||||||
|
emit(pending);
|
||||||
|
}
|
||||||
|
pending = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return { write, flush };
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedDiagnostic(value: string, limit: number): string {
|
||||||
|
if (value.length <= limit) return value;
|
||||||
|
const marker = "…[truncated]";
|
||||||
|
if (limit <= marker.length) return marker.slice(0, limit);
|
||||||
|
return `${value.slice(0, limit - marker.length)}${marker}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactDiagnostic(value: string, secrets: readonly string[]): string {
|
||||||
|
let redacted = value;
|
||||||
|
for (const secret of secrets) redacted = redacted.replaceAll(secret, "[REDACTED]");
|
||||||
|
return redacted
|
||||||
|
.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "")
|
||||||
|
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, " ")
|
||||||
|
.replace(/\b(postgres(?:ql)?):\/\/([^:\s/@]+):([^@\s]+)@/gi, "$1://$2:[REDACTED]@")
|
||||||
|
.replace(/\b((?:api[_-]?key|token|secret|password)\s*[:=]\s*)\S+/gi, "$1[REDACTED]")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
@@ -19,11 +19,12 @@
|
|||||||
*
|
*
|
||||||
* ADR-0018: the agent execution surface is bounded by the run's workspace.
|
* ADR-0018: the agent execution surface is bounded by the run's workspace.
|
||||||
* The SDK sandbox (bubblewrap on Linux, seatbelt on macOS) confines the Claude
|
* The SDK sandbox (bubblewrap on Linux, seatbelt on macOS) confines the Claude
|
||||||
* Code process and its subprocesses at the OS level — writes are confined to
|
* Code process and its subprocesses at the OS level — writes, including the
|
||||||
* the workspace, host reads are denied by default and re-opened only for the
|
* SDK's Unix-socket scratch, are confined to the workspace. Host reads are
|
||||||
* workspace plus named system runtimes, and `failIfUnavailable` hard-fails if
|
* denied by default and re-opened only for the workspace plus named system
|
||||||
* the sandbox can't start. The subprocess gets a minimal environment and SDK
|
* runtimes, and `failIfUnavailable` hard-fails if the sandbox can't start. The
|
||||||
* credential protection removes provider secrets from Bash. This upholds `AgentFileOp.Authorized`
|
* subprocess gets a minimal environment and SDK credential protection removes
|
||||||
|
* provider secrets from Bash. This upholds `AgentFileOp.Authorized`
|
||||||
* (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the
|
* (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the
|
||||||
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
|
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
|
||||||
* is the mechanism, the contract pins the invariant.
|
* is the mechanism, the contract pins the invariant.
|
||||||
@@ -70,6 +71,8 @@ export interface RunRequest {
|
|||||||
readonly sessionId: string;
|
readonly sessionId: string;
|
||||||
readonly prisma: PrismaClient;
|
readonly prisma: PrismaClient;
|
||||||
readonly onStream?: StreamCallback;
|
readonly onStream?: StreamCallback;
|
||||||
|
/** Optional diagnostic sink for Claude SDK subprocess stderr. */
|
||||||
|
readonly onSdkStderr?: ((data: string) => void) | undefined;
|
||||||
/**
|
/**
|
||||||
* Abort controller for the run. Aborting the signal interrupts the SDK query
|
* Abort controller for the run. Aborting the signal interrupts the SDK query
|
||||||
* (it stops the agent loop and cleans up); the run resolves with status
|
* (it stops the agent loop and cleans up); the run resolves with status
|
||||||
@@ -153,6 +156,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
|||||||
if (req.resumeSessionId !== undefined) options.resume = req.resumeSessionId;
|
if (req.resumeSessionId !== undefined) options.resume = req.resumeSessionId;
|
||||||
if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers;
|
if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers;
|
||||||
if (req.abortController !== undefined) options.abortController = req.abortController;
|
if (req.abortController !== undefined) options.abortController = req.abortController;
|
||||||
|
if (req.onSdkStderr !== undefined) options.stderr = req.onSdkStderr;
|
||||||
|
|
||||||
const conversation = query({
|
const conversation = query({
|
||||||
prompt: req.prompt,
|
prompt: req.prompt,
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ const SAFE_HOST_ENV_KEYS = [
|
|||||||
"CPH_BIN",
|
"CPH_BIN",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const PROVIDER_SECRET_ENV_KEYS = ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] as const;
|
const SANDBOX_HIDDEN_ENV_KEYS = [
|
||||||
|
"ANTHROPIC_AUTH_TOKEN",
|
||||||
|
"ANTHROPIC_API_KEY",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export interface AgentSecurityInput {
|
export interface AgentSecurityInput {
|
||||||
readonly workspaceRoot: string;
|
readonly workspaceRoot: string;
|
||||||
@@ -37,6 +40,7 @@ export interface AgentSandboxPolicy {
|
|||||||
readonly allowUnsandboxedCommands: false;
|
readonly allowUnsandboxedCommands: false;
|
||||||
readonly filesystem: {
|
readonly filesystem: {
|
||||||
readonly allowWrite: string[];
|
readonly allowWrite: string[];
|
||||||
|
readonly denyWrite: string[];
|
||||||
readonly denyRead: string[];
|
readonly denyRead: string[];
|
||||||
readonly allowRead: string[];
|
readonly allowRead: string[];
|
||||||
};
|
};
|
||||||
@@ -61,12 +65,16 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
|||||||
const hostEnv = input.hostEnv ?? process.env;
|
const hostEnv = input.hostEnv ?? process.env;
|
||||||
const { workspaceRoot, workspaceDir } = await resolveProjectWorkspace(input.workspaceRoot, input.workspaceDir);
|
const { workspaceRoot, workspaceDir } = await resolveProjectWorkspace(input.workspaceRoot, input.workspaceDir);
|
||||||
|
|
||||||
const runtimeRoot = await ensureDirectoryTree(workspaceDir, [".cph", "agent-runtime"]);
|
const cphRoot = await ensureDirectoryTree(workspaceDir, [".cph"]);
|
||||||
|
const runtimeRoot = await ensureDirectoryTree(cphRoot, ["agent-runtime"]);
|
||||||
const agentHome = await ensureDirectoryTree(runtimeRoot, ["home"]);
|
const agentHome = await ensureDirectoryTree(runtimeRoot, ["home"]);
|
||||||
const agentCache = await ensureDirectoryTree(runtimeRoot, ["cache"]);
|
const agentCache = await ensureDirectoryTree(runtimeRoot, ["cache"]);
|
||||||
const agentConfig = await ensureDirectoryTree(runtimeRoot, ["config"]);
|
const agentConfig = await ensureDirectoryTree(runtimeRoot, ["config"]);
|
||||||
const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]);
|
const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]);
|
||||||
const agentTmp = await ensureDirectoryTree(runtimeRoot, ["tmp"]);
|
// Keep the SDK temp root both short enough for Linux AF_UNIX sockets and
|
||||||
|
// physically inside the project boundary pinned by AgentFileOp.Authorized.
|
||||||
|
const agentTmp = await ensureDirectoryTree(cphRoot, ["t"]);
|
||||||
|
const claudeCodeTmp = join(".cph", "t");
|
||||||
|
|
||||||
const path = hostEnv["PATH"]?.trim();
|
const path = hostEnv["PATH"]?.trim();
|
||||||
if (path === undefined || path === "") {
|
if (path === undefined || path === "") {
|
||||||
@@ -82,11 +90,14 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
|||||||
env.XDG_CACHE_HOME = agentCache;
|
env.XDG_CACHE_HOME = agentCache;
|
||||||
env.XDG_CONFIG_HOME = agentConfig;
|
env.XDG_CONFIG_HOME = agentConfig;
|
||||||
env.XDG_STATE_HOME = agentState;
|
env.XDG_STATE_HOME = agentState;
|
||||||
|
// General subprocess temp paths stay absolute so tools continue to work
|
||||||
|
// after `cd`. Only the SDK's socket prefix is relative: Claude resolves it
|
||||||
|
// from the canonical workspace cwd before Bash commands can change cwd.
|
||||||
env.TMPDIR = agentTmp;
|
env.TMPDIR = agentTmp;
|
||||||
env.TMP = agentTmp;
|
env.TMP = agentTmp;
|
||||||
env.TEMP = agentTmp;
|
env.TEMP = agentTmp;
|
||||||
env.CLAUDE_CONFIG_DIR = agentConfig;
|
env.CLAUDE_CONFIG_DIR = agentConfig;
|
||||||
env.CLAUDE_CODE_TMPDIR = agentTmp;
|
env.CLAUDE_CODE_TMPDIR = claudeCodeTmp;
|
||||||
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
||||||
env.DISABLE_TELEMETRY = "1";
|
env.DISABLE_TELEMETRY = "1";
|
||||||
env.DO_NOT_TRACK = "1";
|
env.DO_NOT_TRACK = "1";
|
||||||
@@ -112,6 +123,10 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
|||||||
allowUnsandboxedCommands: false,
|
allowUnsandboxedCommands: false,
|
||||||
filesystem: {
|
filesystem: {
|
||||||
allowWrite: [workspaceDir],
|
allowWrite: [workspaceDir],
|
||||||
|
// Reject every write path by default, then re-open only the canonical
|
||||||
|
// workspace. This prevents bubblewrap's ordinary temp exceptions from
|
||||||
|
// turning an unauthorized path into a successful ephemeral write.
|
||||||
|
denyWrite: ["/"],
|
||||||
// Deny host reads by default, then re-open exactly this run's
|
// Deny host reads by default, then re-open exactly this run's
|
||||||
// workspace plus the named system runtime needed to execute tools.
|
// workspace plus the named system runtime needed to execute tools.
|
||||||
// SDK allowRead takes precedence over matching denyRead paths.
|
// SDK allowRead takes precedence over matching denyRead paths.
|
||||||
@@ -120,7 +135,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
|||||||
},
|
},
|
||||||
credentials: {
|
credentials: {
|
||||||
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
|
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
|
||||||
envVars: PROVIDER_SECRET_ENV_KEYS.map((name) => ({ name, mode: "deny" as const })),
|
envVars: SANDBOX_HIDDEN_ENV_KEYS.map((name) => ({ name, mode: "deny" as const })),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -144,8 +159,10 @@ function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>)
|
|||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
"/bin",
|
"/bin",
|
||||||
|
"/sbin",
|
||||||
"/usr/bin",
|
"/usr/bin",
|
||||||
"/usr/local/bin",
|
"/usr/local/bin",
|
||||||
|
"/usr/sbin",
|
||||||
"/lib",
|
"/lib",
|
||||||
"/lib64",
|
"/lib64",
|
||||||
"/usr/lib",
|
"/usr/lib",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface Options {
|
|||||||
readonly cacheDir: string;
|
readonly cacheDir: string;
|
||||||
readonly workspaceRoot: string;
|
readonly workspaceRoot: string;
|
||||||
readonly bwrapBin: string;
|
readonly bwrapBin: string;
|
||||||
|
readonly socatBin: string;
|
||||||
readonly pgIsReadyBin: string;
|
readonly pgIsReadyBin: string;
|
||||||
readonly serviceUid?: number;
|
readonly serviceUid?: number;
|
||||||
readonly serviceGid?: number;
|
readonly serviceGid?: number;
|
||||||
@@ -50,6 +51,7 @@ async function main(): Promise<void> {
|
|||||||
await validateExecutable(options.runuserBin, "runuser");
|
await validateExecutable(options.runuserBin, "runuser");
|
||||||
await validateExecutable(options.setprivBin, "setpriv");
|
await validateExecutable(options.setprivBin, "setpriv");
|
||||||
await validateExecutable(options.bwrapBin, "bubblewrap");
|
await validateExecutable(options.bwrapBin, "bubblewrap");
|
||||||
|
await validateExecutable(options.socatBin, "socat");
|
||||||
await validateExecutable(result.cphBin, "cph");
|
await validateExecutable(result.cphBin, "cph");
|
||||||
await validateExecutable(options.pgIsReadyBin, "pg_isready");
|
await validateExecutable(options.pgIsReadyBin, "pg_isready");
|
||||||
await validateCph(result.cphBin);
|
await validateCph(result.cphBin);
|
||||||
@@ -257,7 +259,7 @@ async function validateServiceRuntime(
|
|||||||
HOME: configuredInput.serviceHome,
|
HOME: configuredInput.serviceHome,
|
||||||
XDG_STATE_HOME: configuredInput.stateDir,
|
XDG_STATE_HOME: configuredInput.stateDir,
|
||||||
XDG_CACHE_HOME: configuredInput.cacheDir,
|
XDG_CACHE_HOME: configuredInput.cacheDir,
|
||||||
PATH: [dirname(options.nodeBin), dirname(options.bwrapBin), dirname(result.cphBin), "/usr/bin", "/bin"].join(":"),
|
PATH: [dirname(options.nodeBin), dirname(options.bwrapBin), dirname(options.socatBin), dirname(result.cphBin), "/usr/bin", "/bin"].join(":"),
|
||||||
};
|
};
|
||||||
const pathAccessRequirements = (input: DeploymentPreflightInput) => [
|
const pathAccessRequirements = (input: DeploymentPreflightInput) => [
|
||||||
[input.hubDir, constants.R_OK | constants.X_OK],
|
[input.hubDir, constants.R_OK | constants.X_OK],
|
||||||
@@ -278,6 +280,7 @@ async function validateServiceRuntime(
|
|||||||
...pathAccessRequirements(canonicalInput),
|
...pathAccessRequirements(canonicalInput),
|
||||||
[options.nodeBin, constants.X_OK],
|
[options.nodeBin, constants.X_OK],
|
||||||
[options.bwrapBin, constants.X_OK],
|
[options.bwrapBin, constants.X_OK],
|
||||||
|
[options.socatBin, constants.X_OK],
|
||||||
[options.setprivBin, constants.X_OK],
|
[options.setprivBin, constants.X_OK],
|
||||||
[result.cphBin, constants.X_OK],
|
[result.cphBin, constants.X_OK],
|
||||||
] as const;
|
] as const;
|
||||||
@@ -315,6 +318,7 @@ async function validateServiceRuntime(
|
|||||||
"Hub dependency and authenticated database probe",
|
"Hub dependency and authenticated database probe",
|
||||||
);
|
);
|
||||||
await runAsService(options, serviceEnv, result.cphBin, ["--version"], "cph runtime probe");
|
await runAsService(options, serviceEnv, result.cphBin, ["--version"], "cph runtime probe");
|
||||||
|
await runAsService(options, serviceEnv, options.socatBin, ["-V"], "socat runtime probe");
|
||||||
await runAsService(
|
await runAsService(
|
||||||
options,
|
options,
|
||||||
serviceEnv,
|
serviceEnv,
|
||||||
@@ -390,6 +394,7 @@ function parseArgs(argv: readonly string[]): Options {
|
|||||||
"--cache-dir",
|
"--cache-dir",
|
||||||
"--workspace-root",
|
"--workspace-root",
|
||||||
"--bwrap-bin",
|
"--bwrap-bin",
|
||||||
|
"--socat-bin",
|
||||||
"--pg-isready-bin",
|
"--pg-isready-bin",
|
||||||
"--service-uid",
|
"--service-uid",
|
||||||
"--service-gid",
|
"--service-gid",
|
||||||
@@ -417,6 +422,7 @@ function parseArgs(argv: readonly string[]): Options {
|
|||||||
cacheDir: required(values, "--cache-dir"),
|
cacheDir: required(values, "--cache-dir"),
|
||||||
workspaceRoot: required(values, "--workspace-root"),
|
workspaceRoot: required(values, "--workspace-root"),
|
||||||
bwrapBin: required(values, "--bwrap-bin"),
|
bwrapBin: required(values, "--bwrap-bin"),
|
||||||
|
socatBin: required(values, "--socat-bin"),
|
||||||
pgIsReadyBin: required(values, "--pg-isready-bin"),
|
pgIsReadyBin: required(values, "--pg-isready-bin"),
|
||||||
...(serviceUid === undefined ? {} : { serviceUid, serviceGid: serviceGid! }),
|
...(serviceUid === undefined ? {} : { serviceUid, serviceGid: serviceGid! }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
|
|||||||
import { createPermissionAuthorizer, type AuthorizationDecision, type PermissionAuthorizer } from "../permission.js";
|
import { createPermissionAuthorizer, type AuthorizationDecision, type PermissionAuthorizer } from "../permission.js";
|
||||||
import { writeAudit } from "../audit.js";
|
import { writeAudit } from "../audit.js";
|
||||||
import { formatRunCostLine } from "../agent/cost.js";
|
import { formatRunCostLine } from "../agent/cost.js";
|
||||||
|
import { createAgentSdkStderrSink } from "../agent/diagnostics.js";
|
||||||
import { StreamingAgentCard } from "./card/streaming-card.js";
|
import { StreamingAgentCard } from "./card/streaming-card.js";
|
||||||
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
|
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
|
||||||
import { readFeishuContext } from "./read.js";
|
import { readFeishuContext } from "./read.js";
|
||||||
@@ -331,6 +332,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
// stop the SDK query. Cleared in the finally below.
|
// stop the SDK query. Cleared in the finally below.
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
activeRuns.set(run.id, abortController);
|
activeRuns.set(run.id, abortController);
|
||||||
|
const sdkStderr = createAgentSdkStderrSink({
|
||||||
|
logger: deps.logger,
|
||||||
|
runId: run.id,
|
||||||
|
projectId,
|
||||||
|
sensitiveValues: Object.values(provider.sdkEnv),
|
||||||
|
});
|
||||||
runAgent({
|
runAgent({
|
||||||
prompt: promptForAgent,
|
prompt: promptForAgent,
|
||||||
model,
|
model,
|
||||||
@@ -345,6 +352,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
prisma: deps.prisma,
|
prisma: deps.prisma,
|
||||||
abortController,
|
abortController,
|
||||||
|
onSdkStderr: sdkStderr.write,
|
||||||
onStream: (event) => {
|
onStream: (event) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "text-delta":
|
case "text-delta":
|
||||||
@@ -433,6 +441,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.failed", metadata: { error: String(e) } });
|
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.failed", metadata: { error: String(e) } });
|
||||||
})
|
})
|
||||||
.finally(async () => {
|
.finally(async () => {
|
||||||
|
sdkStderr.flush();
|
||||||
try {
|
try {
|
||||||
await releaseLock(deps.prisma, run.id);
|
await releaseLock(deps.prisma, run.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { constants } from "node:fs";
|
import { constants } from "node:fs";
|
||||||
import { access, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
import { access, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
@@ -25,25 +26,36 @@ describe("real Claude SDK sandbox boundary", () => {
|
|||||||
|
|
||||||
itOnLinux("denies sibling workspace, service files, credentials, and unsandboxed Bash while cph still works", async () => {
|
itOnLinux("denies sibling workspace, service files, credentials, and unsandboxed Bash while cph still works", async () => {
|
||||||
await access("/usr/bin/bwrap", constants.X_OK);
|
await access("/usr/bin/bwrap", constants.X_OK);
|
||||||
|
await access("/usr/bin/socat", constants.X_OK);
|
||||||
const noNewPrivilegesStatus = await readFile("/proc/self/status", "utf8");
|
const noNewPrivilegesStatus = await readFile("/proc/self/status", "utf8");
|
||||||
expect(noNewPrivilegesStatus).toMatch(/^NoNewPrivs:\s+1$/m);
|
expect(noNewPrivilegesStatus).toMatch(/^NoNewPrivs:\s+1$/m);
|
||||||
|
expect(noNewPrivilegesStatus).toMatch(/^CapEff:\s+0+$/m);
|
||||||
|
expect(process.getuid?.()).toBeGreaterThan(0);
|
||||||
|
|
||||||
const { stdout: cphPathOutput } = await execFileAsync("/usr/bin/which", ["cph"]);
|
const { stdout: cphPathOutput } = await execFileAsync("/usr/bin/which", ["cph"]);
|
||||||
const cphBin = cphPathOutput.trim();
|
const cphBin = cphPathOutput.trim();
|
||||||
await access(cphBin, constants.X_OK);
|
await access(cphBin, constants.X_OK);
|
||||||
|
|
||||||
// Keep CLAUDE_CODE_TMPDIR short enough for the SDK's AF_UNIX socket paths;
|
// CI provisions this runner-owned root below /var/lib before dropping into
|
||||||
// otherwise the SDK deliberately falls back to shared /tmp.
|
// no_new_privs. Keeping the fixture out of /tmp proves that an SDK-wide
|
||||||
const root = await mkdtemp("/tmp/cph-");
|
// temp exception cannot make a cross-project escape look contained.
|
||||||
roots.push(root);
|
const configuredTestRoot = process.env["CPH_SANDBOX_TEST_ROOT"]?.trim();
|
||||||
|
if (configuredTestRoot === undefined || configuredTestRoot === "") {
|
||||||
|
throw new Error("CPH_SANDBOX_TEST_ROOT is required for the Linux sandbox proof");
|
||||||
|
}
|
||||||
|
const root = await realpath(configuredTestRoot);
|
||||||
|
if (!root.startsWith("/var/lib/")) {
|
||||||
|
throw new Error(`CPH_SANDBOX_TEST_ROOT must be below /var/lib: ${root}`);
|
||||||
|
}
|
||||||
|
const nonce = randomUUID().replaceAll("-", "");
|
||||||
const workspaceRoot = join(root, "workspaces");
|
const workspaceRoot = join(root, "workspaces");
|
||||||
const workspace = join(workspaceRoot, "org-a", "project-a");
|
const workspace = join(workspaceRoot, "org-a", `project_${nonce}`);
|
||||||
const sibling = join(workspaceRoot, "org-b", "project-b");
|
const sibling = join(workspaceRoot, "org-b", `project_${randomUUID().replaceAll("-", "")}`);
|
||||||
const serviceSecret = join(root, "service-secrets", "platform.env");
|
const serviceSecret = join(root, `service-secret-${nonce}`);
|
||||||
|
roots.push(workspace, sibling, serviceSecret);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
mkdir(workspace, { recursive: true }),
|
mkdir(workspace, { recursive: true }),
|
||||||
mkdir(sibling, { recursive: true }),
|
mkdir(sibling, { recursive: true }),
|
||||||
mkdir(join(root, "service-secrets"), { recursive: true }),
|
|
||||||
]);
|
]);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
writeFile(join(workspace, "allowed.txt"), "allowed\n"),
|
writeFile(join(workspace, "allowed.txt"), "allowed\n"),
|
||||||
@@ -57,8 +69,14 @@ describe("real Claude SDK sandbox boundary", () => {
|
|||||||
const canonicalSibling = await realpath(sibling);
|
const canonicalSibling = await realpath(sibling);
|
||||||
const canonicalServiceSecret = await realpath(serviceSecret);
|
const canonicalServiceSecret = await realpath(serviceSecret);
|
||||||
const resultPath = join(canonicalWorkspace, "sandbox-result.txt");
|
const resultPath = join(canonicalWorkspace, "sandbox-result.txt");
|
||||||
|
const cphVersionPath = join(canonicalWorkspace, "cph-version.txt");
|
||||||
|
const effectiveTempProbePath = join(canonicalWorkspace, ".cph", "t", "effective-temp.txt");
|
||||||
|
const denialLogPath = join(canonicalWorkspace, ".cph", "t", "denials.log");
|
||||||
const siblingEscapePath = join(canonicalSibling, "escape.txt");
|
const siblingEscapePath = join(canonicalSibling, "escape.txt");
|
||||||
const unsandboxedEscapePath = join(await realpath(root), "unsandboxed-escape.txt");
|
const unsandboxedEscapePath = join(root, `unsandboxed-escape-${nonce}`);
|
||||||
|
const hostTmpEscapePath = `/tmp/cph-host-temp-${nonce}`;
|
||||||
|
const hostVarTmpEscapePath = `/var/tmp/cph-host-temp-${nonce}`;
|
||||||
|
roots.push(unsandboxedEscapePath, hostTmpEscapePath, hostVarTmpEscapePath);
|
||||||
|
|
||||||
setHostEnv({
|
setHostEnv({
|
||||||
CPH_BIN: cphBin,
|
CPH_BIN: cphBin,
|
||||||
@@ -70,21 +88,32 @@ describe("real Claude SDK sandbox boundary", () => {
|
|||||||
|
|
||||||
const bashCommand = [
|
const bashCommand = [
|
||||||
"set -eu",
|
"set -eu",
|
||||||
|
`if printf 'unsafe\\n' > ${shellQuote(unsandboxedEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 31; fi`,
|
||||||
`test "$(cat ${shellQuote(join(canonicalWorkspace, "allowed.txt"))})" = "allowed"`,
|
`test "$(cat ${shellQuote(join(canonicalWorkspace, "allowed.txt"))})" = "allowed"`,
|
||||||
`if cat ${shellQuote(join(canonicalSibling, "secret.txt"))} >/dev/null 2>&1; then exit 21; fi`,
|
`if sibling_value=$(cat ${shellQuote(join(canonicalSibling, "secret.txt"))} 2>> ${shellQuote(denialLogPath)}); then exit 21; fi`,
|
||||||
`if printf 'escape\\n' > ${shellQuote(siblingEscapePath)} 2>/dev/null; then exit 22; fi`,
|
`if printf 'escape\\n' > ${shellQuote(siblingEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 32; fi`,
|
||||||
`if cat ${shellQuote(canonicalServiceSecret)} >/dev/null 2>&1; then exit 23; fi`,
|
`if service_value=$(cat ${shellQuote(canonicalServiceSecret)} 2>> ${shellQuote(denialLogPath)}); then exit 23; fi`,
|
||||||
|
`mkdir ${shellQuote(join(canonicalWorkspace, "subdir"))}`,
|
||||||
|
`cd ${shellQuote(join(canonicalWorkspace, "subdir"))}`,
|
||||||
|
`test "\${TMPDIR-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`,
|
||||||
|
`test "\${TMP-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`,
|
||||||
|
`test "\${TEMP-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`,
|
||||||
|
'test "${CLAUDE_CODE_TMPDIR-unset}" = ".cph/t"',
|
||||||
|
`test "$(realpath "$TMPDIR")" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`,
|
||||||
|
`printf 'workspace-temp\\n' > "$TMPDIR/effective-temp.txt"`,
|
||||||
|
`if printf 'host-temp\\n' > ${shellQuote(hostTmpEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 33; fi`,
|
||||||
|
`if printf 'host-var-temp\\n' > ${shellQuote(hostVarTmpEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 34; fi`,
|
||||||
'test "${DATABASE_URL-unset}" = unset',
|
'test "${DATABASE_URL-unset}" = unset',
|
||||||
'test "${FEISHU_APP_SECRET-unset}" = unset',
|
'test "${FEISHU_APP_SECRET-unset}" = unset',
|
||||||
'test "${HUB_SESSION_SECRET-unset}" = unset',
|
'test "${HUB_SESSION_SECRET-unset}" = unset',
|
||||||
'test "${ANTHROPIC_AUTH_TOKEN-unset}" = unset',
|
'test "${ANTHROPIC_AUTH_TOKEN-unset}" = unset',
|
||||||
'test "${ANTHROPIC_API_KEY-unset}" = unset',
|
'test "${ANTHROPIC_API_KEY-unset}" = unset',
|
||||||
"cph --version | grep '^cph '",
|
`cph --version > ${shellQuote(cphVersionPath)}`,
|
||||||
`printf 'sandbox-ok\\n' > ${shellQuote(resultPath)}`,
|
`printf 'sandbox-ok\\n' > ${shellQuote(resultPath)}`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
const bypassCommand = `printf 'unsafe\\n' > ${shellQuote(unsandboxedEscapePath)}`;
|
const stub = await startAnthropicStub(bashCommand);
|
||||||
const stub = await startAnthropicStub(bypassCommand, bashCommand);
|
|
||||||
const streamEvents: StreamEvent[] = [];
|
const streamEvents: StreamEvent[] = [];
|
||||||
|
const sdkStderr: string[] = [];
|
||||||
try {
|
try {
|
||||||
const result = await runAgent({
|
const result = await runAgent({
|
||||||
prompt: "Run the supplied sandbox boundary probe.",
|
prompt: "Run the supplied sandbox boundary probe.",
|
||||||
@@ -110,20 +139,33 @@ describe("real Claude SDK sandbox boundary", () => {
|
|||||||
agentMessage: { create: async () => ({}) },
|
agentMessage: { create: async () => ({}) },
|
||||||
} as unknown as import("@prisma/client").PrismaClient,
|
} as unknown as import("@prisma/client").PrismaClient,
|
||||||
onStream: (event) => streamEvents.push(event),
|
onStream: (event) => streamEvents.push(event),
|
||||||
|
onSdkStderr: (data) => sdkStderr.push(data),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toMatchObject({ status: "completed" });
|
expect(
|
||||||
expect(stub.requestCount()).toBeGreaterThanOrEqual(3);
|
result.status,
|
||||||
|
[result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"),
|
||||||
|
).toBe("completed");
|
||||||
|
expect(stub.requestCount()).toBeGreaterThanOrEqual(2);
|
||||||
const toolResults = streamEvents.filter((event) => event.type === "tool-result");
|
const toolResults = streamEvents.filter((event) => event.type === "tool-result");
|
||||||
expect(toolResults).toHaveLength(2);
|
expect(toolResults).toHaveLength(1);
|
||||||
expect(toolResults[0]).toMatchObject({ isError: true });
|
const sandboxedResult = toolResults[0];
|
||||||
const sandboxedResult = toolResults[1];
|
|
||||||
expect(sandboxedResult?.type).toBe("tool-result");
|
expect(sandboxedResult?.type).toBe("tool-result");
|
||||||
if (sandboxedResult?.type !== "tool-result") throw new Error("missing sandboxed Bash result");
|
if (sandboxedResult?.type !== "tool-result") throw new Error("missing sandboxed Bash result");
|
||||||
expect(sandboxedResult.isError, sandboxedResult.result).toBe(false);
|
expect(sandboxedResult.isError, `${sandboxedResult.result}\n${sdkStderr.join("")}`).toBe(false);
|
||||||
await expect(access(unsandboxedEscapePath)).rejects.toMatchObject({ code: "ENOENT" });
|
await expect(access(unsandboxedEscapePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
await expect(readFile(resultPath, "utf8")).resolves.toBe("sandbox-ok\n");
|
const resultContents = await readFile(resultPath, "utf8").catch((error: unknown) => {
|
||||||
|
throw new Error(`sandbox result file missing after tool success:\n${sandboxedResult.result}`, { cause: error });
|
||||||
|
});
|
||||||
|
expect(resultContents).toBe("sandbox-ok\n");
|
||||||
|
await expect(readFile(cphVersionPath, "utf8")).resolves.toMatch(/^cph /);
|
||||||
|
await expect(readFile(effectiveTempProbePath, "utf8")).resolves.toBe("workspace-temp\n");
|
||||||
|
const denialLog = await readFile(denialLogPath, "utf8");
|
||||||
|
expect(denialLog).not.toContain("sibling-secret");
|
||||||
|
expect(denialLog).not.toContain("platform-secret");
|
||||||
await expect(access(siblingEscapePath)).rejects.toMatchObject({ code: "ENOENT" });
|
await expect(access(siblingEscapePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
|
await expect(access(hostTmpEscapePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
|
await expect(access(hostVarTmpEscapePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
await expect(readFile(join(canonicalSibling, "secret.txt"), "utf8")).resolves.toBe("sibling-secret\n");
|
await expect(readFile(join(canonicalSibling, "secret.txt"), "utf8")).resolves.toBe("sibling-secret\n");
|
||||||
await expect(readFile(canonicalServiceSecret, "utf8")).resolves.toBe("platform-secret\n");
|
await expect(readFile(canonicalServiceSecret, "utf8")).resolves.toBe("platform-secret\n");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -139,7 +181,7 @@ function setHostEnv(values: Readonly<Record<string, string>>): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startAnthropicStub(bypassCommand: string, sandboxedCommand: string): Promise<{
|
async function startAnthropicStub(bashCommand: string): Promise<{
|
||||||
readonly baseUrl: string;
|
readonly baseUrl: string;
|
||||||
readonly requestCount: () => number;
|
readonly requestCount: () => number;
|
||||||
readonly close: () => Promise<void>;
|
readonly close: () => Promise<void>;
|
||||||
@@ -156,10 +198,11 @@ async function startAnthropicStub(bypassCommand: string, sandboxedCommand: strin
|
|||||||
};
|
};
|
||||||
requests++;
|
requests++;
|
||||||
const events = requests === 1
|
const events = requests === 1
|
||||||
? bashToolEvents(bypassCommand, requests, true)
|
// Deliberately request the SDK's bypass flag. Production sets
|
||||||
: requests === 2
|
// allowUnsandboxedCommands=false, so the flag must be ignored and the
|
||||||
? bashToolEvents(sandboxedCommand, requests, false)
|
// host-side escape sentinels must remain absent.
|
||||||
: finalTextEvents(requests);
|
? bashToolEvents(bashCommand, requests, true)
|
||||||
|
: finalTextEvents(requests);
|
||||||
writeAnthropicStream(response, events);
|
writeAnthropicStream(response, events);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
response.writeHead(500, { "content-type": "application/json" });
|
response.writeHead(500, { "content-type": "application/json" });
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ describe("deployment preflight CLI", () => {
|
|||||||
expect(probeLog).toContain(workspaceRoot);
|
expect(probeLog).toContain(workspaceRoot);
|
||||||
expect(probeLog).toContain("service-setpriv\n");
|
expect(probeLog).toContain("service-setpriv\n");
|
||||||
expect(probeLog).toContain("service-bwrap\n");
|
expect(probeLog).toContain("service-bwrap\n");
|
||||||
|
expect(probeLog).toContain("service-socat\n");
|
||||||
expect(probeLog).toContain("service-prisma\n");
|
expect(probeLog).toContain("service-prisma\n");
|
||||||
expect(probeLog).toContain("service-database\n");
|
expect(probeLog).toContain("service-database\n");
|
||||||
});
|
});
|
||||||
@@ -141,6 +142,7 @@ describe("deployment preflight CLI", () => {
|
|||||||
const binDir = join(root, "bin");
|
const binDir = join(root, "bin");
|
||||||
const cphBin = join(binDir, "cph");
|
const cphBin = join(binDir, "cph");
|
||||||
const bwrapBin = join(binDir, "bwrap");
|
const bwrapBin = join(binDir, "bwrap");
|
||||||
|
const socatBin = join(binDir, "socat");
|
||||||
const pgIsReadyBin = join(binDir, "pg_isready");
|
const pgIsReadyBin = join(binDir, "pg_isready");
|
||||||
const runuserBin = join(binDir, "runuser");
|
const runuserBin = join(binDir, "runuser");
|
||||||
const setprivBin = join(binDir, "setpriv");
|
const setprivBin = join(binDir, "setpriv");
|
||||||
@@ -155,6 +157,7 @@ describe("deployment preflight CLI", () => {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
executable(cphBin, `#!/bin/sh\nprintf 'cph\\n' >> '${options.marker}'\nprintf 'cph 0.0.2\\n'\n`),
|
executable(cphBin, `#!/bin/sh\nprintf 'cph\\n' >> '${options.marker}'\nprintf 'cph 0.0.2\\n'\n`),
|
||||||
executable(bwrapBin, `#!/bin/sh\nprintf 'service-bwrap\\n' >> '${options.marker}'\nexit 0\n`),
|
executable(bwrapBin, `#!/bin/sh\nprintf 'service-bwrap\\n' >> '${options.marker}'\nexit 0\n`),
|
||||||
|
executable(socatBin, `#!/bin/sh\nprintf 'service-socat\\n' >> '${options.marker}'\nexit 0\n`),
|
||||||
executable(
|
executable(
|
||||||
runuserBin,
|
runuserBin,
|
||||||
`#!/bin/sh\nprintf '%s\\n' "$*" >> '${options.marker}'\n[ "$1" = "-u" ] || exit 91\nshift 2\n[ "$1" = "--" ] || exit 92\nshift\nexec "$@"\n`,
|
`#!/bin/sh\nprintf '%s\\n' "$*" >> '${options.marker}'\n[ "$1" = "-u" ] || exit 91\nshift 2\n[ "$1" = "--" ] || exit 92\nshift\nexec "$@"\n`,
|
||||||
@@ -226,6 +229,8 @@ describe("deployment preflight CLI", () => {
|
|||||||
options.workspaceRoot,
|
options.workspaceRoot,
|
||||||
"--bwrap-bin",
|
"--bwrap-bin",
|
||||||
bwrapBin,
|
bwrapBin,
|
||||||
|
"--socat-bin",
|
||||||
|
socatBin,
|
||||||
"--pg-isready-bin",
|
"--pg-isready-bin",
|
||||||
pgIsReadyBin,
|
pgIsReadyBin,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createAgentSdkStderrSink } from "../../src/agent/diagnostics.js";
|
||||||
|
|
||||||
|
describe("agent SDK stderr diagnostics", () => {
|
||||||
|
it("redacts a credential split across chunks and correlates the log", () => {
|
||||||
|
const warn = vi.fn();
|
||||||
|
const sink = createAgentSdkStderrSink({
|
||||||
|
logger: { warn },
|
||||||
|
runId: "run-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
sensitiveValues: ["provider-secret"],
|
||||||
|
});
|
||||||
|
|
||||||
|
sink.write("sandbox startup token=provider-");
|
||||||
|
sink.write("secret\nremaining diagnostic");
|
||||||
|
sink.flush();
|
||||||
|
|
||||||
|
const serialized = JSON.stringify(warn.mock.calls);
|
||||||
|
expect(serialized).not.toContain("provider-secret");
|
||||||
|
expect(serialized).toContain("[REDACTED]");
|
||||||
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ runId: "run-1", projectId: "project-1" }),
|
||||||
|
"agent SDK stderr",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds line length, line count, and an unterminated line", () => {
|
||||||
|
const warn = vi.fn();
|
||||||
|
const sink = createAgentSdkStderrSink({
|
||||||
|
logger: { warn },
|
||||||
|
runId: "run-2",
|
||||||
|
projectId: "project-2",
|
||||||
|
sensitiveValues: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
sink.write(`${"x".repeat(3_000)}\n`);
|
||||||
|
sink.write(Array.from({ length: 30 }, (_, index) => `line-${index}`).join("\n"));
|
||||||
|
sink.flush();
|
||||||
|
|
||||||
|
expect(warn.mock.calls.length).toBeLessThanOrEqual(21);
|
||||||
|
const diagnostics = warn.mock.calls
|
||||||
|
.map(([bindings]) => (bindings as { sdkStderr?: string }).sdkStderr)
|
||||||
|
.filter((value): value is string => value !== undefined);
|
||||||
|
expect(diagnostics.every((value) => value.length <= 2_000)).toBe(true);
|
||||||
|
expect(warn.mock.calls.some(([, message]) => String(message).includes("limit reached"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,7 +46,7 @@ describe("agent subprocess security policy", () => {
|
|||||||
expect(policy.env).not.toHaveProperty("HUB_SESSION_SECRET");
|
expect(policy.env).not.toHaveProperty("HUB_SESSION_SECRET");
|
||||||
expect(policy.env.HOME).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
|
expect(policy.env.HOME).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
|
||||||
expect(policy.env.CLAUDE_CONFIG_DIR).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
|
expect(policy.env.CLAUDE_CONFIG_DIR).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
|
||||||
expect(policy.env.CLAUDE_CODE_TMPDIR).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
|
expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(join(".cph", "t"));
|
||||||
|
|
||||||
expect(policy.sandbox).toMatchObject({
|
expect(policy.sandbox).toMatchObject({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -81,6 +81,24 @@ describe("agent subprocess security policy", () => {
|
|||||||
})).rejects.toThrow("unsupported provider environment variable: DATABASE_URL");
|
})).rejects.toThrow("unsupported provider environment variable: DATABASE_URL");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the short SDK socket directory inside the project workspace", async () => {
|
||||||
|
const { workspaceRoot, workspace } = await makeWorkspace();
|
||||||
|
const policy = await createAgentSecurityPolicy({
|
||||||
|
workspaceRoot,
|
||||||
|
workspaceDir: workspace,
|
||||||
|
hostEnv: { PATH: "/usr/bin:/bin" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(join(".cph", "t"));
|
||||||
|
const absoluteTemp = join(await realpath(workspace), ".cph", "t");
|
||||||
|
expect(policy.env.TMPDIR).toBe(absoluteTemp);
|
||||||
|
expect(policy.env.TMP).toBe(absoluteTemp);
|
||||||
|
expect(policy.env.TEMP).toBe(absoluteTemp);
|
||||||
|
expect(policy.sandbox.filesystem.allowWrite).toEqual([await realpath(workspace)]);
|
||||||
|
expect(policy.sandbox.filesystem.denyWrite).toEqual(["/"]);
|
||||||
|
expect(policy.sandbox.filesystem.allowRead).not.toContain(expect.stringMatching(/^\/run\//));
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a project workspace whose real path escapes the configured workspace root", async () => {
|
it("rejects a project workspace whose real path escapes the configured workspace root", async () => {
|
||||||
const { root, workspaceRoot } = await makeWorkspace();
|
const { root, workspaceRoot } = await makeWorkspace();
|
||||||
const outside = join(root, "outside");
|
const outside = join(root, "outside");
|
||||||
|
|||||||
Reference in New Issue
Block a user