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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer-4 pre-gate helpers for env-bound secrets. The redaction transport (the
|
|
3
|
+
* daemon call) stays with the sanitize-output hook; this module owns only the
|
|
4
|
+
* cheap, deterministic checks around it.
|
|
5
|
+
*/
|
|
6
|
+
import { minEnvSecretLen, envBoundSecretVars } from "./env-config.mjs";
|
|
7
|
+
|
|
8
|
+
// Zero-width / format (Cf) characters an attacker can splice between a value's
|
|
9
|
+
// characters to break an exact-substring pre-gate while the daemon's redactor
|
|
10
|
+
// still matches across them. This is a curated subset of the common bidi /
|
|
11
|
+
// zero-width controls, NOT an exact copy of the daemon's full dynamic Cf set — it
|
|
12
|
+
// is a defense-in-depth backstop, because Layer 1 (applyLayer1) strips every Cf
|
|
13
|
+
// splice from the text BEFORE this pre-gate runs, so hasEnvBoundSecret already
|
|
14
|
+
// sees the plain value. A run of zero-or-more is allowed at each interior gap, so
|
|
15
|
+
// the plain value still matches (a superset of `includes`). Required literals
|
|
16
|
+
// between every gap keep the pattern linear — no ReDoS.
|
|
17
|
+
const ENV_INVIS_RUN =
|
|
18
|
+
"[\\u200b\\u200c\\u200d\\u2060\\ufeff\\u00ad\\u180e\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069]*";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Regex matching `value` tolerating invisible chars spliced between its
|
|
22
|
+
* characters (mirrors the engine's env-value regex). Code-point split so
|
|
23
|
+
* an astral character is escaped whole, not as two surrogate halves.
|
|
24
|
+
* @param {string} value
|
|
25
|
+
* @returns {RegExp}
|
|
26
|
+
*/
|
|
27
|
+
export function envValueRegex(value) {
|
|
28
|
+
return new RegExp(
|
|
29
|
+
[...value]
|
|
30
|
+
.map((ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
31
|
+
.join(ENV_INVIS_RUN),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* True when tool output contains the literal value of a configured env-bound
|
|
37
|
+
* secret. The shape-based secret hint can't match a prefix-less key or a host
|
|
38
|
+
* credential, so the pre-gate must also fire on the value itself — otherwise
|
|
39
|
+
* the engine's env-bound redaction never runs. Invisible-tolerant so a
|
|
40
|
+
* value with spliced Cf chars (which the daemon still redacts) trips it too.
|
|
41
|
+
* @param {string} text
|
|
42
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
export function hasEnvBoundSecret(text, env = process.env) {
|
|
46
|
+
const minLen = minEnvSecretLen();
|
|
47
|
+
return envBoundSecretVars().some((name) => {
|
|
48
|
+
const value = env[name];
|
|
49
|
+
// Code-point length to match envValueRegex's code-point split — an astral
|
|
50
|
+
// char counts once, not as two UTF-16 units, so minLen means the same thing
|
|
51
|
+
// on both sides of the gate.
|
|
52
|
+
return (
|
|
53
|
+
value && [...value].length >= minLen && envValueRegex(value).test(text)
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in structured trace channel for the hooks. When _AGENT_SANITIZER_TRACE
|
|
3
|
+
* names a level (info|debug; off/empty disables), each call appends one JSON line
|
|
4
|
+
* {"ts":<epoch_ms>,"level":"info","event":"<name>",...<fields>}
|
|
5
|
+
* to the sink — the file named by _AGENT_SANITIZER_TRACE_FILE, else stderr. The
|
|
6
|
+
* point is that every defense layer announces it ENGAGED, so a missing
|
|
7
|
+
* announcement is loud. It is best-effort: a sink it can't write never throws, so
|
|
8
|
+
* dropping a trace() onto a hook path costs nothing and risks nothing.
|
|
9
|
+
*
|
|
10
|
+
* METADATA ONLY — never pass a tool_input body or secret material as a field; the
|
|
11
|
+
* channel is not redaction-aware.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { appendFileSync } from "node:fs";
|
|
15
|
+
|
|
16
|
+
/** Trace-channel event names. */
|
|
17
|
+
export const TraceEvent = Object.freeze({
|
|
18
|
+
HOOK_RAN: "hook_ran",
|
|
19
|
+
SCAN_INVISIBLE_CHARS_RAN: "scan_invisible_chars_ran",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const LEVELS = Object.freeze({ off: 0, info: 1, debug: 2 });
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Numeric verbosity from _AGENT_SANITIZER_TRACE: 0 off, 1 info, 2 debug.
|
|
26
|
+
* Unknown, empty, or "off" → 0.
|
|
27
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
28
|
+
* @returns {number}
|
|
29
|
+
*/
|
|
30
|
+
export function traceThreshold(env = process.env) {
|
|
31
|
+
const value = (env._AGENT_SANITIZER_TRACE ?? "").toLowerCase();
|
|
32
|
+
if (value === "debug" || value === "2") return LEVELS.debug;
|
|
33
|
+
if (["info", "1", "true", "on"].includes(value)) return LEVELS.info;
|
|
34
|
+
return LEVELS.off;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Emit one JSON trace line for `event` at `level` (default "info") carrying the
|
|
39
|
+
* metadata `fields`. No-op when the channel is below `level`; best-effort on write.
|
|
40
|
+
* @param {string} event
|
|
41
|
+
* @param {Record<string, unknown>} [fields]
|
|
42
|
+
* @param {"info"|"debug"} [level]
|
|
43
|
+
* @returns {void}
|
|
44
|
+
*/
|
|
45
|
+
export function trace(event, fields = {}, level = "info") {
|
|
46
|
+
// info|debug are the only real levels; anything else (a producer typo) clamps
|
|
47
|
+
// to info for BOTH the gate and the recorded field, so a line never carries a
|
|
48
|
+
// level outside {info,debug} for a reader to bucket on.
|
|
49
|
+
const lvl = level === "debug" ? "debug" : "info";
|
|
50
|
+
if (traceThreshold() < LEVELS[lvl]) return;
|
|
51
|
+
const line =
|
|
52
|
+
JSON.stringify({ ts: Date.now(), level: lvl, event, ...fields }) + "\n";
|
|
53
|
+
const file = process.env._AGENT_SANITIZER_TRACE_FILE;
|
|
54
|
+
try {
|
|
55
|
+
if (file) appendFileSync(file, line);
|
|
56
|
+
else process.stderr.write(line);
|
|
57
|
+
} catch {
|
|
58
|
+
// best-effort: a trace we can't write must never break a hook.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry point: ALL FOUR sanitization hooks behind one dispatch flag. The four
|
|
3
|
+
* hooks share almost the entire package graph, so one bundle per hook would ship
|
|
4
|
+
* four near-identical copies; a single entry with a `--hook=<name>` flag
|
|
5
|
+
* selecting the hook ships the graph once (hooks.json passes the flag).
|
|
6
|
+
*
|
|
7
|
+
* The plugin's shipped artifact is the esbuild BUNDLE with every package inlined
|
|
8
|
+
* at build time — there is no node_modules for a runtime import to resolve — so
|
|
9
|
+
* this binder statically imports the packages the hooks lazy-load and
|
|
10
|
+
* pre-registers them BEFORE any hook module is imported. Each hook module is
|
|
11
|
+
* loaded through a literal dynamic import so the registration (and the CLI-slot
|
|
12
|
+
* claim) precede its top-level lazyImport calls, then its own exported CLI runs.
|
|
13
|
+
*
|
|
14
|
+
* Layer 2/3 (the remark/rehype HTML parser graph) is inlined and runs locally.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
claimCliEntry,
|
|
18
|
+
isMain,
|
|
19
|
+
registerLazyModules,
|
|
20
|
+
readFlag,
|
|
21
|
+
readStdinJson,
|
|
22
|
+
} from "./lib/hook-io.mjs";
|
|
23
|
+
|
|
24
|
+
// The packages the hooks lazy-load, each behind a thunk whose import specifier
|
|
25
|
+
// is a LITERAL — esbuild only inlines `import("…")` it can read statically, so
|
|
26
|
+
// an `import(variable)` here would survive into the bundle as a runtime dial
|
|
27
|
+
// against a node_modules the plugin does not ship, and every registration would
|
|
28
|
+
// fail at once.
|
|
29
|
+
const LAZY_LOADERS = {
|
|
30
|
+
"agent-control-plane-core": () => import("agent-control-plane-core"),
|
|
31
|
+
"agent-control-plane-core/claude": () =>
|
|
32
|
+
import("agent-control-plane-core/claude"),
|
|
33
|
+
"agent-sanitizer": () => import("agent-sanitizer"),
|
|
34
|
+
"agent-sanitizer/confusables": () => import("agent-sanitizer/confusables"),
|
|
35
|
+
"agent-sanitizer/invisible": () => import("agent-sanitizer/invisible"),
|
|
36
|
+
"agent-sanitizer/output": () => import("agent-sanitizer/output"),
|
|
37
|
+
"agent-sanitizer/rehydrate": () => import("agent-sanitizer/rehydrate"),
|
|
38
|
+
"namespace-guard": () => import("namespace-guard"),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Pre-register every package the hooks lazy-load, skipping any that will not
|
|
43
|
+
* load.
|
|
44
|
+
*
|
|
45
|
+
* A top-level static import of these would abort the process before `main`
|
|
46
|
+
* runs, and an aborted hook writes NOTHING to stdout — which Claude Code reads
|
|
47
|
+
* as a non-blocking hook error and shows the raw tool output. That is a
|
|
48
|
+
* fail-OPEN on the very hook that withholds secrets. Registering what resolves
|
|
49
|
+
* and leaving the rest absent hands the gap to each hook's own lazyImport
|
|
50
|
+
* guard, whose posture is to block.
|
|
51
|
+
* @returns {Promise<void>}
|
|
52
|
+
*/
|
|
53
|
+
async function registerAvailableModules() {
|
|
54
|
+
/** @type {Record<string, Record<string, any>>} */
|
|
55
|
+
const loaded = {};
|
|
56
|
+
await Promise.all(
|
|
57
|
+
Object.entries(LAZY_LOADERS).map(async ([specifier, load]) => {
|
|
58
|
+
try {
|
|
59
|
+
loaded[specifier] = await load();
|
|
60
|
+
} catch {
|
|
61
|
+
// Left unregistered on purpose — see the fail-open note above.
|
|
62
|
+
}
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
registerLazyModules(loaded);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Dispatch to the hook named by `--hook=<name>` in argv. Exported and guarded by
|
|
70
|
+
* isMain below so importing this module (the published entry point) is a no-op:
|
|
71
|
+
* only a direct `node plugin-hooks.mjs --hook=…` run consumes stdin and exits.
|
|
72
|
+
* @returns {Promise<void>}
|
|
73
|
+
*/
|
|
74
|
+
export async function main() {
|
|
75
|
+
// This binder owns the process's CLI entry: inside the bundle every inlined
|
|
76
|
+
// module shares this file's import.meta.url, so without the claim the inlined
|
|
77
|
+
// hooks' own isMain-guarded CLIs would also fire and consume stdin.
|
|
78
|
+
claimCliEntry();
|
|
79
|
+
await registerAvailableModules();
|
|
80
|
+
|
|
81
|
+
const mode = readFlag(process.argv, "hook");
|
|
82
|
+
switch (mode) {
|
|
83
|
+
case "pretooluse-sanitize": {
|
|
84
|
+
const { cliMain } =
|
|
85
|
+
/** @type {typeof import("./pretooluse-sanitize.mjs")} */ (
|
|
86
|
+
await import("./pretooluse-sanitize.mjs")
|
|
87
|
+
);
|
|
88
|
+
await cliMain();
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case "sanitize-output": {
|
|
92
|
+
const { cliMain } =
|
|
93
|
+
/** @type {typeof import("./sanitize-output.mjs")} */ (
|
|
94
|
+
await import("./sanitize-output.mjs")
|
|
95
|
+
);
|
|
96
|
+
await cliMain();
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case "sanitize-user-prompt": {
|
|
100
|
+
const { main: promptMain } =
|
|
101
|
+
/** @type {typeof import("./sanitize-user-prompt.mjs")} */ (
|
|
102
|
+
await import("./sanitize-user-prompt.mjs")
|
|
103
|
+
);
|
|
104
|
+
await promptMain(readStdinJson, (chunk) => process.stdout.write(chunk));
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case "scan-invisible-chars": {
|
|
108
|
+
const { cliMain } =
|
|
109
|
+
/** @type {typeof import("./scan-invisible-chars.mjs")} */ (
|
|
110
|
+
await import("./scan-invisible-chars.mjs")
|
|
111
|
+
);
|
|
112
|
+
await cliMain();
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
default:
|
|
116
|
+
// An unknown mode means broken hooks.json wiring — fail CLOSED, never fall
|
|
117
|
+
// through to some default hook and vet the wrong payload class. Exit 2 is
|
|
118
|
+
// the one non-zero code Claude Code treats as blocking (a plain exit 1 is
|
|
119
|
+
// a non-blocking hook error that lets the guarded action through
|
|
120
|
+
// unsanitized); it blocks PreToolUse and UserPromptSubmit, surfaces
|
|
121
|
+
// stderr for PostToolUse, and is harmless for SessionStart.
|
|
122
|
+
process.stderr.write(
|
|
123
|
+
`plugin-hooks: unknown hook mode ${JSON.stringify(mode)}\n`,
|
|
124
|
+
);
|
|
125
|
+
process.exit(2);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// isMain is read BEFORE main() claims the CLI slot (the claim makes every later
|
|
130
|
+
// isMain answer false, including this one).
|
|
131
|
+
if (isMain(import.meta.url)) {
|
|
132
|
+
await main();
|
|
133
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PreToolUse content-protection orchestrator. Runs four layers in ONE process:
|
|
3
|
+
*
|
|
4
|
+
* 1. Invisible-char injection gate (lib/invisible-alert.mjs)
|
|
5
|
+
* 2. Confusable/homoglyph normalization of paths & commands
|
|
6
|
+
* (agent-sanitizer/confusables, namespace-guard scanner injected)
|
|
7
|
+
* 3. Stego / terminal-control stripping of model-authored fields
|
|
8
|
+
* (lib/authored-content.mjs)
|
|
9
|
+
* 4. Rehydration of secret-redaction placeholders in Edit/Write inputs
|
|
10
|
+
* (agent-sanitizer/rehydrate, redactor-daemon io injected)
|
|
11
|
+
*
|
|
12
|
+
* WHY ONE PROCESS: Claude Code runs PreToolUse hooks in parallel and does NOT
|
|
13
|
+
* chain their `updatedInput` — each hook sees the original input and the last to
|
|
14
|
+
* finish wins. Registered as three separate hooks, layers 2 and 3 both rewrite
|
|
15
|
+
* the shared Bash `command` field from the original text, so a command carrying
|
|
16
|
+
* BOTH a confusable AND a stego payload had one fix non-deterministically
|
|
17
|
+
* clobbered by the other. Composing them here makes the rewrite deterministic
|
|
18
|
+
* (normalize, then strip the normalized text) and pays a single Node start
|
|
19
|
+
* instead of three on the hottest path.
|
|
20
|
+
*
|
|
21
|
+
* Layers 2 and 4 are the provider-agnostic transforms in the agent-sanitizer
|
|
22
|
+
* package; this file binds its peers (namespace-guard, the redactor daemon, the
|
|
23
|
+
* filesystem) into them.
|
|
24
|
+
*/
|
|
25
|
+
import { createRequire } from "node:module";
|
|
26
|
+
import { readFileSync } from "node:fs";
|
|
27
|
+
import {
|
|
28
|
+
isMain,
|
|
29
|
+
lazyImport,
|
|
30
|
+
registeredLazyModule,
|
|
31
|
+
emitHookResponse,
|
|
32
|
+
safeErrMessage,
|
|
33
|
+
HookEvent,
|
|
34
|
+
PermissionDecision,
|
|
35
|
+
} from "./lib/hook-io.mjs";
|
|
36
|
+
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
37
|
+
import {
|
|
38
|
+
invisibleCharAlert,
|
|
39
|
+
gateAskReason,
|
|
40
|
+
gateReminderContext,
|
|
41
|
+
alertAcknowledged,
|
|
42
|
+
acknowledgeAlert,
|
|
43
|
+
} from "./lib/invisible-alert.mjs";
|
|
44
|
+
import {
|
|
45
|
+
sanitizeAuthoredContent,
|
|
46
|
+
authoredContext,
|
|
47
|
+
} from "./lib/authored-content.mjs";
|
|
48
|
+
import { redactViaDaemon } from "./lib/redactor-client.mjs";
|
|
49
|
+
import { trace, TraceEvent } from "./lib/trace.mjs";
|
|
50
|
+
|
|
51
|
+
const HOOK_NAME = "pretooluse-sanitize";
|
|
52
|
+
|
|
53
|
+
// Layers 2 & 4 come from the agent-sanitizer package, bound via lazyImport (see
|
|
54
|
+
// its doc for the fail-OPEN hazard of a bare static npm import); a failed load
|
|
55
|
+
// leaves these bindings undefined, so the layer calls below throw into the CLI's
|
|
56
|
+
// fail-closed catch (ask) instead.
|
|
57
|
+
const { normalizeConfusables, normalizeContext } =
|
|
58
|
+
/** @type {typeof import("agent-sanitizer/confusables")} */ (
|
|
59
|
+
await lazyImport("agent-sanitizer/confusables")
|
|
60
|
+
);
|
|
61
|
+
const { rehydrateRedacted } =
|
|
62
|
+
/** @type {typeof import("agent-sanitizer/rehydrate")} */ (
|
|
63
|
+
await lazyImport("agent-sanitizer/rehydrate")
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// Injection seams binding the peer dependencies into the provider-agnostic
|
|
67
|
+
// package functions. namespace-guard (the confusable vision map) and the
|
|
68
|
+
// redactor daemon are external to the package; it imports neither.
|
|
69
|
+
// namespace-guard is lazy-required so its map loads only on the first field that
|
|
70
|
+
// actually carries a non-ASCII glyph — normalizeConfusables applies its ASCII
|
|
71
|
+
// fast-path before ever calling scan.
|
|
72
|
+
const require = createRequire(import.meta.url);
|
|
73
|
+
// Registry first, so a build-time bundle (which has no node_modules for the
|
|
74
|
+
// require to resolve from) reaches its statically-inlined, pre-registered copy;
|
|
75
|
+
// running from source keeps the lazy require. The lookup must stay synchronous —
|
|
76
|
+
// normalizeConfusables calls the scan inline.
|
|
77
|
+
/** @param {string} text */
|
|
78
|
+
const confusableScan = (text) =>
|
|
79
|
+
(registeredLazyModule("namespace-guard") ?? require("namespace-guard")).scan(
|
|
80
|
+
text,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* File + redactor-daemon I/O the package's rehydrateRedacted runs against:
|
|
85
|
+
* `redactMap` yields the redacted view plus ordered (placeholder, original,
|
|
86
|
+
* start) pairs, `redact` the plain redacted text or null. Both go through the
|
|
87
|
+
* long-lived redactor daemon so detect-secrets stays the only engine.
|
|
88
|
+
* @type {import("agent-sanitizer/rehydrate").RehydrateIo}
|
|
89
|
+
*/
|
|
90
|
+
const redactorIo = {
|
|
91
|
+
readFile: (path) => readFileSync(path, "utf8"),
|
|
92
|
+
redactMap: async (text) =>
|
|
93
|
+
/** @type {any} */ (await redactViaDaemon(text, { map: true })),
|
|
94
|
+
redact: async (text) => {
|
|
95
|
+
const out = await redactViaDaemon(text, {});
|
|
96
|
+
return out ? /** @type {string} */ (out.text) : null;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Default Layer-4 rehydrator: the package's rehydrateRedacted bound to the
|
|
102
|
+
* redactor-daemon io. Hoisted (not an inline default-param arrow) so tests can
|
|
103
|
+
* still inject a fake as the second argument to buildPreToolUseResponse.
|
|
104
|
+
* @param {string} tool
|
|
105
|
+
* @param {any} toolInput
|
|
106
|
+
*/
|
|
107
|
+
const defaultRehydrate = (tool, toolInput) =>
|
|
108
|
+
rehydrateRedacted(tool, toolInput, redactorIo);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Trace the response on the way out — "noop" (clean pass-through), "deny",
|
|
112
|
+
* "ask", or "modified" (input rewritten and/or context attached) — and return
|
|
113
|
+
* it unchanged. The trace lives on this in-process, mutation-tested path, not in
|
|
114
|
+
* the CLI block, so engagement is announced (hook_ran — metadata only: hook
|
|
115
|
+
* name, tool, outcome) for every exit.
|
|
116
|
+
* @param {string} toolName
|
|
117
|
+
* @param {Record<string, unknown> | null} fields
|
|
118
|
+
* @returns {Record<string, unknown> | null}
|
|
119
|
+
*/
|
|
120
|
+
function emitTraced(toolName, fields) {
|
|
121
|
+
let outcome = "modified";
|
|
122
|
+
if (fields === null) outcome = "noop";
|
|
123
|
+
else if (fields.permissionDecision === PermissionDecision.DENY)
|
|
124
|
+
outcome = "deny";
|
|
125
|
+
else if (fields.permissionDecision === PermissionDecision.ASK)
|
|
126
|
+
outcome = "ask";
|
|
127
|
+
trace(TraceEvent.HOOK_RAN, { hook: HOOK_NAME, tool: toolName, outcome });
|
|
128
|
+
return fields;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Compose the four protections. Returns the `hookSpecificOutput` fields to
|
|
133
|
+
* emit, or null for a clean no-op. Throws only if a layer's engine throws; the
|
|
134
|
+
* caller fails closed (ask) on any throw. Every exit routes through emitTraced.
|
|
135
|
+
* @param {any} input parsed PreToolUse event
|
|
136
|
+
* @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
|
|
137
|
+
* injectable for tests; the default binds the real redactor-daemon io (the
|
|
138
|
+
* layer reads the target file and maps secrets through the daemon)
|
|
139
|
+
* @returns {Promise<Record<string, unknown> | null>}
|
|
140
|
+
*/
|
|
141
|
+
export async function buildPreToolUseResponse(
|
|
142
|
+
input,
|
|
143
|
+
rehydrate = defaultRehydrate,
|
|
144
|
+
) {
|
|
145
|
+
const asks = [];
|
|
146
|
+
const contexts = [];
|
|
147
|
+
|
|
148
|
+
// Layer 1: gate. Persists across the session until the injected files are
|
|
149
|
+
// cleaned. It asks ONCE (a hard checkpoint, recorded once emitted) then
|
|
150
|
+
// degrades to a passive reminder, so it doesn't prompt on every tool call.
|
|
151
|
+
const findings = invisibleCharAlert();
|
|
152
|
+
let pendingGateAck = false;
|
|
153
|
+
if (findings) {
|
|
154
|
+
if (alertAcknowledged()) {
|
|
155
|
+
contexts.push(gateReminderContext());
|
|
156
|
+
} else {
|
|
157
|
+
asks.push(gateAskReason(findings));
|
|
158
|
+
pendingGateAck = true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const { tool_name: tool, tool_input: toolInput } = input;
|
|
163
|
+
|
|
164
|
+
// Layers 2 then 3, chained: normalize confusables first, then strip authored
|
|
165
|
+
// stego/terminal-control from the normalized text.
|
|
166
|
+
let current = toolInput;
|
|
167
|
+
let changed = false;
|
|
168
|
+
|
|
169
|
+
const norm = normalizeConfusables(tool, current, { scan: confusableScan });
|
|
170
|
+
if (norm) {
|
|
171
|
+
current = norm.updatedInput;
|
|
172
|
+
changed = true;
|
|
173
|
+
contexts.push(normalizeContext(norm.normalized));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (process.env.AGENT_SANITIZER_OUTPUT_DISABLED !== "1") {
|
|
177
|
+
const authored = sanitizeAuthoredContent(tool, current);
|
|
178
|
+
if (authored) {
|
|
179
|
+
current = authored.updatedInput;
|
|
180
|
+
changed = true;
|
|
181
|
+
contexts.push(authoredContext(authored.changed));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Layer 4: re-anchor Edit/Write inputs composed from a sanitized file view
|
|
186
|
+
// ([REDACTED…] placeholders, stripped invisible characters) back onto the
|
|
187
|
+
// on-disk bytes. Runs last so it sees the final authored text and its
|
|
188
|
+
// rehydrated secrets are not re-stripped by layer 3. An unresolvable or
|
|
189
|
+
// secret-exposing call is denied outright — that verdict outranks any ask
|
|
190
|
+
// above, so it returns immediately.
|
|
191
|
+
const rehydrated = await rehydrate(tool, current);
|
|
192
|
+
if (rehydrated && "deny" in rehydrated)
|
|
193
|
+
return emitTraced(input.tool_name, {
|
|
194
|
+
permissionDecision: PermissionDecision.DENY,
|
|
195
|
+
permissionDecisionReason: rehydrated.deny,
|
|
196
|
+
});
|
|
197
|
+
if (rehydrated) {
|
|
198
|
+
current = rehydrated.updatedInput;
|
|
199
|
+
changed = true;
|
|
200
|
+
contexts.push(rehydrated.context);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return emitTraced(
|
|
204
|
+
input.tool_name,
|
|
205
|
+
assembleResponse({ changed, current, asks, contexts, pendingGateAck }),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Assemble the hookSpecificOutput fields from the per-layer results, or null
|
|
211
|
+
* for a clean no-op (nothing asked, changed, or annotated). Records the gate
|
|
212
|
+
* acknowledgement only when an ask actually lands in the response.
|
|
213
|
+
* @param {{ changed: boolean, current: any, asks: string[], contexts: string[], pendingGateAck: boolean }} parts
|
|
214
|
+
* @returns {Record<string, unknown> | null}
|
|
215
|
+
*/
|
|
216
|
+
function assembleResponse({
|
|
217
|
+
changed,
|
|
218
|
+
current,
|
|
219
|
+
asks,
|
|
220
|
+
contexts,
|
|
221
|
+
pendingGateAck,
|
|
222
|
+
}) {
|
|
223
|
+
if (asks.length === 0 && !changed && contexts.length === 0) return null;
|
|
224
|
+
|
|
225
|
+
/** @type {Record<string, unknown>} */
|
|
226
|
+
const fields = {};
|
|
227
|
+
// Include the rewritten input even alongside an ask: applying it can only
|
|
228
|
+
// surface a *cleaner* call to the user than the original (and is ignored if
|
|
229
|
+
// Claude Code doesn't apply updatedInput under an ask).
|
|
230
|
+
if (changed) fields.updatedInput = current;
|
|
231
|
+
if (asks.length > 0) {
|
|
232
|
+
fields.permissionDecision = PermissionDecision.ASK;
|
|
233
|
+
// Stryker disable next-line StringLiteral: the gate is the only source that
|
|
234
|
+
// pushes onto `asks`, so the array never holds more than one reason and the
|
|
235
|
+
// separator is unobservable — join("") is equivalent. The paragraph break is
|
|
236
|
+
// kept for the day a second ask source is added.
|
|
237
|
+
fields.permissionDecisionReason = asks.join("\n\n");
|
|
238
|
+
}
|
|
239
|
+
if (contexts.length > 0) fields.additionalContext = contexts.join(" ");
|
|
240
|
+
// Record the gate ack only now that the ask is actually in the response — a
|
|
241
|
+
// rehydrate deny above returns first, so a preempted ask is not marked seen.
|
|
242
|
+
if (pendingGateAck) acknowledgeAlert();
|
|
243
|
+
return fields;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Agent-agnostic judge over the four protections: consumes a control-plane
|
|
248
|
+
* ToolCallEvent and returns a Verdict, so a non-Claude host can run the same
|
|
249
|
+
* sanitization pipeline through its own adapter. The wired Claude CLI below
|
|
250
|
+
* routes through this judge and renders the Verdict with the Claude adapter; on
|
|
251
|
+
* any throw (a control-plane package-load failure included) it falls back to
|
|
252
|
+
* failClosedFields — a native response that needs no package — so the
|
|
253
|
+
* fail-closed posture holds even when the adapter never loaded.
|
|
254
|
+
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
255
|
+
* @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
|
|
256
|
+
* @returns {Promise<import("agent-control-plane-core").Verdict>}
|
|
257
|
+
*/
|
|
258
|
+
export async function judgePreToolUseSanitize(event, rehydrate) {
|
|
259
|
+
const { Decision, EventKind } = controlPlane();
|
|
260
|
+
// A payload the adapter cannot classify (a missing/unexpected hook_event_name)
|
|
261
|
+
// would drive the pipeline with an empty event and no-op to ALLOW — a silent
|
|
262
|
+
// fail-OPEN of the gate. This hook is wired only to PreToolUse, so an
|
|
263
|
+
// unclassifiable payload is harness-contract drift or an out-of-band caller,
|
|
264
|
+
// never a real call: deny-when-blind. Rewarding an unclassifiable payload with
|
|
265
|
+
// a pass is the one incentive a gate must never create.
|
|
266
|
+
if (event.event === EventKind.UNKNOWN)
|
|
267
|
+
return {
|
|
268
|
+
decision: Decision.DENY,
|
|
269
|
+
reason:
|
|
270
|
+
"PreToolUse sanitization blocked (fail-closed): unrecognized hook payload.",
|
|
271
|
+
};
|
|
272
|
+
const fields = await buildPreToolUseResponse(
|
|
273
|
+
{ tool_name: event.tool, tool_input: event.input },
|
|
274
|
+
rehydrate,
|
|
275
|
+
);
|
|
276
|
+
if (fields === null) return { decision: Decision.ALLOW };
|
|
277
|
+
/** @type {Record<string, unknown>} */
|
|
278
|
+
const verdict = {
|
|
279
|
+
decision: fields.permissionDecision ?? Decision.ALLOW,
|
|
280
|
+
};
|
|
281
|
+
if (fields.permissionDecisionReason !== undefined)
|
|
282
|
+
verdict.reason = fields.permissionDecisionReason;
|
|
283
|
+
if (fields.updatedInput !== undefined)
|
|
284
|
+
verdict.mutated_input = fields.updatedInput;
|
|
285
|
+
if (fields.additionalContext !== undefined)
|
|
286
|
+
verdict.additional_context = fields.additionalContext;
|
|
287
|
+
return /** @type {import("agent-control-plane-core").Verdict} */ (verdict);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The fail-closed hookSpecificOutput fields for a hook-level failure, chosen by
|
|
292
|
+
* WHICH failure it was. Corrupt/unparsable INPUT (`parsedOk` false — a JSON parse
|
|
293
|
+
* error or the oversize-body cap) is a state an adversary can induce with no
|
|
294
|
+
* upside to failing, so it hard-DENIES: no human to talk past, no approval
|
|
295
|
+
* fatigue, no latency. A LAYER/engine throw after a clean parse (`parsedOk` true
|
|
296
|
+
* — redactor daemon down, package not loaded) is the sanitizer being UNAVAILABLE,
|
|
297
|
+
* so it ASKS to keep a human in the loop rather than hard-block on infrastructure.
|
|
298
|
+
* @param {boolean} parsedOk whether the input parsed before the failure
|
|
299
|
+
* @param {unknown} err
|
|
300
|
+
* @returns {Record<string, unknown>}
|
|
301
|
+
*/
|
|
302
|
+
export function failClosedFields(parsedOk, err) {
|
|
303
|
+
return {
|
|
304
|
+
permissionDecision: parsedOk
|
|
305
|
+
? PermissionDecision.ASK
|
|
306
|
+
: PermissionDecision.DENY,
|
|
307
|
+
permissionDecisionReason: parsedOk
|
|
308
|
+
? `PreToolUse sanitization failed (fail-closed): ${safeErrMessage(err)}`
|
|
309
|
+
: `PreToolUse input unparsable (fail-closed): ${safeErrMessage(err)}`,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Stryker disable all: CLI wiring — it runs only in the spawned hook
|
|
314
|
+
// subprocess, never in-process, so every mutant from here down is NoCoverage.
|
|
315
|
+
// The exported judgePreToolUseSanitize and failClosedFields above carry the
|
|
316
|
+
// real, mutation-tested logic.
|
|
317
|
+
/**
|
|
318
|
+
* The hook's CLI: parse → judge → render, with this hook's fail-closed posture.
|
|
319
|
+
* Exported so a bundle entry (which must claim the CLI slot before this module
|
|
320
|
+
* loads) can run the exact same wiring instead of duplicating the onError
|
|
321
|
+
* posture.
|
|
322
|
+
* @returns {Promise<void>}
|
|
323
|
+
*/
|
|
324
|
+
export async function cliMain() {
|
|
325
|
+
await runJudgeCli("pretooluse-sanitize", judgePreToolUseSanitize, {
|
|
326
|
+
// Fail closed WITHOUT the package: unparsable INPUT (`input` undefined)
|
|
327
|
+
// hard-denies (adversary-inducible, no benefit to failing); any throw
|
|
328
|
+
// after a clean parse — a layer engine down or the control-plane package
|
|
329
|
+
// unavailable — asks to keep a human in the loop. emitHookResponse renders
|
|
330
|
+
// natively, so this posture holds even when the adapter never loaded.
|
|
331
|
+
onError: (err, input) =>
|
|
332
|
+
emitHookResponse(
|
|
333
|
+
HookEvent.PRE_TOOL_USE,
|
|
334
|
+
failClosedFields(input !== undefined, err),
|
|
335
|
+
),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (isMain(import.meta.url)) {
|
|
340
|
+
await cliMain();
|
|
341
|
+
}
|