agent-sanitizer 2.66.6 → 2.67.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/README.md +26 -18
- package/bin/sanitize-cli.mjs +5 -7
- package/claude-hooks/lib/authored-content.mjs +25 -25
- package/claude-hooks/lib/env-config.mjs +3 -3
- package/claude-hooks/lib/hook-io.mjs +4 -12
- package/claude-hooks/lib/placeholder-grammar.mjs +7 -12
- package/claude-hooks/lib/redactor-client.mjs +2 -2
- package/claude-hooks/lib/secret-annotate.mjs +1 -1
- package/claude-hooks/pretooluse-sanitize.mjs +10 -14
- package/claude-hooks/scan-invisible-chars.mjs +6 -35
- package/package.json +1 -1
- package/src/claude-context.mjs +22 -0
- package/src/credential-names.mjs +14 -20
- package/src/err-message.mjs +21 -0
- package/src/file-write-tools.mjs +96 -0
- package/src/gates.mjs +32 -5
- package/src/instructions.mjs +54 -56
- package/src/output.mjs +1 -12
- package/src/rehydrate.mjs +39 -41
- package/types/claude-context.d.mts +17 -0
- package/types/claude-hooks/lib/authored-content.d.mts +4 -4
- package/types/claude-hooks/lib/hook-io.d.mts +2 -8
- package/types/claude-hooks/lib/placeholder-grammar.d.mts +0 -1
- package/types/claude-hooks/pretooluse-sanitize.d.mts +6 -2
- package/types/credential-names.d.mts +5 -4
- package/types/err-message.d.mts +16 -0
- package/types/file-write-tools.d.mts +64 -0
- package/types/gates.d.mts +1 -15
- package/types/instructions.d.mts +25 -16
- package/types/src/claude-context.d.mts +17 -0
- package/types/src/err-message.d.mts +16 -0
- package/types/src/file-write-tools.d.mts +64 -0
- package/types/src/instructions.d.mts +25 -16
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file-editing tools whose input fields are the bytes persisted to disk, as
|
|
3
|
+
* ONE table, and the reader for the field specs it holds.
|
|
4
|
+
*
|
|
5
|
+
* PROBLEM CLASS — the set of file-writing tools restated per consumer. The
|
|
6
|
+
* fault carve-out that refuses a placeholder-bearing write, the advisories that
|
|
7
|
+
* stay silent on rehydrated tools, the authored-content sanitizer and the
|
|
8
|
+
* rehydration layer each kept their own list. A tool added to one list (a new
|
|
9
|
+
* write-shaped tool, or a renamed field) left the others silently stale, which
|
|
10
|
+
* is a placeholder persisted over a secret on disk. Every consumer reads this
|
|
11
|
+
* table instead.
|
|
12
|
+
*
|
|
13
|
+
* Package-free and dependency-free by construction: the PreToolUse fault path
|
|
14
|
+
* imports it relatively and must work exactly when the agent-sanitizer package
|
|
15
|
+
* failed to load.
|
|
16
|
+
*
|
|
17
|
+
* Each entry:
|
|
18
|
+
* - `authored` — the fields the model writes, which Layer 3 sanitizes.
|
|
19
|
+
* - `placeholders` — every field a redaction placeholder can sit in.
|
|
20
|
+
* - `rehydrates` — whether the rehydration layer re-anchors the call onto
|
|
21
|
+
* disk bytes (`false`: it refuses a placeholder-bearing call instead).
|
|
22
|
+
*
|
|
23
|
+
* A field spec is `key`, or `key[].sub` for `sub` on every element of the
|
|
24
|
+
* array at `key` (MultiEdit batches its writes as `edits[]`).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** @typedef {{ authored: readonly string[], placeholders: readonly string[], rehydrates: boolean }} FileWriteTool */
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Null-prototype: the tool name comes from an untrusted payload, and on a plain
|
|
31
|
+
* object literal `TABLE["constructor"]` answers a truthy inherited value.
|
|
32
|
+
* @type {Readonly<Record<string, FileWriteTool>>}
|
|
33
|
+
*/
|
|
34
|
+
export const FILE_WRITE_TOOLS = Object.freeze(
|
|
35
|
+
Object.assign(Object.create(null), {
|
|
36
|
+
Write: Object.freeze({
|
|
37
|
+
authored: Object.freeze(["content"]),
|
|
38
|
+
placeholders: Object.freeze(["content"]),
|
|
39
|
+
rehydrates: true,
|
|
40
|
+
}),
|
|
41
|
+
Edit: Object.freeze({
|
|
42
|
+
authored: Object.freeze(["new_string"]),
|
|
43
|
+
placeholders: Object.freeze(["old_string", "new_string"]),
|
|
44
|
+
rehydrates: true,
|
|
45
|
+
}),
|
|
46
|
+
MultiEdit: Object.freeze({
|
|
47
|
+
authored: Object.freeze(["edits[].new_string"]),
|
|
48
|
+
placeholders: Object.freeze(["edits[].old_string", "edits[].new_string"]),
|
|
49
|
+
rehydrates: true,
|
|
50
|
+
}),
|
|
51
|
+
NotebookEdit: Object.freeze({
|
|
52
|
+
authored: Object.freeze(["new_source"]),
|
|
53
|
+
placeholders: Object.freeze(["new_source"]),
|
|
54
|
+
rehydrates: false,
|
|
55
|
+
}),
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* True when `tool` is one of {@link FILE_WRITE_TOOLS}.
|
|
61
|
+
* @param {unknown} tool
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
export function isFileWriteTool(tool) {
|
|
65
|
+
return typeof tool === "string" && FILE_WRITE_TOOLS[tool] !== undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const NESTED_SPEC = /^(?<arr>\w+)\[\]\.(?<sub>\w+)$/u;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A field spec, parsed: `{ key }` for a top-level field, or `{ arr, sub }` for
|
|
72
|
+
* `sub` on every element of the array at `arr`.
|
|
73
|
+
* @param {string} spec
|
|
74
|
+
* @returns {{ key: string } | { arr: string, sub: string }}
|
|
75
|
+
*/
|
|
76
|
+
export function parseFieldSpec(spec) {
|
|
77
|
+
const nested = NESTED_SPEC.exec(spec);
|
|
78
|
+
if (!nested?.groups) return { key: spec };
|
|
79
|
+
return { arr: nested.groups.arr, sub: nested.groups.sub };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Every value the field `spec` addresses in `input`: one for a top-level
|
|
84
|
+
* field, one per array element for a nested one. `null` when the array a
|
|
85
|
+
* nested spec names is absent, not an array, or empty.
|
|
86
|
+
* @param {string} spec
|
|
87
|
+
* @param {any} input
|
|
88
|
+
* @returns {unknown[] | null}
|
|
89
|
+
*/
|
|
90
|
+
export function fieldValues(spec, input) {
|
|
91
|
+
const parsed = parseFieldSpec(spec);
|
|
92
|
+
if ("key" in parsed) return [input?.[parsed.key]];
|
|
93
|
+
const arr = input?.[parsed.arr];
|
|
94
|
+
if (!Array.isArray(arr) || arr.length === 0) return null;
|
|
95
|
+
return arr.map((/** @type {any} */ element) => element?.[parsed.sub]);
|
|
96
|
+
}
|
package/src/gates.mjs
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
* These are pulled out of `html.mjs` so the package root can re-export them
|
|
6
6
|
* without dragging in the heavy remark/rehype/unified graph: a static
|
|
7
7
|
* `export … from "./html.mjs"` would eagerly evaluate that ~200ms module on
|
|
8
|
-
* every root import, defeating the lazy-load design. This module imports
|
|
9
|
-
*
|
|
8
|
+
* every root import, defeating the lazy-load design. This module imports only
|
|
9
|
+
* the dependency-free credential vocabulary, so re-exporting it is free.
|
|
10
10
|
*
|
|
11
11
|
* INVARIANT: every gate is a SUPERSET of its parser (a narrower one silently
|
|
12
12
|
* turns its layer off; a needless fire only costs a parse), checked by
|
|
13
13
|
* test/gates-superset.test.mjs.
|
|
14
14
|
*/
|
|
15
|
+
import { credentialNames } from "./credential-names.mjs";
|
|
15
16
|
|
|
16
17
|
// ─── Cheap pre-gates ─────────────────────────────────────────────────────────
|
|
17
18
|
|
|
@@ -117,17 +118,43 @@ export const SECRET_HINT =
|
|
|
117
118
|
export const SECRET_HINT_EXT =
|
|
118
119
|
/(?:AC|SK)[a-z0-9]{32}|SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}|sq0csp-[0-9A-Za-z_-]{43}|(?<![0-9])[0-9]{8,10}:[0-9A-Za-z_-]{35}|(?<![0-9a-z])[0-9a-z]{32}-us[0-9]{1,2}|(?<![A-Za-z0-9_-])[MNO][A-Za-z0-9_-]{23,25}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27}|T3BlbkFJ|pypi-AgE|(?<![A-Za-z0-9])AKC[A-Za-z0-9]{10}|(?<![A-Za-z0-9])AP[0-9A-Fa-f][A-Za-z0-9]{8}|:\/\/[^\s:/@]{1,64}:[^\s:/@]{1,64}@|(?:key|pw|pass)["']?[\s:=>]+["']?[A-Za-z0-9_/+-]{20}/i;
|
|
119
120
|
|
|
121
|
+
/** @type {RegExp | undefined} */
|
|
122
|
+
let _fieldNameHint;
|
|
120
123
|
/**
|
|
121
|
-
*
|
|
124
|
+
* Every noun the redactor cuts as `<noun> = <value>`, rendered from the same
|
|
125
|
+
* vocabulary the redactor reads.
|
|
126
|
+
*
|
|
127
|
+
* PROBLEM CLASS — a secret pre-gate narrower than the redactor behind it. A
|
|
128
|
+
* hand-written keyword list misses each noun added to the vocabulary later
|
|
129
|
+
* (`passphrase = …` reached the model unredacted), so this arm is generated
|
|
130
|
+
* from credential-names.json and is a superset of the field-value redactor by
|
|
131
|
+
* construction. Each part is `[a-z0-9]+`, so the alternation holds only
|
|
132
|
+
* bounded literals and stays linear. Built on first use, never at load: a
|
|
133
|
+
* malformed vocabulary then throws inside the caller's fail-closed catch.
|
|
134
|
+
* @returns {RegExp}
|
|
135
|
+
*/
|
|
136
|
+
function fieldNameHint() {
|
|
137
|
+
return (_fieldNameHint ??= new RegExp(
|
|
138
|
+
credentialNames().fieldNamePatterns.join("|"),
|
|
139
|
+
"i",
|
|
140
|
+
));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Any hint arm against `text` exactly as written.
|
|
122
145
|
* @param {string} text
|
|
123
146
|
* @returns {boolean}
|
|
124
147
|
*/
|
|
125
148
|
function hintMatchesView(text) {
|
|
126
|
-
return
|
|
149
|
+
return (
|
|
150
|
+
SECRET_HINT.test(text) ||
|
|
151
|
+
SECRET_HINT_EXT.test(text) ||
|
|
152
|
+
fieldNameHint().test(text)
|
|
153
|
+
);
|
|
127
154
|
}
|
|
128
155
|
|
|
129
156
|
/**
|
|
130
|
-
* True when
|
|
157
|
+
* True when any pre-gate arm shape-matches `text`, or the same text
|
|
131
158
|
* with every `\n` removed (the view the engine's `_redact_cross_line` scans, so
|
|
132
159
|
* this stays a superset of the redactor). Two literals OR'd so neither grows
|
|
133
160
|
* into a polynomial-backtracking shape (see SECRET_HINT).
|
package/src/instructions.mjs
CHANGED
|
@@ -39,7 +39,11 @@ import {
|
|
|
39
39
|
payloadVerdict,
|
|
40
40
|
stripInvisible,
|
|
41
41
|
} from "./invisible.mjs";
|
|
42
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
classifyReadFailure,
|
|
44
|
+
excludeNodeModules,
|
|
45
|
+
isInsideDir,
|
|
46
|
+
} from "./claude-context.mjs";
|
|
43
47
|
import { compatGlobs } from "./glob-compat.mjs";
|
|
44
48
|
|
|
45
49
|
// The library's public door onto Claude Code's context scope. The definitions
|
|
@@ -56,6 +60,7 @@ export {
|
|
|
56
60
|
CLAUDE_INSTRUCTION_GLOBS,
|
|
57
61
|
CLAUDE_LAUNCH_GLOBS,
|
|
58
62
|
CLAUDE_MEMORY_FILES,
|
|
63
|
+
classifyReadFailure,
|
|
59
64
|
contextScopeContradiction,
|
|
60
65
|
excludeFromContextScan,
|
|
61
66
|
USER_GLOBAL_EVENT_NAMED_GLOBS,
|
|
@@ -310,47 +315,26 @@ function containedWritePath(absPath, root) {
|
|
|
310
315
|
}
|
|
311
316
|
|
|
312
317
|
/**
|
|
313
|
-
*
|
|
314
|
-
* followed) path and decide whether to keep, drop, or reject it. Three
|
|
315
|
-
* outcomes are kept distinct:
|
|
316
|
-
*
|
|
317
|
-
* - The realpath is contained in `realRoot` → KEEP.
|
|
318
|
-
* - The match is contained LEXICALLY (the glob pattern itself, followed
|
|
319
|
-
* literally with no symlink resolution, never leaves `literalRoot`) but its
|
|
320
|
-
* REALPATH escapes `realRoot` — an in-tree symlink (e.g. a planted
|
|
321
|
-
* `CLAUDE.md -> /etc/passwd`) whose target lives outside the tree → SKIP.
|
|
322
|
-
* One planted symlink must not abort scanning every other instruction
|
|
323
|
-
* file; treat it like the existing dangling-symlink case.
|
|
324
|
-
* - The match ESCAPES LEXICALLY — the glob pattern itself reaches outside
|
|
325
|
-
* `literalRoot` via `..` or an absolute path outside the tree, with no
|
|
326
|
-
* symlink involved → THROW. That is a caller misconfiguration (a scanner
|
|
327
|
-
* pointed outside its own tree) that must surface loudly, not be silently
|
|
328
|
-
* skipped.
|
|
329
|
-
* - The path cannot be resolved at all (ENOENT/EACCES — a dangling symlink or
|
|
330
|
-
* unreadable entry that still lives inside the tree) → return false to SKIP
|
|
331
|
-
* it, matching scanInstructionFiles' existing skip-on-unreadable behavior.
|
|
318
|
+
* Refuse a glob match the caller's PATTERN carried outside the scan root.
|
|
332
319
|
*
|
|
333
|
-
* A
|
|
334
|
-
*
|
|
335
|
-
*
|
|
320
|
+
* A match whose literal path stays inside `literalRoot` is kept whatever it
|
|
321
|
+
* resolves to: an in-tree symlink to a file outside the tree (a planted
|
|
322
|
+
* `CLAUDE.md -> /tmp/payload.md`), or a dangling one. The host loads such a
|
|
323
|
+
* file through the same path, so dropping it reported an unscanned file as
|
|
324
|
+
* clean. Scanning it is a read; {@link cleanFile} still refuses to write
|
|
325
|
+
* through it. A match that ESCAPES LEXICALLY — `..` or an absolute glob outside
|
|
326
|
+
* the tree, no symlink involved — is a caller misconfiguration and throws.
|
|
336
327
|
* @param {string} absPath absolute path to a glob match
|
|
337
328
|
* @param {string} realRoot realpath of the scan root
|
|
338
329
|
* @param {string} literalRoot `cwd`, resolved but NOT symlink-followed
|
|
339
330
|
* @param {string} pattern the glob that produced this match
|
|
340
|
-
* @returns {boolean} true to keep the match, false to skip it
|
|
341
331
|
*/
|
|
342
|
-
function
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
}
|
|
349
|
-
if (isContained(realRoot, real)) return true;
|
|
350
|
-
// The glob pattern itself never left the scan root lexically, so the escape
|
|
351
|
-
// is caused by an in-tree symlink resolving outside the tree, not by the
|
|
352
|
-
// caller's glob configuration. Skip this one match, do not abort the scan.
|
|
353
|
-
if (isContained(literalRoot, absPath)) return false;
|
|
332
|
+
function assertContained(absPath, realRoot, literalRoot, pattern) {
|
|
333
|
+
if (isContained(literalRoot, absPath)) return;
|
|
334
|
+
// A match outside the tree that resolves to nothing throws its own errno:
|
|
335
|
+
// still loud, and still refused.
|
|
336
|
+
const real = realpathSync(absPath);
|
|
337
|
+
if (isContained(realRoot, real)) return;
|
|
354
338
|
throw new Error(
|
|
355
339
|
`instruction-file path escapes scan root: pattern ${JSON.stringify(
|
|
356
340
|
pattern,
|
|
@@ -422,14 +406,12 @@ function ancestorEntries(rel) {
|
|
|
422
406
|
* Expand `globs` (relative to `cwd`) to absolute file paths, skipping
|
|
423
407
|
* `node_modules`. The glob set is the caller's instruction-file convention.
|
|
424
408
|
*
|
|
425
|
-
* Containment is enforced per match (see {@link
|
|
426
|
-
* glob pattern itself escapes `cwd` — via `..` or an absolute-path glob
|
|
409
|
+
* Containment is enforced per match (see {@link assertContained}): a match
|
|
410
|
+
* whose glob pattern itself escapes `cwd` — via `..` or an absolute-path glob
|
|
427
411
|
* outside the tree — THROWS, since reaching outside the tree is a caller
|
|
428
|
-
* misconfiguration. A match that lexically stays inside `cwd`
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
* tree), is SKIPPED, so one bad symlink never aborts scanning the rest of the
|
|
432
|
-
* project.
|
|
412
|
+
* misconfiguration. A match that lexically stays inside `cwd` is RETURNED even
|
|
413
|
+
* when an in-tree symlink resolves it outside the tree or to nothing, so the
|
|
414
|
+
* reader decides what an unreadable one means.
|
|
433
415
|
*
|
|
434
416
|
* `exclude` prunes the walk via {@link walkContextGlobs}, so it is handed one
|
|
435
417
|
* root-relative, `/`-separated path per entry. It is composed with, never
|
|
@@ -450,37 +432,53 @@ export function findInstructionFiles(
|
|
|
450
432
|
// One pattern at a time, so a containment failure can name the glob that
|
|
451
433
|
// reached outside the tree.
|
|
452
434
|
for (const pattern of globs)
|
|
453
|
-
for (const absPath of walkContextGlobs([pattern], cwd, exclude))
|
|
454
|
-
|
|
455
|
-
|
|
435
|
+
for (const absPath of walkContextGlobs([pattern], cwd, exclude)) {
|
|
436
|
+
assertContained(absPath, realRoot, literalRoot, pattern);
|
|
437
|
+
seen.add(absPath);
|
|
438
|
+
}
|
|
456
439
|
return [...seen];
|
|
457
440
|
}
|
|
458
441
|
|
|
459
442
|
/**
|
|
460
|
-
* Scan every instruction file matched by `globs`
|
|
461
|
-
* findings
|
|
462
|
-
*
|
|
443
|
+
* Scan every instruction file matched by `globs`. `findings` lists only the
|
|
444
|
+
* files with findings; `skipped` lists every file that exists but could not be
|
|
445
|
+
* read, so a caller never reports an unread file as clean. A missing file
|
|
446
|
+
* (ENOENT, a dangling symlink) is in neither: no bytes of it can load. Paths
|
|
447
|
+
* are relative to `cwd`. Pure scan — no mutation; pair with {@link cleanFile}.
|
|
463
448
|
* @param {string[]} globs
|
|
464
449
|
* @param {{ cwd?: string, exclude?: (entry: string) => boolean }} [options]
|
|
465
450
|
* `exclude` is forwarded to {@link findInstructionFiles}
|
|
466
|
-
* @returns {
|
|
451
|
+
* @returns {{
|
|
452
|
+
* findings: Array<{ file: string, findings: ReturnType<typeof scanText> }>,
|
|
453
|
+
* skipped: Array<{ file: string, reason: string }>,
|
|
454
|
+
* }}
|
|
467
455
|
*/
|
|
468
456
|
export function scanInstructionFiles(
|
|
469
457
|
globs,
|
|
470
458
|
{ cwd = process.cwd(), exclude } = {},
|
|
471
459
|
) {
|
|
472
|
-
const
|
|
460
|
+
const findings = [];
|
|
461
|
+
const skipped = [];
|
|
473
462
|
for (const file of findInstructionFiles(globs, { cwd, exclude })) {
|
|
474
463
|
let content;
|
|
475
464
|
try {
|
|
476
465
|
content = readFileSync(file, "utf-8");
|
|
477
|
-
} catch {
|
|
478
|
-
|
|
466
|
+
} catch (err) {
|
|
467
|
+
if (classifyReadFailure(err) === "skipped")
|
|
468
|
+
skipped.push({
|
|
469
|
+
file: relative(cwd, file),
|
|
470
|
+
// classifyReadFailure returns only for an errno, so `code` is set.
|
|
471
|
+
reason: /** @type {string} */ (
|
|
472
|
+
/** @type {NodeJS.ErrnoException} */ (err).code
|
|
473
|
+
),
|
|
474
|
+
});
|
|
475
|
+
continue;
|
|
479
476
|
}
|
|
480
|
-
const
|
|
481
|
-
if (
|
|
477
|
+
const fileFindings = scanText(content);
|
|
478
|
+
if (fileFindings.length > 0)
|
|
479
|
+
findings.push({ file: relative(cwd, file), findings: fileFindings });
|
|
482
480
|
}
|
|
483
|
-
return
|
|
481
|
+
return { findings, skipped };
|
|
484
482
|
}
|
|
485
483
|
|
|
486
484
|
/**
|
package/src/output.mjs
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
warningMessages,
|
|
55
55
|
} from "./severity.mjs";
|
|
56
56
|
import { orderedMatches, spliceOrdered } from "./view-map.mjs";
|
|
57
|
+
import { errMessage } from "./err-message.mjs";
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
60
|
* Closed enum of LIBRARY-OWNED Layer-5 warning codes — the ONLY warning values
|
|
@@ -120,18 +121,6 @@ function mapFilterWarning(code) {
|
|
|
120
121
|
return label;
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
/**
|
|
124
|
-
* Message from a caught value (`unknown` under strict mode), with one level of
|
|
125
|
-
* cause chain appended so a wrapped failure reads "outer: root".
|
|
126
|
-
* @param {unknown} err
|
|
127
|
-
* @returns {string}
|
|
128
|
-
*/
|
|
129
|
-
function errMessage(err) {
|
|
130
|
-
if (!(err instanceof Error)) return String(err);
|
|
131
|
-
const cause = err.cause instanceof Error ? `: ${err.cause.message}` : "";
|
|
132
|
-
return err.message + cause;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
124
|
/**
|
|
136
125
|
* @typedef {{ text: string, found: string[], note?: string }} RedactResult
|
|
137
126
|
* Layer-4 result: the redacted text, the category labels redacted, and an
|
package/src/rehydrate.mjs
CHANGED
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
* {@link rehydrateRedacted}).
|
|
56
56
|
*/
|
|
57
57
|
import { applyLayer1, normalizeLoneSurrogates } from "./layer1.mjs";
|
|
58
|
+
import { FILE_WRITE_TOOLS, fieldValues } from "./file-write-tools.mjs";
|
|
58
59
|
import {
|
|
59
60
|
occurrences,
|
|
60
61
|
overlapAwareCount,
|
|
@@ -771,42 +772,45 @@ function multiEditDeny(filePath) {
|
|
|
771
772
|
};
|
|
772
773
|
}
|
|
773
774
|
|
|
775
|
+
/**
|
|
776
|
+
* Every string a placeholder can sit in, per one {@link FILE_WRITE_TOOLS}
|
|
777
|
+
* entry, or null when the input is not well-formed for it: a placeholder field
|
|
778
|
+
* that is not a string, or a nested field over an absent or empty array.
|
|
779
|
+
* @param {import("./file-write-tools.mjs").FileWriteTool} entry
|
|
780
|
+
* @param {any} ti
|
|
781
|
+
* @returns {string[] | null}
|
|
782
|
+
*/
|
|
783
|
+
function placeholderFields(entry, ti) {
|
|
784
|
+
const values = [];
|
|
785
|
+
for (const spec of entry.placeholders) {
|
|
786
|
+
const found = fieldValues(spec, ti);
|
|
787
|
+
if (found === null || found.some((v) => typeof v !== "string")) return null;
|
|
788
|
+
values.push(.../** @type {string[]} */ (found));
|
|
789
|
+
}
|
|
790
|
+
return values;
|
|
791
|
+
}
|
|
792
|
+
|
|
774
793
|
/**
|
|
775
794
|
* True when this tool call could need re-anchoring against the target file's
|
|
776
|
-
* sanitized view: any well-formed
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
795
|
+
* sanitized view: any well-formed call to a tool whose table entry
|
|
796
|
+
* `rehydrates`. An Edit or Write qualifies even without placeholders: the view
|
|
797
|
+
* may differ from disk via stripped invisible characters, and a hint-free
|
|
798
|
+
* whole-file Write of such a file would silently persist the stripped bytes.
|
|
799
|
+
*
|
|
800
|
+
* MultiEdit applies its edits SEQUENTIALLY, each against the result of the
|
|
801
|
+
* previous, so the span machinery below (which maps one old_string against
|
|
802
|
+
* one static view) cannot re-anchor it. It is still a candidate: on a
|
|
803
|
+
* divergent file an unguarded pass-through is both a silent clobber and the
|
|
804
|
+
* same char-extraction oracle R1 closes for Edit. The dispatch at the bottom
|
|
805
|
+
* passes the clean-file case through and denies the divergent one.
|
|
781
806
|
* @param {string} tool
|
|
782
807
|
* @param {any} ti
|
|
783
808
|
*/
|
|
784
809
|
function isCandidate(tool, ti) {
|
|
785
810
|
if (typeof ti?.file_path !== "string") return false;
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
);
|
|
790
|
-
if (tool === "Write") return typeof ti.content === "string";
|
|
791
|
-
// MultiEdit applies its edits SEQUENTIALLY, each against the result of the
|
|
792
|
-
// previous, so the span machinery below (which maps one old_string against
|
|
793
|
-
// one static view) cannot re-anchor it. It is still a candidate: on a
|
|
794
|
-
// divergent file an unguarded pass-through is both a silent clobber (a
|
|
795
|
-
// placeholder in new_string persisted verbatim) and the same char-extraction
|
|
796
|
-
// oracle R1 closes for Edit (an old_string matching bytes inside a redacted
|
|
797
|
-
// span). The dispatch at the bottom pass-throughs the clean-file case and
|
|
798
|
-
// denies the divergent one with use-single-Edit guidance.
|
|
799
|
-
if (tool === "MultiEdit")
|
|
800
|
-
return (
|
|
801
|
-
Array.isArray(ti.edits) &&
|
|
802
|
-
ti.edits.length > 0 &&
|
|
803
|
-
ti.edits.every(
|
|
804
|
-
(/** @type {any} */ edit) =>
|
|
805
|
-
typeof edit?.old_string === "string" &&
|
|
806
|
-
typeof edit?.new_string === "string",
|
|
807
|
-
)
|
|
808
|
-
);
|
|
809
|
-
return false;
|
|
811
|
+
const entry = FILE_WRITE_TOOLS[tool];
|
|
812
|
+
if (!entry?.rehydrates) return false;
|
|
813
|
+
return placeholderFields(entry, ti) !== null;
|
|
810
814
|
}
|
|
811
815
|
|
|
812
816
|
/**
|
|
@@ -838,8 +842,9 @@ export async function rehydrateRedacted(
|
|
|
838
842
|
// secret; mapping .ipynb JSON is not supported, so refuse with guidance.
|
|
839
843
|
if (
|
|
840
844
|
tool === "NotebookEdit" &&
|
|
841
|
-
|
|
842
|
-
|
|
845
|
+
placeholderFields(FILE_WRITE_TOOLS.NotebookEdit, toolInput)?.some((v) =>
|
|
846
|
+
v.includes(hint),
|
|
847
|
+
)
|
|
843
848
|
)
|
|
844
849
|
return {
|
|
845
850
|
deny:
|
|
@@ -848,16 +853,9 @@ export async function rehydrateRedacted(
|
|
|
848
853
|
`the secret-bearing cell unchanged, or ask the user to edit it.`,
|
|
849
854
|
};
|
|
850
855
|
if (!isCandidate(tool, toolInput)) return null;
|
|
851
|
-
const hinted =
|
|
852
|
-
tool
|
|
853
|
-
|
|
854
|
-
: tool === "MultiEdit"
|
|
855
|
-
? toolInput.edits.some(
|
|
856
|
-
(/** @type {{old_string: string, new_string: string}} */ edit) =>
|
|
857
|
-
edit.old_string.includes(hint) || edit.new_string.includes(hint),
|
|
858
|
-
)
|
|
859
|
-
: toolInput.old_string.includes(hint) ||
|
|
860
|
-
toolInput.new_string.includes(hint);
|
|
856
|
+
const hinted = /** @type {string[]} */ (
|
|
857
|
+
placeholderFields(FILE_WRITE_TOOLS[tool], toolInput)
|
|
858
|
+
).some((v) => v.includes(hint));
|
|
861
859
|
|
|
862
860
|
let content;
|
|
863
861
|
try {
|
|
@@ -37,6 +37,23 @@ export function isInsideDir(dir: string, file: string): boolean;
|
|
|
37
37
|
* @returns {string}
|
|
38
38
|
*/
|
|
39
39
|
export function displayUntrusted(text: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* PROBLEM CLASS — how a failed instruction-file read is classified. Every
|
|
42
|
+
* scanner reads this one bucketing; nothing re-derives it from an errno.
|
|
43
|
+
*
|
|
44
|
+
* A read failure is never swallowed into an empty findings list — that turns
|
|
45
|
+
* "we could not read this file" into "this file is fine". Three buckets:
|
|
46
|
+
*
|
|
47
|
+
* - SKIPPED — the file exists and this uid cannot read it (EACCES, EISDIR,
|
|
48
|
+
* ELOOP…). Unvetted context, which the caller must report.
|
|
49
|
+
* - ABSENT — ENOENT. The path resolves to nothing, and the host loads
|
|
50
|
+
* instruction files through the same open, so no bytes can reach the model.
|
|
51
|
+
* - THROWN — no errno at all, i.e. a bug. Nothing can be trusted, so the
|
|
52
|
+
* error propagates to the caller's failure posture.
|
|
53
|
+
* @param {unknown} err
|
|
54
|
+
* @returns {"absent" | "skipped"} never returns for a non-errno throw
|
|
55
|
+
*/
|
|
56
|
+
export function classifyReadFailure(err: unknown): "absent" | "skipped";
|
|
40
57
|
/**
|
|
41
58
|
* The one directory no instruction-file walk ever descends into. Its own
|
|
42
59
|
* function so the name is spelled once, and so the two predicates that need it
|
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
* fatigue, and the doctrine here is precision over recall. The signal is the
|
|
9
9
|
* partition test, which reads this function.
|
|
10
10
|
* @param {string} tool
|
|
11
|
-
* @returns {{ kind: "covered", fields: string[] } | { kind: "exempt", reason: string } | { kind: "undeclared" }}
|
|
11
|
+
* @returns {{ kind: "covered", fields: readonly string[] } | { kind: "exempt", reason: string } | { kind: "undeclared" }}
|
|
12
12
|
*/
|
|
13
13
|
export function authoredScopeDecision(tool: string): {
|
|
14
14
|
kind: "covered";
|
|
15
|
-
fields: string[];
|
|
15
|
+
fields: readonly string[];
|
|
16
16
|
} | {
|
|
17
17
|
kind: "exempt";
|
|
18
18
|
reason: string;
|
|
@@ -34,8 +34,8 @@ export function sanitizeAuthoredContent(tool: string, toolInput: any): {
|
|
|
34
34
|
updatedInput: any;
|
|
35
35
|
changed: string[];
|
|
36
36
|
} | null;
|
|
37
|
-
/** @type {Record<string, string[]
|
|
38
|
-
export const AUTHORED_FIELDS: Record<string, string[]
|
|
37
|
+
/** @type {Readonly<Record<string, readonly string[]>>} */
|
|
38
|
+
export const AUTHORED_FIELDS: Readonly<Record<string, readonly string[]>>;
|
|
39
39
|
/** @type {Record<string, string>} */
|
|
40
40
|
export const EXEMPT_TOOLS: Record<string, string>;
|
|
41
41
|
/** @type {ReadonlyArray<{ pattern: RegExp, reason: string }>} */
|
|
@@ -264,14 +264,6 @@ export function makeDeadline(budgetMs: number, now?: () => number): {
|
|
|
264
264
|
export function scrubUntrustedText(raw: unknown, layer1: (text: string) => {
|
|
265
265
|
cleaned: string;
|
|
266
266
|
}, cap?: number): string;
|
|
267
|
-
/**
|
|
268
|
-
* Message from a caught value, which is `unknown` under strict mode. Appends
|
|
269
|
-
* the cause chain (one level) when the cause is itself an Error so callers
|
|
270
|
-
* get "outer: root" instead of just "outer" when an error wraps another.
|
|
271
|
-
* @param {unknown} err
|
|
272
|
-
* @returns {string}
|
|
273
|
-
*/
|
|
274
|
-
export function errMessage(err: unknown): string;
|
|
275
267
|
/**
|
|
276
268
|
* errMessage() for an error whose message may embed attacker-chosen bytes: V8
|
|
277
269
|
* quotes a snippet of the offending input in a JSON.parse SyntaxError, so a hook
|
|
@@ -622,6 +614,7 @@ export class EmptyStdinError extends Error {
|
|
|
622
614
|
* command the reader should actually run.
|
|
623
615
|
*/
|
|
624
616
|
export const DEFAULT_MISSING_PACKAGE_REMEDY: "reinstall the hook dependencies (pnpm install) and retry.";
|
|
617
|
+
export { errMessage };
|
|
625
618
|
/**
|
|
626
619
|
* The project the hooks are guarding. Every per-project $TMPDIR store is keyed to
|
|
627
620
|
* it, so it lives here — beside the other shared identity these hooks agree on —
|
|
@@ -668,3 +661,4 @@ export type HookIoSharedState = {
|
|
|
668
661
|
hookgateMarker: string | null;
|
|
669
662
|
hookgateMarkerResolved: boolean;
|
|
670
663
|
};
|
|
664
|
+
import { errMessage } from "../../src/err-message.mjs";
|
|
@@ -94,7 +94,6 @@ export const PLACEHOLDER_LABEL_CHARS: "A-Za-z0-9 ()._-";
|
|
|
94
94
|
* `grep "\[REDACTED"`) is not mistaken for a placeholder.
|
|
95
95
|
*/
|
|
96
96
|
export const PLACEHOLDER_RE: RegExp;
|
|
97
|
-
export const REHYDRATED_TOOLS: Set<string>;
|
|
98
97
|
/**
|
|
99
98
|
* One found token: the exact placeholder text and the dotted field path of the
|
|
100
99
|
* FIRST input field carrying it (empty for a bare string input).
|
|
@@ -154,8 +154,13 @@ export function failClosedFields(parsedOk: boolean, err: unknown, opts?: {
|
|
|
154
154
|
* view; with the sanitizer down, rehydration cannot translate it back, so
|
|
155
155
|
* letting the call through would persist the literal placeholder text over the
|
|
156
156
|
* real secret on disk — a destructive clobber, not a missed scan.
|
|
157
|
-
* Package-free by construction (a
|
|
157
|
+
* Package-free by construction (a table lookup and a walk of the already-parsed
|
|
158
158
|
* payload), so it holds when the failure IS the missing package.
|
|
159
|
+
*
|
|
160
|
+
* Deliberately NOT exhaustive over every clobber path: Bash can also write a
|
|
161
|
+
* placeholder to disk (`>`, `tee`, `sed -i`, a heredoc), but command strings
|
|
162
|
+
* mention "[REDACTED" benignly far too often for an ask to hold precision
|
|
163
|
+
* there. That is an accepted gap, named in THREAT-MODEL.md's carve-out.
|
|
159
164
|
* @param {unknown} input raw parsed PreToolUse payload (undefined if unparsed)
|
|
160
165
|
* @returns {boolean}
|
|
161
166
|
*/
|
|
@@ -234,7 +239,6 @@ export const PRE_TOOL_USE_MESSAGES: Readonly<{
|
|
|
234
239
|
remedy: string;
|
|
235
240
|
}>;
|
|
236
241
|
export const REDACTION_HINT: "[REDACTED";
|
|
237
|
-
export const WRITE_SHAPED_TOOLS: Set<string>;
|
|
238
242
|
/**
|
|
239
243
|
* A host-supplied deny gate: given the PreToolUse input, the reason this call
|
|
240
244
|
* must be blocked, or null to let the pipeline continue. Hosts use these for
|
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
export function parseCredentialNames(spec: Record<string, unknown>): CredentialNames;
|
|
11
11
|
/** The validated renderings of the packaged vocabulary, memoized.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* The data arrives with the module (the one static import above, so bundles
|
|
14
|
+
* carry it), but it is VALIDATED lazily, never at module load: a malformed
|
|
15
|
+
* vocabulary then throws on first use, inside the caller's fail-closed catch. A
|
|
16
|
+
* throw at load would abort before that catch installs, and a guardrail that
|
|
17
|
+
* fails to load is a guardrail that fails OPEN.
|
|
17
18
|
* @returns {CredentialNames} */
|
|
18
19
|
export function credentialNames(): CredentialNames;
|
|
19
20
|
/** @typedef {"trailing" | "any-segment"} CredentialNameScope */
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The message of a caught value, which is `unknown` under strict mode.
|
|
3
|
+
*
|
|
4
|
+
* PROBLEM CLASS — rendering a caught error as text. The library's warnings and
|
|
5
|
+
* every hook's failure reason read this one definition, so a wrapped failure
|
|
6
|
+
* reads "outer: root" everywhere rather than "outer" in some copies.
|
|
7
|
+
* Dependency-free: the hooks import it relatively and must keep it when the
|
|
8
|
+
* agent-sanitizer package failed to load.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Message from a caught value, with one level of cause chain appended when the
|
|
12
|
+
* cause is itself an Error.
|
|
13
|
+
* @param {unknown} err
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function errMessage(err: unknown): string;
|