forked from bai/curriculum-project-hub
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 035c264179 |
@@ -99,16 +99,17 @@ 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]` 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.
|
||||
- `sandbox.filesystem.allowWrite` confines deliverable writes to the canonical
|
||||
ADR-0007 workspace and opens one service-owned SDK scratch directory under
|
||||
`/tmp/cph-agent-<uid>/<project-hash>`. Config/cache/home stay beneath
|
||||
`.cph/agent-runtime/`; `TMPDIR`, `TMP`, `TEMP`, and `CLAUDE_CODE_TMPDIR` all
|
||||
point at the project-keyed scratch directory. Its canonical prefix is capped
|
||||
at 64 bytes so the SDK can append randomized `socat` bridge socket names
|
||||
without exceeding Linux `sockaddr_un.sun_path`. The per-Organization OS UID,
|
||||
project hash, mode `0700`, symlink rejection, and exact sandbox allowlist keep
|
||||
that scratch isolated; sibling scratch paths and ordinary `/tmp` remain
|
||||
denied. Root is denied for writes and only the workspace plus that exact
|
||||
internal scratch directory are re-opened.
|
||||
- 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.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
+34
-11
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
@@ -26,6 +27,11 @@ const SANDBOX_HIDDEN_ENV_KEYS = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
] 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 = 64;
|
||||
|
||||
export interface AgentSecurityInput {
|
||||
readonly workspaceRoot: string;
|
||||
readonly workspaceDir: string;
|
||||
@@ -72,10 +78,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
||||
const agentCache = await ensureDirectoryTree(runtimeRoot, ["cache"]);
|
||||
const agentConfig = await ensureDirectoryTree(runtimeRoot, ["config"]);
|
||||
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 claudeCodeTmp = join(".cph", "t");
|
||||
const agentTmp = await resolveShortAgentTemp(workspaceDir);
|
||||
|
||||
const path = hostEnv["PATH"]?.trim();
|
||||
if (path === undefined || path === "") {
|
||||
@@ -91,14 +94,13 @@ 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.
|
||||
// All four variables must use the short path. Claude's sandbox bridge uses
|
||||
// the ordinary temp variables, while other SDK paths use CLAUDE_CODE_TMPDIR.
|
||||
env.TMPDIR = agentTmp;
|
||||
env.TMP = agentTmp;
|
||||
env.TEMP = agentTmp;
|
||||
env.CLAUDE_CONFIG_DIR = agentConfig;
|
||||
env.CLAUDE_CODE_TMPDIR = claudeCodeTmp;
|
||||
env.CLAUDE_CODE_TMPDIR = agentTmp;
|
||||
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
||||
env.DISABLE_TELEMETRY = "1";
|
||||
env.DO_NOT_TRACK = "1";
|
||||
@@ -123,7 +125,10 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
||||
autoAllowBashIfSandboxed: true,
|
||||
allowUnsandboxedCommands: false,
|
||||
filesystem: {
|
||||
allowWrite: [workspaceDir],
|
||||
// The temp directory is service-owned, project-keyed, mode 0700, and
|
||||
// contains only SDK sockets/scratch. User deliverables remain confined
|
||||
// to workspaceDir.
|
||||
allowWrite: [workspaceDir, agentTmp],
|
||||
// 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.
|
||||
@@ -132,7 +137,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
||||
// workspace plus the named system runtime needed to execute tools.
|
||||
// SDK allowRead takes precedence over matching denyRead paths.
|
||||
denyRead: ["/"],
|
||||
allowRead: [workspaceDir, ...runtimeReadPaths],
|
||||
allowRead: [workspaceDir, agentTmp, ...runtimeReadPaths],
|
||||
},
|
||||
credentials: {
|
||||
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
|
||||
@@ -142,6 +147,24 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveShortAgentTemp(workspaceDir: string): Promise<string> {
|
||||
// Do not use os.tmpdir() on macOS: its per-user path can itself exceed the
|
||||
// Unix socket budget. /tmp canonicalizes to /private/tmp there and /tmp on
|
||||
// Linux. The UID boundary prevents Organizations' service accounts sharing
|
||||
// a writable runtime root; the workspace hash separates Projects.
|
||||
const systemTmp = await realpath(process.platform === "win32" ? homedir() : "/tmp");
|
||||
const uid = process.getuid?.() ?? "user";
|
||||
const workspaceKey = createHash("sha256").update(workspaceDir).digest("hex").slice(0, 12);
|
||||
const agentTmp = await ensureDirectoryTree(systemTmp, [`cph-agent-${uid}`, workspaceKey]);
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
return agentTmp;
|
||||
}
|
||||
|
||||
function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
|
||||
const platformPaths = process.platform === "darwin"
|
||||
? [
|
||||
@@ -282,7 +305,7 @@ async function ensureDirectoryTree(root: string, components: readonly string[]):
|
||||
}
|
||||
const canonical = await realpath(current);
|
||||
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);
|
||||
return canonical;
|
||||
|
||||
@@ -70,8 +70,7 @@ describe("real Claude SDK sandbox boundary", () => {
|
||||
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 denialLogPath = join(canonicalWorkspace, ".cph", "denials.log");
|
||||
const siblingEscapePath = join(canonicalSibling, "escape.txt");
|
||||
const unsandboxedEscapePath = join(root, `unsandboxed-escape-${nonce}`);
|
||||
const hostTmpEscapePath = `/tmp/cph-host-temp-${nonce}`;
|
||||
@@ -95,12 +94,12 @@ describe("real Claude SDK sandbox boundary", () => {
|
||||
`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"`,
|
||||
'test "${TMPDIR-unset}" = "${TMP-unset}"',
|
||||
'test "${TMPDIR-unset}" = "${TEMP-unset}"',
|
||||
'test "${TMPDIR-unset}" = "${CLAUDE_CODE_TMPDIR-unset}"',
|
||||
'test "${#TMPDIR}" -le 64',
|
||||
`case "$TMPDIR" in ${shellQuote(canonicalWorkspace)}/*) exit 35;; esac`,
|
||||
`printf 'sdk-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',
|
||||
@@ -159,7 +158,6 @@ describe("real Claude SDK sandbox boundary", () => {
|
||||
});
|
||||
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");
|
||||
|
||||
@@ -46,7 +46,11 @@ 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).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).not.toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
|
||||
expect(Buffer.byteLength(policy.env.TMPDIR!)).toBeLessThanOrEqual(64);
|
||||
|
||||
expect(policy.sandbox).toMatchObject({
|
||||
enabled: true,
|
||||
@@ -54,7 +58,7 @@ describe("agent subprocess security policy", () => {
|
||||
autoAllowBashIfSandboxed: true,
|
||||
allowUnsandboxedCommands: false,
|
||||
filesystem: {
|
||||
allowWrite: [canonicalWorkspace],
|
||||
allowWrite: expect.arrayContaining([canonicalWorkspace, policy.env.TMPDIR]),
|
||||
denyRead: ["/"],
|
||||
allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]),
|
||||
},
|
||||
@@ -81,7 +85,7 @@ 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 () => {
|
||||
it("keeps every SDK temp variable on a short isolated path outside a long project workspace", async () => {
|
||||
const { workspaceRoot, workspace } = await makeWorkspace();
|
||||
const policy = await createAgentSecurityPolicy({
|
||||
workspaceRoot,
|
||||
@@ -89,14 +93,16 @@ describe("agent subprocess security policy", () => {
|
||||
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)]);
|
||||
const canonicalWorkspace = await realpath(workspace);
|
||||
const temp = policy.env.TMPDIR!;
|
||||
expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(temp);
|
||||
expect(policy.env.TMP).toBe(temp);
|
||||
expect(policy.env.TEMP).toBe(temp);
|
||||
expect(temp).not.toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
|
||||
expect(Buffer.byteLength(temp)).toBeLessThanOrEqual(64);
|
||||
expect(policy.sandbox.filesystem.allowWrite).toEqual([canonicalWorkspace, temp]);
|
||||
expect(policy.sandbox.filesystem.denyWrite).toEqual(["/"]);
|
||||
expect(policy.sandbox.filesystem.allowRead).not.toContain(expect.stringMatching(/^\/run\//));
|
||||
expect(policy.sandbox.filesystem.allowRead).toContain(temp);
|
||||
});
|
||||
|
||||
it("rejects a project workspace whose real path escapes the configured workspace root", async () => {
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("runAgent", () => {
|
||||
failIfUnavailable: true,
|
||||
allowUnsandboxedCommands: false,
|
||||
filesystem: expect.objectContaining({
|
||||
allowWrite: [workspace],
|
||||
allowWrite: expect.arrayContaining([workspace]),
|
||||
denyRead: ["/"],
|
||||
allowRead: expect.arrayContaining([workspace]),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user