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