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
package/README.md
CHANGED
|
@@ -51,18 +51,20 @@ Split into subpaths so the heavy HTML dependency stays opt-in. **Seam** names
|
|
|
51
51
|
the callback you inject for the agent-specific concern; `—` is a pure transform,
|
|
52
52
|
`fs (direct)` does its own file I/O instead of taking one.
|
|
53
53
|
|
|
54
|
-
| Layer | Import
|
|
55
|
-
| ----- |
|
|
56
|
-
| 1 | `/invisible`
|
|
57
|
-
| 1 | `/layer1`
|
|
58
|
-
| 2 | `/html`
|
|
59
|
-
| 3 | `/html`
|
|
60
|
-
| — | `/confusables`
|
|
61
|
-
| — | `/instructions`
|
|
62
|
-
| — | `/prompt`
|
|
63
|
-
| 1–5 | `/output`
|
|
64
|
-
| — | `/rehydrate`
|
|
65
|
-
| — | `/view-map`
|
|
54
|
+
| Layer | Import | Purpose | Seam |
|
|
55
|
+
| ----- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
|
|
56
|
+
| 1 | `/invisible` | Strip zero-width, bidi, variation-selector and tag chars + ANSI/SGR escapes. Preserves ZWNJ/ZWJ for Arabic/Indic/emoji. Zero deps. | — |
|
|
57
|
+
| 1 | `/layer1` | Layer 1 in one call: the invisible-char and ANSI strips run to a FIXED POINT over each other (either removal can reconstitute what the other matches), then a residual control-introducer sweep closes it — not a two-step pipeline a caller can reproduce in a fixed order. `applyLayer1` leaves an unpaired UTF-16 surrogate in place; `applyLayer1WellFormed` also maps it to U+FFFD, which is the string `/output` shows a model — take that one when the result feeds a redactor, an offset calculation or a view map. Zero deps. | — |
|
|
58
|
+
| 2 | `/html` | Splice out HTML comments and elements hidden via `display:none`, off-screen, white-on-white, `hidden`. Each splice leaves a keyed, round-trippable placeholder. | — |
|
|
59
|
+
| 3 | `/html` | Detect exfil-shaped URLs (payloads in query/path, embedded creds, `data:`/`javascript:`, off-origin redirects) and confusable HOSTS (`аpple.com`). Reports only — never rewrites. | — |
|
|
60
|
+
| — | `/confusables` | Fold look-alike glyphs in tool-call input (paths, commands) to ASCII, closing a cross-script deny-rule bypass. Gated per token, so non-Latin prose passes through unfolded. | `scan` (optional) |
|
|
61
|
+
| — | `/instructions` | Scan/auto-clean `CLAUDE.md`, `AGENTS.md`, `SKILL.md`, etc., decoding Unicode-tag + zero-width-binary payloads. | `fs` (direct) |
|
|
62
|
+
| — | `/prompt` | Classify a prompt pass / note / block on payload-capable invisible/ANSI content (inert escapes get the note). | — |
|
|
63
|
+
| 1–5 | `/output` | Run Layers 1–4 over structured tool output, preserving shape. The Layer-5 slot takes a delete-only filter. | `redact`, `filterInjection` |
|
|
64
|
+
| — | `/rehydrate` | Re-anchor a model Edit or whole-file Write composed from the _sanitized_ view back onto real bytes; gate MultiEdit on a verified view==disk; deny anything ambiguous or secret-exposing. | `io` |
|
|
65
|
+
| — | `/view-map` | Pure offset/text machinery mapping a file's on-disk bytes ↔ the sanitized view (Layer-1 deletions, Layer-4 redactions). No I/O — consumed by `/rehydrate`. | — |
|
|
66
|
+
| — | `/credential-names-matcher` | Build an env-var NAME matcher from the credential-noun vocabulary: `credentialNameMatcher({ scope })` for a redactor (`"trailing"`) or an env scrub (`"any-segment"`), linear in the name's length. | — |
|
|
67
|
+
| — | `/credential-names` | The credential-noun vocabulary itself, as JSON — the same file the Python package reads. | — |
|
|
66
68
|
|
|
67
69
|
See [`THREAT-MODEL.md`](./THREAT-MODEL.md) for per-vector detail.
|
|
68
70
|
|
|
@@ -471,12 +473,18 @@ import {
|
|
|
471
473
|
scanInstructionFiles,
|
|
472
474
|
cleanFile,
|
|
473
475
|
} from "agent-sanitizer/instructions";
|
|
474
|
-
const findings = scanInstructionFiles(
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
476
|
+
const { findings, skipped } = scanInstructionFiles(
|
|
477
|
+
["CLAUDE.md", "**/SKILL.md"],
|
|
478
|
+
{
|
|
479
|
+
cwd: projectDir,
|
|
480
|
+
// Skip what git says is not this checkout's source: ignored directories and
|
|
481
|
+
// nested worktrees. Ignored FILES (CLAUDE.local.md) are still scanned.
|
|
482
|
+
exclude: contextScanExclude(projectDir),
|
|
483
|
+
},
|
|
484
|
+
);
|
|
485
|
+
// `skipped` lists files that exist but could not be read ({ file, reason }).
|
|
486
|
+
// Treat them as unvetted: they were never checked.
|
|
487
|
+
if (skipped.length) console.warn("not scanned:", skipped);
|
|
480
488
|
// `root` is required: a rewrite whose realpath leaves it throws instead.
|
|
481
489
|
for (const { file } of findings)
|
|
482
490
|
cleanFile(`${projectDir}/${file}`, { root: projectDir });
|
package/bin/sanitize-cli.mjs
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* sanitize { text, html? } -> { cleaned, found, warnings, notes, splices? }
|
|
18
18
|
* sanitizeText { text, html?, exfilScan?, flagDigestValues? } -> { cleaned, warnings, notes, modified, sgrNote }
|
|
19
19
|
* classifyPrompt { text } -> { action, reason? }
|
|
20
|
-
* scanInstructionFiles { globs, cwd? } -> { findings: [{ file, findings }] }
|
|
20
|
+
* scanInstructionFiles { globs, cwd? } -> { findings: [{ file, findings }], skipped: [{ file, reason }] }
|
|
21
21
|
* Skips `node_modules`, the directories git ignores wholesale, and any
|
|
22
22
|
* nested worktree — none of them is the scanned checkout's own source.
|
|
23
23
|
* cleanFile { path, root? } -> { changed }
|
|
@@ -166,12 +166,10 @@ export const OPS = {
|
|
|
166
166
|
// Whole-tree scope: a gitignored build directory and a nested worktree are
|
|
167
167
|
// not this checkout's source, so a finding from one names a file the caller
|
|
168
168
|
// does not own — and `cleanFile` would rewrite it.
|
|
169
|
-
return {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}),
|
|
174
|
-
};
|
|
169
|
+
return scanInstructionFiles(globs, {
|
|
170
|
+
cwd,
|
|
171
|
+
exclude: contextScanExclude(cwd),
|
|
172
|
+
});
|
|
175
173
|
},
|
|
176
174
|
|
|
177
175
|
/** @param {Record<string, unknown>} req */
|
|
@@ -53,6 +53,10 @@
|
|
|
53
53
|
*/
|
|
54
54
|
import { lazyImport } from "./hook-io.mjs";
|
|
55
55
|
import { readKnob } from "./knobs.mjs";
|
|
56
|
+
import {
|
|
57
|
+
FILE_WRITE_TOOLS,
|
|
58
|
+
parseFieldSpec,
|
|
59
|
+
} from "../../src/file-write-tools.mjs";
|
|
56
60
|
|
|
57
61
|
// Bound via lazyImport (see its doc for the fail-OPEN hazard of a bare static
|
|
58
62
|
// npm import — here the load crash would fire inside pretooluse-sanitize.mjs's
|
|
@@ -68,25 +72,24 @@ const { payloadVerdict, stripInvisibleWithReport } =
|
|
|
68
72
|
await lazyImport("agent-sanitizer/invisible")
|
|
69
73
|
);
|
|
70
74
|
|
|
71
|
-
// Content fields the model authors, per tool
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
// Null-prototype: `tool` comes from the payload, and on a plain object literal
|
|
79
|
-
// `FIELDS["constructor"]` answers a truthy inherited value that the field loop
|
|
80
|
-
// below would then try to iterate.
|
|
81
|
-
/** @type {Record<string, string[]>} */
|
|
75
|
+
// Content fields the model authors, per tool: every file-write tool's
|
|
76
|
+
// `authored` fields from the one table, plus Bash's command. Paths and
|
|
77
|
+
// confusables are the confusable layer's domain; here we target the free-text
|
|
78
|
+
// fields that carry model-authored prose / code / data out into persisted or
|
|
79
|
+
// displayed artifacts. Null-prototype: `tool` comes from the payload, and on a
|
|
80
|
+
// plain object `FIELDS["constructor"]` answers a truthy inherited value.
|
|
81
|
+
/** @type {Readonly<Record<string, readonly string[]>>} */
|
|
82
82
|
export const AUTHORED_FIELDS = Object.freeze(
|
|
83
|
-
Object.assign(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
83
|
+
Object.assign(
|
|
84
|
+
Object.create(null),
|
|
85
|
+
Object.fromEntries(
|
|
86
|
+
Object.entries(FILE_WRITE_TOOLS).map(([tool, { authored }]) => [
|
|
87
|
+
tool,
|
|
88
|
+
authored,
|
|
89
|
+
]),
|
|
90
|
+
),
|
|
91
|
+
{ Bash: Object.freeze(["command"]) },
|
|
92
|
+
),
|
|
90
93
|
);
|
|
91
94
|
|
|
92
95
|
// The other half of the partition: tools this layer has LOOKED AT and decided
|
|
@@ -131,7 +134,7 @@ export const EXEMPT_TOOL_PATTERNS = Object.freeze([
|
|
|
131
134
|
* fatigue, and the doctrine here is precision over recall. The signal is the
|
|
132
135
|
* partition test, which reads this function.
|
|
133
136
|
* @param {string} tool
|
|
134
|
-
* @returns {{ kind: "covered", fields: string[] } | { kind: "exempt", reason: string } | { kind: "undeclared" }}
|
|
137
|
+
* @returns {{ kind: "covered", fields: readonly string[] } | { kind: "exempt", reason: string } | { kind: "undeclared" }}
|
|
135
138
|
*/
|
|
136
139
|
export function authoredScopeDecision(tool) {
|
|
137
140
|
const fields = AUTHORED_FIELDS[tool];
|
|
@@ -279,12 +282,9 @@ export function sanitizeAuthoredContent(tool, toolInput) {
|
|
|
279
282
|
// every own field (including a literal `__proto__`) as a plain own property.
|
|
280
283
|
const updatedInput = Object.assign(Object.create(null), toolInput);
|
|
281
284
|
for (const k of keys) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if (nested) {
|
|
286
|
-
const arrKey = nested[1];
|
|
287
|
-
const subKey = nested[2];
|
|
285
|
+
const spec = parseFieldSpec(k);
|
|
286
|
+
if ("arr" in spec) {
|
|
287
|
+
const { arr: arrKey, sub: subKey } = spec;
|
|
288
288
|
const arr = toolInput[arrKey];
|
|
289
289
|
if (!Array.isArray(arr)) continue;
|
|
290
290
|
let nestedChanged = false;
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* set of credential-bearing variable names. A rendered second copy is what let
|
|
10
10
|
* this matcher fall twelve segments behind the engine.
|
|
11
11
|
*
|
|
12
|
-
* The
|
|
12
|
+
* The vocabulary comes from `credentialNames()`, the package's one loader of
|
|
13
|
+
* that file. The JSON configs below are imported as modules, not read from disk
|
|
14
|
+
* at call time:
|
|
13
15
|
* esbuild inlines them into the plugin bundle (which ships with no config
|
|
14
16
|
* directory beside it) and Node resolves them natively from the package when the
|
|
15
17
|
* hooks run from source. Their VALIDATION stays lazy — a malformed credential
|
|
@@ -21,7 +23,6 @@
|
|
|
21
23
|
// engine alias and an external consumer resolves the same specifier.
|
|
22
24
|
import { credentialNameMatcher } from "agent-sanitizer/credential-names-matcher";
|
|
23
25
|
|
|
24
|
-
import credentialNames from "../../python/agent_sanitizer/secrets/data/credential-names.json" with { type: "json" };
|
|
25
26
|
import redactionFloor from "../../python/agent_sanitizer/secrets/data/redaction-floor.json" with { type: "json" };
|
|
26
27
|
import inferenceKeys from "../config/inference-key-vars.json" with { type: "json" };
|
|
27
28
|
import scrubbed from "../config/scrubbed-env-vars.json" with { type: "json" };
|
|
@@ -155,7 +156,6 @@ let _credentialRule;
|
|
|
155
156
|
function credentialRule() {
|
|
156
157
|
if (_credentialRule !== undefined) return _credentialRule;
|
|
157
158
|
return (_credentialRule = credentialNameMatcher({
|
|
158
|
-
spec: credentialNames,
|
|
159
159
|
scope: "trailing",
|
|
160
160
|
declineNonSecret: true,
|
|
161
161
|
}));
|
|
@@ -17,6 +17,7 @@ import { createHash } from "node:crypto";
|
|
|
17
17
|
import { tmpdir } from "node:os";
|
|
18
18
|
import { isAbsolute, join } from "node:path";
|
|
19
19
|
import { pathToFileURL } from "node:url";
|
|
20
|
+
import { errMessage } from "../../src/err-message.mjs";
|
|
20
21
|
import { excludeProvisioning } from "./hook-timing.mjs";
|
|
21
22
|
import { readKnob } from "./knobs.mjs";
|
|
22
23
|
|
|
@@ -637,18 +638,9 @@ export function scrubUntrustedText(raw, layer1, cap = UNTRUSTED_TEXT_CAP) {
|
|
|
637
638
|
: cleaned;
|
|
638
639
|
}
|
|
639
640
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
* get "outer: root" instead of just "outer" when an error wraps another.
|
|
644
|
-
* @param {unknown} err
|
|
645
|
-
* @returns {string}
|
|
646
|
-
*/
|
|
647
|
-
export function errMessage(err) {
|
|
648
|
-
if (!(err instanceof Error)) return String(err);
|
|
649
|
-
const cause = err.cause instanceof Error ? `: ${err.cause.message}` : "";
|
|
650
|
-
return err.message + cause;
|
|
651
|
-
}
|
|
641
|
+
// Published from this module as part of the hooks' I/O surface; defined once
|
|
642
|
+
// beside the library that renders the same failures.
|
|
643
|
+
export { errMessage };
|
|
652
644
|
|
|
653
645
|
/**
|
|
654
646
|
* errMessage() for an error whose message may embed attacker-chosen bytes: V8
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
UNPARSEABLE_PLACEHOLDER,
|
|
16
16
|
} from "../../src/layer2-placeholder.mjs";
|
|
17
17
|
import { spanPath, revealDir } from "./reveal.mjs";
|
|
18
|
+
import { isFileWriteTool } from "../../src/file-write-tools.mjs";
|
|
18
19
|
|
|
19
20
|
// Re-exported under this module's published names: consumers of the hooks
|
|
20
21
|
// surface import the Layer-2 grammar from here.
|
|
@@ -70,16 +71,6 @@ export function layer2KeysIn(value, depth = 0) {
|
|
|
70
71
|
return [...keys];
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
// Tools whose inputs the rehydration layer itself resolves (or, for
|
|
74
|
-
// MultiEdit/NotebookEdit, refuses with guidance). Both advisories stay silent
|
|
75
|
-
// on these: their placeholder handling is a verdict, not a note.
|
|
76
|
-
export const REHYDRATED_TOOLS = new Set([
|
|
77
|
-
"Edit",
|
|
78
|
-
"Write",
|
|
79
|
-
"MultiEdit",
|
|
80
|
-
"NotebookEdit",
|
|
81
|
-
]);
|
|
82
|
-
|
|
83
74
|
/**
|
|
84
75
|
* Depth-capped walk: does any string in `value` carry secret-placeholder-shaped
|
|
85
76
|
* text? The cap fails OPEN (deeper content is unseen) — every caller feeds a
|
|
@@ -197,7 +188,9 @@ function tokenList(found) {
|
|
|
197
188
|
* @returns {string | null}
|
|
198
189
|
*/
|
|
199
190
|
export function placeholderNotice(tool, toolInput) {
|
|
200
|
-
|
|
191
|
+
// The rehydration layer resolves (or refuses) a file-write tool's
|
|
192
|
+
// placeholders itself: there the handling is a verdict, not a note.
|
|
193
|
+
if (isFileWriteTool(tool)) return null;
|
|
201
194
|
const { secret } = collectPlaceholders(toolInput);
|
|
202
195
|
if (secret.length === 0) return null;
|
|
203
196
|
return (
|
|
@@ -233,7 +226,9 @@ export function placeholderNotice(tool, toolInput) {
|
|
|
233
226
|
* @returns {string | null}
|
|
234
227
|
*/
|
|
235
228
|
export function layer2PlaceholderNotice(tool, toolInput) {
|
|
236
|
-
|
|
229
|
+
// The rehydration layer resolves (or refuses) a file-write tool's
|
|
230
|
+
// placeholders itself: there the handling is a verdict, not a note.
|
|
231
|
+
if (isFileWriteTool(tool)) return null;
|
|
237
232
|
const { layer2 } = collectPlaceholders(toolInput);
|
|
238
233
|
if (layer2.length === 0) return null;
|
|
239
234
|
// Both routes can be named at once: an input can mix keyed placeholders
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from "./redactor-wire.mjs";
|
|
38
38
|
import {
|
|
39
39
|
currentUid,
|
|
40
|
+
errMessage,
|
|
40
41
|
isPrivateDir,
|
|
41
42
|
ownedByCurrentUser,
|
|
42
43
|
privateDirAt,
|
|
@@ -200,9 +201,8 @@ function isRespawnable(err) {
|
|
|
200
201
|
* @returns {Error}
|
|
201
202
|
*/
|
|
202
203
|
function failClosed(cause) {
|
|
203
|
-
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
204
204
|
return new Error(
|
|
205
|
-
`secret redaction unavailable (${
|
|
205
|
+
`secret redaction unavailable (${errMessage(cause)}); secret-shaped output could not be vetted`,
|
|
206
206
|
);
|
|
207
207
|
}
|
|
208
208
|
|
|
@@ -94,7 +94,7 @@ const ENV_VALUE_REGEX_CACHE = new Map();
|
|
|
94
94
|
*/
|
|
95
95
|
export function hasEnvBoundSecret(text, env = process.env) {
|
|
96
96
|
const minLen = minEnvSecretLen();
|
|
97
|
-
return envBoundSecretVars().some((name) => {
|
|
97
|
+
return envBoundSecretVars(env).some((name) => {
|
|
98
98
|
const value = env[name];
|
|
99
99
|
// Code-point length to match envValueRegex's code-point split — an astral
|
|
100
100
|
// char counts once, not as two UTF-16 units, so minLen means the same thing
|
|
@@ -49,6 +49,9 @@ import {
|
|
|
49
49
|
defaultOpen,
|
|
50
50
|
} from "./lib/hook-fault.mjs";
|
|
51
51
|
import { runLayerPipeline } from "./lib/layer-pipeline.mjs";
|
|
52
|
+
// Relative, like src/claude-context.mjs: the fault path reads this table and
|
|
53
|
+
// must work when the agent-sanitizer package failed to load.
|
|
54
|
+
import { isFileWriteTool } from "../src/file-write-tools.mjs";
|
|
52
55
|
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
53
56
|
import {
|
|
54
57
|
invisibleCharAlert,
|
|
@@ -764,18 +767,6 @@ export function failClosedFields(parsedOk, err, opts = {}) {
|
|
|
764
767
|
// DEFAULT_HINT) failed to load. Exported so test/claude-hooks-fail-open.test.mjs
|
|
765
768
|
// can pin the two spellings together.
|
|
766
769
|
export const REDACTION_HINT = "[REDACTED";
|
|
767
|
-
// The file-editing tools whose input fields ARE the bytes persisted to disk.
|
|
768
|
-
// Deliberately NOT exhaustive over every clobber path: Bash can also write a
|
|
769
|
-
// placeholder to disk (`>`, `tee`, `sed -i`, a heredoc), but command strings
|
|
770
|
-
// mention "[REDACTED" benignly far too often — grepping for it, discussing
|
|
771
|
-
// it — for an ask to hold precision there. That is an accepted gap, named in
|
|
772
|
-
// THREAT-MODEL.md's carve-out paragraph, not a completeness claim.
|
|
773
|
-
export const WRITE_SHAPED_TOOLS = new Set([
|
|
774
|
-
"Write",
|
|
775
|
-
"Edit",
|
|
776
|
-
"MultiEdit",
|
|
777
|
-
"NotebookEdit",
|
|
778
|
-
]);
|
|
779
770
|
|
|
780
771
|
/**
|
|
781
772
|
* True when a faulting PreToolUse call is the one case the OPEN posture must
|
|
@@ -786,8 +777,13 @@ export const WRITE_SHAPED_TOOLS = new Set([
|
|
|
786
777
|
* view; with the sanitizer down, rehydration cannot translate it back, so
|
|
787
778
|
* letting the call through would persist the literal placeholder text over the
|
|
788
779
|
* real secret on disk — a destructive clobber, not a missed scan.
|
|
789
|
-
* Package-free by construction (a
|
|
780
|
+
* Package-free by construction (a table lookup and a walk of the already-parsed
|
|
790
781
|
* payload), so it holds when the failure IS the missing package.
|
|
782
|
+
*
|
|
783
|
+
* Deliberately NOT exhaustive over every clobber path: Bash can also write a
|
|
784
|
+
* placeholder to disk (`>`, `tee`, `sed -i`, a heredoc), but command strings
|
|
785
|
+
* mention "[REDACTED" benignly far too often for an ask to hold precision
|
|
786
|
+
* there. That is an accepted gap, named in THREAT-MODEL.md's carve-out.
|
|
791
787
|
* @param {unknown} input raw parsed PreToolUse payload (undefined if unparsed)
|
|
792
788
|
* @returns {boolean}
|
|
793
789
|
*/
|
|
@@ -797,7 +793,7 @@ export function hintedWriteFault(input) {
|
|
|
797
793
|
input
|
|
798
794
|
);
|
|
799
795
|
return (
|
|
800
|
-
|
|
796
|
+
isFileWriteTool(payload?.tool_name) &&
|
|
801
797
|
containsPlaceholder(payload?.tool_input)
|
|
802
798
|
);
|
|
803
799
|
}
|
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
CLAUDE_CONTEXT_SUBDIRS,
|
|
70
70
|
CLAUDE_INSTRUCTION_GLOBS,
|
|
71
71
|
CLAUDE_LAUNCH_GLOBS,
|
|
72
|
+
classifyReadFailure,
|
|
72
73
|
displayUntrusted,
|
|
73
74
|
isInsideDir,
|
|
74
75
|
} from "../src/claude-context.mjs";
|
|
@@ -219,12 +220,11 @@ function decodeRun(run) {
|
|
|
219
220
|
return instrDecodeRun(run);
|
|
220
221
|
}
|
|
221
222
|
|
|
222
|
-
// Target discovery stays hook-local glue, NOT a
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
// by cleanFile's own root containment and O_NOFOLLOW open.
|
|
223
|
+
// Target discovery stays hook-local glue, NOT a call to the library's
|
|
224
|
+
// `findInstructionFiles`: this hook walks the bounded LAUNCH scope, the library
|
|
225
|
+
// the whole-tree one. Both bucket an unreadable target through
|
|
226
|
+
// classifyReadFailure. The write-side symlink hazard is covered by cleanFile's
|
|
227
|
+
// own root containment and O_NOFOLLOW open.
|
|
228
228
|
|
|
229
229
|
/**
|
|
230
230
|
* Every file Claude Code loads as model context AT LAUNCH: `dir`'s own
|
|
@@ -286,35 +286,6 @@ export {
|
|
|
286
286
|
|
|
287
287
|
// Main (skip when imported for testing)
|
|
288
288
|
|
|
289
|
-
/**
|
|
290
|
-
* PROBLEM CLASS — how a failed instruction-file read is classified. Every
|
|
291
|
-
* consumer reads this one bucketing; nothing re-derives it from an errno.
|
|
292
|
-
*
|
|
293
|
-
* The scan is the only thing between a poisoned `CLAUDE.md` and a session that
|
|
294
|
-
* loads it as instructions, and its caller announces "clean" on the trace
|
|
295
|
-
* channel, whose whole purpose is that a MISSING announcement is loud. So a
|
|
296
|
-
* read failure is never swallowed into an empty findings list — that turns "we
|
|
297
|
-
* could not read this file" into "this file is fine". Three buckets:
|
|
298
|
-
*
|
|
299
|
-
* - SKIPPED — the file exists and this uid cannot read it (EACCES, EISDIR,
|
|
300
|
-
* ELOOP…). Unvetted context: reported to the operator and it arms the gate.
|
|
301
|
-
* - ABSENT — ENOENT. The path resolves to nothing, and Claude Code loads
|
|
302
|
-
* instruction files through the same open, so no bytes can reach the model.
|
|
303
|
-
* Announced on the trace channel only; naming a risk that does not exist
|
|
304
|
-
* teaches the operator to dismiss the gate.
|
|
305
|
-
* - THROWN — no errno at all, i.e. a bug (a TypeError from an unloaded
|
|
306
|
-
* binding). Nothing here can be trusted, so it goes to the caller's
|
|
307
|
-
* declared failure posture. Same errno-vs-bug split {@link
|
|
308
|
-
* autoCleanFindings} uses.
|
|
309
|
-
* @param {unknown} err
|
|
310
|
-
* @returns {"absent" | "skipped"} never returns for a non-errno throw
|
|
311
|
-
*/
|
|
312
|
-
function classifyReadFailure(err) {
|
|
313
|
-
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
314
|
-
if (code === undefined) throw err;
|
|
315
|
-
return code === "ENOENT" ? "absent" : "skipped";
|
|
316
|
-
}
|
|
317
|
-
|
|
318
289
|
/**
|
|
319
290
|
* Scan every instruction file under the project, bucketing each unreadable
|
|
320
291
|
* target through {@link classifyReadFailure}. `scanned` is DERIVED from the two
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.67.0",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/claude-context.mjs
CHANGED
|
@@ -250,6 +250,28 @@ export function displayUntrusted(text) {
|
|
|
250
250
|
);
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
/**
|
|
254
|
+
* PROBLEM CLASS — how a failed instruction-file read is classified. Every
|
|
255
|
+
* scanner reads this one bucketing; nothing re-derives it from an errno.
|
|
256
|
+
*
|
|
257
|
+
* A read failure is never swallowed into an empty findings list — that turns
|
|
258
|
+
* "we could not read this file" into "this file is fine". Three buckets:
|
|
259
|
+
*
|
|
260
|
+
* - SKIPPED — the file exists and this uid cannot read it (EACCES, EISDIR,
|
|
261
|
+
* ELOOP…). Unvetted context, which the caller must report.
|
|
262
|
+
* - ABSENT — ENOENT. The path resolves to nothing, and the host loads
|
|
263
|
+
* instruction files through the same open, so no bytes can reach the model.
|
|
264
|
+
* - THROWN — no errno at all, i.e. a bug. Nothing can be trusted, so the
|
|
265
|
+
* error propagates to the caller's failure posture.
|
|
266
|
+
* @param {unknown} err
|
|
267
|
+
* @returns {"absent" | "skipped"} never returns for a non-errno throw
|
|
268
|
+
*/
|
|
269
|
+
export function classifyReadFailure(err) {
|
|
270
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
271
|
+
if (code === undefined) throw err;
|
|
272
|
+
return code === "ENOENT" ? "absent" : "skipped";
|
|
273
|
+
}
|
|
274
|
+
|
|
253
275
|
/**
|
|
254
276
|
* The one directory no instruction-file walk ever descends into. Its own
|
|
255
277
|
* function so the name is spelled once, and so the two predicates that need it
|
package/src/credential-names.mjs
CHANGED
|
@@ -34,20 +34,15 @@
|
|
|
34
34
|
* — the same file the Python renderings read and the same file the
|
|
35
35
|
* `agent-sanitizer/credential-names` subpath exports, so the ecosystems cannot
|
|
36
36
|
* drift apart on the words either.
|
|
37
|
+
*
|
|
38
|
+
* PROBLEM CLASS — the vocabulary loaded by more than one reader. This module is
|
|
39
|
+
* the ONE JavaScript loader: a static JSON import, which a bundler inlines into
|
|
40
|
+
* every bundle that reaches it (the plugin ships no `python/` directory, so a
|
|
41
|
+
* module-relative file read there finds nothing). Every consumer — the secret
|
|
42
|
+
* pre-gate, the hooks' env-var matcher — calls {@link credentialNames} instead
|
|
43
|
+
* of importing the file again.
|
|
37
44
|
*/
|
|
38
|
-
import
|
|
39
|
-
import { dirname, join } from "node:path";
|
|
40
|
-
import { fileURLToPath } from "node:url";
|
|
41
|
-
|
|
42
|
-
const DATA_FILE = join(
|
|
43
|
-
dirname(fileURLToPath(import.meta.url)),
|
|
44
|
-
"..",
|
|
45
|
-
"python",
|
|
46
|
-
"agent_sanitizer",
|
|
47
|
-
"secrets",
|
|
48
|
-
"data",
|
|
49
|
-
"credential-names.json",
|
|
50
|
-
);
|
|
45
|
+
import packagedSpec from "../python/agent_sanitizer/secrets/data/credential-names.json" with { type: "json" };
|
|
51
46
|
|
|
52
47
|
const FILE_LABEL = "credential-names.json";
|
|
53
48
|
const ENV_NAME_USE = "env-name";
|
|
@@ -158,15 +153,14 @@ export function parseCredentialNames(spec) {
|
|
|
158
153
|
let _packaged;
|
|
159
154
|
/** The validated renderings of the packaged vocabulary, memoized.
|
|
160
155
|
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
156
|
+
* The data arrives with the module (the one static import above, so bundles
|
|
157
|
+
* carry it), but it is VALIDATED lazily, never at module load: a malformed
|
|
158
|
+
* vocabulary then throws on first use, inside the caller's fail-closed catch. A
|
|
159
|
+
* throw at load would abort before that catch installs, and a guardrail that
|
|
160
|
+
* fails to load is a guardrail that fails OPEN.
|
|
165
161
|
* @returns {CredentialNames} */
|
|
166
162
|
export function credentialNames() {
|
|
167
|
-
return (_packaged ??= parseCredentialNames(
|
|
168
|
-
JSON.parse(readFileSync(DATA_FILE, "utf8")),
|
|
169
|
-
));
|
|
163
|
+
return (_packaged ??= parseCredentialNames(packagedSpec));
|
|
170
164
|
}
|
|
171
165
|
|
|
172
166
|
/** @typedef {"trailing" | "any-segment"} CredentialNameScope */
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
/**
|
|
12
|
+
* Message from a caught value, with one level of cause chain appended when the
|
|
13
|
+
* cause is itself an Error.
|
|
14
|
+
* @param {unknown} err
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
export function errMessage(err) {
|
|
18
|
+
if (!(err instanceof Error)) return String(err);
|
|
19
|
+
const cause = err.cause instanceof Error ? `: ${err.cause.message}` : "";
|
|
20
|
+
return err.message + cause;
|
|
21
|
+
}
|