agent-sanitizer 2.43.12 → 2.44.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 -0
- package/claude-hooks/lib/hook-io.mjs +69 -9
- package/claude-hooks/lib/invisible-alert.mjs +207 -92
- package/claude-hooks/lib/redactor-client.mjs +15 -12
- package/claude-hooks/lib/reveal.mjs +49 -2
- package/claude-hooks/lib/secret-drop-guard.mjs +44 -3
- package/claude-hooks/plugin-hooks.mjs +5 -2
- package/claude-hooks/pretooluse-sanitize.mjs +25 -11
- package/claude-hooks/scan-invisible-chars.mjs +59 -32
- package/claude-hooks/scan-loaded-instructions.mjs +10 -3
- package/package.json +1 -1
- package/types/claude-hooks/lib/hook-io.d.mts +33 -7
- package/types/claude-hooks/lib/invisible-alert.d.mts +101 -47
- package/types/claude-hooks/lib/reveal.d.mts +16 -0
- package/types/claude-hooks/lib/secret-drop-guard.d.mts +11 -0
- package/types/claude-hooks/scan-invisible-chars.d.mts +18 -3
|
@@ -1,16 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The path prefix every alert artifact of ONE session under this project shares.
|
|
3
|
+
*
|
|
4
|
+
* Session-keying is what makes the gate's one-time ask correct by construction.
|
|
5
|
+
* The store and its ack used to be keyed by PROJECT alone and reset by a
|
|
6
|
+
* destructive clear at SessionStart, which left two ways for a session to
|
|
7
|
+
* inherit the previous one's answer: an early-exiting scanner arm (a dep-load
|
|
8
|
+
* failure) returns before the clear, and nothing pins SessionStart against the
|
|
9
|
+
* InstructionsLoaded events fired for the files loaded at launch. A session that
|
|
10
|
+
* cannot see another session's files needs neither the clear nor the ordering —
|
|
11
|
+
* past sessions' artifacts simply age out through {@link sweepStaleSessions}.
|
|
12
|
+
* @param {string} [sessionId] the harness's session identity
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function sessionPrefix(sessionId?: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* The directory holding this session's alert findings, one file per finding.
|
|
18
|
+
* @param {string} [sessionId]
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function alertDir(sessionId?: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Companion marker the PreToolUse gate writes once it has surfaced the alert
|
|
24
|
+
* this session, so the gate asks ONCE then degrades to a passive reminder
|
|
25
|
+
* instead of prompting on every tool call. Session-keyed like the findings it
|
|
26
|
+
* answers for, so a fresh session cannot read an older session's answer.
|
|
27
|
+
* @param {string} [sessionId]
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function alertAckFile(sessionId?: string): string;
|
|
1
31
|
/**
|
|
2
32
|
* Marker the InstructionsLoaded scanner writes on every fire, so another hook
|
|
3
33
|
* can tell whether that event is being scanned at all this session.
|
|
4
|
-
*
|
|
5
|
-
* Keyed by the SESSION, and never cleared at SessionStart like the alert pair
|
|
6
|
-
* above (a later session sweeps it once it is older than the TTL):
|
|
7
|
-
* nothing pins the order of SessionStart against the InstructionsLoaded events
|
|
8
|
-
* Claude Code fires for the files it loads at launch, so a clear could erase a
|
|
9
|
-
* marker written moments earlier and produce the notice on a session that IS
|
|
10
|
-
* covered. Session-keyed, the question each session asks is answered by that
|
|
11
|
-
* session's own file and no ordering matters. A host that exports no session id
|
|
12
|
-
* falls back to one shared name — where the marker can outlive its session, and
|
|
13
|
-
* a later session on a host that stopped emitting the event stays quiet.
|
|
14
34
|
* @param {string} [sessionId]
|
|
15
35
|
* @returns {string}
|
|
16
36
|
*/
|
|
@@ -31,13 +51,25 @@ export function instructionsLoadedNoticeFile(sessionId?: string): string;
|
|
|
31
51
|
* @returns {boolean}
|
|
32
52
|
*/
|
|
33
53
|
export function instructionsLoadedSeen(sessionId?: string): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Delete this project's artifacts from OTHER sessions once they are older than
|
|
56
|
+
* the TTL. The current session's own prefix is skipped, so the sweep can never
|
|
57
|
+
* answer its own question wrong; every other session's files are past history
|
|
58
|
+
* that nothing reads.
|
|
59
|
+
*
|
|
60
|
+
* This replaces the destructive SessionStart clear: with the store session-keyed
|
|
61
|
+
* there is nothing to reset, only old state to age out.
|
|
62
|
+
* @param {string} [sessionId] the session whose artifacts must be kept
|
|
63
|
+
* @returns {void}
|
|
64
|
+
*/
|
|
65
|
+
export function sweepStaleSessions(sessionId?: string): void;
|
|
34
66
|
/**
|
|
35
67
|
* Record that the InstructionsLoaded scanner engaged. Symlink-safe presence
|
|
36
68
|
* write (see writeSentinelFile) at a predictable $TMPDIR path.
|
|
37
69
|
*
|
|
38
70
|
* The event fires once per instruction file loaded, so the already-recorded case
|
|
39
|
-
* returns without a write — and the stale-
|
|
40
|
-
* session, where one readdir is paid once rather than per loaded file.
|
|
71
|
+
* returns without a write — and the stale-session sweep rides the FIRST fire of
|
|
72
|
+
* a session, where one readdir is paid once rather than per loaded file.
|
|
41
73
|
* @param {string} [sessionId]
|
|
42
74
|
* @returns {void}
|
|
43
75
|
*/
|
|
@@ -45,8 +77,12 @@ export function recordInstructionsLoaded(sessionId?: string): void;
|
|
|
45
77
|
/**
|
|
46
78
|
* The one-time context line for a session where no InstructionsLoaded scan ran,
|
|
47
79
|
* or null when the scan has been seen or the notice was already surfaced this
|
|
48
|
-
* session.
|
|
49
|
-
*
|
|
80
|
+
* session.
|
|
81
|
+
*
|
|
82
|
+
* PURE: it does not record that the notice was handed out. The caller records
|
|
83
|
+
* separately, once the notice has actually landed in a response — a deny
|
|
84
|
+
* assembled after this call discards the notice, and a marker written here would
|
|
85
|
+
* have burned the session's one chance to report the loss.
|
|
50
86
|
*
|
|
51
87
|
* The loss it names is real and otherwise invisible: SessionStart scans the
|
|
52
88
|
* instruction files that load at launch, and everything a subdirectory loads
|
|
@@ -63,48 +99,67 @@ export function recordInstructionsLoaded(sessionId?: string): void;
|
|
|
63
99
|
* @returns {string | null}
|
|
64
100
|
*/
|
|
65
101
|
export function instructionsLoadedGapNotice(sessionId?: string): string | null;
|
|
102
|
+
/**
|
|
103
|
+
* Record that the gap notice above was surfaced, so it rides on ONE tool call
|
|
104
|
+
* rather than every one — the per-call repeat is what trains a reader to skip it.
|
|
105
|
+
* Called only once the notice is in a response that is actually being returned.
|
|
106
|
+
* @param {string} [sessionId]
|
|
107
|
+
* @returns {void}
|
|
108
|
+
*/
|
|
109
|
+
export function recordInstructionsLoadedNotice(sessionId?: string): void;
|
|
66
110
|
/**
|
|
67
111
|
* The alert findings if invisible-char injection was detected in instruction
|
|
68
|
-
* files and couldn't be auto-cleaned, else null.
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* would
|
|
112
|
+
* files and couldn't be auto-cleaned, else null.
|
|
113
|
+
*
|
|
114
|
+
* The store is a DIRECTORY of one file per finding, all at predictable,
|
|
115
|
+
* world-visible $TMPDIR paths, so both the directory and every entry in it are
|
|
116
|
+
* attacker-plantable: trust the directory only when it is a real directory this
|
|
117
|
+
* uid owns (a symlink would let a co-tenant aim this reader at unrelated files),
|
|
118
|
+
* each entry only when markerIsTrusted confirms a regular file this uid owns,
|
|
119
|
+
* then scrub the bytes through Layer-1 before any caller splices them into a
|
|
120
|
+
* reason — the report would otherwise carry ANSI/invisible spoofing into the
|
|
121
|
+
* model's context.
|
|
122
|
+
* This session's store AND the shared `no-session` fallback, because a hook that
|
|
123
|
+
* faults BEFORE it can parse its payload has no session identity to key by: its
|
|
124
|
+
* finding lands in the fallback, and a strictly session-keyed read would leave
|
|
125
|
+
* the one report of an unscanned instruction file unreachable. The ack stays
|
|
126
|
+
* strictly session-keyed, so the gate still asks exactly once per session.
|
|
127
|
+
* @param {string} [sessionId]
|
|
74
128
|
* @returns {string | null}
|
|
75
129
|
*/
|
|
76
|
-
export function invisibleCharAlert(): string | null;
|
|
130
|
+
export function invisibleCharAlert(sessionId?: string): string | null;
|
|
77
131
|
/**
|
|
78
|
-
* Add `text` to the alert the PreToolUse gate surfaces, keeping
|
|
79
|
-
* already there.
|
|
132
|
+
* Add `text` to the alert the PreToolUse gate surfaces this session, keeping
|
|
133
|
+
* whatever is already there.
|
|
80
134
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* world-visible $TMPDIR path; a foreign or squatted file reads as empty and is
|
|
87
|
-
* replaced rather than appended to.
|
|
135
|
+
* One O_EXCL-created, randomly-named file per finding. The store used to be a
|
|
136
|
+
* single file appended through a read-modify-write, so two hooks recording a
|
|
137
|
+
* finding at once silently dropped one of them; a fresh file per finding has no
|
|
138
|
+
* shared cell to lose. Symlink-refusing (writeFileNoFollow) because the store
|
|
139
|
+
* sits at a predictable, world-visible $TMPDIR path.
|
|
88
140
|
* @param {string} text
|
|
89
|
-
* @
|
|
141
|
+
* @param {string} [sessionId]
|
|
142
|
+
* @returns {boolean} whether the finding was recorded
|
|
90
143
|
*/
|
|
91
|
-
export function appendAlert(text: string):
|
|
144
|
+
export function appendAlert(text: string, sessionId?: string): boolean;
|
|
92
145
|
/**
|
|
93
146
|
* True once the gate has surfaced its blocking ask this session. Validates
|
|
94
|
-
* ownership (not mere existence): a co-tenant could pre-create
|
|
95
|
-
* predictable $TMPDIR path to permanently suppress the one-time blocking ask down
|
|
96
|
-
* the passive reminder, so trust the marker only when it is a regular file
|
|
97
|
-
* wrote (markerIsTrusted), mirroring how acknowledgeAlert writes it.
|
|
147
|
+
* ownership (not mere existence): a co-tenant could pre-create the ack at its
|
|
148
|
+
* predictable $TMPDIR path to permanently suppress the one-time blocking ask down
|
|
149
|
+
* to the passive reminder, so trust the marker only when it is a regular file
|
|
150
|
+
* this uid wrote (markerIsTrusted), mirroring how acknowledgeAlert writes it.
|
|
151
|
+
* @param {string} [sessionId]
|
|
98
152
|
* @returns {boolean}
|
|
99
153
|
*/
|
|
100
|
-
export function alertAcknowledged(): boolean;
|
|
154
|
+
export function alertAcknowledged(sessionId?: string): boolean;
|
|
101
155
|
/**
|
|
102
156
|
* Record that the gate has surfaced its blocking ask, so later tool calls get a
|
|
103
|
-
* passive reminder instead of an ask on every call.
|
|
104
|
-
*
|
|
157
|
+
* passive reminder instead of an ask on every call. Session-keyed, so the next
|
|
158
|
+
* session re-asks once without anything having to clear this.
|
|
159
|
+
* @param {string} [sessionId]
|
|
105
160
|
* @returns {void}
|
|
106
161
|
*/
|
|
107
|
-
export function acknowledgeAlert(): void;
|
|
162
|
+
export function acknowledgeAlert(sessionId?: string): void;
|
|
108
163
|
/**
|
|
109
164
|
* The blocking ask. The heading states only that the scan did not finish clean:
|
|
110
165
|
* the alert carries injection findings, unreadable targets, or a scanner fault,
|
|
@@ -121,10 +176,9 @@ export function gateAskReason(findings: string): string;
|
|
|
121
176
|
* @returns {string}
|
|
122
177
|
*/
|
|
123
178
|
export function gateReminderContext(): string;
|
|
124
|
-
/**
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
export const
|
|
130
|
-
export const ALERT_ACK_FILE: string;
|
|
179
|
+
/**
|
|
180
|
+
* The path prefix every alert artifact of this PROJECT shares. Never a file
|
|
181
|
+
* itself — only {@link sessionPrefix} and the sweep read it — so that one
|
|
182
|
+
* `startsWith` covers every artifact the sweep must age out.
|
|
183
|
+
*/
|
|
184
|
+
export const ALERT_BASE: string;
|
|
@@ -5,6 +5,15 @@
|
|
|
5
5
|
* @returns {string}
|
|
6
6
|
*/
|
|
7
7
|
export function revealDir(): string;
|
|
8
|
+
/**
|
|
9
|
+
* Delete this project's reveal sidecars and spans older than {@link REVEAL_TTL_MS}.
|
|
10
|
+
*
|
|
11
|
+
* Nothing else removes them: every entry is content-addressed, so a store that is
|
|
12
|
+
* never swept grows for the life of the machine. Called from SessionStart, the one
|
|
13
|
+
* touchpoint that runs once per session rather than once per tool call.
|
|
14
|
+
* @returns {void}
|
|
15
|
+
*/
|
|
16
|
+
export function sweepStaleReveals(): void;
|
|
8
17
|
/**
|
|
9
18
|
* Persist one reveal's pre-splice text and return the model-facing hint naming
|
|
10
19
|
* its path, or null when the write fails (the splice already protected the
|
|
@@ -68,6 +77,13 @@ export function readSpan(key: string): string | null;
|
|
|
68
77
|
* @returns {boolean}
|
|
69
78
|
*/
|
|
70
79
|
export function isRevealRead(toolName: string, toolInput: any): boolean;
|
|
80
|
+
/**
|
|
81
|
+
* How long a reveal sidecar or span file is kept before a later session sweeps it.
|
|
82
|
+
* It must outlast the longest session that could still rehydrate a placeholder the
|
|
83
|
+
* model is holding, so it is generous rather than tight — these files are small,
|
|
84
|
+
* and the cost of sweeping one too early is a rehydration that fails closed.
|
|
85
|
+
*/
|
|
86
|
+
export const REVEAL_TTL_MS: number;
|
|
71
87
|
/**
|
|
72
88
|
* The model-facing line telling it Layer-2 placeholders round-trip: pushed once
|
|
73
89
|
* per tool output whose splices were persisted, so the model knows to leave the
|
|
@@ -18,6 +18,17 @@ export function dropFingerprint(filePath: string, content: string, dropped?: str
|
|
|
18
18
|
* @returns {string}
|
|
19
19
|
*/
|
|
20
20
|
export function confirmMarkerPath(fingerprint: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Delete this project's confirm sentinels older than {@link CONFIRM_TTL_MS}.
|
|
23
|
+
*
|
|
24
|
+
* consumeConfirm removes the sentinel it honors, but an abandoned confirmation —
|
|
25
|
+
* denied, never retried — is removed by nothing, so the store grows for the life
|
|
26
|
+
* of the machine. Called from SessionStart, the one touchpoint that runs once per
|
|
27
|
+
* session rather than once per tool call. A sentinel past the TTL is already inert
|
|
28
|
+
* (consumeConfirm refuses it), so this reclaims space without changing a verdict.
|
|
29
|
+
* @returns {void}
|
|
30
|
+
*/
|
|
31
|
+
export function sweepStaleConfirms(): void;
|
|
21
32
|
/**
|
|
22
33
|
* Whether git tracks `filePath`. Exit 0 is tracked; exit 1 (untracked) and 128
|
|
23
34
|
* (not a repository) both mean "no git recovery exists", which is what the
|
|
@@ -46,18 +46,33 @@ export function formatSkipped(skipped: Array<{
|
|
|
46
46
|
* @param {{
|
|
47
47
|
* trace?: import("./lib/trace.mjs").TraceFn,
|
|
48
48
|
* scan?: () => ReturnType<typeof scanProject>,
|
|
49
|
+
* sessionId?: string,
|
|
49
50
|
* }} [opts] `trace` is where this scan announces engagement; a host with its
|
|
50
51
|
* own trace channel passes its sink so the announcement lands where its
|
|
51
52
|
* detector reads (see lib/trace.mjs). `scan` is the scanner, injectable so the
|
|
52
53
|
* FAULT path below — a scanner that throws something other than an errno, i.e.
|
|
53
54
|
* a bug — is drivable end to end; no filesystem state can force it, and an
|
|
54
55
|
* untested fault path is how a posture goes missing in the first place.
|
|
56
|
+
* `sessionId` keys the alert store this scan writes; the CLI entry reads it off
|
|
57
|
+
* the SessionStart payload, and an in-process caller passes it directly.
|
|
55
58
|
* @returns {Promise<void>}
|
|
56
59
|
*/
|
|
57
60
|
export function cliMain(opts?: {
|
|
58
61
|
trace?: import("./lib/trace.mjs").TraceFn;
|
|
59
62
|
scan?: () => ReturnType<typeof scanProject>;
|
|
63
|
+
sessionId?: string;
|
|
60
64
|
}): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* The session identity from the SessionStart payload on stdin, or undefined when
|
|
67
|
+
* the host sent none.
|
|
68
|
+
*
|
|
69
|
+
* Swallowing: the payload is read for ONE optional field, and a host that pipes
|
|
70
|
+
* nothing (or malformed JSON) must still get the scan — a session-start scan
|
|
71
|
+
* refused over an unparseable envelope is a strictly worse outcome than one
|
|
72
|
+
* keyed to the shared `no-session` fallback.
|
|
73
|
+
* @returns {Promise<string | undefined>}
|
|
74
|
+
*/
|
|
75
|
+
export function sessionIdFromStdin(): Promise<string | undefined>;
|
|
61
76
|
/**
|
|
62
77
|
* Read one file and run the SSOT scan over it. The scan logic itself (long-run
|
|
63
78
|
* decode + scattered threshold-evasion counting) is `scanText`'s — a local
|
|
@@ -108,9 +123,9 @@ export function decodeRun(run: string): {
|
|
|
108
123
|
* @returns {string[]}
|
|
109
124
|
*/
|
|
110
125
|
export function findInstructionFiles(dir: string): string[];
|
|
111
|
-
import {
|
|
112
|
-
import {
|
|
126
|
+
import { alertAckFile } from "./lib/invisible-alert.mjs";
|
|
127
|
+
import { alertDir } from "./lib/invisible-alert.mjs";
|
|
113
128
|
export let LONG_RUN_RE: RegExp;
|
|
114
129
|
export let LONG_RUN_THRESHOLD: 10;
|
|
115
130
|
export let TOTAL_INVISIBLE_THRESHOLD: 30;
|
|
116
|
-
export { CLAUDE_CONTEXT_SUBDIRS, CLAUDE_INSTRUCTION_GLOBS, CLAUDE_LAUNCH_GLOBS,
|
|
131
|
+
export { CLAUDE_CONTEXT_SUBDIRS, CLAUDE_INSTRUCTION_GLOBS, CLAUDE_LAUNCH_GLOBS, alertAckFile, alertDir };
|