Compare commits

...

5 Commits

20 changed files with 302 additions and 113 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.
+1 -1
View File
@@ -23,7 +23,7 @@ Bot Open ID 不需要管理员手工寻找。平台部署人员会使用 App ID/
- 获取消息内容,用于读取触发消息和线程上下文(`im:message:readonly` - 获取消息内容,用于读取触发消息和线程上下文(`im:message:readonly`
- 获取与上传图片或文件资源(`im:resource` - 获取与上传图片或文件资源(`im:resource`
- 添加、删除消息表情回复(`im:message.reactions:write_only` - 添加、删除消息表情回复(`im:message.reactions:write_only`
- 获取用户基本信息(`contact:user.base:readonly` - 获取用户基本信息(`contact:user.base:readonly``contact:user.basic_profile:readonly`
如果飞书 API 调试台提示某个上述操作缺少更细粒度权限,请把提示截图交给平台部署人员,不要直接勾选通讯录全量读取或其他超出清单的权限。 如果飞书 API 调试台提示某个上述操作缺少更细粒度权限,请把提示截图交给平台部署人员,不要直接勾选通讯录全量读取或其他超出清单的权限。
+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.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.4", "version": "0.0.7",
"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.7",
"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;
+20 -24
View File
@@ -1,4 +1,4 @@
import { extname, isAbsolute, join } from "node:path"; import { isAbsolute, join, relative, resolve, sep } from "node:path";
import { import {
WorkspaceFileBoundaryError, WorkspaceFileBoundaryError,
readWorkspaceFileNoFollow, readWorkspaceFileNoFollow,
@@ -10,39 +10,21 @@ export interface DeliverableFile {
readonly data: Buffer; readonly data: Buffer;
} }
const DELIVERABLE_EXTENSIONS = new Set([ const DELIVERABLE_CPH_DIRECTORY = "inbox";
".csv",
".doc",
".docx",
".gif",
".jpeg",
".jpg",
".md",
".pdf",
".png",
".ppt",
".pptx",
".svg",
".txt",
".typ",
".xls",
".xlsx",
".zip",
]);
export async function resolveDeliverableFile( export async function resolveDeliverableFile(
requestedPath: string, requestedPath: string,
workspaceRoot: string, workspaceRoot: string,
workspaceDir: string, workspaceDir: string,
maxBytes?: number,
): Promise<DeliverableFile | null> { ): Promise<DeliverableFile | null> {
const token = requestedPath.trim(); const token = requestedPath.trim();
if (token === "" || !DELIVERABLE_EXTENSIONS.has(extname(token).toLowerCase())) { if (token === "") return null;
return null;
}
for (const candidate of candidatePaths(token)) { for (const candidate of candidatePaths(token)) {
assertNotPlatformRuntimePath(candidate, workspaceDir);
try { try {
return await readWorkspaceFileNoFollow(workspaceRoot, workspaceDir, candidate); return await readWorkspaceFileNoFollow(workspaceRoot, workspaceDir, candidate, maxBytes);
} catch (error) { } catch (error) {
if (!(error instanceof WorkspaceFileBoundaryError) || error.reason !== "not_found") { if (!(error instanceof WorkspaceFileBoundaryError) || error.reason !== "not_found") {
throw error; throw error;
@@ -54,6 +36,20 @@ export async function resolveDeliverableFile(
return null; return null;
} }
function assertNotPlatformRuntimePath(candidate: string, workspaceDir: string): void {
const workspace = resolve(workspaceDir);
const target = isAbsolute(candidate) ? resolve(candidate) : resolve(workspace, candidate);
const rel = relative(workspace, target);
const components = rel.split(sep);
if (components[0] === ".cph" && components[1] !== DELIVERABLE_CPH_DIRECTORY) {
throw new WorkspaceFileBoundaryError(
`platform runtime files cannot be delivered: ${candidate}`,
candidate,
"boundary",
);
}
}
function candidatePaths(token: string): string[] { function candidatePaths(token: string): string[] {
if (isAbsolute(token)) return [token]; if (isAbsolute(token)) return [token];
const candidates = [token]; const candidates = [token];
+20 -5
View File
@@ -15,6 +15,7 @@ export interface FileDeliveryToolOptions {
readonly runId: string; readonly runId: string;
readonly workspaceRoot?: string | undefined; readonly workspaceRoot?: string | undefined;
readonly workspaceDir: string; readonly workspaceDir: string;
readonly maxFileBytes?: number | undefined;
readonly sendOptions?: SendMessageOptions | undefined; readonly sendOptions?: SendMessageOptions | undefined;
readonly approvalManager: ApprovalManager; readonly approvalManager: ApprovalManager;
readonly onDelivered?: (path: string) => void; readonly onDelivered?: (path: string) => void;
@@ -29,7 +30,7 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
tools.push( tools.push(
tool( tool(
"send_file", "send_file",
"Upload an existing file from the current project workspace to the current Feishu chat. The path must point to a concrete no-symlink file, for example build/student.pdf or README.md.", "Upload any existing regular file from the current project workspace to the current Feishu chat. The path must point to a concrete no-symlink file and must not be inside platform runtime directories.",
{ {
path: z.string().describe("Workspace-relative path, or an absolute path physically inside the current workspace."), path: z.string().describe("Workspace-relative path, or an absolute path physically inside the current workspace."),
name: z.string().optional().describe("Optional display filename. Defaults to the file's basename."), name: z.string().optional().describe("Optional display filename. Defaults to the file's basename."),
@@ -46,12 +47,18 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
content: [{ type: "text", text: "File delivery is unavailable because workspace isolation is not configured." }], content: [{ type: "text", text: "File delivery is unavailable because workspace isolation is not configured." }],
}; };
} }
if (options.maxFileBytes === undefined) {
throw new Error("Agent file delivery requires a configured maximum file size");
}
let file; let file;
try { try {
file = await resolveDeliverableFile(args.path, workspaceRoot, options.workspaceDir); file = await resolveDeliverableFile(args.path, workspaceRoot, options.workspaceDir, options.maxFileBytes);
} catch (error) { } catch (error) {
const boundaryViolation = error instanceof WorkspaceFileBoundaryError && error.reason === "boundary"; const boundaryViolation = error instanceof WorkspaceFileBoundaryError && error.reason === "boundary";
const log = boundaryViolation ? options.rt.logger.warn.bind(options.rt.logger) : options.rt.logger.error.bind(options.rt.logger); const limitViolation = error instanceof WorkspaceFileBoundaryError && error.reason === "limit";
const log = boundaryViolation || limitViolation
? options.rt.logger.warn.bind(options.rt.logger)
: options.rt.logger.error.bind(options.rt.logger);
log( log(
{ {
runId: options.runId, runId: options.runId,
@@ -61,12 +68,20 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
}, },
boundaryViolation boundaryViolation
? "Agent file delivery refused by workspace boundary" ? "Agent file delivery refused by workspace boundary"
: "Agent file delivery failed during workspace file access", : limitViolation
? "Agent file delivery refused by file size limit"
: "Agent file delivery failed during workspace file access",
); );
if (limitViolation) {
return {
isError: true,
content: [{ type: "text", text: `File exceeds the configured delivery limit: ${args.path}` }],
};
}
if (!boundaryViolation) throw error; if (!boundaryViolation) throw error;
return { return {
isError: true, isError: true,
content: [{ type: "text", text: "File path is outside the current workspace or uses a symlink." }], content: [{ type: "text", text: "File path is outside the current workspace, belongs to platform runtime, or uses a symlink." }],
}; };
} }
if (file === null) { if (file === null) {
+1
View File
@@ -444,6 +444,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
runId: run.id, runId: run.id,
workspaceRoot: projectWorkspaceRoot, workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir, workspaceDir: project.workspaceDir,
maxFileBytes: deps.resourceLimits?.maxBytesPerFile,
sendOptions, sendOptions,
approvalManager, approvalManager,
tools: cphHubMcpToolsForRole(roleTools), tools: cphHubMcpToolsForRole(roleTools),
+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 -1
View File
@@ -37,6 +37,7 @@ export async function readWorkspaceFileNoFollow(
workspaceRoot: string, workspaceRoot: string,
workspaceDir: string, workspaceDir: string,
requestedPath: string, requestedPath: string,
maxBytes?: number,
): Promise<WorkspaceFileSnapshot> { ): Promise<WorkspaceFileSnapshot> {
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir); const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
const components = fileComponents(workspace, requestedPath); const components = fileComponents(workspace, requestedPath);
@@ -53,8 +54,15 @@ export async function readWorkspaceFileNoFollow(
if (!metadata.isFile()) { if (!metadata.isFile()) {
throw new WorkspaceFileBoundaryError(`deliverable is not a regular file: ${requestedPath}`, requestedPath); throw new WorkspaceFileBoundaryError(`deliverable is not a regular file: ${requestedPath}`, requestedPath);
} }
if (maxBytes !== undefined && metadata.size > maxBytes) {
throw new WorkspaceFileBoundaryError(
`workspace file exceeds ${maxBytes} bytes: ${requestedPath}`,
requestedPath,
"limit",
);
}
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath); await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
const data = await file.readFile(); const data = await readFileSnapshot(file, maxBytes, requestedPath);
result = { path: join(workspace, ...components), name, data }; result = { path: join(workspace, ...components), name, data };
} catch (error) { } catch (error) {
failure = boundaryError(error, requestedPath, "cannot read workspace file without following symlinks"); failure = boundaryError(error, requestedPath, "cannot read workspace file without following symlinks");
@@ -67,6 +75,33 @@ export async function readWorkspaceFileNoFollow(
return result!; return result!;
} }
async function readFileSnapshot(
file: FileHandle,
maxBytes: number | undefined,
requestedPath: string,
): Promise<Buffer> {
if (maxBytes === undefined) return file.readFile();
const chunks: Buffer[] = [];
let total = 0;
let position = 0;
while (true) {
const remainingWithSentinel = maxBytes - total + 1;
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, remainingWithSentinel));
const { bytesRead } = await file.read(chunk, 0, chunk.length, position);
if (bytesRead === 0) return Buffer.concat(chunks, total);
total += bytesRead;
if (total > maxBytes) {
throw new WorkspaceFileBoundaryError(
`workspace file exceeds ${maxBytes} bytes: ${requestedPath}`,
requestedPath,
"limit",
);
}
chunks.push(chunk.subarray(0, bytesRead));
position += bytesRead;
}
}
/** /**
* Create one inbound file exclusively below the workspace and stream into its * Create one inbound file exclusively below the workspace and stream into its
* already-open descriptor. Linux uses /proc/self/fd-relative traversal so a * already-open descriptor. Linux uses /proc/self/fd-relative traversal so a
@@ -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 };
} }
+60 -1
View File
@@ -34,6 +34,65 @@ describe("file delivery path resolution", () => {
} }
}); });
itOnLinux("accepts arbitrary extensions and extensionless workspace files", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
await writeFile(join(workspace, "lesson.json"), "{\"ok\":true}\n");
await writeFile(join(workspace, "Makefile"), "all:\n\t@true\n");
await expect(resolveDeliverableFile("lesson.json", join(root, "examples"), workspace))
.resolves.toMatchObject({ name: "lesson.json" });
await expect(resolveDeliverableFile("Makefile", join(root, "examples"), workspace))
.resolves.toMatchObject({ name: "Makefile" });
} finally {
await rm(root, { recursive: true, force: true });
}
});
itOnLinux("allows the Feishu inbox but refuses other platform runtime files", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
await mkdir(join(workspace, ".cph", "agent-runtime"), { recursive: true });
await mkdir(join(workspace, ".cph", "inbox"), { recursive: true });
await writeFile(join(workspace, ".cph", "agent-runtime", "session.jsonl"), "internal\n");
await writeFile(join(workspace, ".cph", "denials.log"), "internal\n");
await writeFile(join(workspace, ".cph", "inbox", "source.bin"), "source\n");
await expect(
resolveDeliverableFile(".cph/inbox/source.bin", join(root, "examples"), workspace),
).resolves.toMatchObject({ name: "source.bin" });
await expect(
resolveDeliverableFile(".cph/agent-runtime/session.jsonl", join(root, "examples"), workspace),
).rejects.toMatchObject({ reason: "boundary" });
await expect(
resolveDeliverableFile(".cph/denials.log", join(root, "examples"), workspace),
).rejects.toMatchObject({ reason: "boundary" });
} finally {
await rm(root, { recursive: true, force: true });
}
});
itOnLinux("refuses a file larger than the configured delivery limit", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
await writeFile(join(workspace, "exact.bin"), Buffer.alloc(10));
await writeFile(join(workspace, "artifact.bin"), Buffer.alloc(11));
await expect(
resolveDeliverableFile("exact.bin", join(root, "examples"), workspace, 10),
).resolves.toMatchObject({ name: "exact.bin", data: Buffer.alloc(10) });
await expect(
resolveDeliverableFile("artifact.bin", join(root, "examples"), workspace, 10),
).rejects.toMatchObject({ reason: "limit" });
} finally {
await rm(root, { recursive: true, force: true });
}
});
itOnLinux("rejects a deliverable symlink even when its target exists", async () => { itOnLinux("rejects a deliverable symlink even when its target exists", async () => {
const root = await makeRepo(); const root = await makeRepo();
try { try {
@@ -50,7 +109,7 @@ describe("file delivery path resolution", () => {
} }
}); });
it("does not infer files from natural-language prompts", async () => { itOnLinux("does not infer files from natural-language prompts", async () => {
const root = await makeRepo(); const root = await makeRepo();
try { try {
const workspace = join(root, "examples", "TH-141"); const workspace = join(root, "examples", "TH-141");
+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]),
}), }),