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 +85 -145
- package/package.json +10 -3
- package/types/claude-hooks/lib/authored-content.d.mts +15 -0
- package/types/claude-hooks/lib/control-plane.d.mts +62 -0
- package/types/claude-hooks/lib/env-config.d.mts +48 -0
- package/types/claude-hooks/lib/hook-io.d.mts +196 -0
- package/types/claude-hooks/lib/invisible-alert.d.mts +44 -0
- package/types/claude-hooks/lib/redactor-client.d.mts +134 -0
- package/types/claude-hooks/lib/reveal.d.mts +26 -0
- package/types/claude-hooks/lib/secret-annotate.d.mts +19 -0
- package/types/claude-hooks/lib/trace.d.mts +21 -0
- package/types/claude-hooks/plugin-hooks.d.mts +7 -0
- package/types/claude-hooks/pretooluse-sanitize.d.mts +47 -0
- package/types/claude-hooks/sanitize-output.d.mts +168 -0
- package/types/claude-hooks/sanitize-user-prompt.d.mts +23 -0
- package/types/claude-hooks/scan-invisible-chars.d.mts +64 -0
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.
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
|
273
|
-
is published as data so a consumer with its own matcher derives it
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
|
310
|
-
through the bundled CLI, so
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
`
|
|
316
|
-
|
|
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":"ab"}' | 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
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
`
|
|
333
|
-
|
|
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.
|
|
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":
|
|
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 };
|