forked from EduCraft/curriculum-project-hub
fix: prove Linux Agent sandbox boundary
This commit is contained in:
@@ -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.
|
||||
* 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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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! }),
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user