diff --git a/AGENTS.md b/AGENTS.md index 58018e4..6367ec2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,3 +37,5 @@ PRs should describe runtime behavior changes, list `bun run check` and any test ## Security & Configuration Tips Do not commit `.env`, logs, session transcripts, generated result references, or `node_modules/`. Keep registry and deploy credentials in Gitea secrets. + +Automatic approval for `glob` and `grep` must remain limited to canonical paths inside an explicit safe workspace subtree. Broad workspace-root searches, symlink escapes, external paths, and `.env`, `data/`, or `logs/` targets must stay interactive. diff --git a/README.md b/README.md index 2377220..bdb8438 100644 --- a/README.md +++ b/README.md @@ -86,11 +86,13 @@ TJWATER_API_BASE_URL=http://127.0.0.1:8000 `opencode.json` 已启用 `experimental.continue_loop_on_deny`。用户拒绝权限请求后,OpenCode V1 会把拒绝结果交还给 Agent,让其尝试无需该权限的替代方案,而不是直接结束本轮执行。 -前端提供三种整体权限模式:“请求批准”只执行 OpenCode 明确允许的白名单,其他权限请求逐次交给用户确认;“自动批准”额外自动放行低风险业务工具,其他请求仍需确认;“始终允许”自动放行当前对话中所有未被 OpenCode 明确禁止的权限请求。自动放行统一使用单次批准,切换整体模式后立即恢复对应策略,不会写入持久授权。 +前端提供三种整体权限模式:“请求批准”只执行 OpenCode 明确允许的白名单,其他权限请求逐次交给用户确认;“自动批准”额外自动放行低风险业务工具、skill,以及真实路径位于工作区安全子树且不涉及 `.env`、`data/`、`logs/` 的 glob/grep;工作区根目录的宽泛搜索仍需确认。“始终允许”自动放行当前对话中所有未被 OpenCode 明确禁止的权限请求。自动放行统一使用单次批准,切换整体模式后立即恢复对应策略,不会写入持久授权。 单次权限请求支持“允许一次”“保存授权”和“拒绝”。“保存授权”使用 OpenCode 的 `always` 回复,仅保存 OpenCode 为本次请求建议的权限范围,并只在当前 OpenCode 会话内生效。外部目录以及 `.env`、`data/`、`logs/` 路径仍由静态配置明确禁止,三种整体模式都不能绕过这些拒绝规则。 -`store_render_ref` 只会从 `RESULT_REF_IMPORT_DIR`(默认 `./data/result-imports`)导入包装格式 JSON。文件必须包含 `metadata`、`location.file_path` 和 `data`,且真实路径不能越出导入目录;单文件默认上限为 64 MiB,成功导入后源包装文件会被删除。 +`store_render_ref` 只会从 `RESULT_REF_IMPORT_DIR`(默认 `./data/result-imports`)导入包装格式 JSON。文件必须包含 `metadata`、`location.file_path` 和 `data`,且真实路径不能越出导入目录;单文件默认上限为 128 MiB,成功导入后源包装文件会被删除。 + +CLI 桥接层对 stdout 设置独立的 128 MiB 硬上限(`MAX_CLI_OUTPUT_BYTES`);stderr 最多保留 256 KiB(`MAX_CLI_STDERR_BYTES`),超出后截断但不会终止 CLI。`MAX_INLINE_RESULT_BYTES`(默认 12000 字节)只控制 OpenCode 的内联阈值,较大结果由 OpenCode 写入标准 `tool-output` 目录。Agent 启动时及后续定期清理其中超过 `RESULT_REF_TTL_HOURS`(默认 7 天)的 `tool_*` 文件。 ## 配置与安全 diff --git a/cli/src/commands/network.ts b/cli/src/commands/network.ts index 1321d5e..6eae671 100644 --- a/cli/src/commands/network.ts +++ b/cli/src/commands/network.ts @@ -1,5 +1,6 @@ -import { emitApi } from "../core/http.js"; -import { parseOptions, requiredString } from "../core/options.js"; +import { emitApi, requestAllPages } from "../core/http.js"; +import { optionalNumber, parseOptions, requiredString } from "../core/options.js"; +import { success } from "../core/output.js"; import type { HandlerMap, RuntimeContext } from "../core/types.js"; function apiGet(ctx: RuntimeContext, argv: string[], summary: string, path: string, key: string): Promise { @@ -7,20 +8,28 @@ function apiGet(ctx: RuntimeContext, argv: string[], summary: string, path: stri return emitApi(ctx, summary, { method: "GET", path, params: { [key]: requiredString(values, key) }, requireProject: true }); } -function apiGetAll(ctx: RuntimeContext, summary: string, path: string): Promise { - return emitApi(ctx, summary, { method: "GET", path, requireProject: true }); +async function apiGetAll(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise { + const { values } = parseOptions(argv, { limit: "integer", "page-size": "integer" }); + const requestedPageSize = optionalNumber(values, "page-size") ?? optionalNumber(values, "limit") ?? 1000; + const pageSize = Math.min(1000, Math.max(1, requestedPageSize)); + const [data, durationMs] = await requestAllPages( + ctx, + { method: "GET", path, requireProject: true }, + pageSize, + ); + success(summary, data, ctx, durationMs); } export const networkHandlers: HandlerMap = { "network get-junction-properties": (ctx, argv) => apiGet(ctx, argv, "读取节点属性成功", "/junctions/properties", "junction"), "network get-pipe-properties": (ctx, argv) => apiGet(ctx, argv, "读取管道属性成功", "/pipes/properties", "pipe"), - "network get-all-pipes-properties": (ctx) => apiGetAll(ctx, "读取全部管道属性成功", "/pipes"), + "network get-all-pipes-properties": (ctx, argv) => apiGetAll(ctx, argv, "读取全部管道属性成功", "/pipes"), "network get-reservoir-properties": (ctx, argv) => apiGet(ctx, argv, "读取水库属性成功", "/reservoirs/properties", "reservoir"), - "network get-all-reservoirs-properties": (ctx) => apiGetAll(ctx, "读取全部水库属性成功", "/reservoirs"), + "network get-all-reservoirs-properties": (ctx, argv) => apiGetAll(ctx, argv, "读取全部水库属性成功", "/reservoirs"), "network get-tank-properties": (ctx, argv) => apiGet(ctx, argv, "读取水箱属性成功", "/tanks/properties", "tank"), - "network get-all-tanks-properties": (ctx) => apiGetAll(ctx, "读取全部水箱属性成功", "/tanks"), + "network get-all-tanks-properties": (ctx, argv) => apiGetAll(ctx, argv, "读取全部水箱属性成功", "/tanks"), "network get-pump-properties": (ctx, argv) => apiGet(ctx, argv, "读取水泵属性成功", "/pumps/properties", "pump"), - "network get-all-pumps-properties": (ctx) => apiGetAll(ctx, "读取全部水泵属性成功", "/pumps"), + "network get-all-pumps-properties": (ctx, argv) => apiGetAll(ctx, argv, "读取全部水泵属性成功", "/pumps"), "network get-valve-properties": (ctx, argv) => apiGet(ctx, argv, "读取阀门属性成功", "/valves/properties", "valve"), - "network get-all-valves-properties": (ctx) => apiGetAll(ctx, "读取全部阀门属性成功", "/valves"), + "network get-all-valves-properties": (ctx, argv) => apiGetAll(ctx, argv, "读取全部阀门属性成功", "/valves"), }; diff --git a/cli/src/core/http.ts b/cli/src/core/http.ts index 27b2af0..9a591eb 100644 --- a/cli/src/core/http.ts +++ b/cli/src/core/http.ts @@ -76,6 +76,100 @@ export async function requestJson(ctx: RuntimeContext, request: RequestOptions): return [payload, durationMs]; } +export async function requestAllPages( + ctx: RuntimeContext, + request: RequestOptions, + pageSize: number, +): Promise<[unknown[], number]> { + const items: unknown[] = []; + let durationMs = 0; + let offset = 0; + let expectedTotal: number | null = null; + + while (expectedTotal === null || offset < expectedTotal) { + const [payload, pageDurationMs] = await requestJson(ctx, { + ...request, + params: { + ...request.params, + limit: pageSize, + offset, + }, + }); + durationMs += pageDurationMs; + const page = normalizePage(payload); + if (!page) { + throw new CliError( + "服务端错误", + "INVALID_PAGINATION_RESPONSE", + "backend collection response must contain items, total, limit, and offset", + 7, + false, + payload, + ); + } + if (expectedTotal === null) { + expectedTotal = page.total; + } else if (page.total !== expectedTotal) { + throw new CliError( + "服务端错误", + "PAGINATION_TOTAL_CHANGED", + `backend collection total changed from ${expectedTotal} to ${page.total}`, + 7, + true, + ); + } + if (page.offset !== offset) { + throw new CliError( + "服务端错误", + "PAGINATION_OFFSET_MISMATCH", + `backend collection returned offset ${page.offset}, expected ${offset}`, + 7, + true, + ); + } + if (page.items.length === 0 && offset < expectedTotal) { + throw new CliError( + "服务端错误", + "PAGINATION_STALLED", + `backend collection returned an empty page at offset ${offset} before total ${expectedTotal}`, + 7, + true, + ); + } + items.push(...page.items); + offset += page.items.length; + } + + return [items.slice(0, expectedTotal ?? 0), durationMs]; +} + +function normalizePage( + payload: unknown, +): { items: unknown[]; limit: number; offset: number; total: number } | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const page = payload as Record; + if ( + !Array.isArray(page.items) || + typeof page.limit !== "number" || + !Number.isInteger(page.limit) || + typeof page.offset !== "number" || + !Number.isInteger(page.offset) || + typeof page.total !== "number" || + !Number.isInteger(page.total) || + page.limit <= 0 || + page.offset < 0 || + page.total < 0 + ) { + return null; + } + return { + items: page.items, + limit: page.limit, + offset: page.offset, + total: page.total, + }; +} + function mapStatus(status: number): number { if (status === 400 || status === 422) return 2; if (status === 401) return 3; diff --git a/cli/src/help/docs.ts b/cli/src/help/docs.ts index 15b4b12..9fb759a 100644 --- a/cli/src/help/docs.ts +++ b/cli/src/help/docs.ts @@ -32,15 +32,15 @@ type CommandSpec = readonly [path: string, summary: string, options: readonly st const commandSpecs: readonly CommandSpec[] = [ ["network get-junction-properties", "读取节点属性", ["--junction "], ["tjwater-cli network get-junction-properties --junction J1"]], ["network get-pipe-properties", "读取管道属性", ["--pipe "], ["tjwater-cli network get-pipe-properties --pipe P1"]], - ["network get-all-pipes-properties", "读取全部管道属性", [], ["tjwater-cli network get-all-pipes-properties"]], + ["network get-all-pipes-properties", "读取全部管道属性", ["[--page-size ]"], ["tjwater-cli network get-all-pipes-properties"]], ["network get-reservoir-properties", "读取水库属性", ["--reservoir "], ["tjwater-cli network get-reservoir-properties --reservoir R1"]], - ["network get-all-reservoirs-properties", "读取全部水库属性", [], ["tjwater-cli network get-all-reservoirs-properties"]], + ["network get-all-reservoirs-properties", "读取全部水库属性", ["[--page-size ]"], ["tjwater-cli network get-all-reservoirs-properties"]], ["network get-tank-properties", "读取水箱属性", ["--tank "], ["tjwater-cli network get-tank-properties --tank T1"]], - ["network get-all-tanks-properties", "读取全部水箱属性", [], ["tjwater-cli network get-all-tanks-properties"]], + ["network get-all-tanks-properties", "读取全部水箱属性", ["[--page-size ]"], ["tjwater-cli network get-all-tanks-properties"]], ["network get-pump-properties", "读取水泵属性", ["--pump "], ["tjwater-cli network get-pump-properties --pump PU1"]], - ["network get-all-pumps-properties", "读取全部水泵属性", [], ["tjwater-cli network get-all-pumps-properties"]], + ["network get-all-pumps-properties", "读取全部水泵属性", ["[--page-size ]"], ["tjwater-cli network get-all-pumps-properties"]], ["network get-valve-properties", "读取阀门属性", ["--valve "], ["tjwater-cli network get-valve-properties --valve V1"]], - ["network get-all-valves-properties", "读取全部阀门属性", [], ["tjwater-cli network get-all-valves-properties"]], + ["network get-all-valves-properties", "读取全部阀门属性", ["[--page-size ]"], ["tjwater-cli network get-all-valves-properties"]], ["component option schema", "读取选项 schema", ["--kind ", "[--pump ]"], ["tjwater-cli component option schema --kind time", "tjwater-cli component option schema --kind energy", "tjwater-cli component option schema --kind pump-energy --pump PUMP1", "tjwater-cli component option schema --kind network"]], ["component option get", "读取选项属性", ["--kind ", "[--pump ]"], ["tjwater-cli component option get --kind time", "tjwater-cli component option get --kind energy", "tjwater-cli component option get --kind pump-energy --pump PUMP1", "tjwater-cli component option get --kind network"]], ["simulation run", "触发指定绝对时间的模拟运行", ["--start-time ", "--duration "], ["tjwater-cli simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30"], ["tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", "tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"]], diff --git a/node-tests/cli/tjwaterCli.node.mjs b/node-tests/cli/tjwaterCli.node.mjs index 530fb42..845de12 100644 --- a/node-tests/cli/tjwaterCli.node.mjs +++ b/node-tests/cli/tjwaterCli.node.mjs @@ -104,7 +104,11 @@ async function startJsonServer(responseData) { url: req.url, }); res.setHeader("content-type", "application/json"); - res.end(JSON.stringify(responseData)); + res.end( + JSON.stringify( + typeof responseData === "function" ? responseData(req) : responseData, + ), + ); }); await new Promise((resolveListen, reject) => { @@ -138,7 +142,20 @@ function normalizeSeenRequest(request) { }; } -async function runAgainstServer(name, runner, args, auth, responseData = { accepted: true }) { +function defaultContractResponse(req) { + const url = new URL(req.url, "http://127.0.0.1"); + if (["/api/v1/pipes", "/api/v1/reservoirs", "/api/v1/tanks", "/api/v1/pumps", "/api/v1/valves"].includes(url.pathname)) { + return { + items: [], + limit: Number(url.searchParams.get("limit")), + offset: Number(url.searchParams.get("offset")), + total: 0, + }; + } + return { accepted: true }; +} + +async function runAgainstServer(name, runner, args, auth, responseData = defaultContractResponse) { const server = await startJsonServer(responseData); try { const result = await runner(["--auth-stdin", ...args], { ...auth, server: server.url }); @@ -232,6 +249,52 @@ test("sends auth headers and simulation body through the backend API contract", } }); +test("get-all network commands collect every backend page", async () => { + const items = Array.from({ length: 2_005 }, (_, index) => ({ + id: `P${index + 1}`, + node1: `N${index + 1}`, + node2: `N${index + 2}`, + })); + const server = await startJsonServer((req) => { + const url = new URL(req.url, "http://127.0.0.1"); + const limit = Number(url.searchParams.get("limit") ?? 100); + const offset = Number(url.searchParams.get("offset") ?? 0); + return { + items: items.slice(offset, offset + limit), + limit, + offset, + total: items.length, + }; + }); + + try { + const result = await runCli( + ["--auth-stdin", "network", "get-all-pipes-properties"], + { + server: server.url, + access_token: "token-1", + project_id: "project-1", + }, + ); + + assert.equal(result.exitCode, 0, result.stderr); + const payload = parseJsonResult(result); + assert.equal(payload.data.length, items.length); + assert.deepEqual(payload.data[0], items[0]); + assert.deepEqual(payload.data.at(-1), items.at(-1)); + assert.deepEqual( + server.seen.map((request) => normalizeSeenRequest(request).query), + [ + { limit: "1000", offset: "0" }, + { limit: "1000", offset: "1000" }, + { limit: "1000", offset: "2000" }, + ], + ); + } finally { + await server.close(); + } +}); + test("uses project scoped headers for realtime data commands", async () => { const server = await startJsonServer([{ id: "P1" }]); try { diff --git a/src/cli/executeCliCommand.ts b/src/cli/executeCliCommand.ts index 1d32cd0..5363118 100644 --- a/src/cli/executeCliCommand.ts +++ b/src/cli/executeCliCommand.ts @@ -10,6 +10,7 @@ export type CliExecutionResult = { signal: NodeJS.Signals | null; status: number; stderr: string; + stderrTruncated: boolean; stdout: string; exceededStream?: OutputStream; }; @@ -17,7 +18,8 @@ export type CliExecutionResult = { type ExecuteCliCommandOptions = { apiBaseUrl: string; cliPath: string; - maxOutputBytes: number; + maxStderrBytes: number; + maxStdoutBytes: number; terminationGraceMs?: number; }; @@ -46,9 +48,11 @@ export const executeCliCommand = async ( timeoutSec: number, options: ExecuteCliCommandOptions, ): Promise => { - const maxOutputBytes = options.maxOutputBytes; - if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes <= 0) { - throw new Error("maxOutputBytes must be a positive safe integer"); + if (!Number.isSafeInteger(options.maxStdoutBytes) || options.maxStdoutBytes <= 0) { + throw new Error("maxStdoutBytes must be a positive safe integer"); + } + if (!Number.isSafeInteger(options.maxStderrBytes) || options.maxStderrBytes <= 0) { + throw new Error("maxStderrBytes must be a positive safe integer"); } const child = spawn( @@ -60,6 +64,7 @@ export const executeCliCommand = async ( const stderrChunks: Buffer[] = []; let stdoutBytes = 0; let stderrBytes = 0; + let stderrTruncated = false; let terminationReason: | "timeout" | "output_limit" @@ -98,23 +103,34 @@ export const executeCliCommand = async ( }, options.terminationGraceMs ?? 1500); }; - const capture = (stream: OutputStream, data: Buffer) => { + const captureStdout = (data: Buffer) => { if (terminationReason) { return; } - const chunks = stream === "stdout" ? stdoutChunks : stderrChunks; - const bytes = stream === "stdout" ? stdoutBytes : stderrBytes; - if (bytes + data.length > maxOutputBytes) { - exceededStream = stream; + if (stdoutBytes + data.length > options.maxStdoutBytes) { + exceededStream = "stdout"; terminate("output_limit"); return; } - chunks.push(data); - if (stream === "stdout") { - stdoutBytes += data.length; - } else { - stderrBytes += data.length; + stdoutChunks.push(data); + stdoutBytes += data.length; + }; + + const captureStderr = (data: Buffer) => { + if (terminationReason || stderrTruncated) { + return; } + const remainingBytes = options.maxStderrBytes - stderrBytes; + if (data.length > remainingBytes) { + if (remainingBytes > 0) { + stderrChunks.push(data.subarray(0, remainingBytes)); + stderrBytes += remainingBytes; + } + stderrTruncated = true; + return; + } + stderrChunks.push(data); + stderrBytes += data.length; }; const timeoutTimer = setTimeout(() => { @@ -123,8 +139,8 @@ export const executeCliCommand = async ( } }, timeoutSec * 1000); - child.stdout.on("data", (data: Buffer) => capture("stdout", data)); - child.stderr.on("data", (data: Buffer) => capture("stderr", data)); + child.stdout.on("data", captureStdout); + child.stderr.on("data", captureStderr); child.stdin.on("error", (error) => { if (terminationReason === null) { executionError = error; @@ -150,6 +166,7 @@ export const executeCliCommand = async ( signal, status: 504, stderr: "", + stderrTruncated, stdout: "", }); return; @@ -166,6 +183,7 @@ export const executeCliCommand = async ( signal, status: 502, stderr: "", + stderrTruncated, stdout: "", }); return; @@ -179,6 +197,7 @@ export const executeCliCommand = async ( signal, status: getCompletedStatus(exitCode, stdout), stderr, + stderrTruncated, stdout, }); }); diff --git a/src/config.ts b/src/config.ts index 097864a..9ae5999 100644 --- a/src/config.ts +++ b/src/config.ts @@ -61,8 +61,20 @@ const envSchema = z TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"), // 代理调用 TJWater 后端 API 的超时时间(毫秒)。 TJWATER_API_TIMEOUT_MS: z.coerce.number().int().positive().default(30000), - // 后端结果在直接内联返回给模型前允许的最大字节数。 + // OpenCode 工具结果以内联形式返回给模型的阈值;更大的结果由 OpenCode 落盘。 MAX_INLINE_RESULT_BYTES: z.coerce.number().int().positive().default(12000), + // 单次 tjwater-cli stdout 的硬上限;超过后终止子进程。 + MAX_CLI_OUTPUT_BYTES: z.coerce + .number() + .int() + .positive() + .default(128 * 1024 * 1024), + // 单次 tjwater-cli stderr 最多保留的字节数;超过后截断但不终止进程。 + MAX_CLI_STDERR_BYTES: z.coerce + .number() + .int() + .positive() + .default(256 * 1024), // 生成结果 preview 时最多抽样的条目数。 MAX_PREVIEW_SAMPLE_ITEMS: z.coerce.number().int().positive().default(3), // memory 持久化存储目录。 @@ -110,7 +122,7 @@ const envSchema = z .number() .int() .positive() - .default(64 * 1024 * 1024), + .default(128 * 1024 * 1024), // result_ref 保留时长(小时)。 RESULT_REF_TTL_HOURS: z.coerce.number().int().positive().default(168), // 定时清理过期 result_ref 的扫描周期(毫秒)。 diff --git a/src/routes/chatPermissionPolicy.ts b/src/routes/chatPermissionPolicy.ts index fef640e..b87d8f7 100644 --- a/src/routes/chatPermissionPolicy.ts +++ b/src/routes/chatPermissionPolicy.ts @@ -1,5 +1,14 @@ +import { lstatSync, readdirSync, realpathSync } from "node:fs"; +import { isAbsolute, relative, resolve, sep } from "node:path"; + export type ApprovalMode = "request" | "auto" | "always"; +export type PermissionApprovalContext = { + metadata?: Record; + patterns?: readonly string[]; + workspaceRoot?: string; +}; + const lowRiskToolPermissions = new Set([ "apply_layer_style", "geocode", @@ -12,10 +21,31 @@ const lowRiskToolPermissions = new Set([ "zoom_to_map", ]); +const lowRiskSearchRootNames = new Set([ + ".opencode", + "cli", + "contracts", + "node-tests", + "scripts", + "src", + "tests", +]); + const normalizePermission = (permission: string) => permission.trim().toLowerCase(); -export const canAutoApprovePermission = (permission: string): boolean => { +export const canAutoApprovePermission = ( + permission: string, + context: PermissionApprovalContext = {}, +): boolean => { const normalized = normalizePermission(permission); + if (normalized === "skill") { + return true; + } + + if (normalized === "glob" || normalized === "grep") { + return isSafeWorkspaceSearch(normalized, context); + } + if (lowRiskToolPermissions.has(normalized)) { return true; } @@ -30,6 +60,7 @@ export const canAutoApprovePermission = (permission: string): boolean => { export const resolvePermissionApproval = ( approvalMode: ApprovalMode, permission: string, + context: PermissionApprovalContext = {}, ) => { if (approvalMode === "always") { return { @@ -40,7 +71,7 @@ export const resolvePermissionApproval = ( } as const; } - if (approvalMode === "auto" && canAutoApprovePermission(permission)) { + if (approvalMode === "auto" && canAutoApprovePermission(permission, context)) { return { autoApprove: true, title: "已自动批准低风险权限", @@ -54,3 +85,138 @@ export const resolvePermissionApproval = ( detail: undefined, } as const; }; + +const isSafeWorkspaceSearch = ( + permission: "glob" | "grep", + context: PermissionApprovalContext, +): boolean => { + const workspaceRoot = context.workspaceRoot?.trim(); + if (!workspaceRoot) { + return false; + } + + const requestedPath = + typeof context.metadata?.path === "string" && context.metadata.path.trim() + ? context.metadata.path + : workspaceRoot; + let root: string; + let searchRoot: string; + try { + root = realpathSync.native(resolve(workspaceRoot)); + searchRoot = realpathSync.native(resolve(root, requestedPath)); + } catch { + return false; + } + + let relativePath = relative(root, searchRoot); + if ( + relativePath === ".." || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { + return false; + } + + const expressions: string[] = []; + if (permission === "glob" && typeof context.metadata?.pattern === "string") { + expressions.push(context.metadata.pattern); + } + if (permission === "glob") { + expressions.push(...(context.patterns ?? [])); + } + if (typeof context.metadata?.include === "string") { + expressions.push(context.metadata.include); + } + if ( + expressions.some( + (expression) => + isAbsolute(expression) || + containsParentTraversal(expression) || + containsAmbiguousGlobSyntax(expression) || + containsProtectedPath(expression), + ) + ) { + return false; + } + + if (!relativePath) { + if (permission !== "glob") { + return false; + } + const literalPrefix = getLiteralGlobPrefix(expressions[0]); + if (!literalPrefix) { + return false; + } + try { + searchRoot = realpathSync.native(resolve(root, literalPrefix)); + } catch { + return false; + } + relativePath = relative(root, searchRoot); + } + + return ( + relativePath !== "" && + relativePath !== ".." && + !relativePath.startsWith(`..${sep}`) && + !isAbsolute(relativePath) && + !containsProtectedPath(relativePath) && + isSafeSearchTarget(searchRoot, relativePath) + ); +}; + +const isSafeSearchTarget = (searchRoot: string, relativePath: string): boolean => { + try { + const target = lstatSync(searchRoot); + if (target.isFile()) { + return true; + } + if (!target.isDirectory()) { + return false; + } + const topLevelName = relativePath.split(sep)[0]; + if (!topLevelName || !lowRiskSearchRootNames.has(topLevelName)) { + return false; + } + + const pending = [searchRoot]; + while (pending.length > 0) { + const directory = pending.pop()!; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isSymbolicLink() || containsProtectedPath(entry.name)) { + return false; + } + if (entry.isDirectory()) { + pending.push(resolve(directory, entry.name)); + } + } + } + return true; + } catch { + return false; + } +}; + +const getLiteralGlobPrefix = (expression: string | undefined): string | null => { + const firstSegment = expression + ?.replaceAll("\\", "/") + .replace(/^\.\//, "") + .split("/")[0]; + return firstSegment && !/[*?[\]{}()!+@]/.test(firstSegment) + ? firstSegment + : null; +}; + +const containsParentTraversal = (value: string): boolean => + value.replaceAll("\\", "/").split("/").includes(".."); + +const containsAmbiguousGlobSyntax = (value: string): boolean => + /[?[\]{}()!+@\\]/.test(value); + +const containsProtectedPath = (value: string): boolean => { + const normalized = value.replaceAll("\\", "/").toLowerCase(); + return ( + normalized.includes(".env") || + /(?:^|[^a-z0-9_-])(?:data|logs)(?:$|[^a-z0-9_-])/.test(normalized) + ); +}; diff --git a/src/routes/chatStream.ts b/src/routes/chatStream.ts index 63a9ed0..d1de2af 100644 --- a/src/routes/chatStream.ts +++ b/src/routes/chatStream.ts @@ -394,6 +394,11 @@ export const streamPromptResponse = async ({ const permissionApproval = resolvePermissionApproval( approvalMode, event.properties.permission, + { + metadata: event.properties.metadata, + patterns: event.properties.patterns, + workspaceRoot: process.cwd(), + }, ); logDevelopmentDebug("permission request received", { ...debugContext, @@ -440,6 +445,11 @@ export const streamPromptResponse = async ({ const permissionApproval = resolvePermissionApproval( approvalMode, event.properties.action, + { + metadata: event.properties.metadata, + patterns: event.properties.resources, + workspaceRoot: process.cwd(), + }, ); logDevelopmentDebug("permission v2 request received", { ...debugContext, diff --git a/src/runtime/opencode.ts b/src/runtime/opencode.ts index 2427531..100f487 100644 --- a/src/runtime/opencode.ts +++ b/src/runtime/opencode.ts @@ -7,6 +7,10 @@ import { resolve } from "node:path"; import { config } from "../config.js"; import { logger } from "../logger.js"; +import { + cleanupExpiredToolOutputs, + resolveOpencodeToolOutputDirectory, +} from "./opencodeToolOutputCleanup.js"; const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development"; @@ -46,6 +50,7 @@ const getRuntimeMessageId = (message: RuntimeMessage) => message.info.id; export class OpencodeRuntimeAdapter { private clientPromise: Promise | null = null; private closeServer: (() => void) | null = null; + private toolOutputCleanupTimer: ReturnType | null = null; async ensureClient(): Promise { if (!this.clientPromise) { @@ -380,12 +385,18 @@ export class OpencodeRuntimeAdapter { } async dispose(): Promise { + if (this.toolOutputCleanupTimer) { + clearInterval(this.toolOutputCleanupTimer); + this.toolOutputCleanupTimer = null; + } this.closeServer?.(); this.closeServer = null; this.clientPromise = null; } private async bootstrapClient(): Promise { + await this.cleanupToolOutputs(); + // embedded 模式下,把服务内工具桥地址注入到 opencode 进程环境里, // 这样 .opencode/tools 下的自定义工具可以回调本服务。 process.env.TJWATER_AGENT_INTERNAL_BASE_URL = `http://127.0.0.1:${config.PORT}`; @@ -436,9 +447,39 @@ export class OpencodeRuntimeAdapter { this.closeServer = () => { runtime.server.close(); }; + this.startToolOutputCleanupLoop(); return runtime.client; } + + private async cleanupToolOutputs(): Promise { + const directory = resolveOpencodeToolOutputDirectory(); + const ttlMs = config.RESULT_REF_TTL_HOURS * 60 * 60 * 1000; + try { + const result = await cleanupExpiredToolOutputs(directory, ttlMs); + if (result.removed > 0) { + logger.info( + { directory, ...result }, + "removed expired opencode tool output files", + ); + } + } catch (error) { + logger.warn( + { err: error, directory }, + "failed to clean expired opencode tool output files", + ); + } + } + + private startToolOutputCleanupLoop(): void { + if (this.toolOutputCleanupTimer) { + return; + } + this.toolOutputCleanupTimer = setInterval(() => { + void this.cleanupToolOutputs(); + }, config.RESULT_REF_CLEANUP_INTERVAL_MS); + this.toolOutputCleanupTimer.unref(); + } } export const opencodeRuntime = new OpencodeRuntimeAdapter(); @@ -448,6 +489,10 @@ function buildOpencodeConfig(): Record { deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()), { model: config.OPENCODE_MODEL, + tool_output: { + max_bytes: config.MAX_INLINE_RESULT_BYTES, + max_lines: 2000, + }, }, ); } diff --git a/src/runtime/opencodeToolOutputCleanup.ts b/src/runtime/opencodeToolOutputCleanup.ts new file mode 100644 index 0000000..d39b4da --- /dev/null +++ b/src/runtime/opencodeToolOutputCleanup.ts @@ -0,0 +1,59 @@ +import { readdir, rm, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export type ToolOutputCleanupResult = { + removed: number; + scanned: number; +}; + +export const resolveOpencodeToolOutputDirectory = (): string => { + const dataRoot = process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share"); + return join(dataRoot, "opencode", "tool-output"); +}; + +export const cleanupExpiredToolOutputs = async ( + directory: string, + ttlMs: number, + now = Date.now(), +): Promise => { + if (!Number.isFinite(ttlMs) || ttlMs <= 0) { + throw new Error("tool output cleanup ttlMs must be a positive number"); + } + + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (isNodeError(error, "ENOENT")) { + return { removed: 0, scanned: 0 }; + } + throw error; + } + + let removed = 0; + for (const entry of entries) { + if (!entry.isFile() || !entry.name.startsWith("tool_")) { + continue; + } + + const path = join(directory, entry.name); + try { + const file = await stat(path); + if (now - file.mtimeMs <= ttlMs) { + continue; + } + await rm(path); + removed += 1; + } catch (error) { + if (!isNodeError(error, "ENOENT")) { + throw error; + } + } + } + + return { removed, scanned: entries.length }; +}; + +const isNodeError = (error: unknown, code: string): error is NodeJS.ErrnoException => + error instanceof Error && "code" in error && error.code === code; diff --git a/src/server.ts b/src/server.ts index feaeba6..03d0cfe 100644 --- a/src/server.ts +++ b/src/server.ts @@ -233,7 +233,8 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => { executeCliCommand(activeContext, command, timeoutSec, { apiBaseUrl: config.TJWATER_API_BASE_URL, cliPath: config.TJWATER_CLI_PATH, - maxOutputBytes: config.MAX_INLINE_RESULT_BYTES, + maxStderrBytes: config.MAX_CLI_STDERR_BYTES, + maxStdoutBytes: config.MAX_CLI_OUTPUT_BYTES, }), ); } catch (error) { @@ -278,7 +279,7 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => { summary: "CLI 输出超过安全限制", error: { code: "OUTPUT_LIMIT_EXCEEDED", - message: `${result.exceededStream ?? "output"} exceeded ${config.MAX_INLINE_RESULT_BYTES} bytes`, + message: `${result.exceededStream ?? "output"} exceeded ${config.MAX_CLI_OUTPUT_BYTES} bytes`, retryable: false, }, }); @@ -307,16 +308,17 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => { return; } - try { - res.json(JSON.parse(result.stdout)); - } catch { - res.json({ - ok: true, - schema_version: "tjwater-cli/v1", - raw: result.stdout, - stderr: result.stderr || undefined, - }); + if (result.stdout.trim()) { + res.status(200).type("application/json").send(result.stdout); + return; } + res.json({ + ok: true, + schema_version: "tjwater-cli/v1", + raw: "", + stderr: result.stderr || undefined, + stderr_truncated: result.stderrTruncated || undefined, + }); }); app.post("/internal/tools/store-render-ref", async (req, res) => { diff --git a/tests/cli/executeCliCommand.test.ts b/tests/cli/executeCliCommand.test.ts index b82b33d..aee7405 100644 --- a/tests/cli/executeCliCommand.test.ts +++ b/tests/cli/executeCliCommand.test.ts @@ -21,7 +21,8 @@ const context: RuntimeSessionContext = { const run = ( command: string, options: { - maxOutputBytes?: number; + maxStderrBytes?: number; + maxStdoutBytes?: number; terminationGraceMs?: number; timeoutSec?: number; } = {}, @@ -29,13 +30,14 @@ const run = ( executeCliCommand(context, command, options.timeoutSec ?? 1, { apiBaseUrl: "http://127.0.0.1:8000", cliPath, - maxOutputBytes: options.maxOutputBytes ?? 64, + maxStderrBytes: options.maxStderrBytes ?? 8, + maxStdoutBytes: options.maxStdoutBytes ?? 64, terminationGraceMs: options.terminationGraceMs ?? 20, }); describe("executeCliCommand", () => { test("accepts output at the byte limit", async () => { - await expect(run("stdout 123456", { maxOutputBytes: 6 })).resolves.toMatchObject({ + await expect(run("stdout 123456", { maxStdoutBytes: 6 })).resolves.toMatchObject({ outcome: "completed", exitCode: 0, status: 200, @@ -43,8 +45,16 @@ describe("executeCliCommand", () => { }); }); + test("does not treat the OpenCode 12000-byte inline threshold as a CLI limit", async () => { + const stdout = "x".repeat(12_001); + const result = await run(`stdout ${stdout}`, { maxStdoutBytes: 128 * 1024 * 1024 }); + + expect(result.outcome).toBe("completed"); + expect(result.stdout).toBe(stdout); + }); + test("rejects multibyte output above the byte limit without returning a partial body", async () => { - await expect(run("stdout 水水", { maxOutputBytes: 5 })).resolves.toMatchObject({ + await expect(run("stdout 水水", { maxStdoutBytes: 5 })).resolves.toMatchObject({ outcome: "output_limit", exceededStream: "stdout", status: 502, @@ -53,13 +63,16 @@ describe("executeCliCommand", () => { }); }); - test("limits stderr independently", async () => { - await expect(run("stderr 1234567", { maxOutputBytes: 6 })).resolves.toMatchObject({ - outcome: "output_limit", - exceededStream: "stderr", - status: 502, - stderr: "", - stdout: "", + test("truncates stderr independently without terminating a successful command", async () => { + await expect( + run("stderr-success 123456789", { maxStderrBytes: 6 }), + ).resolves.toMatchObject({ + outcome: "completed", + exitCode: 0, + status: 200, + stderr: "123456", + stderrTruncated: true, + stdout: '{"ok":true}', }); }); @@ -94,7 +107,8 @@ describe("executeCliCommand", () => { executeCliCommand(largeContext, "ignore-term", 0.25, { apiBaseUrl: "http://127.0.0.1:8000", cliPath, - maxOutputBytes: 64, + maxStderrBytes: 8, + maxStdoutBytes: 64, terminationGraceMs: 20, }), ).resolves.toMatchObject({ @@ -114,7 +128,8 @@ describe("executeCliCommand", () => { executeCliCommand(largeContext, "closed-stdin", 1, { apiBaseUrl: "http://127.0.0.1:8000", cliPath, - maxOutputBytes: 64, + maxStderrBytes: 8, + maxStdoutBytes: 64, terminationGraceMs: 20, }), ).rejects.toBeInstanceOf(Error); diff --git a/tests/fixtures/fakeCli.mjs b/tests/fixtures/fakeCli.mjs index 373494f..59d78c2 100755 --- a/tests/fixtures/fakeCli.mjs +++ b/tests/fixtures/fakeCli.mjs @@ -15,6 +15,12 @@ if (command === "stderr") { process.exit(1); } +if (command === "stderr-success") { + process.stderr.write(value); + process.stdout.write('{"ok":true}'); + process.exit(0); +} + if (command === "term") { process.on("SIGTERM", () => { setTimeout(() => process.exit(0), 30); diff --git a/tests/routes/chatPermissionPolicy.test.ts b/tests/routes/chatPermissionPolicy.test.ts index 2f63859..277c6b2 100644 --- a/tests/routes/chatPermissionPolicy.test.ts +++ b/tests/routes/chatPermissionPolicy.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "bun:test"; +import { mkdtemp, mkdir, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { canAutoApprovePermission, @@ -9,10 +12,100 @@ describe("permission approval policy", () => { it.each([ "show_chart", "web_search", + "skill", ])("allows low-risk permission %s", (permission) => { expect(canAutoApprovePermission(permission)).toBe(true); }); + it("allows structured searches within the workspace", () => { + const workspaceRoot = process.cwd(); + const context = { + workspaceRoot, + metadata: { path: join(workspaceRoot, "src"), include: "*.ts" }, + patterns: ["*.ts"], + }; + expect(canAutoApprovePermission("glob", context)).toBe(true); + expect(canAutoApprovePermission("grep", context)).toBe(true); + }); + + it("allows a glob with an explicit safe prefix from the workspace root", () => { + expect( + canAutoApprovePermission("glob", { + workspaceRoot: process.cwd(), + metadata: { path: process.cwd(), pattern: "src/**/*.ts" }, + patterns: ["src/**/*.ts"], + }), + ).toBe(true); + }); + + it.each([ + { metadata: { path: dirname(process.cwd()) }, patterns: ["*"] }, + { metadata: { path: join(process.cwd(), ".local.env") }, patterns: ["*"] }, + { metadata: { path: join(process.cwd(), "data") }, patterns: ["*"] }, + { metadata: { path: join(process.cwd(), "src") }, patterns: ["../logs/**"] }, + { metadata: { path: process.cwd() }, patterns: ["**/*.env"] }, + { metadata: { path: process.cwd() }, patterns: ["**/*"] }, + { metadata: { path: join(process.cwd(), "src") }, patterns: [".[e]nv"] }, + ])("keeps protected or external searches interactive", (request) => { + expect( + canAutoApprovePermission("glob", { + workspaceRoot: process.cwd(), + ...request, + }), + ).toBe(false); + }); + + it("keeps grep from the workspace root interactive", () => { + expect( + canAutoApprovePermission("grep", { + workspaceRoot: process.cwd(), + metadata: { path: process.cwd(), include: "*.ts" }, + patterns: ["secret"], + }), + ).toBe(false); + }); + + it("rejects a workspace symlink that resolves outside the workspace", async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), "permission-workspace-")); + const externalRoot = await mkdtemp(join(tmpdir(), "permission-external-")); + try { + await mkdir(join(externalRoot, "src")); + const linkedPath = join(workspaceRoot, "linked"); + await symlink(join(externalRoot, "src"), linkedPath, "dir"); + + expect( + canAutoApprovePermission("grep", { + workspaceRoot, + metadata: { path: linkedPath, include: "*.ts" }, + patterns: ["secret"], + }), + ).toBe(false); + } finally { + await Promise.all([ + rm(workspaceRoot, { force: true, recursive: true }), + rm(externalRoot, { force: true, recursive: true }), + ]); + } + }); + + it("rejects protected descendants below an otherwise safe search root", async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), "permission-descendant-")); + try { + const sourceRoot = join(workspaceRoot, "src"); + await mkdir(join(sourceRoot, "data"), { recursive: true }); + + expect( + canAutoApprovePermission("grep", { + workspaceRoot, + metadata: { path: sourceRoot, include: "*.ts" }, + patterns: ["secret"], + }), + ).toBe(false); + } finally { + await rm(workspaceRoot, { force: true, recursive: true }); + } + }); + it.each([ "bash", "edit", diff --git a/tests/runtime/opencodeToolOutputCleanup.test.ts b/tests/runtime/opencodeToolOutputCleanup.test.ts new file mode 100644 index 0000000..4130e18 --- /dev/null +++ b/tests/runtime/opencodeToolOutputCleanup.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { + lstat, + mkdir, + mkdtemp, + readFile, + rm, + stat, + symlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { cleanupExpiredToolOutputs } from "../../src/runtime/opencodeToolOutputCleanup.js"; + +describe("cleanupExpiredToolOutputs", () => { + test("removes only expired regular tool output files", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-tool-output-cleanup-")); + const expired = join(directory, "tool_expired"); + const current = join(directory, "tool_current"); + const unrelated = join(directory, "keep.txt"); + const toolDirectory = join(directory, "tool_directory"); + const toolSymlink = join(directory, "tool_symlink"); + try { + await Promise.all([ + writeFile(expired, "expired"), + writeFile(current, "current"), + writeFile(unrelated, "unrelated"), + mkdir(toolDirectory), + ]); + await symlink(unrelated, toolSymlink); + const now = Date.now(); + const old = new Date(now - 8 * 24 * 60 * 60 * 1000); + await utimes(expired, old, old); + + const result = await cleanupExpiredToolOutputs( + directory, + 7 * 24 * 60 * 60 * 1000, + now, + ); + + expect(result).toEqual({ removed: 1, scanned: 5 }); + await expect(stat(expired)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(current, "utf8")).toBe("current"); + expect(await readFile(unrelated, "utf8")).toBe("unrelated"); + expect((await stat(toolDirectory)).isDirectory()).toBe(true); + expect((await lstat(toolSymlink)).isSymbolicLink()).toBe(true); + } finally { + await rm(directory, { force: true, recursive: true }); + } + }); + + test("treats a missing tool output directory as empty", async () => { + const directory = join(tmpdir(), `missing-tool-output-${crypto.randomUUID()}`); + await expect(cleanupExpiredToolOutputs(directory, 1_000)).resolves.toEqual({ + removed: 0, + scanned: 0, + }); + }); +});