agent-sanitizer 2.67.1 → 2.68.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/THREAT-MODEL.md +21 -21
- package/claude-hooks/lib/control-plane.mjs +66 -31
- package/claude-hooks/lib/env-config.mjs +2 -2
- package/claude-hooks/lib/hook-fault.mjs +11 -35
- package/claude-hooks/lib/hook-timing.mjs +26 -6
- package/claude-hooks/lib/redactor-client.mjs +23 -23
- package/claude-hooks/lib/secret-annotate.mjs +36 -66
- package/claude-hooks/plugin-hooks.mjs +1 -3
- package/claude-hooks/pretooluse-sanitize.mjs +0 -2
- package/claude-hooks/sanitize-output.mjs +27 -40
- package/claude-hooks/sanitize-user-prompt.mjs +2 -5
- package/claude-hooks/scan-invisible-chars.mjs +0 -2
- package/claude-hooks/scan-loaded-instructions.mjs +0 -2
- package/package.json +2 -2
- package/src/output.mjs +15 -15
- package/types/claude-hooks/lib/control-plane.d.mts +17 -8
- package/types/claude-hooks/lib/hook-fault.d.mts +6 -73
- package/types/claude-hooks/lib/hook-timing.d.mts +9 -4
- package/types/claude-hooks/lib/redactor-client.d.mts +3 -3
- package/types/claude-hooks/lib/secret-annotate.d.mts +11 -17
- package/types/claude-hooks/sanitize-output.d.mts +1 -1
- package/types/output.d.mts +4 -5
|
@@ -17,70 +17,25 @@ import charset from "../../python/agent_sanitizer/data/invisible-charset.json" w
|
|
|
17
17
|
// Invisible characters an attacker can splice between a value's characters to
|
|
18
18
|
// break an exact-substring pre-gate while the daemon's redactor still matches
|
|
19
19
|
// across them. Built from the SAME pinned charset Layer 1 strips and the daemon
|
|
20
|
-
// tolerates (`
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
// all. A run of zero-or-more is allowed at each interior gap, so the plain
|
|
26
|
-
// value still matches (a superset of `includes`). The required literals between
|
|
27
|
-
// every gap are what bound the match — a `*` gap is never adjacent to another
|
|
28
|
-
// gap, so there is no ambiguity to backtrack over and no ReDoS. (The class size
|
|
29
|
-
// is irrelevant to that: a character class matches in O(1) whether it holds one
|
|
30
|
-
// member or all 435.) That argument holds only while no literal is itself a
|
|
31
|
-
// member of the class, so {@link envValueRegex} drops the value's own invisible
|
|
32
|
-
// code points before it splices: `a<ZWSP>a` would otherwise build
|
|
33
|
-
// `a[…]*<ZWSP>[…]*a`, whose two stars share the literal and backtrack
|
|
34
|
-
// quadratically over a long run of ZWSP.
|
|
35
|
-
const ENV_INVIS_CODEPOINTS = [
|
|
36
|
-
...new Set([...charset.cf_codepoints, ...charset.extra_codepoints]),
|
|
37
|
-
].sort((a, b) => a - b);
|
|
38
|
-
const ENV_INVIS_SET = new Set(ENV_INVIS_CODEPOINTS);
|
|
39
|
-
const ENV_INVIS_RUN =
|
|
20
|
+
// tolerates (`_env_value_spans` in agent_sanitizer.secrets.engine), not
|
|
21
|
+
// a hand-curated subset: a subset pre-gate silently under-matches whenever this
|
|
22
|
+
// gate runs on text Layer 1 has not already stripped, and a value spliced with a
|
|
23
|
+
// code point in the gap then never reaches the daemon at all.
|
|
24
|
+
const ENV_INVIS_RE = new RegExp(
|
|
40
25
|
"[" +
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
26
|
+
[...new Set([...charset.cf_codepoints, ...charset.extra_codepoints])]
|
|
27
|
+
.map((cp) => `\\u{${cp.toString(16)}}`)
|
|
28
|
+
.join("") +
|
|
29
|
+
"]",
|
|
30
|
+
"gu",
|
|
31
|
+
);
|
|
46
32
|
|
|
47
33
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* `u` flag, which the astral `\u{…}` class members in {@link ENV_INVIS_RUN}
|
|
52
|
-
* require.
|
|
53
|
-
* Memoized per distinct value: {@link ENV_INVIS_RUN} renders ~435 code points
|
|
54
|
-
* as ~4 KB of source and is joined at EVERY interior gap, so a 20-char secret
|
|
55
|
-
* compiles a ~75 KB pattern — and `hasEnvBoundSecret` builds one per configured
|
|
56
|
-
* var on every PostToolUse output. Env values are stable for the process's
|
|
57
|
-
* lifetime, so the cache is bounded by the number of distinct values. Sharing an
|
|
58
|
-
* instance is safe because the regex carries no `g`/`y` flag, hence no
|
|
59
|
-
* `lastIndex` state to leak between calls.
|
|
60
|
-
* @param {string} value
|
|
61
|
-
* @returns {RegExp}
|
|
34
|
+
* `s` with every charset invisible removed.
|
|
35
|
+
* @param {string} s
|
|
36
|
+
* @returns {string}
|
|
62
37
|
*/
|
|
63
|
-
|
|
64
|
-
let re = ENV_VALUE_REGEX_CACHE.get(value);
|
|
65
|
-
if (re === undefined) {
|
|
66
|
-
const visible = [...value].filter(
|
|
67
|
-
(ch) => !ENV_INVIS_SET.has(/** @type {number} */ (ch.codePointAt(0))),
|
|
68
|
-
);
|
|
69
|
-
// A value made only of invisibles has no literal to anchor a gap on: match
|
|
70
|
-
// it exactly rather than build a pattern that is nothing but gaps.
|
|
71
|
-
re = new RegExp(
|
|
72
|
-
visible.length > 0
|
|
73
|
-
? visible.map(escapeLiteral).join(ENV_INVIS_RUN)
|
|
74
|
-
: [...value].map(escapeLiteral).join(""),
|
|
75
|
-
"u",
|
|
76
|
-
);
|
|
77
|
-
ENV_VALUE_REGEX_CACHE.set(value, re);
|
|
78
|
-
}
|
|
79
|
-
return re;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** value → compiled matcher; see {@link envValueRegex}. */
|
|
83
|
-
const ENV_VALUE_REGEX_CACHE = new Map();
|
|
38
|
+
const stripInvisibles = (s) => s.replace(ENV_INVIS_RE, "");
|
|
84
39
|
|
|
85
40
|
/**
|
|
86
41
|
* True when tool output contains the literal value of a configured env-bound
|
|
@@ -88,19 +43,34 @@ const ENV_VALUE_REGEX_CACHE = new Map();
|
|
|
88
43
|
* credential, so the pre-gate must also fire on the value itself — otherwise
|
|
89
44
|
* the engine's env-bound redaction never runs. Invisible-tolerant so a
|
|
90
45
|
* value with spliced Cf chars (which the daemon still redacts) trips it too.
|
|
46
|
+
*
|
|
47
|
+
* Both sides are stripped and compared as substrings, which is exactly
|
|
48
|
+
* equivalent: the value's visible characters appear contiguously in the
|
|
49
|
+
* stripped text iff they appear in the original separated only by invisibles.
|
|
50
|
+
* It is linear and independent of the value's length — a regex with an
|
|
51
|
+
* invisible class at every gap grows ~4 KB per character, and a long secret (a
|
|
52
|
+
* PEM key) overflows V8's regex size limit and throws on every output.
|
|
53
|
+
*
|
|
54
|
+
* Callers pass post-Layer-1 text: terminal escapes are already gone, so a value
|
|
55
|
+
* split by one is contiguous here. Raw text spliced with an escape is not
|
|
56
|
+
* matched.
|
|
91
57
|
* @param {string} text
|
|
92
58
|
* @param {NodeJS.ProcessEnv} [env]
|
|
93
59
|
* @returns {boolean}
|
|
94
60
|
*/
|
|
95
61
|
export function hasEnvBoundSecret(text, env = process.env) {
|
|
96
62
|
const minLen = minEnvSecretLen();
|
|
63
|
+
/** @type {string | undefined} */
|
|
64
|
+
let strippedText;
|
|
97
65
|
return envBoundSecretVars(env).some((name) => {
|
|
98
66
|
const value = env[name];
|
|
99
|
-
// Code-point length
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
);
|
|
67
|
+
// Code-point length, so an astral char counts once, not as two UTF-16 units.
|
|
68
|
+
if (!value || [...value].length < minLen) return false;
|
|
69
|
+
const needle = stripInvisibles(value);
|
|
70
|
+
// A value made only of invisibles has no visible character to anchor on:
|
|
71
|
+
// match it exactly rather than as the empty string, which matches anything.
|
|
72
|
+
if (needle === "") return text.includes(value);
|
|
73
|
+
strippedText ??= stripInvisibles(text);
|
|
74
|
+
return strippedText.includes(needle);
|
|
105
75
|
});
|
|
106
76
|
}
|
|
@@ -26,7 +26,6 @@ import {
|
|
|
26
26
|
} from "./lib/hook-io.mjs";
|
|
27
27
|
import {
|
|
28
28
|
registerFaultPolicy,
|
|
29
|
-
KNOB_DECIDES,
|
|
30
29
|
hookFaultOutcome,
|
|
31
30
|
writeFaultOutcome,
|
|
32
31
|
} from "./lib/hook-fault.mjs";
|
|
@@ -55,7 +54,6 @@ const unknownMode = (/** @type {{ message: string }} */ ctx) => ({
|
|
|
55
54
|
registerFaultPolicy(HOOK_NAME, {
|
|
56
55
|
event: null,
|
|
57
56
|
guarded: "hook payload",
|
|
58
|
-
alwaysClosed: KNOB_DECIDES,
|
|
59
57
|
open: unknownMode,
|
|
60
58
|
closed: unknownMode,
|
|
61
59
|
});
|
|
@@ -90,7 +88,7 @@ const LAZY_LOADERS = {
|
|
|
90
88
|
* as a non-blocking hook error and shows the raw tool output. That is a
|
|
91
89
|
* fail-OPEN on the very hook that withholds secrets. Registering what resolves
|
|
92
90
|
* and leaving the rest absent hands the gap to each hook's own lazyImport
|
|
93
|
-
* guard,
|
|
91
|
+
* guard, which answers it loudly under the operator's fault posture.
|
|
94
92
|
* @returns {Promise<void>}
|
|
95
93
|
*/
|
|
96
94
|
async function registerAvailableModules() {
|
|
@@ -44,7 +44,6 @@ import {
|
|
|
44
44
|
} from "./lib/hook-io.mjs";
|
|
45
45
|
import {
|
|
46
46
|
registerFaultPolicy,
|
|
47
|
-
KNOB_DECIDES,
|
|
48
47
|
hookFaultOutcome,
|
|
49
48
|
defaultOpen,
|
|
50
49
|
} from "./lib/hook-fault.mjs";
|
|
@@ -845,7 +844,6 @@ export function hookFailureFields(parsedOk, err, opts = {}) {
|
|
|
845
844
|
registerFaultPolicy(HOOK_NAME, {
|
|
846
845
|
event: HookEvent.PRE_TOOL_USE,
|
|
847
846
|
guarded: "tool input",
|
|
848
|
-
alwaysClosed: KNOB_DECIDES,
|
|
849
847
|
open: (ctx) => {
|
|
850
848
|
// Non-carve-out faults take the SHARED open rendering, not a copy of it —
|
|
851
849
|
// hook-fault.mjs owns that body, and a restated one would silently drift.
|
|
@@ -50,11 +50,7 @@ import { registerFaultPolicy, hookFaultOutcome } from "./lib/hook-fault.mjs";
|
|
|
50
50
|
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
51
51
|
import { hookTrace, TraceEvent } from "./lib/trace.mjs";
|
|
52
52
|
import { hasEnvBoundSecret } from "./lib/secret-annotate.mjs";
|
|
53
|
-
import {
|
|
54
|
-
digestFlaggingEnabled,
|
|
55
|
-
secretsEnabled,
|
|
56
|
-
SECRETS_ENABLED_ENV,
|
|
57
|
-
} from "./lib/env-config.mjs";
|
|
53
|
+
import { digestFlaggingEnabled, secretsEnabled } from "./lib/env-config.mjs";
|
|
58
54
|
import {
|
|
59
55
|
persistReveal,
|
|
60
56
|
persistSpan,
|
|
@@ -120,16 +116,17 @@ const HOOK_NAME = "sanitize-output";
|
|
|
120
116
|
// reaches the model-facing context. Exported so tests assert it by reference.
|
|
121
117
|
export const REVEAL_WITHHELD_WARNING = withheldWarning("reveal sidecar");
|
|
122
118
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Total wall-clock budget for one hook invocation's blocking Layer-4 redactor
|
|
121
|
+
* calls, SHARED across every string leaf of the tool output. Each call gets the
|
|
122
|
+
* budget remaining at that moment; once it is spent, a further secret-shaped
|
|
123
|
+
* leaf throws, answered under the operator's fault posture. The per-call
|
|
124
|
+
* timeouts bound each call but not their SUM, and a structured output with many
|
|
125
|
+
* secret-shaped leaves could otherwise run past the PostToolUse hook kill,
|
|
126
|
+
* which shows the RAW output with no warning. The default sits above one slow
|
|
127
|
+
* leaf (a cold redactor respawn plus a full scan) and far under the hook
|
|
128
|
+
* timeout; env-tunable so tests can drive the exhausted-budget path fast.
|
|
129
|
+
*/
|
|
133
130
|
const SANITIZE_BUDGET_MS = readKnob("_AGENT_SANITIZER_SANITIZE_BUDGET_MS");
|
|
134
131
|
|
|
135
132
|
// Web-ingress tools always get the Layer 2 HTML rewrite; local tools — Read,
|
|
@@ -172,11 +169,13 @@ function isUntrustedIngress(toolName) {
|
|
|
172
169
|
|
|
173
170
|
/**
|
|
174
171
|
* Redact secrets via the long-lived redactor daemon (lib/redactor-client.mjs).
|
|
175
|
-
* Returns `{text, found}` or null when nothing was redacted; throws
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
172
|
+
* Returns `{text, found}` or null when nothing was redacted; throws when
|
|
173
|
+
* secret-shaped text cannot be vetted, which the CLI answers under the
|
|
174
|
+
* operator's fault posture. The cheap pre-gate runs first so plain output never
|
|
175
|
+
* touches the daemon. `text` is post-Layer-1, so it carries no terminal escape
|
|
176
|
+
* and the pre-gate needs no escape-stripped view. A transient daemon failure
|
|
177
|
+
* fails only THIS call — no session-wide sentinel — and the client respawns a
|
|
178
|
+
* dead daemon on the next call.
|
|
180
179
|
* @param {string} text
|
|
181
180
|
* @param {boolean} webIngress
|
|
182
181
|
* @param {{remainingMs: () => number}} [deadline] shared wall-clock budget
|
|
@@ -288,9 +287,9 @@ export async function sanitizeText(
|
|
|
288
287
|
// Layer 4 — OPT-IN (`secretsOn`): with the knob unset the seam gets no
|
|
289
288
|
// redact callback at all, so plain output never spawns the daemon and no
|
|
290
289
|
// placeholder ever enters the model's view. When it runs, the seam rethrows
|
|
291
|
-
// a redactor throw wrapped, and this hook's fault policy
|
|
292
|
-
//
|
|
293
|
-
// operator's terminal here first: the
|
|
290
|
+
// a redactor throw wrapped, and this hook's fault policy answers it under
|
|
291
|
+
// the operator's AGENT_SANITIZER_FAIL_OPEN posture. Surface the failure to
|
|
292
|
+
// the operator's terminal here first: the fault note rides in what only
|
|
294
293
|
// the model sees, so a degraded redactor would otherwise be invisible to
|
|
295
294
|
// the human.
|
|
296
295
|
redact: !secretsOn
|
|
@@ -579,7 +578,7 @@ function failClosedParts(
|
|
|
579
578
|
* fail-OPEN by default — a warning context and NO `updatedToolOutput`, leaving
|
|
580
579
|
* the original tool output in the model's view — or the fail-closed
|
|
581
580
|
* suppression of {@link emitFailClosed} when the caller set
|
|
582
|
-
* AGENT_SANITIZER_FAIL_OPEN=0
|
|
581
|
+
* AGENT_SANITIZER_FAIL_OPEN=0.
|
|
583
582
|
*
|
|
584
583
|
* This is the hook where the two postures diverge the most, so state the open
|
|
585
584
|
* one plainly: several of these layers throw on inputs an attacker composes (a
|
|
@@ -625,29 +624,17 @@ function suppressionMessage(cause) {
|
|
|
625
624
|
return `[SANITIZATION FAILED — original output suppressed for safety. Hook error: ${cause}]`;
|
|
626
625
|
}
|
|
627
626
|
|
|
628
|
-
/** Why an open knob still suppressed: the secret layer's own guarantee. */
|
|
629
|
-
const SECRETS_PIN_NOTE =
|
|
630
|
-
` Secret redaction is on (${SECRETS_ENABLED_ENV}=1), so output it could ` +
|
|
631
|
-
"not vet is withheld under either AGENT_SANITIZER_FAIL_OPEN posture.";
|
|
632
|
-
|
|
633
627
|
// This hook's entry in the one posture table (lib/hook-fault.mjs). OPEN is the
|
|
634
628
|
// shared default (a warning context, the original output left in the model's
|
|
635
629
|
// view); CLOSED replaces every string leaf of the output with the placeholder.
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
// unredacted credential to the model.
|
|
630
|
+
// Layer 4 follows the same knob: a redactor that cannot run (a dead daemon, a
|
|
631
|
+
// spent budget) leaves the output unvetted and says so, rather than blocking
|
|
632
|
+
// every tool call on an availability failure.
|
|
640
633
|
registerFaultPolicy(HOOK_NAME, {
|
|
641
634
|
event: HookEvent.POST_TOOL_USE,
|
|
642
635
|
guarded: "tool output",
|
|
643
|
-
alwaysClosed: secretsEnabled,
|
|
644
636
|
closed: (ctx) =>
|
|
645
|
-
failClosedParts(
|
|
646
|
-
ctx.input,
|
|
647
|
-
suppressionMessage(ctx.message) +
|
|
648
|
-
(ctx.pinnedClosed ? SECRETS_PIN_NOTE : ""),
|
|
649
|
-
ctx.remedy,
|
|
650
|
-
),
|
|
637
|
+
failClosedParts(ctx.input, suppressionMessage(ctx.message), ctx.remedy),
|
|
651
638
|
});
|
|
652
639
|
|
|
653
640
|
/**
|
|
@@ -28,7 +28,6 @@ import {
|
|
|
28
28
|
} from "./lib/hook-io.mjs";
|
|
29
29
|
import {
|
|
30
30
|
registerFaultPolicy,
|
|
31
|
-
KNOB_DECIDES,
|
|
32
31
|
hookFaultOutcome,
|
|
33
32
|
writeFaultOutcome,
|
|
34
33
|
} from "./lib/hook-fault.mjs";
|
|
@@ -86,7 +85,6 @@ const HOOK_NAME = "sanitize-user-prompt";
|
|
|
86
85
|
registerFaultPolicy(HOOK_NAME, {
|
|
87
86
|
event: HookEvent.USER_PROMPT_SUBMIT,
|
|
88
87
|
guarded: "prompt",
|
|
89
|
-
alwaysClosed: KNOB_DECIDES,
|
|
90
88
|
closed: (ctx) => ({
|
|
91
89
|
envelope: {
|
|
92
90
|
decision: "block",
|
|
@@ -145,9 +143,8 @@ export function judgeSanitizeUserPrompt(
|
|
|
145
143
|
const { Decision, EventKind } = controlPlane();
|
|
146
144
|
// A payload the adapter cannot classify carries no readable prompt, so an
|
|
147
145
|
// abstain would fail OPEN on harness contract drift; this gate's posture is
|
|
148
|
-
// deny-when-blind. (
|
|
149
|
-
//
|
|
150
|
-
// honors on UserPromptSubmit.)
|
|
146
|
+
// deny-when-blind. (runJudgeCli's renderEscalated blocks it with exit 2 and
|
|
147
|
+
// the reason on stderr, which Claude honors on UserPromptSubmit.)
|
|
151
148
|
if (event.event === EventKind.UNKNOWN)
|
|
152
149
|
return { decision: Decision.DENY, reason: messages.unknownEvent };
|
|
153
150
|
if (event.event !== EventKind.PROMPT_SUBMIT)
|
|
@@ -39,7 +39,6 @@ import {
|
|
|
39
39
|
} from "./lib/hook-io.mjs";
|
|
40
40
|
import {
|
|
41
41
|
registerFaultPolicy,
|
|
42
|
-
KNOB_DECIDES,
|
|
43
42
|
hookFaultOutcome,
|
|
44
43
|
writeFaultOutcome,
|
|
45
44
|
} from "./lib/hook-fault.mjs";
|
|
@@ -166,7 +165,6 @@ function faultLine(ctx) {
|
|
|
166
165
|
registerFaultPolicy(HOOK_NAME, {
|
|
167
166
|
event: HookEvent.SESSION_START,
|
|
168
167
|
guarded: "instruction files",
|
|
169
|
-
alwaysClosed: KNOB_DECIDES,
|
|
170
168
|
open: (ctx) => ({
|
|
171
169
|
stderr: `${faultLine(ctx)} Passing through unguarded; set AGENT_SANITIZER_FAIL_OPEN=0 to arm the tool-call gate instead.\n`,
|
|
172
170
|
exitCode: 1,
|
|
@@ -32,7 +32,6 @@ import {
|
|
|
32
32
|
} from "./lib/hook-io.mjs";
|
|
33
33
|
import {
|
|
34
34
|
registerFaultPolicy,
|
|
35
|
-
KNOB_DECIDES,
|
|
36
35
|
hookFaultOutcome,
|
|
37
36
|
writeFaultOutcome,
|
|
38
37
|
} from "./lib/hook-fault.mjs";
|
|
@@ -123,7 +122,6 @@ function emptyPayloadParts(ctx) {
|
|
|
123
122
|
registerFaultPolicy(HOOK_NAME, {
|
|
124
123
|
event: HookEvent.INSTRUCTIONS_LOADED,
|
|
125
124
|
guarded: "a loaded instruction file",
|
|
126
|
-
alwaysClosed: KNOB_DECIDES,
|
|
127
125
|
open: (ctx) =>
|
|
128
126
|
emptyPayloadParts(ctx) ?? {
|
|
129
127
|
stderr: `${faultLine(ctx)} Passing through unguarded; set AGENT_SANITIZER_FAIL_OPEN=0 to arm the tool-call gate instead.\n`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.68.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": {
|
|
@@ -227,7 +227,7 @@
|
|
|
227
227
|
"SECURITY.md"
|
|
228
228
|
],
|
|
229
229
|
"dependencies": {
|
|
230
|
-
"agent-control-plane-core": "0.
|
|
230
|
+
"agent-control-plane-core": "0.11.2",
|
|
231
231
|
"css-tree": "^3.2.1",
|
|
232
232
|
"hast-util-from-parse5": "8.0.3",
|
|
233
233
|
"mdast-util-gfm": "3.1.0",
|
package/src/output.mjs
CHANGED
|
@@ -174,18 +174,19 @@ function mutate(state, text, report) {
|
|
|
174
174
|
* Run Layer 4 (`options.redact`, when set) over the state's current text and
|
|
175
175
|
* fold any finding back in. The single Layer-4 invocation site FOR THE PIPELINE
|
|
176
176
|
* STATE: the first pass and the re-run after a span deletion are the same call,
|
|
177
|
-
* so they share one
|
|
177
|
+
* so they share one throw-on-fault handling, warning prose and invariants.
|
|
178
178
|
*
|
|
179
179
|
* One other site runs Layer 4 deliberately: {@link vetStageValue}, which vets a
|
|
180
180
|
* stage value on its way out and has no `PipelineState` to fold a finding into.
|
|
181
181
|
* It shares the post-redaction invariant (normalize what the redactor's own
|
|
182
|
-
* output may have stranded) but NOT the
|
|
183
|
-
*
|
|
182
|
+
* output may have stranded) but NOT the fault handling — it withholds the one
|
|
183
|
+
* field instead of throwing. Adding a third site means
|
|
184
184
|
* re-deciding both halves, so route through one of these two instead.
|
|
185
185
|
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
186
|
+
* Throws rather than returning unvetted text: a redactor we could not run might
|
|
187
|
+
* have let a secret through, so the seam never hands back a value as if it were
|
|
188
|
+
* vetted. The throw is rethrown wrapped and the CALLER's fault posture decides
|
|
189
|
+
* what reaches the model (the Claude Code hooks: AGENT_SANITIZER_FAIL_OPEN).
|
|
189
190
|
* @param {PipelineState} state
|
|
190
191
|
* @param {SanitizeTextOptions} options
|
|
191
192
|
* @returns {Promise<void>}
|
|
@@ -200,7 +201,7 @@ async function runRedact(state, options) {
|
|
|
200
201
|
} catch (l4err) {
|
|
201
202
|
throw new Error(
|
|
202
203
|
`CRITICAL: secret redaction failed (${errMessage(l4err)}). ` +
|
|
203
|
-
"
|
|
204
|
+
"This text was not vetted for secrets.",
|
|
204
205
|
{ cause: l4err },
|
|
205
206
|
);
|
|
206
207
|
}
|
|
@@ -599,8 +600,8 @@ async function vetStageValue(text, redact, findings, label) {
|
|
|
599
600
|
return secrets ? normalizeLoneSurrogates(secrets.text) : text;
|
|
600
601
|
} catch {
|
|
601
602
|
// A WARNING: the caller asked for this field and is not getting it, and the
|
|
602
|
-
// reason is an unrunnable redactor — the same fault that
|
|
603
|
-
//
|
|
603
|
+
// reason is an unrunnable redactor — the same fault that makes `cleaned`
|
|
604
|
+
// throw, just with a survivable remedy here.
|
|
604
605
|
findings.push(warning(withheldWarning(label)));
|
|
605
606
|
return undefined;
|
|
606
607
|
}
|
|
@@ -629,11 +630,10 @@ async function vetStageValue(text, redact, findings, label) {
|
|
|
629
630
|
|
|
630
631
|
/**
|
|
631
632
|
* Run the configured layers over a single text blob. Layer 1 always runs; the
|
|
632
|
-
* rest are opt-in via `options`. Layer 4 (`redact`)
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
* 5, below) — a redactor failure there fails the whole call closed too.
|
|
633
|
+
* rest are opt-in via `options`. A Layer 4 (`redact`) redactor that throws is
|
|
634
|
+
* rethrown wrapped, so the caller answers it under its fault posture rather
|
|
635
|
+
* than receiving unvetted text as vetted. The same holds for Layer 4's re-scan
|
|
636
|
+
* after a Layer-5 span deletion (see Layer 5, below).
|
|
637
637
|
* `reveal` is the pre-Layer-2 text, present only when the HTML splice removed
|
|
638
638
|
* bytes, so a caller can persist what was hidden for later inspection (see
|
|
639
639
|
* {@link applyMarkdownPipeline}); the field is omitted otherwise, and also when
|
|
@@ -677,7 +677,7 @@ export async function sanitizeText(text, options = {}) {
|
|
|
677
677
|
const { reveal: revealText, splices: stageSplices } =
|
|
678
678
|
await applyMarkdownPipeline(state, options);
|
|
679
679
|
|
|
680
|
-
// Layer 4 —
|
|
680
|
+
// Layer 4 — throws on a redactor fault (see runRedact).
|
|
681
681
|
await runRedact(state, options);
|
|
682
682
|
|
|
683
683
|
// Layer 5 — secure span-deletion slot (see module doc). A warning-only result
|
|
@@ -2,30 +2,33 @@
|
|
|
2
2
|
* The loaded control-plane bindings, narrowed to non-undefined — or a throw
|
|
3
3
|
* the calling hook's catch converts into its own failure posture. Overrides
|
|
4
4
|
* exist so tests can drive the unavailable arm in-process.
|
|
5
|
-
* @param {{ claudeAdapter?: unknown, Decision?: unknown, EventKind?: unknown }} [overrides]
|
|
5
|
+
* @param {{ claudeAdapter?: unknown, Decision?: unknown, EventKind?: unknown, renderEscalated?: unknown }} [overrides]
|
|
6
6
|
* @returns {{
|
|
7
7
|
* claudeAdapter: typeof import("agent-control-plane-core/claude").claudeAdapter,
|
|
8
8
|
* Decision: typeof import("agent-control-plane-core/contract").Decision,
|
|
9
9
|
* EventKind: typeof import("agent-control-plane-core/contract").EventKind,
|
|
10
|
+
* renderEscalated: typeof import("agent-control-plane-core/contract").renderEscalated,
|
|
10
11
|
* }}
|
|
11
12
|
*/
|
|
12
13
|
export function controlPlane(overrides?: {
|
|
13
14
|
claudeAdapter?: unknown;
|
|
14
15
|
Decision?: unknown;
|
|
15
16
|
EventKind?: unknown;
|
|
17
|
+
renderEscalated?: unknown;
|
|
16
18
|
}): {
|
|
17
19
|
claudeAdapter: typeof import("agent-control-plane-core/claude").claudeAdapter;
|
|
18
20
|
Decision: typeof import("agent-control-plane-core/contract").Decision;
|
|
19
21
|
EventKind: typeof import("agent-control-plane-core/contract").EventKind;
|
|
22
|
+
renderEscalated: typeof import("agent-control-plane-core/contract").renderEscalated;
|
|
20
23
|
};
|
|
21
24
|
/**
|
|
22
25
|
* Serialize a rendered NativeResponse for Claude Code's stdout, or null when
|
|
23
26
|
* the body carries nothing a silent exit 0 doesn't already say. The adapter's
|
|
24
27
|
* exit_code is deliberately NOT honored by the hooks: Claude Code parses hook
|
|
25
|
-
* stdout as JSON only on exit 0
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
28
|
+
* stdout as JSON only on exit 0, and that body is the one channel that carries
|
|
29
|
+
* every verdict field (updatedInput, additionalContext) beside the decision.
|
|
30
|
+
* For this host the stdout JSON's permissionDecision IS the enforcement
|
|
31
|
+
* channel, and a rendered verdict always exits 0.
|
|
29
32
|
* @param {{ stdout?: unknown }} response a NativeResponse from adapter.render
|
|
30
33
|
* @returns {string | null}
|
|
31
34
|
*/
|
|
@@ -38,8 +41,10 @@ export function nativeStdout(response: {
|
|
|
38
41
|
* native response. This encodes the two transport invariants every gate hook
|
|
39
42
|
* shares: stdin is read BEFORE the control-plane bindings are touched, so a
|
|
40
43
|
* package-load failure still lands in `onError` with the parsed input in hand;
|
|
41
|
-
* and
|
|
42
|
-
*
|
|
44
|
+
* and a verdict exits 0 in the stdout JSON (see nativeStdout). The verdict
|
|
45
|
+
* renders through the package's renderEscalated: a response with no stdout body
|
|
46
|
+
* that still blocks (a deny on a payload that names no event) blocks through its
|
|
47
|
+
* exit code and stderr instead. Any throw —
|
|
43
48
|
* unparsable stdin, missing package, a judge error — is reported on stderr and
|
|
44
49
|
* routed to `onError(err, input)` (`input` undefined when stdin never parsed),
|
|
45
50
|
* where the hook applies its declared fail posture.
|
|
@@ -57,15 +62,19 @@ export function nativeStdout(response: {
|
|
|
57
62
|
* @param {(input: unknown) => unknown} [opts.transformInput] raw-payload normalization before adapter.parse
|
|
58
63
|
* @param {() => Promise<unknown>} [opts.readInput] injectable stdin reader
|
|
59
64
|
* @param {(chunk: string) => void} [opts.write] injectable stdout writer
|
|
65
|
+
* @param {(chunk: string) => void} [opts.writeErr] injectable stderr writer
|
|
66
|
+
* @param {(code: number) => void} [opts.setExitCode] injectable exit-code setter
|
|
60
67
|
* @param {import("./hook-timing.mjs").ReportTo} [opts.reportTo] the composer
|
|
61
68
|
* that owns this hook, which a slow-run notice names and sends the report to;
|
|
62
69
|
* omitted, the notice names this package
|
|
63
70
|
* @returns {Promise<void>}
|
|
64
71
|
*/
|
|
65
|
-
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, reportTo, }: {
|
|
72
|
+
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, writeErr, setExitCode, reportTo, }: {
|
|
66
73
|
onError: (err: unknown, input: unknown) => void;
|
|
67
74
|
transformInput?: ((input: unknown) => unknown) | undefined;
|
|
68
75
|
readInput?: (() => Promise<unknown>) | undefined;
|
|
69
76
|
write?: ((chunk: string) => void) | undefined;
|
|
77
|
+
writeErr?: ((chunk: string) => void) | undefined;
|
|
78
|
+
setExitCode?: ((code: number) => void) | undefined;
|
|
70
79
|
reportTo?: import("./hook-timing.mjs").ReportTo | undefined;
|
|
71
80
|
}): Promise<void>;
|
|
@@ -32,8 +32,7 @@ export function defaultOpen(ctx: FaultContext): FaultParts;
|
|
|
32
32
|
/**
|
|
33
33
|
* Resolve `hook`'s response to its own failure under the caller's posture. This
|
|
34
34
|
* is the ONLY place {@link failOpenEnabled} is consulted on a hook fault, so the
|
|
35
|
-
* knob cannot be honored in three hooks and skipped in the fourth
|
|
36
|
-
* only place a policy's `alwaysClosed` can override it.
|
|
35
|
+
* knob cannot be honored in three hooks and skipped in the fourth.
|
|
37
36
|
* @param {string} hook
|
|
38
37
|
* @param {unknown} err
|
|
39
38
|
* @param {Record<string, any> & {
|
|
@@ -65,63 +64,6 @@ export function writeFaultOutcome(outcome: FaultOutcome, write?: (chunk: string)
|
|
|
65
64
|
* @type {readonly string[]}
|
|
66
65
|
*/
|
|
67
66
|
export const FAULT_POLICY_HOOKS: readonly string[];
|
|
68
|
-
/**
|
|
69
|
-
* What a hook's fault renders to. `fields`/`envelope` are the stdout response
|
|
70
|
-
* (`fields` is the `hookSpecificOutput` body, wrapped with the policy's event;
|
|
71
|
-
* `envelope` is a hook that answers with a top-level shape instead, like
|
|
72
|
-
* UserPromptSubmit's `{decision:"block"}`); `stderr` and `exitCode` are the
|
|
73
|
-
* process-level halves a hook with no stdout channel uses; `armAlert` asks the
|
|
74
|
-
* caller to persist its cross-hook alert so a LATER gate carries the closed
|
|
75
|
-
* posture the faulting hook could not express itself.
|
|
76
|
-
* @typedef {{
|
|
77
|
-
* posture: "open" | "closed",
|
|
78
|
-
* fields: Record<string, unknown> | null,
|
|
79
|
-
* fallbackFields: Record<string, unknown> | null,
|
|
80
|
-
* envelope: object | null,
|
|
81
|
-
* stderr: string | null,
|
|
82
|
-
* exitCode: number,
|
|
83
|
-
* armAlert: boolean,
|
|
84
|
-
* }} FaultOutcome
|
|
85
|
-
*/
|
|
86
|
-
/**
|
|
87
|
-
* What a policy's `open`/`closed` builder returns: any subset of a
|
|
88
|
-
* {@link FaultOutcome}'s renderable slots. Everything omitted takes its default
|
|
89
|
-
* (no output, exit 0, no alert).
|
|
90
|
-
* @typedef {{
|
|
91
|
-
* fields?: Record<string, unknown>,
|
|
92
|
-
* fallbackFields?: Record<string, unknown>,
|
|
93
|
-
* envelope?: object,
|
|
94
|
-
* stderr?: string,
|
|
95
|
-
* exitCode?: number,
|
|
96
|
-
* armAlert?: boolean,
|
|
97
|
-
* }} FaultParts
|
|
98
|
-
*/
|
|
99
|
-
/**
|
|
100
|
-
* The context a builder is handed: the caller's own inputs (whatever it passed
|
|
101
|
-
* to {@link hookFaultOutcome}) plus the values every hook derived by hand
|
|
102
|
-
* before — the hook name, the scrubbed error message, and the model-facing
|
|
103
|
-
* open-posture warning — and `pinnedClosed`, true when the policy's
|
|
104
|
-
* `alwaysClosed` overrode an open knob, so the closed arm can say why.
|
|
105
|
-
* @typedef {Record<string, any> & {
|
|
106
|
-
* hook: string,
|
|
107
|
-
* err: unknown,
|
|
108
|
-
* message: string,
|
|
109
|
-
* openContext: string,
|
|
110
|
-
* pinnedClosed: boolean,
|
|
111
|
-
* }} FaultContext
|
|
112
|
-
*/
|
|
113
|
-
/**
|
|
114
|
-
* Whether a hook's faults are withheld whatever AGENT_SANITIZER_FAIL_OPEN says,
|
|
115
|
-
* for the caller's environment.
|
|
116
|
-
* @typedef {(
|
|
117
|
-
* env: NodeJS.ProcessEnv | Record<string, string | undefined> | undefined,
|
|
118
|
-
* ) => boolean} AlwaysClosed
|
|
119
|
-
*/
|
|
120
|
-
/**
|
|
121
|
-
* The `alwaysClosed` of a hook whose posture the knob alone decides.
|
|
122
|
-
* @type {AlwaysClosed}
|
|
123
|
-
*/
|
|
124
|
-
export const KNOB_DECIDES: AlwaysClosed;
|
|
125
67
|
/**
|
|
126
68
|
* What a hook's fault renders to. `fields`/`envelope` are the stdout response
|
|
127
69
|
* (`fields` is the `hookSpecificOutput` body, wrapped with the policy's event;
|
|
@@ -157,32 +99,23 @@ export type FaultParts = {
|
|
|
157
99
|
* The context a builder is handed: the caller's own inputs (whatever it passed
|
|
158
100
|
* to {@link hookFaultOutcome}) plus the values every hook derived by hand
|
|
159
101
|
* before — the hook name, the scrubbed error message, and the model-facing
|
|
160
|
-
* open-posture warning
|
|
161
|
-
* `alwaysClosed` overrode an open knob, so the closed arm can say why.
|
|
102
|
+
* open-posture warning.
|
|
162
103
|
*/
|
|
163
104
|
export type FaultContext = Record<string, any> & {
|
|
164
105
|
hook: string;
|
|
165
106
|
err: unknown;
|
|
166
107
|
message: string;
|
|
167
108
|
openContext: string;
|
|
168
|
-
pinnedClosed: boolean;
|
|
169
109
|
};
|
|
170
110
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* A hook's declared posture. `alwaysClosed` is REQUIRED, so every hook states
|
|
177
|
-
* on the record whether the knob can open it: {@link KNOB_DECIDES}, or a
|
|
178
|
-
* predicate that pins the CLOSED arm — how a guarantee the docs make
|
|
179
|
-
* unconditionally (Layer 4 withholds what it could not vet) is enforced in the
|
|
180
|
-
* one posture resolver rather than by a hook re-reading the knob.
|
|
111
|
+
* A hook's declared posture. There is deliberately no way for a policy to pin
|
|
112
|
+
* the CLOSED arm: AGENT_SANITIZER_FAIL_OPEN alone decides, so no hook fault can
|
|
113
|
+
* withhold a tool output or block a prompt unless the operator set the knob to
|
|
114
|
+
* `0`. A pin turns any bug in the guarded layer into every tool call failing.
|
|
181
115
|
*/
|
|
182
116
|
export type FaultPolicy = {
|
|
183
117
|
event: string | null;
|
|
184
118
|
guarded: string;
|
|
185
|
-
alwaysClosed: AlwaysClosed;
|
|
186
119
|
open?: (ctx: FaultContext) => FaultParts;
|
|
187
120
|
closed: (ctx: FaultContext) => FaultParts;
|
|
188
121
|
};
|