forked from bai/curriculum-project-hub
fix: confine Agent and MCP data access
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { constants } from "node:fs";
|
||||
import { access, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { runAgent, type StreamEvent } from "../../src/agent/runner.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const originalEnv = new Map<string, string | undefined>();
|
||||
const itOnLinux = process.platform === "linux" ? it : it.skip;
|
||||
|
||||
describe("real Claude SDK sandbox boundary", () => {
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const [name, value] of originalEnv) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
originalEnv.clear();
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
itOnLinux("denies sibling workspace, service files, credentials, and unsandboxed Bash while cph still works", async () => {
|
||||
await access("/usr/bin/bwrap", constants.X_OK);
|
||||
const noNewPrivilegesStatus = await readFile("/proc/self/status", "utf8");
|
||||
expect(noNewPrivilegesStatus).toMatch(/^NoNewPrivs:\s+1$/m);
|
||||
|
||||
const { stdout: cphPathOutput } = await execFileAsync("/usr/bin/which", ["cph"]);
|
||||
const cphBin = cphPathOutput.trim();
|
||||
await access(cphBin, constants.X_OK);
|
||||
|
||||
// Keep CLAUDE_CODE_TMPDIR short enough for the SDK's AF_UNIX socket paths;
|
||||
// otherwise the SDK deliberately falls back to shared /tmp.
|
||||
const root = await mkdtemp("/tmp/cph-");
|
||||
roots.push(root);
|
||||
const workspaceRoot = join(root, "workspaces");
|
||||
const workspace = join(workspaceRoot, "org-a", "project-a");
|
||||
const sibling = join(workspaceRoot, "org-b", "project-b");
|
||||
const serviceSecret = join(root, "service-secrets", "platform.env");
|
||||
await Promise.all([
|
||||
mkdir(workspace, { recursive: true }),
|
||||
mkdir(sibling, { recursive: true }),
|
||||
mkdir(join(root, "service-secrets"), { recursive: true }),
|
||||
]);
|
||||
await Promise.all([
|
||||
writeFile(join(workspace, "allowed.txt"), "allowed\n"),
|
||||
writeFile(join(sibling, "secret.txt"), "sibling-secret\n"),
|
||||
writeFile(serviceSecret, "platform-secret\n"),
|
||||
]);
|
||||
// macOS tmpdir is reached through /var -> /private/var. Exercise the
|
||||
// sandbox with canonical paths, matching the canonical cwd returned by
|
||||
// createAgentSecurityPolicy rather than relying on a host symlink alias.
|
||||
const canonicalWorkspace = await realpath(workspace);
|
||||
const canonicalSibling = await realpath(sibling);
|
||||
const canonicalServiceSecret = await realpath(serviceSecret);
|
||||
const resultPath = join(canonicalWorkspace, "sandbox-result.txt");
|
||||
const siblingEscapePath = join(canonicalSibling, "escape.txt");
|
||||
const unsandboxedEscapePath = join(await realpath(root), "unsandboxed-escape.txt");
|
||||
|
||||
setHostEnv({
|
||||
CPH_BIN: cphBin,
|
||||
CPH_SANDBOX_EXTRA_DENY_READ: canonicalServiceSecret,
|
||||
DATABASE_URL: "postgresql://platform-secret",
|
||||
FEISHU_APP_SECRET: "feishu-secret",
|
||||
HUB_SESSION_SECRET: "session-secret",
|
||||
});
|
||||
|
||||
const bashCommand = [
|
||||
"set -eu",
|
||||
`test "$(cat ${shellQuote(join(canonicalWorkspace, "allowed.txt"))})" = "allowed"`,
|
||||
`if cat ${shellQuote(join(canonicalSibling, "secret.txt"))} >/dev/null 2>&1; then exit 21; fi`,
|
||||
`if printf 'escape\\n' > ${shellQuote(siblingEscapePath)} 2>/dev/null; then exit 22; fi`,
|
||||
`if cat ${shellQuote(canonicalServiceSecret)} >/dev/null 2>&1; then exit 23; fi`,
|
||||
'test "${DATABASE_URL-unset}" = unset',
|
||||
'test "${FEISHU_APP_SECRET-unset}" = unset',
|
||||
'test "${HUB_SESSION_SECRET-unset}" = unset',
|
||||
'test "${ANTHROPIC_AUTH_TOKEN-unset}" = unset',
|
||||
'test "${ANTHROPIC_API_KEY-unset}" = unset',
|
||||
"cph --version | grep '^cph '",
|
||||
`printf 'sandbox-ok\\n' > ${shellQuote(resultPath)}`,
|
||||
].join("\n");
|
||||
const bypassCommand = `printf 'unsafe\\n' > ${shellQuote(unsandboxedEscapePath)}`;
|
||||
const stub = await startAnthropicStub(bypassCommand, bashCommand);
|
||||
const streamEvents: StreamEvent[] = [];
|
||||
try {
|
||||
const result = await runAgent({
|
||||
prompt: "Run the supplied sandbox boundary probe.",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
project: {
|
||||
projectId: "project-a",
|
||||
boundChatId: "chat-a",
|
||||
workspaceRoot,
|
||||
workspaceDir: workspace,
|
||||
},
|
||||
systemPrompt: "Use the Bash tool exactly once, then report completion.",
|
||||
providerEnv: {
|
||||
ANTHROPIC_BASE_URL: stub.baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: "provider-secret",
|
||||
ANTHROPIC_API_KEY: "provider-api-secret",
|
||||
},
|
||||
tools: ["bash"],
|
||||
maxTurns: 3,
|
||||
runId: "sandbox-run",
|
||||
sessionId: "sandbox-session",
|
||||
prisma: {
|
||||
projectAgentLock: { update: async () => ({}) },
|
||||
agentMessage: { create: async () => ({}) },
|
||||
} as unknown as import("@prisma/client").PrismaClient,
|
||||
onStream: (event) => streamEvents.push(event),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ status: "completed" });
|
||||
expect(stub.requestCount()).toBeGreaterThanOrEqual(3);
|
||||
const toolResults = streamEvents.filter((event) => event.type === "tool-result");
|
||||
expect(toolResults).toHaveLength(2);
|
||||
expect(toolResults[0]).toMatchObject({ isError: true });
|
||||
const sandboxedResult = toolResults[1];
|
||||
expect(sandboxedResult?.type).toBe("tool-result");
|
||||
if (sandboxedResult?.type !== "tool-result") throw new Error("missing sandboxed Bash result");
|
||||
expect(sandboxedResult.isError, sandboxedResult.result).toBe(false);
|
||||
await expect(access(unsandboxedEscapePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(readFile(resultPath, "utf8")).resolves.toBe("sandbox-ok\n");
|
||||
await expect(access(siblingEscapePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(readFile(join(canonicalSibling, "secret.txt"), "utf8")).resolves.toBe("sibling-secret\n");
|
||||
await expect(readFile(canonicalServiceSecret, "utf8")).resolves.toBe("platform-secret\n");
|
||||
} finally {
|
||||
await stub.close();
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
function setHostEnv(values: Readonly<Record<string, string>>): void {
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
if (!originalEnv.has(name)) originalEnv.set(name, process.env[name]);
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
async function startAnthropicStub(bypassCommand: string, sandboxedCommand: string): Promise<{
|
||||
readonly baseUrl: string;
|
||||
readonly requestCount: () => number;
|
||||
readonly close: () => Promise<void>;
|
||||
}> {
|
||||
let requests = 0;
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
if (request.method !== "POST" || request.url?.startsWith("/v1/messages") !== true) {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
const body = JSON.parse(await requestBody(request)) as {
|
||||
readonly messages?: ReadonlyArray<{ readonly content?: unknown }>;
|
||||
};
|
||||
requests++;
|
||||
const events = requests === 1
|
||||
? bashToolEvents(bypassCommand, requests, true)
|
||||
: requests === 2
|
||||
? bashToolEvents(sandboxedCommand, requests, false)
|
||||
: finalTextEvents(requests);
|
||||
writeAnthropicStream(response, events);
|
||||
} catch (error) {
|
||||
response.writeHead(500, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
||||
}
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("listening", resolve);
|
||||
server.once("error", reject);
|
||||
});
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") throw new Error("Anthropic stub did not bind an INET port");
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
requestCount: () => requests,
|
||||
close: () => new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => error === undefined ? resolve() : reject(error));
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function bashToolEvents(command: string, sequence: number, disableSandbox: boolean): AnthropicEvent[] {
|
||||
const messageId = `msg_tool_${sequence}`;
|
||||
const toolUseId = `toolu_sandbox_probe_${sequence}`;
|
||||
return [
|
||||
messageStart(messageId),
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: toolUseId, name: "Bash", input: {} },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: {
|
||||
type: "input_json_delta",
|
||||
partial_json: JSON.stringify({ command, ...(disableSandbox ? { dangerouslyDisableSandbox: true } : {}) }),
|
||||
},
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
messageDelta("tool_use"),
|
||||
{ type: "message_stop" },
|
||||
];
|
||||
}
|
||||
|
||||
function finalTextEvents(sequence: number): AnthropicEvent[] {
|
||||
const messageId = `msg_final_${sequence}`;
|
||||
return [
|
||||
messageStart(messageId),
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "sandbox probe complete" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
messageDelta("end_turn"),
|
||||
{ type: "message_stop" },
|
||||
];
|
||||
}
|
||||
|
||||
type AnthropicEvent = Record<string, unknown> & { readonly type: string };
|
||||
|
||||
function messageStart(messageId: string): AnthropicEvent {
|
||||
return {
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: messageId,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, output_tokens: 0 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function messageDelta(stopReason: "tool_use" | "end_turn"): AnthropicEvent {
|
||||
return {
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: stopReason, stop_sequence: null },
|
||||
usage: { output_tokens: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
function writeAnthropicStream(response: ServerResponse, events: readonly AnthropicEvent[]): void {
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-cache",
|
||||
connection: "keep-alive",
|
||||
});
|
||||
for (const event of events) {
|
||||
response.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
response.end();
|
||||
}
|
||||
|
||||
async function requestBody(request: IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import "dotenv/config";
|
||||
import { runAgent } from "../../src/agent/runner.js";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
@@ -47,7 +47,12 @@ describeOrSkip("real model integration (OpenRouter)", () => {
|
||||
sessionId: "real-test-session",
|
||||
prompt: "请只回复: OK",
|
||||
model: MODEL,
|
||||
project: { projectId: "real-test-project", boundChatId: "chat-test", workspaceDir: workspace },
|
||||
project: {
|
||||
projectId: "real-test-project",
|
||||
boundChatId: "chat-test",
|
||||
workspaceRoot: dirname(workspace),
|
||||
workspaceDir: workspace,
|
||||
},
|
||||
prisma: stubPrisma,
|
||||
systemPrompt: "你是一个极简 smoke test 助手。请严格按用户要求回复。",
|
||||
maxTurns: 3,
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js";
|
||||
import { InMemoryModelRegistry } from "../../src/agent/models.js";
|
||||
import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
|
||||
import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
|
||||
import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
|
||||
import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js";
|
||||
import type { RunRequest, RunResult } from "../../src/agent/runner.js";
|
||||
import type { RuntimeSettings } from "../../src/settings/runtime.js";
|
||||
|
||||
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
|
||||
const itOnLinux = process.platform === "linux" ? it : it.skip;
|
||||
type TestRunner = (req: RunRequest) => Promise<RunResult>;
|
||||
const workspaceRoots: string[] = [];
|
||||
|
||||
type TestTriggerDeps = Omit<Parameters<typeof makeProductionTriggerHandler>[0], "projectWorkspaceRoot"> & {
|
||||
readonly projectWorkspaceRoot?: string;
|
||||
};
|
||||
|
||||
function makeTriggerHandler(deps: TestTriggerDeps): ReturnType<typeof makeProductionTriggerHandler> {
|
||||
return makeProductionTriggerHandler({ projectWorkspaceRoot: "/tmp", ...deps });
|
||||
}
|
||||
|
||||
function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
|
||||
return {
|
||||
async provider(providerId) {
|
||||
@@ -153,19 +163,15 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
expect(run.costSource).toBe("provider_reported");
|
||||
});
|
||||
|
||||
it("includes downloaded post image paths in the agent prompt", async () => {
|
||||
itOnLinux("includes downloaded post image paths in the agent prompt", async () => {
|
||||
const workspaceDir = await tempWorkspaceRoot();
|
||||
await seedProject("proj-post-image", "chat-post-image");
|
||||
await prisma.project.update({
|
||||
where: { id: "proj-post-image" },
|
||||
data: { workspaceDir },
|
||||
});
|
||||
let downloadedPath: string | undefined;
|
||||
const messageResourceGet = vi.fn(async () => ({
|
||||
writeFile: async (filePath: string) => {
|
||||
downloadedPath = filePath;
|
||||
await writeFile(filePath, "image bytes");
|
||||
},
|
||||
getReadableStream: () => Readable.from([Buffer.from("image bytes")]),
|
||||
}));
|
||||
const imV1 = (rt.client as unknown as {
|
||||
im: { v1: { messageResource?: { get: typeof messageResourceGet } } };
|
||||
@@ -192,6 +198,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
projectWorkspaceRoot: dirname(workspaceDir),
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
});
|
||||
|
||||
@@ -200,8 +207,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(runAgentCalls).toHaveLength(1);
|
||||
});
|
||||
expect(downloadedPath).toBeDefined();
|
||||
expect(runAgentCalls[0]?.prompt).toContain(downloadedPath);
|
||||
expect(runAgentCalls[0]?.prompt).toContain(join(await realpath(workspaceDir), ".cph", "inbox"));
|
||||
expect(messageResourceGet).toHaveBeenCalledWith({
|
||||
params: { type: "image" },
|
||||
path: { message_id: event.message.message_id, file_key: "img-key-1" },
|
||||
|
||||
Reference in New Issue
Block a user