From fa551f336f6df72411a37a241948e660941ceba3 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 29 Sep 2026 16:21:58 -0700 Subject: [PATCH 1/2] fix(auth): point store tests at the testkit mock helper The credential-lock tests imported a path that does not exist, so typecheck failed on main. Every other mock.module caller already uses testkit. --- src/auth/store.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth/store.test.ts b/src/auth/store.test.ts index a29f23cf6..ae0d7a9ed 100644 --- a/src/auth/store.test.ts +++ b/src/auth/store.test.ts @@ -11,7 +11,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { type } from "arktype"; -import { withMockedModule } from "../../tests/helpers/mock-module.js"; +import { withMockedModule } from "../../testkit/mock-module.js"; import type { BaseTokens } from "./store.js"; let failingUnlinkPath: string | undefined; From d02bfd502b30ce619fc9d77ae32bf3b1d87a1819 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 29 Sep 2026 16:22:07 -0700 Subject: [PATCH 2/2] feat(read): diagnose blocked file inspection with a next action read_file used to dump PDFs as text or refuse binaries without saying whether the gap was a missing extractor, a worker that cannot run host commands, or a malformed file. The first failure now names that cause and the concrete unblock. --- docs/ARCHITECTURE.md | 2 +- src/plugins/file-inspection-diagnosis.test.ts | 136 ++++++++++++++++++ src/plugins/file-inspection-diagnosis.ts | 127 ++++++++++++++++ src/plugins/read-file-guard-plugin.test.ts | 87 +++++++++++ src/plugins/read-file-guard-plugin.ts | 92 +++++++++++- src/subagent/run.ts | 13 +- 6 files changed, 450 insertions(+), 7 deletions(-) create mode 100644 src/plugins/file-inspection-diagnosis.test.ts create mode 100644 src/plugins/file-inspection-diagnosis.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8ba7752a3..1b6e15949 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -415,7 +415,7 @@ tool call - **Authorization** (`run-shell-authz.ts`, enforced by the permission gate) — Denies catastrophic shell command patterns by regex, and hard-blocks shell `find`, head-position `rg`, and recursive `grep -r` (they can walk huge trees and OOM the host). Bounded `grep`/`search_files` tools remain practical alternatives (timeout + output caps); the patterns match those three command shapes only — an `ls -R`, `fd`, or scripted `os.walk` is just as unbounded and is not caught, so the block message tells the model not to substitute one. The gate hard-denies these at the top of its verdict path — before auto-allow, prompting, grants, and skipPermissions — so no mode or stored grant can admit them. - **Permission** (`permission-plugin.ts`) — Delegates consequential calls to the permission gate. - **Shell Guard** (`shell-guard-plugin.ts`) — Corbits Code-only replacement for stock `run_shell` (interchange stays unpatched): 120s foreground default (`settings.shell.timeoutMs` overrides that default only; a positive per-call `timeout` is the bound with no ceiling — `maxTimeoutMs` does not clamp it), background `run_shell` has no default (only a per-call timeout arms a timer), 512KB display cap with head+tail retention (the process keeps running when the cap is hit), process-group kill on timeout, abort, and plugin dispose (live children tracked in the plugin and reaped by `posixTools.dispose`), and `background: true` — the call returns a `shell_id` at once (registry in `src/shell/background-shell.ts`), the process group keeps running past the turn, completion is process-exit (stdio-close is not required), delivered on a later turn via `buildShellBackgroundMessage`, and `shell_collect` retrieves or cancels with a 300s wait cap (schema advertised by `advertiseShellGuardTimeout` only when `shell_collect` is mounted; evaluated by the permission chain at start time like any shell call). Foreground expiry is exit 124 + `timedOut:true` plus a nudge to retry with `background:true`. Also applies a 10s wall-clock budget to `grep`/`search_files` (budget expiry and budget-torn-down aborts surface as explicit timeout errors, never as empty results; an unbounded workspace-root `search_files` walk is refused up front with scope guidance instead of running to the budget). Ripgrep detached spawns are not tracked. -- **Read File Guard** (`read-file-guard-plugin.ts`) — Corbits Code-only short-circuit for `read_file` on real filesystem paths and configured `tool-output://` URIs (interchange stays unpatched): streaming reads that never decode the whole file in one pass, caps model-facing output at 50KB, defaults to 2000 lines, truncates long lines with recovery hints, samples the first chunk to reject binary, and stops at an 8MB scan ceiling. Emits `offset` continuation notices so the model can page without losing file or spill content on disk. +- **Read File Guard** (`read-file-guard-plugin.ts`) — Corbits Code-only short-circuit for `read_file` on real filesystem paths and configured `tool-output://` URIs (interchange stays unpatched): streaming reads that never decode the whole file in one pass, caps model-facing output at 50KB, defaults to 2000 lines, truncates long lines with recovery hints, samples the first chunk to reject binary and to diagnose blocked PDF inspection (missing `pdftotext` vs installed-but-worker-cannot-execute vs malformed/unreadable file, each with one next action), and stops at an 8MB scan ceiling. Emits `offset` continuation notices so the model can page without losing file or spill content on disk. - **Verify** (`verify-plugin.ts`) — Re-reads after `write_file` / `edit_file` and errors on mismatch. Per-path serialization (`file-mutation-lock.ts`) prevents parallel edits on one file from tripping verification. - **Edit file line range** (`edit-file-line-range-plugin.ts`) — Corbits Code-only short-circuit for `edit_file` mode B (`start_line`/`end_line`/`new_string`), same pattern as shell-guard; schema advertised via `advertiseEditFileLineRange`. Modes are mutually exclusive: a call supplying both `old_string` and `start_line`/`end_line` is rejected with a recoverable error naming which fields to omit (no file-content disambiguation). - **Edit file diagnostics** (`edit-file-diagnostics-plugin.ts`) — On stock substring mismatch (`old_string not found` / not unique), appends nearby file context (whitespace near-miss, occurrence lines) without changing match semantics. diff --git a/src/plugins/file-inspection-diagnosis.test.ts b/src/plugins/file-inspection-diagnosis.test.ts new file mode 100644 index 000000000..d2d6cd177 --- /dev/null +++ b/src/plugins/file-inspection-diagnosis.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test"; +import { + chunkLooksLikePdf, + diagnoseBlockedFileInspection, + inspectionKindFromFirstChunk, + pathLooksLikePdf, + PDF_EXTRACTOR_BIN, +} from "./file-inspection-diagnosis.js"; + +const PATH = "/tmp/report.pdf"; + +describe("PDF sniffing", () => { + test("magic bytes classify as pdf regardless of suffix", () => { + expect(chunkLooksLikePdf(Buffer.from("%PDF-1.7\n"))).toBe(true); + expect( + inspectionKindFromFirstChunk("notes.bin", Buffer.from("%PDF-1.4")), + ).toBe("pdf"); + }); + + test("a .pdf name without magic is malformed, not a capability miss", () => { + expect(pathLooksLikePdf("report.PDF")).toBe(true); + expect( + inspectionKindFromFirstChunk("report.pdf", Buffer.from("not-a-pdf\0")), + ).toBe("pdf-malformed"); + expect( + inspectionKindFromFirstChunk("report.pdf", Buffer.from("plain text")), + ).toBe("pdf-malformed"); + }); + + test("NUL without PDF markers is ordinary binary", () => { + expect( + inspectionKindFromFirstChunk("blob.bin", Buffer.from([0x41, 0x00, 0x42])), + ).toBe("binary"); + }); + + test("plain text is not an inspection block", () => { + expect( + inspectionKindFromFirstChunk("readme.txt", Buffer.from("hello\n")), + ).toBeUndefined(); + }); +}); + +describe("diagnoseBlockedFileInspection", () => { + test("missing extractor names the capability and an install action", () => { + const result = diagnoseBlockedFileInspection({ + path: PATH, + kind: "pdf", + extractorAvailable: false, + canExecuteHostCommands: true, + }); + expect(result.code).toBe("missing_extractor"); + expect(result.message).toContain("PDF"); + expect(result.message).toContain(PDF_EXTRACTOR_BIN); + expect(result.message).toContain("missing"); + expect(result.message.toLowerCase()).toContain("poppler"); + expect(result.message).toContain("not a malformed file"); + expect(result.message).not.toContain("permission boundary"); + }); + + test("missing extractor wins over an unmounted shell (first gap)", () => { + const result = diagnoseBlockedFileInspection({ + path: PATH, + kind: "pdf", + extractorAvailable: false, + canExecuteHostCommands: false, + }); + expect(result.code).toBe("missing_extractor"); + }); + + test("installed extractor with no run_shell is a permission boundary", () => { + const result = diagnoseBlockedFileInspection({ + path: PATH, + kind: "pdf", + extractorAvailable: true, + canExecuteHostCommands: false, + }); + expect(result.code).toBe("permission_boundary"); + expect(result.message).toContain("installed"); + expect(result.message).toContain("permission boundary"); + expect(result.message).toContain("run_shell"); + expect(result.message).toContain("not a malformed file"); + expect(result.message.toLowerCase()).not.toContain("brew install"); + }); + + test("installed extractor the worker can run names the bash extract command", () => { + const result = diagnoseBlockedFileInspection({ + path: PATH, + kind: "pdf", + extractorAvailable: true, + canExecuteHostCommands: true, + }); + expect(result.code).toBe("extractor_ready"); + expect(result.message).toContain("bash"); + expect(result.message).toContain(`${PDF_EXTRACTOR_BIN} ${PATH} -`); + }); + + test("malformed PDF is not blamed on tooling or permission", () => { + const result = diagnoseBlockedFileInspection({ + path: PATH, + kind: "pdf-malformed", + extractorAvailable: false, + canExecuteHostCommands: false, + }); + expect(result.code).toBe("malformed"); + expect(result.message).toContain("not a valid PDF"); + expect(result.message).toContain("not a missing extractor"); + expect(result.message).not.toContain("brew install"); + expect(result.message).not.toContain("grant this worker"); + }); + + test("unreadable file is filesystem permission, not a worker-tool gap", () => { + const result = diagnoseBlockedFileInspection({ + path: PATH, + kind: "unreadable", + extractorAvailable: true, + canExecuteHostCommands: true, + }); + expect(result.code).toBe("unreadable"); + expect(result.message).toContain("unreadable"); + expect(result.message).toContain("filesystem permission"); + expect(result.message).not.toContain("grant this worker"); + expect(result.message.toLowerCase()).not.toContain("poppler"); + }); + + test("ordinary binary is not a missing-tool failure", () => { + const result = diagnoseBlockedFileInspection({ + path: "/tmp/blob.bin", + kind: "binary", + extractorAvailable: false, + canExecuteHostCommands: false, + }); + expect(result.code).toBe("binary"); + expect(result.message).toContain("refusing to read binary file"); + expect(result.message).toContain("not a missing-tool"); + }); +}); diff --git a/src/plugins/file-inspection-diagnosis.ts b/src/plugins/file-inspection-diagnosis.ts new file mode 100644 index 000000000..4d4336a75 --- /dev/null +++ b/src/plugins/file-inspection-diagnosis.ts @@ -0,0 +1,127 @@ +import { extname } from "node:path"; + +/** Host binary that extracts text from a PDF (poppler). */ +export const PDF_EXTRACTOR_BIN = "pdftotext"; + +const PDF_MAGIC = Buffer.from("%PDF"); + +export type FileInspectionKind = + | "pdf" + | "pdf-malformed" + | "binary" + | "unreadable"; + +export type FileInspectionBlockCode = + | "missing_extractor" + | "permission_boundary" + | "extractor_ready" + | "malformed" + | "unreadable" + | "binary"; + +export interface FileInspectionDiagnosis { + code: FileInspectionBlockCode; + message: string; +} + +export interface DiagnoseBlockedFileInspectionInput { + path: string; + kind: FileInspectionKind; + extractorAvailable: boolean; + canExecuteHostCommands: boolean; + extractorName?: string; +} + +export function chunkLooksLikePdf(chunk: Uint8Array): boolean { + return ( + chunk.length >= PDF_MAGIC.length && + Buffer.from(chunk.subarray(0, PDF_MAGIC.length)).equals(PDF_MAGIC) + ); +} + +export function pathLooksLikePdf(filePath: string): boolean { + return extname(filePath).toLowerCase() === ".pdf"; +} + +/** + * Classify the first streamed chunk. PDF magic wins even without a .pdf + * suffix. A .pdf name without magic is malformed, whether or not the chunk + * looks binary. NUL without PDF markers is an ordinary binary file. + */ +export function inspectionKindFromFirstChunk( + filePath: string, + chunk: Uint8Array, +): FileInspectionKind | undefined { + if (chunkLooksLikePdf(chunk)) return "pdf"; + if (pathLooksLikePdf(filePath)) return "pdf-malformed"; + if (chunk.includes(0)) return "binary"; + return undefined; +} + +function extractorLabel(name: string | undefined): string { + return name !== undefined && name.length > 0 ? name : PDF_EXTRACTOR_BIN; +} + +/** + * Operator- and model-facing diagnosis when read_file cannot inspect a file. + * One fact (capability vs permission vs bad file) plus one next action. + */ +export function diagnoseBlockedFileInspection( + input: DiagnoseBlockedFileInspectionInput, +): FileInspectionDiagnosis { + const extractor = extractorLabel(input.extractorName); + const path = input.path; + + if (input.kind === "unreadable") { + return { + code: "unreadable", + message: + `Cannot inspect ${path}: the file is unreadable (filesystem permission denied), not a missing extractor or worker-tool gap. ` + + `Check ownership and mode, or copy the file into a readable workspace path.`, + }; + } + + if (input.kind === "pdf-malformed") { + return { + code: "malformed", + message: + `Cannot inspect ${path}: the file is not a valid PDF (malformed or unreadable bytes), not a missing extractor or permission boundary. ` + + `Open it in a PDF reader or re-export it, then retry.`, + }; + } + + if (input.kind === "binary") { + return { + code: "binary", + message: + `refusing to read binary file: ${path}. This is a binary file, not a missing-tool or permission failure. ` + + `Use a format-specific extractor if you need text from it.`, + }; + } + + // kind === "pdf" + if (!input.extractorAvailable) { + return { + code: "missing_extractor", + message: + `Cannot inspect PDF ${path}: ${extractor} is not installed (missing PDF-extraction capability), not a malformed file. ` + + `Install poppler so ${extractor} is on PATH (for example \`brew install poppler\` or \`apt-get install poppler-utils\`), then retry.`, + }; + } + + if (!input.canExecuteHostCommands) { + return { + code: "permission_boundary", + message: + `Cannot inspect PDF ${path}: ${extractor} is installed, but this worker cannot execute it (run_shell is not mounted — a permission boundary, not a malformed file). ` + + `Re-dispatch to a director that mounts run_shell, or grant this worker run_shell, then retry.`, + }; + } + + return { + code: "extractor_ready", + message: + `Cannot inspect PDF ${path} as text via read_file. ${extractor} is installed and this session can run host commands. ` + + `Run \`${extractor} ${path} -\` via bash to extract text.`, + }; +} diff --git a/src/plugins/read-file-guard-plugin.test.ts b/src/plugins/read-file-guard-plugin.test.ts index af1ce8173..7e5c5e215 100644 --- a/src/plugins/read-file-guard-plugin.test.ts +++ b/src/plugins/read-file-guard-plugin.test.ts @@ -126,6 +126,57 @@ describe("readFileBounded", () => { ); expect(isError).toBe(true); expect(content).toContain("binary"); + expect(content).toContain("not a missing-tool"); + }); + + test("a PDF without pdftotext names the missing extractor, not a malformed file", async () => { + const p = await fixture("report.pdf", Buffer.from("%PDF-1.4\n1 0 obj\n")); + const { content, isError } = await readFileBounded( + p, + 0, + 2000, + neverAbort(), + { whichExtractor: () => null, canExecuteHostCommands: () => true }, + ); + expect(isError).toBe(true); + expect(content).toContain("pdftotext"); + expect(content).toContain("missing"); + expect(content).toContain("not a malformed file"); + expect(content).not.toContain("permission boundary"); + }); + + test("a PDF with pdftotext installed but no run_shell names the permission boundary", async () => { + const p = await fixture("locked.pdf", Buffer.from("%PDF-1.7\n")); + const { content, isError } = await readFileBounded( + p, + 0, + 2000, + neverAbort(), + { + whichExtractor: () => "/usr/bin/pdftotext", + canExecuteHostCommands: () => false, + }, + ); + expect(isError).toBe(true); + expect(content).toContain("installed"); + expect(content).toContain("permission boundary"); + expect(content).toContain("run_shell"); + expect(content).toContain("not a malformed file"); + }); + + test("a .pdf name without magic is malformed, not a missing extractor", async () => { + const p = await fixture("fake.pdf", Buffer.from("not a pdf\0")); + const { content, isError } = await readFileBounded( + p, + 0, + 2000, + neverAbort(), + { whichExtractor: () => null, canExecuteHostCommands: () => false }, + ); + expect(isError).toBe(true); + expect(content).toContain("not a valid PDF"); + expect(content).toContain("not a missing extractor"); + expect(content).not.toContain("brew install"); }); test("a NUL deep in an otherwise-valid file does not discard streamed content", async () => { @@ -737,6 +788,42 @@ describe("readFileGuardPlugin", () => { expect(replay.isError).toBe(true); expect(String(replay.content)).toBe(String(first.content)); }); + + test("PDF inspection: extractor unavailable then installed but worker cannot execute", async () => { + await fixture("flow.pdf", Buffer.from("%PDF-1.4\ntrailer\n")); + const call: ToolCall = { + id: "pdf-flow", + name: "read_file", + arguments: { path: "flow.pdf" }, + }; + + const missing = readFileGuardPlugin(dir, { + whichExtractor: () => null, + canExecuteHostCommands: () => true, + }); + const missingResult = await defined(missing.middleware)(fallback)( + call, + neverAbort(), + ); + expect(missingResult.isError).toBe(true); + expect(String(missingResult.content)).toContain("pdftotext"); + expect(String(missingResult.content)).toContain("missing"); + expect(String(missingResult.content)).toContain("not a malformed file"); + + const blocked = readFileGuardPlugin(dir, { + whichExtractor: () => "/opt/homebrew/bin/pdftotext", + canExecuteHostCommands: () => false, + }); + const blockedResult = await defined(blocked.middleware)(fallback)( + call, + neverAbort(), + ); + expect(blockedResult.isError).toBe(true); + expect(String(blockedResult.content)).toContain("installed"); + expect(String(blockedResult.content)).toContain("permission boundary"); + expect(String(blockedResult.content)).toContain("run_shell"); + expect(String(blockedResult.content)).not.toContain("brew install"); + }); }); describe("CL-8980 single-way path+offset resume", () => { diff --git a/src/plugins/read-file-guard-plugin.ts b/src/plugins/read-file-guard-plugin.ts index a15b68828..d9278390c 100644 --- a/src/plugins/read-file-guard-plugin.ts +++ b/src/plugins/read-file-guard-plugin.ts @@ -10,6 +10,11 @@ import { isToolOutputLike, } from "../util/tool-output-uri.js"; import { formatReadFileTimeoutMessage } from "./tool-time-budget.js"; +import { + diagnoseBlockedFileInspection, + inspectionKindFromFirstChunk, + PDF_EXTRACTOR_BIN, +} from "./file-inspection-diagnosis.js"; // Corbits Code-side guard for read_file. Stock @intx/tools-posix read-file loads the // whole file into memory (buffer -> string -> split) and, with no limit, returns @@ -45,6 +50,16 @@ interface BoundedRead { export interface ReadFileGuardPluginOptions { blobReader?: BlobReader; + /** + * Resolve the PDF text extractor on PATH. Tests inject a stub; production + * uses Bun.which("pdftotext"). + */ + whichExtractor?: () => string | null; + /** + * Live: whether this session can run host commands (run_shell mounted). + * Workers flip this after the capability filter; omitted means yes. + */ + canExecuteHostCommands?: () => boolean; } // A truncated read tells the model to continue with the same path and the @@ -71,13 +86,33 @@ function mapFilesystemStreamError( err: NodeJS.ErrnoException, ): Error { if (err.code === "ENOENT") return new Error(`file not found: ${displayPath}`); - if (err.code === "EACCES") - return new Error(`permission denied: ${displayPath}`); + if (err.code === "EACCES" || err.code === "EPERM") { + return new Error( + diagnoseBlockedFileInspection({ + path: displayPath, + kind: "unreadable", + extractorAvailable: false, + canExecuteHostCommands: false, + }).message, + ); + } if (err.code === "EISDIR") return new Error(`path is a directory: ${displayPath}`); return err; } +function isAccessDenied(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + return code === "EACCES" || code === "EPERM"; +} + +function resolveExtractorProbe( + whichExtractor: (() => string | null) | undefined, +): boolean { + const probe = whichExtractor ?? (() => Bun.which(PDF_EXTRACTOR_BIN)); + return probe() !== null; +} + /** * Streams UTF-8 from `stream`, emitting up to `limit` line-numbered lines after * skipping `offset` lines (zero-based). Never splits the full decoded text in one pass. @@ -102,6 +137,7 @@ function readStreamBounded( mapStreamError?: (err: NodeJS.ErrnoException) => Error; wrapLongLines?: boolean; windowHugeLines?: boolean; + diagnoseFirstChunk?: (chunk: Buffer) => string | undefined; } = {}, ): Promise { return new Promise((resolveP, rejectP) => { @@ -109,6 +145,7 @@ function readStreamBounded( mapStreamError, wrapLongLines = false, windowHugeLines = false, + diagnoseFirstChunk, } = options; const decoder = new StringDecoder("utf8"); const contentBudget = READ_FILE_MAX_BYTES - NOTICE_RESERVE_BYTES; @@ -270,6 +307,14 @@ function readStreamBounded( if (settled) return; if (firstChunk) { firstChunk = false; + const diagnosed = diagnoseFirstChunk?.(chunk); + if (diagnosed !== undefined) { + done({ + content: diagnosed, + isError: true, + }); + return; + } if (chunk.includes(0)) { done({ content: `refusing to read binary file: ${displayPath}`, @@ -319,6 +364,10 @@ export function readFileBounded( offset: number, limit: number, signal: AbortSignal, + inspection?: Pick< + ReadFileGuardPluginOptions, + "whichExtractor" | "canExecuteHostCommands" + >, ): Promise { return readStreamBounded( createReadStream(absolutePath), @@ -329,6 +378,17 @@ export function readFileBounded( { mapStreamError: (err) => mapFilesystemStreamError(absolutePath, err), windowHugeLines: true, + diagnoseFirstChunk: (chunk) => { + const kind = inspectionKindFromFirstChunk(absolutePath, chunk); + if (kind === undefined) return undefined; + return diagnoseBlockedFileInspection({ + path: absolutePath, + kind, + extractorAvailable: resolveExtractorProbe(inspection?.whichExtractor), + canExecuteHostCommands: + inspection?.canExecuteHostCommands?.() ?? true, + }).message; + }, }, ); } @@ -412,7 +472,11 @@ export function readFileGuardPlugin( cwd: string, options: ReadFileGuardPluginOptions = {}, ): ToolPlugin { - const { blobReader } = options; + const { blobReader, whichExtractor, canExecuteHostCommands } = options; + const inspection = { + ...(whichExtractor !== undefined ? { whichExtractor } : {}), + ...(canExecuteHostCommands !== undefined ? { canExecuteHostCommands } : {}), + }; return { middleware: (next) => async (call, signal) => { if (call.name !== "read_file") return next(call, signal); @@ -468,12 +532,30 @@ export function readFileGuardPlugin( try { const info = await stat(absolutePath); if (info.isDirectory()) return next(call, signal); - } catch { + } catch (err) { + if (isAccessDenied(err)) { + return { + callId: call.id, + content: diagnoseBlockedFileInspection({ + path: rawPath, + kind: "unreadable", + extractorAvailable: false, + canExecuteHostCommands: false, + }).message, + isError: true, + }; + } return next(call, signal); } try { - const res = await readFileBounded(absolutePath, offset, limit, signal); + const res = await readFileBounded( + absolutePath, + offset, + limit, + signal, + inspection, + ); return res.isError ? { callId: call.id, content: res.content, isError: true } : { callId: call.id, content: res.content }; diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 390952468..272a7404c 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -640,6 +640,11 @@ async function runSubAgentInner( // not spawn background children they cannot collect. The getter is live so // the capability filter below can unwire it before the first tool call. let backgroundCollectMounted = true; + // Live: read_file PDF diagnosis asks whether this worker can run pdftotext + // via bash. The capability filter below may unmount run_shell after the + // plugin stack is built, so the getter is flipped in the same place as + // backgroundCollectMounted. + let hostCommandsMounted = true; // Child tools resolve spills against the child's own store first, then // the parent's: parent tool-output:// URIs handed in the brief must // remain readable after spawn, and the child's own spills stay local. @@ -668,7 +673,10 @@ async function runSubAgentInner( ...(params.secretGuardExtraDeniedPaths !== undefined ? { secretGuardExtraDeniedPaths: params.secretGuardExtraDeniedPaths } : {}), - readFileGuard: { blobReader: sessionBlobReader }, + readFileGuard: { + blobReader: sessionBlobReader, + canExecuteHostCommands: () => hostCommandsMounted, + }, getBackgroundShellRegistry: () => backgroundCollectMounted ? backgroundShells : undefined, getShellOutputFeeds: () => childShellOutputFeed, @@ -792,6 +800,9 @@ async function runSubAgentInner( backgroundCollectMounted = tools.some( (tool) => tool.definition.name === "shell_collect", ); + hostCommandsMounted = tools.some( + (tool) => canonicalToolName(tool.definition.name) === "run_shell", + ); if (!backgroundCollectMounted) { tools = tools.map((tool) => tool.definition.name === "run_shell"