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
package/THREAT-MODEL.md
CHANGED
|
@@ -298,16 +298,17 @@ plugin injects the Python engine from `agent-sanitizer[secrets]`
|
|
|
298
298
|
(`python/agent_sanitizer/secrets/engine.py`) over a local daemon;
|
|
299
299
|
`plugin/scripts/provision-redactor.sh` installs it at SessionStart.
|
|
300
300
|
|
|
301
|
-
**
|
|
302
|
-
daemon, engine error — is rethrown from
|
|
303
|
-
redaction failed …` (`src/output.mjs`)
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
301
|
+
**A redactor fault follows the operator's posture.** A redactor that throws —
|
|
302
|
+
unreachable daemon, engine error, spent budget — is rethrown from
|
|
303
|
+
`sanitizeValue` as `CRITICAL: secret redaction failed …` (`src/output.mjs`), so
|
|
304
|
+
the seam never returns unvetted text as if it were vetted. The output hook then
|
|
305
|
+
answers it like any other fault, under `AGENT_SANITIZER_FAIL_OPEN`
|
|
306
|
+
(`lib/hook-fault.mjs`, the one posture resolver, which has no per-hook override):
|
|
307
|
+
open by default — the output reaches the model with an UNSANITIZED warning, and
|
|
308
|
+
the operator's terminal names the redactor failure — and withheld under `=0`.
|
|
309
|
+
Accepted risk of the default: while the redactor is down, a credential in tool
|
|
310
|
+
output reaches the model unredacted. The alternative blocks every tool call on
|
|
311
|
+
an availability failure, which is what a deployment chooses with `=0`.
|
|
311
312
|
|
|
312
313
|
**Lone surrogates are normalized to U+FFFD before the redactor sees the text.**
|
|
313
314
|
A secret split by an interposed lone surrogate renders as contiguous to the
|
|
@@ -608,9 +609,9 @@ RECONSTITUTES during stripping is judged as the sequence it becomes.
|
|
|
608
609
|
Layer order: 1 → 2–3 → 4 → 5 → 4, the second Layer 4 a re-scan of what Layer 5's
|
|
609
610
|
deletions left. Shape matters
|
|
610
611
|
(a harness that gets a shape-mismatched value silently shows the raw output).
|
|
611
|
-
Layer 4 (secret redaction) is an **injected** redactor
|
|
612
|
-
|
|
613
|
-
caller
|
|
612
|
+
Layer 4 (secret redaction) is an **injected** redactor: a redactor that throws
|
|
613
|
+
makes the pipeline rethrow rather than return an unvetted value, and the
|
|
614
|
+
caller's posture decides whether the output is suppressed. That
|
|
614
615
|
suppression (`suppressToolOutput`) replaces string leaves in place so the
|
|
615
616
|
placeholder still matches the tool's shape, with one exception it must make: an
|
|
616
617
|
Anthropic **content block** is collapsed whole to `{ type: "text", text: … }`
|
|
@@ -977,14 +978,13 @@ to hold precision. All other faults keep the open default, and
|
|
|
977
978
|
|
|
978
979
|
**The open default is not enforceable against content.** Several of those
|
|
979
980
|
failures are composable by whoever authored the payload — in the output hook
|
|
980
|
-
alone, a nesting depth that overflows the sanitize walk
|
|
981
|
-
exhausted by many secret-shaped leaves
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
not walked leaf-wise: a shape-preserving walk rewrites only string leaves, so a
|
|
981
|
+
alone, a nesting depth that overflows the sanitize walk, a redaction budget
|
|
982
|
+
exhausted by many secret-shaped leaves, and a payload large enough to blow the
|
|
983
|
+
redactor daemon's scan budget. (A **key collision** — two field names that
|
|
984
|
+
collapse to one after Layer 1 — does not fail the hook: only the colliding
|
|
985
|
+
fields are withheld, and every sibling field survives. Both colliding values
|
|
986
|
+
are replaced **whole** by a marker string, not walked leaf-wise: a
|
|
987
|
+
shape-preserving walk rewrites only string leaves, so a
|
|
988
988
|
colliding number or boolean would reach the model verbatim under a legitimate
|
|
989
989
|
field name while the warning claimed it was withheld. That changes the field's
|
|
990
990
|
JSON type, which is accepted because a duplicate name is off-schema by
|
|
@@ -37,6 +37,8 @@ let claudeAdapter;
|
|
|
37
37
|
let Decision;
|
|
38
38
|
/** @type {typeof import("agent-control-plane-core/contract").EventKind | undefined} */
|
|
39
39
|
let EventKind;
|
|
40
|
+
/** @type {typeof import("agent-control-plane-core/contract").renderEscalated | undefined} */
|
|
41
|
+
let renderEscalated;
|
|
40
42
|
|
|
41
43
|
/* c8 ignore start -- module-load boundary: the real import resolves in every
|
|
42
44
|
in-process test and spawned CLI run, and a missing node_modules can't be
|
|
@@ -55,26 +57,35 @@ const loaded = await awaitLazyDependency({
|
|
|
55
57
|
);
|
|
56
58
|
// The /contract subpath, not the package root: the root barrel also pulls
|
|
57
59
|
// all four adapters, the registry and the conformance harness, which costs
|
|
58
|
-
// ~22 ms of import work per process for
|
|
59
|
-
// process per tool call, so that cost lands on every gated call.
|
|
60
|
-
const {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
// ~22 ms of import work per process for three bindings. A guardrail hook is
|
|
61
|
+
// one process per tool call, so that cost lands on every gated call.
|
|
62
|
+
const {
|
|
63
|
+
Decision: decision,
|
|
64
|
+
EventKind: eventKind,
|
|
65
|
+
renderEscalated: escalated,
|
|
66
|
+
} = /** @type {Partial<typeof import("agent-control-plane-core/contract")>} */ (
|
|
67
|
+
await lazyImport("agent-control-plane-core/contract")
|
|
68
|
+
);
|
|
69
|
+
if (!adapter || !decision || !eventKind || !escalated) return null;
|
|
70
|
+
return {
|
|
71
|
+
claudeAdapter: adapter,
|
|
72
|
+
Decision: decision,
|
|
73
|
+
EventKind: eventKind,
|
|
74
|
+
renderEscalated: escalated,
|
|
75
|
+
};
|
|
66
76
|
},
|
|
67
77
|
markerPresent: () => markerIsTrusted(marker),
|
|
68
78
|
setupAlive: () => probeSetupAlive(marker),
|
|
69
79
|
});
|
|
70
80
|
if (loaded) {
|
|
71
81
|
const bound =
|
|
72
|
-
/** @type {{ claudeAdapter: typeof claudeAdapter, Decision: typeof Decision, EventKind: typeof EventKind }} */ (
|
|
82
|
+
/** @type {{ claudeAdapter: typeof claudeAdapter, Decision: typeof Decision, EventKind: typeof EventKind, renderEscalated: typeof renderEscalated }} */ (
|
|
73
83
|
loaded
|
|
74
84
|
);
|
|
75
85
|
claudeAdapter = bound.claudeAdapter;
|
|
76
86
|
Decision = bound.Decision;
|
|
77
87
|
EventKind = bound.EventKind;
|
|
88
|
+
renderEscalated = bound.renderEscalated;
|
|
78
89
|
}
|
|
79
90
|
// Stryker restore all
|
|
80
91
|
/* c8 ignore stop */
|
|
@@ -83,16 +94,28 @@ if (loaded) {
|
|
|
83
94
|
* The loaded control-plane bindings, narrowed to non-undefined — or a throw
|
|
84
95
|
* the calling hook's catch converts into its own failure posture. Overrides
|
|
85
96
|
* exist so tests can drive the unavailable arm in-process.
|
|
86
|
-
* @param {{ claudeAdapter?: unknown, Decision?: unknown, EventKind?: unknown }} [overrides]
|
|
97
|
+
* @param {{ claudeAdapter?: unknown, Decision?: unknown, EventKind?: unknown, renderEscalated?: unknown }} [overrides]
|
|
87
98
|
* @returns {{
|
|
88
99
|
* claudeAdapter: typeof import("agent-control-plane-core/claude").claudeAdapter,
|
|
89
100
|
* Decision: typeof import("agent-control-plane-core/contract").Decision,
|
|
90
101
|
* EventKind: typeof import("agent-control-plane-core/contract").EventKind,
|
|
102
|
+
* renderEscalated: typeof import("agent-control-plane-core/contract").renderEscalated,
|
|
91
103
|
* }}
|
|
92
104
|
*/
|
|
93
105
|
export function controlPlane(overrides = {}) {
|
|
94
|
-
const bindings = {
|
|
95
|
-
|
|
106
|
+
const bindings = {
|
|
107
|
+
claudeAdapter,
|
|
108
|
+
Decision,
|
|
109
|
+
EventKind,
|
|
110
|
+
renderEscalated,
|
|
111
|
+
...overrides,
|
|
112
|
+
};
|
|
113
|
+
if (
|
|
114
|
+
!bindings.claudeAdapter ||
|
|
115
|
+
!bindings.Decision ||
|
|
116
|
+
!bindings.EventKind ||
|
|
117
|
+
!bindings.renderEscalated
|
|
118
|
+
)
|
|
96
119
|
throw missingPackageError("agent-control-plane-core");
|
|
97
120
|
return /** @type {ReturnType<typeof controlPlane>} */ (bindings);
|
|
98
121
|
}
|
|
@@ -101,10 +124,10 @@ export function controlPlane(overrides = {}) {
|
|
|
101
124
|
* Serialize a rendered NativeResponse for Claude Code's stdout, or null when
|
|
102
125
|
* the body carries nothing a silent exit 0 doesn't already say. The adapter's
|
|
103
126
|
* exit_code is deliberately NOT honored by the hooks: Claude Code parses hook
|
|
104
|
-
* stdout as JSON only on exit 0
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
127
|
+
* stdout as JSON only on exit 0, and that body is the one channel that carries
|
|
128
|
+
* every verdict field (updatedInput, additionalContext) beside the decision.
|
|
129
|
+
* For this host the stdout JSON's permissionDecision IS the enforcement
|
|
130
|
+
* channel, and a rendered verdict always exits 0.
|
|
108
131
|
* @param {{ stdout?: unknown }} response a NativeResponse from adapter.render
|
|
109
132
|
* @returns {string | null}
|
|
110
133
|
*/
|
|
@@ -133,8 +156,10 @@ export function nativeStdout(response) {
|
|
|
133
156
|
* native response. This encodes the two transport invariants every gate hook
|
|
134
157
|
* shares: stdin is read BEFORE the control-plane bindings are touched, so a
|
|
135
158
|
* package-load failure still lands in `onError` with the parsed input in hand;
|
|
136
|
-
* and
|
|
137
|
-
*
|
|
159
|
+
* and a verdict exits 0 in the stdout JSON (see nativeStdout). The verdict
|
|
160
|
+
* renders through the package's renderEscalated: a response with no stdout body
|
|
161
|
+
* that still blocks (a deny on a payload that names no event) blocks through its
|
|
162
|
+
* exit code and stderr instead. Any throw —
|
|
138
163
|
* unparsable stdin, missing package, a judge error — is reported on stderr and
|
|
139
164
|
* routed to `onError(err, input)` (`input` undefined when stdin never parsed),
|
|
140
165
|
* where the hook applies its declared fail posture.
|
|
@@ -152,6 +177,8 @@ export function nativeStdout(response) {
|
|
|
152
177
|
* @param {(input: unknown) => unknown} [opts.transformInput] raw-payload normalization before adapter.parse
|
|
153
178
|
* @param {() => Promise<unknown>} [opts.readInput] injectable stdin reader
|
|
154
179
|
* @param {(chunk: string) => void} [opts.write] injectable stdout writer
|
|
180
|
+
* @param {(chunk: string) => void} [opts.writeErr] injectable stderr writer
|
|
181
|
+
* @param {(code: number) => void} [opts.setExitCode] injectable exit-code setter
|
|
155
182
|
* @param {import("./hook-timing.mjs").ReportTo} [opts.reportTo] the composer
|
|
156
183
|
* that owns this hook, which a slow-run notice names and sends the report to;
|
|
157
184
|
* omitted, the notice names this package
|
|
@@ -165,6 +192,10 @@ export async function runJudgeCli(
|
|
|
165
192
|
transformInput = (raw) => raw,
|
|
166
193
|
readInput = readStdinJson,
|
|
167
194
|
write = (chunk) => process.stdout.write(chunk),
|
|
195
|
+
writeErr = (chunk) => process.stderr.write(chunk),
|
|
196
|
+
setExitCode = (code) => {
|
|
197
|
+
process.exitCode = code;
|
|
198
|
+
},
|
|
168
199
|
reportTo,
|
|
169
200
|
},
|
|
170
201
|
) {
|
|
@@ -190,26 +221,30 @@ export async function runJudgeCli(
|
|
|
190
221
|
// over stdin is not this hook's cost, and blaming it for one would send
|
|
191
222
|
// operators chasing a bug report that is not theirs to fix.
|
|
192
223
|
timer = startHookTimer();
|
|
193
|
-
const { claudeAdapter: adapter } = controlPlane();
|
|
224
|
+
const { claudeAdapter: adapter, renderEscalated: render } = controlPlane();
|
|
194
225
|
const event = adapter.parse(transformInput(input));
|
|
195
226
|
tool = event.tool ?? null;
|
|
196
227
|
// Awaited into its own binding first: as an inline argument, the timer read
|
|
197
228
|
// would be evaluated BEFORE the judge it is supposed to be timing.
|
|
198
229
|
const judged = await judge(event);
|
|
199
|
-
const
|
|
200
|
-
adapter
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
),
|
|
230
|
+
const response = render(
|
|
231
|
+
adapter,
|
|
232
|
+
withSlowHookNotice(hookName, timer.wallMs(), judged, undefined, {
|
|
233
|
+
payloadBytes,
|
|
234
|
+
tool,
|
|
235
|
+
cpuMs: timer.cpuMs(),
|
|
236
|
+
redactorMs: timer.redactorMs(),
|
|
237
|
+
hostMs: timer.hostMs(),
|
|
238
|
+
reportTo,
|
|
239
|
+
}),
|
|
240
|
+
event,
|
|
211
241
|
);
|
|
242
|
+
const out = nativeStdout(response);
|
|
212
243
|
if (out !== null) write(out);
|
|
244
|
+
else if (response.enforced) {
|
|
245
|
+
writeErr(`${response.stderr}\n`);
|
|
246
|
+
setExitCode(response.exit_code);
|
|
247
|
+
}
|
|
213
248
|
} catch (err) {
|
|
214
249
|
process.stderr.write(`${hookName} hook error: ${errMessage(err)}\n`);
|
|
215
250
|
// After the error line, before the posture: the fault is what the reader
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* esbuild inlines them into the plugin bundle (which ships with no config
|
|
16
16
|
* directory beside it) and Node resolves them natively from the package when the
|
|
17
17
|
* hooks run from source. Their VALIDATION stays lazy — a malformed credential
|
|
18
|
-
* vocabulary throws on first use, inside the consuming hook's
|
|
18
|
+
* vocabulary throws on first use, inside the consuming hook's fault catch,
|
|
19
19
|
* rather than at module load where a throw would abort before that catch installs
|
|
20
20
|
* and let the harness pass the tool output through UNSANITIZED (fail OPEN).
|
|
21
21
|
*/
|
|
@@ -72,7 +72,7 @@ export function configureEnvConfigSource(source) {
|
|
|
72
72
|
/**
|
|
73
73
|
* The stored host source with its shape validated, or null when unconfigured.
|
|
74
74
|
* Shape errors — a non-object source, a key this seam does not read — throw
|
|
75
|
-
* HERE, on first use inside the consuming hook's
|
|
75
|
+
* HERE, on first use inside the consuming hook's fault catch, never at
|
|
76
76
|
* configure time: a top-level throw in a bundle entry would kill the hook
|
|
77
77
|
* before that catch installs (fail OPEN), and silently ignoring the problem
|
|
78
78
|
* is worse — a typo'd `minSecretLength: 32` must not leave the helpers
|
|
@@ -85,41 +85,23 @@ export const FAULT_POLICY_HOOKS = Object.freeze([
|
|
|
85
85
|
* The context a builder is handed: the caller's own inputs (whatever it passed
|
|
86
86
|
* to {@link hookFaultOutcome}) plus the values every hook derived by hand
|
|
87
87
|
* before — the hook name, the scrubbed error message, and the model-facing
|
|
88
|
-
* open-posture warning
|
|
89
|
-
* `alwaysClosed` overrode an open knob, so the closed arm can say why.
|
|
88
|
+
* open-posture warning.
|
|
90
89
|
* @typedef {Record<string, any> & {
|
|
91
90
|
* hook: string,
|
|
92
91
|
* err: unknown,
|
|
93
92
|
* message: string,
|
|
94
93
|
* openContext: string,
|
|
95
|
-
* pinnedClosed: boolean,
|
|
96
94
|
* }} FaultContext
|
|
97
95
|
*/
|
|
98
96
|
|
|
99
97
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* ) => boolean} AlwaysClosed
|
|
105
|
-
*/
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* The `alwaysClosed` of a hook whose posture the knob alone decides.
|
|
109
|
-
* @type {AlwaysClosed}
|
|
110
|
-
*/
|
|
111
|
-
export const KNOB_DECIDES = Object.freeze(() => false);
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* A hook's declared posture. `alwaysClosed` is REQUIRED, so every hook states
|
|
115
|
-
* on the record whether the knob can open it: {@link KNOB_DECIDES}, or a
|
|
116
|
-
* predicate that pins the CLOSED arm — how a guarantee the docs make
|
|
117
|
-
* unconditionally (Layer 4 withholds what it could not vet) is enforced in the
|
|
118
|
-
* one posture resolver rather than by a hook re-reading the knob.
|
|
98
|
+
* A hook's declared posture. There is deliberately no way for a policy to pin
|
|
99
|
+
* the CLOSED arm: AGENT_SANITIZER_FAIL_OPEN alone decides, so no hook fault can
|
|
100
|
+
* withhold a tool output or block a prompt unless the operator set the knob to
|
|
101
|
+
* `0`. A pin turns any bug in the guarded layer into every tool call failing.
|
|
119
102
|
* @typedef {{
|
|
120
103
|
* event: string | null,
|
|
121
104
|
* guarded: string,
|
|
122
|
-
* alwaysClosed: AlwaysClosed,
|
|
123
105
|
* open?: (ctx: FaultContext) => FaultParts,
|
|
124
106
|
* closed: (ctx: FaultContext) => FaultParts,
|
|
125
107
|
* }} FaultPolicy
|
|
@@ -143,12 +125,10 @@ export function registerFaultPolicy(hook, policy) {
|
|
|
143
125
|
);
|
|
144
126
|
// Checked here, not at fault time: a posture that is incomplete must fail the
|
|
145
127
|
// import, never the one fault it was supposed to answer.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
"every hook states it explicitly (KNOB_DECIDES for alwaysClosed)",
|
|
151
|
-
);
|
|
128
|
+
if (typeof policy.closed !== "function")
|
|
129
|
+
throw new Error(
|
|
130
|
+
`fault policy for ${JSON.stringify(hook)} declares no \`closed\` arm`,
|
|
131
|
+
);
|
|
152
132
|
policies.set(hook, policy);
|
|
153
133
|
}
|
|
154
134
|
|
|
@@ -190,8 +170,7 @@ export function defaultOpen(ctx) {
|
|
|
190
170
|
/**
|
|
191
171
|
* Resolve `hook`'s response to its own failure under the caller's posture. This
|
|
192
172
|
* is the ONLY place {@link failOpenEnabled} is consulted on a hook fault, so the
|
|
193
|
-
* knob cannot be honored in three hooks and skipped in the fourth
|
|
194
|
-
* only place a policy's `alwaysClosed` can override it.
|
|
173
|
+
* knob cannot be honored in three hooks and skipped in the fourth.
|
|
195
174
|
* @param {string} hook
|
|
196
175
|
* @param {unknown} err
|
|
197
176
|
* @param {Record<string, any> & {
|
|
@@ -204,9 +183,7 @@ export function defaultOpen(ctx) {
|
|
|
204
183
|
*/
|
|
205
184
|
export function hookFaultOutcome(hook, err, ctx = {}) {
|
|
206
185
|
const policy = faultPolicy(hook);
|
|
207
|
-
const
|
|
208
|
-
const pinnedClosed = knobOpen && policy.alwaysClosed(ctx.env);
|
|
209
|
-
const open = knobOpen && !pinnedClosed;
|
|
186
|
+
const open = failOpenEnabled(ctx.env);
|
|
210
187
|
/** @type {FaultContext} */
|
|
211
188
|
const full = {
|
|
212
189
|
...ctx,
|
|
@@ -214,7 +191,6 @@ export function hookFaultOutcome(hook, err, ctx = {}) {
|
|
|
214
191
|
err,
|
|
215
192
|
message: safeErrMessage(err),
|
|
216
193
|
openContext: failOpenContext(hook, policy.guarded, err),
|
|
217
|
-
pinnedClosed,
|
|
218
194
|
};
|
|
219
195
|
const parts = (open ? (policy.open ?? defaultOpen) : policy.closed)(full);
|
|
220
196
|
const fields = parts.fields ?? null;
|
|
@@ -17,8 +17,16 @@
|
|
|
17
17
|
* EXTENSION it called ({@link chargeHostExtension}) — the redactor daemon and a
|
|
18
18
|
* host callback's subprocess or socket peer are separate processes whose CPU this
|
|
19
19
|
* one cannot see (see {@link processCpuMs}), so the call that waits for each is
|
|
20
|
-
* the only measurable stand-in.
|
|
21
|
-
*
|
|
20
|
+
* the only measurable stand-in. A caller that supplies the CPU and redactor
|
|
21
|
+
* figures is reported only when the share its owner answers for — CPU plus
|
|
22
|
+
* redactor round trips plus host extensions — itself exceeds the budget; wall
|
|
23
|
+
* time past that is the machine's, and neither a notice nor a report request is
|
|
24
|
+
* the answer to it. Redactor wait counts toward that share, so a hook wedged on a
|
|
25
|
+
* dead redactor socket (no CPU, all wait) is still reported. A wait on a
|
|
26
|
+
* subprocess this package spawns itself (a `git` probe, a lock) is charged to
|
|
27
|
+
* no window, so it reads as the machine's and goes unreported — the accepted
|
|
28
|
+
* cost of never blaming the sanitizer for a loaded host. A caller that supplies
|
|
29
|
+
* no attribution is gated on wall-clock alone.
|
|
22
30
|
*
|
|
23
31
|
* The host-extension window is what turned "blocked on something outside the
|
|
24
32
|
* sanitizer" — a verdict nobody can act on — into a named callee: a composer's
|
|
@@ -598,6 +606,11 @@ export function startHookTimer(now = Date.now, cpuNow = processCpuMs) {
|
|
|
598
606
|
* The CPU, redactor and host shares overlap by the framing this side does
|
|
599
607
|
* mid-round-trip and mid-callback, so they do not sum to the elapsed time; a
|
|
600
608
|
* share that dominates despite the overlap is still the one to act on.
|
|
609
|
+
*
|
|
610
|
+
* Reached only for a run whose owned share already exceeds the budget
|
|
611
|
+
* ({@link slowHookNotice}), so the "none of them" verdict means the unmeasured
|
|
612
|
+
* remainder is larger than a share that is itself over budget — not that the
|
|
613
|
+
* hook's own cost was small.
|
|
601
614
|
* @param {number} elapsedMs
|
|
602
615
|
* @param {number} cpuMs
|
|
603
616
|
* @param {number} redactorMs
|
|
@@ -646,10 +659,15 @@ function attributeWait(elapsedMs, cpuMs, redactorMs, hostMs, owner) {
|
|
|
646
659
|
* sanitizer computing, and with `context.redactorMs` too it names the window the
|
|
647
660
|
* time went into ({@link attributeWait}), including `context.hostMs`'s host
|
|
648
661
|
* extensions — a caller that measured the first two but has no extensions to
|
|
649
|
-
* charge reports that window as zero, which is a measurement and not a shrug.
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
*
|
|
662
|
+
* charge reports that window as zero, which is a measurement and not a shrug.
|
|
663
|
+
* Such a caller is reported only when `cpuMs + redactorMs + hostMs` (an absent
|
|
664
|
+
* `hostMs` counting as 0) exceeds `thresholdMs`: a wall-clock overrun whose
|
|
665
|
+
* owned share is within the budget is the machine's and returns null, so the
|
|
666
|
+
* operator is never asked to report a cost the owner did not spend. Without the
|
|
667
|
+
* measurements the line says that it cannot tell, rather than asserting an
|
|
668
|
+
* attribution nothing measured: a wall-clock overrun on a loaded host is the
|
|
669
|
+
* common case, and blaming it on the sanitizer sends the operator hunting a
|
|
670
|
+
* per-call cost that does not exist.
|
|
653
671
|
*
|
|
654
672
|
* A CPU figure alone cannot pick a cause, so with only that the clause names
|
|
655
673
|
* candidates and commits to none. A hook that blocks on a dead socket inside a
|
|
@@ -684,6 +702,8 @@ export function slowHookNotice(
|
|
|
684
702
|
// number, including when it is 0.
|
|
685
703
|
const hostMs =
|
|
686
704
|
typeof context?.hostMs === "number" ? context.hostMs : undefined;
|
|
705
|
+
if (attributed && cpuMs + redactorMs + (hostMs ?? 0) <= thresholdMs)
|
|
706
|
+
return null;
|
|
687
707
|
const owner = noticeOwner(context?.reportTo, version);
|
|
688
708
|
const attribution = attributed
|
|
689
709
|
? attributeWait(elapsedMs, cpuMs, redactorMs, hostMs, owner)
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* request is just a scan. detect-secrets stays the one and only detection engine
|
|
7
7
|
* — this module never inspects the text itself.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* retries from scratch. If the socket is absent or dead we (re)spawn the daemon
|
|
9
|
+
* Per call only: a connection/protocol/scan failure throws, and the caller
|
|
10
|
+
* answers THAT output under its fault posture; it sets no session-wide state,
|
|
11
|
+
* so the next call retries from scratch. If the socket is absent or dead we (re)spawn the daemon
|
|
12
12
|
* once and retry — a crashed daemon self-heals on the next redaction.
|
|
13
13
|
*
|
|
14
14
|
* Wire protocol: python/agent_sanitizer/secrets/data/redactor-protocol.json,
|
|
@@ -54,7 +54,7 @@ const DAEMON_REASON = /^[\x20-\x7e]{1,200}$/u;
|
|
|
54
54
|
export { positiveMsOr } from "./knobs.mjs";
|
|
55
55
|
|
|
56
56
|
// sun_path is 104 bytes on macOS (108 on Linux), NUL included; a longer path
|
|
57
|
-
// makes the daemon's bind() fail and every call
|
|
57
|
+
// makes the daemon's bind() fail and every call fault. The daemon binds
|
|
58
58
|
// "." + 16 hex digits before renaming onto "redactor.sock", so the longer
|
|
59
59
|
// 17-byte name is what must fit.
|
|
60
60
|
const SOCKET_PATH_MAX = 103;
|
|
@@ -100,7 +100,7 @@ export const DEFAULT_SOCKET_PATH = resolveSocketPath(process.env, currentUid());
|
|
|
100
100
|
*
|
|
101
101
|
* 1. `_AGENT_SANITIZER_REDACTOR_DAEMON` — authoritative when set (the launcher
|
|
102
102
|
* sets it to the provisioned venv's console script when one exists, and
|
|
103
|
-
* tests point it at dead paths to drive the
|
|
103
|
+
* tests point it at dead paths to drive the fault arm; a fallback
|
|
104
104
|
* from an explicit setting would turn those arms into silent passes).
|
|
105
105
|
* 2. The shipped self-contained zipapp (`plugin/dist/redactor/daemon.pyz`) —
|
|
106
106
|
* the guaranteed floor: it needs only a python3 on PATH, so a cold start
|
|
@@ -126,20 +126,20 @@ function daemonCommand() {
|
|
|
126
126
|
}
|
|
127
127
|
// How long to wait for a freshly-spawned daemon to start accepting. A cold start
|
|
128
128
|
// pays the detect-secrets import + plugin prime (~1-3s), so the default leaves
|
|
129
|
-
// margin; tests shorten it to exercise the give-up
|
|
129
|
+
// margin; tests shorten it to exercise the give-up path quickly.
|
|
130
130
|
const WAIT_DEADLINE_MS = readKnob("_AGENT_SANITIZER_REDACTOR_WAIT_MS");
|
|
131
131
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
132
|
+
/**
|
|
133
|
+
* The total per-connect deadline. A daemon that ACCEPTS and then stalls — a
|
|
134
|
+
* deadlock, a pathological detect-secrets input, a length prefix that never
|
|
135
|
+
* completes — emits no errno isRespawnable reacts to and never closes, so
|
|
136
|
+
* without it connectAndRequest never settles and the PostToolUse hook is killed
|
|
137
|
+
* at its timeout, which shows the RAW output with no warning. A stall instead
|
|
138
|
+
* rejects, and the caller's fault posture answers it, warning included. Two
|
|
139
|
+
* connects plus the spawn wait must fit under the hook timeout. Read per call,
|
|
140
|
+
* so tests shorten it through the env override (or the deadlineMs parameter).
|
|
141
|
+
* @returns {number}
|
|
142
|
+
*/
|
|
143
143
|
function requestDeadlineMs() {
|
|
144
144
|
return readKnob("_AGENT_SANITIZER_REDACTOR_REQUEST_MS");
|
|
145
145
|
}
|
|
@@ -270,9 +270,9 @@ export function classifySocket(socketPath, deps = {}) {
|
|
|
270
270
|
* by us or by root, carrying no group/other write bit.
|
|
271
271
|
*
|
|
272
272
|
* This refusal is what blocks a co-tenant unlinking our socket and binding their
|
|
273
|
-
* own listener that answers "nothing to redact" to every payload, which would
|
|
274
|
-
*
|
|
275
|
-
* transcript.
|
|
273
|
+
* own listener that answers "nothing to redact" to every payload, which would turn
|
|
274
|
+
* a loud fault into a silent "nothing to redact" and leak unscrubbed secrets to
|
|
275
|
+
* the transcript.
|
|
276
276
|
*
|
|
277
277
|
* Root ownership is trusted because it is strictly stronger than our own, not a
|
|
278
278
|
* concession: a deployment may hand the socket dir to root after the daemon has
|
|
@@ -291,9 +291,9 @@ function isTrustedSocketDir(dir, uid) {
|
|
|
291
291
|
/**
|
|
292
292
|
* Open one connection, send `request`, resolve with the parsed response object
|
|
293
293
|
* (or null). Rejects on connect failure, a malformed/oversize/short frame, or an
|
|
294
|
-
* {error} response — every one of which the caller
|
|
294
|
+
* {error} response — every one of which the caller treats as a fault. A
|
|
295
295
|
* socket present but not owned by us, and a request the protocol does not
|
|
296
|
-
* accept,
|
|
296
|
+
* accept, throw WITHOUT dialing (the error carries no errno, so
|
|
297
297
|
* isRespawnable is false): we never dial into a squat, and an oversize request
|
|
298
298
|
* is not mistaken for a dead daemon worth respawning.
|
|
299
299
|
* @param {string} socketPath
|
|
@@ -484,7 +484,7 @@ function canConnect(socketPath) {
|
|
|
484
484
|
/**
|
|
485
485
|
* Redact `text` via the daemon. Returns the response object (`{text, found}` for
|
|
486
486
|
* plain, `{text, pairs, found}` / `{unmappable}` for map) or null when nothing was
|
|
487
|
-
* redacted (plain mode). Throws
|
|
487
|
+
* redacted (plain mode). Throws when the text cannot be vetted.
|
|
488
488
|
*
|
|
489
489
|
* `connect`/`spawn`/`waitForSocket` are injectable seams (default to the real
|
|
490
490
|
* implementations) so callers can stub the daemon in-process. `deadline` is the
|