Compare commits

...

2 Commits

Author SHA1 Message Date
hongjr03 7fcb57013e fix: restore bounded agent sandbox execution 2026-07-11 02:34:56 +08:00
hongjr03 035c264179 fix: keep agent sandbox sockets on short paths 2026-07-11 02:22:19 +08:00
14 changed files with 164 additions and 81 deletions
+2 -2
View File
@@ -93,8 +93,8 @@ jobs:
- name: Prove real Claude SDK Bash sandbox boundary - name: Prove real Claude SDK Bash sandbox boundary
run: | run: |
sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /var/lib/cph-test sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /w/t
CPH_SANDBOX_TEST_ROOT=/var/lib/cph-test \ CPH_SANDBOX_TEST_ROOT=/w/t \
/usr/bin/setpriv --no-new-privs \ /usr/bin/setpriv --no-new-privs \
npx vitest run test/integration/agent-sandbox-linux.test.ts npx vitest run test/integration/agent-sandbox-linux.test.ts
@@ -93,22 +93,26 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
its Bash subprocesses run sandboxed; if the sandbox can't start, `query()` its Bash subprocesses run sandboxed; if the sandbox can't start, `query()`
emits an error and exits rather than running unsandboxed. emits an error and exits rather than running unsandboxed.
- `sandbox.allowUnsandboxedCommands: false` — a tool cannot opt out with the - `sandbox.allowUnsandboxedCommands: false` — a tool cannot opt out with the
SDK's `dangerouslyDisableSandbox` input. SDK's `dangerouslyDisableSandbox` input. Claude Code 2.1.202 does not enforce
that option reliably, so a host-side `PreToolUse` hook also denies every
Bash request whose input explicitly sets `dangerouslyDisableSandbox: true`
before a process can start.
- `sandbox.filesystem.denyRead: ["/"]` with `allowRead` for the canonical - `sandbox.filesystem.denyRead: ["/"]` with `allowRead` for the canonical
current workspace and a small named system-runtime set — normal reads stay current workspace and a small named system-runtime set — normal reads stay
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]` confines every write to the - `sandbox.filesystem.allowWrite: [workspaceDir]` confines persistent host
canonical ADR-0007 workspace. Config/cache/home stay beneath effects to the canonical ADR-0007 workspace. Config/cache/home stay beneath
`.cph/agent-runtime/`. `TMPDIR`, `TMP`, and `TEMP` use the absolute `.cph/agent-runtime/`; `TMPDIR`, `TMP`, `TEMP`, and `CLAUDE_CODE_TMPDIR` all
workspace-local `.cph/t/` path so tools remain anchored after `cd`; point at the workspace-local `.cph/t`. The workspace allocator uses stable
`CLAUDE_CODE_TMPDIR` uses the short relative `.cph/t` prefix, resolved from compact Organization/Project path segments, deployment requires a short
the canonical workspace cwd, because the SDK's socat bridge otherwise falls workspace root, and the canonical temp prefix fails fast above 56 bytes so
back to a host temp directory when its Unix-socket prefix is too long. the SDK can append randomized `socat` bridge socket names without exceeding
Root is denied for writes and only the canonical workspace is re-opened, so Linux `sockaddr_un.sun_path`. Bubblewrap shadows non-allowlisted host trees
`/tmp`, `/var/tmp`, sibling projects and every other host path are rejected. with disposable tmpfs mounts: a shell write there may succeed inside that
There is no writable scratch exception outside the project directory. private namespace, but it cannot mutate the corresponding host path. The
Linux proof checks host state after the sandbox exits.
- 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.
+2 -1
View File
@@ -15,6 +15,7 @@ Application code lives in immutable versioned directories under
sudo BASE=/srv/curriculum-project-hub \ sudo BASE=/srv/curriculum-project-hub \
HUB_DIR=/srv/curriculum-project-hub/releases/<release-id>/hub \ HUB_DIR=/srv/curriculum-project-hub/releases/<release-id>/hub \
INSTANCE_ID=org-a \ INSTANCE_ID=org-a \
WORKSPACE_ROOT=/w/997 \
PORT=8788 \ PORT=8788 \
MEMORY_MAX=16G CPU_QUOTA=400% TASKS_MAX=512 \ MEMORY_MAX=16G CPU_QUOTA=400% TASKS_MAX=512 \
bash /srv/curriculum-project-hub/releases/<release-id>/hub/deploy/install_service.sh bash /srv/curriculum-project-hub/releases/<release-id>/hub/deploy/install_service.sh
@@ -49,7 +50,7 @@ Default state paths are:
```text ```text
/var/lib/cph-hub/org-a/home /var/lib/cph-hub/org-a/home
/var/lib/cph-hub/org-a/state /var/lib/cph-hub/org-a/state
/var/lib/cph-hub/org-a/workspaces /w/997
/var/cache/cph-hub/org-a /var/cache/cph-hub/org-a
``` ```
+4 -2
View File
@@ -10,6 +10,7 @@
# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub # PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub
# PLATFORM_DEPLOY_RELEASE optional immutable release id, defaults to git HEAD # PLATFORM_DEPLOY_RELEASE optional immutable release id, defaults to git HEAD
# PLATFORM_DEPLOY_INSTANCE required, Silo instance id # PLATFORM_DEPLOY_INSTANCE required, Silo instance id
# PLATFORM_DEPLOY_WORKSPACE_ROOT required short per-Silo path (for example /w/997)
# PLATFORM_DEPLOY_MEMORY_MAX / CPU_QUOTA / TASKS_MAX required ceilings # PLATFORM_DEPLOY_MEMORY_MAX / CPU_QUOTA / TASKS_MAX required ceilings
# 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 24+, npm, rsync, PostgreSQL client tools # The target host must have Node.js 24+, npm, rsync, PostgreSQL client tools
@@ -31,6 +32,7 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
RELEASE_ID="${PLATFORM_DEPLOY_RELEASE:-$(git -C "$REPO_ROOT" rev-parse --verify HEAD)}" RELEASE_ID="${PLATFORM_DEPLOY_RELEASE:-$(git -C "$REPO_ROOT" rev-parse --verify HEAD)}"
[[ "$RELEASE_ID" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "invalid PLATFORM_DEPLOY_RELEASE" >&2; exit 1; } [[ "$RELEASE_ID" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "invalid PLATFORM_DEPLOY_RELEASE" >&2; exit 1; }
INSTANCE_ID="${PLATFORM_DEPLOY_INSTANCE:?PLATFORM_DEPLOY_INSTANCE required}" INSTANCE_ID="${PLATFORM_DEPLOY_INSTANCE:?PLATFORM_DEPLOY_INSTANCE required}"
WORKSPACE_ROOT="${PLATFORM_DEPLOY_WORKSPACE_ROOT:?PLATFORM_DEPLOY_WORKSPACE_ROOT required}"
SERVICE_UNIT="cph-hub-$INSTANCE_ID.service" SERVICE_UNIT="cph-hub-$INSTANCE_ID.service"
MEMORY_MAX="${PLATFORM_DEPLOY_MEMORY_MAX:?PLATFORM_DEPLOY_MEMORY_MAX required}" MEMORY_MAX="${PLATFORM_DEPLOY_MEMORY_MAX:?PLATFORM_DEPLOY_MEMORY_MAX required}"
CPU_QUOTA="${PLATFORM_DEPLOY_CPU_QUOTA:?PLATFORM_DEPLOY_CPU_QUOTA required}" CPU_QUOTA="${PLATFORM_DEPLOY_CPU_QUOTA:?PLATFORM_DEPLOY_CPU_QUOTA required}"
@@ -67,11 +69,11 @@ fi
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" " ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "
set -euo pipefail set -euo pipefail
if [ \"\$(id -u)\" = \"0\" ]; then if [ \"\$(id -u)\" = \"0\" ]; then
BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY_MAX' CPU_QUOTA='$CPU_QUOTA' TASKS_MAX='$TASKS_MAX' bash '$HUB_DIR/deploy/install_service.sh' BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' WORKSPACE_ROOT='$WORKSPACE_ROOT' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY_MAX' CPU_QUOTA='$CPU_QUOTA' TASKS_MAX='$TASKS_MAX' bash '$HUB_DIR/deploy/install_service.sh'
systemctl restart '$SERVICE_UNIT' systemctl restart '$SERVICE_UNIT'
systemctl is-active --quiet '$SERVICE_UNIT' systemctl is-active --quiet '$SERVICE_UNIT'
else else
sudo -n BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY_MAX' CPU_QUOTA='$CPU_QUOTA' TASKS_MAX='$TASKS_MAX' bash '$HUB_DIR/deploy/install_service.sh' sudo -n BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' WORKSPACE_ROOT='$WORKSPACE_ROOT' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY_MAX' CPU_QUOTA='$CPU_QUOTA' TASKS_MAX='$TASKS_MAX' bash '$HUB_DIR/deploy/install_service.sh'
sudo -n systemctl restart '$SERVICE_UNIT' sudo -n systemctl restart '$SERVICE_UNIT'
sudo -n systemctl is-active --quiet '$SERVICE_UNIT' sudo -n systemctl is-active --quiet '$SERVICE_UNIT'
fi fi
+2 -1
View File
@@ -23,7 +23,7 @@ SERVICE_GROUP="${SERVICE_GROUP:-$SERVICE_USER}"
SERVICE_HOME="${SERVICE_HOME:-/var/lib/cph-hub/$INSTANCE_ID/home}" SERVICE_HOME="${SERVICE_HOME:-/var/lib/cph-hub/$INSTANCE_ID/home}"
STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/$INSTANCE_ID/state}" STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/$INSTANCE_ID/state}"
CACHE_DIR="${CACHE_DIR:-/var/cache/cph-hub/$INSTANCE_ID}" CACHE_DIR="${CACHE_DIR:-/var/cache/cph-hub/$INSTANCE_ID}"
WORKSPACE_ROOT="${WORKSPACE_ROOT:-/var/lib/cph-hub/$INSTANCE_ID/workspaces}" WORKSPACE_ROOT="${WORKSPACE_ROOT:?WORKSPACE_ROOT required (use a short per-Silo path such as /w/997)}"
HOST="${HOST:-127.0.0.1}" HOST="${HOST:-127.0.0.1}"
PORT="${PORT:?PORT is required and must be unique on the host}" PORT="${PORT:?PORT is required and must be unique on the host}"
ENV_FILE="${ENV_FILE:-$BASE/.secrets/$INSTANCE_ID/platform.env}" ENV_FILE="${ENV_FILE:-$BASE/.secrets/$INSTANCE_ID/platform.env}"
@@ -120,6 +120,7 @@ done
[[ "$MEMORY_MAX" =~ ^[1-9][0-9]*[KMGT]$ ]] || fail "MEMORY_MAX must be a systemd byte size such as 16G" [[ "$MEMORY_MAX" =~ ^[1-9][0-9]*[KMGT]$ ]] || fail "MEMORY_MAX must be a systemd byte size such as 16G"
[[ "$CPU_QUOTA" =~ ^[1-9][0-9]*%$ ]] || fail "CPU_QUOTA must be a positive percentage such as 400%" [[ "$CPU_QUOTA" =~ ^[1-9][0-9]*%$ ]] || fail "CPU_QUOTA must be a positive percentage such as 400%"
[[ "$TASKS_MAX" =~ ^[1-9][0-9]*$ ]] || fail "TASKS_MAX must be a positive integer" [[ "$TASKS_MAX" =~ ^[1-9][0-9]*$ ]] || fail "TASKS_MAX must be a positive integer"
[ "${#WORKSPACE_ROOT}" -le 16 ] || fail "WORKSPACE_ROOT must be at most 16 bytes for Agent sandbox sockets: $WORKSPACE_ROOT"
KEYRING_CREATED=false KEYRING_CREATED=false
if [ -L "$KEYRING_FILE" ]; then if [ -L "$KEYRING_FILE" ]; then
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.4", "version": "0.0.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.4", "version": "0.0.6",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202", "@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.4", "version": "0.0.6",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
+23 -1
View File
@@ -29,7 +29,7 @@
* `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.
*/ */
import { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk"; import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import { claudeSdkToolConfigForRole } from "./roleTools.js"; import { claudeSdkToolConfigForRole } from "./roleTools.js";
import { createAgentSecurityPolicy } from "./security.js"; import { createAgentSecurityPolicy } from "./security.js";
@@ -96,6 +96,22 @@ export interface RunResult {
const DEFAULT_MAX_TURNS = 25; const DEFAULT_MAX_TURNS = 25;
const denyUnsandboxedBash: HookCallback = async (input) => {
if (input.hook_event_name !== "PreToolUse" || input.tool_name !== "Bash") return {};
const toolInput = input.tool_input;
if (
typeof toolInput !== "object" || toolInput === null ||
!("dangerouslyDisableSandbox" in toolInput) || toolInput.dangerouslyDisableSandbox !== true
) return {};
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "This deployment requires every Bash command to remain sandboxed.",
},
};
};
export async function runAgent(req: RunRequest): Promise<RunResult> { export async function runAgent(req: RunRequest): Promise<RunResult> {
const onStream = req.onStream; const onStream = req.onStream;
const cap = req.maxTurns ?? DEFAULT_MAX_TURNS; const cap = req.maxTurns ?? DEFAULT_MAX_TURNS;
@@ -151,6 +167,12 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
// settings that could widen tools, hooks, MCP servers, or sandbox paths. // settings that could widen tools, hooks, MCP servers, or sandbox paths.
settingSources: [], settingSources: [],
strictMcpConfig: true, strictMcpConfig: true,
// Claude Code 2.1.202 can honor the per-call opt-out despite
// sandbox.allowUnsandboxedCommands=false. Enforce the invariant again at
// the PreToolUse boundary, before the Bash process can be spawned.
hooks: {
PreToolUse: [{ matcher: "Bash", hooks: [denyUnsandboxedBash] }],
},
}; };
if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt; if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
if (req.model !== undefined) options.model = req.model; if (req.model !== undefined) options.model = req.model;
+19 -8
View File
@@ -26,6 +26,11 @@ const SANDBOX_HIDDEN_ENV_KEYS = [
"ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY",
] as const; ] as const;
// Linux sockaddr_un.sun_path is 108 bytes including the terminator. Claude's
// sandbox appends its own user directory and randomized bridge socket names,
// so keep our prefix well below that hard limit.
const MAX_AGENT_TMP_PREFIX_BYTES = 56;
export interface AgentSecurityInput { export interface AgentSecurityInput {
readonly workspaceRoot: string; readonly workspaceRoot: string;
readonly workspaceDir: string; readonly workspaceDir: string;
@@ -72,10 +77,8 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
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"]);
// 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 agentTmp = await ensureDirectoryTree(cphRoot, ["t"]);
const claudeCodeTmp = join(".cph", "t"); assertShortAgentTemp(agentTmp);
const path = hostEnv["PATH"]?.trim(); const path = hostEnv["PATH"]?.trim();
if (path === undefined || path === "") { if (path === undefined || path === "") {
@@ -91,14 +94,13 @@ 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 // All four variables must use the short path. Claude's sandbox bridge uses
// after `cd`. Only the SDK's socket prefix is relative: Claude resolves it // the ordinary temp variables, while other SDK paths use CLAUDE_CODE_TMPDIR.
// 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 = claudeCodeTmp; env.CLAUDE_CODE_TMPDIR = agentTmp;
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";
@@ -142,6 +144,15 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
}; };
} }
function assertShortAgentTemp(agentTmp: string): void {
const prefixBytes = Buffer.byteLength(agentTmp);
if (prefixBytes > MAX_AGENT_TMP_PREFIX_BYTES) {
throw new Error(
`Agent temp path is too long for sandbox bridge sockets (${prefixBytes} > ${MAX_AGENT_TMP_PREFIX_BYTES} bytes): ${agentTmp}`,
);
}
}
function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>): string[] { function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
const platformPaths = process.platform === "darwin" const platformPaths = process.platform === "darwin"
? [ ? [
@@ -282,7 +293,7 @@ async function ensureDirectoryTree(root: string, components: readonly string[]):
} }
const canonical = await realpath(current); const canonical = await realpath(current);
if (canonical !== current || !isStrictDescendant(root, canonical)) { if (canonical !== current || !isStrictDescendant(root, canonical)) {
throw new Error(`Agent runtime path escapes project workspace: ${current}`); throw new Error(`Agent runtime path escapes its configured root: ${current}`);
} }
await chmod(canonical, 0o700); await chmod(canonical, 0o700);
return canonical; return canonical;
+12 -2
View File
@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { mkdir, rm } from "node:fs/promises"; import { mkdir, rm } from "node:fs/promises";
import { dirname, relative, resolve } from "node:path"; import { dirname, relative, resolve } from "node:path";
import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClient } from "@prisma/client"; import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClient } from "@prisma/client";
@@ -595,7 +595,11 @@ function projectWorkspaceDir(input: {
readonly projectId: string; readonly projectId: string;
}): string { }): string {
const root = resolve(requireNonEmpty(input.workspaceRoot, "workspace root")); const root = resolve(requireNonEmpty(input.workspaceRoot, "workspace root"));
const dir = resolve(root, safePathSegment(input.organizationSlug, "organization slug"), safePathSegment(input.projectId, "project id")); const dir = resolve(
root,
compactWorkspaceSegment("o", input.organizationSlug, "organization slug"),
compactWorkspaceSegment("p", input.projectId, "project id"),
);
const rel = relative(root, dir); const rel = relative(root, dir);
if (rel === "" || rel.startsWith("..")) { if (rel === "" || rel.startsWith("..")) {
throw new Error(`allocated workspace escapes root: ${dir}`); throw new Error(`allocated workspace escapes root: ${dir}`);
@@ -603,6 +607,12 @@ function projectWorkspaceDir(input: {
return dir; return dir;
} }
function compactWorkspaceSegment(prefix: "o" | "p", value: string, label: string): string {
const normalized = safePathSegment(value, label);
const digest = createHash("sha256").update(normalized).digest("base64url").slice(0, 16);
return `${prefix}_${digest}`;
}
function safePathSegment(value: string, label: string): string { function safePathSegment(value: string, label: string): string {
const segment = requireNonEmpty(value, label).replace(/[^A-Za-z0-9._-]+/g, "_"); const segment = requireNonEmpty(value, label).replace(/[^A-Za-z0-9._-]+/g, "_");
if (segment === "." || segment === ".." || segment === "") { if (segment === "." || segment === ".." || segment === "") {
@@ -36,22 +36,22 @@ describe("real Claude SDK sandbox boundary", () => {
const cphBin = cphPathOutput.trim(); const cphBin = cphPathOutput.trim();
await access(cphBin, constants.X_OK); await access(cphBin, constants.X_OK);
// CI provisions this runner-owned root below /var/lib before dropping into // CI provisions a deliberately short runner-owned root before dropping
// no_new_privs. Keeping the fixture out of /tmp proves that an SDK-wide // into no_new_privs. Claude appends randomized AF_UNIX bridge socket names,
// temp exception cannot make a cross-project escape look contained. // so the entire workspace-local .cph/t prefix must stay within its budget.
const configuredTestRoot = process.env["CPH_SANDBOX_TEST_ROOT"]?.trim(); const configuredTestRoot = process.env["CPH_SANDBOX_TEST_ROOT"]?.trim();
if (configuredTestRoot === undefined || configuredTestRoot === "") { if (configuredTestRoot === undefined || configuredTestRoot === "") {
throw new Error("CPH_SANDBOX_TEST_ROOT is required for the Linux sandbox proof"); throw new Error("CPH_SANDBOX_TEST_ROOT is required for the Linux sandbox proof");
} }
const root = await realpath(configuredTestRoot); const root = await realpath(configuredTestRoot);
if (!root.startsWith("/var/lib/")) { if (!root.startsWith("/") || Buffer.byteLength(root) > 16) {
throw new Error(`CPH_SANDBOX_TEST_ROOT must be below /var/lib: ${root}`); throw new Error(`CPH_SANDBOX_TEST_ROOT must be an absolute path of at most 16 bytes: ${root}`);
} }
const nonce = randomUUID().replaceAll("-", ""); const nonce = randomUUID().replaceAll("-", "");
const workspaceRoot = join(root, "workspaces"); const workspaceRoot = join(root, "w");
const workspace = join(workspaceRoot, "org-a", `project_${nonce}`); const workspace = join(workspaceRoot, "a", `p_${nonce.slice(0, 8)}`);
const sibling = join(workspaceRoot, "org-b", `project_${randomUUID().replaceAll("-", "")}`); const sibling = join(workspaceRoot, "b", `p_${nonce.slice(8, 16)}`);
const serviceSecret = join(root, `service-secret-${nonce}`); const serviceSecret = join(root, `s_${nonce.slice(16, 24)}`);
roots.push(workspace, sibling, serviceSecret); roots.push(workspace, sibling, serviceSecret);
await Promise.all([ await Promise.all([
mkdir(workspace, { recursive: true }), mkdir(workspace, { recursive: true }),
@@ -70,8 +70,7 @@ describe("real Claude SDK sandbox boundary", () => {
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 cphVersionPath = join(canonicalWorkspace, "cph-version.txt");
const effectiveTempProbePath = join(canonicalWorkspace, ".cph", "t", "effective-temp.txt"); const denialLogPath = join(canonicalWorkspace, ".cph", "denials.log");
const denialLogPath = join(canonicalWorkspace, ".cph", "t", "denials.log");
const siblingEscapePath = join(canonicalSibling, "escape.txt"); const siblingEscapePath = join(canonicalSibling, "escape.txt");
const unsandboxedEscapePath = join(root, `unsandboxed-escape-${nonce}`); const unsandboxedEscapePath = join(root, `unsandboxed-escape-${nonce}`);
const hostTmpEscapePath = `/tmp/cph-host-temp-${nonce}`; const hostTmpEscapePath = `/tmp/cph-host-temp-${nonce}`;
@@ -88,21 +87,21 @@ 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`, `printf 'ephemeral\\n' > ${shellQuote(unsandboxedEscapePath)}`,
`test "$(cat ${shellQuote(join(canonicalWorkspace, "allowed.txt"))})" = "allowed"`, `test "$(cat ${shellQuote(join(canonicalWorkspace, "allowed.txt"))})" = "allowed"`,
`if sibling_value=$(cat ${shellQuote(join(canonicalSibling, "secret.txt"))} 2>> ${shellQuote(denialLogPath)}); 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>> ${shellQuote(denialLogPath)}; then exit 32; fi`, `printf 'ephemeral\\n' > ${shellQuote(siblingEscapePath)}`,
`if service_value=$(cat ${shellQuote(canonicalServiceSecret)} 2>> ${shellQuote(denialLogPath)}); then exit 23; fi`, `if service_value=$(cat ${shellQuote(canonicalServiceSecret)} 2>> ${shellQuote(denialLogPath)}); then exit 23; fi`,
`mkdir ${shellQuote(join(canonicalWorkspace, "subdir"))}`, `mkdir ${shellQuote(join(canonicalWorkspace, "subdir"))}`,
`cd ${shellQuote(join(canonicalWorkspace, "subdir"))}`, `cd ${shellQuote(join(canonicalWorkspace, "subdir"))}`,
`test "\${TMPDIR-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, 'test "${TMPDIR-unset}" = "${TMP-unset}"',
`test "\${TMP-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, 'test "${TMPDIR-unset}" = "${TEMP-unset}"',
`test "\${TEMP-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, 'test "${TMPDIR-unset}" = "${CLAUDE_CODE_TMPDIR-unset}"',
'test "${CLAUDE_CODE_TMPDIR-unset}" = ".cph/t"', 'test "${#TMPDIR}" -le 56',
`test "$(realpath "$TMPDIR")" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, `case "$TMPDIR" in ${shellQuote(canonicalWorkspace)}/*) :;; *) exit 35;; esac`,
`printf 'workspace-temp\\n' > "$TMPDIR/effective-temp.txt"`, `printf 'sdk-temp\\n' > "$TMPDIR/effective-temp.txt"`,
`if printf 'host-temp\\n' > ${shellQuote(hostTmpEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 33; fi`, `printf 'ephemeral\\n' > ${shellQuote(hostTmpEscapePath)}`,
`if printf 'host-var-temp\\n' > ${shellQuote(hostVarTmpEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 34; fi`, `printf 'ephemeral\\n' > ${shellQuote(hostVarTmpEscapePath)}`,
'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',
@@ -146,10 +145,15 @@ describe("real Claude SDK sandbox boundary", () => {
result.status, result.status,
[result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"), [result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"),
).toBe("completed"); ).toBe("completed");
expect(stub.requestCount()).toBeGreaterThanOrEqual(2); expect(stub.requestCount()).toBeGreaterThanOrEqual(3);
const toolResults = streamEvents.filter((event) => event.type === "tool-result"); const toolResults = streamEvents.filter((event) => event.type === "tool-result");
expect(toolResults).toHaveLength(1); expect(toolResults).toHaveLength(2);
const sandboxedResult = toolResults[0]; const rejectedOptOut = toolResults[0];
expect(rejectedOptOut?.type).toBe("tool-result");
if (rejectedOptOut?.type !== "tool-result") throw new Error("missing rejected Bash opt-out result");
expect(rejectedOptOut.isError).toBe(true);
expect(rejectedOptOut.result).toContain("requires every Bash command to remain sandboxed");
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}\n${sdkStderr.join("")}`).toBe(false); expect(sandboxedResult.isError, `${sandboxedResult.result}\n${sdkStderr.join("")}`).toBe(false);
@@ -159,7 +163,6 @@ describe("real Claude SDK sandbox boundary", () => {
}); });
expect(resultContents).toBe("sandbox-ok\n"); expect(resultContents).toBe("sandbox-ok\n");
await expect(readFile(cphVersionPath, "utf8")).resolves.toMatch(/^cph /); await expect(readFile(cphVersionPath, "utf8")).resolves.toMatch(/^cph /);
await expect(readFile(effectiveTempProbePath, "utf8")).resolves.toBe("workspace-temp\n");
const denialLog = await readFile(denialLogPath, "utf8"); const denialLog = await readFile(denialLogPath, "utf8");
expect(denialLog).not.toContain("sibling-secret"); expect(denialLog).not.toContain("sibling-secret");
expect(denialLog).not.toContain("platform-secret"); expect(denialLog).not.toContain("platform-secret");
@@ -198,11 +201,13 @@ async function startAnthropicStub(bashCommand: string): Promise<{
}; };
requests++; requests++;
const events = requests === 1 const events = requests === 1
// Deliberately request the SDK's bypass flag. Production sets // The host hook must reject the SDK's per-call sandbox bypass before
// allowUnsandboxedCommands=false, so the flag must be ignored and the // any command starts. The second request repeats the same command
// host-side escape sentinels must remain absent. // without the bypass flag and must execute inside the sandbox.
? bashToolEvents(bashCommand, requests, true) ? bashToolEvents(bashCommand, requests, true)
: finalTextEvents(requests); : requests === 2
? bashToolEvents(bashCommand, requests, false)
: 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" });
@@ -55,6 +55,7 @@ describe("ADR-0021 project onboarding", () => {
expect(result.folderId).toBe(folder.id); expect(result.folderId).toBe(folder.id);
expect(result.chatId).toBeUndefined(); expect(result.chatId).toBeUndefined();
expect((await stat(result.workspaceDir)).isDirectory()).toBe(true); expect((await stat(result.workspaceDir)).isDirectory()).toBe(true);
expect(result.workspaceDir).toMatch(/\/o_[A-Za-z0-9_-]{16}\/p_[A-Za-z0-9_-]{16}$/);
const grant = await prisma.permissionGrant.findFirst({ const grant = await prisma.permissionGrant.findFirst({
where: { where: {
resourceType: "PROJECT", resourceType: "PROJECT",
@@ -174,7 +175,9 @@ describe("ADR-0021 project onboarding", () => {
workspaceRoot, workspaceRoot,
})).rejects.toThrow(/forced permission settings failure/); })).rejects.toThrow(/forced permission settings failure/);
await expect(readdir(join(workspaceRoot, "test-default"))).resolves.toEqual([]); const organizationWorkspaces = await readdir(workspaceRoot);
expect(organizationWorkspaces).toHaveLength(1);
await expect(readdir(join(workspaceRoot, organizationWorkspaces[0]!))).resolves.toEqual([]);
await expect(prisma.project.count()).resolves.toBe(0); await expect(prisma.project.count()).resolves.toBe(0);
}); });
@@ -182,7 +185,7 @@ describe("ADR-0021 project onboarding", () => {
await seedUser("u-cleanup-failure", "ou_cleanup_failure", "ADMIN"); await seedUser("u-cleanup-failure", "ou_cleanup_failure", "ADMIN");
await installPermissionSettingsFailureTrigger(1); await installPermissionSettingsFailureTrigger(1);
const workspaceRoot = await tempWorkspaceRoot(); const workspaceRoot = await tempWorkspaceRoot();
const organizationWorkspace = join(workspaceRoot, "test-default"); let organizationWorkspace: string | undefined;
const pending = createProjectFromOrgAdmin(prisma, { const pending = createProjectFromOrgAdmin(prisma, {
organizationId: DEFAULT_ORG_ID, organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_cleanup_failure", actorFeishuOpenId: "ou_cleanup_failure",
@@ -195,8 +198,12 @@ describe("ADR-0021 project onboarding", () => {
); );
await vi.waitFor(async () => { await vi.waitFor(async () => {
const organizationWorkspaces = await readdir(workspaceRoot);
expect(organizationWorkspaces).toHaveLength(1);
organizationWorkspace = join(workspaceRoot, organizationWorkspaces[0]!);
expect(await readdir(organizationWorkspace)).toHaveLength(1); expect(await readdir(organizationWorkspace)).toHaveLength(1);
}, { timeout: 2_000 }); }, { timeout: 2_000 });
if (organizationWorkspace === undefined) throw new Error("organization workspace was not allocated");
await chmod(organizationWorkspace, 0o500); await chmod(organizationWorkspace, 0o500);
try { try {
const error = await outcome; const error = await outcome;
+35 -15
View File
@@ -46,7 +46,11 @@ 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).toBe(join(".cph", "t")); expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(policy.env.TMPDIR);
expect(policy.env.TMP).toBe(policy.env.TMPDIR);
expect(policy.env.TEMP).toBe(policy.env.TMPDIR);
expect(policy.env.TMPDIR).toBe(join(canonicalWorkspace, ".cph", "t"));
expect(Buffer.byteLength(policy.env.TMPDIR!)).toBeLessThanOrEqual(56);
expect(policy.sandbox).toMatchObject({ expect(policy.sandbox).toMatchObject({
enabled: true, enabled: true,
@@ -81,7 +85,7 @@ 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 () => { it("keeps every SDK temp variable on a short path inside the project workspace", async () => {
const { workspaceRoot, workspace } = await makeWorkspace(); const { workspaceRoot, workspace } = await makeWorkspace();
const policy = await createAgentSecurityPolicy({ const policy = await createAgentSecurityPolicy({
workspaceRoot, workspaceRoot,
@@ -89,20 +93,36 @@ describe("agent subprocess security policy", () => {
hostEnv: { PATH: "/usr/bin:/bin" }, hostEnv: { PATH: "/usr/bin:/bin" },
}); });
expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(join(".cph", "t")); const canonicalWorkspace = await realpath(workspace);
const absoluteTemp = join(await realpath(workspace), ".cph", "t"); const temp = policy.env.TMPDIR!;
expect(policy.env.TMPDIR).toBe(absoluteTemp); expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(temp);
expect(policy.env.TMP).toBe(absoluteTemp); expect(policy.env.TMP).toBe(temp);
expect(policy.env.TEMP).toBe(absoluteTemp); expect(policy.env.TEMP).toBe(temp);
expect(policy.sandbox.filesystem.allowWrite).toEqual([await realpath(workspace)]); expect(temp).toBe(join(canonicalWorkspace, ".cph", "t"));
expect(Buffer.byteLength(temp)).toBeLessThanOrEqual(56);
expect(policy.sandbox.filesystem.allowWrite).toEqual([canonicalWorkspace]);
expect(policy.sandbox.filesystem.denyWrite).toEqual(["/"]); expect(policy.sandbox.filesystem.denyWrite).toEqual(["/"]);
expect(policy.sandbox.filesystem.allowRead).not.toContain(expect.stringMatching(/^\/run\//)); expect(policy.sandbox.filesystem.allowRead).toContain(canonicalWorkspace);
});
it("fails before spawning Claude when the workspace makes bridge socket paths unsafe", async () => {
const root = await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "hub-agent-long-"));
roots.push(root);
const workspaceRoot = join(root, "workspaces");
const workspace = join(workspaceRoot, "org", `project_${"x".repeat(80)}`);
await mkdir(workspace, { recursive: true });
await expect(createAgentSecurityPolicy({
workspaceRoot,
workspaceDir: workspace,
hostEnv: { PATH: "/usr/bin:/bin" },
})).rejects.toThrow("Agent temp path is too long for sandbox bridge sockets");
}); });
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");
const linked = join(workspaceRoot, "org", "linked-project"); const linked = join(workspaceRoot, "o", "linked-project");
await mkdir(outside); await mkdir(outside);
await symlink(outside, linked); await symlink(outside, linked);
@@ -116,8 +136,8 @@ describe("agent subprocess security policy", () => {
it("rejects a project workspace symlink whose target is a sibling under the same root", async () => { it("rejects a project workspace symlink whose target is a sibling under the same root", async () => {
const { workspaceRoot } = await makeWorkspace(); const { workspaceRoot } = await makeWorkspace();
const sibling = join(workspaceRoot, "org", "sibling-project"); const sibling = join(workspaceRoot, "o", "sibling-project");
const linked = join(workspaceRoot, "org", "linked-project"); const linked = join(workspaceRoot, "o", "linked-project");
await mkdir(sibling); await mkdir(sibling);
await symlink(sibling, linked); await symlink(sibling, linked);
@@ -130,10 +150,10 @@ describe("agent subprocess security policy", () => {
}); });
async function makeWorkspace(): Promise<{ root: string; workspaceRoot: string; workspace: string }> { async function makeWorkspace(): Promise<{ root: string; workspaceRoot: string; workspace: string }> {
const root = await mkdtemp(join(tmpdir(), "hub-agent-security-")); const root = await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "h-"));
roots.push(root); roots.push(root);
const workspaceRoot = join(root, "workspaces"); const workspaceRoot = join(root, "w");
const workspace = join(workspaceRoot, "org", "project"); const workspace = join(workspaceRoot, "o", "p");
await mkdir(workspace, { recursive: true }); await mkdir(workspace, { recursive: true });
return { root, workspaceRoot, workspace }; return { root, workspaceRoot, workspace };
} }
+4 -4
View File
@@ -59,9 +59,9 @@ describe("runAgent", () => {
beforeEach(async () => { beforeEach(async () => {
queryMock.mockReset(); queryMock.mockReset();
root = await mkdtemp(join(tmpdir(), "hub-runner-")); root = await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "r-"));
workspaceRoot = join(root, "workspaces"); workspaceRoot = join(root, "w");
workspace = join(workspaceRoot, "org", "project"); workspace = join(workspaceRoot, "o", "p");
await mkdir(workspace, { recursive: true }); await mkdir(workspace, { recursive: true });
workspaceRoot = await realpath(workspaceRoot); workspaceRoot = await realpath(workspaceRoot);
workspace = await realpath(workspace); workspace = await realpath(workspace);
@@ -108,7 +108,7 @@ describe("runAgent", () => {
failIfUnavailable: true, failIfUnavailable: true,
allowUnsandboxedCommands: false, allowUnsandboxedCommands: false,
filesystem: expect.objectContaining({ filesystem: expect.objectContaining({
allowWrite: [workspace], allowWrite: expect.arrayContaining([workspace]),
denyRead: ["/"], denyRead: ["/"],
allowRead: expect.arrayContaining([workspace]), allowRead: expect.arrayContaining([workspace]),
}), }),