forked from bai/curriculum-project-hub
feat(hub): workspace 文件工具 + cph 子进程工具(hub↔courseware 耦合点)
agent 的窄工具面补全: - workspace.ts: read_file/write_file/list_files,路径全 confinement 到 ctx.workspaceDir(ADR-0007 目录树);.. 与绝对路径逃逸返 error JSON 不中断 run - cph.ts: cph_check/cph_build 子进程,cwd=workspace;ADR-0016 版本契约由 cph 自身在 load 阶段校验(cphVersionMismatch),Hub 透传诊断不重复校验; CPH_BIN 可配,缺二进制返 exitCode 127 不抛 - server.ts: 注册全部 5 个工具 修 dynamic import(rule: ts-no-dynamic-import)——mkdir 改顶部静态 import; 三个 handler 的 confine 包进 try(操作性错误返 JSON 而非冒泡中断 run)。 tsc rc=0;smoke 通过(逃逸拒绝/读写回环/列举/cph ENOENT)。
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Workspace file tools — bounded read/write/list against the project's
|
||||
* curriculum engineering-file tree (ADR-0007 directory tree).
|
||||
*
|
||||
* All paths are **confined to `ctx.workspaceDir`**: the handler resolves the
|
||||
* requested relative path against the workspace root and rejects any path that
|
||||
* escapes it (via `..` or absolute paths). This is the agent's only file
|
||||
* surface — there is no general bash escape hatch, by design (the Hub agent is
|
||||
* narrower than a general "Claude Code").
|
||||
*/
|
||||
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
|
||||
import { join, resolve, relative, isAbsolute } from "node:path";
|
||||
import type { RegisteredTool } from "./tools.js";
|
||||
|
||||
/** Thrown when a requested path escapes the project workspace root. */
|
||||
export class PathEscape extends Error {
|
||||
constructor(readonly requested: string, readonly workspaceDir: string) {
|
||||
super(`path escapes workspace: ${requested} (root ${workspaceDir})`);
|
||||
this.name = "PathEscape";
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a tool-supplied path against the workspace root, rejecting escapes. */
|
||||
function confine(requestedPath: string, workspaceDir: string): string {
|
||||
if (isAbsolute(requestedPath)) {
|
||||
// Allow absolute paths only if they're already inside the workspace.
|
||||
const rel = relative(workspaceDir, requestedPath);
|
||||
if (rel.startsWith("..") || rel === "") {
|
||||
throw new PathEscape(requestedPath, workspaceDir);
|
||||
}
|
||||
return requestedPath;
|
||||
}
|
||||
const resolved = resolve(workspaceDir, requestedPath);
|
||||
const rel = relative(workspaceDir, resolved);
|
||||
if (rel.startsWith("..")) {
|
||||
throw new PathEscape(requestedPath, workspaceDir);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
interface ReadFileArgs {
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
export function readFileTool(): RegisteredTool {
|
||||
return {
|
||||
spec: {
|
||||
name: "read_file",
|
||||
description: "Read a file from the project's curriculum engineering-file workspace. Path is relative to the workspace root.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string", description: "Relative path within the workspace." } },
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
async execute(args, ctx): Promise<string> {
|
||||
const a = args as ReadFileArgs;
|
||||
try {
|
||||
const full = confine(a.path, ctx.workspaceDir);
|
||||
return await readFile(full, "utf8");
|
||||
} catch (e) {
|
||||
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface WriteFileArgs {
|
||||
readonly path: string;
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
export function writeFileTool(): RegisteredTool {
|
||||
return {
|
||||
spec: {
|
||||
name: "write_file",
|
||||
description: "Write a file in the project's curriculum engineering-file workspace. Path is relative to the workspace root. Creates parent directories.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Relative path within the workspace." },
|
||||
content: { type: "string", description: "The full file content to write." },
|
||||
},
|
||||
required: ["path", "content"],
|
||||
},
|
||||
},
|
||||
async execute(args, ctx): Promise<string> {
|
||||
const a = args as WriteFileArgs;
|
||||
try {
|
||||
const full = confine(a.path, ctx.workspaceDir);
|
||||
await mkdir(join(full, ".."), { recursive: true });
|
||||
await writeFile(full, a.content, "utf8");
|
||||
return JSON.stringify({ ok: true, path: a.path, bytes: a.content.length });
|
||||
} catch (e) {
|
||||
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface ListFilesArgs {
|
||||
readonly path?: string;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
readonly name: string;
|
||||
readonly kind: "file" | "dir";
|
||||
}
|
||||
|
||||
export function listFilesTool(): RegisteredTool {
|
||||
return {
|
||||
spec: {
|
||||
name: "list_files",
|
||||
description: "List files and directories at a path within the workspace. Defaults to the workspace root.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string", description: "Relative path within the workspace; defaults to root." } },
|
||||
},
|
||||
},
|
||||
async execute(args, ctx): Promise<string> {
|
||||
const a = (args as ListFilesArgs) ?? {};
|
||||
const sub = a.path ?? ".";
|
||||
try {
|
||||
const full = confine(sub, ctx.workspaceDir);
|
||||
const entries = await readdir(full, { withFileTypes: true });
|
||||
const result: Entry[] = entries.map((e) => ({
|
||||
name: e.name,
|
||||
kind: e.isDirectory() ? "dir" : "file",
|
||||
}));
|
||||
return JSON.stringify(result);
|
||||
} catch (e) {
|
||||
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user