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