@theokit/sdk-tools 0.27.6 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/dist/extract-document.cjs +175 -0
- package/dist/extract-document.cjs.map +1 -0
- package/dist/extract-document.d.cts +59 -0
- package/dist/extract-document.d.ts +59 -0
- package/dist/extract-document.js +173 -0
- package/dist/extract-document.js.map +1 -0
- package/dist/github-link-preview.cjs +227 -0
- package/dist/github-link-preview.cjs.map +1 -0
- package/dist/github-link-preview.d.cts +69 -0
- package/dist/github-link-preview.d.ts +69 -0
- package/dist/github-link-preview.js +225 -0
- package/dist/github-link-preview.js.map +1 -0
- package/dist/goal.cjs +84 -0
- package/dist/goal.cjs.map +1 -0
- package/dist/goal.d.cts +58 -0
- package/dist/goal.d.ts +58 -0
- package/dist/goal.js +82 -0
- package/dist/goal.js.map +1 -0
- package/dist/index.cjs +127 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +110 -59
- package/dist/index.d.ts +110 -59
- package/dist/index.js +128 -11
- package/dist/index.js.map +1 -1
- package/dist/read-pdf.cjs +154 -0
- package/dist/read-pdf.cjs.map +1 -0
- package/dist/read-pdf.d.cts +58 -0
- package/dist/read-pdf.d.ts +58 -0
- package/dist/read-pdf.js +152 -0
- package/dist/read-pdf.js.map +1 -0
- package/dist/screenshot-logbook.cjs +245 -0
- package/dist/screenshot-logbook.cjs.map +1 -0
- package/dist/screenshot-logbook.d.cts +124 -0
- package/dist/screenshot-logbook.d.ts +124 -0
- package/dist/screenshot-logbook.js +241 -0
- package/dist/screenshot-logbook.js.map +1 -0
- package/dist/structured-output.cjs +52 -0
- package/dist/structured-output.cjs.map +1 -0
- package/dist/structured-output.d.cts +61 -0
- package/dist/structured-output.d.ts +61 -0
- package/dist/structured-output.js +50 -0
- package/dist/structured-output.js.map +1 -0
- package/dist/web-search-CE82zZVl.d.cts +61 -0
- package/dist/web-search-CE82zZVl.d.ts +61 -0
- package/dist/web-search-adapters.cjs +71 -0
- package/dist/web-search-adapters.cjs.map +1 -0
- package/dist/web-search-adapters.d.cts +57 -0
- package/dist/web-search-adapters.d.ts +57 -0
- package/dist/web-search-adapters.js +68 -0
- package/dist/web-search-adapters.js.map +1 -0
- package/package.json +84 -4
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var fs = require('fs');
|
|
4
|
+
var sdk = require('@theokit/sdk');
|
|
5
|
+
var zod = require('zod');
|
|
6
|
+
var pathSafety = require('@theokit/sdk/path-safety');
|
|
7
|
+
|
|
8
|
+
// src/read-pdf.ts
|
|
9
|
+
|
|
10
|
+
// src/path-scope.ts
|
|
11
|
+
function checkPathScope(path, projectRoot) {
|
|
12
|
+
if (path === void 0 || path === "") return null;
|
|
13
|
+
try {
|
|
14
|
+
const abs = pathSafety.safePathJoin(projectRoot, path);
|
|
15
|
+
pathSafety.assertNoSymlinkEscape(abs, projectRoot);
|
|
16
|
+
return null;
|
|
17
|
+
} catch (err) {
|
|
18
|
+
if (err instanceof pathSafety.PathTraversalError || err instanceof pathSafety.ForbiddenPathError) {
|
|
19
|
+
return JSON.stringify({ ok: false, error: "path_traversal", path });
|
|
20
|
+
}
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
var SENSITIVE_SEGMENTS = /* @__PURE__ */ new Set([".env", ".git", "node_modules", ".theo"]);
|
|
25
|
+
function isForbiddenAtAnyDepth(path) {
|
|
26
|
+
const segs = path.replace(/\\/g, "/").split("/").filter(Boolean);
|
|
27
|
+
return segs.some((s) => {
|
|
28
|
+
if (s === ".env.example") return false;
|
|
29
|
+
return SENSITIVE_SEGMENTS.has(s) || /^\.env\./.test(s);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// src/read-pdf.ts
|
|
34
|
+
var DEFAULT_MAX_PDF_BYTES = 10 * 1024 * 1024;
|
|
35
|
+
var DEFAULT_MAX_PDF_CHARS = 1e5;
|
|
36
|
+
var PDF_MAGIC = "%PDF-";
|
|
37
|
+
var INSTALL_COMMAND = "npm install unpdf";
|
|
38
|
+
var limitsSchema = zod.z.object({
|
|
39
|
+
maxBytes: zod.z.number().int().positive(),
|
|
40
|
+
maxChars: zod.z.number().int().positive()
|
|
41
|
+
});
|
|
42
|
+
var json = (result) => JSON.stringify(result);
|
|
43
|
+
function createReadPdfTool(options) {
|
|
44
|
+
const { projectRoot } = options;
|
|
45
|
+
const limits = parseLimits(options);
|
|
46
|
+
const extract = options.extractText;
|
|
47
|
+
return sdk.Tool.create({
|
|
48
|
+
name: options.name ?? "read_pdf",
|
|
49
|
+
description: options.description ?? "Read the text of a PDF file in the project. Returns the page count and the text, cut at a character limit.",
|
|
50
|
+
inputSchema: zod.z.object({
|
|
51
|
+
path: zod.z.string().describe("Path to the PDF, relative to the project root.")
|
|
52
|
+
}),
|
|
53
|
+
handler: async (input) => {
|
|
54
|
+
const refused = checkPathScope(input.path, projectRoot);
|
|
55
|
+
if (refused !== null) return refused;
|
|
56
|
+
if (isForbiddenAtAnyDepth(input.path)) {
|
|
57
|
+
return json({ ok: false, error: "path_traversal", path: input.path });
|
|
58
|
+
}
|
|
59
|
+
const outcome = readWithinBudget(pathSafety.safePathJoin(projectRoot, input.path), limits.maxBytes);
|
|
60
|
+
if (!outcome.ok) return json({ ...outcome.failure, path: input.path });
|
|
61
|
+
return json(await extractWithin(outcome.bytes, input.path, limits.maxChars, extract));
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function parseLimits(options) {
|
|
66
|
+
const parsed = limitsSchema.safeParse({
|
|
67
|
+
maxBytes: options.maxBytes ?? DEFAULT_MAX_PDF_BYTES,
|
|
68
|
+
maxChars: options.maxChars ?? DEFAULT_MAX_PDF_CHARS
|
|
69
|
+
});
|
|
70
|
+
if (!parsed.success) {
|
|
71
|
+
const detail = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
|
|
72
|
+
throw new RangeError(`createReadPdfTool: invalid options (${detail})`);
|
|
73
|
+
}
|
|
74
|
+
return parsed.data;
|
|
75
|
+
}
|
|
76
|
+
function readWithinBudget(absolute, maxBytes) {
|
|
77
|
+
const opened = openForRead(absolute);
|
|
78
|
+
if (!opened.ok) return opened;
|
|
79
|
+
const { fd } = opened;
|
|
80
|
+
try {
|
|
81
|
+
const stat = fs.fstatSync(fd);
|
|
82
|
+
if (!stat.isFile()) return { ok: false, failure: { ok: false, error: "not_a_pdf" } };
|
|
83
|
+
if (stat.size > maxBytes) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
failure: { ok: false, error: "pdf_too_large", bytes: stat.size, limit_bytes: maxBytes }
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const buffer = Buffer.alloc(stat.size);
|
|
90
|
+
const bytes = new Uint8Array(buffer.subarray(0, fs.readSync(fd, buffer, 0, stat.size, 0)));
|
|
91
|
+
if (Buffer.from(bytes.subarray(0, PDF_MAGIC.length)).toString("latin1") !== PDF_MAGIC) {
|
|
92
|
+
return { ok: false, failure: { ok: false, error: "not_a_pdf" } };
|
|
93
|
+
}
|
|
94
|
+
return { ok: true, bytes };
|
|
95
|
+
} finally {
|
|
96
|
+
fs.closeSync(fd);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function openForRead(absolute) {
|
|
100
|
+
try {
|
|
101
|
+
return { ok: true, fd: fs.openSync(absolute, "r") };
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (err.code === "ENOENT") {
|
|
104
|
+
return { ok: false, failure: { ok: false, error: "not_found" } };
|
|
105
|
+
}
|
|
106
|
+
return { ok: false, failure: { ok: false, error: "pdf_unreadable", message: messageOf(err) } };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
var messageOf = (err) => err instanceof Error ? err.message : String(err);
|
|
110
|
+
async function extractWithin(bytes, path, maxChars, injected) {
|
|
111
|
+
const engine = injected ? { ok: true, extract: injected } : await loadUnpdf();
|
|
112
|
+
if (!engine.ok) {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
error: "pdf_engine_missing",
|
|
116
|
+
path,
|
|
117
|
+
install: INSTALL_COMMAND,
|
|
118
|
+
message: engine.message
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
let extracted;
|
|
122
|
+
try {
|
|
123
|
+
extracted = await engine.extract(bytes);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
return { ok: false, error: "pdf_unreadable", path, message: messageOf(err) };
|
|
126
|
+
}
|
|
127
|
+
const { pages, text } = extracted;
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
path,
|
|
131
|
+
pages,
|
|
132
|
+
chars: text.length,
|
|
133
|
+
truncated: text.length > maxChars,
|
|
134
|
+
text: text.slice(0, maxChars)
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async function loadUnpdf() {
|
|
138
|
+
try {
|
|
139
|
+
const unpdf = await import('unpdf');
|
|
140
|
+
return {
|
|
141
|
+
ok: true,
|
|
142
|
+
extract: async (bytes) => {
|
|
143
|
+
const { totalPages, text } = await unpdf.extractText(bytes, { mergePages: true });
|
|
144
|
+
return { pages: totalPages, text };
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
} catch (err) {
|
|
148
|
+
return { ok: false, message: messageOf(err) };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
exports.createReadPdfTool = createReadPdfTool;
|
|
153
|
+
//# sourceMappingURL=read-pdf.cjs.map
|
|
154
|
+
//# sourceMappingURL=read-pdf.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/path-scope.ts","../src/read-pdf.ts"],"names":["safePathJoin","assertNoSymlinkEscape","PathTraversalError","ForbiddenPathError","z","Tool","fstatSync","readSync","closeSync","openSync"],"mappings":"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","file":"read-pdf.cjs","sourcesContent":["/**\n * Shared `safePathJoin + assertNoSymlinkEscape` helper for built-in coding\n * tools (git-diff, run-vitest, list-dir, read-file, search-text). Returns a\n * pre-formatted `path_traversal` JSON string on failure or `null` on pass.\n *\n * @internal\n */\n\nimport {\n assertNoSymlinkEscape,\n ForbiddenPathError,\n PathTraversalError,\n safePathJoin,\n} from \"./internal/path-guard.js\";\n\nexport function checkPathScope(path: string | undefined, projectRoot: string): string | null {\n if (path === undefined || path === \"\") return null;\n try {\n const abs = safePathJoin(projectRoot, path);\n assertNoSymlinkEscape(abs, projectRoot);\n return null;\n } catch (err) {\n if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {\n return JSON.stringify({ ok: false, error: \"path_traversal\", path });\n }\n throw err;\n }\n}\n\n/**\n * Segments that may never appear in a path honored by `allowAbsolute`.\n *\n * M76 — promoted from `read-file.ts`, where it was private. Duplicating it in `list-dir` would duplicate\n * security KNOWLEDGE: the copies would have to agree on what counts as a secret, and one fixed\n * without the other reopens the hole in the forgotten tool.\n */\nconst SENSITIVE_SEGMENTS = new Set([\".env\", \".git\", \"node_modules\", \".theo\"]);\n\n/**\n * ANY-segment secret guard — the half of `allowAbsolute` that cannot be separated.\n *\n * `isForbiddenPath` only blocks the sensitive item when it is the FIRST segment (relative to the\n * project). An absolute path (`/home/u/proj/.env/sub`) puts it deeper, and it would pass. This one\n * checks every segment, closing the \"reads-anywhere\" exfiltration.\n */\nexport function isForbiddenAtAnyDepth(path: string): boolean {\n const segs = path.replace(/\\\\/g, \"/\").split(\"/\").filter(Boolean);\n return segs.some((s) => {\n if (s === \".env.example\") return false; // a template — safe\n return SENSITIVE_SEGMENTS.has(s) || /^\\.env\\./.test(s);\n });\n}\n","/**\n * `read_pdf` — let the agent read the text of a PDF in the project.\n *\n * ## Why this is a built-in\n *\n * A PDF is the one common document an agent cannot read with `read_file`: the bytes are a\n * compressed object graph, not text. The confinement is the same as `view_image`'s and is the part\n * worth reviewing once: a document reader that honours any path is a file exfiltration primitive.\n *\n * ## The engine\n *\n * Text extraction is delegated to `unpdf`, an OPTIONAL peer loaded lazily on the first call, so a\n * consumer of the other tools never pays for it. Its absence is a typed `pdf_engine_missing`\n * result naming the install command, never a throw. A consumer can pass `extractText` to use any\n * other engine; the envelope stays the same.\n *\n * ## Result shape\n *\n * - `{ ok: true, path, pages, chars, truncated, text }` — `chars` is the length of the whole\n * extracted text; `text` is cut at `maxChars` and `truncated` says whether it was.\n * - `{ ok: false, error: \"path_traversal\" | \"not_found\" | \"not_a_pdf\" | \"pdf_too_large\" |\n * \"pdf_unreadable\" | \"pdf_engine_missing\", path, … }`\n */\n\nimport { closeSync, fstatSync, openSync, readSync } from \"node:fs\";\n\nimport type { CustomTool } from \"@theokit/sdk\";\nimport { Tool } from \"@theokit/sdk\";\nimport { z } from \"zod\";\n\nimport { safePathJoin } from \"./internal/path-guard.js\";\nimport { checkPathScope, isForbiddenAtAnyDepth } from \"./path-scope.js\";\n\n/** Default ceiling on the file read, in bytes on disk: 10 MB. */\nconst DEFAULT_MAX_PDF_BYTES = 10 * 1024 * 1024;\n\n/** Default ceiling on the text returned to the model, in characters. */\nconst DEFAULT_MAX_PDF_CHARS = 100_000;\n\n/** Every PDF starts with this header; anything else is refused before the engine sees it. */\nconst PDF_MAGIC = \"%PDF-\";\n\nconst INSTALL_COMMAND = \"npm install unpdf\";\n\n/** The engine contract: PDF bytes in, page count and text out. */\ntype PdfTextExtractor = (bytes: Uint8Array) => Promise<{ pages: number; text: string }>;\n\n/**\n * Options for {@link createReadPdfTool}.\n *\n * @public\n */\nexport interface CreateReadPdfToolOptions {\n /** Root the tool reads from. Every path is resolved inside it. */\n projectRoot: string;\n /** Name exposed to the model. Omitted ⇒ `read_pdf`. */\n name?: string;\n /** Description exposed to the model. Omitted ⇒ the literal below. */\n description?: string;\n /** Ceiling on the file size in bytes. Omitted ⇒ {@link DEFAULT_MAX_PDF_BYTES}. */\n maxBytes?: number;\n /** Ceiling on the returned text in characters. Omitted ⇒ {@link DEFAULT_MAX_PDF_CHARS}. */\n maxChars?: number;\n /** Engine that turns the bytes into text. Omitted ⇒ the optional peer `unpdf`, loaded lazily. */\n extractText?: PdfTextExtractor;\n}\n\nconst limitsSchema = z.object({\n maxBytes: z.number().int().positive(),\n maxChars: z.number().int().positive(),\n});\n\ntype PdfErrorCode =\n | \"path_traversal\"\n | \"not_found\"\n | \"not_a_pdf\"\n | \"pdf_too_large\"\n | \"pdf_unreadable\"\n | \"pdf_engine_missing\";\n\ninterface ReadPdfOk {\n ok: true;\n path: string;\n pages: number;\n chars: number;\n truncated: boolean;\n text: string;\n}\n\n/** A failure before the path is attached: the read helper does not know the input path. */\ninterface PdfFailureBody {\n ok: false;\n error: PdfErrorCode;\n [extra: string]: unknown;\n}\n\ntype ReadPdfFailure = PdfFailureBody & { path: string };\n\nconst json = (result: ReadPdfOk | ReadPdfFailure): string => JSON.stringify(result);\n\n/**\n * Read the text of a PDF inside the project root.\n *\n * @public\n */\nexport function createReadPdfTool(options: CreateReadPdfToolOptions): CustomTool {\n const { projectRoot } = options;\n const limits = parseLimits(options);\n const extract = options.extractText;\n\n return Tool.create({\n name: options.name ?? \"read_pdf\",\n description:\n options.description ??\n \"Read the text of a PDF file in the project. Returns the page count and the text, cut at a character limit.\",\n inputSchema: z.object({\n path: z.string().describe(\"Path to the PDF, relative to the project root.\"),\n }),\n handler: async (input: { path: string }): Promise<string> => {\n // Same order as `view_image`: the shared scope guard (lexical and symlink escapes), then the\n // any-depth secret guard, and only then a join that can no longer throw outside the envelope.\n const refused = checkPathScope(input.path, projectRoot);\n if (refused !== null) return refused;\n if (isForbiddenAtAnyDepth(input.path)) {\n return json({ ok: false, error: \"path_traversal\", path: input.path });\n }\n\n const outcome = readWithinBudget(safePathJoin(projectRoot, input.path), limits.maxBytes);\n if (!outcome.ok) return json({ ...outcome.failure, path: input.path });\n\n return json(await extractWithin(outcome.bytes, input.path, limits.maxChars, extract));\n },\n }) as unknown as CustomTool;\n}\n\n/** Validates the numeric options once, at construction, so a bad limit fails the factory call. */\nfunction parseLimits(options: CreateReadPdfToolOptions): z.infer<typeof limitsSchema> {\n const parsed = limitsSchema.safeParse({\n maxBytes: options.maxBytes ?? DEFAULT_MAX_PDF_BYTES,\n maxChars: options.maxChars ?? DEFAULT_MAX_PDF_CHARS,\n });\n if (!parsed.success) {\n const detail = parsed.error.issues\n .map((issue) => `${issue.path.join(\".\")}: ${issue.message}`)\n .join(\"; \");\n throw new RangeError(`createReadPdfTool: invalid options (${detail})`);\n }\n return parsed.data;\n}\n\ntype ReadOutcome =\n | { readonly ok: true; readonly bytes: Uint8Array }\n | { readonly ok: false; readonly failure: PdfFailureBody };\n\n/**\n * Sizes, reads and checks one file through a single descriptor (CodeQL js/file-system-race #22):\n * the size cap must describe the bytes actually returned, so the read is bounded by the size the\n * descriptor reported, and a file that grows after `fstat` cannot push past the cap.\n */\nfunction readWithinBudget(absolute: string, maxBytes: number): ReadOutcome {\n const opened = openForRead(absolute);\n if (!opened.ok) return opened;\n const { fd } = opened;\n try {\n const stat = fstatSync(fd);\n // A directory named `x.pdf` opens on Linux; it is still not a PDF.\n if (!stat.isFile()) return { ok: false, failure: { ok: false, error: \"not_a_pdf\" } };\n // `>` and not `>=`: a file of exactly the limit fits.\n if (stat.size > maxBytes) {\n return {\n ok: false,\n failure: { ok: false, error: \"pdf_too_large\", bytes: stat.size, limit_bytes: maxBytes },\n };\n }\n const buffer = Buffer.alloc(stat.size);\n const bytes = new Uint8Array(buffer.subarray(0, readSync(fd, buffer, 0, stat.size, 0)));\n if (Buffer.from(bytes.subarray(0, PDF_MAGIC.length)).toString(\"latin1\") !== PDF_MAGIC) {\n return { ok: false, failure: { ok: false, error: \"not_a_pdf\" } };\n }\n return { ok: true, bytes };\n } finally {\n closeSync(fd);\n }\n}\n\n/**\n * Opens the file, telling a missing file (`not_found`, which the model answers with another path)\n * apart from one it may not read (`pdf_unreadable` with the system's message).\n */\nfunction openForRead(\n absolute: string,\n): { readonly ok: true; readonly fd: number } | Extract<ReadOutcome, { ok: false }> {\n try {\n return { ok: true, fd: openSync(absolute, \"r\") };\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") {\n return { ok: false, failure: { ok: false, error: \"not_found\" } };\n }\n return { ok: false, failure: { ok: false, error: \"pdf_unreadable\", message: messageOf(err) } };\n }\n}\n\nconst messageOf = (err: unknown): string => (err instanceof Error ? err.message : String(err));\n\n/** Runs the engine and cuts its text, mapping every engine failure to a typed result. */\nasync function extractWithin(\n bytes: Uint8Array,\n path: string,\n maxChars: number,\n injected: PdfTextExtractor | undefined,\n): Promise<ReadPdfOk | ReadPdfFailure> {\n const engine = injected ? { ok: true as const, extract: injected } : await loadUnpdf();\n if (!engine.ok) {\n return {\n ok: false,\n error: \"pdf_engine_missing\",\n path,\n install: INSTALL_COMMAND,\n message: engine.message,\n };\n }\n let extracted: { pages: number; text: string };\n try {\n extracted = await engine.extract(bytes);\n } catch (err) {\n return { ok: false, error: \"pdf_unreadable\", path, message: messageOf(err) };\n }\n const { pages, text } = extracted;\n return {\n ok: true,\n path,\n pages,\n chars: text.length,\n truncated: text.length > maxChars,\n text: text.slice(0, maxChars),\n };\n}\n\n/**\n * Loads the optional `unpdf` peer, or the reason it could not be loaded.\n *\n * Any load failure counts, not only a missing module: an ESM-only package required from the CJS\n * build on an older Node fails differently, and the caller's answer is the same install hint. The\n * cause is kept in `message` so a broken install is not reported as a merely absent one. Nothing is\n * cached, so installing the peer mid-session takes effect on the next call.\n */\nasync function loadUnpdf(): Promise<\n | { readonly ok: true; readonly extract: PdfTextExtractor }\n | { readonly ok: false; readonly message: string }\n> {\n try {\n const unpdf = await import(\"unpdf\");\n return {\n ok: true,\n extract: async (bytes) => {\n const { totalPages, text } = await unpdf.extractText(bytes, { mergePages: true });\n return { pages: totalPages, text };\n },\n };\n } catch (err) {\n return { ok: false, message: messageOf(err) };\n }\n}\n"]}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { CustomTool } from '@theokit/sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `read_pdf` — let the agent read the text of a PDF in the project.
|
|
5
|
+
*
|
|
6
|
+
* ## Why this is a built-in
|
|
7
|
+
*
|
|
8
|
+
* A PDF is the one common document an agent cannot read with `read_file`: the bytes are a
|
|
9
|
+
* compressed object graph, not text. The confinement is the same as `view_image`'s and is the part
|
|
10
|
+
* worth reviewing once: a document reader that honours any path is a file exfiltration primitive.
|
|
11
|
+
*
|
|
12
|
+
* ## The engine
|
|
13
|
+
*
|
|
14
|
+
* Text extraction is delegated to `unpdf`, an OPTIONAL peer loaded lazily on the first call, so a
|
|
15
|
+
* consumer of the other tools never pays for it. Its absence is a typed `pdf_engine_missing`
|
|
16
|
+
* result naming the install command, never a throw. A consumer can pass `extractText` to use any
|
|
17
|
+
* other engine; the envelope stays the same.
|
|
18
|
+
*
|
|
19
|
+
* ## Result shape
|
|
20
|
+
*
|
|
21
|
+
* - `{ ok: true, path, pages, chars, truncated, text }` — `chars` is the length of the whole
|
|
22
|
+
* extracted text; `text` is cut at `maxChars` and `truncated` says whether it was.
|
|
23
|
+
* - `{ ok: false, error: "path_traversal" | "not_found" | "not_a_pdf" | "pdf_too_large" |
|
|
24
|
+
* "pdf_unreadable" | "pdf_engine_missing", path, … }`
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The engine contract: PDF bytes in, page count and text out. */
|
|
28
|
+
type PdfTextExtractor = (bytes: Uint8Array) => Promise<{
|
|
29
|
+
pages: number;
|
|
30
|
+
text: string;
|
|
31
|
+
}>;
|
|
32
|
+
/**
|
|
33
|
+
* Options for {@link createReadPdfTool}.
|
|
34
|
+
*
|
|
35
|
+
* @public
|
|
36
|
+
*/
|
|
37
|
+
interface CreateReadPdfToolOptions {
|
|
38
|
+
/** Root the tool reads from. Every path is resolved inside it. */
|
|
39
|
+
projectRoot: string;
|
|
40
|
+
/** Name exposed to the model. Omitted ⇒ `read_pdf`. */
|
|
41
|
+
name?: string;
|
|
42
|
+
/** Description exposed to the model. Omitted ⇒ the literal below. */
|
|
43
|
+
description?: string;
|
|
44
|
+
/** Ceiling on the file size in bytes. Omitted ⇒ {@link DEFAULT_MAX_PDF_BYTES}. */
|
|
45
|
+
maxBytes?: number;
|
|
46
|
+
/** Ceiling on the returned text in characters. Omitted ⇒ {@link DEFAULT_MAX_PDF_CHARS}. */
|
|
47
|
+
maxChars?: number;
|
|
48
|
+
/** Engine that turns the bytes into text. Omitted ⇒ the optional peer `unpdf`, loaded lazily. */
|
|
49
|
+
extractText?: PdfTextExtractor;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Read the text of a PDF inside the project root.
|
|
53
|
+
*
|
|
54
|
+
* @public
|
|
55
|
+
*/
|
|
56
|
+
declare function createReadPdfTool(options: CreateReadPdfToolOptions): CustomTool;
|
|
57
|
+
|
|
58
|
+
export { type CreateReadPdfToolOptions, createReadPdfTool };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { CustomTool } from '@theokit/sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `read_pdf` — let the agent read the text of a PDF in the project.
|
|
5
|
+
*
|
|
6
|
+
* ## Why this is a built-in
|
|
7
|
+
*
|
|
8
|
+
* A PDF is the one common document an agent cannot read with `read_file`: the bytes are a
|
|
9
|
+
* compressed object graph, not text. The confinement is the same as `view_image`'s and is the part
|
|
10
|
+
* worth reviewing once: a document reader that honours any path is a file exfiltration primitive.
|
|
11
|
+
*
|
|
12
|
+
* ## The engine
|
|
13
|
+
*
|
|
14
|
+
* Text extraction is delegated to `unpdf`, an OPTIONAL peer loaded lazily on the first call, so a
|
|
15
|
+
* consumer of the other tools never pays for it. Its absence is a typed `pdf_engine_missing`
|
|
16
|
+
* result naming the install command, never a throw. A consumer can pass `extractText` to use any
|
|
17
|
+
* other engine; the envelope stays the same.
|
|
18
|
+
*
|
|
19
|
+
* ## Result shape
|
|
20
|
+
*
|
|
21
|
+
* - `{ ok: true, path, pages, chars, truncated, text }` — `chars` is the length of the whole
|
|
22
|
+
* extracted text; `text` is cut at `maxChars` and `truncated` says whether it was.
|
|
23
|
+
* - `{ ok: false, error: "path_traversal" | "not_found" | "not_a_pdf" | "pdf_too_large" |
|
|
24
|
+
* "pdf_unreadable" | "pdf_engine_missing", path, … }`
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The engine contract: PDF bytes in, page count and text out. */
|
|
28
|
+
type PdfTextExtractor = (bytes: Uint8Array) => Promise<{
|
|
29
|
+
pages: number;
|
|
30
|
+
text: string;
|
|
31
|
+
}>;
|
|
32
|
+
/**
|
|
33
|
+
* Options for {@link createReadPdfTool}.
|
|
34
|
+
*
|
|
35
|
+
* @public
|
|
36
|
+
*/
|
|
37
|
+
interface CreateReadPdfToolOptions {
|
|
38
|
+
/** Root the tool reads from. Every path is resolved inside it. */
|
|
39
|
+
projectRoot: string;
|
|
40
|
+
/** Name exposed to the model. Omitted ⇒ `read_pdf`. */
|
|
41
|
+
name?: string;
|
|
42
|
+
/** Description exposed to the model. Omitted ⇒ the literal below. */
|
|
43
|
+
description?: string;
|
|
44
|
+
/** Ceiling on the file size in bytes. Omitted ⇒ {@link DEFAULT_MAX_PDF_BYTES}. */
|
|
45
|
+
maxBytes?: number;
|
|
46
|
+
/** Ceiling on the returned text in characters. Omitted ⇒ {@link DEFAULT_MAX_PDF_CHARS}. */
|
|
47
|
+
maxChars?: number;
|
|
48
|
+
/** Engine that turns the bytes into text. Omitted ⇒ the optional peer `unpdf`, loaded lazily. */
|
|
49
|
+
extractText?: PdfTextExtractor;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Read the text of a PDF inside the project root.
|
|
53
|
+
*
|
|
54
|
+
* @public
|
|
55
|
+
*/
|
|
56
|
+
declare function createReadPdfTool(options: CreateReadPdfToolOptions): CustomTool;
|
|
57
|
+
|
|
58
|
+
export { type CreateReadPdfToolOptions, createReadPdfTool };
|
package/dist/read-pdf.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { fstatSync, readSync, closeSync, openSync } from 'fs';
|
|
2
|
+
import { Tool } from '@theokit/sdk';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { safePathJoin, assertNoSymlinkEscape, PathTraversalError, ForbiddenPathError } from '@theokit/sdk/path-safety';
|
|
5
|
+
|
|
6
|
+
// src/read-pdf.ts
|
|
7
|
+
|
|
8
|
+
// src/path-scope.ts
|
|
9
|
+
function checkPathScope(path, projectRoot) {
|
|
10
|
+
if (path === void 0 || path === "") return null;
|
|
11
|
+
try {
|
|
12
|
+
const abs = safePathJoin(projectRoot, path);
|
|
13
|
+
assertNoSymlinkEscape(abs, projectRoot);
|
|
14
|
+
return null;
|
|
15
|
+
} catch (err) {
|
|
16
|
+
if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
|
|
17
|
+
return JSON.stringify({ ok: false, error: "path_traversal", path });
|
|
18
|
+
}
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
var SENSITIVE_SEGMENTS = /* @__PURE__ */ new Set([".env", ".git", "node_modules", ".theo"]);
|
|
23
|
+
function isForbiddenAtAnyDepth(path) {
|
|
24
|
+
const segs = path.replace(/\\/g, "/").split("/").filter(Boolean);
|
|
25
|
+
return segs.some((s) => {
|
|
26
|
+
if (s === ".env.example") return false;
|
|
27
|
+
return SENSITIVE_SEGMENTS.has(s) || /^\.env\./.test(s);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/read-pdf.ts
|
|
32
|
+
var DEFAULT_MAX_PDF_BYTES = 10 * 1024 * 1024;
|
|
33
|
+
var DEFAULT_MAX_PDF_CHARS = 1e5;
|
|
34
|
+
var PDF_MAGIC = "%PDF-";
|
|
35
|
+
var INSTALL_COMMAND = "npm install unpdf";
|
|
36
|
+
var limitsSchema = z.object({
|
|
37
|
+
maxBytes: z.number().int().positive(),
|
|
38
|
+
maxChars: z.number().int().positive()
|
|
39
|
+
});
|
|
40
|
+
var json = (result) => JSON.stringify(result);
|
|
41
|
+
function createReadPdfTool(options) {
|
|
42
|
+
const { projectRoot } = options;
|
|
43
|
+
const limits = parseLimits(options);
|
|
44
|
+
const extract = options.extractText;
|
|
45
|
+
return Tool.create({
|
|
46
|
+
name: options.name ?? "read_pdf",
|
|
47
|
+
description: options.description ?? "Read the text of a PDF file in the project. Returns the page count and the text, cut at a character limit.",
|
|
48
|
+
inputSchema: z.object({
|
|
49
|
+
path: z.string().describe("Path to the PDF, relative to the project root.")
|
|
50
|
+
}),
|
|
51
|
+
handler: async (input) => {
|
|
52
|
+
const refused = checkPathScope(input.path, projectRoot);
|
|
53
|
+
if (refused !== null) return refused;
|
|
54
|
+
if (isForbiddenAtAnyDepth(input.path)) {
|
|
55
|
+
return json({ ok: false, error: "path_traversal", path: input.path });
|
|
56
|
+
}
|
|
57
|
+
const outcome = readWithinBudget(safePathJoin(projectRoot, input.path), limits.maxBytes);
|
|
58
|
+
if (!outcome.ok) return json({ ...outcome.failure, path: input.path });
|
|
59
|
+
return json(await extractWithin(outcome.bytes, input.path, limits.maxChars, extract));
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function parseLimits(options) {
|
|
64
|
+
const parsed = limitsSchema.safeParse({
|
|
65
|
+
maxBytes: options.maxBytes ?? DEFAULT_MAX_PDF_BYTES,
|
|
66
|
+
maxChars: options.maxChars ?? DEFAULT_MAX_PDF_CHARS
|
|
67
|
+
});
|
|
68
|
+
if (!parsed.success) {
|
|
69
|
+
const detail = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
|
|
70
|
+
throw new RangeError(`createReadPdfTool: invalid options (${detail})`);
|
|
71
|
+
}
|
|
72
|
+
return parsed.data;
|
|
73
|
+
}
|
|
74
|
+
function readWithinBudget(absolute, maxBytes) {
|
|
75
|
+
const opened = openForRead(absolute);
|
|
76
|
+
if (!opened.ok) return opened;
|
|
77
|
+
const { fd } = opened;
|
|
78
|
+
try {
|
|
79
|
+
const stat = fstatSync(fd);
|
|
80
|
+
if (!stat.isFile()) return { ok: false, failure: { ok: false, error: "not_a_pdf" } };
|
|
81
|
+
if (stat.size > maxBytes) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
failure: { ok: false, error: "pdf_too_large", bytes: stat.size, limit_bytes: maxBytes }
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const buffer = Buffer.alloc(stat.size);
|
|
88
|
+
const bytes = new Uint8Array(buffer.subarray(0, readSync(fd, buffer, 0, stat.size, 0)));
|
|
89
|
+
if (Buffer.from(bytes.subarray(0, PDF_MAGIC.length)).toString("latin1") !== PDF_MAGIC) {
|
|
90
|
+
return { ok: false, failure: { ok: false, error: "not_a_pdf" } };
|
|
91
|
+
}
|
|
92
|
+
return { ok: true, bytes };
|
|
93
|
+
} finally {
|
|
94
|
+
closeSync(fd);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function openForRead(absolute) {
|
|
98
|
+
try {
|
|
99
|
+
return { ok: true, fd: openSync(absolute, "r") };
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err.code === "ENOENT") {
|
|
102
|
+
return { ok: false, failure: { ok: false, error: "not_found" } };
|
|
103
|
+
}
|
|
104
|
+
return { ok: false, failure: { ok: false, error: "pdf_unreadable", message: messageOf(err) } };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
var messageOf = (err) => err instanceof Error ? err.message : String(err);
|
|
108
|
+
async function extractWithin(bytes, path, maxChars, injected) {
|
|
109
|
+
const engine = injected ? { ok: true, extract: injected } : await loadUnpdf();
|
|
110
|
+
if (!engine.ok) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
error: "pdf_engine_missing",
|
|
114
|
+
path,
|
|
115
|
+
install: INSTALL_COMMAND,
|
|
116
|
+
message: engine.message
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
let extracted;
|
|
120
|
+
try {
|
|
121
|
+
extracted = await engine.extract(bytes);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
return { ok: false, error: "pdf_unreadable", path, message: messageOf(err) };
|
|
124
|
+
}
|
|
125
|
+
const { pages, text } = extracted;
|
|
126
|
+
return {
|
|
127
|
+
ok: true,
|
|
128
|
+
path,
|
|
129
|
+
pages,
|
|
130
|
+
chars: text.length,
|
|
131
|
+
truncated: text.length > maxChars,
|
|
132
|
+
text: text.slice(0, maxChars)
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
async function loadUnpdf() {
|
|
136
|
+
try {
|
|
137
|
+
const unpdf = await import('unpdf');
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
extract: async (bytes) => {
|
|
141
|
+
const { totalPages, text } = await unpdf.extractText(bytes, { mergePages: true });
|
|
142
|
+
return { pages: totalPages, text };
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
} catch (err) {
|
|
146
|
+
return { ok: false, message: messageOf(err) };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export { createReadPdfTool };
|
|
151
|
+
//# sourceMappingURL=read-pdf.js.map
|
|
152
|
+
//# sourceMappingURL=read-pdf.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/path-scope.ts","../src/read-pdf.ts"],"names":[],"mappings":"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","file":"read-pdf.js","sourcesContent":["/**\n * Shared `safePathJoin + assertNoSymlinkEscape` helper for built-in coding\n * tools (git-diff, run-vitest, list-dir, read-file, search-text). Returns a\n * pre-formatted `path_traversal` JSON string on failure or `null` on pass.\n *\n * @internal\n */\n\nimport {\n assertNoSymlinkEscape,\n ForbiddenPathError,\n PathTraversalError,\n safePathJoin,\n} from \"./internal/path-guard.js\";\n\nexport function checkPathScope(path: string | undefined, projectRoot: string): string | null {\n if (path === undefined || path === \"\") return null;\n try {\n const abs = safePathJoin(projectRoot, path);\n assertNoSymlinkEscape(abs, projectRoot);\n return null;\n } catch (err) {\n if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {\n return JSON.stringify({ ok: false, error: \"path_traversal\", path });\n }\n throw err;\n }\n}\n\n/**\n * Segments that may never appear in a path honored by `allowAbsolute`.\n *\n * M76 — promoted from `read-file.ts`, where it was private. Duplicating it in `list-dir` would duplicate\n * security KNOWLEDGE: the copies would have to agree on what counts as a secret, and one fixed\n * without the other reopens the hole in the forgotten tool.\n */\nconst SENSITIVE_SEGMENTS = new Set([\".env\", \".git\", \"node_modules\", \".theo\"]);\n\n/**\n * ANY-segment secret guard — the half of `allowAbsolute` that cannot be separated.\n *\n * `isForbiddenPath` only blocks the sensitive item when it is the FIRST segment (relative to the\n * project). An absolute path (`/home/u/proj/.env/sub`) puts it deeper, and it would pass. This one\n * checks every segment, closing the \"reads-anywhere\" exfiltration.\n */\nexport function isForbiddenAtAnyDepth(path: string): boolean {\n const segs = path.replace(/\\\\/g, \"/\").split(\"/\").filter(Boolean);\n return segs.some((s) => {\n if (s === \".env.example\") return false; // a template — safe\n return SENSITIVE_SEGMENTS.has(s) || /^\\.env\\./.test(s);\n });\n}\n","/**\n * `read_pdf` — let the agent read the text of a PDF in the project.\n *\n * ## Why this is a built-in\n *\n * A PDF is the one common document an agent cannot read with `read_file`: the bytes are a\n * compressed object graph, not text. The confinement is the same as `view_image`'s and is the part\n * worth reviewing once: a document reader that honours any path is a file exfiltration primitive.\n *\n * ## The engine\n *\n * Text extraction is delegated to `unpdf`, an OPTIONAL peer loaded lazily on the first call, so a\n * consumer of the other tools never pays for it. Its absence is a typed `pdf_engine_missing`\n * result naming the install command, never a throw. A consumer can pass `extractText` to use any\n * other engine; the envelope stays the same.\n *\n * ## Result shape\n *\n * - `{ ok: true, path, pages, chars, truncated, text }` — `chars` is the length of the whole\n * extracted text; `text` is cut at `maxChars` and `truncated` says whether it was.\n * - `{ ok: false, error: \"path_traversal\" | \"not_found\" | \"not_a_pdf\" | \"pdf_too_large\" |\n * \"pdf_unreadable\" | \"pdf_engine_missing\", path, … }`\n */\n\nimport { closeSync, fstatSync, openSync, readSync } from \"node:fs\";\n\nimport type { CustomTool } from \"@theokit/sdk\";\nimport { Tool } from \"@theokit/sdk\";\nimport { z } from \"zod\";\n\nimport { safePathJoin } from \"./internal/path-guard.js\";\nimport { checkPathScope, isForbiddenAtAnyDepth } from \"./path-scope.js\";\n\n/** Default ceiling on the file read, in bytes on disk: 10 MB. */\nconst DEFAULT_MAX_PDF_BYTES = 10 * 1024 * 1024;\n\n/** Default ceiling on the text returned to the model, in characters. */\nconst DEFAULT_MAX_PDF_CHARS = 100_000;\n\n/** Every PDF starts with this header; anything else is refused before the engine sees it. */\nconst PDF_MAGIC = \"%PDF-\";\n\nconst INSTALL_COMMAND = \"npm install unpdf\";\n\n/** The engine contract: PDF bytes in, page count and text out. */\ntype PdfTextExtractor = (bytes: Uint8Array) => Promise<{ pages: number; text: string }>;\n\n/**\n * Options for {@link createReadPdfTool}.\n *\n * @public\n */\nexport interface CreateReadPdfToolOptions {\n /** Root the tool reads from. Every path is resolved inside it. */\n projectRoot: string;\n /** Name exposed to the model. Omitted ⇒ `read_pdf`. */\n name?: string;\n /** Description exposed to the model. Omitted ⇒ the literal below. */\n description?: string;\n /** Ceiling on the file size in bytes. Omitted ⇒ {@link DEFAULT_MAX_PDF_BYTES}. */\n maxBytes?: number;\n /** Ceiling on the returned text in characters. Omitted ⇒ {@link DEFAULT_MAX_PDF_CHARS}. */\n maxChars?: number;\n /** Engine that turns the bytes into text. Omitted ⇒ the optional peer `unpdf`, loaded lazily. */\n extractText?: PdfTextExtractor;\n}\n\nconst limitsSchema = z.object({\n maxBytes: z.number().int().positive(),\n maxChars: z.number().int().positive(),\n});\n\ntype PdfErrorCode =\n | \"path_traversal\"\n | \"not_found\"\n | \"not_a_pdf\"\n | \"pdf_too_large\"\n | \"pdf_unreadable\"\n | \"pdf_engine_missing\";\n\ninterface ReadPdfOk {\n ok: true;\n path: string;\n pages: number;\n chars: number;\n truncated: boolean;\n text: string;\n}\n\n/** A failure before the path is attached: the read helper does not know the input path. */\ninterface PdfFailureBody {\n ok: false;\n error: PdfErrorCode;\n [extra: string]: unknown;\n}\n\ntype ReadPdfFailure = PdfFailureBody & { path: string };\n\nconst json = (result: ReadPdfOk | ReadPdfFailure): string => JSON.stringify(result);\n\n/**\n * Read the text of a PDF inside the project root.\n *\n * @public\n */\nexport function createReadPdfTool(options: CreateReadPdfToolOptions): CustomTool {\n const { projectRoot } = options;\n const limits = parseLimits(options);\n const extract = options.extractText;\n\n return Tool.create({\n name: options.name ?? \"read_pdf\",\n description:\n options.description ??\n \"Read the text of a PDF file in the project. Returns the page count and the text, cut at a character limit.\",\n inputSchema: z.object({\n path: z.string().describe(\"Path to the PDF, relative to the project root.\"),\n }),\n handler: async (input: { path: string }): Promise<string> => {\n // Same order as `view_image`: the shared scope guard (lexical and symlink escapes), then the\n // any-depth secret guard, and only then a join that can no longer throw outside the envelope.\n const refused = checkPathScope(input.path, projectRoot);\n if (refused !== null) return refused;\n if (isForbiddenAtAnyDepth(input.path)) {\n return json({ ok: false, error: \"path_traversal\", path: input.path });\n }\n\n const outcome = readWithinBudget(safePathJoin(projectRoot, input.path), limits.maxBytes);\n if (!outcome.ok) return json({ ...outcome.failure, path: input.path });\n\n return json(await extractWithin(outcome.bytes, input.path, limits.maxChars, extract));\n },\n }) as unknown as CustomTool;\n}\n\n/** Validates the numeric options once, at construction, so a bad limit fails the factory call. */\nfunction parseLimits(options: CreateReadPdfToolOptions): z.infer<typeof limitsSchema> {\n const parsed = limitsSchema.safeParse({\n maxBytes: options.maxBytes ?? DEFAULT_MAX_PDF_BYTES,\n maxChars: options.maxChars ?? DEFAULT_MAX_PDF_CHARS,\n });\n if (!parsed.success) {\n const detail = parsed.error.issues\n .map((issue) => `${issue.path.join(\".\")}: ${issue.message}`)\n .join(\"; \");\n throw new RangeError(`createReadPdfTool: invalid options (${detail})`);\n }\n return parsed.data;\n}\n\ntype ReadOutcome =\n | { readonly ok: true; readonly bytes: Uint8Array }\n | { readonly ok: false; readonly failure: PdfFailureBody };\n\n/**\n * Sizes, reads and checks one file through a single descriptor (CodeQL js/file-system-race #22):\n * the size cap must describe the bytes actually returned, so the read is bounded by the size the\n * descriptor reported, and a file that grows after `fstat` cannot push past the cap.\n */\nfunction readWithinBudget(absolute: string, maxBytes: number): ReadOutcome {\n const opened = openForRead(absolute);\n if (!opened.ok) return opened;\n const { fd } = opened;\n try {\n const stat = fstatSync(fd);\n // A directory named `x.pdf` opens on Linux; it is still not a PDF.\n if (!stat.isFile()) return { ok: false, failure: { ok: false, error: \"not_a_pdf\" } };\n // `>` and not `>=`: a file of exactly the limit fits.\n if (stat.size > maxBytes) {\n return {\n ok: false,\n failure: { ok: false, error: \"pdf_too_large\", bytes: stat.size, limit_bytes: maxBytes },\n };\n }\n const buffer = Buffer.alloc(stat.size);\n const bytes = new Uint8Array(buffer.subarray(0, readSync(fd, buffer, 0, stat.size, 0)));\n if (Buffer.from(bytes.subarray(0, PDF_MAGIC.length)).toString(\"latin1\") !== PDF_MAGIC) {\n return { ok: false, failure: { ok: false, error: \"not_a_pdf\" } };\n }\n return { ok: true, bytes };\n } finally {\n closeSync(fd);\n }\n}\n\n/**\n * Opens the file, telling a missing file (`not_found`, which the model answers with another path)\n * apart from one it may not read (`pdf_unreadable` with the system's message).\n */\nfunction openForRead(\n absolute: string,\n): { readonly ok: true; readonly fd: number } | Extract<ReadOutcome, { ok: false }> {\n try {\n return { ok: true, fd: openSync(absolute, \"r\") };\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") {\n return { ok: false, failure: { ok: false, error: \"not_found\" } };\n }\n return { ok: false, failure: { ok: false, error: \"pdf_unreadable\", message: messageOf(err) } };\n }\n}\n\nconst messageOf = (err: unknown): string => (err instanceof Error ? err.message : String(err));\n\n/** Runs the engine and cuts its text, mapping every engine failure to a typed result. */\nasync function extractWithin(\n bytes: Uint8Array,\n path: string,\n maxChars: number,\n injected: PdfTextExtractor | undefined,\n): Promise<ReadPdfOk | ReadPdfFailure> {\n const engine = injected ? { ok: true as const, extract: injected } : await loadUnpdf();\n if (!engine.ok) {\n return {\n ok: false,\n error: \"pdf_engine_missing\",\n path,\n install: INSTALL_COMMAND,\n message: engine.message,\n };\n }\n let extracted: { pages: number; text: string };\n try {\n extracted = await engine.extract(bytes);\n } catch (err) {\n return { ok: false, error: \"pdf_unreadable\", path, message: messageOf(err) };\n }\n const { pages, text } = extracted;\n return {\n ok: true,\n path,\n pages,\n chars: text.length,\n truncated: text.length > maxChars,\n text: text.slice(0, maxChars),\n };\n}\n\n/**\n * Loads the optional `unpdf` peer, or the reason it could not be loaded.\n *\n * Any load failure counts, not only a missing module: an ESM-only package required from the CJS\n * build on an older Node fails differently, and the caller's answer is the same install hint. The\n * cause is kept in `message` so a broken install is not reported as a merely absent one. Nothing is\n * cached, so installing the peer mid-session takes effect on the next call.\n */\nasync function loadUnpdf(): Promise<\n | { readonly ok: true; readonly extract: PdfTextExtractor }\n | { readonly ok: false; readonly message: string }\n> {\n try {\n const unpdf = await import(\"unpdf\");\n return {\n ok: true,\n extract: async (bytes) => {\n const { totalPages, text } = await unpdf.extractText(bytes, { mergePages: true });\n return { pages: totalPages, text };\n },\n };\n } catch (err) {\n return { ok: false, message: messageOf(err) };\n }\n}\n"]}
|