agent-sanitizer 2.2.2 → 2.4.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.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * UserPromptSubmit: gate user prompts on payload-capable invisible Unicode
3
+ * and ANSI escapes. A prompt pasted from a tampered web page can carry tag
4
+ * characters or zero-width sequences that the LLM reads but the user cannot
5
+ * see. The PostToolUse sanitizer never runs on user input, so this is the
6
+ * only line of defense.
7
+ *
8
+ * UserPromptSubmit cannot rewrite the prompt — `additionalContext` is added
9
+ * alongside the original, not in place of it — so the only way to neutralize
10
+ * a payload is to block. Thresholds match scan-invisible-chars (SessionStart)
11
+ * for UX consistency.
12
+ *
13
+ * One carve-out: a prompt whose only escape content is SGR color/style codes
14
+ * (ESC [ params m) passes with a note instead of blocking. Pasting colored
15
+ * terminal output (test runs, build logs) is the single most common debugging
16
+ * action, and SGR is display-only by the ECMA-48 grammar — it cannot move the
17
+ * cursor, erase the screen, or carry an OSC payload. Anything beyond SGR
18
+ * (cursor movement, erase, OSC title-set, DCS/APC/PM) still blocks, as do the
19
+ * invisible-char thresholds, which are the actual web-paste payload defense.
20
+ */
21
+ import { readStdinJson, safeErrMessage, isMain } from "./lib/hook-io.mjs";
22
+ import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
23
+ import { trace, TraceEvent } from "./lib/trace.mjs";
24
+ // classifyPrompt (the user-prompt verdict) and stripAnsiFully (its ANSI stripper)
25
+ // come from the agent-sanitizer package. They are bound by a *caught* dynamic
26
+ // import, never a bare top-level `import … from "…"`: a static npm import
27
+ // resolves before any try/catch, so a missing node_modules would crash this hook
28
+ // at load and let the prompt through UNSANITIZED (fail-open). A failed load
29
+ // leaves the bindings undefined, which main()'s typeof guard turns into a
30
+ // fail-closed block.
31
+ /** @type {typeof import("agent-sanitizer/prompt").classifyPrompt} */
32
+ export let classifyPrompt;
33
+ /** @type {typeof import("agent-sanitizer").stripAnsiFully} */
34
+ let stripAnsiFully;
35
+
36
+ const BLOCK_CONTEXT =
37
+ "User prompt blocked: payload-capable invisible/ANSI characters detected.";
38
+ const SGR_NOTE =
39
+ "The prompt contains ANSI SGR color codes (pasted terminal output). They are display-only formatting noise; read through them.";
40
+
41
+ /* c8 ignore start — module-load boundary: the imports resolve in every real
42
+ * run, and their failure (the package absent) can't be simulated in-process, so
43
+ * neither arm is observable to the in-process tests. main()'s typeof guard
44
+ * converts an undefined stripper into a fail-closed block — that guard IS tested. */
45
+ // Stryker disable all
46
+ try {
47
+ // The /prompt subpath is imported first: if it fails, the catch fires before
48
+ // stripAnsiFully is assigned, so a half-load can never leave the stripper set
49
+ // while the classifier is missing (main guards on the stripper alone).
50
+ ({ classifyPrompt } = await import("agent-sanitizer/prompt"));
51
+ ({ stripAnsiFully } = await import("agent-sanitizer"));
52
+ } catch {
53
+ // Leave classifyPrompt/stripAnsiFully undefined so main()'s typeof guard fails
54
+ // closed — the prompt is blocked, never passed through with the package
55
+ // half-loaded.
56
+ }
57
+ // Stryker restore all
58
+ /* c8 ignore stop */
59
+
60
+ /**
61
+ * Judge a normalized prompt-submit event. Agent-agnostic: consumes the
62
+ * control-plane ToolCallEvent and returns a Verdict, so the same prompt gate
63
+ * renders through any agent adapter, not just Claude's. Throws (into the
64
+ * calling hook's catch) when the sanitizer package never loaded — this hook is
65
+ * the only defense on user input, so a prompt it cannot classify must block,
66
+ * never pass through.
67
+ * @param {import("agent-control-plane-core").ToolCallEvent} event
68
+ * @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
69
+ * to the package's stripAnsiFully; injectable so the fail-closed path is testable)
70
+ * @returns {import("agent-control-plane-core").Verdict}
71
+ */
72
+ export function judgeSanitizeUserPrompt(event, strip = stripAnsiFully) {
73
+ const { Decision, EventKind } = controlPlane();
74
+ // A payload the adapter cannot classify carries no readable prompt, so an
75
+ // abstain would fail OPEN on harness contract drift; this gate's posture is
76
+ // deny-when-blind. (Renders through the adapter's top-level decision:"block"
77
+ // channel — a non-PRE_TOOL event has no permissionDecision body — which Claude
78
+ // honors on UserPromptSubmit.)
79
+ if (event.event === EventKind.UNKNOWN)
80
+ return {
81
+ decision: Decision.DENY,
82
+ reason: "User prompt blocked (fail-closed): unrecognized hook payload.",
83
+ };
84
+ if (event.event !== EventKind.PROMPT_SUBMIT)
85
+ return { decision: Decision.ALLOW };
86
+ // The module-load guard: a missing stripper means agent-sanitizer never
87
+ // loaded. Guarding on the stripper alone is sufficient — it loads AFTER
88
+ // classifyPrompt in the same try, so a present stripper proves the classifier
89
+ // loaded too.
90
+ if (typeof strip !== "function")
91
+ throw new Error("agent-sanitizer is unavailable");
92
+ // The contract guarantees a string here: every adapter normalizes the
93
+ // prompt-submit input (Claude's parse coerces a missing/non-string prompt to
94
+ // "" via asString), so a defensive typeof re-check is a dead branch.
95
+ const prompt = /** @type {string} */ (event.input.prompt);
96
+ if (!prompt) return { decision: Decision.ALLOW };
97
+ const verdict = classifyPrompt(prompt, strip);
98
+ if (verdict.action === "pass") return { decision: Decision.ALLOW };
99
+ if (verdict.action === "note")
100
+ return { decision: Decision.ALLOW, additional_context: SGR_NOTE };
101
+ // block: carry the reason AND a context note — UserPromptSubmit can't rewrite
102
+ // the prompt, so the context is the only forward signal about why it dropped.
103
+ return {
104
+ decision: Decision.DENY,
105
+ reason: verdict.reason,
106
+ additional_context: BLOCK_CONTEXT,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * @param {() => Promise<any> | any} read
112
+ * @param {(chunk: string) => void} write
113
+ * @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
114
+ * to the package's stripAnsiFully; injectable so the fail-closed path is testable)
115
+ * @returns {Promise<void>}
116
+ */
117
+ export async function main(read, write, strip = stripAnsiFully) {
118
+ // Delegate the parse → judge → render → write contract to the shared
119
+ // runJudgeCli so this hook doesn't re-implement the control-plane boundary:
120
+ // runJudgeCli reads stdin BEFORE loading the control-plane package, so a
121
+ // load failure fails to this hook's posture (the onError block) instead of
122
+ // leaving stdin unread. The fail-closed onError writes the UserPromptSubmit
123
+ // `decision:"block"` envelope by hand because the adapter that would render it
124
+ // is exactly what may have failed to load.
125
+ await runJudgeCli(
126
+ "sanitize-user-prompt",
127
+ (event) => {
128
+ const verdict = judgeSanitizeUserPrompt(event, strip);
129
+ // Announce engagement on the trace channel like the other stdin hooks —
130
+ // a prompt gate that silently stopped running is otherwise invisible.
131
+ trace(TraceEvent.HOOK_RAN, {
132
+ hook: "sanitize-user-prompt",
133
+ outcome:
134
+ verdict.decision === controlPlane().Decision.DENY
135
+ ? "deny"
136
+ : verdict.additional_context
137
+ ? "note"
138
+ : "allow",
139
+ });
140
+ return verdict;
141
+ },
142
+ {
143
+ readInput: read,
144
+ write,
145
+ onError: (err) =>
146
+ write(
147
+ JSON.stringify({
148
+ decision: "block",
149
+ reason: `sanitize-user-prompt hook failed (fail-closed): ${safeErrMessage(err)}`,
150
+ }),
151
+ ),
152
+ },
153
+ );
154
+ }
155
+
156
+ /* c8 ignore start — CLI entry runs only in the spawned subprocess; main/render/
157
+ * classifyPrompt are mutation-tested via the in-process tests that call them. */
158
+ // Stryker disable all: same subprocess-only boundary as the c8 ignore — the
159
+ // direct-run guard can't be observed in-process.
160
+ if (isMain(import.meta.url)) {
161
+ void main(readStdinJson, (chunk) => process.stdout.write(chunk));
162
+ }
163
+ /* c8 ignore stop */
164
+ // Stryker restore all
@@ -0,0 +1,313 @@
1
+ /**
2
+ * SessionStart: scan CLAUDE.md and .claude/ markdown for runs of invisible
3
+ * Unicode that may encode hidden instructions. Pasted markdown can embed
4
+ * invisible sequences (tag chars, zero-width encodings) that hijack the model's
5
+ * behavior — invisible in an editor but read by the LLM. These files load as
6
+ * project instructions at session start, bypassing the PostToolUse sanitizer.
7
+ */
8
+ import { readFileSync, globSync, writeFileSync, unlinkSync } from "node:fs";
9
+ import { join, relative } from "node:path";
10
+ import { isMain, lazyImport, writeFileNoFollow } from "./lib/hook-io.mjs";
11
+ import {
12
+ ALERT_FILE,
13
+ ALERT_ACK_FILE,
14
+ PROJECT_DIR,
15
+ } from "./lib/invisible-alert.mjs";
16
+ import { trace, TraceEvent } from "./lib/trace.mjs";
17
+
18
+ // Layer-1 primitives, bound via lazyImport (see its doc for the fail-OPEN
19
+ // hazard of a bare static npm import — here the instruction files would load
20
+ // UNSCANNED). A failed load leaves the bindings undefined, and cliMain's guard
21
+ // below fails loud rather than silently passing.
22
+ const {
23
+ LONG_RUN_RE,
24
+ LONG_RUN_THRESHOLD,
25
+ SCATTERED_THRESHOLD: TOTAL_INVISIBLE_THRESHOLD,
26
+ STRIP,
27
+ stripInvisible,
28
+ } = /** @type {typeof import("agent-sanitizer/invisible")} */ (
29
+ await lazyImport("agent-sanitizer/invisible")
30
+ );
31
+
32
+ // Decoder
33
+
34
+ /**
35
+ * @param {string} run
36
+ * @returns {{ method: string, decoded: string }}
37
+ */
38
+ function decodeRun(run) {
39
+ const cps = [...run].map((ch) => /** @type {number} */ (ch.codePointAt(0)));
40
+
41
+ // Tag characters U+E0001-U+E007F map directly to ASCII
42
+ const tagAscii = cps
43
+ .filter((cp) => cp >= 0xe0001 && cp <= 0xe007f)
44
+ // Stryker disable next-line ArithmeticOperator: cp - 0xe0000 → cp + 0xe0000 is equivalent — 0xe0000 is a multiple of 2^16 and String.fromCharCode truncates to 16 bits, so both yield the same character.
45
+ .map((cp) => String.fromCharCode(cp - 0xe0000))
46
+ .join("");
47
+
48
+ if (tagAscii.length > 0) {
49
+ return { method: "Unicode tag characters → ASCII", decoded: tagAscii };
50
+ }
51
+
52
+ // Zero-width binary encoding: ZWSP=0, ZWNJ=1, ZWJ=group separator.
53
+ const ZW_BIT = new Map([
54
+ [0x200b, "0"],
55
+ [0x200c, "1"],
56
+ [0x200d, "|"],
57
+ ]);
58
+ if (cps.every((cp) => ZW_BIT.has(cp))) {
59
+ const bits = cps.map((cp) => ZW_BIT.get(cp)).join("");
60
+ return {
61
+ method: "zero-width binary encoding",
62
+ decoded: `[${cps.length} zero-width chars: ${bits.slice(0, 80)}]`,
63
+ };
64
+ }
65
+
66
+ // Mixed/unknown
67
+ return {
68
+ method: "invisible Unicode sequence",
69
+ decoded: cps
70
+ .map((cp) => `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`)
71
+ .join(" "),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * @param {string} dir
77
+ * @returns {string[]}
78
+ */
79
+ function findMdFiles(dir) {
80
+ return globSync("**/*.md", {
81
+ cwd: dir,
82
+ exclude: (name) => name === "node_modules",
83
+ }).map((name) => join(dir, name));
84
+ }
85
+
86
+ /**
87
+ * Every subdirectory instruction file (CLAUDE.md, CLAUDE.local.md, AGENTS.md)
88
+ * under `dir`. Claude Code loads these as project instructions on entry to their
89
+ * containing directory — a load path that bypasses the PostToolUse sanitizer — so
90
+ * a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model uncleaned
91
+ * unless it is scanned here. Skips node_modules; `**` skips dot directories by
92
+ * default (`.git`, and `.claude`, which the caller scans separately).
93
+ * @param {string} dir
94
+ * @returns {string[]}
95
+ */
96
+ function findInstructionFiles(dir) {
97
+ return globSync(["**/CLAUDE.md", "**/CLAUDE.local.md", "**/AGENTS.md"], {
98
+ cwd: dir,
99
+ exclude: (name) => name === "node_modules",
100
+ }).map((name) => join(dir, name));
101
+ }
102
+
103
+ // Scanner
104
+
105
+ /**
106
+ * @param {string} filePath
107
+ * @returns {Array<{ line: number, charCount: number, method: string, decoded: string }>}
108
+ */
109
+ function scanFile(filePath) {
110
+ const content = readFileSync(filePath, "utf-8");
111
+ const findings = [];
112
+ LONG_RUN_RE.lastIndex = 0;
113
+ let match;
114
+ let runChars = 0;
115
+ while ((match = LONG_RUN_RE.exec(content)) !== null) {
116
+ const lineNum = content.slice(0, match.index).split("\n").length;
117
+ const charCount = [...match[0]].length;
118
+ runChars += charCount;
119
+ findings.push({ line: lineNum, charCount, ...decodeRun(match[0]) });
120
+ }
121
+
122
+ // Threshold-evasion: scattered invisible chars not in a long run can still be
123
+ // a payload. Always evaluated; chars already in a run are excluded so they
124
+ // aren't double-counted.
125
+ const allInvisible = content.match(STRIP);
126
+ const scattered = (allInvisible ? allInvisible.length : 0) - runChars;
127
+ if (scattered >= TOTAL_INVISIBLE_THRESHOLD) {
128
+ findings.push({
129
+ line: 0,
130
+ charCount: scattered,
131
+ method: "scattered invisible chars (possible threshold evasion)",
132
+ decoded: `[${scattered} invisible chars distributed across file]`,
133
+ });
134
+ }
135
+
136
+ return findings;
137
+ }
138
+
139
+ export {
140
+ decodeRun,
141
+ findMdFiles,
142
+ findInstructionFiles,
143
+ scanFile,
144
+ ALERT_FILE,
145
+ ALERT_ACK_FILE,
146
+ LONG_RUN_RE,
147
+ LONG_RUN_THRESHOLD,
148
+ TOTAL_INVISIBLE_THRESHOLD,
149
+ };
150
+
151
+ /**
152
+ * @param {Array<{
153
+ * file: string,
154
+ * findings: Array<{ line: number, charCount: number, method: string, decoded: string }>,
155
+ * }>} allFindings
156
+ * @returns {string}
157
+ */
158
+ function formatReport(allFindings) {
159
+ const BAR = "━".repeat(52);
160
+ const lines = [
161
+ "",
162
+ `━━━ INVISIBLE CHARACTER INJECTION DETECTED ${BAR.slice(0, 11)}`,
163
+ "",
164
+ "Invisible Unicode in instruction files can hijack the model’s behavior",
165
+ "(skill invocation, tool use, instruction override). This commonly",
166
+ "happens when copy-pasting content from the internet.",
167
+ "",
168
+ "These files are loaded directly as context, bypassing PostToolUse",
169
+ "sanitization, so the invisible characters reach the model raw.",
170
+ "",
171
+ ];
172
+
173
+ for (const { file, findings } of allFindings) {
174
+ lines.push(` ${file}:`);
175
+ for (const finding of findings) {
176
+ lines.push(
177
+ ` Line ${finding.line}: ${finding.charCount} invisible chars (${finding.method})`,
178
+ );
179
+ lines.push(` Decodes to: ${JSON.stringify(finding.decoded)}`);
180
+ }
181
+ lines.push("");
182
+ }
183
+
184
+ lines.push(BAR);
185
+ return lines.join("\n");
186
+ }
187
+
188
+ export { formatReport };
189
+
190
+ // Main (skip when imported for testing)
191
+
192
+ // Stryker disable all: CLI-entry body. It runs only as a spawned subprocess,
193
+ // which in-process tests can't observe, so every mutant here is unkillable by
194
+ // construction (same boundary as the c8-ignored regions below). The exported
195
+ // scanFile/decodeRun above carry the real, mutation-tested logic.
196
+ /**
197
+ * Scan every instruction file under the project for invisible-char findings.
198
+ * @returns {Array<{file: string, findings: ReturnType<typeof scanFile>}>}
199
+ */
200
+ function scanProject() {
201
+ const targets = [
202
+ ...new Set([
203
+ ...findInstructionFiles(PROJECT_DIR),
204
+ ...findMdFiles(join(PROJECT_DIR, ".claude")),
205
+ ]),
206
+ ];
207
+ const allFindings = [];
208
+ for (const file of targets) {
209
+ try {
210
+ const findings = scanFile(file);
211
+ if (findings.length > 0) {
212
+ allFindings.push({ file: relative(PROJECT_DIR, file), findings });
213
+ }
214
+ } catch {
215
+ // File doesn't exist or unreadable
216
+ }
217
+ }
218
+ return allFindings;
219
+ }
220
+
221
+ /**
222
+ * The hook's CLI: scan the instruction files, auto-clean what it can, persist
223
+ * the alert for the PreToolUse gate otherwise. Exported so a bundle entry
224
+ * (which must claim the CLI slot before this module loads) can run the exact
225
+ * same scan instead of duplicating it.
226
+ * @returns {Promise<void>}
227
+ */
228
+ export async function cliMain() {
229
+ /* c8 ignore start -- fail-closed module-load guard: only reachable when the
230
+ agent-sanitizer import above failed, which can't be simulated in the
231
+ spawned-subprocess CLI run the tests observe. */
232
+ if (typeof stripInvisible !== "function") {
233
+ // Emit the engagement event with a "skipped" outcome so the loss is LOUD on
234
+ // the trace channel — a scan that never ran is otherwise invisible, and the
235
+ // downstream PreToolUse sanitize gate then passes cleanly all session.
236
+ trace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
237
+ process.stderr.write(
238
+ "scan-invisible-chars: agent-sanitizer failed to load; instruction " +
239
+ "files were NOT scanned for hidden Unicode.\n",
240
+ );
241
+ process.exit(1);
242
+ }
243
+ /* c8 ignore stop */
244
+
245
+ // Clean up stale alert + its ack marker from a previous session so this
246
+ // session re-surfaces the gate once if injection is still present.
247
+ for (const stale of [ALERT_FILE, ALERT_ACK_FILE]) {
248
+ try {
249
+ unlinkSync(stale);
250
+ } catch {
251
+ // Doesn't exist or not writable
252
+ }
253
+ }
254
+
255
+ const allFindings = scanProject();
256
+
257
+ if (allFindings.length === 0) {
258
+ trace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "clean" });
259
+ return;
260
+ }
261
+ trace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
262
+ outcome: "found",
263
+ files: allFindings.length,
264
+ });
265
+
266
+ // Auto-clean contaminated files so the session proceeds without blocking
267
+ // every tool call; the gate hook is the fallback when cleaning fails.
268
+ let cleaned = 0;
269
+ for (const { file } of allFindings) {
270
+ const absPath = join(PROJECT_DIR, file);
271
+ try {
272
+ const original = readFileSync(absPath, "utf-8");
273
+ const stripped = stripInvisible(original);
274
+ if (stripped !== original) {
275
+ writeFileSync(absPath, stripped);
276
+ cleaned++;
277
+ }
278
+ /* c8 ignore start -- only fires on a file this uid cannot rewrite, which the test run cannot create */
279
+ } catch {
280
+ // Unreadable or unwritable: the file stays contaminated and falls into the
281
+ // alert path below, which hands it to the PreToolUse gate.
282
+ }
283
+ /* c8 ignore stop */
284
+ }
285
+
286
+ const report = formatReport(allFindings);
287
+
288
+ if (cleaned === allFindings.length) {
289
+ process.stderr.write(
290
+ report +
291
+ `\nAll ${cleaned} file(s) cleaned on disk automatically. ` +
292
+ "NOTE: these files load as project instructions at session start, so " +
293
+ "THIS session may have already ingested the pre-clean bytes before the " +
294
+ "hook ran — treat any injected-looking instruction from them with " +
295
+ "suspicion, and restart the session if in doubt. Future sessions load " +
296
+ "the cleaned files.\n",
297
+ );
298
+ /* c8 ignore start -- only reachable when the write catch above fires */
299
+ } else {
300
+ process.stderr.write(report + "\n");
301
+ // ALERT_FILE sits at a predictable, world-visible $TMPDIR path, so a plain
302
+ // writeFileSync would follow a co-tenant-planted symlink and overwrite an
303
+ // arbitrary file this uid owns. Create it symlink-refusingly (see
304
+ // writeFileNoFollow); the PreToolUse gate treats an absent alert as "nothing
305
+ // to surface", so a lost race degrades safely rather than to a hijacked write.
306
+ writeFileNoFollow(ALERT_FILE, report + "\n");
307
+ }
308
+ /* c8 ignore stop */
309
+ }
310
+
311
+ if (isMain(import.meta.url)) {
312
+ await cliMain();
313
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.2.2",
3
+ "version": "2.4.0",
4
4
  "description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -46,6 +46,7 @@
46
46
  ]
47
47
  },
48
48
  "devDependencies": {
49
+ "sanitizer-engine": "npm:agent-sanitizer@2.1.0",
49
50
  "@commitlint/cli": "^21.0.1",
50
51
  "@commitlint/config-conventional": "^21.0.1",
51
52
  "@eslint/js": "10.0.1",
@@ -120,11 +121,15 @@
120
121
  "types": "./types/rehydrate.d.mts",
121
122
  "default": "./src/rehydrate.mjs"
122
123
  },
123
- "./credential-names": "./python/agent_sanitizer/secrets/data/credential-names.json"
124
+ "./credential-names": "./python/agent_sanitizer/secrets/data/credential-names.json",
125
+ "./claude-hooks": "./claude-hooks/plugin-hooks.mjs"
124
126
  },
125
127
  "files": [
126
128
  "src/*.mjs",
127
129
  "python/agent_sanitizer/secrets/data/credential-names.json",
130
+ "claude-hooks/*.mjs",
131
+ "claude-hooks/lib/*.mjs",
132
+ "claude-hooks/config/*.json",
128
133
  "bin/sanitize-cli.mjs",
129
134
  "types",
130
135
  "LICENSE",
@@ -133,6 +138,8 @@
133
138
  "SECURITY.md"
134
139
  ],
135
140
  "dependencies": {
141
+ "agent-control-plane-core": "0.2.13",
142
+ "namespace-guard": "0.20.0",
136
143
  "css-tree": "^3.2.1",
137
144
  "rehype-parse": "9.0.1",
138
145
  "remark-gfm": "4.0.1",
@@ -144,8 +151,8 @@
144
151
  "scripts": {
145
152
  "test": "c8 node --test",
146
153
  "coverage": "c8 node --test",
147
- "check": "tsc --noEmit",
148
- "typecheck": "tsc --noEmit",
154
+ "check": "tsc --noEmit && tsc -p tsconfig.hooks.json --noEmit",
155
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.hooks.json --noEmit",
149
156
  "build:types": "tsc -p tsconfig.build.json",
150
157
  "gen:joining-type": "node scripts/gen-joining-type.mjs",
151
158
  "lint": "eslint .",