agent-sanitizer 2.4.0 → 2.5.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 CHANGED
@@ -30,11 +30,9 @@ placeholders where hidden HTML was spliced out.
30
30
 
31
31
  ## Entry points
32
32
 
33
- Split into subpaths so the heavy HTML dependency stays opt-in. The **seam**
34
- column marks entry points that inject the agent-specific concern (a callback
35
- you supply) rather than baking it in; `—` means the function is a pure
36
- transform with no such hook, and `fs (direct)` means it does its own file I/O
37
- (Node's filesystem, not an agent harness) instead of taking a callback.
33
+ Split into subpaths so the heavy HTML dependency stays opt-in. **Seam** names
34
+ the callback you inject for the agent-specific concern; `—` is a pure transform,
35
+ `fs (direct)` does its own file I/O instead of taking one.
38
36
 
39
37
  | # | Import | Purpose | Seam |
40
38
  | --- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
@@ -69,16 +67,12 @@ without notice.
69
67
 
70
68
  ### `FILTER_WARNING` codes (Layer 5)
71
69
 
72
- The Layer-5 `filterInjection` seam is deliberately thin: the injected filter may
73
- only ask for **verbatim span deletions** (`removeSpans`) and may only warn with a
74
- **closed enum code** (`warning`), never free text. Because the filter runs on
75
- attacker-influenced content and its `warning` would otherwise be concatenated
76
- straight into the model-facing context **without** re-passing Layer 1, a
77
- compromised or prompt-injected filter emitting arbitrary text would defeat the
78
- "a compromised filter can only remove bytes, never inject" contract. So the
79
- **library owns the message** for each code, and a filter returning any value
80
- outside this enum makes `sanitizeText` **throw** (fail loud). Branch on the code,
81
- like `found`:
70
+ The Layer-5 `filterInjection` seam is deliberately thin: the filter may only
71
+ request **verbatim span deletions** (`removeSpans`) and warn with a **closed
72
+ enum code**, never free text. Its warning reaches the model-facing context
73
+ without re-passing Layer 1, so a prompt-injected filter emitting arbitrary text
74
+ would defeat the "can only remove bytes, never inject" contract. The library
75
+ owns each message, and any value outside the enum makes `sanitizeText` **throw**:
82
76
 
83
77
  | `FILTER_WARNING` code | Meaning |
84
78
  | --------------------- | --------------------------------------------------------------------------------------- |
@@ -88,120 +82,79 @@ like `found`:
88
82
 
89
83
  ## Using it with Claude Code
90
84
 
91
- The entry points above are a library you supply the wiring. For Claude Code
92
- the wiring is already written, as four hooks that put Layers 1–4 on the tool
93
- stream: tool input, tool output, user prompts, and a session-start scan of the
94
- instruction files.
95
-
96
- ### The plugin (recommended)
85
+ Four hooks put Layers 1–4 on the tool stream: tool input, tool output, user
86
+ prompts, and a session-start scan of the instruction files.
97
87
 
98
88
  ```
99
89
  /plugin marketplace add AlexanderMattTurner/agent-sanitizer
100
90
  /plugin install agent-sanitizer@agent-sanitizer
101
91
  ```
102
92
 
103
- The plugin ships a self-contained bundle and a committed Python zipapp of the
104
- secret-redaction engine, so it needs no `node_modules` and no install step
105
- beyond a `python3` on PATH.
106
-
107
- ### Without the plugin
93
+ The plugin needs no `node_modules` and no build step just `python3` on PATH
94
+ for Layer 4.
108
95
 
109
- Install the package and point your `settings.json` at the published hook entry.
110
- One entry point dispatches all four modes on `--hook=`:
96
+ To wire them yourself instead, one entry dispatches all four modes on `--hook=`:
111
97
 
112
98
  ```jsonc
99
+ // settings.json — one entry per event; PreToolUse/PostToolUse also take "matcher": "*"
113
100
  {
114
- "hooks": {
115
- "UserPromptSubmit": [
116
- {
117
- "hooks": [
118
- {
119
- "type": "command",
120
- "command": "node ./node_modules/agent-sanitizer/claude-hooks/plugin-hooks.mjs --hook=sanitize-user-prompt",
121
- },
122
- ],
123
- },
124
- ],
125
- "PreToolUse": [
126
- {
127
- "matcher": "*",
128
- "hooks": [
129
- {
130
- "type": "command",
131
- "command": "node ./node_modules/agent-sanitizer/claude-hooks/plugin-hooks.mjs --hook=pretooluse-sanitize",
132
- },
133
- ],
134
- },
135
- ],
136
- "PostToolUse": [
137
- {
138
- "matcher": "*",
139
- "hooks": [
140
- {
141
- "type": "command",
142
- "command": "node ./node_modules/agent-sanitizer/claude-hooks/plugin-hooks.mjs --hook=sanitize-output",
143
- },
144
- ],
145
- },
146
- ],
147
- "SessionStart": [
148
- {
149
- "hooks": [
150
- {
151
- "type": "command",
152
- "command": "node ./node_modules/agent-sanitizer/claude-hooks/plugin-hooks.mjs --hook=scan-invisible-chars",
153
- },
154
- ],
155
- },
156
- ],
157
- },
101
+ "type": "command",
102
+ "command": "node ./node_modules/agent-sanitizer/claude-hooks/plugin-hooks.mjs --hook=sanitize-output",
158
103
  }
159
104
  ```
160
105
 
161
- Resolve the path however your project prefers — `require.resolve("agent-sanitizer/claude-hooks")` gives it without hardcoding a layout. Importing the module instead of spawning it is a no-op, so a build step that pulls it in will not consume stdin.
106
+ | Event | `--hook=` |
107
+ | ------------------ | ---------------------- |
108
+ | `UserPromptSubmit` | `sanitize-user-prompt` |
109
+ | `PreToolUse` | `pretooluse-sanitize` |
110
+ | `PostToolUse` | `sanitize-output` |
111
+ | `SessionStart` | `scan-invisible-chars` |
162
112
 
163
- **Layer 4 needs the Python engine.** Secret redaction runs out-of-process
164
- against `agent-secret-redactor-daemon`, from the `secrets` extra on PyPI:
113
+ `require.resolve("agent-sanitizer/claude-hooks")` gives the path without
114
+ hardcoding a layout. Importing the module rather than spawning it is a no-op.
165
115
 
166
- ```bash
167
- pip install 'agent-sanitizer[secrets]' # version-match the npm package
168
- ```
116
+ **To compose the hooks instead of spawning them**, each module is a subpath of
117
+ its own, typed, with the pieces exported individually:
169
118
 
170
- Without it, `sanitize-output` **fails closed** on secret-shaped output — the
171
- tool result is suppressed and replaced with a placeholder rather than shown
172
- unvetted. That is the intended posture, not a degradation to ignore: Layers 1–3
173
- still run, but a missing daemon means every secret-shaped output is withheld.
119
+ ```js
120
+ import {
121
+ sanitizeText,
122
+ evaluateToolOutput,
123
+ } from "agent-sanitizer/claude-hooks/sanitize-output";
124
+ import {
125
+ lazyImport,
126
+ makeDeadline,
127
+ } from "agent-sanitizer/claude-hooks/lib/hook-io";
128
+ ```
174
129
 
175
- **Layer 5 (second-model injection filtering) is not included.** The `/output`
176
- seam accepts a `filterInjection` callback, but these hooks never supply one, so
177
- nothing here calls out to a model or leaves the machine.
130
+ `agent-sanitizer/claude-hooks/<module>` for the four hooks
131
+ (`sanitize-output`, `pretooluse-sanitize`, `sanitize-user-prompt`,
132
+ `scan-invisible-chars`) and `agent-sanitizer/claude-hooks/lib/<module>` for the
133
+ shared libs. Importing one runs no CLI and reads no stdin. Same stability
134
+ posture as the `_AGENT_SANITIZER_*` variables below: reachable and typed, but
135
+ the supported surface is the `--hook=` CLI, so these move between minor
136
+ versions.
178
137
 
179
- ### Internal environment variables
138
+ **Layer 4 needs the Python engine** — `pip install 'agent-sanitizer[secrets]'`,
139
+ version-matched to the npm package. Without it `sanitize-output` fails closed:
140
+ secret-shaped output is suppressed, not shown unvetted. Layers 1–3 still run.
180
141
 
181
- These tune the hooks' internals. They are **not a stable interface** — they
182
- carry a leading underscore and may change between minor versions. The stable
183
- surface is the `--hook=` CLI and the settings wiring above.
142
+ **Layer 5 (second-model injection filtering) is not included.** These hooks
143
+ never supply the `/output` seam's `filterInjection` callback, so nothing here
144
+ calls a model or leaves the machine.
184
145
 
185
- | Variable | Effect |
186
- | -------------------------------------- | ------------------------------------------------------------------ |
187
- | `_AGENT_SANITIZER_REDACTOR_DAEMON` | Path to the redactor daemon binary |
188
- | `_AGENT_SANITIZER_REDACTOR_SOCKET` | Unix socket the daemon binds; per-session and private by default |
189
- | `_AGENT_SANITIZER_REDACTOR_WAIT_MS` | How long to wait for a freshly spawned daemon to accept |
190
- | `_AGENT_SANITIZER_REDACTOR_REQUEST_MS` | Deadline for one request; bounds a daemon that accepts then stalls |
191
- | `_AGENT_SANITIZER_SANITIZE_BUDGET_MS` | Total wall-clock budget for one hook run's daemon calls |
192
- | `_AGENT_SANITIZER_TRACE` | `info` or `debug` to emit one JSON line per layer engagement |
193
- | `_AGENT_SANITIZER_TRACE_FILE` | Trace sink; stderr when unset |
194
- | `_AGENT_SANITIZER_REVEAL_DIR` | Where Layer 2 stores pre-splice text for the model to read back |
146
+ Hook internals are tuned by `_AGENT_SANITIZER_*` variables (redactor daemon
147
+ path/socket/timeouts, sanitize budget, trace channel, Layer-2 reveal dir). The
148
+ leading underscore marks them unstable — the supported surface is the `--hook=`
149
+ CLI above.
195
150
 
196
151
  ## How this compares
197
152
 
198
- The "sanitize untrusted LLM input" space mostly splits into two camps: ML
199
- classifiers that score a prompt's _intent_ (Lakera Guard, Meta's Prompt
200
- Guard, Rebuff, NeMo Guardrails' input rails), and PII-focused redactors
201
- (Microsoft Presidio). Neither targets the byte-level hiding channel this
202
- library covers, and that gap is exactly where invisible-Unicode and
203
- hidden-HTML payloads live—content a semantic classifier never "sees" as
204
- suspicious because it renders as blank space or doesn't render at all.
153
+ The space splits into ML classifiers that score a prompt's _intent_ (Lakera
154
+ Guard, Meta's Prompt Guard, Rebuff, NeMo Guardrails) and PII redactors
155
+ (Presidio). Neither targets the byte-level hiding channel content a semantic
156
+ classifier never "sees" as suspicious because it renders as blank space or
157
+ doesn't render at all.
205
158
 
206
159
  | | `agent-sanitizer` | Semantic guard/classifier (Lakera, Prompt Guard, Rebuff, NeMo rails) | PII redactor (Presidio) |
207
160
  | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
@@ -213,13 +166,10 @@ suspicious because it renders as blank space or doesn't render at all.
213
166
  | **Reversibility** | `/rehydrate` re-anchors a model's edit from the sanitized view back onto real bytes, denying anything ambiguous | N/A—classifiers only pass/block, they don't rewrite-and-reverse | N/A |
214
167
  | **Non-JS support** | Same verdicts via a bundled CLI/worker—Python client included, no reimplementation | Usually a hosted API (language-agnostic) or Python-only SDK | Python-first (spaCy-based) |
215
168
 
216
- In practice these are complementary, not competing: run a semantic guard for
217
- intent, Presidio for PII you must not leak, and this library for the hidden
218
- channel both of those are blind to. If you already run a classifier and are
219
- still getting bitten by zero-width payloads or `display:none` instructions
220
- riding along in RAG context, that's the gap this library closes.
169
+ These are complementary: a semantic guard for intent, Presidio for PII, and this
170
+ for the hidden channel both are blind to.
221
171
 
222
- ### Examples
172
+ ## Examples
223
173
 
224
174
  ```js
225
175
  import { stripInvisibleWithReport } from "agent-sanitizer/invisible";
@@ -269,25 +219,21 @@ await rehydrateRedacted("Edit", toolInput, {
269
219
  }); // { updatedInput, context } | { deny } | null — a deny never exposes a secret
270
220
  ```
271
221
 
272
- The credential-noun vocabulary — the words that make an identifier name a secret —
273
- is published as data so a consumer with its own matcher derives it from one list
274
- instead of forking one. Each noun carries the `uses` it is valid for: `env-name`
275
- for a matcher that inspects a variable NAME only, `field-value` for one that
276
- redacts whatever follows `noun = ` (a broad noun there mangles ordinary text, so
277
- `key` and `pat` are name-only).
222
+ The credential-noun vocabulary — the words that make an identifier name a
223
+ secret — is published as data so a consumer with its own matcher derives it
224
+ rather than forking it. Each noun's `uses` marks where it is valid: `env-name`
225
+ inspects a variable NAME only, `field-value` also redacts what follows
226
+ `noun = ` (too broad for `key` and `pat`, which stay name-only).
278
227
 
279
228
  ```js
280
229
  import { createRequire } from "node:module";
281
- const vocabulary = createRequire(import.meta.url)(
282
- "agent-sanitizer/credential-names",
283
- );
284
- vocabulary.nouns; // [{ parts: ["api", "key"], uses: ["env-name", "field-value"] }, …]
230
+ createRequire(import.meta.url)("agent-sanitizer/credential-names").nouns;
231
+ // [{ parts: ["api", "key"], uses: ["env-name", "field-value"] }, …]
285
232
  ```
286
233
 
287
234
  ```python
288
235
  from agent_sanitizer.secrets import credential_name_segments
289
-
290
- credential_name_segments() # ("API_KEY", "APIKEY", "ACCESS_KEY", …) — rendered for a NAME matcher
236
+ credential_name_segments() # ("API_KEY", "APIKEY", "ACCESS_KEY", …)
291
237
  ```
292
238
 
293
239
  ## Limits
@@ -306,17 +252,14 @@ layer does and does not defend against.
306
252
 
307
253
  ## Non-JS pipelines (Python, etc.)
308
254
 
309
- The JS is the **single source of truth**—non-JS callers drive the same verdicts
310
- through the bundled CLI, so there’s
311
- no second implementation to drift. An `op` field selects the entry point
312
- (default `sanitize`); the self-contained ones—`sanitizeText`, `classifyPrompt`,
313
- `scanInstructionFiles`, `cleanFile`—are all bridged. Entry points with an
314
- injected JS callback have no wire form and stay JS-only. The bridged
315
- `sanitizeText` runs Layers 1–3 only (invisible/ANSI strip, HTML hidden-content
316
- splice, exfil detection): it never redacts secrets (Layer 4) or runs injection
317
- filtering (Layer 5), and the wire protocol's `sgrNote` (Python's
318
- `TextResult.sgr_note`) is always `false`, since the bridge never wires
319
- `sgrCarveOut`.
255
+ The JS is the **single source of truth** — non-JS callers drive the same
256
+ verdicts through the bundled CLI, so no second implementation can drift. An `op`
257
+ field selects the entry point (default `sanitize`); the self-contained ones
258
+ `sanitizeText`, `classifyPrompt`, `scanInstructionFiles`, `cleanFile` — are
259
+ bridged, while entry points taking a JS callback have no wire form. Bridged
260
+ `sanitizeText` runs Layers 1–3 only: no secret redaction (Layer 4), no injection
261
+ filtering (Layer 5), and `sgrNote` is always `false` since the bridge never
262
+ wires `sgrCarveOut`.
320
263
 
321
264
  ```sh
322
265
  echo '{"text":"a​b"}' | npx sanitize-cli # default op: sanitize
@@ -324,16 +267,13 @@ echo '{"op":"classifyPrompt","text":"…"}' | npx sanitize-cli
324
267
  sanitize-cli --worker # newline-delimited, one response/line
325
268
  ```
326
269
 
327
- The [`python/`](./python) client wraps every bridged op (`sanitize`,
328
- `sanitize_text`, `classify_prompt`, `scan_instruction_files`, `clean_file`). The
329
- wheel ships a self-contained, single-file build of the CLI (`src/` and its npm
330
- dependencies bundled into one `.mjs` at release time), so a `pip install` plus
331
- Node.js (>=22) on `PATH` works with no separate JavaScript checkout to clone.
332
- `AGENT_SANITIZER_CLI` is only an override escape hatch (e.g. to point at a
333
- custom/dev build)—a normal install never needs to set it. The first
334
- `html=True` call starts a shared worker, so the ~200 ms HTML module-load is
335
- paid **once per process**; Layer-1 calls stay one-shot. `persist=True/False`
336
- forces the mode and `shutdown_worker()` (also an `atexit` hook) stops it.
270
+ The [`python/`](./python) client wraps every bridged op. The wheel ships a
271
+ single-file build of the CLI, so `pip install` plus Node.js (>=22) on `PATH`
272
+ needs no JavaScript checkout; `AGENT_SANITIZER_CLI` is an override escape hatch
273
+ a normal install never sets. The first `html=True` call starts a shared worker,
274
+ paying the ~200 ms HTML module-load **once per process**; Layer-1 calls stay
275
+ one-shot. `persist=True/False` forces the mode; `shutdown_worker()` (also an
276
+ `atexit` hook) stops it.
337
277
 
338
278
  ```python
339
279
  from agent_sanitizer import sanitize, Sanitizer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.4.0",
3
+ "version": "2.5.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": {
@@ -122,7 +122,14 @@
122
122
  "default": "./src/rehydrate.mjs"
123
123
  },
124
124
  "./credential-names": "./python/agent_sanitizer/secrets/data/credential-names.json",
125
- "./claude-hooks": "./claude-hooks/plugin-hooks.mjs"
125
+ "./claude-hooks": {
126
+ "types": "./types/claude-hooks/plugin-hooks.d.mts",
127
+ "default": "./claude-hooks/plugin-hooks.mjs"
128
+ },
129
+ "./claude-hooks/*": {
130
+ "types": "./types/claude-hooks/*.d.mts",
131
+ "default": "./claude-hooks/*.mjs"
132
+ }
126
133
  },
127
134
  "files": [
128
135
  "src/*.mjs",
@@ -153,7 +160,7 @@
153
160
  "coverage": "c8 node --test",
154
161
  "check": "tsc --noEmit && tsc -p tsconfig.hooks.json --noEmit",
155
162
  "typecheck": "tsc --noEmit && tsc -p tsconfig.hooks.json --noEmit",
156
- "build:types": "tsc -p tsconfig.build.json",
163
+ "build:types": "tsc -p tsconfig.build.json && tsc -p tsconfig.build-hooks.json",
157
164
  "gen:joining-type": "node scripts/gen-joining-type.mjs",
158
165
  "lint": "eslint .",
159
166
  "test:mutation": "stryker run",
@@ -0,0 +1,15 @@
1
+ /** @param {string[]} changed */
2
+ export function authoredContext(changed: string[]): string;
3
+ /**
4
+ * Strip authored stego / terminal-control sequences from the model-authored
5
+ * fields of a tool call. Returns the updated input plus a per-field description
6
+ * of what was stripped, or null when nothing changed. Throws on internal error
7
+ * (caller fails closed).
8
+ * @param {string} tool
9
+ * @param {any} toolInput
10
+ * @returns {{ updatedInput: any, changed: string[] } | null}
11
+ */
12
+ export function sanitizeAuthoredContent(tool: string, toolInput: any): {
13
+ updatedInput: any;
14
+ changed: string[];
15
+ } | null;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The loaded control-plane bindings, narrowed to non-undefined — or a throw
3
+ * the calling hook's catch converts into its own failure posture. Overrides
4
+ * exist so tests can drive the unavailable arm in-process.
5
+ * @param {{ claudeAdapter?: unknown, Decision?: unknown, EventKind?: unknown }} [overrides]
6
+ * @returns {{
7
+ * claudeAdapter: typeof import("agent-control-plane-core/claude").claudeAdapter,
8
+ * Decision: typeof import("agent-control-plane-core").Decision,
9
+ * EventKind: typeof import("agent-control-plane-core").EventKind,
10
+ * }}
11
+ */
12
+ export function controlPlane(overrides?: {
13
+ claudeAdapter?: unknown;
14
+ Decision?: unknown;
15
+ EventKind?: unknown;
16
+ }): {
17
+ claudeAdapter: typeof import("agent-control-plane-core/claude").claudeAdapter;
18
+ Decision: typeof import("agent-control-plane-core").Decision;
19
+ EventKind: typeof import("agent-control-plane-core").EventKind;
20
+ };
21
+ /**
22
+ * Serialize a rendered NativeResponse for Claude Code's stdout, or null when
23
+ * the body carries nothing a silent exit 0 doesn't already say. The adapter's
24
+ * exit_code is deliberately NOT honored by the hooks: Claude Code parses hook
25
+ * stdout as JSON only on exit 0 — under the adapter's exit-2 enforced-deny
26
+ * channel it discards stdout and reads the (empty) stderr instead, so the
27
+ * deny would land without its reason. For this host the stdout JSON's
28
+ * permissionDecision IS the enforcement channel, and hooks always exit 0.
29
+ * @param {{ stdout?: unknown }} response a NativeResponse from adapter.render
30
+ * @returns {string | null}
31
+ */
32
+ export function nativeStdout(response: {
33
+ stdout?: unknown;
34
+ }): string | null;
35
+ /**
36
+ * Run a judge hook's CLI transport: read the native payload from stdin, parse
37
+ * it through the claude adapter, render the judge's verdict, and write the
38
+ * native response. This encodes the two transport invariants every gate hook
39
+ * shares: stdin is read BEFORE the control-plane bindings are touched, so a
40
+ * package-load failure still lands in `onError` with the parsed input in hand;
41
+ * and the process always exits 0 with the verdict in the stdout JSON (see
42
+ * nativeStdout — exit-code enforcement is deliberately not used). Any throw —
43
+ * unparsable stdin, missing package, a judge error — is reported on stderr and
44
+ * routed to `onError(err, input)` (`input` undefined when stdin never parsed),
45
+ * where the hook applies its declared fail posture.
46
+ * @param {string} hookName prefix for the stderr diagnostic
47
+ * @param {(event: import("agent-control-plane-core").ToolCallEvent) =>
48
+ * import("agent-control-plane-core").Verdict |
49
+ * Promise<import("agent-control-plane-core").Verdict>} judge
50
+ * @param {object} opts
51
+ * @param {(err: unknown, input: unknown) => void} opts.onError fail-posture emitter
52
+ * @param {(input: unknown) => unknown} [opts.transformInput] raw-payload normalization before adapter.parse
53
+ * @param {() => Promise<unknown>} [opts.readInput] injectable stdin reader
54
+ * @param {(chunk: string) => void} [opts.write] injectable stdout writer
55
+ * @returns {Promise<void>}
56
+ */
57
+ export function runJudgeCli(hookName: string, judge: (event: import("agent-control-plane-core").ToolCallEvent) => import("agent-control-plane-core").Verdict | Promise<import("agent-control-plane-core").Verdict>, { onError, transformInput, readInput, write, }: {
58
+ onError: (err: unknown, input: unknown) => void;
59
+ transformInput?: ((input: unknown) => unknown) | undefined;
60
+ readInput?: (() => Promise<unknown>) | undefined;
61
+ write?: ((chunk: string) => void) | undefined;
62
+ }): Promise<void>;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The inference-provider key env vars. Their values authenticate the agent to a
3
+ * model backend, so they are masked like any other credential.
4
+ * @returns {string[]}
5
+ */
6
+ export function inferenceKeyVars(): string[];
7
+ /**
8
+ * The placeholder floor: a candidate value shorter than this is too short to be a
9
+ * real secret and is skipped by the env-bound redaction pre-gate.
10
+ * @returns {number}
11
+ */
12
+ export function minEnvSecretLen(): number;
13
+ /**
14
+ * Validate a credential-var-names spec and build its match/exclude regexes. Pure
15
+ * and exported so the fail-closed paths can be driven directly with a bad spec.
16
+ * @param {Record<string, unknown>} spec
17
+ * @returns {{ match: RegExp, exclude: RegExp }}
18
+ */
19
+ export function buildCredentialNameRes(spec: Record<string, unknown>): {
20
+ match: RegExp;
21
+ exclude: RegExp;
22
+ };
23
+ /**
24
+ * True when `name` looks like a credential-bearing variable (and isn't a known
25
+ * non-secret lookalike).
26
+ * @param {string} name
27
+ * @returns {boolean}
28
+ */
29
+ export function looksLikeCredentialVar(name: string): boolean;
30
+ /**
31
+ * Credential-shaped env-var names present in `env` with a value long enough to be
32
+ * a real secret (the min_secret_len floor the daemon also applies), beyond the
33
+ * curated set. Reads the live environment so a newly-forwarded token is redacted
34
+ * without a code change.
35
+ * @param {Record<string, string | undefined>} [env]
36
+ * @returns {string[]}
37
+ */
38
+ export function dynamicSecretVars(env?: Record<string, string | undefined>): string[];
39
+ /**
40
+ * The env-bound redaction set: the UNION of the inference keys, the curated host
41
+ * credentials, and any credential-shaped var present in the environment. The
42
+ * redactor binds the same union; every consumer (the sanitize-output pre-gate,
43
+ * the redactor client's per-request env snapshot) must mirror it exactly, else a
44
+ * credential value would never trip the daemon.
45
+ * @param {Record<string, string | undefined>} [env]
46
+ * @returns {string[]}
47
+ */
48
+ export function envBoundSecretVars(env?: Record<string, string | undefined>): string[];
@@ -0,0 +1,196 @@
1
+ /**
2
+ * True when this module is the process entry point (run directly as a CLI, not
3
+ * imported). Guards an undefined `process.argv[1]` (e.g. the REPL) before
4
+ * resolving it: the bare `import.meta.url === pathToFileURL(process.argv[1])`
5
+ * form throws there. Resolving argv[1] through pathToFileURL also normalizes a
6
+ * relative invocation path to an absolute file URL before comparing.
7
+ * @param {string} importMetaUrl the caller's `import.meta.url`
8
+ * @returns {boolean}
9
+ */
10
+ export function isMain(importMetaUrl: string): boolean;
11
+ /**
12
+ * Claim the process's CLI-entry slot for the calling module: every subsequent
13
+ * {@link isMain} call answers false. For bundle entry points that inline other
14
+ * isMain-guarded hooks (see isMain's bundle note); a claim cannot be released.
15
+ * @returns {void}
16
+ */
17
+ export function claimCliEntry(): void;
18
+ /**
19
+ * Find a `--name=value` flag in argv (by prefix scan, not position) and return
20
+ * its value, or undefined if absent. A named flag stays correct when unrelated
21
+ * arguments are prepended or interspersed — a bare positional index (argv[2])
22
+ * silently reads the wrong value the moment the command line grows.
23
+ * @param {string[]} argv
24
+ * @param {string} name flag name without the leading `--` or trailing `=`
25
+ * @returns {string|undefined}
26
+ */
27
+ export function readFlag(argv: string[], name: string): string | undefined;
28
+ /**
29
+ * @param {number} [maxBytes] cap before aborting (overridable for tests)
30
+ * @returns {Promise<any>}
31
+ */
32
+ export function readStdinJson(maxBytes?: number): Promise<any>;
33
+ /**
34
+ * Register already-loaded module namespaces for {@link lazyImport} to return in
35
+ * place of a runtime dynamic import. Call before importing any module that
36
+ * lazy-loads the given specifiers.
37
+ * @param {Record<string, Record<string, any>>} modules specifier → namespace
38
+ * @returns {void}
39
+ */
40
+ export function registerLazyModules(modules: Record<string, Record<string, any>>): void;
41
+ /**
42
+ * The pre-registered namespace for `specifier`, or undefined when none was
43
+ * registered. The synchronous face of the registry, for call sites that cannot
44
+ * await {@link lazyImport} (e.g. a sync callback binding a scanner package):
45
+ * inside a bundle the registered namespace is the ONLY way to reach the
46
+ * package, since a runtime require/import has no node_modules to resolve from.
47
+ * @param {string} specifier
48
+ * @returns {Record<string, any> | undefined}
49
+ */
50
+ export function registeredLazyModule(specifier: string): Record<string, any> | undefined;
51
+ /**
52
+ * Dynamic-import `specifier`, yielding `{}` when the module cannot be loaded.
53
+ * Hooks bind their npm packages through this instead of a bare static import: a
54
+ * static npm import resolves before any try/catch, so a missing node_modules
55
+ * would crash the hook at load — the harness treats that as a non-blocking
56
+ * error and the tool call proceeds UNGUARDED (fail OPEN). Destructuring from
57
+ * the `{}` failure value leaves each binding undefined, so the first use throws
58
+ * into the hook's own catch and the hook takes its declared failure posture
59
+ * instead. A specifier registered via {@link registerLazyModules} resolves from
60
+ * the registry without touching the loader.
61
+ * @param {string} specifier
62
+ * @returns {Promise<Record<string, any>>}
63
+ */
64
+ export function lazyImport(specifier: string): Promise<Record<string, any>>;
65
+ /**
66
+ * A monotonic wall-clock budget shared across one hook run's downstream blocking
67
+ * calls. `remainingMs()` returns the milliseconds left until the budget is spent
68
+ * (clamped at 0), so an orchestrator hands each sub-call `min(its own timeout,
69
+ * remaining)` and a SERIES of daemon calls can never sum past the budget. This is
70
+ * the fail-open hazard a per-call-only deadline leaves open: when many output
71
+ * leaves each pay the Layer-4 redactor, the calls' individual timeouts bound each
72
+ * call but not their SUM — a pathological pile-up could exceed the PostToolUse
73
+ * hook kill, and a killed hook is non-blocking, so the RAW output would be shown.
74
+ * `now` is injectable so time-dependent logic is unit-testable with a fake clock.
75
+ * @param {number} budgetMs total wall-clock budget from creation
76
+ * @param {() => number} [now] clock source (defaults to Date.now)
77
+ * @returns {{ remainingMs: () => number }}
78
+ */
79
+ export function makeDeadline(budgetMs: number, now?: () => number): {
80
+ remainingMs: () => number;
81
+ };
82
+ /**
83
+ * Scrub untrusted text before it is spliced into the model's context via a
84
+ * warning/reason field: strip ANSI and payload-capable invisibles to a fixed
85
+ * point (via the injected `layer1`, the package's composite Layer-1 view),
86
+ * replace lone surrogates so the model's UTF-16 context stays well-formed, then
87
+ * cap by whole code points (never mid-pair, which the surrogate pass above
88
+ * already swept). `layer1` is injected rather than imported so this
89
+ * dependency-light module never eagerly loads the sanitizer package — each
90
+ * caller passes its own caught-import binding.
91
+ * @param {unknown} raw
92
+ * @param {(text: string) => { cleaned: string }} layer1
93
+ * @param {number} [cap]
94
+ * @returns {string}
95
+ */
96
+ export function scrubUntrustedText(raw: unknown, layer1: (text: string) => {
97
+ cleaned: string;
98
+ }, cap?: number): string;
99
+ /**
100
+ * Message from a caught value, which is `unknown` under strict mode. Appends
101
+ * the cause chain (one level) when the cause is itself an Error so callers
102
+ * get "outer: root" instead of just "outer" when an error wraps another.
103
+ * @param {unknown} err
104
+ * @returns {string}
105
+ */
106
+ export function errMessage(err: unknown): string;
107
+ /**
108
+ * errMessage() for an error whose message may embed attacker-chosen bytes: V8
109
+ * quotes a snippet of the offending input in a JSON.parse SyntaxError, so a hook
110
+ * that splices errMessage(err) into a user-/model-facing reason would relay raw
111
+ * ANSI escapes and invisible/format characters lifted from that snippet. Keep only
112
+ * printable ASCII (plus tab/newline) and drop every other code point — dropping the
113
+ * ESC/CSI-introducer and zero-width bytes neutralizes the sequence while leaving the
114
+ * residual literal text readable — then cap the length so a long snippet can't flood
115
+ * the reason. Use this instead of errMessage at any callsite that splices the
116
+ * message into a reason/warning shown to the user or model.
117
+ * @param {unknown} err
118
+ * @param {number} [cap]
119
+ * @returns {string}
120
+ */
121
+ export function safeErrMessage(err: unknown, cap?: number): string;
122
+ /**
123
+ * Write the `hookSpecificOutput` envelope a hook returns to stdout.
124
+ * @param {string} hookEventName
125
+ * @param {Record<string, unknown>} fields
126
+ * @returns {void}
127
+ */
128
+ export function emitHookResponse(hookEventName: string, fields: Record<string, unknown>): void;
129
+ /**
130
+ * Is the file at `path` one WE wrote — a regular file owned by this uid — rather
131
+ * than a squat? These markers live at predictable, world-visible $TMPDIR paths, so
132
+ * a co-tenant could pre-plant a file (or a symlink at the path) to steer a gate.
133
+ * lstatSync does NOT traverse a final symlink, so a planted symlink reads as a
134
+ * symlink (isFile() false) and a foreign file fails the uid check: either way the
135
+ * marker is untrusted and the caller ignores it.
136
+ * @param {string | null} path
137
+ * @returns {boolean}
138
+ */
139
+ export function markerIsTrusted(path: string | null): boolean;
140
+ /**
141
+ * Create a presence sentinel at `path` without following a symlink a co-tenant
142
+ * may have pre-planted there. These sentinels live at predictable, world-visible
143
+ * paths under $TMPDIR (a project-hash or fixed name), so a plain writeFileSync —
144
+ * which opens O_CREAT|O_TRUNC and follows a symlink at the path — would let
145
+ * anyone able to plant that symlink redirect the write and truncate an arbitrary
146
+ * file the hook's user owns. Unlink any existing entry first (removing a squatted
147
+ * symlink), then create exclusively (O_EXCL) so a symlink re-planted in the race
148
+ * window fails the open rather than being dereferenced. Content is irrelevant —
149
+ * callers test only for existence — so the file is left empty. Best-effort: a
150
+ * missing/read-only $TMPDIR or a lost race just leaves the sentinel absent, and
151
+ * every caller treats "absent" as "not yet done" (a repeated ask, never a crash),
152
+ * so all failures are swallowed.
153
+ * @param {string} path
154
+ * @returns {void}
155
+ */
156
+ export function writeSentinelFile(path: string): void;
157
+ /**
158
+ * Write `content` to `path` without following a symlink a co-tenant may have
159
+ * pre-planted there — the content-bearing counterpart to writeSentinelFile. These
160
+ * hooks write to predictable, world-visible $TMPDIR paths (a project-hash name, or
161
+ * a content-addressed digest an attacker who chose the input bytes can precompute),
162
+ * so a plain writeFileSync — which opens O_CREAT|O_TRUNC and follows a final
163
+ * symlink — would let anyone able to plant that symlink redirect the write and
164
+ * truncate/overwrite an arbitrary file the hook's user owns. Unlink any existing
165
+ * entry first (removing a squatted symlink), then create exclusively (O_EXCL via
166
+ * "wx") so a symlink re-planted in the unlink→open race window fails the open
167
+ * rather than being dereferenced. Returns true on success, false when the write
168
+ * could not be completed (unwritable dir, or a lost race) so the caller decides
169
+ * whether a failed best-effort write is fatal.
170
+ * @param {string} path
171
+ * @param {string} content
172
+ * @param {number} [mode]
173
+ * @returns {boolean}
174
+ */
175
+ export function writeFileNoFollow(path: string, content: string, mode?: number): boolean;
176
+ /** Claude Code hook event names (the hookEventName field). */
177
+ export const HookEvent: Readonly<{
178
+ PRE_TOOL_USE: "PreToolUse";
179
+ POST_TOOL_USE: "PostToolUse";
180
+ USER_PROMPT_SUBMIT: "UserPromptSubmit";
181
+ SESSION_START: "SessionStart";
182
+ }>;
183
+ /** Claude Code permissionDecision verdicts. */
184
+ export const PermissionDecision: Readonly<{
185
+ ALLOW: "allow";
186
+ DENY: "deny";
187
+ ASK: "ask";
188
+ }>;
189
+ /**
190
+ * Hard cap on hook stdin. A well-formed Claude Code hook payload is at most a
191
+ * few MB (tool input plus the harness-truncated tool output); 64 MiB leaves
192
+ * generous headroom while refusing a runaway or malformed sender before its
193
+ * bytes are buffered into memory — an unbounded read would OOM the hook process
194
+ * and take its own fail-closed output down with it.
195
+ */
196
+ export const MAX_STDIN_BYTES: number;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The alert findings if invisible-char injection was detected in instruction
3
+ * files and couldn't be auto-cleaned, else null. ALERT_FILE lives at a predictable,
4
+ * world-visible $TMPDIR path, so its contents are attacker-writable (a co-tenant can
5
+ * plant a file/symlink there): trust it only when markerIsTrusted confirms a regular
6
+ * file THIS uid owns (a squatted symlink/foreign file reads as no alert), then scrub
7
+ * the bytes through Layer-1 before any caller splices them into a reason — the report
8
+ * would otherwise carry ANSI/invisible spoofing into the model's context.
9
+ * @returns {string | null}
10
+ */
11
+ export function invisibleCharAlert(): string | null;
12
+ /**
13
+ * True once the gate has surfaced its blocking ask this session. Validates
14
+ * ownership (not mere existence): a co-tenant could pre-create ALERT_ACK_FILE at its
15
+ * predictable $TMPDIR path to permanently suppress the one-time blocking ask down to
16
+ * the passive reminder, so trust the marker only when it is a regular file this uid
17
+ * wrote (markerIsTrusted), mirroring how acknowledgeAlert writes it.
18
+ * @returns {boolean}
19
+ */
20
+ export function alertAcknowledged(): boolean;
21
+ /**
22
+ * Record that the gate has surfaced its blocking ask, so later tool calls get a
23
+ * passive reminder instead of an ask on every call. Cleared at SessionStart by
24
+ * the scanner so each fresh session re-asks once.
25
+ * @returns {void}
26
+ */
27
+ export function acknowledgeAlert(): void;
28
+ /**
29
+ * @param {string} findings
30
+ * @returns {string}
31
+ */
32
+ export function gateAskReason(findings: string): string;
33
+ /**
34
+ * Non-blocking reminder for tool calls after the first ask: the injection is
35
+ * still present, but the user was already asked once this session, so this rides
36
+ * as context rather than re-prompting on every call.
37
+ * @returns {string}
38
+ */
39
+ export function gateReminderContext(): string;
40
+ /** The project the hooks are guarding; the alert paths are keyed to it. */
41
+ export const PROJECT_DIR: string;
42
+ /** Findings the SessionStart scanner could not clean, for the PreToolUse gate. */
43
+ export const ALERT_FILE: string;
44
+ export const ALERT_ACK_FILE: string;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Parse a millisecond deadline from an env override, falling back to `fallback`
3
+ * unless the value is a finite positive number. A bare `Number(env) || fallback`
4
+ * silently accepts a NEGATIVE override (`-5 || 8000` is -5) — a non-positive
5
+ * deadline makes the fail-closed wait/request return immediately, defeating the
6
+ * deadline. Unset/blank/NaN/<=0 all take the sane positive fallback; a load-time
7
+ * throw is deliberately avoided so a misconfigured env can never crash these
8
+ * fail-closed hooks into a fail-OPEN non-load.
9
+ * @param {string|undefined} raw the env override value
10
+ * @param {number} fallback the sane positive default
11
+ * @returns {number}
12
+ */
13
+ export function positiveMsOr(raw: string | undefined, fallback: number): number;
14
+ /**
15
+ * The shape the redactor returns: plain mode `{text, found}`, map mode
16
+ * `{text, pairs, found}` or `{unmappable}`. All fields optional so a consumer
17
+ * narrows the variant it expects.
18
+ * @typedef {object} RedactResponse
19
+ * @property {string} [text]
20
+ * @property {string[]} [found]
21
+ * @property {{placeholder: string, original: string, start: number}[]} [pairs]
22
+ * @property {string} [unmappable]
23
+ */
24
+ /**
25
+ * Classify the socket path before we connect and hand it live credentials.
26
+ * The request body carries collectEnvSecrets() — plaintext key VALUES — and the
27
+ * socket lives at a predictable, world-visible $TMPDIR path any co-tenant can
28
+ * reach. This is the one channel in the hook suite that ships secrets, so it
29
+ * needs the same squat defense markerIsTrusted / writeFileNoFollow apply to the
30
+ * marker/sentinel files. lstatSync does NOT traverse a final symlink, so a
31
+ * planted symlink reads as a symlink (isSocket() false) and a foreign daemon
32
+ * fails the uid check.
33
+ * - "absent" → nothing there yet: let createConnection ENOENT so the caller's
34
+ * respawn path spawns OUR daemon (never a refuse — that would
35
+ * break the cold-start spawn).
36
+ * - "untrusted" → something IS bound there but it is not our socket under a dir
37
+ * only a trusted uid can write (a co-tenant squat): refuse, so
38
+ * no secret is written.
39
+ * - "ok" → our socket, our uid, under a dir isTrustedSocketDir accepts.
40
+ * `lstat`/`uid` are injectable seams so a test can drive a stat shape the test
41
+ * process cannot create (a dir owned by another uid); production binds the real ones.
42
+ * @param {string} socketPath
43
+ * @param {{lstat?: typeof lstatSync, uid?: number}} [deps]
44
+ * @returns {"absent" | "untrusted" | "ok"}
45
+ */
46
+ export function classifySocket(socketPath: string, deps?: {
47
+ lstat?: typeof lstatSync;
48
+ uid?: number;
49
+ }): "absent" | "untrusted" | "ok";
50
+ /**
51
+ * Open one connection, send `request`, resolve with the parsed response object
52
+ * (or null). Rejects on connect failure, a malformed/oversize/short frame, or an
53
+ * {error} response — every one of which the caller turns into a fail-closed. A
54
+ * socket present but not owned by us fails closed WITHOUT respawning (the error
55
+ * carries no errno, so isRespawnable is false), so we never dial into a squat.
56
+ * @param {string} socketPath
57
+ * @param {{text: string, map: boolean, web_ingress: boolean}} request
58
+ * @param {number} [deadlineMs] total exchange deadline; defaults to the env-tunable value
59
+ * @returns {Promise<RedactResponse|null>}
60
+ */
61
+ export function connectAndRequest(socketPath: string, request: {
62
+ text: string;
63
+ map: boolean;
64
+ web_ingress: boolean;
65
+ }, deadlineMs?: number): Promise<RedactResponse | null>;
66
+ /**
67
+ * Spawn the daemon detached so it outlives this hook process. The daemon's bind()
68
+ * is the cross-process mutex, so a racing second spawn just exits — the spawn is
69
+ * idempotent and needs no lock here.
70
+ * @param {string} socketPath
71
+ * @param {string[]} [command] daemon command as [argv0, ...leadingArgs]
72
+ * (injectable so tests can drive the missing-binary arm in-process;
73
+ * production always uses daemonCommand())
74
+ */
75
+ export function spawnDaemon(socketPath: string, command?: string[]): void;
76
+ /**
77
+ * Poll until the daemon is accepting connections or the deadline passes. Probes by
78
+ * connecting (not just existsSync) so it waits for listen(), not merely bind().
79
+ * @param {string} socketPath
80
+ * @param {{deadlineMs?: number, stepMs?: number}} [opts]
81
+ * @returns {Promise<boolean>}
82
+ */
83
+ export function waitForSocket(socketPath: string, { deadlineMs, stepMs }?: {
84
+ deadlineMs?: number;
85
+ stepMs?: number;
86
+ }): Promise<boolean>;
87
+ /**
88
+ * Redact `text` via the daemon. Returns the response object (`{text, found}` for
89
+ * plain, `{text, pairs, found}` / `{unmappable}` for map) or null when nothing was
90
+ * redacted (plain mode). Throws to fail closed when the text cannot be vetted.
91
+ *
92
+ * `connect`/`spawn`/`waitForSocket` are injectable seams (default to the real
93
+ * implementations) so callers can stub the daemon in-process. `deadline` is the
94
+ * caller's shared wall-clock budget (makeDeadline): when supplied, every dial and
95
+ * the respawn wait are bounded by the budget REMAINING at that moment, and a spent
96
+ * budget fails CLOSED without dialing — never dial with a non-positive deadline,
97
+ * which would race and could return the raw, unvetted secret (fail open). Omitted,
98
+ * the redactor keeps its own per-call request deadline (the standalone default).
99
+ * @param {string} text
100
+ * @param {{map?: boolean, webIngress?: boolean, socketPath?: string,
101
+ * deadline?: {remainingMs: () => number},
102
+ * connect?: typeof connectAndRequest, spawn?: typeof spawnDaemon,
103
+ * waitForSocket?: typeof waitForSocket}} [opts]
104
+ * @returns {Promise<RedactResponse|null>}
105
+ */
106
+ export function redactViaDaemon(text: string, opts?: {
107
+ map?: boolean;
108
+ webIngress?: boolean;
109
+ socketPath?: string;
110
+ deadline?: {
111
+ remainingMs: () => number;
112
+ };
113
+ connect?: typeof connectAndRequest;
114
+ spawn?: typeof spawnDaemon;
115
+ waitForSocket?: typeof waitForSocket;
116
+ }): Promise<RedactResponse | null>;
117
+ export const FRAME_CAP: number;
118
+ export const DEFAULT_SOCKET_PATH: string;
119
+ /**
120
+ * The shape the redactor returns: plain mode `{text, found}`, map mode
121
+ * `{text, pairs, found}` or `{unmappable}`. All fields optional so a consumer
122
+ * narrows the variant it expects.
123
+ */
124
+ export type RedactResponse = {
125
+ text?: string | undefined;
126
+ found?: string[] | undefined;
127
+ pairs?: {
128
+ placeholder: string;
129
+ original: string;
130
+ start: number;
131
+ }[] | undefined;
132
+ unmappable?: string | undefined;
133
+ };
134
+ import { lstatSync } from "node:fs";
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Persist one reveal's pre-splice text and return the model-facing hint naming
3
+ * its path, or null when the write fails (the splice already protected the
4
+ * output, so a failed convenience write must not break sanitization). The store
5
+ * dir is verified private/uid-owned and the file is created symlink-refusingly
6
+ * (O_EXCL): the path is content-addressed, so an attacker who chose the page bytes
7
+ * can precompute it and pre-plant a symlink there to redirect this write onto a
8
+ * victim file — writeFileNoFollow refuses that instead of following it.
9
+ * @param {string} content
10
+ * @returns {string | null}
11
+ */
12
+ export function persistReveal(content: string): string | null;
13
+ /**
14
+ * True when this PostToolUse event is a Read of a reveal sidecar file, so its
15
+ * output must be marked untrusted even though Read is otherwise a trusted local
16
+ * tool. Containment is checked against the lexically resolved path with a
17
+ * trailing separator so a sibling dir sharing the prefix (…-reveal-evil) cannot
18
+ * pass. The model picks what it Reads (no attacker-planted symlinks to escape),
19
+ * so lexical resolution — not realpath — is the right boundary here.
20
+ * @param {string} toolName
21
+ * @param {any} toolInput
22
+ * @returns {boolean}
23
+ */
24
+ export function isRevealRead(toolName: string, toolInput: any): boolean;
25
+ /** Envelope prepended to a reveal-file Read so its bytes are framed as untrusted. */
26
+ export const REVEAL_READ_ENVELOPE: string;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Regex matching `value` tolerating invisible chars spliced between its
3
+ * characters (mirrors the engine's env-value regex). Code-point split so
4
+ * an astral character is escaped whole, not as two surrogate halves.
5
+ * @param {string} value
6
+ * @returns {RegExp}
7
+ */
8
+ export function envValueRegex(value: string): RegExp;
9
+ /**
10
+ * True when tool output contains the literal value of a configured env-bound
11
+ * secret. The shape-based secret hint can't match a prefix-less key or a host
12
+ * credential, so the pre-gate must also fire on the value itself — otherwise
13
+ * the engine's env-bound redaction never runs. Invisible-tolerant so a
14
+ * value with spliced Cf chars (which the daemon still redacts) trips it too.
15
+ * @param {string} text
16
+ * @param {NodeJS.ProcessEnv} [env]
17
+ * @returns {boolean}
18
+ */
19
+ export function hasEnvBoundSecret(text: string, env?: NodeJS.ProcessEnv): boolean;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Numeric verbosity from _AGENT_SANITIZER_TRACE: 0 off, 1 info, 2 debug.
3
+ * Unknown, empty, or "off" → 0.
4
+ * @param {NodeJS.ProcessEnv} [env]
5
+ * @returns {number}
6
+ */
7
+ export function traceThreshold(env?: NodeJS.ProcessEnv): number;
8
+ /**
9
+ * Emit one JSON trace line for `event` at `level` (default "info") carrying the
10
+ * metadata `fields`. No-op when the channel is below `level`; best-effort on write.
11
+ * @param {string} event
12
+ * @param {Record<string, unknown>} [fields]
13
+ * @param {"info"|"debug"} [level]
14
+ * @returns {void}
15
+ */
16
+ export function trace(event: string, fields?: Record<string, unknown>, level?: "info" | "debug"): void;
17
+ /** Trace-channel event names. */
18
+ export const TraceEvent: Readonly<{
19
+ HOOK_RAN: "hook_ran";
20
+ SCAN_INVISIBLE_CHARS_RAN: "scan_invisible_chars_ran";
21
+ }>;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Dispatch to the hook named by `--hook=<name>` in argv. Exported and guarded by
3
+ * isMain below so importing this module (the published entry point) is a no-op:
4
+ * only a direct `node plugin-hooks.mjs --hook=…` run consumes stdin and exits.
5
+ * @returns {Promise<void>}
6
+ */
7
+ export function main(): Promise<void>;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Compose the four protections. Returns the `hookSpecificOutput` fields to
3
+ * emit, or null for a clean no-op. Throws only if a layer's engine throws; the
4
+ * caller fails closed (ask) on any throw. Every exit routes through emitTraced.
5
+ * @param {any} input parsed PreToolUse event
6
+ * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
7
+ * injectable for tests; the default binds the real redactor-daemon io (the
8
+ * layer reads the target file and maps secrets through the daemon)
9
+ * @returns {Promise<Record<string, unknown> | null>}
10
+ */
11
+ export function buildPreToolUseResponse(input: any, rehydrate?: (tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>): Promise<Record<string, unknown> | null>;
12
+ /**
13
+ * Agent-agnostic judge over the four protections: consumes a control-plane
14
+ * ToolCallEvent and returns a Verdict, so a non-Claude host can run the same
15
+ * sanitization pipeline through its own adapter. The wired Claude CLI below
16
+ * routes through this judge and renders the Verdict with the Claude adapter; on
17
+ * any throw (a control-plane package-load failure included) it falls back to
18
+ * failClosedFields — a native response that needs no package — so the
19
+ * fail-closed posture holds even when the adapter never loaded.
20
+ * @param {import("agent-control-plane-core").ToolCallEvent} event
21
+ * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
22
+ * @returns {Promise<import("agent-control-plane-core").Verdict>}
23
+ */
24
+ export function judgePreToolUseSanitize(event: import("agent-control-plane-core").ToolCallEvent, rehydrate?: (tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>): Promise<import("agent-control-plane-core").Verdict>;
25
+ /**
26
+ * The fail-closed hookSpecificOutput fields for a hook-level failure, chosen by
27
+ * WHICH failure it was. Corrupt/unparsable INPUT (`parsedOk` false — a JSON parse
28
+ * error or the oversize-body cap) is a state an adversary can induce with no
29
+ * upside to failing, so it hard-DENIES: no human to talk past, no approval
30
+ * fatigue, no latency. A LAYER/engine throw after a clean parse (`parsedOk` true
31
+ * — redactor daemon down, package not loaded) is the sanitizer being UNAVAILABLE,
32
+ * so it ASKS to keep a human in the loop rather than hard-block on infrastructure.
33
+ * @param {boolean} parsedOk whether the input parsed before the failure
34
+ * @param {unknown} err
35
+ * @returns {Record<string, unknown>}
36
+ */
37
+ export function failClosedFields(parsedOk: boolean, err: unknown): Record<string, unknown>;
38
+ /**
39
+ * The hook's CLI: parse → judge → render, with this hook's fail-closed posture.
40
+ * Exported so a bundle entry (which must claim the CLI slot before this module
41
+ * loads) can run the exact same wiring instead of duplicating the onError
42
+ * posture.
43
+ * @returns {Promise<void>}
44
+ */
45
+ export function cliMain(): Promise<void>;
46
+ declare const rehydrateRedacted: typeof import("agent-sanitizer/rehydrate").rehydrateRedacted;
47
+ export {};
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Run Layers 1-4 over a single text blob, delegated to the package's output seam
3
+ * (sanitizeTextSeam) bound here to this hook's per-tool policy: which tools get
4
+ * the HTML rewrite (Layer 2) and the exfil-URL scan (Layer 3), the injected
5
+ * secret redactor (Layer 4), and the display-only-SGR carve-out. `reveal` carries
6
+ * the seam's pre-Layer-2 text when the HTML splice removed anything, for the
7
+ * orchestrator to persist.
8
+ * @param {string} text
9
+ * @param {string} toolName gates the SGR carve-out and the untrusted-ingress passes
10
+ * @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
11
+ * all leaves of one hook run; a direct caller gets a fresh full budget
12
+ * @returns {Promise<{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
13
+ */
14
+ export function sanitizeText(text: string, toolName: string, deadline?: {
15
+ remainingMs: () => number;
16
+ }): Promise<{
17
+ cleaned: string;
18
+ warnings: string[];
19
+ modified: boolean;
20
+ sgrNote: boolean;
21
+ reveal?: string;
22
+ }>;
23
+ /**
24
+ * Sanitize every string leaf of a tool-output value, preserving its shape.
25
+ * Built-in tools return structured objects (Bash: `{stdout, stderr, interrupted,
26
+ * isImage}`), and the harness ignores an `updatedToolOutput` whose shape does not
27
+ * match the tool's schema — showing the raw output instead. So a single flat
28
+ * string handed back for an object-shaped tool would leak the unsanitized output;
29
+ * rewriting leaves in place keeps the shape intact. Object KEYS are sanitized
30
+ * too (a connector can hide a secret in a field name); non-string leaves
31
+ * (booleans, numbers, null) pass through untouched, and `warnings` accumulates
32
+ * across leaves.
33
+ * `sgrNote` is the OR across leaves: true when some leaf was an SGR-only strip.
34
+ * `reveals` accumulates each leaf's pre-Layer-2 text (when the HTML splice
35
+ * removed something) for the orchestrator to persist — same mutated-accumulator
36
+ * shape as `warnings`.
37
+ * @param {any} value
38
+ * @param {string} toolName
39
+ * @param {string[]} warnings
40
+ * @param {string[]} [reveals]
41
+ * @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
42
+ * every leaf of this value (created once by the top-level caller)
43
+ * @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
44
+ */
45
+ export function sanitizeValue(value: any, toolName: string, warnings: string[], reveals?: string[], deadline?: {
46
+ remainingMs: () => number;
47
+ }): Promise<{
48
+ value: any;
49
+ modified: boolean;
50
+ sgrNote: boolean;
51
+ }>;
52
+ /**
53
+ * Compose the model-facing additionalContext line for a sanitized/flagged tool
54
+ * output. The seam (composeContextSeam) owns the prefix + warning join; this
55
+ * binds the untrusted-ingress classification to the seam's `injectionAlert` slot
56
+ * — the semantic-injection alert rides ONLY on web/MCP output, the channel where
57
+ * injected natural language actually arrives (see isUntrustedIngress). On local
58
+ * tools (Read, Bash, Grep, gh) the alert on a plain ANSI/secret strip is pure
59
+ * noise that desensitizes the reader to the one place it matters, so it is
60
+ * omitted.
61
+ * @param {boolean} modified output bytes were changed (vs. flagged only)
62
+ * @param {string[]} warnings
63
+ * @param {string} toolName
64
+ * @returns {string}
65
+ */
66
+ export function composeContext(modified: boolean, warnings: string[], toolName: string): string;
67
+ /**
68
+ * Fail-closed replacement: a shape-matching placeholder for the parsed tool
69
+ * output, or the bare `message` when stdin never parsed or carried no
70
+ * tool_response (no shape to match).
71
+ * @param {any} input parsed hook input, or undefined if parsing threw
72
+ * @param {string} message
73
+ * @returns {any}
74
+ */
75
+ export function failClosedReplacement(input: any, message: string): any;
76
+ /**
77
+ * Whether the sanitizer's bindings actually loaded. lazyImport swallows a
78
+ * missing package and yields `{}`, so the absence shows up as an undefined
79
+ * binding here — NOT as a "Cannot find package" error, which the failing call
80
+ * site (a TypeError on an undefined function) never carries. Testing the
81
+ * binding is therefore the only detection that fires on the real condition.
82
+ * @returns {boolean}
83
+ */
84
+ export function sanitizerDepsLoaded(): boolean;
85
+ /**
86
+ * The model-facing note for a fail-closed emission, with the missing-dependency
87
+ * remedy appended when the sanitizer's bindings are the thing that is absent.
88
+ * @param {() => boolean} [depsLoaded] injectable seam for testing
89
+ * @returns {string}
90
+ */
91
+ export function failClosedContext(depsLoaded?: () => boolean): string;
92
+ /**
93
+ * Emit a fail-closed PostToolUse response, robust to the suppression itself
94
+ * throwing. The shape-matching replacement walks `input.tool_response` and the
95
+ * emit serializes it; a pathologically deep (but valid-JSON) tool_response
96
+ * overflows that walk or `JSON.stringify`, which — left uncaught in the CLI's
97
+ * own catch — would exit non-zero with NO response, and the harness would then
98
+ * show the RAW, unvetted output (fail OPEN). The fallback emits the bare
99
+ * `message` string instead: shallow, always serializable, and a valid string
100
+ * tool_response, so the hook still fails CLOSED. `emit` is an injectable seam so
101
+ * the fallback is unit-testable without a subprocess.
102
+ * @param {any} input parsed hook input, or undefined if parsing threw
103
+ * @param {string} message
104
+ * @param {(fields: Record<string, unknown>) => void} [emit]
105
+ * @returns {void}
106
+ */
107
+ export function emitFailClosed(input: any, message: string, emit?: (fields: Record<string, unknown>) => void): void;
108
+ /**
109
+ * Run the sanitization pipeline over a tool output and return the contract-
110
+ * shaped verdict fields — `mutated_output` (the shape-matching sanitized value)
111
+ * and/or `additional_context` (the model-facing note) — or null when there is
112
+ * nothing to change (no tool output, or a clean scan). Agent-neutral by
113
+ * construction: it speaks the control-plane vocabulary, never Claude's native
114
+ * `updatedToolOutput`/`additionalContext` wire keys (the adapter renders those).
115
+ * Every exit routes through `emit`, which announces engagement on the trace
116
+ * channel (hook_ran — metadata only: hook name, tool, outcome) and returns the
117
+ * fields unchanged. The trace lives here, not in the CLI block below, so it
118
+ * rides the in-process, mutation-tested path.
119
+ * @param {any} input the tool_name / tool_input / tool_response to sanitize
120
+ * @returns {Promise<{ mutated_output?: unknown, additional_context?: string } | null>}
121
+ */
122
+ export function evaluateToolOutput(input: any): Promise<{
123
+ mutated_output?: unknown;
124
+ additional_context?: string;
125
+ } | null>;
126
+ /**
127
+ * Judge a normalized PostToolUse event: run the sanitization pipeline and
128
+ * express its outcome as a control-plane Verdict. sanitize-output only ever
129
+ * ALLOWS — the tool already ran, so this governs the model's VIEW of the
130
+ * output, not the side effect. It either rewrites that view (`mutated_output`),
131
+ * attaches a warning (`additional_context`), or does neither (a bare allow).
132
+ * {@link evaluateToolOutput} already returns those contract fields (or null),
133
+ * so the judge only stamps the `allow` decision onto them — no native-envelope
134
+ * translation. Throws only if a layer engine throws (or on an UNKNOWN event);
135
+ * the CLI fails closed on any throw.
136
+ * @param {import("agent-control-plane-core").ToolCallEvent} event
137
+ * @returns {Promise<import("agent-control-plane-core").Verdict>}
138
+ */
139
+ export function judgeSanitizeOutput(event: import("agent-control-plane-core").ToolCallEvent): Promise<import("agent-control-plane-core").Verdict>;
140
+ /**
141
+ * Default a raw payload's `hook_event_name` to PostToolUse when it is absent.
142
+ * sanitize-output is wired ONLY to the PostToolUse event, so a payload that
143
+ * omits the field is a PostToolUse call by construction. The claude adapter
144
+ * extracts `tool_response` (this hook's actual input) ONLY for a PostToolUse
145
+ * event; without this default a field-less but legitimate payload would parse as
146
+ * UNKNOWN, {@link judgeSanitizeOutput} would throw, and the CLI would fail closed
147
+ * (suppress) on real tool output. A payload carrying a DIFFERENT event name is
148
+ * left untouched, so the judge's UNKNOWN guard still fails closed on a genuinely
149
+ * unrecognized event.
150
+ * @param {unknown} input the raw stdin payload
151
+ * @returns {unknown}
152
+ */
153
+ export function withPostToolUseDefault(input: unknown): unknown;
154
+ /**
155
+ * The hook's CLI: parse → judge → render, with this hook's fail-closed posture.
156
+ * Exported so a bundle entry (which must claim the CLI slot before this module
157
+ * loads) can run the exact same wiring instead of duplicating the onError
158
+ * posture.
159
+ * @returns {Promise<void>}
160
+ */
161
+ export function cliMain(): Promise<void>;
162
+ export const applyLayer1: typeof import("agent-sanitizer").applyLayer1;
163
+ export const matchesSecretHint: typeof import("agent-sanitizer").matchesSecretHint;
164
+ export const SECRET_HINT: RegExp;
165
+ export const SECRET_HINT_EXT: RegExp;
166
+ export const describeRemoved: typeof import("agent-sanitizer/output").describeRemoved;
167
+ export const describeWarned: typeof import("agent-sanitizer/output").describeWarned;
168
+ export const suppressToolOutput: typeof import("agent-sanitizer/output").suppressToolOutput;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Judge a normalized prompt-submit event. Agent-agnostic: consumes the
3
+ * control-plane ToolCallEvent and returns a Verdict, so the same prompt gate
4
+ * renders through any agent adapter, not just Claude's. Throws (into the
5
+ * calling hook's catch) when the sanitizer package never loaded — this hook is
6
+ * the only defense on user input, so a prompt it cannot classify must block,
7
+ * never pass through.
8
+ * @param {import("agent-control-plane-core").ToolCallEvent} event
9
+ * @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
10
+ * to the package's stripAnsiFully; injectable so the fail-closed path is testable)
11
+ * @returns {import("agent-control-plane-core").Verdict}
12
+ */
13
+ export function judgeSanitizeUserPrompt(event: import("agent-control-plane-core").ToolCallEvent, strip?: ((s: string) => string) | null): import("agent-control-plane-core").Verdict;
14
+ /**
15
+ * @param {() => Promise<any> | any} read
16
+ * @param {(chunk: string) => void} write
17
+ * @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
18
+ * to the package's stripAnsiFully; injectable so the fail-closed path is testable)
19
+ * @returns {Promise<void>}
20
+ */
21
+ export function main(read: () => Promise<any> | any, write: (chunk: string) => void, strip?: ((s: string) => string) | null): Promise<void>;
22
+ /** @type {typeof import("agent-sanitizer/prompt").classifyPrompt} */
23
+ export let classifyPrompt: typeof import("agent-sanitizer/prompt").classifyPrompt;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The hook's CLI: scan the instruction files, auto-clean what it can, persist
3
+ * the alert for the PreToolUse gate otherwise. Exported so a bundle entry
4
+ * (which must claim the CLI slot before this module loads) can run the exact
5
+ * same scan instead of duplicating it.
6
+ * @returns {Promise<void>}
7
+ */
8
+ export function cliMain(): Promise<void>;
9
+ /**
10
+ * @param {string} run
11
+ * @returns {{ method: string, decoded: string }}
12
+ */
13
+ export function decodeRun(run: string): {
14
+ method: string;
15
+ decoded: string;
16
+ };
17
+ /**
18
+ * @param {string} dir
19
+ * @returns {string[]}
20
+ */
21
+ export function findMdFiles(dir: string): string[];
22
+ /**
23
+ * Every subdirectory instruction file (CLAUDE.md, CLAUDE.local.md, AGENTS.md)
24
+ * under `dir`. Claude Code loads these as project instructions on entry to their
25
+ * containing directory — a load path that bypasses the PostToolUse sanitizer — so
26
+ * a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model uncleaned
27
+ * unless it is scanned here. Skips node_modules; `**` skips dot directories by
28
+ * default (`.git`, and `.claude`, which the caller scans separately).
29
+ * @param {string} dir
30
+ * @returns {string[]}
31
+ */
32
+ export function findInstructionFiles(dir: string): string[];
33
+ /**
34
+ * @param {string} filePath
35
+ * @returns {Array<{ line: number, charCount: number, method: string, decoded: string }>}
36
+ */
37
+ export function scanFile(filePath: string): Array<{
38
+ line: number;
39
+ charCount: number;
40
+ method: string;
41
+ decoded: string;
42
+ }>;
43
+ import { ALERT_FILE } from "./lib/invisible-alert.mjs";
44
+ import { ALERT_ACK_FILE } from "./lib/invisible-alert.mjs";
45
+ export const LONG_RUN_RE: RegExp;
46
+ export const LONG_RUN_THRESHOLD: 10;
47
+ export const TOTAL_INVISIBLE_THRESHOLD: 30;
48
+ /**
49
+ * @param {Array<{
50
+ * file: string,
51
+ * findings: Array<{ line: number, charCount: number, method: string, decoded: string }>,
52
+ * }>} allFindings
53
+ * @returns {string}
54
+ */
55
+ export function formatReport(allFindings: Array<{
56
+ file: string;
57
+ findings: Array<{
58
+ line: number;
59
+ charCount: number;
60
+ method: string;
61
+ decoded: string;
62
+ }>;
63
+ }>): string;
64
+ export { ALERT_FILE, ALERT_ACK_FILE };