forked from bai/curriculum-project-hub
fix: confine Agent and MCP data access
This commit is contained in:
+28
-46
@@ -20,21 +20,23 @@
|
||||
* ADR-0018: the agent execution surface is bounded by the run's workspace.
|
||||
* The SDK sandbox (bubblewrap on Linux, seatbelt on macOS) confines the Claude
|
||||
* Code process and its subprocesses at the OS level — writes are confined to
|
||||
* the workspace (`sandbox.filesystem.allowWrite`), sensitive host paths are
|
||||
* denied reads (`denyRead`), and `failIfUnavailable` hard-fails if the sandbox
|
||||
* can't start (never run unsandboxed). This upholds `AgentFileOp.Authorized`
|
||||
* the workspace, host reads are denied by default and re-opened only for the
|
||||
* workspace plus named system runtimes, and `failIfUnavailable` hard-fails if
|
||||
* the sandbox can't start. The subprocess gets a minimal environment and SDK
|
||||
* credential protection removes provider secrets from Bash. This upholds `AgentFileOp.Authorized`
|
||||
* (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the
|
||||
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
|
||||
* is the mechanism, the contract pins the invariant.
|
||||
*/
|
||||
import { homedir } from "node:os";
|
||||
import { query, 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 { claudeSdkToolConfigForRole } from "./roleTools.js";
|
||||
import { createAgentSecurityPolicy } from "./security.js";
|
||||
|
||||
export interface ProjectContext {
|
||||
readonly projectId: string;
|
||||
readonly boundChatId: string;
|
||||
readonly workspaceRoot?: string | undefined;
|
||||
readonly workspaceDir: string;
|
||||
}
|
||||
|
||||
@@ -90,33 +92,6 @@ export interface RunResult {
|
||||
|
||||
const DEFAULT_MAX_TURNS = 25;
|
||||
|
||||
/**
|
||||
* Host paths the agent may not read (ADR-0018 defense-in-depth). The sandbox
|
||||
* confines writes to the workspace; these deny reads of credentials/secrets
|
||||
* the process could otherwise see (read-only mounts are still readable). The
|
||||
* workspace itself is never in this list — it's writable by `allowWrite`.
|
||||
* Extend via `CPH_SANDBOX_EXTRA_DENY_READ` (colon-separated) for deployments
|
||||
* with additional secret locations.
|
||||
*/
|
||||
const SENSITIVE_READ_PATHS: readonly string[] = (() => {
|
||||
const home = homedir();
|
||||
const base = [
|
||||
"/etc",
|
||||
"/root",
|
||||
"/var/log",
|
||||
"/proc",
|
||||
"/sys",
|
||||
`${home}/.ssh`,
|
||||
`${home}/.aws`,
|
||||
`${home}/.config/gcloud`,
|
||||
`${home}/.gnupg`,
|
||||
];
|
||||
const extra = process.env["CPH_SANDBOX_EXTRA_DENY_READ"];
|
||||
return extra !== undefined && extra !== ""
|
||||
? [...base, ...extra.split(":").filter((p) => p !== "")]
|
||||
: base;
|
||||
})();
|
||||
|
||||
export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
const onStream = req.onStream;
|
||||
const cap = req.maxTurns ?? DEFAULT_MAX_TURNS;
|
||||
@@ -140,9 +115,19 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
try {
|
||||
await persistAgentMessage(req, "user", req.prompt);
|
||||
const toolConfig = claudeSdkToolConfigForRole(req.tools);
|
||||
const workspaceRoot = req.project.workspaceRoot?.trim();
|
||||
if (workspaceRoot === undefined || workspaceRoot === "") {
|
||||
throw new Error("Agent run requires the configured workspace root");
|
||||
}
|
||||
const security = await createAgentSecurityPolicy({
|
||||
workspaceRoot,
|
||||
workspaceDir: req.project.workspaceDir,
|
||||
providerEnv: req.providerEnv,
|
||||
});
|
||||
|
||||
const options: Parameters<typeof query>[0]["options"] = {
|
||||
cwd: req.project.workspaceDir,
|
||||
type QueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>;
|
||||
const options: QueryOptions = {
|
||||
cwd: security.cwd,
|
||||
tools: [...toolConfig.tools],
|
||||
allowedTools: [...toolConfig.allowedTools],
|
||||
maxTurns: cap,
|
||||
@@ -152,22 +137,19 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
permissionMode: "bypassPermissions" as const,
|
||||
allowDangerouslySkipPermissions: true,
|
||||
// ADR-0018: OS-level sandbox confines the agent process + its Bash
|
||||
// subprocesses. Writes confined to the workspace; sensitive host paths
|
||||
// denied reads; network open. failIfUnavailable hard-fails if the
|
||||
// sandbox can't start — never run unsandboxed.
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
failIfUnavailable: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
filesystem: {
|
||||
allowWrite: [req.project.workspaceDir],
|
||||
denyRead: [...SENSITIVE_READ_PATHS],
|
||||
},
|
||||
},
|
||||
// subprocesses. Writes and ordinary reads are confined to the workspace;
|
||||
// named system runtime files remain readable so cph can execute. Network
|
||||
// stays open. failIfUnavailable and allowUnsandboxedCommands=false make
|
||||
// sandbox loss a hard failure.
|
||||
sandbox: { ...security.sandbox },
|
||||
env: security.env,
|
||||
// The project workspace is untrusted input. Do not load user/project
|
||||
// settings that could widen tools, hooks, MCP servers, or sandbox paths.
|
||||
settingSources: [],
|
||||
strictMcpConfig: true,
|
||||
};
|
||||
if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
|
||||
if (req.model !== undefined) options.model = req.model;
|
||||
if (req.providerEnv !== undefined) options.env = { ...process.env, ...req.providerEnv };
|
||||
if (req.resumeSessionId !== undefined) options.resume = req.resumeSessionId;
|
||||
if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers;
|
||||
if (req.abortController !== undefined) options.abortController = req.abortController;
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
|
||||
const PROVIDER_ENV_KEYS = new Set([
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
]);
|
||||
|
||||
const SAFE_HOST_ENV_KEYS = [
|
||||
"PATH",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"TZ",
|
||||
"TERM",
|
||||
"USER",
|
||||
"LOGNAME",
|
||||
"SHELL",
|
||||
"CPH_BIN",
|
||||
] as const;
|
||||
|
||||
const PROVIDER_SECRET_ENV_KEYS = ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] as const;
|
||||
|
||||
export interface AgentSecurityInput {
|
||||
readonly workspaceRoot: string;
|
||||
readonly workspaceDir: string;
|
||||
readonly providerEnv?: Readonly<Record<string, string | undefined>> | undefined;
|
||||
readonly hostEnv?: Readonly<Record<string, string | undefined>> | undefined;
|
||||
}
|
||||
|
||||
export interface AgentSandboxPolicy {
|
||||
readonly enabled: true;
|
||||
readonly failIfUnavailable: true;
|
||||
readonly autoAllowBashIfSandboxed: true;
|
||||
readonly allowUnsandboxedCommands: false;
|
||||
readonly filesystem: {
|
||||
readonly allowWrite: string[];
|
||||
readonly denyRead: string[];
|
||||
readonly allowRead: string[];
|
||||
};
|
||||
readonly credentials: {
|
||||
readonly files: Array<{ readonly path: string; readonly mode: "deny" }>;
|
||||
readonly envVars: Array<{ readonly name: string; readonly mode: "deny" }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentSecurityPolicy {
|
||||
readonly cwd: string;
|
||||
readonly workspaceRoot: string;
|
||||
readonly env: Record<string, string | undefined>;
|
||||
readonly sandbox: AgentSandboxPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the complete Claude subprocess boundary from trusted run context.
|
||||
* This function intentionally replaces, rather than extends, process.env.
|
||||
*/
|
||||
export async function createAgentSecurityPolicy(input: AgentSecurityInput): Promise<AgentSecurityPolicy> {
|
||||
const hostEnv = input.hostEnv ?? process.env;
|
||||
const { workspaceRoot, workspaceDir } = await resolveProjectWorkspace(input.workspaceRoot, input.workspaceDir);
|
||||
|
||||
const runtimeRoot = await ensureDirectoryTree(workspaceDir, [".cph", "agent-runtime"]);
|
||||
const agentHome = await ensureDirectoryTree(runtimeRoot, ["home"]);
|
||||
const agentCache = await ensureDirectoryTree(runtimeRoot, ["cache"]);
|
||||
const agentConfig = await ensureDirectoryTree(runtimeRoot, ["config"]);
|
||||
const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]);
|
||||
const agentTmp = await ensureDirectoryTree(runtimeRoot, ["tmp"]);
|
||||
|
||||
const path = hostEnv["PATH"]?.trim();
|
||||
if (path === undefined || path === "") {
|
||||
throw new Error("PATH is required for the Agent subprocess");
|
||||
}
|
||||
|
||||
const env: Record<string, string | undefined> = {};
|
||||
for (const name of SAFE_HOST_ENV_KEYS) {
|
||||
const value = hostEnv[name];
|
||||
if (value !== undefined) env[name] = value;
|
||||
}
|
||||
env.HOME = agentHome;
|
||||
env.XDG_CACHE_HOME = agentCache;
|
||||
env.XDG_CONFIG_HOME = agentConfig;
|
||||
env.XDG_STATE_HOME = agentState;
|
||||
env.TMPDIR = agentTmp;
|
||||
env.TMP = agentTmp;
|
||||
env.TEMP = agentTmp;
|
||||
env.CLAUDE_CONFIG_DIR = agentConfig;
|
||||
env.CLAUDE_CODE_TMPDIR = agentTmp;
|
||||
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
||||
env.DISABLE_TELEMETRY = "1";
|
||||
env.DO_NOT_TRACK = "1";
|
||||
env.CLAUDE_AGENT_SDK_CLIENT_APP = "cph-hub/0.0.0";
|
||||
|
||||
for (const [name, value] of Object.entries(input.providerEnv ?? {})) {
|
||||
if (!PROVIDER_ENV_KEYS.has(name)) {
|
||||
throw new Error(`unsupported provider environment variable: ${name}`);
|
||||
}
|
||||
env[name] = value;
|
||||
}
|
||||
|
||||
const sensitiveReadPaths = hostSensitiveReadPaths(hostEnv);
|
||||
const runtimeReadPaths = hostRuntimeReadPaths(hostEnv);
|
||||
return {
|
||||
cwd: workspaceDir,
|
||||
workspaceRoot,
|
||||
env,
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
failIfUnavailable: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
allowUnsandboxedCommands: false,
|
||||
filesystem: {
|
||||
allowWrite: [workspaceDir],
|
||||
// Deny host reads by default, then re-open exactly this run's
|
||||
// workspace plus the named system runtime needed to execute tools.
|
||||
// SDK allowRead takes precedence over matching denyRead paths.
|
||||
denyRead: ["/"],
|
||||
allowRead: [workspaceDir, ...runtimeReadPaths],
|
||||
},
|
||||
credentials: {
|
||||
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
|
||||
envVars: PROVIDER_SECRET_ENV_KEYS.map((name) => ({ name, mode: "deny" as const })),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
|
||||
const platformPaths = process.platform === "darwin"
|
||||
? [
|
||||
"/bin",
|
||||
"/usr/bin",
|
||||
"/usr/lib",
|
||||
"/usr/share",
|
||||
"/System/Library",
|
||||
"/Library/Fonts",
|
||||
"/private/etc/hosts",
|
||||
"/private/etc/resolv.conf",
|
||||
"/private/etc/ssl",
|
||||
"/dev/null",
|
||||
"/dev/random",
|
||||
"/dev/urandom",
|
||||
]
|
||||
: [
|
||||
"/bin",
|
||||
"/usr/bin",
|
||||
"/usr/local/bin",
|
||||
"/lib",
|
||||
"/lib64",
|
||||
"/usr/lib",
|
||||
"/usr/lib64",
|
||||
"/usr/share",
|
||||
"/etc/fonts",
|
||||
"/etc/hosts",
|
||||
"/etc/localtime",
|
||||
"/etc/nsswitch.conf",
|
||||
"/etc/resolv.conf",
|
||||
"/etc/ssl/certs",
|
||||
"/dev/null",
|
||||
"/dev/random",
|
||||
"/dev/urandom",
|
||||
];
|
||||
const cphBin = env["CPH_BIN"]?.trim();
|
||||
if (cphBin !== undefined && cphBin !== "") {
|
||||
if (!isAbsolute(cphBin)) throw new Error("CPH_BIN must be absolute for the Agent subprocess");
|
||||
platformPaths.push(resolve(cphBin));
|
||||
}
|
||||
return [...new Set(platformPaths.map((path) => resolve(path)))];
|
||||
}
|
||||
|
||||
function hostSensitiveReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
|
||||
const home = homedir();
|
||||
const paths = [
|
||||
join(home, ".ssh"),
|
||||
join(home, ".aws"),
|
||||
join(home, ".config", "gcloud"),
|
||||
join(home, ".gnupg"),
|
||||
];
|
||||
const extra = env["CPH_SANDBOX_EXTRA_DENY_READ"];
|
||||
if (extra !== undefined && extra !== "") {
|
||||
for (const path of extra.split(":")) {
|
||||
if (path === "") continue;
|
||||
if (!isAbsolute(path)) throw new Error("CPH_SANDBOX_EXTRA_DENY_READ entries must be absolute paths");
|
||||
paths.push(resolve(path));
|
||||
}
|
||||
}
|
||||
return [...new Set(paths.map((path) => resolve(path)))];
|
||||
}
|
||||
|
||||
async function requireDirectoryRealpath(path: string, label: string): Promise<string> {
|
||||
if (!isAbsolute(path)) throw new Error(`${label} must be an absolute path`);
|
||||
const configuredMetadata = await lstat(path);
|
||||
if (configuredMetadata.isSymbolicLink() || !configuredMetadata.isDirectory()) {
|
||||
throw new Error(`${label} is not a real directory: ${path}`);
|
||||
}
|
||||
const canonical = await realpath(path);
|
||||
const metadata = await lstat(canonical);
|
||||
if (!metadata.isDirectory()) throw new Error(`${label} is not a directory: ${path}`);
|
||||
return canonical;
|
||||
}
|
||||
|
||||
async function resolveProjectWorkspace(
|
||||
configuredRoot: string,
|
||||
configuredWorkspace: string,
|
||||
): Promise<{ workspaceRoot: string; workspaceDir: string }> {
|
||||
if (!isAbsolute(configuredRoot) || !isAbsolute(configuredWorkspace)) {
|
||||
throw new Error("workspace root and project workspace must be absolute paths");
|
||||
}
|
||||
const rootPath = resolve(configuredRoot);
|
||||
const workspacePath = resolve(configuredWorkspace);
|
||||
const lexicalRelative = relative(rootPath, workspacePath);
|
||||
if (
|
||||
lexicalRelative === "" ||
|
||||
lexicalRelative === ".." ||
|
||||
lexicalRelative.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) ||
|
||||
isAbsolute(lexicalRelative)
|
||||
) {
|
||||
throw new Error(`project workspace escapes configured workspace root: ${configuredWorkspace}`);
|
||||
}
|
||||
|
||||
const workspaceRoot = await requireDirectoryRealpath(rootPath, "workspace root");
|
||||
const components = lexicalRelative.split(process.platform === "win32" ? /[\\/]/ : "/");
|
||||
let current = rootPath;
|
||||
for (const component of components) {
|
||||
current = join(current, component);
|
||||
const metadata = await lstat(current);
|
||||
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
||||
throw new Error(`project workspace contains a symlink or non-directory component: ${current}`);
|
||||
}
|
||||
}
|
||||
const workspaceDir = await realpath(workspacePath);
|
||||
const expectedWorkspace = join(workspaceRoot, ...components);
|
||||
if (workspaceDir !== expectedWorkspace || !isStrictDescendant(workspaceRoot, workspaceDir)) {
|
||||
throw new Error(`project workspace escapes configured workspace root: ${configuredWorkspace}`);
|
||||
}
|
||||
return { workspaceRoot, workspaceDir };
|
||||
}
|
||||
|
||||
async function ensureDirectoryTree(root: string, components: readonly string[]): Promise<string> {
|
||||
let current = root;
|
||||
for (const component of components) {
|
||||
if (component === "" || component === "." || component === ".." || component.includes("/") || component.includes("\\")) {
|
||||
throw new Error(`invalid Agent runtime directory component: ${component}`);
|
||||
}
|
||||
current = join(current, component);
|
||||
try {
|
||||
const metadata = await lstat(current);
|
||||
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
||||
throw new Error(`Agent runtime path is not a real directory: ${current}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isErrno(error, "ENOENT")) throw error;
|
||||
try {
|
||||
await mkdir(current, { mode: 0o700 });
|
||||
} catch (mkdirError) {
|
||||
if (!isErrno(mkdirError, "EEXIST")) throw mkdirError;
|
||||
}
|
||||
const metadata = await lstat(current);
|
||||
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
||||
throw new Error(`Agent runtime path is not a real directory: ${current}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const canonical = await realpath(current);
|
||||
if (canonical !== current || !isStrictDescendant(root, canonical)) {
|
||||
throw new Error(`Agent runtime path escapes project workspace: ${current}`);
|
||||
}
|
||||
await chmod(canonical, 0o700);
|
||||
return canonical;
|
||||
}
|
||||
|
||||
function isStrictDescendant(parent: string, child: string): boolean {
|
||||
const candidate = relative(parent, child);
|
||||
return candidate !== "" && candidate !== ".." && !candidate.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && !isAbsolute(candidate);
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
+66
-16
@@ -6,8 +6,12 @@
|
||||
* reactions, file download/upload, and the card action callback.
|
||||
*/
|
||||
import * as lark from "@larksuiteoapi/node-sdk";
|
||||
import { readFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { Readable } from "node:stream";
|
||||
import {
|
||||
WorkspaceFileBoundaryError,
|
||||
writeNewWorkspaceFileNoFollow,
|
||||
} from "../security/workspaceFiles.js";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js";
|
||||
import type { SenderNameCache } from "./senderCache.js";
|
||||
@@ -486,20 +490,38 @@ export async function downloadMessageFile(
|
||||
rt: FeishuRuntime,
|
||||
messageId: string,
|
||||
fileKey: string,
|
||||
savePath: string,
|
||||
workspaceRoot: string,
|
||||
workspaceDir: string,
|
||||
workspaceRelativePath: string,
|
||||
resourceType: "image" | "file",
|
||||
): Promise<void> {
|
||||
await mkdir(dirname(savePath), { recursive: true });
|
||||
): Promise<string> {
|
||||
try {
|
||||
await withRetry(async () => {
|
||||
return await withRetry(async () => {
|
||||
const resource = await rt.client.im.v1.messageResource.get({
|
||||
params: { type: resourceType },
|
||||
path: { message_id: messageId, file_key: fileKey },
|
||||
});
|
||||
await resource.writeFile(savePath);
|
||||
const readableResource = resource as unknown as { getReadableStream: () => Readable };
|
||||
const source = readableResource.getReadableStream();
|
||||
try {
|
||||
return await writeNewWorkspaceFileNoFollow(
|
||||
workspaceRoot,
|
||||
workspaceDir,
|
||||
workspaceRelativePath,
|
||||
source,
|
||||
);
|
||||
} catch (error) {
|
||||
source.destroy();
|
||||
throw error;
|
||||
}
|
||||
}, {
|
||||
shouldRetry: (error) => !(error instanceof WorkspaceFileBoundaryError),
|
||||
});
|
||||
} catch (e) {
|
||||
rt.logger.error({ err: e, messageId, fileKey, resourceType, savePath }, "downloadMessageFile failed");
|
||||
rt.logger.error(
|
||||
{ err: e, messageId, fileKey, resourceType, workspaceRoot, workspaceDir, workspaceRelativePath },
|
||||
"downloadMessageFile failed",
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -512,33 +534,61 @@ export async function sendFile(
|
||||
fileName: string,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
return await sendFileData(rt, chatId, await readFile(filePath), fileName, options);
|
||||
} catch (error) {
|
||||
// Legacy callers model delivery as nullable. Keep that API while retaining
|
||||
// the complete failure object logged by sendFileData (or log read failure).
|
||||
if (!(error instanceof FeishuFileDeliveryError)) {
|
||||
rt.logger.error({ chatId, filePath, fileName, err: error }, "sendFile failed before upload");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class FeishuFileDeliveryError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "FeishuFileDeliveryError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload and send an already-opened, no-follow file snapshot to a chat. */
|
||||
export async function sendFileData(
|
||||
rt: FeishuRuntime,
|
||||
chatId: string,
|
||||
fileData: Buffer,
|
||||
fileName: string,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<string> {
|
||||
const client = rt.client as unknown as {
|
||||
im: { v1: {
|
||||
file: { create: (p: unknown) => Promise<FileCreateResponse> }
|
||||
} };
|
||||
};
|
||||
try {
|
||||
const buf = await readFile(filePath);
|
||||
const ext = fileName.split(".").pop() ?? "";
|
||||
const fileType = ext === "pdf" ? "pdf" : ext === "doc" ? "doc" : ext === "xls" ? "xls" : ext === "ppt" ? "ppt" : "stream";
|
||||
const uploadRes = await withRetry(async () =>
|
||||
client.im.v1.file.create({ data: { file_type: fileType, file_name: fileName, file: buf } }),
|
||||
client.im.v1.file.create({ data: { file_type: fileType, file_name: fileName, file: fileData } }),
|
||||
);
|
||||
const fileKey = fileKeyFromResponse(uploadRes);
|
||||
if (fileKey === undefined) {
|
||||
rt.logger.warn({ chatId, filePath }, "sendFile failed: upload response missing file_key");
|
||||
return null;
|
||||
throw new FeishuFileDeliveryError("Feishu file upload response is missing file_key");
|
||||
}
|
||||
const messageId = await withRetry(async () =>
|
||||
sendMessagePayload(rt, chatId, { msgType: "file", content: JSON.stringify({ file_key: fileKey }) }, options),
|
||||
);
|
||||
if (messageId === null) {
|
||||
rt.logger.warn({ chatId, filePath }, "sendFile failed: message response missing message_id");
|
||||
throw new FeishuFileDeliveryError("Feishu file message response is missing message_id");
|
||||
}
|
||||
return messageId;
|
||||
} catch (e) {
|
||||
rt.logger.warn({ chatId, filePath, err: e instanceof Error ? e.message : String(e) }, "sendFile failed");
|
||||
return null;
|
||||
} catch (error) {
|
||||
const failure = error instanceof FeishuFileDeliveryError
|
||||
? error
|
||||
: new FeishuFileDeliveryError("Feishu file delivery failed", { cause: error });
|
||||
rt.logger.error({ chatId, fileName, err: failure }, "sendFileData failed");
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface DownloadedFeishuMessageResource extends FeishuMessageResourceAr
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
type DownloadContext = Pick<ToolContext, "boundChatId" | "workspaceDir">;
|
||||
type DownloadContext = Pick<ToolContext, "boundChatId" | "workspaceDir"> & { readonly workspaceRoot: string };
|
||||
|
||||
interface MessageLookupResult {
|
||||
readonly data?: {
|
||||
@@ -36,18 +36,29 @@ export async function downloadFeishuMessageResource(
|
||||
throw new Error(`Feishu message not found: ${args.messageId}`);
|
||||
}
|
||||
if (message.chat_id !== context.boundChatId) {
|
||||
rt.logger.warn(
|
||||
{ messageId: args.messageId, boundChatId: context.boundChatId, returnedChatId: message.chat_id },
|
||||
"Feishu resource download refused outside bound chat",
|
||||
);
|
||||
throw new Error(
|
||||
`refused Feishu resource download: message ${args.messageId} is not in the current project's bound chat`,
|
||||
);
|
||||
}
|
||||
|
||||
const extension = args.resourceType === "image" ? ".png" : ".bin";
|
||||
const savePath = join(
|
||||
context.workspaceDir,
|
||||
const workspaceRelativePath = join(
|
||||
".cph",
|
||||
"inbox",
|
||||
`feishu-${args.resourceType}-${randomUUID()}${extension}`,
|
||||
);
|
||||
await downloadMessageFile(rt, args.messageId, args.fileKey, savePath, args.resourceType);
|
||||
const savePath = await downloadMessageFile(
|
||||
rt,
|
||||
args.messageId,
|
||||
args.fileKey,
|
||||
context.workspaceRoot,
|
||||
context.workspaceDir,
|
||||
workspaceRelativePath,
|
||||
args.resourceType,
|
||||
);
|
||||
return { ...args, path: savePath };
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { access, stat } from "node:fs/promises";
|
||||
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { extname, isAbsolute, join } from "node:path";
|
||||
import {
|
||||
WorkspaceFileBoundaryError,
|
||||
readWorkspaceFileNoFollow,
|
||||
} from "../security/workspaceFiles.js";
|
||||
|
||||
export interface DeliverableFile {
|
||||
readonly path: string;
|
||||
readonly name: string;
|
||||
readonly data: Buffer;
|
||||
}
|
||||
|
||||
const DELIVERABLE_EXTENSIONS = new Set([
|
||||
@@ -28,6 +32,7 @@ const DELIVERABLE_EXTENSIONS = new Set([
|
||||
|
||||
export async function resolveDeliverableFile(
|
||||
requestedPath: string,
|
||||
workspaceRoot: string,
|
||||
workspaceDir: string,
|
||||
): Promise<DeliverableFile | null> {
|
||||
const token = requestedPath.trim();
|
||||
@@ -35,61 +40,25 @@ export async function resolveDeliverableFile(
|
||||
return null;
|
||||
}
|
||||
|
||||
const roots = await allowedRoots(workspaceDir);
|
||||
for (const candidate of candidatePaths(token, workspaceDir, roots)) {
|
||||
if (!isAllowedPath(candidate, roots)) continue;
|
||||
const existing = await existingFile(candidate);
|
||||
if (existing !== null) return { path: existing, name: basename(existing) };
|
||||
for (const candidate of candidatePaths(token)) {
|
||||
try {
|
||||
return await readWorkspaceFileNoFollow(workspaceRoot, workspaceDir, candidate);
|
||||
} catch (error) {
|
||||
if (!(error instanceof WorkspaceFileBoundaryError) || error.reason !== "not_found") {
|
||||
throw error;
|
||||
}
|
||||
// Try the explicit workspace/build fallback for a bare filename. No
|
||||
// candidate outside the project workspace is ever considered.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function allowedRoots(workspaceDir: string): Promise<string[]> {
|
||||
const roots = [resolve(workspaceDir)];
|
||||
const gitRoot = await findGitRoot(workspaceDir);
|
||||
if (gitRoot !== undefined) roots.push(gitRoot);
|
||||
return [...new Set(roots)];
|
||||
}
|
||||
|
||||
async function findGitRoot(startDir: string): Promise<string | undefined> {
|
||||
let current = resolve(startDir);
|
||||
while (true) {
|
||||
try {
|
||||
await access(join(current, ".git"));
|
||||
return current;
|
||||
} catch {
|
||||
const parent = resolve(current, "..");
|
||||
if (parent === current) return undefined;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function candidatePaths(token: string, workspaceDir: string, roots: readonly string[]): string[] {
|
||||
if (isAbsolute(token)) return [resolve(token)];
|
||||
|
||||
const candidates: string[] = [];
|
||||
for (const root of roots) {
|
||||
candidates.push(resolve(root, token));
|
||||
}
|
||||
function candidatePaths(token: string): string[] {
|
||||
if (isAbsolute(token)) return [token];
|
||||
const candidates = [token];
|
||||
if (!token.includes("/") && !token.includes("\\")) {
|
||||
candidates.push(resolve(workspaceDir, "build", token));
|
||||
candidates.push(join("build", token));
|
||||
}
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
function isAllowedPath(path: string, roots: readonly string[]): boolean {
|
||||
return roots.some((root) => {
|
||||
const rel = relative(root, path);
|
||||
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
||||
});
|
||||
}
|
||||
|
||||
async function existingFile(path: string): Promise<string | null> {
|
||||
try {
|
||||
const s = await stat(path);
|
||||
return s.isFile() ? resolve(path) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { z } from "zod";
|
||||
import { sendApprovalCard, sendFile, type FeishuRuntime, type SendMessageOptions } from "./client.js";
|
||||
import { sendApprovalCard, sendFileData, type FeishuRuntime, type SendMessageOptions } from "./client.js";
|
||||
import { resolveDeliverableFile } from "./fileDelivery.js";
|
||||
import { downloadFeishuMessageResource } from "./download.js";
|
||||
import { readFeishuContext } from "./read.js";
|
||||
import type { ApprovalManager } from "./approval.js";
|
||||
import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js";
|
||||
import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js";
|
||||
|
||||
export interface FileDeliveryToolOptions {
|
||||
readonly rt: FeishuRuntime;
|
||||
readonly chatId: string;
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly workspaceRoot?: string | undefined;
|
||||
readonly workspaceDir: string;
|
||||
readonly sendOptions?: SendMessageOptions | undefined;
|
||||
readonly approvalManager: ApprovalManager;
|
||||
@@ -27,13 +29,46 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
tools.push(
|
||||
tool(
|
||||
"send_file",
|
||||
"Upload an existing project/repository file to the current Feishu chat. The path must point to a concrete file, for example build/student.pdf or README.md.",
|
||||
"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.",
|
||||
{
|
||||
path: z.string().describe("Workspace-relative, repository-relative, or absolute path to the existing file."),
|
||||
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."),
|
||||
},
|
||||
async (args) => {
|
||||
const file = await resolveDeliverableFile(args.path, options.workspaceDir);
|
||||
const workspaceRoot = options.workspaceRoot?.trim();
|
||||
if (workspaceRoot === undefined || workspaceRoot === "") {
|
||||
options.rt.logger.error(
|
||||
{ runId: options.runId, projectId: options.projectId },
|
||||
"Agent file delivery missing configured workspace root",
|
||||
);
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "File delivery is unavailable because workspace isolation is not configured." }],
|
||||
};
|
||||
}
|
||||
let file;
|
||||
try {
|
||||
file = await resolveDeliverableFile(args.path, workspaceRoot, options.workspaceDir);
|
||||
} catch (error) {
|
||||
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);
|
||||
log(
|
||||
{
|
||||
runId: options.runId,
|
||||
projectId: options.projectId,
|
||||
requestedPath: args.path,
|
||||
err: error,
|
||||
},
|
||||
boundaryViolation
|
||||
? "Agent file delivery refused by workspace boundary"
|
||||
: "Agent file delivery failed during workspace file access",
|
||||
);
|
||||
if (!boundaryViolation) throw error;
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "File path is outside the current workspace or uses a symlink." }],
|
||||
};
|
||||
}
|
||||
if (file === null) {
|
||||
return {
|
||||
isError: true,
|
||||
@@ -41,13 +76,13 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
};
|
||||
}
|
||||
|
||||
const messageId = await sendFile(options.rt, options.chatId, file.path, args.name ?? file.name, options.sendOptions);
|
||||
if (messageId === null) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: `Failed to send file: ${file.path}` }],
|
||||
};
|
||||
}
|
||||
const messageId = await sendFileData(
|
||||
options.rt,
|
||||
options.chatId,
|
||||
file.data,
|
||||
args.name ?? file.name,
|
||||
options.sendOptions,
|
||||
);
|
||||
|
||||
options.onDelivered?.(file.path);
|
||||
return {
|
||||
@@ -97,13 +132,28 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
resource_type: z.enum(["image", "file"]).describe("Use image for image_key and file for file_key."),
|
||||
},
|
||||
async (args) => {
|
||||
const workspaceRoot = options.workspaceRoot?.trim();
|
||||
if (workspaceRoot === undefined || workspaceRoot === "") {
|
||||
options.rt.logger.error(
|
||||
{ runId: options.runId, projectId: options.projectId },
|
||||
"Feishu resource download missing configured workspace root",
|
||||
);
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "Resource download is unavailable because workspace isolation is not configured." }],
|
||||
};
|
||||
}
|
||||
const downloaded = await downloadFeishuMessageResource(
|
||||
{
|
||||
messageId: args.message_id,
|
||||
fileKey: args.file_key,
|
||||
resourceType: args.resource_type,
|
||||
},
|
||||
{ boundChatId: options.chatId, workspaceDir: options.workspaceDir },
|
||||
{
|
||||
boundChatId: options.chatId,
|
||||
workspaceRoot,
|
||||
workspaceDir: options.workspaceDir,
|
||||
},
|
||||
options.rt,
|
||||
);
|
||||
return { content: [{ type: "text", text: JSON.stringify(downloaded) }] };
|
||||
|
||||
+22
-1
@@ -48,12 +48,15 @@ function im(rt: FeishuRuntime): ImV1Message {
|
||||
/** Read on-demand Feishu context for the current run. Entry point for the tool. */
|
||||
export async function readFeishuContext(
|
||||
args: FeishuContextArgs,
|
||||
_ctx: ToolContext,
|
||||
ctx: ToolContext,
|
||||
rt: unknown,
|
||||
): Promise<string> {
|
||||
const runtime = rt as FeishuRuntime;
|
||||
const api = im(runtime);
|
||||
try {
|
||||
if (args.chat_id !== ctx.boundChatId) {
|
||||
throw new Error("refused Feishu context read outside the current project's bound chat");
|
||||
}
|
||||
switch (args.anchor) {
|
||||
case "trigger_message":
|
||||
case "status_card":
|
||||
@@ -62,6 +65,7 @@ export async function readFeishuContext(
|
||||
const res = await api.get({ path: { message_id: args.id } });
|
||||
const msg = res.data?.message ?? res.data?.items?.[0];
|
||||
if (msg === undefined) return JSON.stringify({ error: "message not found", id: args.id });
|
||||
assertBoundChat(msg, ctx.boundChatId);
|
||||
return JSON.stringify(compact(msg));
|
||||
}
|
||||
case "thread": {
|
||||
@@ -75,16 +79,33 @@ export async function readFeishuContext(
|
||||
},
|
||||
});
|
||||
const items = res.data?.items ?? [];
|
||||
for (const item of items) assertBoundChat(item, ctx.boundChatId);
|
||||
return JSON.stringify(items.map(compact));
|
||||
}
|
||||
default:
|
||||
return JSON.stringify({ error: `unknown anchor type: ${args.anchor as string}` });
|
||||
}
|
||||
} catch (e) {
|
||||
runtime.logger.warn(
|
||||
{
|
||||
runId: ctx.runId,
|
||||
projectId: ctx.projectId,
|
||||
anchor: args.anchor,
|
||||
anchorId: args.id,
|
||||
err: e instanceof Error ? e.message : String(e),
|
||||
},
|
||||
"Feishu context read refused or failed",
|
||||
);
|
||||
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
function assertBoundChat(message: LarkMessage, boundChatId: string): void {
|
||||
if (message.chat_id !== boundChatId) {
|
||||
throw new Error("refused Feishu context result outside the current project's bound chat");
|
||||
}
|
||||
}
|
||||
|
||||
/** Project a lark message down to the fields the model actually needs. */
|
||||
function compact(m: LarkMessage): Record<string, unknown> {
|
||||
const parentId = m.parent_id ?? m.parent_message_id;
|
||||
|
||||
+35
-11
@@ -72,7 +72,7 @@ interface TriggerDeps {
|
||||
readonly authorizer?: PermissionAuthorizer | undefined;
|
||||
readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
|
||||
readonly triggerQueue?: TriggerQueue | undefined;
|
||||
readonly projectWorkspaceRoot?: string | undefined;
|
||||
readonly projectWorkspaceRoot: string;
|
||||
}
|
||||
|
||||
interface TriggerActor {
|
||||
@@ -103,6 +103,8 @@ export interface TriggerHandler {
|
||||
* agent run is the long leg); the lock guarantees one run per project at a time.
|
||||
*/
|
||||
export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
const projectWorkspaceRoot = deps.projectWorkspaceRoot.trim();
|
||||
if (projectWorkspaceRoot === "") throw new Error("projectWorkspaceRoot is required");
|
||||
const authorizer = deps.authorizer ?? createPermissionAuthorizer(deps.prisma);
|
||||
const runAgent = deps.runAgent ?? defaultRunAgent;
|
||||
const approvalManager = new ApprovalManager();
|
||||
@@ -288,6 +290,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
const projectCtx: ProjectContext = {
|
||||
projectId,
|
||||
boundChatId: chatId, // ADR-0003: Feishu reads stay in this chat
|
||||
workspaceRoot: projectWorkspaceRoot,
|
||||
workspaceDir: project.workspaceDir,
|
||||
};
|
||||
|
||||
@@ -314,6 +317,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
chatId,
|
||||
projectId,
|
||||
runId: run.id,
|
||||
workspaceRoot: projectWorkspaceRoot,
|
||||
workspaceDir: project.workspaceDir,
|
||||
sendOptions,
|
||||
approvalManager,
|
||||
@@ -566,16 +570,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
|
||||
try {
|
||||
if (action.action === "create_project_from_chat") {
|
||||
if (deps.projectWorkspaceRoot === undefined || deps.projectWorkspaceRoot.trim() === "") {
|
||||
throw new Error("HUB_PROJECT_WORKSPACE_ROOT is required for Feishu project creation");
|
||||
}
|
||||
const name = defaultProjectNameForChat(chatId);
|
||||
const project = await createProjectFromFeishuChat(deps.prisma, {
|
||||
organizationId: action.organization_id,
|
||||
actorFeishuOpenId: operatorOpenId,
|
||||
chatId,
|
||||
name,
|
||||
workspaceRoot: deps.projectWorkspaceRoot,
|
||||
workspaceRoot: projectWorkspaceRoot,
|
||||
folderId: action.folder_id,
|
||||
});
|
||||
await resolveProjectOnboardingCard(rt, messageId, {
|
||||
@@ -771,6 +772,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
const downloadedResources = await downloadPostMessageFiles({
|
||||
rt,
|
||||
msg,
|
||||
workspaceRoot: projectWorkspaceRoot,
|
||||
workspaceDir: project.workspaceDir,
|
||||
imageKeys: parsed.imageKeys,
|
||||
fileKeys: parsed.fileKeys,
|
||||
@@ -797,8 +799,15 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
const key = content.file_key ?? content.image_key ?? "";
|
||||
if (key !== "") {
|
||||
const fileName = `${msg.message_type}-${Date.now()}.${msg.message_type === "image" ? "png" : "bin"}`;
|
||||
const savePath = `${project.workspaceDir}/.cph/inbox/${fileName}`;
|
||||
await downloadMessageFile(rt, msg.message_id, key, savePath, msg.message_type);
|
||||
const savePath = await downloadMessageFile(
|
||||
rt,
|
||||
msg.message_id,
|
||||
key,
|
||||
projectWorkspaceRoot,
|
||||
project.workspaceDir,
|
||||
`.cph/inbox/${fileName}`,
|
||||
msg.message_type,
|
||||
);
|
||||
cleanPrompt = `用户发来一个文件: ${savePath}`;
|
||||
}
|
||||
}
|
||||
@@ -1298,6 +1307,7 @@ interface DownloadedMessageResource {
|
||||
async function downloadPostMessageFiles(args: {
|
||||
readonly rt: FeishuRuntime;
|
||||
readonly msg: MessageReceiveEvent["message"];
|
||||
readonly workspaceRoot: string;
|
||||
readonly workspaceDir: string;
|
||||
readonly imageKeys: readonly string[];
|
||||
readonly fileKeys: readonly string[];
|
||||
@@ -1305,13 +1315,27 @@ async function downloadPostMessageFiles(args: {
|
||||
const timestamp = Date.now();
|
||||
const downloadedResources: DownloadedMessageResource[] = [];
|
||||
for (const [index, imageKey] of args.imageKeys.entries()) {
|
||||
const savePath = `${args.workspaceDir}/.cph/inbox/post-image-${timestamp}-${index}.png`;
|
||||
await downloadMessageFile(args.rt, args.msg.message_id, imageKey, savePath, "image");
|
||||
const savePath = await downloadMessageFile(
|
||||
args.rt,
|
||||
args.msg.message_id,
|
||||
imageKey,
|
||||
args.workspaceRoot,
|
||||
args.workspaceDir,
|
||||
`.cph/inbox/post-image-${timestamp}-${index}.png`,
|
||||
"image",
|
||||
);
|
||||
downloadedResources.push({ resourceType: "image", path: savePath });
|
||||
}
|
||||
for (const [index, fileKey] of args.fileKeys.entries()) {
|
||||
const savePath = `${args.workspaceDir}/.cph/inbox/post-file-${timestamp}-${index}.bin`;
|
||||
await downloadMessageFile(args.rt, args.msg.message_id, fileKey, savePath, "file");
|
||||
const savePath = await downloadMessageFile(
|
||||
args.rt,
|
||||
args.msg.message_id,
|
||||
fileKey,
|
||||
args.workspaceRoot,
|
||||
args.workspaceDir,
|
||||
`.cph/inbox/post-file-${timestamp}-${index}.bin`,
|
||||
"file",
|
||||
);
|
||||
downloadedResources.push({ resourceType: "file", path: savePath });
|
||||
}
|
||||
return downloadedResources;
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, mkdir, open, realpath, unlink, type FileHandle } from "node:fs/promises";
|
||||
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
export class WorkspaceFileBoundaryError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly requestedPath: string,
|
||||
readonly reason: "not_found" | "boundary" | "io" = "boundary",
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "WorkspaceFileBoundaryError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkspaceFileSnapshot {
|
||||
readonly path: string;
|
||||
readonly name: string;
|
||||
readonly data: Buffer;
|
||||
}
|
||||
|
||||
interface OpenDirectory {
|
||||
readonly handle: FileHandle;
|
||||
readonly displayPath: string;
|
||||
}
|
||||
|
||||
/** Read a stable snapshot without following any symlink below the workspace. */
|
||||
export async function readWorkspaceFileNoFollow(
|
||||
workspaceRoot: string,
|
||||
workspaceDir: string,
|
||||
requestedPath: string,
|
||||
): Promise<WorkspaceFileSnapshot> {
|
||||
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
|
||||
const components = fileComponents(workspace, requestedPath);
|
||||
const name = components.at(-1)!;
|
||||
const directories = components.slice(0, -1);
|
||||
const parent = await openDirectoryTree(workspace, directories, false, requestedPath);
|
||||
const openedPath = childPath(parent, name);
|
||||
let file: FileHandle | undefined;
|
||||
let result: WorkspaceFileSnapshot | undefined;
|
||||
let failure: unknown;
|
||||
try {
|
||||
file = await open(openedPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
const metadata = await file.stat();
|
||||
if (!metadata.isFile()) {
|
||||
throw new WorkspaceFileBoundaryError(`deliverable is not a regular file: ${requestedPath}`, requestedPath);
|
||||
}
|
||||
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
|
||||
const data = await file.readFile();
|
||||
result = { path: join(workspace, ...components), name, data };
|
||||
} catch (error) {
|
||||
failure = boundaryError(error, requestedPath, "cannot read workspace file without following symlinks");
|
||||
}
|
||||
await finishWithCleanup(
|
||||
failure,
|
||||
[file, parent.handle],
|
||||
`workspace file read cleanup failed: ${requestedPath}`,
|
||||
);
|
||||
return result!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one inbound file exclusively below the workspace and stream into its
|
||||
* already-open descriptor. Linux uses /proc/self/fd-relative traversal so a
|
||||
* concurrent directory rename/symlink swap cannot redirect the write.
|
||||
*/
|
||||
export async function writeNewWorkspaceFileNoFollow(
|
||||
workspaceRoot: string,
|
||||
workspaceDir: string,
|
||||
requestedPath: string,
|
||||
source: Readable,
|
||||
): Promise<string> {
|
||||
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
|
||||
const components = fileComponents(workspace, requestedPath);
|
||||
const name = components.at(-1)!;
|
||||
const directories = components.slice(0, -1);
|
||||
const parent = await openDirectoryTree(workspace, directories, true, requestedPath);
|
||||
const openedPath = childPath(parent, name);
|
||||
let file: FileHandle | undefined;
|
||||
let device: number | undefined;
|
||||
let inode: number | undefined;
|
||||
let result: string | undefined;
|
||||
let failure: unknown;
|
||||
const cleanupFailures: unknown[] = [];
|
||||
try {
|
||||
file = await open(
|
||||
openedPath,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
const metadata = await file.stat();
|
||||
device = metadata.dev;
|
||||
inode = metadata.ino;
|
||||
for await (const chunk of source) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
|
||||
let offset = 0;
|
||||
while (offset < buffer.length) {
|
||||
const { bytesWritten } = await file.write(buffer, offset, buffer.length - offset, null);
|
||||
if (bytesWritten === 0) throw new Error("inbound workspace write made no progress");
|
||||
offset += bytesWritten;
|
||||
}
|
||||
}
|
||||
await file.sync();
|
||||
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
|
||||
result = join(workspace, ...components);
|
||||
} catch (error) {
|
||||
failure = boundaryError(error, requestedPath, "cannot create inbound workspace file safely");
|
||||
if (device !== undefined && inode !== undefined) {
|
||||
try {
|
||||
await unlinkIfSameFile(parent, name, device, inode);
|
||||
} catch (cleanupError) {
|
||||
cleanupFailures.push(cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
await finishWithCleanup(
|
||||
failure,
|
||||
[file, parent.handle],
|
||||
`inbound workspace file cleanup failed: ${requestedPath}`,
|
||||
cleanupFailures,
|
||||
);
|
||||
return result!;
|
||||
}
|
||||
|
||||
async function canonicalWorkspace(workspaceRoot: string, workspaceDir: string): Promise<string> {
|
||||
if (process.platform !== "linux") {
|
||||
throw new WorkspaceFileBoundaryError(
|
||||
"race-safe descriptor-relative workspace access requires Linux",
|
||||
workspaceDir,
|
||||
);
|
||||
}
|
||||
if (!isAbsolute(workspaceRoot) || !isAbsolute(workspaceDir)) {
|
||||
throw new WorkspaceFileBoundaryError("workspace root and project path must be absolute", workspaceDir);
|
||||
}
|
||||
const configuredRoot = resolve(workspaceRoot);
|
||||
const configuredWorkspace = resolve(workspaceDir);
|
||||
const rel = relative(configuredRoot, configuredWorkspace);
|
||||
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
||||
throw new WorkspaceFileBoundaryError("project workspace escapes configured workspace root", workspaceDir);
|
||||
}
|
||||
const rootMetadata = await lstat(configuredRoot);
|
||||
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
|
||||
throw new WorkspaceFileBoundaryError("configured workspace root is not a real directory", workspaceRoot);
|
||||
}
|
||||
const root = await realpath(configuredRoot);
|
||||
const components = rel.split(sep);
|
||||
const openedWorkspace = await openDirectoryTree(root, components, false, workspaceDir);
|
||||
let result: string | undefined;
|
||||
let failure: unknown;
|
||||
try {
|
||||
const openedTarget = await realpath(`/proc/self/fd/${openedWorkspace.handle.fd}`);
|
||||
const expected = join(root, ...components);
|
||||
if (openedTarget !== expected) {
|
||||
throw new WorkspaceFileBoundaryError("project workspace contains a symlink", workspaceDir);
|
||||
}
|
||||
result = expected;
|
||||
} catch (error) {
|
||||
failure = boundaryError(error, workspaceDir, "project workspace validation failed");
|
||||
}
|
||||
await finishWithCleanup(
|
||||
failure,
|
||||
[openedWorkspace.handle],
|
||||
`project workspace validation cleanup failed: ${workspaceDir}`,
|
||||
);
|
||||
return result!;
|
||||
}
|
||||
|
||||
function fileComponents(root: string, requestedPath: string): string[] {
|
||||
const token = requestedPath.trim();
|
||||
if (token === "" || token.includes("\0")) {
|
||||
throw new WorkspaceFileBoundaryError("workspace file path is empty or invalid", requestedPath);
|
||||
}
|
||||
const absolute = isAbsolute(token) ? resolve(token) : resolve(root, token);
|
||||
const rel = relative(root, absolute);
|
||||
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
||||
throw new WorkspaceFileBoundaryError(`workspace file path escapes current project: ${requestedPath}`, requestedPath);
|
||||
}
|
||||
const components = rel.split(/[\\/]/);
|
||||
if (components.some((component) => component === "" || component === "." || component === "..")) {
|
||||
throw new WorkspaceFileBoundaryError(`workspace file path is not normalized: ${requestedPath}`, requestedPath);
|
||||
}
|
||||
return components;
|
||||
}
|
||||
|
||||
async function openDirectoryTree(
|
||||
root: string,
|
||||
components: readonly string[],
|
||||
create: boolean,
|
||||
requestedPath: string,
|
||||
): Promise<OpenDirectory> {
|
||||
let current: OpenDirectory = {
|
||||
handle: await open(root, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW),
|
||||
displayPath: root,
|
||||
};
|
||||
try {
|
||||
for (const component of components) {
|
||||
const path = childPath(current, component);
|
||||
let next: FileHandle;
|
||||
try {
|
||||
next = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
} catch (error) {
|
||||
if (!create || !isErrno(error, "ENOENT")) throw error;
|
||||
try {
|
||||
await mkdir(path, { mode: 0o700 });
|
||||
} catch (mkdirError) {
|
||||
if (!isErrno(mkdirError, "EEXIST")) throw mkdirError;
|
||||
}
|
||||
next = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
}
|
||||
const previous = current;
|
||||
current = { handle: next, displayPath: join(previous.displayPath, component) };
|
||||
await previous.handle.close();
|
||||
}
|
||||
return current;
|
||||
} catch (error) {
|
||||
try {
|
||||
await current.handle.close();
|
||||
} catch (closeError) {
|
||||
throw new AggregateError(
|
||||
[error, closeError],
|
||||
`workspace path validation and directory close both failed: ${requestedPath}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw boundaryError(error, requestedPath, "workspace path contains a symlink or non-directory component");
|
||||
}
|
||||
}
|
||||
|
||||
function childPath(parent: OpenDirectory, name: string): string {
|
||||
return `/proc/self/fd/${parent.handle.fd}/${name}`;
|
||||
}
|
||||
|
||||
async function assertNameStillReferences(
|
||||
parent: OpenDirectory,
|
||||
name: string,
|
||||
device: number,
|
||||
inode: number,
|
||||
requestedPath: string,
|
||||
): Promise<void> {
|
||||
const metadata = await lstat(childPath(parent, name));
|
||||
if (metadata.isSymbolicLink() || metadata.dev !== device || metadata.ino !== inode) {
|
||||
throw new WorkspaceFileBoundaryError(
|
||||
`workspace file changed during no-follow access: ${requestedPath}`,
|
||||
requestedPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkIfSameFile(parent: OpenDirectory, name: string, device: number, inode: number): Promise<void> {
|
||||
const path = childPath(parent, name);
|
||||
try {
|
||||
const metadata = await lstat(path);
|
||||
if (!metadata.isSymbolicLink() && metadata.dev === device && metadata.ino === inode) {
|
||||
await unlink(path);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isErrno(error, "ENOENT")) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function boundaryError(error: unknown, requestedPath: string, prefix: string): WorkspaceFileBoundaryError {
|
||||
if (error instanceof WorkspaceFileBoundaryError) return error;
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
const reason = isErrno(error, "ENOENT")
|
||||
? "not_found"
|
||||
: isErrno(error, "ELOOP") || isErrno(error, "ENOTDIR")
|
||||
? "boundary"
|
||||
: "io";
|
||||
return new WorkspaceFileBoundaryError(
|
||||
`${prefix}: ${requestedPath} (${detail})`,
|
||||
requestedPath,
|
||||
reason,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
async function finishWithCleanup(
|
||||
primaryFailure: unknown,
|
||||
handles: ReadonlyArray<FileHandle | undefined>,
|
||||
message: string,
|
||||
additionalFailures: readonly unknown[] = [],
|
||||
): Promise<void> {
|
||||
const settled = await Promise.allSettled(
|
||||
handles.filter((handle): handle is FileHandle => handle !== undefined).map((handle) => handle.close()),
|
||||
);
|
||||
const cleanupFailures = [
|
||||
...additionalFailures,
|
||||
...settled.flatMap((result) => result.status === "rejected" ? [result.reason] : []),
|
||||
];
|
||||
if (primaryFailure !== undefined && cleanupFailures.length === 0) throw primaryFailure;
|
||||
if (primaryFailure !== undefined || cleanupFailures.length > 0) {
|
||||
const failures = primaryFailure === undefined ? cleanupFailures : [primaryFailure, ...cleanupFailures];
|
||||
throw new AggregateError(failures, message, { cause: primaryFailure ?? cleanupFailures[0] });
|
||||
}
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
Reference in New Issue
Block a user