fix: confine Agent and MCP data access

This commit is contained in:
2026-07-10 16:38:08 +08:00
parent 73fa28a178
commit f4968d6657
25 changed files with 1521 additions and 221 deletions
@@ -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("'", `'"'"'`)}'`;
}
+7 -2
View File
@@ -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,
+17 -11
View File
@@ -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" },
+126
View File
@@ -0,0 +1,126 @@
import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createAgentSecurityPolicy } from "../../src/agent/security.js";
describe("agent subprocess security policy", () => {
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
it("passes only provider and safe runtime variables and protects provider credentials from tools", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
const policy = await createAgentSecurityPolicy({
workspaceRoot,
workspaceDir: workspace,
providerEnv: {
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "provider-secret",
ANTHROPIC_API_KEY: "",
},
hostEnv: {
PATH: "/usr/local/bin:/usr/bin:/bin",
LANG: "C.UTF-8",
CPH_BIN: "/usr/local/bin/cph",
DATABASE_URL: "postgresql://platform-secret",
FEISHU_APP_SECRET: "feishu-secret",
HUB_SESSION_SECRET: "session-secret",
},
});
const canonicalWorkspace = await realpath(workspace);
expect(policy.cwd).toBe(canonicalWorkspace);
expect(policy.env).toMatchObject({
PATH: "/usr/local/bin:/usr/bin:/bin",
LANG: "C.UTF-8",
CPH_BIN: "/usr/local/bin/cph",
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "provider-secret",
ANTHROPIC_API_KEY: "",
});
expect(policy.env).not.toHaveProperty("DATABASE_URL");
expect(policy.env).not.toHaveProperty("FEISHU_APP_SECRET");
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).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
expect(policy.sandbox).toMatchObject({
enabled: true,
failIfUnavailable: true,
autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false,
filesystem: {
allowWrite: [canonicalWorkspace],
denyRead: ["/"],
allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]),
},
credentials: {
envVars: expect.arrayContaining([
{ name: "ANTHROPIC_AUTH_TOKEN", mode: "deny" },
{ name: "ANTHROPIC_API_KEY", mode: "deny" },
]),
},
});
});
it("rejects provider environment keys outside the explicit protocol", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
await expect(createAgentSecurityPolicy({
workspaceRoot,
workspaceDir: workspace,
providerEnv: {
ANTHROPIC_AUTH_TOKEN: "provider-secret",
DATABASE_URL: "must-not-cross-boundary",
},
hostEnv: { PATH: "/usr/bin:/bin" },
})).rejects.toThrow("unsupported provider environment variable: DATABASE_URL");
});
it("rejects a project workspace whose real path escapes the configured workspace root", async () => {
const { root, workspaceRoot } = await makeWorkspace();
const outside = join(root, "outside");
const linked = join(workspaceRoot, "org", "linked-project");
await mkdir(outside);
await symlink(outside, linked);
await expect(createAgentSecurityPolicy({
workspaceRoot,
workspaceDir: linked,
providerEnv: { ANTHROPIC_AUTH_TOKEN: "provider-secret" },
hostEnv: { PATH: "/usr/bin:/bin" },
})).rejects.toThrow("project workspace contains a symlink");
});
it("rejects a project workspace symlink whose target is a sibling under the same root", async () => {
const { workspaceRoot } = await makeWorkspace();
const sibling = join(workspaceRoot, "org", "sibling-project");
const linked = join(workspaceRoot, "org", "linked-project");
await mkdir(sibling);
await symlink(sibling, linked);
await expect(createAgentSecurityPolicy({
workspaceRoot,
workspaceDir: linked,
providerEnv: { ANTHROPIC_AUTH_TOKEN: "provider-secret" },
hostEnv: { PATH: "/usr/bin:/bin" },
})).rejects.toThrow("symlink");
});
async function makeWorkspace(): Promise<{ root: string; workspaceRoot: string; workspace: string }> {
const root = await mkdtemp(join(tmpdir(), "hub-agent-security-"));
roots.push(root);
const workspaceRoot = join(root, "workspaces");
const workspace = join(workspaceRoot, "org", "project");
await mkdir(workspace, { recursive: true });
return { root, workspaceRoot, workspace };
}
});
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+7
View File
@@ -109,6 +109,13 @@ describe("Feishu approval cards", () => {
settings: {} as RuntimeSettings,
logger: silentLogger(),
authorizer: allowAllAuthorizer(),
projectWorkspaceRoot: "/tmp",
runAgent: async () => ({
status: "completed",
text: "",
usage: { inputTokens: 0, outputTokens: 0 },
numTurns: 0,
}),
});
const result = trigger.approvalManager.register("message-1", "chat-1");
+50 -9
View File
@@ -1,26 +1,31 @@
import { describe, expect, it, vi } from "vitest";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { access, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import {
downloadMessageFile,
FeishuFileDeliveryError,
resolveSenderName,
sendFile,
sendFileData,
sendLongText,
sendTextMessage,
type FeishuRuntime,
} from "../../src/feishu/client.js";
import { SenderNameCache } from "../../src/feishu/senderCache.js";
const itOnLinux = process.platform === "linux" ? it : it.skip;
describe("Feishu client helpers", () => {
it("downloads an image message resource through the SDK stream helper", async () => {
itOnLinux("downloads an image message resource through the SDK stream helper", async () => {
const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-"));
try {
const destination = join(dir, "image.png");
const resourceWriteFile = vi.fn(async (filePath: string) => {
await writeFile(filePath, "image bytes");
});
const messageResourceGet = vi.fn(async () => ({ writeFile: resourceWriteFile }));
const workspace = join(dir, "workspace");
await mkdir(workspace);
const destination = join(workspace, ".cph", "inbox", "image.png");
const getReadableStream = vi.fn(() => Readable.from([Buffer.from("image bytes")]));
const messageResourceGet = vi.fn(async () => ({ getReadableStream }));
const rawRequest = vi.fn(async () => Buffer.from("image bytes"));
const rt = mockRuntime({
fileCreate: vi.fn(),
@@ -29,13 +34,15 @@ describe("Feishu client helpers", () => {
request: rawRequest,
});
await downloadMessageFile(rt, "message-1", "img-key-1", destination, "image");
await expect(
downloadMessageFile(rt, "message-1", "img-key-1", dir, workspace, ".cph/inbox/image.png", "image"),
).resolves.toBe(join(await realpath(workspace), ".cph", "inbox", "image.png"));
expect(messageResourceGet).toHaveBeenCalledWith({
params: { type: "image" },
path: { message_id: "message-1", file_key: "img-key-1" },
});
expect(resourceWriteFile).toHaveBeenCalledWith(destination);
expect(getReadableStream).toHaveBeenCalledTimes(1);
await expect(readFile(destination, "utf8")).resolves.toBe("image bytes");
expect(rawRequest).not.toHaveBeenCalled();
} finally {
@@ -43,6 +50,27 @@ describe("Feishu client helpers", () => {
}
});
itOnLinux("refuses an inbound download through a symlinked workspace directory", async () => {
const root = await mkdtemp(join(tmpdir(), "hub-feishu-client-"));
try {
const workspace = join(root, "workspace");
const outside = join(root, "outside");
await Promise.all([mkdir(workspace), mkdir(outside)]);
await symlink(outside, join(workspace, ".cph"));
const messageResourceGet = vi.fn(async () => ({
getReadableStream: () => Readable.from([Buffer.from("must-not-escape")]),
}));
const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate: vi.fn(), messageResourceGet });
await expect(
downloadMessageFile(rt, "message-1", "file-key-1", root, workspace, ".cph/inbox/file.bin", "file"),
).rejects.toThrow(/workspace|symlink|directory/i);
await expect(access(join(outside, "inbox", "file.bin"))).rejects.toMatchObject({ code: "ENOENT" });
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("accepts top-level file_key from the Lark SDK file upload response", async () => {
const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-"));
try {
@@ -62,6 +90,19 @@ describe("Feishu client helpers", () => {
}
});
it("rejects malformed file upload responses instead of hiding the failure", async () => {
const rt = mockRuntime({
fileCreate: vi.fn(async () => ({ data: {} })),
messageCreate: vi.fn(),
});
await expect(sendFileData(rt, "chat-1", Buffer.from("bytes"), "student.pdf"))
.rejects.toEqual(expect.objectContaining({
name: "FeishuFileDeliveryError",
message: "Feishu file upload response is missing file_key",
} satisfies Partial<FeishuFileDeliveryError>));
});
it("accepts top-level message_id from message create responses", async () => {
const rt = mockRuntime({
fileCreate: vi.fn(),
+17 -11
View File
@@ -1,11 +1,14 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js";
import type { FeishuRuntime } from "../../src/feishu/client.js";
import { downloadFeishuMessageResource } from "../../src/feishu/download.js";
const itOnLinux = process.platform === "linux" ? it : it.skip;
describe("Feishu message resource download", () => {
it("exposes the download tool to default and explicitly configured roles", () => {
expect(cphHubMcpToolsForRole(undefined)).toContain("feishu_download_resource");
@@ -15,24 +18,27 @@ describe("Feishu message resource download", () => {
]);
});
it("downloads a bound-chat image into the project inbox", async () => {
const workspaceDir = await mkdtemp(join(tmpdir(), "hub-feishu-download-"));
itOnLinux("downloads a bound-chat image into the project inbox", async () => {
const workspaceRoot = await mkdtemp(join(tmpdir(), "hub-feishu-download-"));
const workspaceDir = join(workspaceRoot, "project");
await mkdir(workspaceDir);
try {
const messageGet = vi.fn(async () => ({ data: { items: [{ chat_id: "chat-1" }] } }));
const resourceWriteFile = vi.fn(async (filePath: string) => {
await writeFile(filePath, "image bytes");
});
const messageResourceGet = vi.fn(async () => ({ writeFile: resourceWriteFile }));
const messageResourceGet = vi.fn(async () => ({
getReadableStream: () => Readable.from([Buffer.from("image bytes")]),
}));
const rt = mockRuntime(messageGet, messageResourceGet);
const result = await downloadFeishuMessageResource(
{ messageId: "message-1", fileKey: "img-key-1", resourceType: "image" },
{ boundChatId: "chat-1", workspaceDir },
{ boundChatId: "chat-1", workspaceRoot, workspaceDir },
rt,
);
expect(result).toMatchObject({ resourceType: "image" });
expect(result.path).toMatch(new RegExp(`^${escapeRegExp(join(workspaceDir, ".cph", "inbox"))}`));
expect(result.path).toMatch(
new RegExp(`^${escapeRegExp(join(await realpath(workspaceDir), ".cph", "inbox"))}`),
);
expect(result.path).toMatch(/\.png$/);
await expect(readFile(result.path, "utf8")).resolves.toBe("image bytes");
expect(messageResourceGet).toHaveBeenCalledWith({
@@ -40,7 +46,7 @@ describe("Feishu message resource download", () => {
path: { message_id: "message-1", file_key: "img-key-1" },
});
} finally {
await rm(workspaceDir, { recursive: true, force: true });
await rm(workspaceRoot, { recursive: true, force: true });
}
});
@@ -51,7 +57,7 @@ describe("Feishu message resource download", () => {
await expect(downloadFeishuMessageResource(
{ messageId: "message-other", fileKey: "img-key-other", resourceType: "image" },
{ boundChatId: "chat-1", workspaceDir: "/tmp/project-1" },
{ boundChatId: "chat-1", workspaceRoot: "/tmp", workspaceDir: "/tmp/project-1" },
rt,
)).rejects.toThrow("current project's bound chat");
expect(messageResourceGet).not.toHaveBeenCalled();
+1
View File
@@ -102,6 +102,7 @@ async function triggerWithRunAgent(
settings: mockSettings(),
logger: rt.logger,
authorizer: allowAllAuthorizer(),
projectWorkspaceRoot: "/tmp",
runAgent,
messageBatcherOptions: { maxMessages: 1 },
});
+55
View File
@@ -17,6 +17,7 @@ describe("readFeishuContext", () => {
root_id: "m-root",
parent_id: "m-parent",
thread_id: "thread-1",
chat_id: "chat-1",
msg_type: "text",
body: { content: JSON.stringify({ text: "parent text" }) },
},
@@ -46,4 +47,58 @@ describe("readFeishuContext", () => {
thread_id: "thread-1",
});
});
it("refuses a single-message result from another chat", async () => {
const rt = runtimeWithMessages({
get: [{ message_id: "m-other", chat_id: "chat-other", body: { content: "secret" } }],
list: [],
});
const raw = await readFeishuContext(
{ chat_id: "chat-1", anchor: "trigger_message", id: "m-other" },
{ runId: "run-1", projectId: "proj-1", boundChatId: "chat-1", workspaceDir: "/tmp/proj-1" },
rt,
);
expect(JSON.parse(raw)).toMatchObject({ error: expect.stringContaining("bound chat") });
expect(raw).not.toContain("secret");
});
it("refuses an entire thread result when any item lacks the bound chat id", async () => {
const rt = runtimeWithMessages({
get: [],
list: [
{ message_id: "m-allowed", chat_id: "chat-1", body: { content: "allowed" } },
{ message_id: "m-unscoped", body: { content: "must-not-leak" } },
],
});
const raw = await readFeishuContext(
{ chat_id: "chat-1", anchor: "thread", id: "thread-1" },
{ runId: "run-1", projectId: "proj-1", boundChatId: "chat-1", workspaceDir: "/tmp/proj-1" },
rt,
);
expect(JSON.parse(raw)).toMatchObject({ error: expect.stringContaining("bound chat") });
expect(raw).not.toContain("must-not-leak");
});
});
function runtimeWithMessages(input: {
get: Array<Record<string, unknown>>;
list: Array<Record<string, unknown>>;
}): FeishuRuntime {
return {
client: {
im: {
v1: {
message: {
get: async () => ({ data: { items: input.get } }),
list: async () => ({ data: { items: input.list } }),
},
},
},
},
logger: { warn() {}, info() {}, error() {}, debug() {} },
} as unknown as FeishuRuntime;
}
+20 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vite
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { sendFile, withRetry, type FeishuRuntime } from "../../src/feishu/client.js";
import { sendFile, sendFileData, withRetry, type FeishuRuntime } from "../../src/feishu/client.js";
describe("withRetry", () => {
beforeEach(() => {
@@ -127,6 +127,25 @@ describe("sendFile retry", () => {
await rm(dir, { recursive: true, force: true });
}
});
it("sendFileData preserves an exhausted upload failure as its cause", async () => {
vi.useFakeTimers();
const cause = new Error("persistent upload failure");
const fileCreate = vi.fn(async () => { throw cause; });
const rt = mockRuntime({ fileCreate, messageCreate: vi.fn() });
const result = sendFileData(rt, "chat-1", Buffer.from("pdf bytes"), "student.pdf");
const assertion = expect(result).rejects.toMatchObject({
name: "FeishuFileDeliveryError",
cause,
});
await Promise.resolve();
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(2000);
await assertion;
expect(fileCreate).toHaveBeenCalledTimes(3);
});
});
async function waitForCalls(mock: Mock, expectedCalls: number): Promise<void> {
+28 -12
View File
@@ -1,36 +1,50 @@
import { describe, expect, it } from "vitest";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveDeliverableFile } from "../../src/feishu/fileDelivery.js";
const itOnLinux = process.platform === "linux" ? it : it.skip;
describe("file delivery path resolution", () => {
it("resolves a repo-root file from a project workspace only when explicitly named", async () => {
itOnLinux("never resolves a Git-root file outside the current project workspace", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
await writeFile(join(root, "README.md"), "# root readme\n");
await expect(resolveDeliverableFile("README.md", workspace)).resolves.toEqual({
path: join(root, "README.md"),
name: "README.md",
});
await expect(resolveDeliverableFile("README.md", join(root, "examples"), workspace)).resolves.toBeNull();
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("resolves a bare build filename against workspace/build", async () => {
itOnLinux("resolves a bare build filename against workspace/build", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
const pdf = join(workspace, "build", "student.pdf");
await writeFile(pdf, "pdf bytes");
await expect(resolveDeliverableFile("student.pdf", workspace)).resolves.toEqual({
path: pdf,
name: "student.pdf",
});
const resolved = await resolveDeliverableFile("student.pdf", join(root, "examples"), workspace);
expect(resolved).toMatchObject({ path: await realpath(pdf), name: "student.pdf" });
expect(resolved?.data.toString("utf8")).toBe("pdf bytes");
} finally {
await rm(root, { recursive: true, force: true });
}
});
itOnLinux("rejects a deliverable symlink even when its target exists", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
const outside = join(root, "outside.pdf");
await writeFile(outside, "outside secret");
await symlink(outside, join(workspace, "build", "student.pdf"));
await expect(
resolveDeliverableFile("student.pdf", join(root, "examples"), workspace),
).rejects.toThrow(/symlink|no-follow|directory/i);
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -42,7 +56,9 @@ describe("file delivery path resolution", () => {
const workspace = join(root, "examples", "TH-141");
await writeFile(join(workspace, "build", "student.pdf"), "pdf bytes");
await expect(resolveDeliverableFile("cph build 生成 PDF 给我", workspace)).resolves.toBeNull();
await expect(
resolveDeliverableFile("cph build 生成 PDF 给我", join(root, "examples"), workspace),
).resolves.toBeNull();
} finally {
await rm(root, { recursive: true, force: true });
}
+59 -17
View File
@@ -1,4 +1,7 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runAgent } from "../../src/agent/runner.js";
const queryMock = vi.hoisted(() => vi.fn());
@@ -49,8 +52,30 @@ function abortableMessages(ac: AbortController, ...items: unknown[]) {
}
describe("runAgent", () => {
beforeEach(() => {
let root: string;
let workspaceRoot: string;
let workspace: string;
let previousSecrets: Record<string, string | undefined>;
beforeEach(async () => {
queryMock.mockReset();
root = await mkdtemp(join(tmpdir(), "hub-runner-"));
workspaceRoot = join(root, "workspaces");
workspace = join(workspaceRoot, "org", "project");
await mkdir(workspace, { recursive: true });
workspaceRoot = await realpath(workspaceRoot);
workspace = await realpath(workspace);
previousSecrets = Object.fromEntries(
["DATABASE_URL", "FEISHU_APP_SECRET", "HUB_SESSION_SECRET"].map((name) => [name, process.env[name]]),
);
});
afterEach(async () => {
for (const [name, value] of Object.entries(previousSecrets)) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
await rm(root, { recursive: true, force: true });
});
it("passes the previous Claude SDK session id through resume", async () => {
@@ -59,7 +84,7 @@ describe("runAgent", () => {
const result = await runAgent({
prompt: "再发一次",
model: "anthropic/claude-sonnet-5",
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined,
resumeSessionId: "sdk-session-old",
runId: "run-1",
@@ -70,17 +95,22 @@ describe("runAgent", () => {
expect(queryMock).toHaveBeenCalledWith({
prompt: "再发一次",
options: expect.objectContaining({
cwd: "/tmp/ws",
cwd: workspace,
model: "anthropic/claude-sonnet-5",
resume: "sdk-session-old",
// ADR-0018: agent execution surface bounded by workspace.
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
settingSources: [],
strictMcpConfig: true,
sandbox: expect.objectContaining({
enabled: true,
failIfUnavailable: true,
allowUnsandboxedCommands: false,
filesystem: expect.objectContaining({
allowWrite: ["/tmp/ws"],
allowWrite: [workspace],
denyRead: ["/"],
allowRead: expect.arrayContaining([workspace]),
}),
}),
}),
@@ -93,7 +123,7 @@ describe("runAgent", () => {
await runAgent({
prompt: "你好",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined,
runId: "run-1",
sessionId: "hub-session-1",
@@ -110,7 +140,7 @@ describe("runAgent", () => {
await runAgent({
prompt: "审校一下",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined,
tools: ["read_file", "cph_check", "send_file"],
runId: "run-1",
@@ -132,7 +162,7 @@ describe("runAgent", () => {
await runAgent({
prompt: "只回答",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined,
tools: [],
runId: "run-1",
@@ -154,7 +184,7 @@ describe("runAgent", () => {
const result = await runAgent({
prompt: "算一下成本",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined,
runId: "run-1",
sessionId: "hub-session-1",
@@ -164,20 +194,21 @@ describe("runAgent", () => {
expect(result.costUsd).toBe(0.0042);
});
it("passes provider env per run without mutating process env", async () => {
delete process.env["CPH_TEST_PROVIDER_ENV_MUTATION"];
it("passes only provider and safe runtime env without mutating process env", async () => {
process.env["DATABASE_URL"] = "postgresql://platform-secret";
process.env["FEISHU_APP_SECRET"] = "feishu-secret";
process.env["HUB_SESSION_SECRET"] = "session-secret";
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
prompt: "你好",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined,
providerEnv: {
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "test-token",
ANTHROPIC_API_KEY: "",
CPH_TEST_PROVIDER_ENV_MUTATION: "per-run",
},
runId: "run-1",
sessionId: "hub-session-1",
@@ -191,11 +222,22 @@ describe("runAgent", () => {
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "test-token",
ANTHROPIC_API_KEY: "",
CPH_TEST_PROVIDER_ENV_MUTATION: "per-run",
},
sandbox: {
credentials: {
envVars: expect.arrayContaining([
{ name: "ANTHROPIC_AUTH_TOKEN", mode: "deny" },
{ name: "ANTHROPIC_API_KEY", mode: "deny" },
]),
},
},
},
});
expect(process.env["CPH_TEST_PROVIDER_ENV_MUTATION"]).toBeUndefined();
const env = (call as { options: { env: Record<string, string | undefined> } }).options.env;
expect(env).not.toHaveProperty("DATABASE_URL");
expect(env).not.toHaveProperty("FEISHU_APP_SECRET");
expect(env).not.toHaveProperty("HUB_SESSION_SECRET");
expect(process.env["DATABASE_URL"]).toBe("postgresql://platform-secret");
});
it("persists structured user and assistant messages best-effort", async () => {
@@ -209,7 +251,7 @@ describe("runAgent", () => {
await runAgent({
prompt: "你好",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined,
runId: "run-1",
sessionId: "hub-session-1",
@@ -241,7 +283,7 @@ describe("runAgent", () => {
const runPromise = runAgent({
prompt: "长任务",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined,
runId: "run-abort",
sessionId: "hub-session-abort",