Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/auth/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
136 changes: 136 additions & 0 deletions src/plugins/file-inspection-diagnosis.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
127 changes: 127 additions & 0 deletions src/plugins/file-inspection-diagnosis.ts
Original file line number Diff line number Diff line change
@@ -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.`,
};
}
87 changes: 87 additions & 0 deletions src/plugins/read-file-guard-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading
Loading