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 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
+ }