agent-sanitizer 2.2.2 → 2.4.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 +107 -0
- package/claude-hooks/config/credential-var-names.json +23 -0
- package/claude-hooks/config/inference-key-vars.json +17 -0
- package/claude-hooks/config/scrubbed-env-vars.json +15 -0
- package/claude-hooks/lib/authored-content.mjs +166 -0
- package/claude-hooks/lib/control-plane.mjs +138 -0
- package/claude-hooks/lib/env-config.mjs +140 -0
- package/claude-hooks/lib/hook-io.mjs +366 -0
- package/claude-hooks/lib/invisible-alert.mjs +115 -0
- package/claude-hooks/lib/redactor-client.mjs +514 -0
- package/claude-hooks/lib/reveal.mjs +135 -0
- package/claude-hooks/lib/secret-annotate.mjs +56 -0
- package/claude-hooks/lib/trace.mjs +60 -0
- package/claude-hooks/plugin-hooks.mjs +133 -0
- package/claude-hooks/pretooluse-sanitize.mjs +341 -0
- package/claude-hooks/sanitize-output.mjs +656 -0
- package/claude-hooks/sanitize-user-prompt.mjs +164 -0
- package/claude-hooks/scan-invisible-chars.mjs +313 -0
- package/package.json +11 -4
package/README.md
CHANGED
|
@@ -86,6 +86,113 @@ like `found`:
|
|
|
86
86
|
| `filter-flagged` | The filter flagged the output as a possible injection without deleting (content intact) |
|
|
87
87
|
| `filter-error` | The filter reported a non-fatal internal error while scanning (a fatal filter throws) |
|
|
88
88
|
|
|
89
|
+
## Using it with Claude Code
|
|
90
|
+
|
|
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)
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
/plugin marketplace add AlexanderMattTurner/agent-sanitizer
|
|
100
|
+
/plugin install agent-sanitizer@agent-sanitizer
|
|
101
|
+
```
|
|
102
|
+
|
|
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
|
|
108
|
+
|
|
109
|
+
Install the package and point your `settings.json` at the published hook entry.
|
|
110
|
+
One entry point dispatches all four modes on `--hook=`:
|
|
111
|
+
|
|
112
|
+
```jsonc
|
|
113
|
+
{
|
|
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
|
+
},
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
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.
|
|
162
|
+
|
|
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:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
pip install 'agent-sanitizer[secrets]' # version-match the npm package
|
|
168
|
+
```
|
|
169
|
+
|
|
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.
|
|
174
|
+
|
|
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.
|
|
178
|
+
|
|
179
|
+
### Internal environment variables
|
|
180
|
+
|
|
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.
|
|
184
|
+
|
|
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 |
|
|
195
|
+
|
|
89
196
|
## How this compares
|
|
90
197
|
|
|
91
198
|
The "sanitize untrusted LLM input" space mostly splits into two camps: ML
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"comment": "The credential-shaped ENV-VAR NAME vocabulary the hook-side pre-gate builds its regexes from (looksLikeCredentialVar in lib/env-config.mjs). `segments`: a var whose trailing underscore-delimited segment is one of these is treated as credential-bearing (matched as `(?:^|_)(?:<segment>)$`, case-insensitive). `excludeSuffixes` / `excludeNames`: names that end like a credential but hold a non-secret (an identifier, a public key, the ssh-agent socket path) and must NOT be redacted out of tool output. Every token is restricted to A-Z and _ so it carries no regex metacharacter; the consumer enforces that and fails closed on a violation, an empty list, or a missing field.",
|
|
3
|
+
"segments": [
|
|
4
|
+
"TOKEN",
|
|
5
|
+
"SECRET",
|
|
6
|
+
"SECRETS",
|
|
7
|
+
"PASSWORD",
|
|
8
|
+
"PASSWD",
|
|
9
|
+
"PASSPHRASE",
|
|
10
|
+
"APIKEY",
|
|
11
|
+
"API_KEY",
|
|
12
|
+
"ACCESS_KEY",
|
|
13
|
+
"SECRET_KEY",
|
|
14
|
+
"PRIVATE_KEY",
|
|
15
|
+
"AUTH_TOKEN",
|
|
16
|
+
"PAT",
|
|
17
|
+
"CREDENTIAL",
|
|
18
|
+
"CREDENTIALS",
|
|
19
|
+
"KEY"
|
|
20
|
+
],
|
|
21
|
+
"excludeSuffixes": ["_KEY_ID", "_PUBLIC_KEY"],
|
|
22
|
+
"excludeNames": ["SSH_AUTH_SOCK"]
|
|
23
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Inference-provider API-key env vars whose VALUES the redactor masks by exact match, plus the placeholder floor below which a configured value is treated as a doc stub rather than a real key. Mirrors agent_sanitizer.secrets.config.DEFAULT_MIN_SECRET_LEN; the hooks send these names' current values to the redactor daemon per request (see lib/redactor-client.mjs).",
|
|
3
|
+
"min_secret_len": 16,
|
|
4
|
+
"vars": [
|
|
5
|
+
"ANTHROPIC_API_KEY",
|
|
6
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
7
|
+
"OPENAI_API_KEY",
|
|
8
|
+
"OPENROUTER_API_KEY",
|
|
9
|
+
"GEMINI_API_KEY",
|
|
10
|
+
"GOOGLE_API_KEY",
|
|
11
|
+
"MISTRAL_API_KEY",
|
|
12
|
+
"GROQ_API_KEY",
|
|
13
|
+
"DEEPSEEK_API_KEY",
|
|
14
|
+
"XAI_API_KEY",
|
|
15
|
+
"VENICE_INFERENCE_KEY"
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "The GUARANTEED FLOOR of credential-bearing environment variables whose values must never reach the model through tool output. On top of this list the redactor self-populates with any credential-shaped var present in the environment (looksLikeCredentialVar in lib/env-config.mjs), so a newly-forwarded token is redacted without editing this file — this list only pins the names that must always be covered regardless of shape. The Layer-4 secret redactor treats each as an env-bound secret (env_secrets).",
|
|
3
|
+
"vars": [
|
|
4
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
5
|
+
"GH_TOKEN",
|
|
6
|
+
"GITHUB_TOKEN",
|
|
7
|
+
"AWS_ACCESS_KEY_ID",
|
|
8
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
9
|
+
"AWS_SESSION_TOKEN",
|
|
10
|
+
"NPM_TOKEN",
|
|
11
|
+
"PYPI_TOKEN",
|
|
12
|
+
"DOCKER_PASSWORD",
|
|
13
|
+
"DOCKER_AUTH_CONFIG"
|
|
14
|
+
]
|
|
15
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitize content the *model authors* into tool calls — file writes, edits,
|
|
3
|
+
* notebook cells, and command bodies (commit messages, PR/issue bodies passed on
|
|
4
|
+
* the command line). Two complementary protections:
|
|
5
|
+
*
|
|
6
|
+
* 1. Covert channel (steganography). Format chars (Cf — including the U+E00xx
|
|
7
|
+
* TAG block used for ASCII smuggling and zero-width joiners) and variation
|
|
8
|
+
* selectors can encode a hidden message that another AI reading the
|
|
9
|
+
* committed file / PR / commit later decodes, while staying invisible to a
|
|
10
|
+
* human reviewer. Stripped when payload-capable (see isPayloadCapable):
|
|
11
|
+
* gated on volume because incidental joiners / emoji selectors are benign
|
|
12
|
+
* and authored content is *persisted*, so over-stripping is costly.
|
|
13
|
+
*
|
|
14
|
+
* 2. Terminal-display rewriting. ANSI/terminal control sequences (CSI/OSC)
|
|
15
|
+
* authored into a command — echoed and executed live — or into file
|
|
16
|
+
* content (a latent bomb when the file is later `cat`'d) can clear the
|
|
17
|
+
* screen, reposition the cursor, or overwrite what the user sees, hiding
|
|
18
|
+
* the real command behind spoofed output. Stripped *unconditionally*: a
|
|
19
|
+
* single sequence already does harm, so there is no volume threshold. The
|
|
20
|
+
* false-positive rate is low because real source represents escapes as
|
|
21
|
+
* *literals* (`\033`, `\x1b`, `\e`) — a *raw* ESC byte in authored content
|
|
22
|
+
* is anomalous.
|
|
23
|
+
*
|
|
24
|
+
* Distinct from sanitize-output.mjs, which scrubs tool *responses* flowing
|
|
25
|
+
* toward the model (data the model reads). This scrubs what the model emits
|
|
26
|
+
* (data the model writes out). In pretooluse-sanitize.mjs it runs *after*
|
|
27
|
+
* confusable normalization, so on the shared `command` field it sees the
|
|
28
|
+
* already-normalized text and the two protections compose deterministically.
|
|
29
|
+
*
|
|
30
|
+
* Opt-outs are granular so dropping one protection doesn't drop the other:
|
|
31
|
+
* AGENT_SANITIZER_INVISIBLE_DISABLED=1 keeps invisible chars (legitimate i18n
|
|
32
|
+
* text relying on ZWNJ/ZWJ joiners) while terminal-control stripping stays on;
|
|
33
|
+
* AGENT_SANITIZER_TERMINAL_DISABLED=1 keeps raw escape sequences (fixtures that
|
|
34
|
+
* must contain them) while stego stripping stays on; and
|
|
35
|
+
* AGENT_SANITIZER_OUTPUT_DISABLED=1 disables both.
|
|
36
|
+
*/
|
|
37
|
+
import { lazyImport } from "./hook-io.mjs";
|
|
38
|
+
|
|
39
|
+
// Bound via lazyImport (see its doc for the fail-OPEN hazard of a bare static
|
|
40
|
+
// npm import — here the load crash would fire inside pretooluse-sanitize.mjs's
|
|
41
|
+
// static import of this module, before its fail-closed catch runs). A failed
|
|
42
|
+
// load leaves these bindings undefined, so sanitizeField's calls throw into
|
|
43
|
+
// the fail-closed catch (ask) instead.
|
|
44
|
+
const { stripAnsiFully } = /** @type {typeof import("agent-sanitizer")} */ (
|
|
45
|
+
await lazyImport("agent-sanitizer")
|
|
46
|
+
);
|
|
47
|
+
const { STRIP, LONG_RUN_RE, SCATTERED_THRESHOLD, stripInvisible } =
|
|
48
|
+
/** @type {typeof import("agent-sanitizer/invisible")} */ (
|
|
49
|
+
await lazyImport("agent-sanitizer/invisible")
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Content fields the model authors, per tool. Paths and confusables are the
|
|
53
|
+
// confusable layer's domain; here we target the free-text fields that carry
|
|
54
|
+
// model-authored prose / code / data out into persisted or displayed artifacts.
|
|
55
|
+
// A "key[].sub" entry addresses `sub` on every element of the array at `key`
|
|
56
|
+
// (MultiEdit batches its writes as edits[].new_string), so the nested authored
|
|
57
|
+
// content is sanitized too — not just the top-level fields.
|
|
58
|
+
/** @type {Record<string, string[]>} */
|
|
59
|
+
const FIELDS = {
|
|
60
|
+
Write: ["content"],
|
|
61
|
+
Edit: ["new_string"],
|
|
62
|
+
MultiEdit: ["edits[].new_string"],
|
|
63
|
+
NotebookEdit: ["new_source"],
|
|
64
|
+
Bash: ["command"],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Payload-capable: a long contiguous run, or enough scattered invisibles to
|
|
68
|
+
// carry a message. Mirrors sanitize-user-prompt so the model→world and
|
|
69
|
+
// user→model surfaces share one definition of "stego payload".
|
|
70
|
+
/** @param {string} text */
|
|
71
|
+
function isPayloadCapable(text) {
|
|
72
|
+
LONG_RUN_RE.lastIndex = 0;
|
|
73
|
+
if (LONG_RUN_RE.test(text)) return true;
|
|
74
|
+
return (text.match(STRIP)?.length ?? 0) >= SCATTERED_THRESHOLD;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Returns the cleaned value plus the human-readable actions applied, or null if
|
|
78
|
+
// the field is already clean. Each protection has its own opt-out (see the
|
|
79
|
+
// header) so a deployment can keep one while dropping the other.
|
|
80
|
+
/** @param {string} value */
|
|
81
|
+
function sanitizeField(value) {
|
|
82
|
+
const actions = [];
|
|
83
|
+
let cleaned = value;
|
|
84
|
+
|
|
85
|
+
// Strip terminal-control sequences first, so the invisible scan below runs on
|
|
86
|
+
// the same de-ANSI'd view sanitize-output uses (both go through the package's
|
|
87
|
+
// stripAnsiFully, which strips to a fixed point — so a sequence reconstituted
|
|
88
|
+
// when an inner one is removed is itself stripped on the next pass). Compare
|
|
89
|
+
// before/after rather than pre-testing for ESC: a lone control byte that forms
|
|
90
|
+
// no real sequence does not rewrite the display and is left alone, so we only
|
|
91
|
+
// report a genuine strip.
|
|
92
|
+
if (process.env.AGENT_SANITIZER_TERMINAL_DISABLED !== "1") {
|
|
93
|
+
const deAnsi = stripAnsiFully(cleaned);
|
|
94
|
+
if (deAnsi !== cleaned) {
|
|
95
|
+
cleaned = deAnsi;
|
|
96
|
+
actions.push("terminal-control sequences");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
process.env.AGENT_SANITIZER_INVISIBLE_DISABLED !== "1" &&
|
|
102
|
+
isPayloadCapable(cleaned)
|
|
103
|
+
) {
|
|
104
|
+
cleaned = stripInvisible(cleaned);
|
|
105
|
+
actions.push("invisible characters");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return actions.length > 0 ? { cleaned, actions } : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @param {string[]} changed */
|
|
112
|
+
export function authoredContext(changed) {
|
|
113
|
+
return `Sanitized model-authored content in: ${changed.join("; ")}. This removes a covert channel to other AIs and prevents authored content from rewriting the user's terminal. Opt out granularly with AGENT_SANITIZER_INVISIBLE_DISABLED=1 (i18n joiners) or AGENT_SANITIZER_TERMINAL_DISABLED=1 (raw-escape fixtures), or fully with AGENT_SANITIZER_OUTPUT_DISABLED=1.`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Strip authored stego / terminal-control sequences from the model-authored
|
|
118
|
+
* fields of a tool call. Returns the updated input plus a per-field description
|
|
119
|
+
* of what was stripped, or null when nothing changed. Throws on internal error
|
|
120
|
+
* (caller fails closed).
|
|
121
|
+
* @param {string} tool
|
|
122
|
+
* @param {any} toolInput
|
|
123
|
+
* @returns {{ updatedInput: any, changed: string[] } | null}
|
|
124
|
+
*/
|
|
125
|
+
export function sanitizeAuthoredContent(tool, toolInput) {
|
|
126
|
+
const keys = FIELDS[tool];
|
|
127
|
+
if (!keys || toolInput === null || toolInput === undefined) return null;
|
|
128
|
+
|
|
129
|
+
const changed = [];
|
|
130
|
+
// Null-prototype copy: toolInput is untrusted parsed JSON where a `__proto__`
|
|
131
|
+
// key is own-enumerable, and the computed writes below would otherwise route
|
|
132
|
+
// it through the prototype chain. Object.assign onto Object.create(null) copies
|
|
133
|
+
// every own field (including a literal `__proto__`) as a plain own property.
|
|
134
|
+
const updatedInput = Object.assign(Object.create(null), toolInput);
|
|
135
|
+
for (const k of keys) {
|
|
136
|
+
// Named groups satisfy prefer-named-capture-group; reading the numeric
|
|
137
|
+
// indices keeps the values typed as string (match.groups is optional).
|
|
138
|
+
const nested = k.match(/^(?<arr>\w+)\[\]\.(?<sub>\w+)$/);
|
|
139
|
+
if (nested) {
|
|
140
|
+
const arrKey = nested[1];
|
|
141
|
+
const subKey = nested[2];
|
|
142
|
+
const arr = toolInput[arrKey];
|
|
143
|
+
if (!Array.isArray(arr)) continue;
|
|
144
|
+
let nestedChanged = false;
|
|
145
|
+
const newArr = arr.map((el) => {
|
|
146
|
+
const val = el?.[subKey];
|
|
147
|
+
if (typeof val !== "string") return el;
|
|
148
|
+
const result = sanitizeField(val);
|
|
149
|
+
if (!result) return el;
|
|
150
|
+
nestedChanged = true;
|
|
151
|
+
changed.push(`${arrKey}[].${subKey} (${result.actions.join(", ")})`);
|
|
152
|
+
return { ...el, [subKey]: result.cleaned };
|
|
153
|
+
});
|
|
154
|
+
if (nestedChanged) updatedInput[arrKey] = newArr;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (typeof toolInput[k] !== "string") continue;
|
|
158
|
+
const result = sanitizeField(toolInput[k]);
|
|
159
|
+
if (!result) continue;
|
|
160
|
+
updatedInput[k] = result.cleaned;
|
|
161
|
+
changed.push(`${k} (${result.actions.join(", ")})`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (changed.length === 0) return null;
|
|
165
|
+
return { updatedInput, changed };
|
|
166
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge to the agent-agnostic control plane (agent-control-plane-core).
|
|
3
|
+
* Guardrail judges consume the normalized ToolCallEvent and return a Verdict;
|
|
4
|
+
* a per-agent adapter parses the native payload and renders the native
|
|
5
|
+
* response, so the same judge runs unchanged under any agent the package has
|
|
6
|
+
* an adapter for. This module owns the package load, the one Claude-specific
|
|
7
|
+
* transport rule (nativeStdout), and the shared judge-CLI transport
|
|
8
|
+
* (runJudgeCli).
|
|
9
|
+
*/
|
|
10
|
+
import { errMessage, lazyImport, readStdinJson } from "./hook-io.mjs";
|
|
11
|
+
|
|
12
|
+
// Loaded via a *caught* dynamic import — never a bare static `import … from`.
|
|
13
|
+
// A static npm import resolves before any try/catch, so a missing node_modules
|
|
14
|
+
// would crash every importing hook at load; the harness treats that as a
|
|
15
|
+
// non-blocking error and the tool call sails through UNGUARDED — fail OPEN. A
|
|
16
|
+
// failed load leaves the bindings undefined, so controlPlane() throws into the
|
|
17
|
+
// calling hook's catch and each hook takes its declared failure posture
|
|
18
|
+
// (deny/ask for gates, suppression for the output sanitizer) instead.
|
|
19
|
+
/** @type {typeof import("agent-control-plane-core/claude").claudeAdapter | undefined} */
|
|
20
|
+
let claudeAdapter;
|
|
21
|
+
/** @type {typeof import("agent-control-plane-core").Decision | undefined} */
|
|
22
|
+
let Decision;
|
|
23
|
+
/** @type {typeof import("agent-control-plane-core").EventKind | undefined} */
|
|
24
|
+
let EventKind;
|
|
25
|
+
|
|
26
|
+
/* c8 ignore start -- module-load boundary: the real import resolves in every
|
|
27
|
+
in-process test and spawned CLI run, and a missing node_modules can't be
|
|
28
|
+
simulated in-process, so this glue's failure arm is unobservable here. The
|
|
29
|
+
observable behaviour is controlPlane()'s throw, unit-tested directly. */
|
|
30
|
+
// Stryker disable all
|
|
31
|
+
{
|
|
32
|
+
const { claudeAdapter: adapter } =
|
|
33
|
+
/** @type {Partial<typeof import("agent-control-plane-core/claude")>} */ (
|
|
34
|
+
await lazyImport("agent-control-plane-core/claude")
|
|
35
|
+
);
|
|
36
|
+
const { Decision: decision, EventKind: eventKind } =
|
|
37
|
+
/** @type {Partial<typeof import("agent-control-plane-core")>} */ (
|
|
38
|
+
await lazyImport("agent-control-plane-core")
|
|
39
|
+
);
|
|
40
|
+
claudeAdapter = adapter;
|
|
41
|
+
Decision = decision;
|
|
42
|
+
EventKind = eventKind;
|
|
43
|
+
}
|
|
44
|
+
// Stryker restore all
|
|
45
|
+
/* c8 ignore stop */
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The loaded control-plane bindings, narrowed to non-undefined — or a throw
|
|
49
|
+
* the calling hook's catch converts into its own failure posture. Overrides
|
|
50
|
+
* exist so tests can drive the unavailable arm in-process.
|
|
51
|
+
* @param {{ claudeAdapter?: unknown, Decision?: unknown, EventKind?: unknown }} [overrides]
|
|
52
|
+
* @returns {{
|
|
53
|
+
* claudeAdapter: typeof import("agent-control-plane-core/claude").claudeAdapter,
|
|
54
|
+
* Decision: typeof import("agent-control-plane-core").Decision,
|
|
55
|
+
* EventKind: typeof import("agent-control-plane-core").EventKind,
|
|
56
|
+
* }}
|
|
57
|
+
*/
|
|
58
|
+
export function controlPlane(overrides = {}) {
|
|
59
|
+
const bindings = { claudeAdapter, Decision, EventKind, ...overrides };
|
|
60
|
+
if (!bindings.claudeAdapter || !bindings.Decision || !bindings.EventKind)
|
|
61
|
+
throw new Error("agent-control-plane-core is unavailable");
|
|
62
|
+
return /** @type {ReturnType<typeof controlPlane>} */ (bindings);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Serialize a rendered NativeResponse for Claude Code's stdout, or null when
|
|
67
|
+
* the body carries nothing a silent exit 0 doesn't already say. The adapter's
|
|
68
|
+
* exit_code is deliberately NOT honored by the hooks: Claude Code parses hook
|
|
69
|
+
* stdout as JSON only on exit 0 — under the adapter's exit-2 enforced-deny
|
|
70
|
+
* channel it discards stdout and reads the (empty) stderr instead, so the
|
|
71
|
+
* deny would land without its reason. For this host the stdout JSON's
|
|
72
|
+
* permissionDecision IS the enforcement channel, and hooks always exit 0.
|
|
73
|
+
* @param {{ stdout?: unknown }} response a NativeResponse from adapter.render
|
|
74
|
+
* @returns {string | null}
|
|
75
|
+
*/
|
|
76
|
+
export function nativeStdout(response) {
|
|
77
|
+
const stdout = /** @type {Record<string, unknown> | undefined} */ (
|
|
78
|
+
response.stdout
|
|
79
|
+
);
|
|
80
|
+
if (!stdout) return null;
|
|
81
|
+
// Directives live either inside hookSpecificOutput (permissionDecision,
|
|
82
|
+
// updatedInput, additionalContext) or at the top level (the non-gating
|
|
83
|
+
// decision:"block"/reason the adapter uses for post-tool and unclassified
|
|
84
|
+
// events). A body that is only the echoed hookEventName says nothing.
|
|
85
|
+
const body = /** @type {Record<string, unknown> | undefined} */ (
|
|
86
|
+
stdout.hookSpecificOutput
|
|
87
|
+
);
|
|
88
|
+
const meaningful =
|
|
89
|
+
Object.keys(stdout).some((key) => key !== "hookSpecificOutput") ||
|
|
90
|
+
(body !== undefined &&
|
|
91
|
+
Object.keys(body).some((key) => key !== "hookEventName"));
|
|
92
|
+
return meaningful ? JSON.stringify(stdout) : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Run a judge hook's CLI transport: read the native payload from stdin, parse
|
|
97
|
+
* it through the claude adapter, render the judge's verdict, and write the
|
|
98
|
+
* native response. This encodes the two transport invariants every gate hook
|
|
99
|
+
* shares: stdin is read BEFORE the control-plane bindings are touched, so a
|
|
100
|
+
* package-load failure still lands in `onError` with the parsed input in hand;
|
|
101
|
+
* and the process always exits 0 with the verdict in the stdout JSON (see
|
|
102
|
+
* nativeStdout — exit-code enforcement is deliberately not used). Any throw —
|
|
103
|
+
* unparsable stdin, missing package, a judge error — is reported on stderr and
|
|
104
|
+
* routed to `onError(err, input)` (`input` undefined when stdin never parsed),
|
|
105
|
+
* where the hook applies its declared fail posture.
|
|
106
|
+
* @param {string} hookName prefix for the stderr diagnostic
|
|
107
|
+
* @param {(event: import("agent-control-plane-core").ToolCallEvent) =>
|
|
108
|
+
* import("agent-control-plane-core").Verdict |
|
|
109
|
+
* Promise<import("agent-control-plane-core").Verdict>} judge
|
|
110
|
+
* @param {object} opts
|
|
111
|
+
* @param {(err: unknown, input: unknown) => void} opts.onError fail-posture emitter
|
|
112
|
+
* @param {(input: unknown) => unknown} [opts.transformInput] raw-payload normalization before adapter.parse
|
|
113
|
+
* @param {() => Promise<unknown>} [opts.readInput] injectable stdin reader
|
|
114
|
+
* @param {(chunk: string) => void} [opts.write] injectable stdout writer
|
|
115
|
+
* @returns {Promise<void>}
|
|
116
|
+
*/
|
|
117
|
+
export async function runJudgeCli(
|
|
118
|
+
hookName,
|
|
119
|
+
judge,
|
|
120
|
+
{
|
|
121
|
+
onError,
|
|
122
|
+
transformInput = (raw) => raw,
|
|
123
|
+
readInput = readStdinJson,
|
|
124
|
+
write = (chunk) => process.stdout.write(chunk),
|
|
125
|
+
},
|
|
126
|
+
) {
|
|
127
|
+
let input;
|
|
128
|
+
try {
|
|
129
|
+
input = await readInput();
|
|
130
|
+
const { claudeAdapter: adapter } = controlPlane();
|
|
131
|
+
const event = adapter.parse(transformInput(input));
|
|
132
|
+
const out = nativeStdout(adapter.render(await judge(event), event));
|
|
133
|
+
if (out !== null) write(out);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
process.stderr.write(`${hookName} hook error: ${errMessage(err)}\n`);
|
|
136
|
+
onError(err, input);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The env-bound secret vocabulary the Layer-4 pre-gate and the redactor client
|
|
3
|
+
* share, so the set of variable names whose VALUES get masked has one definition
|
|
4
|
+
* instead of a copy per hook that can silently drift.
|
|
5
|
+
*
|
|
6
|
+
* The three JSON configs are imported as modules, not read from disk at call
|
|
7
|
+
* time: esbuild inlines them into the plugin bundle (which ships with no
|
|
8
|
+
* config directory beside it) and Node resolves them natively from the package
|
|
9
|
+
* when the hooks run from source. Their VALIDATION stays lazy — a malformed
|
|
10
|
+
* credential vocabulary throws on first use, inside the consuming hook's
|
|
11
|
+
* fail-closed catch, rather than at module load where a throw would abort before
|
|
12
|
+
* that catch installs and let the harness pass the tool output through
|
|
13
|
+
* UNSANITIZED (fail OPEN).
|
|
14
|
+
*/
|
|
15
|
+
import credentialVarNames from "../config/credential-var-names.json" with { type: "json" };
|
|
16
|
+
import inferenceKeys from "../config/inference-key-vars.json" with { type: "json" };
|
|
17
|
+
import scrubbed from "../config/scrubbed-env-vars.json" with { type: "json" };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The inference-provider key env vars. Their values authenticate the agent to a
|
|
21
|
+
* model backend, so they are masked like any other credential.
|
|
22
|
+
* @returns {string[]}
|
|
23
|
+
*/
|
|
24
|
+
export function inferenceKeyVars() {
|
|
25
|
+
return inferenceKeys.vars;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The placeholder floor: a candidate value shorter than this is too short to be a
|
|
30
|
+
* real secret and is skipped by the env-bound redaction pre-gate.
|
|
31
|
+
* @returns {number}
|
|
32
|
+
*/
|
|
33
|
+
export function minEnvSecretLen() {
|
|
34
|
+
return inferenceKeys.min_secret_len;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A token from credential-var-names.json. Restricting it to A-Z/_ is what lets
|
|
38
|
+
// the regexes below interpolate it unescaped: a stray metacharacter (or an empty
|
|
39
|
+
// list, which would make the match regex accept nothing and leak every forwarded
|
|
40
|
+
// credential) fails closed here instead of silently under-matching.
|
|
41
|
+
const CRED_TOKEN_RE = /^[A-Z_]+$/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The validated token list under `field`, or throw. An absent, empty, or
|
|
45
|
+
* metacharacter-bearing list must not degrade into a pattern that matches nothing,
|
|
46
|
+
* which would leak every forwarded credential.
|
|
47
|
+
* @param {Record<string, unknown>} spec
|
|
48
|
+
* @param {string} field
|
|
49
|
+
* @returns {string[]}
|
|
50
|
+
*/
|
|
51
|
+
function credentialTokens(spec, field) {
|
|
52
|
+
const group = spec[field];
|
|
53
|
+
if (!Array.isArray(group) || group.length === 0)
|
|
54
|
+
throw new Error(`credential-var-names.json: ${field} is empty or missing`);
|
|
55
|
+
for (const token of group)
|
|
56
|
+
if (typeof token !== "string" || !CRED_TOKEN_RE.test(token))
|
|
57
|
+
throw new Error(
|
|
58
|
+
`credential-var-names.json: bad token ${token} in ${field}`,
|
|
59
|
+
);
|
|
60
|
+
return group;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validate a credential-var-names spec and build its match/exclude regexes. Pure
|
|
65
|
+
* and exported so the fail-closed paths can be driven directly with a bad spec.
|
|
66
|
+
* @param {Record<string, unknown>} spec
|
|
67
|
+
* @returns {{ match: RegExp, exclude: RegExp }}
|
|
68
|
+
*/
|
|
69
|
+
export function buildCredentialNameRes(spec) {
|
|
70
|
+
const segments = credentialTokens(spec, "segments");
|
|
71
|
+
const excludeSuffixes = credentialTokens(spec, "excludeSuffixes");
|
|
72
|
+
const excludeNames = credentialTokens(spec, "excludeNames");
|
|
73
|
+
return {
|
|
74
|
+
match: new RegExp(`(?:^|_)(?:${segments.join("|")})$`, "i"),
|
|
75
|
+
exclude: new RegExp(
|
|
76
|
+
`(?:${excludeSuffixes.join("|")})$|^(?:${excludeNames.join("|")})$`,
|
|
77
|
+
"i",
|
|
78
|
+
),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @type {{ match: RegExp, exclude: RegExp } | undefined} */
|
|
83
|
+
let _credentialNameRes;
|
|
84
|
+
/**
|
|
85
|
+
* The credential-var-NAME regexes, memoized after the first build. Matching by
|
|
86
|
+
* trailing segment lets the redaction set self-populate with any token the
|
|
87
|
+
* process actually holds; a curated list drifts. The curated sets
|
|
88
|
+
* (inferenceKeyVars + scrubbed vars) stay the guaranteed floor; this only ADDS
|
|
89
|
+
* lookalikes.
|
|
90
|
+
* @returns {{ match: RegExp, exclude: RegExp }}
|
|
91
|
+
*/
|
|
92
|
+
function credentialNameRes() {
|
|
93
|
+
if (_credentialNameRes !== undefined) return _credentialNameRes;
|
|
94
|
+
return (_credentialNameRes = buildCredentialNameRes(credentialVarNames));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* True when `name` looks like a credential-bearing variable (and isn't a known
|
|
99
|
+
* non-secret lookalike).
|
|
100
|
+
* @param {string} name
|
|
101
|
+
* @returns {boolean}
|
|
102
|
+
*/
|
|
103
|
+
export function looksLikeCredentialVar(name) {
|
|
104
|
+
const res = credentialNameRes();
|
|
105
|
+
return res.match.test(name) && !res.exclude.test(name);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Credential-shaped env-var names present in `env` with a value long enough to be
|
|
110
|
+
* a real secret (the min_secret_len floor the daemon also applies), beyond the
|
|
111
|
+
* curated set. Reads the live environment so a newly-forwarded token is redacted
|
|
112
|
+
* without a code change.
|
|
113
|
+
* @param {Record<string, string | undefined>} [env]
|
|
114
|
+
* @returns {string[]}
|
|
115
|
+
*/
|
|
116
|
+
export function dynamicSecretVars(env = process.env) {
|
|
117
|
+
const floor = minEnvSecretLen();
|
|
118
|
+
return Object.keys(env).filter(
|
|
119
|
+
(name) => looksLikeCredentialVar(name) && (env[name]?.length ?? 0) >= floor,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The env-bound redaction set: the UNION of the inference keys, the curated host
|
|
125
|
+
* credentials, and any credential-shaped var present in the environment. The
|
|
126
|
+
* redactor binds the same union; every consumer (the sanitize-output pre-gate,
|
|
127
|
+
* the redactor client's per-request env snapshot) must mirror it exactly, else a
|
|
128
|
+
* credential value would never trip the daemon.
|
|
129
|
+
* @param {Record<string, string | undefined>} [env]
|
|
130
|
+
* @returns {string[]}
|
|
131
|
+
*/
|
|
132
|
+
export function envBoundSecretVars(env = process.env) {
|
|
133
|
+
return [
|
|
134
|
+
...new Set([
|
|
135
|
+
...inferenceKeyVars(),
|
|
136
|
+
...scrubbed.vars,
|
|
137
|
+
...dynamicSecretVars(env),
|
|
138
|
+
]),
|
|
139
|
+
];
|
|
140
|
+
}
|