Skip to content

Commit e6b7bff

Browse files
committed
fix(read): treat utf-8 pdf names as text and quote pdftotext
A .pdf suffix without %PDF magic is only malformed when the first chunk is binary. extractor_ready quotes the path so it pastes into bash.
1 parent 000349c commit e6b7bff

5 files changed

Lines changed: 67 additions & 3 deletions

File tree

‎src/agent/tool-aliases.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ export function withAuthzParityDefinitions<
141141

142142
const SHELL_WRAPPERS = new Set(["bash", "sh", "zsh"]);
143143

144-
function shellQuote(arg: string): string {
144+
export function shellQuote(arg: string): string {
145145
if (/^[A-Za-z0-9_\-./:=@%]+$/.test(arg)) return arg;
146146
return `'${arg.replace(/'/g, `'\\''`)}'`;
147147
}

‎src/plugins/file-inspection-diagnosis.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ describe("PDF sniffing", () => {
2222
expect(
2323
inspectionKindFromFirstChunk("report.pdf", Buffer.from("not-a-pdf\0")),
2424
).toBe("pdf-malformed");
25+
});
26+
27+
test("plain UTF-8 named .pdf without magic is not an inspection block", () => {
2528
expect(
2629
inspectionKindFromFirstChunk("report.pdf", Buffer.from("plain text")),
2730
).toBeUndefined();
@@ -94,6 +97,18 @@ describe("diagnoseBlockedFileInspection", () => {
9497
expect(result.message).toContain(`${PDF_EXTRACTOR_BIN} ${PATH} -`);
9598
});
9699

100+
test("extractor_ready quotes a path that would break a pasted bash command", () => {
101+
const spaced = "/tmp/my report.pdf";
102+
const result = diagnoseBlockedFileInspection({
103+
path: spaced,
104+
kind: "pdf",
105+
extractorAvailable: true,
106+
canExecuteHostCommands: true,
107+
});
108+
expect(result.code).toBe("extractor_ready");
109+
expect(result.message).toContain(`${PDF_EXTRACTOR_BIN} '${spaced}' -`);
110+
});
111+
97112
test("malformed PDF is not blamed on tooling or permission", () => {
98113
const result = diagnoseBlockedFileInspection({
99114
path: PATH,

‎src/plugins/file-inspection-diagnosis.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { extname } from "node:path";
2+
import { shellQuote } from "../agent/tool-aliases.js";
23

34
/** Host binary that extracts text from a PDF (poppler). */
45
export const PDF_EXTRACTOR_BIN = "pdftotext";
@@ -122,6 +123,6 @@ export function diagnoseBlockedFileInspection(
122123

123124
return {
124125
code: "extractor_ready",
125-
message: `read_file cannot decode PDFs. ${extractor} is installed — run \`${extractor} ${path} -\` via bash.`,
126+
message: `read_file cannot decode PDFs. ${extractor} is installed — run \`${extractor} ${shellQuote(path)} -\` via bash.`,
126127
};
127128
}

‎src/plugins/read-file-guard-plugin.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,22 @@ describe("readFileBounded", () => {
179179
expect(content).not.toContain("brew install");
180180
});
181181

182+
test("UTF-8 .pdf without magic still reads as text", async () => {
183+
const p = await fixture("utf8.pdf", "plain utf-8 notes\nsecond line");
184+
const { content, isError } = await readFileBounded(
185+
p,
186+
0,
187+
2000,
188+
neverAbort(),
189+
{ whichExtractor: () => null, canExecuteHostCommands: () => false },
190+
);
191+
expect(isError).toBeUndefined();
192+
expect(content).toContain("plain utf-8 notes");
193+
expect(content).toContain("second line");
194+
expect(content).not.toContain("not a valid PDF");
195+
expect(content).not.toContain("malformed");
196+
});
197+
182198
test("a NUL deep in an otherwise-valid file does not discard streamed content", async () => {
183199
// Enough valid text (>64KB) to guarantee the NUL lands in a later chunk.
184200
const head = Array.from(
@@ -825,6 +841,38 @@ describe("readFileGuardPlugin", () => {
825841
expect(String(blockedResult.content)).toContain("run_shell");
826842
expect(String(blockedResult.content)).not.toContain("brew install");
827843
});
844+
845+
test("UTF-8 .pdf without magic still reads as text through the plugin", async () => {
846+
await fixture("notes.pdf", "hello from a misnamed text file");
847+
const result = await run({
848+
id: "utf8-pdf",
849+
name: "read_file",
850+
arguments: { path: "notes.pdf" },
851+
});
852+
expect(result.isError).toBeFalsy();
853+
expect(result.content).toContain("hello from a misnamed text file");
854+
expect(String(result.content)).not.toContain("not a valid PDF");
855+
expect(String(result.content)).not.toContain("malformed");
856+
});
857+
858+
test("extractor_ready quotes a PDF path that contains spaces", async () => {
859+
await fixture("my report.pdf", Buffer.from("%PDF-1.4\n"));
860+
const plugin = readFileGuardPlugin(dir, {
861+
whichExtractor: () => "/usr/bin/pdftotext",
862+
canExecuteHostCommands: () => true,
863+
});
864+
const result = await defined(plugin.middleware)(fallback)(
865+
{
866+
id: "quoted-pdf",
867+
name: "read_file",
868+
arguments: { path: "my report.pdf" },
869+
},
870+
neverAbort(),
871+
);
872+
expect(result.isError).toBe(true);
873+
const abs = join(dir, "my report.pdf");
874+
expect(String(result.content)).toContain(`pdftotext '${abs}' -`);
875+
});
828876
});
829877

830878
describe("CL-8980 single-way path+offset resume", () => {

‎src/plugins/read-file-guard-plugin.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -540,7 +540,7 @@ export function readFileGuardPlugin(
540540
return {
541541
callId: call.id,
542542
content: diagnoseBlockedFileInspection({
543-
path: rawPath,
543+
path: absolutePath,
544544
kind: "unreadable",
545545
extractorAvailable: false,
546546
canExecuteHostCommands: false,

0 commit comments

Comments
 (0)