fix: prove Linux Agent sandbox boundary

This commit is contained in:
2026-07-10 17:59:56 +08:00
parent f4968d6657
commit f07f280b8f
19 changed files with 381 additions and 55 deletions
+6 -2
View File
@@ -44,7 +44,7 @@ jobs:
run: npm ci
- 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
run: |
@@ -89,7 +89,11 @@ jobs:
cargo install --path crates/cph-cli --locked
- 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
run: npx vitest run test/unit
@@ -25,7 +25,7 @@ unit is written or enabled.
Preflight validates the configured bind host/port, authenticated PostgreSQL
access, Node/cph/Prisma, configured and canonical path traversal, and a real
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
before the Feishu listener starts, so bind failure rejects startup instead of
leaving a listener-only process alive.
@@ -1,7 +1,7 @@
# Confine the agent runtime and protect service credentials
Type: task
Status: claimed
Status: resolved
## 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
actual Linux sandbox test proving another workspace and every non-provider
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
Type: task
Status: claimed
Status: resolved
## 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
validate every Feishu message/thread result against the run's bound chat before
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.
- [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.
- [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
@@ -86,7 +86,7 @@ discipline).
### The mechanism: SDK sandbox (OS-level, per agent process)
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`:
- `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,
fonts and the configured `cph` executable remain available as the external
tool exception described above.
- `sandbox.filesystem.allowWrite: [workspaceDir]` — writes are confined to the
canonical ADR-0007 workspace; SDK temp/config/cache/home paths are relocated
beneath `.cph/agent-runtime/` in that same workspace.
- `sandbox.filesystem.allowWrite: [workspaceDir]` confines every write to the
canonical ADR-0007 workspace. Config/cache/home stay beneath
`.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`.
Only provider protocol variables and non-secret runtime variables cross the
boundary; database, Feishu and Hub session credentials never enter it.
+4 -4
View File
@@ -1,8 +1,8 @@
# Hub service installation
The initial production target is a supported Linux host with systemd,
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
PostgreSQL, Node.js 20 or newer, `pg_isready`, `runuser`, `setpriv`, bubblewrap,
`socat` and `cph`. The Hub runs as the dedicated non-login `cph-hub` user; do not
create or run the unit as root.
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
`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,
paths, Node, bubblewrap, `cph --version`, PostgreSQL connectivity and directory
paths, Node, bubblewrap, `socat`, `cph --version`, PostgreSQL connectivity and directory
ownership all pass preflight.
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
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
database/network policy, or a host that forbids unprivileged user namespaces
therefore fail before unit installation.
+1
View File
@@ -6,6 +6,7 @@ Wants=network-online.target
# after installation. Deployment preflight validates the executable itself;
# this start-time assertion keeps later package/host changes fail-closed.
AssertPathExists=__BWRAP_BIN__
AssertPathExists=__SOCAT_BIN__
[Service]
Type=simple
+1 -1
View File
@@ -9,7 +9,7 @@
# 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
# 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
# provisions the dedicated non-root identity and persistent directories.
# Use a dedicated `deploy` user that has passwordless sudo for systemctl and
+6 -1
View File
@@ -24,13 +24,14 @@ ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}"
SYSTEMD_UNIT_DIR="${SYSTEMD_UNIT_DIR:-/etc/systemd/system}"
NODE_BIN="${NODE_BIN:-$(command -v node || 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)}"
RUNUSER_BIN="${RUNUSER_BIN:-$(command -v runuser || true)}"
SETPRIV_BIN="${SETPRIV_BIN:-$(command -v setpriv || true)}"
CPH_BIN_DEFAULT="${CPH_BIN_DEFAULT:-/usr/local/bin/cph}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
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() {
echo "[install] error: $*" >&2
@@ -68,6 +69,7 @@ done
for pair in \
"NODE_BIN:$NODE_BIN" \
"BWRAP_BIN:$BWRAP_BIN" \
"SOCAT_BIN:$SOCAT_BIN" \
"PG_ISREADY_BIN:$PG_ISREADY_BIN"; do
label="${pair%%:*}"
value="${pair#*:}"
@@ -98,6 +100,7 @@ for pair in \
"SYSTEMD_UNIT_DIR:$SYSTEMD_UNIT_DIR" \
"NODE_BIN:$NODE_BIN" \
"BWRAP_BIN:$BWRAP_BIN" \
"SOCAT_BIN:$SOCAT_BIN" \
"RUNUSER_BIN:$RUNUSER_BIN" \
"SETPRIV_BIN:$SETPRIV_BIN" \
"RUNTIME_PATH:$RUNTIME_PATH"; do
@@ -152,6 +155,7 @@ PREFLIGHT_ARGS=(
--cache-dir "$CACHE_DIR"
--workspace-root "$WORKSPACE_ROOT"
--bwrap-bin "$BWRAP_BIN"
--socat-bin "$SOCAT_BIN"
--pg-isready-bin "$PG_ISREADY_BIN"
)
@@ -247,6 +251,7 @@ sed \
-e "s|__ENV_FILE__|$ENV_FILE|g" \
-e "s|__NODE_BIN__|$NODE_BIN|g" \
-e "s|__BWRAP_BIN__|$BWRAP_BIN|g" \
-e "s|__SOCAT_BIN__|$SOCAT_BIN|g" \
-e "s|__RUNTIME_PATH__|$RUNTIME_PATH|g" \
"$SCRIPT_DIR/cph-hub.service" >"$TMP_UNIT"
install -o root -g root -m 0644 "$TMP_UNIT" "$UNIT"
+115
View File
@@ -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();
}
+9 -5
View File
@@ -19,11 +19,12 @@
*
* 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
* Code process and its subprocesses at the OS level — writes are confined to
* the workspace, host reads are denied by default and re-opened only for the
* workspace plus named system runtimes, and `failIfUnavailable` hard-fails if
* the sandbox can't start. The subprocess gets a minimal environment and SDK
* credential protection removes provider secrets from Bash. This upholds `AgentFileOp.Authorized`
* Code process and its subprocesses at the OS level — writes, including the
* SDK's Unix-socket scratch, are confined to the workspace. Host reads are
* denied by default and re-opened only for the workspace plus named system
* runtimes, and `failIfUnavailable` hard-fails if the sandbox can't start. The
* 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
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
* is the mechanism, the contract pins the invariant.
@@ -70,6 +71,8 @@ export interface RunRequest {
readonly sessionId: string;
readonly prisma: PrismaClient;
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
* (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.mcpServers !== undefined) options.mcpServers = req.mcpServers;
if (req.abortController !== undefined) options.abortController = req.abortController;
if (req.onSdkStderr !== undefined) options.stderr = req.onSdkStderr;
const conversation = query({
prompt: req.prompt,
+22 -5
View File
@@ -21,7 +21,10 @@ const SAFE_HOST_ENV_KEYS = [
"CPH_BIN",
] 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 {
readonly workspaceRoot: string;
@@ -37,6 +40,7 @@ export interface AgentSandboxPolicy {
readonly allowUnsandboxedCommands: false;
readonly filesystem: {
readonly allowWrite: string[];
readonly denyWrite: string[];
readonly denyRead: string[];
readonly allowRead: string[];
};
@@ -61,12 +65,16 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
const hostEnv = input.hostEnv ?? process.env;
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 agentCache = await ensureDirectoryTree(runtimeRoot, ["cache"]);
const agentConfig = await ensureDirectoryTree(runtimeRoot, ["config"]);
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();
if (path === undefined || path === "") {
@@ -82,11 +90,14 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
env.XDG_CACHE_HOME = agentCache;
env.XDG_CONFIG_HOME = agentConfig;
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.TMP = agentTmp;
env.TEMP = agentTmp;
env.CLAUDE_CONFIG_DIR = agentConfig;
env.CLAUDE_CODE_TMPDIR = agentTmp;
env.CLAUDE_CODE_TMPDIR = claudeCodeTmp;
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
env.DISABLE_TELEMETRY = "1";
env.DO_NOT_TRACK = "1";
@@ -112,6 +123,10 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
allowUnsandboxedCommands: false,
filesystem: {
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
// workspace plus the named system runtime needed to execute tools.
// SDK allowRead takes precedence over matching denyRead paths.
@@ -120,7 +135,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
},
credentials: {
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",
"/sbin",
"/usr/bin",
"/usr/local/bin",
"/usr/sbin",
"/lib",
"/lib64",
"/usr/lib",
+7 -1
View File
@@ -29,6 +29,7 @@ interface Options {
readonly cacheDir: string;
readonly workspaceRoot: string;
readonly bwrapBin: string;
readonly socatBin: string;
readonly pgIsReadyBin: string;
readonly serviceUid?: number;
readonly serviceGid?: number;
@@ -50,6 +51,7 @@ async function main(): Promise<void> {
await validateExecutable(options.runuserBin, "runuser");
await validateExecutable(options.setprivBin, "setpriv");
await validateExecutable(options.bwrapBin, "bubblewrap");
await validateExecutable(options.socatBin, "socat");
await validateExecutable(result.cphBin, "cph");
await validateExecutable(options.pgIsReadyBin, "pg_isready");
await validateCph(result.cphBin);
@@ -257,7 +259,7 @@ async function validateServiceRuntime(
HOME: configuredInput.serviceHome,
XDG_STATE_HOME: configuredInput.stateDir,
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) => [
[input.hubDir, constants.R_OK | constants.X_OK],
@@ -278,6 +280,7 @@ async function validateServiceRuntime(
...pathAccessRequirements(canonicalInput),
[options.nodeBin, constants.X_OK],
[options.bwrapBin, constants.X_OK],
[options.socatBin, constants.X_OK],
[options.setprivBin, constants.X_OK],
[result.cphBin, constants.X_OK],
] as const;
@@ -315,6 +318,7 @@ async function validateServiceRuntime(
"Hub dependency and authenticated database probe",
);
await runAsService(options, serviceEnv, result.cphBin, ["--version"], "cph runtime probe");
await runAsService(options, serviceEnv, options.socatBin, ["-V"], "socat runtime probe");
await runAsService(
options,
serviceEnv,
@@ -390,6 +394,7 @@ function parseArgs(argv: readonly string[]): Options {
"--cache-dir",
"--workspace-root",
"--bwrap-bin",
"--socat-bin",
"--pg-isready-bin",
"--service-uid",
"--service-gid",
@@ -417,6 +422,7 @@ function parseArgs(argv: readonly string[]): Options {
cacheDir: required(values, "--cache-dir"),
workspaceRoot: required(values, "--workspace-root"),
bwrapBin: required(values, "--bwrap-bin"),
socatBin: required(values, "--socat-bin"),
pgIsReadyBin: required(values, "--pg-isready-bin"),
...(serviceUid === undefined ? {} : { serviceUid, serviceGid: serviceGid! }),
};
+9
View File
@@ -35,6 +35,7 @@ import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
import { createPermissionAuthorizer, type AuthorizationDecision, type PermissionAuthorizer } from "../permission.js";
import { writeAudit } from "../audit.js";
import { formatRunCostLine } from "../agent/cost.js";
import { createAgentSdkStderrSink } from "../agent/diagnostics.js";
import { StreamingAgentCard } from "./card/streaming-card.js";
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.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.
const abortController = new AbortController();
activeRuns.set(run.id, abortController);
const sdkStderr = createAgentSdkStderrSink({
logger: deps.logger,
runId: run.id,
projectId,
sensitiveValues: Object.values(provider.sdkEnv),
});
runAgent({
prompt: promptForAgent,
model,
@@ -345,6 +352,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
sessionId: session.id,
prisma: deps.prisma,
abortController,
onSdkStderr: sdkStderr.write,
onStream: (event) => {
switch (event.type) {
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) } });
})
.finally(async () => {
sdkStderr.flush();
try {
await releaseLock(deps.prisma, run.id);
} catch (e) {
@@ -1,6 +1,7 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
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 { join } from "node:path";
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 () => {
await access("/usr/bin/bwrap", constants.X_OK);
await access("/usr/bin/socat", constants.X_OK);
const noNewPrivilegesStatus = await readFile("/proc/self/status", "utf8");
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 cphBin = cphPathOutput.trim();
await access(cphBin, constants.X_OK);
// Keep CLAUDE_CODE_TMPDIR short enough for the SDK's AF_UNIX socket paths;
// otherwise the SDK deliberately falls back to shared /tmp.
const root = await mkdtemp("/tmp/cph-");
roots.push(root);
// CI provisions this runner-owned root below /var/lib before dropping into
// no_new_privs. Keeping the fixture out of /tmp proves that an SDK-wide
// temp exception cannot make a cross-project escape look contained.
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 workspace = join(workspaceRoot, "org-a", "project-a");
const sibling = join(workspaceRoot, "org-b", "project-b");
const serviceSecret = join(root, "service-secrets", "platform.env");
const workspace = join(workspaceRoot, "org-a", `project_${nonce}`);
const sibling = join(workspaceRoot, "org-b", `project_${randomUUID().replaceAll("-", "")}`);
const serviceSecret = join(root, `service-secret-${nonce}`);
roots.push(workspace, sibling, serviceSecret);
await Promise.all([
mkdir(workspace, { recursive: true }),
mkdir(sibling, { recursive: true }),
mkdir(join(root, "service-secrets"), { recursive: true }),
]);
await Promise.all([
writeFile(join(workspace, "allowed.txt"), "allowed\n"),
@@ -57,8 +69,14 @@ describe("real Claude SDK sandbox boundary", () => {
const canonicalSibling = await realpath(sibling);
const canonicalServiceSecret = await realpath(serviceSecret);
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 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({
CPH_BIN: cphBin,
@@ -70,21 +88,32 @@ describe("real Claude SDK sandbox boundary", () => {
const bashCommand = [
"set -eu",
`if printf 'unsafe\\n' > ${shellQuote(unsandboxedEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 31; fi`,
`test "$(cat ${shellQuote(join(canonicalWorkspace, "allowed.txt"))})" = "allowed"`,
`if cat ${shellQuote(join(canonicalSibling, "secret.txt"))} >/dev/null 2>&1; then exit 21; fi`,
`if printf 'escape\\n' > ${shellQuote(siblingEscapePath)} 2>/dev/null; then exit 22; fi`,
`if cat ${shellQuote(canonicalServiceSecret)} >/dev/null 2>&1; then exit 23; fi`,
`if sibling_value=$(cat ${shellQuote(join(canonicalSibling, "secret.txt"))} 2>> ${shellQuote(denialLogPath)}); then exit 21; fi`,
`if printf 'escape\\n' > ${shellQuote(siblingEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 32; 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 "${FEISHU_APP_SECRET-unset}" = unset',
'test "${HUB_SESSION_SECRET-unset}" = unset',
'test "${ANTHROPIC_AUTH_TOKEN-unset}" = unset',
'test "${ANTHROPIC_API_KEY-unset}" = unset',
"cph --version | grep '^cph '",
`cph --version > ${shellQuote(cphVersionPath)}`,
`printf 'sandbox-ok\\n' > ${shellQuote(resultPath)}`,
].join("\n");
const bypassCommand = `printf 'unsafe\\n' > ${shellQuote(unsandboxedEscapePath)}`;
const stub = await startAnthropicStub(bypassCommand, bashCommand);
const stub = await startAnthropicStub(bashCommand);
const streamEvents: StreamEvent[] = [];
const sdkStderr: string[] = [];
try {
const result = await runAgent({
prompt: "Run the supplied sandbox boundary probe.",
@@ -110,20 +139,33 @@ describe("real Claude SDK sandbox boundary", () => {
agentMessage: { create: async () => ({}) },
} as unknown as import("@prisma/client").PrismaClient,
onStream: (event) => streamEvents.push(event),
onSdkStderr: (data) => sdkStderr.push(data),
});
expect(result).toMatchObject({ status: "completed" });
expect(stub.requestCount()).toBeGreaterThanOrEqual(3);
expect(
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");
expect(toolResults).toHaveLength(2);
expect(toolResults[0]).toMatchObject({ isError: true });
const sandboxedResult = toolResults[1];
expect(toolResults).toHaveLength(1);
const sandboxedResult = toolResults[0];
expect(sandboxedResult?.type).toBe("tool-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(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(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(canonicalServiceSecret, "utf8")).resolves.toBe("platform-secret\n");
} 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 requestCount: () => number;
readonly close: () => Promise<void>;
@@ -156,10 +198,11 @@ async function startAnthropicStub(bypassCommand: string, sandboxedCommand: strin
};
requests++;
const events = requests === 1
? bashToolEvents(bypassCommand, requests, true)
: requests === 2
? bashToolEvents(sandboxedCommand, requests, false)
: finalTextEvents(requests);
// Deliberately request the SDK's bypass flag. Production sets
// allowUnsandboxedCommands=false, so the flag must be ignored and the
// host-side escape sentinels must remain absent.
? bashToolEvents(bashCommand, requests, true)
: finalTextEvents(requests);
writeAnthropicStream(response, events);
} catch (error) {
response.writeHead(500, { "content-type": "application/json" });
@@ -128,6 +128,7 @@ describe("deployment preflight CLI", () => {
expect(probeLog).toContain(workspaceRoot);
expect(probeLog).toContain("service-setpriv\n");
expect(probeLog).toContain("service-bwrap\n");
expect(probeLog).toContain("service-socat\n");
expect(probeLog).toContain("service-prisma\n");
expect(probeLog).toContain("service-database\n");
});
@@ -141,6 +142,7 @@ describe("deployment preflight CLI", () => {
const binDir = join(root, "bin");
const cphBin = join(binDir, "cph");
const bwrapBin = join(binDir, "bwrap");
const socatBin = join(binDir, "socat");
const pgIsReadyBin = join(binDir, "pg_isready");
const runuserBin = join(binDir, "runuser");
const setprivBin = join(binDir, "setpriv");
@@ -155,6 +157,7 @@ describe("deployment preflight CLI", () => {
await Promise.all([
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(socatBin, `#!/bin/sh\nprintf 'service-socat\\n' >> '${options.marker}'\nexit 0\n`),
executable(
runuserBin,
`#!/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,
"--bwrap-bin",
bwrapBin,
"--socat-bin",
socatBin,
"--pg-isready-bin",
pgIsReadyBin,
],
+47
View File
@@ -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);
});
});
+19 -1
View File
@@ -46,7 +46,7 @@ describe("agent subprocess security policy", () => {
expect(policy.env).not.toHaveProperty("HUB_SESSION_SECRET");
expect(policy.env.HOME).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({
enabled: true,
@@ -81,6 +81,24 @@ describe("agent subprocess security policy", () => {
})).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 () => {
const { root, workspaceRoot } = await makeWorkspace();
const outside = join(root, "outside");