agent-sanitizer 2.35.1 → 2.37.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 CHANGED
@@ -151,14 +151,20 @@ Per-vector detail in [`THREAT-MODEL.md`](./THREAT-MODEL.md).
151
151
 
152
152
  ## What installing entails
153
153
 
154
- Installing the plugin puts four hooks on every session, and this is what they
154
+ Installing the plugin puts five hooks on every session, and this is what they
155
155
  buy you:
156
156
 
157
157
  1. Your `CLAUDE.md`, `AGENTS.md` and the context markdown under `.claude/` are
158
- scanned at session start for hidden-Unicode payloads and auto-cleaned where
159
- possible. Only the subdirectories Claude Code loads as context are walked, so
160
- bulk data parked under `.claude/` (`worktrees/`, caches, transcripts) does not
161
- slow startup.
158
+ scanned for hidden-Unicode payloads and auto-cleaned where possible. Session
159
+ start covers what Claude Code loads at launch the project root's own
160
+ instruction files, the `CLAUDE.md` chain above it, and the root `.claude/`
161
+ context subdirectories (rules, skills, agents), never bulk data parked there
162
+ (`worktrees/`, caches, transcripts). Everything else Claude Code loads — a
163
+ subdirectory's `CLAUDE.md`, a path-scoped rule, an `@import`, your
164
+ user-global `~/.claude/CLAUDE.md` and global rules — is scanned from the
165
+ bytes the load event carries, at the moment it loads, so startup costs no
166
+ tree walk at all. Only files inside the project are rewritten: one shared
167
+ with every other project on the machine is reported, not edited.
162
168
  2. Prompts carrying payload-capable invisible or ANSI characters are blocked
163
169
  before they reach the model; pasted terminal color passes with a note.
164
170
  3. Look-alike glyphs in tool inputs are folded to ASCII, so a Cyrillic `а` can't
@@ -191,8 +197,9 @@ working one. Neither posture touches what a sanitizer that RAN decided (see
191
197
 
192
198
  ## Using it with Claude Code
193
199
 
194
- The plugin installed above puts four hooks on the tool stream: tool input, tool
195
- output, user prompts, and a session-start scan of the instruction files. It
200
+ The plugin installed above puts five hooks on the tool stream: tool input, tool
201
+ output, user prompts, a session-start scan of the instruction files that load at
202
+ launch, and a per-file scan of every instruction file loaded after that. It
196
203
  needs only `python3` on PATH, for Layer 4 — the plugin ships the engine itself
197
204
  (see [What installing entails](#what-installing-entails)).
198
205
 
@@ -216,7 +223,7 @@ guessing if the marketplace was never added. To pull a release by hand instead:
216
223
 
217
224
  `plugin/README.md` has the managed-settings form for enabling it fleet-wide.
218
225
 
219
- To wire them yourself instead, one entry dispatches all four modes on `--hook=`:
226
+ To wire them yourself instead, one entry dispatches every mode on `--hook=`:
220
227
 
221
228
  ```jsonc
222
229
  // settings.json — one entry per event; PreToolUse/PostToolUse also take "matcher": "*"
@@ -226,12 +233,13 @@ To wire them yourself instead, one entry dispatches all four modes on `--hook=`:
226
233
  }
227
234
  ```
228
235
 
229
- | Event | `--hook=` |
230
- | ------------------ | ---------------------- |
231
- | `UserPromptSubmit` | `sanitize-user-prompt` |
232
- | `PreToolUse` | `pretooluse-sanitize` |
233
- | `PostToolUse` | `sanitize-output` |
234
- | `SessionStart` | `scan-invisible-chars` |
236
+ | Event | `--hook=` |
237
+ | -------------------- | -------------------------- |
238
+ | `UserPromptSubmit` | `sanitize-user-prompt` |
239
+ | `PreToolUse` | `pretooluse-sanitize` |
240
+ | `PostToolUse` | `sanitize-output` |
241
+ | `SessionStart` | `scan-invisible-chars` |
242
+ | `InstructionsLoaded` | `scan-loaded-instructions` |
235
243
 
236
244
  `require.resolve("agent-sanitizer/claude-hooks")` gives the path without
237
245
  hardcoding a layout. Importing the module rather than spawning it is a no-op.
@@ -259,22 +267,23 @@ singleton, and two copies in one bundle double-fire the inlined CLIs.
259
267
 
260
268
  <!-- exports-table: rows are asserted to equal package.json's ./claude-hooks* exports by test/claude-hooks-exports.test.mjs -->
261
269
 
262
- | Subpath | What it is |
263
- | ----------------------------------- | ------------------------------------------------------------------------------------------ |
264
- | `claude-hooks` | The `--hook=` CLI dispatcher all four hooks are spawned through |
265
- | `claude-hooks/pretooluse-sanitize` | PreToolUse orchestrator: invisible-char gate, confusable folding, stego strip, rehydration |
266
- | `claude-hooks/sanitize-output` | PostToolUse pipeline: Layers 1–4 over tool output, plus the host-extension bag |
267
- | `claude-hooks/sanitize-user-prompt` | UserPromptSubmit verdict on payload-capable invisible/ANSI content |
268
- | `claude-hooks/scan-invisible-chars` | SessionStart scan of `CLAUDE.md` / `.claude/` markdown |
269
- | `claude-hooks/lib/hook-io` | Shared hook I/O: the lazy-module registry, the CLI slot, deadlines, the hookgate marker |
270
- | `claude-hooks/lib/control-plane` | Bridge to `agent-control-plane-core` and the shared judge-CLI transport |
271
- | `claude-hooks/lib/authored-content` | Stego + terminal-control stripping of the fields the MODEL authors |
272
- | `claude-hooks/lib/env-config` | The env-bound secret vocabulary the Layer-4 pre-gate and the redactor client share |
273
- | `claude-hooks/lib/invisible-alert` | Cross-hook alert state for uncleanable invisible-char injection in instruction files |
274
- | `claude-hooks/lib/redactor-client` | Client for the long-lived `agent-secret-redactor-daemon` (Layer 4's transport) |
275
- | `claude-hooks/lib/reveal` | The Layer-2 sidecar that lets the model re-read what the HTML splice removed |
276
- | `claude-hooks/lib/secret-annotate` | The cheap deterministic Layer-4 pre-gate checks around the daemon call |
277
- | `claude-hooks/lib/trace` | The opt-in structured trace channel every layer announces itself on |
270
+ | Subpath | What it is |
271
+ | --------------------------------------- | ------------------------------------------------------------------------------------------ |
272
+ | `claude-hooks` | The `--hook=` CLI dispatcher every hook is spawned through |
273
+ | `claude-hooks/pretooluse-sanitize` | PreToolUse orchestrator: invisible-char gate, confusable folding, stego strip, rehydration |
274
+ | `claude-hooks/sanitize-output` | PostToolUse pipeline: Layers 1–4 over tool output, plus the host-extension bag |
275
+ | `claude-hooks/sanitize-user-prompt` | UserPromptSubmit verdict on payload-capable invisible/ANSI content |
276
+ | `claude-hooks/scan-invisible-chars` | SessionStart scan of the instruction files that load at launch |
277
+ | `claude-hooks/scan-loaded-instructions` | InstructionsLoaded scan of each instruction file as Claude Code loads it |
278
+ | `claude-hooks/lib/hook-io` | Shared hook I/O: the lazy-module registry, the CLI slot, deadlines, the hookgate marker |
279
+ | `claude-hooks/lib/control-plane` | Bridge to `agent-control-plane-core` and the shared judge-CLI transport |
280
+ | `claude-hooks/lib/authored-content` | Stego + terminal-control stripping of the fields the MODEL authors |
281
+ | `claude-hooks/lib/env-config` | The env-bound secret vocabulary the Layer-4 pre-gate and the redactor client share |
282
+ | `claude-hooks/lib/invisible-alert` | Cross-hook alert state for uncleanable invisible-char injection in instruction files |
283
+ | `claude-hooks/lib/redactor-client` | Client for the long-lived `agent-secret-redactor-daemon` (Layer 4's transport) |
284
+ | `claude-hooks/lib/reveal` | The Layer-2 sidecar that lets the model re-read what the HTML splice removed |
285
+ | `claude-hooks/lib/secret-annotate` | The cheap deterministic Layer-4 pre-gate checks around the daemon call |
286
+ | `claude-hooks/lib/trace` | The opt-in structured trace channel every layer announces itself on |
278
287
 
279
288
  Only `plugin-hooks` itself is unexported under its own name — it is reachable as
280
289
  the bare `claude-hooks` entry above.
@@ -334,7 +343,8 @@ await cliMain({ trace: (event, fields) => myChannel.emit(event, fields) });
334
343
  ```
335
344
 
336
345
  The sink rides each hook's options bag — `cliMain({trace})` on
337
- `scan-invisible-chars` and `pretooluse-sanitize`, the extension bag's `trace` on
346
+ `scan-invisible-chars`, `scan-loaded-instructions` and `pretooluse-sanitize`,
347
+ the extension bag's `trace` on
338
348
  `sanitize-output`, `main(read, write, {trace})` on `sanitize-user-prompt`. It
339
349
  receives the same `TraceEvent` names the default emits, and it **replaces** the
340
350
  default rather than running alongside it — the package channel goes silent, so
package/THREAT-MODEL.md CHANGED
@@ -281,6 +281,22 @@ ASCII, zero-width **binary**), and catches scattered payloads below the long-run
281
281
  threshold. `cleanFile` strips the payload in place (Layer-1 strip), failing loud
282
282
  if a contaminated file cannot be rewritten.
283
283
 
284
+ As Claude Code hooks the coverage is split to match how Claude Code loads these
285
+ files. `scan-invisible-chars` (SessionStart) scans the launch set — the project
286
+ root's own instruction files, the `CLAUDE.md` chain above it, and the root
287
+ `.claude/` context subdirectories — and `scan-loaded-instructions`
288
+ (InstructionsLoaded) scans every other instruction file — including the
289
+ user-global `~/.claude` memory and rules, which load into every session on the
290
+ machine — reading the one path the event names, at the moment it loads. The second cannot block: the file is already in
291
+ context when it fires, so its neutralization is to strip the payload from disk
292
+ (so no reload re-reads it) and tell the model to treat what it just read as
293
+ untrusted data. Auto-cleaning is confined to `CLAUDE_PROJECT_DIR` in both — an
294
+ ancestor file, or one under `~/.claude`, is shared with every other project on
295
+ the machine, so it is reported through the cross-hook alert and never
296
+ rewritten. A Claude Code build that emits
297
+ no `InstructionsLoaded` event loses the lazy half entirely; the PreToolUse gate
298
+ says so once per session rather than leaving the gap silent.
299
+
284
300
  ## User-prompt verdict
285
301
 
286
302
  `./prompt` classifies a submitted prompt as **pass / pass-with-note / block** on
@@ -45,6 +45,7 @@ export const FAULT_POLICY_HOOKS = Object.freeze([
45
45
  "sanitize-output",
46
46
  "sanitize-user-prompt",
47
47
  "scan-invisible-chars",
48
+ "scan-loaded-instructions",
48
49
  ]);
49
50
 
50
51
  /**
@@ -186,6 +186,7 @@ export const HookEvent = Object.freeze({
186
186
  POST_TOOL_USE: "PostToolUse",
187
187
  USER_PROMPT_SUBMIT: "UserPromptSubmit",
188
188
  SESSION_START: "SessionStart",
189
+ INSTRUCTIONS_LOADED: "InstructionsLoaded",
189
190
  });
190
191
 
191
192
  /** Claude Code permissionDecision verdicts. */
@@ -10,14 +10,15 @@
10
10
  * Both hooks reach the state through this module so the paths and the trust rule
11
11
  * have one definition.
12
12
  */
13
- import { readFileSync } from "node:fs";
13
+ import { lstatSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
14
14
  import { createHash } from "node:crypto";
15
- import { join } from "node:path";
15
+ import { basename, join } from "node:path";
16
16
  import { tmpdir } from "node:os";
17
17
  import {
18
18
  lazyImport,
19
19
  markerIsTrusted,
20
20
  scrubUntrustedText,
21
+ writeFileNoFollow,
21
22
  writeSentinelFile,
22
23
  } from "./hook-io.mjs";
23
24
 
@@ -51,6 +52,128 @@ export const ALERT_FILE = join(
51
52
  // ALERT_FILE so each fresh session re-asks once.
52
53
  export const ALERT_ACK_FILE = `${ALERT_FILE}.acked`;
53
54
 
55
+ /**
56
+ * Marker the InstructionsLoaded scanner writes on every fire, so another hook
57
+ * can tell whether that event is being scanned at all this session.
58
+ *
59
+ * Keyed by the SESSION, and never cleared at SessionStart like the alert pair
60
+ * above (a later session sweeps it once it is older than the TTL):
61
+ * nothing pins the order of SessionStart against the InstructionsLoaded events
62
+ * Claude Code fires for the files it loads at launch, so a clear could erase a
63
+ * marker written moments earlier and produce the notice on a session that IS
64
+ * covered. Session-keyed, the question each session asks is answered by that
65
+ * session's own file and no ordering matters. A host that exports no session id
66
+ * falls back to one shared name — where the marker can outlive its session, and
67
+ * a later session on a host that stopped emitting the event stays quiet.
68
+ * @param {string} [sessionId]
69
+ * @returns {string}
70
+ */
71
+ export function instructionsLoadedFile(sessionId) {
72
+ // The id becomes a path component, so anything outside this class — a `/` in
73
+ // a hostile session id above all — is folded away rather than escaping
74
+ // $TMPDIR.
75
+ const key =
76
+ (sessionId ?? "").replace(/[^A-Za-z0-9._-]/gu, "_") || "no-session";
77
+ return `${ALERT_FILE}.instructions-loaded.${key}`;
78
+ }
79
+
80
+ /**
81
+ * Companion marker: the notice below has been surfaced this session.
82
+ * @param {string} [sessionId]
83
+ * @returns {string}
84
+ */
85
+ export function instructionsLoadedNoticeFile(sessionId) {
86
+ return `${instructionsLoadedFile(sessionId)}.noticed`;
87
+ }
88
+
89
+ /**
90
+ * Whether the InstructionsLoaded scanner has run this session — i.e. whether the
91
+ * lazily-loaded instruction files are being scanned at all. Ownership-validated
92
+ * like every other marker here: a co-tenant could otherwise plant the
93
+ * predictable path and suppress the notice below, which is the whole signal that
94
+ * nested files are going unscanned.
95
+ * @param {string} [sessionId]
96
+ * @returns {boolean}
97
+ */
98
+ export function instructionsLoadedSeen(sessionId) {
99
+ return markerIsTrusted(instructionsLoadedFile(sessionId));
100
+ }
101
+
102
+ /** How long a past session's marker is kept before the next session sweeps it. */
103
+ const MARKER_TTL_MS = 7 * 24 * 60 * 60 * 1000;
104
+
105
+ /**
106
+ * Delete this project's session markers older than the TTL. Only PAST sessions'
107
+ * files are candidates — the current session's was just written, so the sweep
108
+ * cannot answer its own question wrong.
109
+ * @param {string} keep the marker path this session owns
110
+ * @returns {void}
111
+ */
112
+ function sweepStaleMarkers(keep) {
113
+ const dir = tmpdir();
114
+ const prefix = `${basename(ALERT_FILE)}.instructions-loaded.`;
115
+ const cutoff = Date.now() - MARKER_TTL_MS;
116
+ for (const name of readdirSync(dir)) {
117
+ if (!name.startsWith(prefix)) continue;
118
+ const path = join(dir, name);
119
+ if (path === keep || path === `${keep}.noticed`) continue;
120
+ // lstat, not stat: a squatted symlink at a predictable $TMPDIR path must be
121
+ // judged on ITSELF, not on whatever it points at. unlink removes the link.
122
+ if (lstatSync(path).mtimeMs < cutoff) unlinkSync(path);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Record that the InstructionsLoaded scanner engaged. Symlink-safe presence
128
+ * write (see writeSentinelFile) at a predictable $TMPDIR path.
129
+ *
130
+ * The event fires once per instruction file loaded, so the already-recorded case
131
+ * returns without a write — and the stale-marker sweep rides the FIRST fire of a
132
+ * session, where one readdir is paid once rather than per loaded file.
133
+ * @param {string} [sessionId]
134
+ * @returns {void}
135
+ */
136
+ export function recordInstructionsLoaded(sessionId) {
137
+ const marker = instructionsLoadedFile(sessionId);
138
+ if (markerIsTrusted(marker)) return;
139
+ writeSentinelFile(marker);
140
+ sweepStaleMarkers(marker);
141
+ }
142
+
143
+ /**
144
+ * The one-time context line for a session where no InstructionsLoaded scan ran,
145
+ * or null when the scan has been seen or the notice was already surfaced this
146
+ * session. Records the notice as it hands it out, so it rides on ONE tool call
147
+ * rather than every one — the per-call repeat is what trains a reader to skip it.
148
+ *
149
+ * The loss it names is real and otherwise invisible: SessionStart scans the
150
+ * instruction files that load at launch, and everything a subdirectory loads
151
+ * later is scanned by the event. No scan, and nothing says so.
152
+ *
153
+ * The notice names the OBSERVABLE — no scan ran — and both of its causes, because
154
+ * the marker cannot tell a host that never emits the event from an operator who
155
+ * switched the hook off in AGENT_SANITIZER_DISABLED_HOOKS, and asserting the
156
+ * first would send an operator who chose the second to the wrong fix.
157
+ * @param {string} [sessionId] the harness's session identity, so the answer
158
+ * belongs to THIS session (see instructionsLoadedFile)
159
+ * @returns {string | null}
160
+ */
161
+ export function instructionsLoadedGapNotice(sessionId) {
162
+ if (instructionsLoadedSeen(sessionId)) return null;
163
+ const noticeFile = instructionsLoadedNoticeFile(sessionId);
164
+ if (markerIsTrusted(noticeFile)) return null;
165
+ writeSentinelFile(noticeFile);
166
+ return (
167
+ "agent-sanitizer: no InstructionsLoaded scan has run this session, so " +
168
+ "instruction files loaded from SUBDIRECTORIES (a nested CLAUDE.md, a " +
169
+ "directory-scoped rule) are reaching the model unscanned for hidden " +
170
+ "Unicode — the session-start scan covers only the files loaded at launch. " +
171
+ "Tell the user, and name both causes: a Claude Code that does not emit " +
172
+ "the event (upgrading restores the coverage), or scan-loaded-instructions " +
173
+ "switched off in AGENT_SANITIZER_DISABLED_HOOKS."
174
+ );
175
+ }
176
+
54
177
  /**
55
178
  * The alert findings if invisible-char injection was detected in instruction
56
179
  * files and couldn't be auto-cleaned, else null. ALERT_FILE lives at a predictable,
@@ -67,6 +190,27 @@ export function invisibleCharAlert() {
67
190
  return scrubUntrustedText(raw, applyLayer1);
68
191
  }
69
192
 
193
+ /**
194
+ * Add `text` to the alert the PreToolUse gate surfaces, keeping whatever is
195
+ * already there.
196
+ *
197
+ * Appending, where the SessionStart scanner TRUNCATES: that scan runs once and
198
+ * owns the session's reset, while an instruction file loaded mid-session is one
199
+ * more finding on top of whatever the launch scan left — a truncating write here
200
+ * would silently drop the earlier report. Symlink-refusing (writeFileNoFollow)
201
+ * and ownership-checked on read, because ALERT_FILE sits at a predictable,
202
+ * world-visible $TMPDIR path; a foreign or squatted file reads as empty and is
203
+ * replaced rather than appended to.
204
+ * @param {string} text
205
+ * @returns {void}
206
+ */
207
+ export function appendAlert(text) {
208
+ const existing = markerIsTrusted(ALERT_FILE)
209
+ ? readFileSync(ALERT_FILE, "utf-8")
210
+ : "";
211
+ writeFileNoFollow(ALERT_FILE, existing + text + "\n");
212
+ }
213
+
70
214
  /**
71
215
  * True once the gate has surfaced its blocking ask this session. Validates
72
216
  * ownership (not mere existence): a co-tenant could pre-create ALERT_ACK_FILE at its
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The operator-facing report for hidden-Unicode findings in instruction files.
3
+ *
4
+ * Its own module because two hooks render it — the SessionStart scan of the
5
+ * files that load at launch, and the InstructionsLoaded scan of every file
6
+ * loaded after that — and a second copy would drift in exactly the way that
7
+ * matters: the framing that keeps a decoded payload from reading as an
8
+ * instruction (see decodeRun's `untrusted data` prefix) is part of the report,
9
+ * not decoration on it.
10
+ */
11
+
12
+ /**
13
+ * @param {Array<{
14
+ * file: string,
15
+ * findings: Array<{ line: number | null, charCount: number, method: string, decoded: string }>,
16
+ * }>} allFindings
17
+ * @returns {string}
18
+ */
19
+ export function formatReport(allFindings) {
20
+ const BAR = "━".repeat(52);
21
+ const lines = [
22
+ "",
23
+ `━━━ INVISIBLE CHARACTER INJECTION DETECTED ${BAR.slice(0, 11)}`,
24
+ "",
25
+ "Invisible Unicode in instruction files can hijack the model’s behavior",
26
+ "(skill invocation, tool use, instruction override). This commonly",
27
+ "happens when copy-pasting content from the internet.",
28
+ "",
29
+ "These files are loaded directly as context, bypassing PostToolUse",
30
+ "sanitization, so the invisible characters reach the model raw.",
31
+ "",
32
+ ];
33
+
34
+ for (const { file, findings } of allFindings) {
35
+ lines.push(` ${file}:`);
36
+ for (const finding of findings) {
37
+ // `line` is null for the whole-file scattered-chars finding, which is
38
+ // not tied to any single line.
39
+ const where =
40
+ finding.line === null ? "Whole file" : `Line ${finding.line}`;
41
+ lines.push(
42
+ ` ${where}: ${finding.charCount} invisible chars (${finding.method})`,
43
+ );
44
+ lines.push(` Decodes to: ${JSON.stringify(finding.decoded)}`);
45
+ }
46
+ lines.push("");
47
+ }
48
+
49
+ lines.push(BAR);
50
+ return lines.join("\n");
51
+ }
@@ -35,6 +35,7 @@ import { appendFileSync } from "node:fs";
35
35
  export const TraceEvent = Object.freeze({
36
36
  HOOK_RAN: "hook_ran",
37
37
  SCAN_INVISIBLE_CHARS_RAN: "scan_invisible_chars_ran",
38
+ SCAN_LOADED_INSTRUCTIONS_RAN: "scan_loaded_instructions_ran",
38
39
  });
39
40
 
40
41
  const LEVELS = Object.freeze({ off: 0, info: 1, debug: 2 });
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Entry point: ALL FOUR sanitization hooks behind one dispatch flag. The four
3
- * hooks share almost the entire package graph, so one bundle per hook would ship
4
- * four near-identical copies; a single entry with a `--hook=<name>` flag
2
+ * Entry point: EVERY sanitization hook behind one dispatch flag. The hooks
3
+ * share almost the entire package graph, so one bundle per hook would ship a
4
+ * near-identical copy per hook; a single entry with a `--hook=<name>` flag
5
5
  * selecting the hook ships the graph once (hooks.json passes the flag).
6
6
  *
7
7
  * The plugin's shipped artifact is the esbuild BUNDLE with every package inlined
@@ -33,7 +33,7 @@ import {
33
33
  const HOOK_NAME = "plugin-hooks";
34
34
 
35
35
  // The dispatcher's entry in the one posture table (lib/hook-fault.mjs). It
36
- // answers no single event — it is the binder in front of all four — so it
36
+ // answers no single event — it is the binder in front of all of them — so it
37
37
  // carries no stdout envelope and both arms are process-level.
38
38
  //
39
39
  // BOTH ARMS BLOCK, and that is the declaration, not an oversight. The
@@ -41,7 +41,7 @@ const HOOK_NAME = "plugin-hooks";
41
41
  // mode is static wiring corruption, which means no hook runs at all, silently,
42
42
  // for the life of the install — there is no run to degrade. Stating it here (and
43
43
  // pinning it in plugin/test/plugin-bundle.test.mjs) is the point of the table:
44
- // the arm that ignores the knob does so on the record, next to the four that
44
+ // the arm that ignores the knob does so on the record, next to the ones that
45
45
  // honor it, instead of by hard-exiting past the question.
46
46
  //
47
47
  // Exit 2 is the one non-zero code Claude Code treats as BLOCKING: it blocks
@@ -161,6 +161,16 @@ const HOOKS = {
161
161
  await cliMain();
162
162
  },
163
163
  },
164
+ "scan-loaded-instructions": {
165
+ event: HookEvent.INSTRUCTIONS_LOADED,
166
+ run: async () => {
167
+ const { cliMain } =
168
+ /** @type {typeof import("./scan-loaded-instructions.mjs")} */ (
169
+ await import("./scan-loaded-instructions.mjs")
170
+ );
171
+ await cliMain();
172
+ },
173
+ },
164
174
  };
165
175
 
166
176
  /** The dispatchable hook names, in hooks.json's spelling. */
@@ -55,6 +55,7 @@ import {
55
55
  gateReminderContext,
56
56
  alertAcknowledged,
57
57
  acknowledgeAlert,
58
+ instructionsLoadedGapNotice,
58
59
  } from "./lib/invisible-alert.mjs";
59
60
  import {
60
61
  sanitizeAuthoredContent,
@@ -460,6 +461,14 @@ export async function buildPreToolUseResponse(
460
461
 
461
462
  const { tool_name: tool, tool_input: toolInput } = input;
462
463
 
464
+ // Coverage notice, once per session: with no InstructionsLoaded scan running,
465
+ // every instruction file loaded from a subdirectory reaches the model
466
+ // unscanned, and the SessionStart scan — which covers only what loads at
467
+ // launch — cannot see the loss. Reported here because this is the first hook
468
+ // that runs after the loads would have happened.
469
+ const gapNotice = instructionsLoadedGapNotice(input.session_id);
470
+ if (gapNotice !== null) contexts.push(gapNotice);
471
+
463
472
  // Layers 2-4, run by the declared pipeline: the driver — not this call order —
464
473
  // is what keeps the confusable fold's soundness precondition true once an
465
474
  // erasing layer follows it (see lib/layer-pipeline.mjs).