agent-sanitizer 2.67.2 → 2.69.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 +8 -6
- package/THREAT-MODEL.md +52 -39
- package/claude-hooks/lib/env-config.mjs +2 -2
- package/claude-hooks/lib/hook-fault.mjs +13 -35
- package/claude-hooks/lib/hook-io.mjs +84 -9
- package/claude-hooks/lib/hook-timing.mjs +26 -6
- package/claude-hooks/lib/invisible-alert.mjs +6 -3
- package/claude-hooks/lib/redactor-client.mjs +23 -23
- package/claude-hooks/lib/secret-annotate.mjs +36 -66
- package/claude-hooks/lib/secret-drop-guard.mjs +136 -29
- package/claude-hooks/plugin-hooks.mjs +30 -27
- package/claude-hooks/pretooluse-sanitize.mjs +288 -64
- package/claude-hooks/sanitize-output.mjs +27 -40
- package/claude-hooks/sanitize-user-prompt.mjs +28 -17
- package/claude-hooks/scan-invisible-chars.mjs +86 -35
- package/claude-hooks/scan-loaded-instructions.mjs +20 -16
- package/package.json +1 -1
- package/src/html.mjs +17 -17
- package/src/output.mjs +20 -19
- package/src/rehydrate.mjs +36 -9
- package/types/claude-hooks/lib/hook-fault.d.mts +8 -73
- package/types/claude-hooks/lib/hook-io.d.mts +32 -6
- package/types/claude-hooks/lib/hook-timing.d.mts +9 -4
- package/types/claude-hooks/lib/invisible-alert.d.mts +6 -3
- 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/lib/secret-drop-guard.d.mts +77 -9
- package/types/claude-hooks/pretooluse-sanitize.d.mts +28 -10
- package/types/claude-hooks/sanitize-output.d.mts +1 -1
- package/types/claude-hooks/sanitize-user-prompt.d.mts +3 -2
- package/types/claude-hooks/scan-invisible-chars.d.mts +3 -3
- package/types/output.d.mts +4 -5
- package/types/rehydrate.d.mts +8 -1
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
|
}
|
|
@@ -364,8 +365,9 @@ function processLayer1(text, sgrCarveOut) {
|
|
|
364
365
|
* Refuse to START a parse with no budget left. Each of Layers 2/3 parses the
|
|
365
366
|
* whole document in ONE synchronous call, so nothing interrupts it, and a host
|
|
366
367
|
* that kills the overrun hook shows the RAW text. Called before EACH parse, and
|
|
367
|
-
* only after a pre-gate passed, so a declined call costs no time.
|
|
368
|
-
*
|
|
368
|
+
* only after a pre-gate passed, so a declined call costs no time. Throws, as
|
|
369
|
+
* Layer 4 does: whether the unchecked text then passes or is withheld is the
|
|
370
|
+
* caller's fault posture, never this layer's.
|
|
369
371
|
* @param {Deadline | undefined} deadline
|
|
370
372
|
* @returns {void}
|
|
371
373
|
*/
|
|
@@ -373,8 +375,8 @@ function refuseIfSpent(deadline) {
|
|
|
373
375
|
if (deadline && deadline.remainingMs() <= 0)
|
|
374
376
|
throw new Error(
|
|
375
377
|
"CRITICAL: the sanitization time budget ran out before the hidden-HTML " +
|
|
376
|
-
"and exfil-URL layers finished, so this text was not fully checked
|
|
377
|
-
"
|
|
378
|
+
"and exfil-URL layers finished, so this text was not fully checked " +
|
|
379
|
+
"and must be treated as UNSANITIZED.",
|
|
378
380
|
);
|
|
379
381
|
}
|
|
380
382
|
|
|
@@ -599,8 +601,8 @@ async function vetStageValue(text, redact, findings, label) {
|
|
|
599
601
|
return secrets ? normalizeLoneSurrogates(secrets.text) : text;
|
|
600
602
|
} catch {
|
|
601
603
|
// 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
|
-
//
|
|
604
|
+
// reason is an unrunnable redactor — the same fault that makes `cleaned`
|
|
605
|
+
// throw, just with a survivable remedy here.
|
|
604
606
|
findings.push(warning(withheldWarning(label)));
|
|
605
607
|
return undefined;
|
|
606
608
|
}
|
|
@@ -629,11 +631,10 @@ async function vetStageValue(text, redact, findings, label) {
|
|
|
629
631
|
|
|
630
632
|
/**
|
|
631
633
|
* 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.
|
|
634
|
+
* rest are opt-in via `options`. A Layer 4 (`redact`) redactor that throws is
|
|
635
|
+
* rethrown wrapped, so the caller answers it under its fault posture rather
|
|
636
|
+
* than receiving unvetted text as vetted. The same holds for Layer 4's re-scan
|
|
637
|
+
* after a Layer-5 span deletion (see Layer 5, below).
|
|
637
638
|
* `reveal` is the pre-Layer-2 text, present only when the HTML splice removed
|
|
638
639
|
* bytes, so a caller can persist what was hidden for later inspection (see
|
|
639
640
|
* {@link applyMarkdownPipeline}); the field is omitted otherwise, and also when
|
|
@@ -677,7 +678,7 @@ export async function sanitizeText(text, options = {}) {
|
|
|
677
678
|
const { reveal: revealText, splices: stageSplices } =
|
|
678
679
|
await applyMarkdownPipeline(state, options);
|
|
679
680
|
|
|
680
|
-
// Layer 4 —
|
|
681
|
+
// Layer 4 — throws on a redactor fault (see runRedact).
|
|
681
682
|
await runRedact(state, options);
|
|
682
683
|
|
|
683
684
|
// Layer 5 — secure span-deletion slot (see module doc). A warning-only result
|
package/src/rehydrate.mjs
CHANGED
|
@@ -823,6 +823,12 @@ function isCandidate(tool, ti) {
|
|
|
823
823
|
* there is nothing to do. Throws only on internal error (the caller fails
|
|
824
824
|
* closed).
|
|
825
825
|
*
|
|
826
|
+
* A deny carrying `fault` is not a verdict on the input but this module being
|
|
827
|
+
* unable to do its job (an unreadable target, a redactor map that fails
|
|
828
|
+
* validation): `fault` states what proceeding un-rehydrated would write, and the
|
|
829
|
+
* caller's failure posture decides whether the deny stands. A deny without it
|
|
830
|
+
* is a verdict and stands in every posture.
|
|
831
|
+
*
|
|
826
832
|
* `io` is the injected I/O (file read + redactor map/plain). `hint` is the
|
|
827
833
|
* redaction-placeholder prefix (defaults to {@link DEFAULT_HINT}); override it
|
|
828
834
|
* only if the injected redactor emits a different placeholder shape.
|
|
@@ -830,7 +836,7 @@ function isCandidate(tool, ti) {
|
|
|
830
836
|
* @param {any} toolInput
|
|
831
837
|
* @param {RehydrateIo} io
|
|
832
838
|
* @param {{ hint?: string }} [options]
|
|
833
|
-
* @returns {Promise<{updatedInput: any, context: string} | {deny: string} | null>}
|
|
839
|
+
* @returns {Promise<{updatedInput: any, context: string} | {deny: string, fault?: string} | null>}
|
|
834
840
|
*/
|
|
835
841
|
export async function rehydrateRedacted(
|
|
836
842
|
tool,
|
|
@@ -911,12 +917,17 @@ export async function rehydrateRedacted(
|
|
|
911
917
|
if (tool !== "Write") throw err;
|
|
912
918
|
return null;
|
|
913
919
|
}
|
|
920
|
+
const cause = nodeErr?.code ?? nodeErr?.message;
|
|
914
921
|
return {
|
|
915
922
|
deny:
|
|
916
923
|
`could not read ${toolInput.file_path} to rehydrate its secrets ` +
|
|
917
|
-
`(${
|
|
924
|
+
`(${cause}); the file likely still exists, so writing ` +
|
|
918
925
|
`the placeholder text as-is risks overwriting a real secret. Retry the read, or ` +
|
|
919
926
|
`ask the user to make this change directly`,
|
|
927
|
+
fault:
|
|
928
|
+
`${toolInput.file_path} could not be read (${cause}) to rehydrate its ` +
|
|
929
|
+
`secrets, so this call is not rehydrated and its ${hint}…] placeholder ` +
|
|
930
|
+
`text is written to the file literally`,
|
|
920
931
|
};
|
|
921
932
|
}
|
|
922
933
|
const { layer1Cleaned, cleaned } = layer1View(content);
|
|
@@ -949,8 +960,14 @@ export async function rehydrateRedacted(
|
|
|
949
960
|
// reaches its own resolver-backed pass-through.
|
|
950
961
|
if (tool === "MultiEdit") return multiEditDeny(toolInput.file_path);
|
|
951
962
|
if (!hinted) return null;
|
|
963
|
+
// A verdict, not a fault: the file itself defeats mapping on every call, so
|
|
964
|
+
// a released re-issue would overwrite the real secret with its placeholder.
|
|
965
|
+
// The escape is to stop writing the placeholder, which the reason names.
|
|
952
966
|
return {
|
|
953
|
-
deny:
|
|
967
|
+
deny:
|
|
968
|
+
`cannot resolve redaction placeholders in ${toolInput.file_path}: ` +
|
|
969
|
+
`${mapped.unmappable}. Leave every ${hint}…] placeholder out of this ` +
|
|
970
|
+
`call (edit around it), or ask the user to make this change`,
|
|
954
971
|
};
|
|
955
972
|
}
|
|
956
973
|
// The redactor emits code-point offsets; the offset machinery below works in
|
|
@@ -970,9 +987,8 @@ export async function rehydrateRedacted(
|
|
|
970
987
|
// before acting: construction re-checks range/ordering (the throws in
|
|
971
988
|
// assertPairsOrdered and pairsToUtf16), viewMapDefect proves the view
|
|
972
989
|
// reconstructs `cleaned` exactly. A defective map DENIES for every tool and
|
|
973
|
-
// hint state
|
|
974
|
-
//
|
|
975
|
-
// OPEN, i.e. the unsanitized placeholder write this module exists to prevent.
|
|
990
|
+
// hint state with a `fault`, never a throw: the deny lets the caller refuse
|
|
991
|
+
// the call once and name what proceeding writes, which a throw cannot.
|
|
976
992
|
let view = null;
|
|
977
993
|
let mapDefect = null;
|
|
978
994
|
try {
|
|
@@ -993,14 +1009,25 @@ export async function rehydrateRedacted(
|
|
|
993
1009
|
// Reaching this line at all means the fast pass-through did not fire — the
|
|
994
1010
|
// file provably holds redacted content or diverges from its view — so an
|
|
995
1011
|
// unhinted pass-through here would hand the real Edit bytes inside spans
|
|
996
|
-
// nothing can vet (the R1 hidden-span oracle). Deny
|
|
997
|
-
//
|
|
1012
|
+
// nothing can vet (the R1 hidden-span oracle). Deny, as a fault: the
|
|
1013
|
+
// engine, not the input, is what is wrong.
|
|
998
1014
|
if (tool === "MultiEdit") return multiEditDeny(toolInput.file_path);
|
|
1015
|
+
// A Write replaces the whole file, and the secret-drop guard reads this
|
|
1016
|
+
// same broken map, so the loss it would name has to be named here instead.
|
|
1017
|
+
const dropped =
|
|
1018
|
+
tool === "Write"
|
|
1019
|
+
? `; it replaces the whole file, so any redacted secret it does not ` +
|
|
1020
|
+
`reproduce is removed, and which secrets those are cannot be checked`
|
|
1021
|
+
: "";
|
|
999
1022
|
return {
|
|
1000
1023
|
deny:
|
|
1001
1024
|
`cannot safely edit ${toolInput.file_path}: ${mapDefect}; the file holds ` +
|
|
1002
1025
|
`redacted content whose location cannot be trusted, so no edit can be ` +
|
|
1003
|
-
`verified. Retry later, or ask the user to make this change`,
|
|
1026
|
+
`verified${dropped}. Retry later, or ask the user to make this change`,
|
|
1027
|
+
fault:
|
|
1028
|
+
`${mapDefect} for ${toolInput.file_path}, so this call is not ` +
|
|
1029
|
+
`rehydrated: any ${hint}…] placeholder text in it is written to the ` +
|
|
1030
|
+
`file literally, and it is applied without the redacted-span checks${dropped}`,
|
|
1004
1031
|
};
|
|
1005
1032
|
}
|
|
1006
1033
|
// View identical to disk: any placeholders in an Edit's old_string are
|
|
@@ -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,25 @@ 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
|
-
*
|
|
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, block a prompt, or hold a tool call for good unless the
|
|
114
|
+
* operator set the knob to `0` (an open arm may refuse a call once, releasing the
|
|
115
|
+
* exact re-issue). A pin turns any bug in the guarded layer into every tool call
|
|
116
|
+
* failing.
|
|
181
117
|
*/
|
|
182
118
|
export type FaultPolicy = {
|
|
183
119
|
event: string | null;
|
|
184
120
|
guarded: string;
|
|
185
|
-
alwaysClosed: AlwaysClosed;
|
|
186
121
|
open?: (ctx: FaultContext) => FaultParts;
|
|
187
122
|
closed: (ctx: FaultContext) => FaultParts;
|
|
188
123
|
};
|
|
@@ -286,6 +286,25 @@ export function safeErrMessage(err: unknown, cap?: number): string;
|
|
|
286
286
|
* @returns {void}
|
|
287
287
|
*/
|
|
288
288
|
export function emitHookResponse(hookEventName: string, fields: Record<string, unknown>): void;
|
|
289
|
+
/**
|
|
290
|
+
* @typedef {{
|
|
291
|
+
* add: (envelope: Record<string, any>) => void,
|
|
292
|
+
* emit: (hookEventName: string, fields: Record<string, unknown>) => void,
|
|
293
|
+
* flush: () => void,
|
|
294
|
+
* }} EnvelopeBuffer
|
|
295
|
+
*/
|
|
296
|
+
/**
|
|
297
|
+
* Collect everything a hook run wants to say on stdout and write it as exactly
|
|
298
|
+
* ONE JSON object on `flush`. Claude Code parses a hook's stdout as a single
|
|
299
|
+
* document, so two envelopes written by two reporters (a fault notice, then the
|
|
300
|
+
* slow-hook notice from the timing wrapper's `finally`) concatenate into invalid
|
|
301
|
+
* output and the host DROPS the whole response — the model never sees either
|
|
302
|
+
* warning. Every reporter in a run therefore adds here instead of writing, and
|
|
303
|
+
* the entry point flushes once, last.
|
|
304
|
+
* @param {(chunk: string) => void} [write] injectable stdout sink, for tests
|
|
305
|
+
* @returns {EnvelopeBuffer}
|
|
306
|
+
*/
|
|
307
|
+
export function createEnvelopeBuffer(write?: (chunk: string) => void): EnvelopeBuffer;
|
|
289
308
|
/**
|
|
290
309
|
* The directory the session-lifetime stores live under: the alert markers and
|
|
291
310
|
* findings (invisible-alert.mjs) and the reveal sidecars (reveal.mjs). They must
|
|
@@ -522,14 +541,16 @@ export function touchLiveState(path: string): void;
|
|
|
522
541
|
* file the hook's user owns. Unlink any existing entry first (removing a squatted
|
|
523
542
|
* symlink), then create exclusively (O_EXCL) so a symlink re-planted in the race
|
|
524
543
|
* window fails the open rather than being dereferenced. Content is irrelevant —
|
|
525
|
-
* callers test only for existence — so the file is left empty.
|
|
526
|
-
* missing/read-only $TMPDIR or a lost race
|
|
527
|
-
*
|
|
528
|
-
*
|
|
544
|
+
* callers test only for existence — so the file is left empty. Never throws: a
|
|
545
|
+
* missing/read-only $TMPDIR or a lost race leaves the sentinel absent, and the
|
|
546
|
+
* return value says so. A caller whose refusal is released only by a later read
|
|
547
|
+
* of this sentinel must branch on it, because a refusal that records nothing can
|
|
548
|
+
* never be released.
|
|
529
549
|
* @param {string} path
|
|
530
|
-
* @returns {
|
|
550
|
+
* @returns {boolean} whether a sentinel this uid owns now exists at `path`
|
|
551
|
+
* (the same test {@link markerIsTrusted} applies when a reader looks for it)
|
|
531
552
|
*/
|
|
532
|
-
export function writeSentinelFile(path: string):
|
|
553
|
+
export function writeSentinelFile(path: string): boolean;
|
|
533
554
|
/**
|
|
534
555
|
* Write `content` to `path` without following a symlink a co-tenant may have
|
|
535
556
|
* pre-planted there — the content-bearing counterpart to writeSentinelFile. These
|
|
@@ -635,6 +656,11 @@ export const STATE_ROOT: string;
|
|
|
635
656
|
* says it locks may be judged by the lock.
|
|
636
657
|
*/
|
|
637
658
|
export const SETUP_LOCK_DECLARATION: "flock";
|
|
659
|
+
export type EnvelopeBuffer = {
|
|
660
|
+
add: (envelope: Record<string, any>) => void;
|
|
661
|
+
emit: (hookEventName: string, fields: Record<string, unknown>) => void;
|
|
662
|
+
flush: () => void;
|
|
663
|
+
};
|
|
638
664
|
/**
|
|
639
665
|
* EVERY process-wide slot these helpers keep — the four a host can observe or
|
|
640
666
|
* steer, so a second instance that adopts this object is steered in all four at
|
|
@@ -170,10 +170,15 @@ export function startHookTimer(now?: () => number, cpuNow?: () => number): {
|
|
|
170
170
|
* sanitizer computing, and with `context.redactorMs` too it names the window the
|
|
171
171
|
* time went into ({@link attributeWait}), including `context.hostMs`'s host
|
|
172
172
|
* extensions — a caller that measured the first two but has no extensions to
|
|
173
|
-
* charge reports that window as zero, which is a measurement and not a shrug.
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
173
|
+
* charge reports that window as zero, which is a measurement and not a shrug.
|
|
174
|
+
* Such a caller is reported only when `cpuMs + redactorMs + hostMs` (an absent
|
|
175
|
+
* `hostMs` counting as 0) exceeds `thresholdMs`: a wall-clock overrun whose
|
|
176
|
+
* owned share is within the budget is the machine's and returns null, so the
|
|
177
|
+
* operator is never asked to report a cost the owner did not spend. Without the
|
|
178
|
+
* measurements the line says that it cannot tell, rather than asserting an
|
|
179
|
+
* attribution nothing measured: a wall-clock overrun on a loaded host is the
|
|
180
|
+
* common case, and blaming it on the sanitizer sends the operator hunting a
|
|
181
|
+
* per-call cost that does not exist.
|
|
177
182
|
*
|
|
178
183
|
* A CPU figure alone cannot pick a cause, so with only that the clause names
|
|
179
184
|
* candidates and commits to none. A hook that blocks on a dead socket inside a
|
|
@@ -193,11 +193,14 @@ export function alertAcknowledged(sessionId?: string): boolean;
|
|
|
193
193
|
/**
|
|
194
194
|
* Record that the gate has surfaced its blocking ask, so later tool calls get a
|
|
195
195
|
* passive reminder instead of an ask on every call. Session-keyed, so the next
|
|
196
|
-
* session re-asks once without anything having to clear this.
|
|
196
|
+
* session re-asks once without anything having to clear this. Returns false
|
|
197
|
+
* when the ack could not be recorded, so the caller knows the next call will
|
|
198
|
+
* ask again: under the fail-open posture it surfaces the finding as a warning
|
|
199
|
+
* rather than asking on every tool call for the rest of the session.
|
|
197
200
|
* @param {string} [sessionId]
|
|
198
|
-
* @returns {
|
|
201
|
+
* @returns {boolean}
|
|
199
202
|
*/
|
|
200
|
-
export function acknowledgeAlert(sessionId?: string):
|
|
203
|
+
export function acknowledgeAlert(sessionId?: string): boolean;
|
|
201
204
|
/**
|
|
202
205
|
* The blocking ask. The heading states only that the scan did not finish clean:
|
|
203
206
|
* the alert carries injection findings, unreadable targets, or a scanner fault,
|
|
@@ -55,9 +55,9 @@ export function classifySocket(socketPath: string, deps?: {
|
|
|
55
55
|
/**
|
|
56
56
|
* Open one connection, send `request`, resolve with the parsed response object
|
|
57
57
|
* (or null). Rejects on connect failure, a malformed/oversize/short frame, or an
|
|
58
|
-
* {error} response — every one of which the caller
|
|
58
|
+
* {error} response — every one of which the caller treats as a fault. A
|
|
59
59
|
* socket present but not owned by us, and a request the protocol does not
|
|
60
|
-
* accept,
|
|
60
|
+
* accept, throw WITHOUT dialing (the error carries no errno, so
|
|
61
61
|
* isRespawnable is false): we never dial into a squat, and an oversize request
|
|
62
62
|
* is not mistaken for a dead daemon worth respawning.
|
|
63
63
|
* @param {string} socketPath
|
|
@@ -105,7 +105,7 @@ export function waitForSocket(socketPath: string, { deadlineMs, stepMs, abandon
|
|
|
105
105
|
/**
|
|
106
106
|
* Redact `text` via the daemon. Returns the response object (`{text, found}` for
|
|
107
107
|
* plain, `{text, pairs, found}` / `{unmappable}` for map) or null when nothing was
|
|
108
|
-
* redacted (plain mode). Throws
|
|
108
|
+
* redacted (plain mode). Throws when the text cannot be vetted.
|
|
109
109
|
*
|
|
110
110
|
* `connect`/`spawn`/`waitForSocket` are injectable seams (default to the real
|
|
111
111
|
* implementations) so callers can stub the daemon in-process. `deadline` is the
|
|
@@ -1,26 +1,20 @@
|
|
|
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 — and the
|
|
5
|
-
* `u` flag, which the astral `\u{…}` class members in {@link ENV_INVIS_RUN}
|
|
6
|
-
* require.
|
|
7
|
-
* Memoized per distinct value: {@link ENV_INVIS_RUN} renders ~435 code points
|
|
8
|
-
* as ~4 KB of source and is joined at EVERY interior gap, so a 20-char secret
|
|
9
|
-
* compiles a ~75 KB pattern — and `hasEnvBoundSecret` builds one per configured
|
|
10
|
-
* var on every PostToolUse output. Env values are stable for the process's
|
|
11
|
-
* lifetime, so the cache is bounded by the number of distinct values. Sharing an
|
|
12
|
-
* instance is safe because the regex carries no `g`/`y` flag, hence no
|
|
13
|
-
* `lastIndex` state to leak between calls.
|
|
14
|
-
* @param {string} value
|
|
15
|
-
* @returns {RegExp}
|
|
16
|
-
*/
|
|
17
|
-
export function envValueRegex(value: string): RegExp;
|
|
18
1
|
/**
|
|
19
2
|
* True when tool output contains the literal value of a configured env-bound
|
|
20
3
|
* secret. The shape-based secret hint can't match a prefix-less key or a host
|
|
21
4
|
* credential, so the pre-gate must also fire on the value itself — otherwise
|
|
22
5
|
* the engine's env-bound redaction never runs. Invisible-tolerant so a
|
|
23
6
|
* value with spliced Cf chars (which the daemon still redacts) trips it too.
|
|
7
|
+
*
|
|
8
|
+
* Both sides are stripped and compared as substrings, which is exactly
|
|
9
|
+
* equivalent: the value's visible characters appear contiguously in the
|
|
10
|
+
* stripped text iff they appear in the original separated only by invisibles.
|
|
11
|
+
* It is linear and independent of the value's length — a regex with an
|
|
12
|
+
* invisible class at every gap grows ~4 KB per character, and a long secret (a
|
|
13
|
+
* PEM key) overflows V8's regex size limit and throws on every output.
|
|
14
|
+
*
|
|
15
|
+
* Callers pass post-Layer-1 text: terminal escapes are already gone, so a value
|
|
16
|
+
* split by one is contiguous here. Raw text spliced with an escape is not
|
|
17
|
+
* matched.
|
|
24
18
|
* @param {string} text
|
|
25
19
|
* @param {NodeJS.ProcessEnv} [env]
|
|
26
20
|
* @returns {boolean}
|
|
@@ -11,6 +11,28 @@
|
|
|
11
11
|
* @returns {string}
|
|
12
12
|
*/
|
|
13
13
|
export function dropFingerprint(filePath: string, content: string, dropped?: string[]): string;
|
|
14
|
+
/**
|
|
15
|
+
* Fingerprint of one refusal of one exact tool call — same refusing site, same
|
|
16
|
+
* tool, same input — released by re-issuing that call. Keyed on the site too, so
|
|
17
|
+
* a call refused by two sites in turn needs a confirmation for each and one
|
|
18
|
+
* re-issue never releases a refusal the model was not shown. The input is
|
|
19
|
+
* hashed in canonical key order, so a re-issue whose keys arrive reordered is
|
|
20
|
+
* still the same call. Distinct domain tags keep it disjoint from
|
|
21
|
+
* {@link dropFingerprint}.
|
|
22
|
+
* @param {string} site
|
|
23
|
+
* @param {unknown} tool
|
|
24
|
+
* @param {unknown} toolInput
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function reissueFingerprint(site: string, tool: unknown, toolInput: unknown): string;
|
|
28
|
+
/**
|
|
29
|
+
* Record a confirm sentinel for `fingerprint`, so the identical re-issue is
|
|
30
|
+
* approved by {@link consumeConfirm}. False when it could not be recorded: the
|
|
31
|
+
* caller then must not refuse, because nothing could ever release the refusal.
|
|
32
|
+
* @param {string} fingerprint
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
export function recordConfirm(fingerprint: string): boolean;
|
|
14
36
|
/**
|
|
15
37
|
* The confirm sentinel's path for one fingerprint. Predictable and
|
|
16
38
|
* world-visible like the alert markers, hence the markerIsTrusted read.
|
|
@@ -50,15 +72,16 @@ export function gitTracked(filePath: string, spawn?: typeof spawnSync): boolean;
|
|
|
50
72
|
* rehydration substituted any placeholders — so a preserved secret shows up as
|
|
51
73
|
* its real value in `content`. `io` is the rehydrate-shaped I/O bag (readFile /
|
|
52
74
|
* redact / redactMap). Injectable seams: `isTracked` (the git probe),
|
|
53
|
-
* `confirmSeen`/`recordConfirm` (the sentinel state).
|
|
75
|
+
* `confirmSeen`/`recordConfirm` (the sentinel state). A deny whose confirmation
|
|
76
|
+
* could not be recorded carries `fault` (see {@link dropUnconfirmable}).
|
|
54
77
|
* @param {{file_path?: unknown, content?: unknown}} toolInput
|
|
55
78
|
* @param {import("agent-sanitizer/rehydrate").RehydrateIo} io
|
|
56
79
|
* @param {{
|
|
57
80
|
* isTracked?: (filePath: string) => boolean,
|
|
58
81
|
* confirmSeen?: (fingerprint: string) => boolean,
|
|
59
|
-
* recordConfirm?: (fingerprint: string) =>
|
|
82
|
+
* recordConfirm?: (fingerprint: string) => boolean,
|
|
60
83
|
* }} [opts]
|
|
61
|
-
* @returns {Promise<{deny: string} | null>}
|
|
84
|
+
* @returns {Promise<{deny: string, fault?: string} | null>}
|
|
62
85
|
*/
|
|
63
86
|
export function secretDropGuard(toolInput: {
|
|
64
87
|
file_path?: unknown;
|
|
@@ -66,9 +89,10 @@ export function secretDropGuard(toolInput: {
|
|
|
66
89
|
}, io: import("agent-sanitizer/rehydrate").RehydrateIo, opts?: {
|
|
67
90
|
isTracked?: (filePath: string) => boolean;
|
|
68
91
|
confirmSeen?: (fingerprint: string) => boolean;
|
|
69
|
-
recordConfirm?: (fingerprint: string) =>
|
|
92
|
+
recordConfirm?: (fingerprint: string) => boolean;
|
|
70
93
|
}): Promise<{
|
|
71
94
|
deny: string;
|
|
95
|
+
fault?: string;
|
|
72
96
|
} | null>;
|
|
73
97
|
/**
|
|
74
98
|
* Compose a Layer-4 rehydrator with this guard: for a Write the rehydrator
|
|
@@ -76,14 +100,41 @@ export function secretDropGuard(toolInput: {
|
|
|
76
100
|
* substitution — so a rehydrated secret counts as preserved and only a
|
|
77
101
|
* genuinely dropped one can trip it. A rehydrate deny short-circuits (the
|
|
78
102
|
* guard never runs), non-Write tools skip the guard entirely, and a guard
|
|
79
|
-
* deny wins over a pass/rewrite result.
|
|
80
|
-
*
|
|
103
|
+
* deny wins over a pass/rewrite result. A guard FAULT deny carries the
|
|
104
|
+
* rewrite as `release`: the open posture lets that call proceed, and it must
|
|
105
|
+
* proceed rehydrated, or every secret the rewrite restored is overwritten by
|
|
106
|
+
* its placeholder.
|
|
107
|
+
* @typedef {{updatedInput: any, context: string}} RehydrateRewrite
|
|
108
|
+
* @typedef {RehydrateRewrite | {deny: string, fault?: string, release?: RehydrateRewrite} | null} RehydrateResult
|
|
81
109
|
* @param {(tool: string, toolInput: any) => Promise<RehydrateResult>} rehydrate
|
|
82
110
|
* @param {import("agent-sanitizer/rehydrate").RehydrateIo} io
|
|
83
111
|
* @param {typeof secretDropGuard} [guard]
|
|
84
112
|
* @returns {(tool: string, toolInput: any) => Promise<RehydrateResult>}
|
|
85
113
|
*/
|
|
86
114
|
export function withSecretDropGuard(rehydrate: (tool: string, toolInput: any) => Promise<RehydrateResult>, io: import("agent-sanitizer/rehydrate").RehydrateIo, guard?: typeof secretDropGuard): (tool: string, toolInput: any) => Promise<RehydrateResult>;
|
|
115
|
+
/**
|
|
116
|
+
* True when a trusted, fresh confirm sentinel exists for `fingerprint`,
|
|
117
|
+
* consuming it so one confirmation approves exactly one call. A sentinel
|
|
118
|
+
* older than {@link CONFIRM_TTL_MS} is stale — an abandoned deny from a
|
|
119
|
+
* previous session, not a live confirmation — so it is removed and NOT
|
|
120
|
+
* honored, preventing a leftover marker from acting as a standing
|
|
121
|
+
* auto-approval. Removal is best-effort: a fresh marker we could not unlink
|
|
122
|
+
* still counted as consumed for THIS call, and a leftover empty sentinel only
|
|
123
|
+
* ever re-approves the identical fingerprinted call the model already
|
|
124
|
+
* confirmed — and only within the TTL.
|
|
125
|
+
* @param {string} fingerprint
|
|
126
|
+
* @returns {boolean}
|
|
127
|
+
*/
|
|
128
|
+
export function consumeConfirm(fingerprint: string): boolean;
|
|
129
|
+
/**
|
|
130
|
+
* True when a trusted confirm sentinel younger than {@link CONFIRM_TTL_MS}
|
|
131
|
+
* exists for `fingerprint`, WITHOUT consuming it — for a caller that learns
|
|
132
|
+
* only later whether the call it confirms actually proceeds, and consumes it
|
|
133
|
+
* then.
|
|
134
|
+
* @param {string} fingerprint
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
export function confirmFresh(fingerprint: string): boolean;
|
|
87
138
|
/** A confirm sentinel older than this is stale, not a confirmation. */
|
|
88
139
|
export const CONFIRM_TTL_MS: number;
|
|
89
140
|
/**
|
|
@@ -92,12 +143,29 @@ export const CONFIRM_TTL_MS: number;
|
|
|
92
143
|
* substitution — so a rehydrated secret counts as preserved and only a
|
|
93
144
|
* genuinely dropped one can trip it. A rehydrate deny short-circuits (the
|
|
94
145
|
* guard never runs), non-Write tools skip the guard entirely, and a guard
|
|
95
|
-
* deny wins over a pass/rewrite result.
|
|
146
|
+
* deny wins over a pass/rewrite result. A guard FAULT deny carries the
|
|
147
|
+
* rewrite as `release`: the open posture lets that call proceed, and it must
|
|
148
|
+
* proceed rehydrated, or every secret the rewrite restored is overwritten by
|
|
149
|
+
* its placeholder.
|
|
96
150
|
*/
|
|
97
|
-
export type
|
|
151
|
+
export type RehydrateRewrite = {
|
|
98
152
|
updatedInput: any;
|
|
99
153
|
context: string;
|
|
100
|
-
}
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* Compose a Layer-4 rehydrator with this guard: for a Write the rehydrator
|
|
157
|
+
* did not deny, run the guard on the FINAL content — after any placeholder
|
|
158
|
+
* substitution — so a rehydrated secret counts as preserved and only a
|
|
159
|
+
* genuinely dropped one can trip it. A rehydrate deny short-circuits (the
|
|
160
|
+
* guard never runs), non-Write tools skip the guard entirely, and a guard
|
|
161
|
+
* deny wins over a pass/rewrite result. A guard FAULT deny carries the
|
|
162
|
+
* rewrite as `release`: the open posture lets that call proceed, and it must
|
|
163
|
+
* proceed rehydrated, or every secret the rewrite restored is overwritten by
|
|
164
|
+
* its placeholder.
|
|
165
|
+
*/
|
|
166
|
+
export type RehydrateResult = RehydrateRewrite | {
|
|
101
167
|
deny: string;
|
|
168
|
+
fault?: string;
|
|
169
|
+
release?: RehydrateRewrite;
|
|
102
170
|
} | null;
|
|
103
171
|
import { spawnSync } from "node:child_process";
|