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
+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",