@hasna/hooks 0.11.7 → 0.12.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,136 @@
1
+ /**
2
+ * Signed-link shapes in text.
3
+ *
4
+ * The same shape the in-process harness redaction uses for this class: an
5
+ * absolute http(s) URL whose query or fragment carries a capability-class
6
+ * parameter. The measured signed action links carry `expires`, `pr_number`,
7
+ * `repository`, `return_to` and `signature`; `signature` is the discriminating
8
+ * parameter, and the other capability names below are the same class.
9
+ *
10
+ * A match is replaced by the URL's origin plus `/[redacted signed link]`, so a
11
+ * reader keeps the host for diagnosis and nothing else. The matched text is
12
+ * never returned, logged or echoed by anything in this module.
13
+ */
14
+
15
+ /** Query or fragment parameter names that mark a URL as a signed capability. */
16
+ export const SIGNED_LINK_PARAMS: readonly string[] = [
17
+ "signature",
18
+ "sig",
19
+ "x-amz-signature",
20
+ "x-goog-signature",
21
+ "x-ms-signature",
22
+ "hmac",
23
+ "token",
24
+ ];
25
+
26
+ /** Cheap pre-filter: any absolute URL. (A keyword pre-filter would miss percent- or entity-encoded names.) */
27
+ const HINT = /https?:\/\//i;
28
+
29
+ /**
30
+ * Bounded absolute URL: stops at whitespace, quotes, angle brackets,
31
+ * parentheses and backticks, and at a backslash unless it starts a JSON
32
+ * `\u0026` (`&`), `\u003d` (`=`), `\u003f` (`?`) or `\u0023` (`#`) escape, so a
33
+ * link embedded in escaped JSON or HTML is matched whole.
34
+ */
35
+ const URL_PATTERN = /https?:\/\/(?:[^\s"'<>()`\\]|\\u00(?:26|3[dDfF]|23))+/gi;
36
+
37
+ const TRAILING_PUNCTUATION = /[.,;:!?]+$/;
38
+
39
+ export const REDACTION_MARKER = "[redacted signed link]";
40
+
41
+ /**
42
+ * Undo the encodings a separator survives in: JSON `\u0026`-style escapes,
43
+ * HTML entities (`&amp;`, repeated as `&amp;amp;`, and `&#38;` / `&#x26;`),
44
+ * so `?a=1&amp;signature=x` is read as `?a=1&signature=x`.
45
+ */
46
+ function decodeSeparators(url: string): string {
47
+ let text = url
48
+ .replace(/\\u0026/gi, "&").replace(/\\u003d/gi, "=").replace(/\\u003f/gi, "?").replace(/\\u0023/gi, "#");
49
+ for (let guard = 0; guard < 8; guard++) {
50
+ const next = text
51
+ .replace(/&amp;/gi, "&").replace(/&#0*38;/g, "&").replace(/&#x0*26;/gi, "&")
52
+ .replace(/&#0*61;/g, "=").replace(/&#x0*3d;/gi, "=").replace(/&equals;/gi, "=")
53
+ .replace(/&#0*63;/g, "?").replace(/&#x0*3f;/gi, "?").replace(/&quest;/gi, "?");
54
+ if (next === text) break;
55
+ text = next;
56
+ }
57
+ return text;
58
+ }
59
+
60
+ /** True when a URL carries a signature-class query or fragment parameter. */
61
+ export function hasSignatureParameter(url: string): boolean {
62
+ const decoded = decodeSeparators(url);
63
+ const query = decoded.indexOf("?");
64
+ const hash = decoded.indexOf("#");
65
+ const chunks: string[] = [];
66
+ if (query >= 0) chunks.push(decoded.slice(query + 1).split("#")[0] ?? "");
67
+ if (hash >= 0) chunks.push(decoded.slice(hash + 1));
68
+ for (const chunk of chunks) {
69
+ for (const pair of chunk.split(/[&;]/)) {
70
+ let key = (pair.split("=", 1)[0] ?? "").trim();
71
+ try { key = decodeURIComponent(key); } catch { /* keep the raw key */ }
72
+ if (SIGNED_LINK_PARAMS.includes(key.toLowerCase())) return true;
73
+ }
74
+ }
75
+ return false;
76
+ }
77
+
78
+ /** Number of signed-link URLs in `text`. */
79
+ export function countSignedLinks(text: string): number {
80
+ if (typeof text !== "string" || !text || !HINT.test(text)) return 0;
81
+ let count = 0;
82
+ for (const match of text.matchAll(URL_PATTERN)) {
83
+ const url = match[0].replace(TRAILING_PUNCTUATION, "");
84
+ if (hasSignatureParameter(url)) count++;
85
+ }
86
+ return count;
87
+ }
88
+
89
+ /** Replace every signed-link URL in `text` with `<origin>/[redacted signed link]`. */
90
+ export function redactSignedLinks(text: string): string {
91
+ if (typeof text !== "string" || !text || !HINT.test(text)) return text;
92
+ return text.replace(URL_PATTERN, (match) => {
93
+ const trailing = match.match(TRAILING_PUNCTUATION)?.[0] ?? "";
94
+ const url = trailing ? match.slice(0, -trailing.length) : match;
95
+ if (!hasSignatureParameter(url)) return match;
96
+ const origin = url.match(/^https?:\/\/[^/?#]+/i)?.[0];
97
+ return origin ? `${origin}/${REDACTION_MARKER}${trailing}` : `${REDACTION_MARKER}${trailing}`;
98
+ });
99
+ }
100
+
101
+ const MAX_DEPTH = 2_000;
102
+ const MAX_NODES = 1_000_000;
103
+
104
+ /**
105
+ * A deep copy of `value` with every signed link in every string replaced, and
106
+ * how many strings changed. Structure, key order and non-string values are
107
+ * preserved exactly, so the copy keeps the tool's output shape. `complete` is
108
+ * false only when the value is too large or too deep to copy; then `links`
109
+ * reports whether a bounded scan of its serialized text found a link at all,
110
+ * and a caller that finds none has nothing to report.
111
+ */
112
+ export function redactValue(value: unknown): { value: unknown; changed: number; complete: boolean; links: number } {
113
+ let changed = 0;
114
+ let nodes = 0;
115
+ let complete = true;
116
+ const visit = (current: unknown, depth: number): unknown => {
117
+ if (!complete) return current;
118
+ if (++nodes > MAX_NODES || depth > MAX_DEPTH) { complete = false; return current; }
119
+ if (typeof current === "string") {
120
+ const next = redactSignedLinks(current);
121
+ if (next !== current) changed++;
122
+ return next;
123
+ }
124
+ if (!current || typeof current !== "object") return current;
125
+ if (Array.isArray(current)) return current.map((item) => visit(item, depth + 1));
126
+ const out: Record<string, unknown> = {};
127
+ for (const [key, item] of Object.entries(current as Record<string, unknown>)) out[key] = visit(item, depth + 1);
128
+ return out;
129
+ };
130
+ const result = visit(value, 0);
131
+ if (complete) return { value: result, changed, complete, links: changed };
132
+ let serialized = "";
133
+ try { serialized = JSON.stringify(value) ?? ""; } catch { serialized = ""; }
134
+ // JSON doubles backslashes; undo that so escaped separators still read as separators.
135
+ return { value, changed: 0, complete: false, links: countSignedLinks(serialized.replace(/\\\\/g, "\\")) };
136
+ }
@@ -0,0 +1,47 @@
1
+ # signed-link-output
2
+
3
+ Claude Code PostToolUse hook, installed as `hooks run signed-link-output`
4
+ (matcher `Bash`, `WebFetch` and MCP tools). It is the backstop behind the
5
+ `signed-link-guard` PreToolUse guard.
6
+
7
+ When a tool result contains a signed-link URL — an absolute `http(s)` URL whose
8
+ query or fragment carries `signature`, `sig`, `x-amz-signature`,
9
+ `x-goog-signature`, `x-ms-signature`, `hmac` or `token` — it returns the two
10
+ fields below. Separators are recognised in their encoded forms too: `&amp;`
11
+ (also repeated, `&amp;amp;`), `&#38;`, `&#x26;`, the JSON escape `\u0026` and
12
+ percent-encoded parameter names. A result with no such link gets no output at
13
+ all, however large it is.
14
+
15
+ - `hookSpecificOutput.updatedToolOutput`: the same tool result with each such
16
+ URL replaced by `<origin>/[redacted signed link]`. Keys, order and
17
+ non-string values are kept, so the result keeps the tool's output shape.
18
+ - `hookSpecificOutput.additionalContext`: a notice that the output contained a
19
+ signed link the model must not repeat, store or try to recover, and should
20
+ report under the incident path (naming the command, never the link).
21
+
22
+ It never blocks, and it never prints, logs or stores a matched URL.
23
+
24
+ ## What it can and cannot do
25
+
26
+ - Output replacement depends on the running Claude Code build. The 2.1.285
27
+ build accepts `updatedToolOutput` on PostToolUse for every tool, checks it
28
+ against the tool's output schema, and uses the original output (with a hook
29
+ error) when the check fails. Older builds may ignore or reject the field.
30
+ - It runs after the tool has run. The command already executed, the terminal
31
+ may already have shown the output, and files the command or the harness
32
+ wrote (including a spill file for large output) keep the link. Whether the
33
+ on-disk transcript stores the original or the replacement is not verified.
34
+ - PostToolUse hooks run in parallel on the original output and the last
35
+ rewrite wins, so another rewriting hook can undo this one. This hook returns
36
+ a rewrite only when it changed something.
37
+ - A URL that carries its capability in the path, with no capability-named
38
+ parameter, is not recognised.
39
+ - Output larger than its copy bound (1,000,000 JSON nodes or 2,000 levels
40
+ deep) is still scanned for links; when one is found the notice is added but
41
+ the output is not rewritten, because a partial copy would change the
42
+ tool's output shape.
43
+ - Claude Code's `Monitor` tool delivers each later stdout line to the model
44
+ as a notification rather than in its tool result, so this hook is not known
45
+ to see those lines. For Monitor, rely on the PreToolUse guard.
46
+
47
+ Prevention is the PreToolUse guard's job; this hook narrows what slips past it.
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "signed-link-output",
3
+ "version": "0.1.0",
4
+ "description": "PostToolUse Signed Link Output hook for @hasna/hooks",
5
+ "type": "module",
6
+ "main": "./src/hook.ts",
7
+ "scripts": {
8
+ "typecheck": "tsc --noEmit"
9
+ },
10
+ "author": "Hasna",
11
+ "license": "Apache-2.0"
12
+ }
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * PostToolUse hook: signed-link-output (Claude Code)
5
+ *
6
+ * The second layer behind signed-link-guard. When a tool result contains a
7
+ * signed-link URL (an absolute URL whose query or fragment carries a
8
+ * capability-class parameter such as `signature`), this hook:
9
+ *
10
+ * 1. returns `hookSpecificOutput.updatedToolOutput`: the same tool result
11
+ * with each such URL replaced by `<origin>/[redacted signed link]`, and
12
+ * 2. returns `hookSpecificOutput.additionalContext`: a notice telling the
13
+ * model the output contained a signed link that it must not repeat,
14
+ * store or try to recover, and that it should report it under the
15
+ * incident path.
16
+ *
17
+ * WHAT THIS CAN AND CANNOT DO (stated plainly; do not describe it as more):
18
+ *
19
+ * - `updatedToolOutput` replaces the tool result "before it is sent to the
20
+ * model" on Claude Code builds that implement it (verified by reading the
21
+ * 2.1.285 bundle: PostToolUse accepts `updatedToolOutput` for every tool,
22
+ * validates it against the tool's output schema, and falls back to the
23
+ * ORIGINAL output with an error when it does not match). The copy keeps
24
+ * every key and non-string value, so it keeps the tool's shape.
25
+ * - It runs after the tool ran. The command already executed, the terminal
26
+ * may already have displayed the output, and anything the command wrote to
27
+ * disk (including a harness's own spill file for large output) keeps the
28
+ * link. Whether the harness's on-disk transcript records the original or
29
+ * the replacement has not been verified here.
30
+ * - PostToolUse hooks run in parallel on the ORIGINAL output and a later
31
+ * rewrite wins, so another hook that rewrites the same result can undo
32
+ * this redaction. This hook never returns an identity rewrite.
33
+ * - Older Claude Code builds without `updatedToolOutput` may reject or
34
+ * ignore the field; the notice is the only effect there, if any.
35
+ * - It never blocks and never echoes, logs or stores the matched URL.
36
+ *
37
+ * The prevention layer is the PreToolUse guard, which stops the known reads
38
+ * before they run.
39
+ */
40
+
41
+ import { readFileSync, writeSync } from "fs";
42
+ import { redactValue } from "../../hook-signed-link-guard/src/links";
43
+
44
+ export const SIGNED_LINK_NOTICE =
45
+ "This tool result contained a signed-link URL (a capability that cannot be revoked). " +
46
+ "Where this Claude Code build supports output replacement, the link was replaced with a redaction marker before you saw it. " +
47
+ "Do not repeat, quote, store, forward or try to recover the link, and do not re-run the command to see it. " +
48
+ "Report the exposure under the incident path, naming the command and the tool, never the link. " +
49
+ "Read GitHub content with bounded scalar projections (for example `gh pr view <n> --json number,state,headRefOid`).";
50
+
51
+ export interface PostToolUseInput {
52
+ hook_event_name?: string;
53
+ tool_name?: string;
54
+ tool_response?: unknown;
55
+ /** Older catalog convention; Claude Code sends `tool_response`. */
56
+ tool_output?: unknown;
57
+ }
58
+
59
+ export interface PostToolUseOutput {
60
+ hookSpecificOutput?: {
61
+ hookEventName: "PostToolUse";
62
+ additionalContext: string;
63
+ updatedToolOutput?: unknown;
64
+ };
65
+ }
66
+
67
+ /** The hook's decision for one PostToolUse input. An empty object means "nothing to do". */
68
+ export function evaluate(input: PostToolUseInput): PostToolUseOutput {
69
+ if (!input || input.hook_event_name !== "PostToolUse") return {};
70
+ const field = input.tool_response !== undefined ? "tool_response" : input.tool_output !== undefined ? "tool_output" : undefined;
71
+ if (!field) return {};
72
+ const redacted = redactValue(input[field]);
73
+ // No link found means nothing to report: never send the incident notice
74
+ // for output that is merely large or deep.
75
+ if (redacted.links === 0) return {};
76
+ const output: PostToolUseOutput = { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: SIGNED_LINK_NOTICE } };
77
+ // Only a real change is returned as a rewrite: an identity rewrite would
78
+ // compete with, and could undo, another hook's redaction.
79
+ if (field === "tool_response" && redacted.complete && redacted.changed > 0) output.hookSpecificOutput!.updatedToolOutput = redacted.value;
80
+ return output;
81
+ }
82
+
83
+ export function run(): void {
84
+ let input: PostToolUseInput = {};
85
+ try {
86
+ const raw = readFileSync(0, "utf8");
87
+ if (raw.trim()) input = JSON.parse(raw) as PostToolUseInput;
88
+ } catch {
89
+ return; // Unreadable input: no decision, and nothing about it is printed.
90
+ }
91
+ let output: PostToolUseOutput = {};
92
+ try { output = evaluate(input); } catch { output = {}; }
93
+ if (output.hookSpecificOutput) writeAll(JSON.stringify(output) + "\n");
94
+ }
95
+
96
+ /** Synchronous and complete: an asynchronous pipe write can be cut off by process exit. */
97
+ function writeAll(text: string): void {
98
+ const bytes = Buffer.from(text);
99
+ for (let offset = 0; offset < bytes.length;) {
100
+ try { offset += writeSync(1, bytes, offset, bytes.length - offset); }
101
+ catch (error) {
102
+ if ((error as NodeJS.ErrnoException).code !== "EAGAIN") throw error;
103
+ Bun.sleepSync(1);
104
+ }
105
+ }
106
+ }
107
+
108
+ if (import.meta.main) {
109
+ run();
110
+ process.exit(0);
111
+ }
@@ -221,6 +221,206 @@ export interface ScanResult {
221
221
  /* Lexer — source spans, not decoded tokens */
222
222
  /* ------------------------------------------------------------------ */
223
223
 
224
+ /* Quote, substitution and here-document scanners over source text. Each
225
+ * returns the index just past the construct that starts at `from`, or -1 when
226
+ * the construct does not end (the caller then treats the command as not
227
+ * trustworthy). */
228
+
229
+ function scanSingleAt(text: string, from: number): number {
230
+ const end = text.indexOf("'", from + 1);
231
+ return end === -1 ? -1 : end + 1;
232
+ }
233
+
234
+ function scanBacktickAt(text: string, from: number): number {
235
+ let j = from + 1;
236
+ while (j < text.length) {
237
+ if (text[j] === "\\") {
238
+ j += 2;
239
+ continue;
240
+ }
241
+ if (text[j] === "`") return j + 1;
242
+ j++;
243
+ }
244
+ return -1;
245
+ }
246
+
247
+ function scanDoubleAt(text: string, from: number): number {
248
+ let j = from + 1;
249
+ while (j < text.length) {
250
+ const c = text[j];
251
+ if (c === "\\") {
252
+ j += 2;
253
+ continue;
254
+ }
255
+ if (c === '"') return j + 1;
256
+ if (c === "`") {
257
+ const e = scanBacktickAt(text, j);
258
+ if (e < 0) return -1;
259
+ j = e;
260
+ continue;
261
+ }
262
+ if (c === "$" && text[j + 1] === "(") {
263
+ const e = scanParenAt(text, j + 1);
264
+ if (e < 0) return -1;
265
+ j = e;
266
+ continue;
267
+ }
268
+ j++;
269
+ }
270
+ return -1;
271
+ }
272
+
273
+ /** A here-document operator (`<<WORD`, `<<-WORD`, quoted or not) at `at`. */
274
+ export interface HeredocMarker {
275
+ /** The delimiter after quote removal. */
276
+ delimiter: string;
277
+ /** `<<-`: leading tabs are stripped from body lines and the terminator. */
278
+ stripTabs: boolean;
279
+ /** Any quoting in the delimiter word: the body is literal (no expansion). */
280
+ quoted: boolean;
281
+ /** Index just past the delimiter word. */
282
+ end: number;
283
+ }
284
+
285
+ /**
286
+ * Parse the here-document operator starting at `at` (which must point at the
287
+ * first `<` of `<<`). Returns null for a here-string (`<<<`) or a missing word.
288
+ */
289
+ export function heredocMarkerAt(text: string, at: number): HeredocMarker | null {
290
+ if (text[at] !== "<" || text[at + 1] !== "<" || text[at + 2] === "<") return null;
291
+ let j = at + 2;
292
+ const stripTabs = text[j] === "-";
293
+ if (stripTabs) j++;
294
+ while (text[j] === " " || text[j] === "\t") j++;
295
+ let delimiter = "";
296
+ let quoted = false;
297
+ while (j < text.length && !/[\s;|&<>()]/.test(text[j])) {
298
+ const c = text[j];
299
+ if (c === "'" || c === '"') {
300
+ const close = text.indexOf(c, j + 1);
301
+ if (close < 0) return null;
302
+ delimiter += text.slice(j + 1, close);
303
+ quoted = true;
304
+ j = close + 1;
305
+ continue;
306
+ }
307
+ if (c === "\\") {
308
+ if (j + 1 >= text.length) return null;
309
+ delimiter += text[j + 1];
310
+ quoted = true;
311
+ j += 2;
312
+ continue;
313
+ }
314
+ delimiter += c;
315
+ j++;
316
+ }
317
+ return delimiter ? { delimiter, stripTabs, quoted, end: j } : null;
318
+ }
319
+
320
+ /**
321
+ * Skip the bodies of `pending` here-documents, which start on the line after
322
+ * the newline at `newline`. Returns the index of the character that ends the
323
+ * last terminator line (its newline, or a `)` that closes an enclosing
324
+ * substitution on the terminator line), or -1 when a terminator is missing.
325
+ */
326
+ export function skipHeredocBodies(text: string, newline: number, pending: HeredocMarker[]): number {
327
+ let lineStart = newline + 1;
328
+ for (const marker of pending) {
329
+ for (;;) {
330
+ if (lineStart > text.length) return -1;
331
+ const lineEnd = text.indexOf("\n", lineStart);
332
+ const stop = lineEnd === -1 ? text.length : lineEnd;
333
+ let line = text.slice(lineStart, stop);
334
+ if (line.endsWith("\r")) line = line.slice(0, -1);
335
+ const offset = marker.stripTabs ? line.length - line.replace(/^\t+/, "").length : 0;
336
+ if (marker.stripTabs) line = line.slice(offset);
337
+ if (line === marker.delimiter) {
338
+ lineStart = stop + 1;
339
+ break;
340
+ }
341
+ // Inside a substitution, bash also ends the body at `DELIM)`.
342
+ if (line.startsWith(marker.delimiter) && /^\s*\)/.test(line.slice(marker.delimiter.length))) {
343
+ if (marker === pending[pending.length - 1]) return lineStart + offset + marker.delimiter.length;
344
+ return -1;
345
+ }
346
+ if (lineEnd === -1) return -1;
347
+ lineStart = lineEnd + 1;
348
+ }
349
+ }
350
+ return lineStart - 1;
351
+ }
352
+
353
+ /**
354
+ * Index just past the `)` that closes the `(` at `from` (the `(` of `$(`,
355
+ * `<(`, `>(` or a subshell), or -1. Quotes, backticks and nested
356
+ * parentheses are followed; here-document bodies are skipped as data (their
357
+ * quotes and parentheses are not shell syntax), and so are `#` comments.
358
+ */
359
+ export function scanParenAt(text: string, from: number): number {
360
+ const n = text.length;
361
+ // `$((` arithmetic: `<<` is a shift and `#` a base prefix, not syntax.
362
+ const arithmetic = text[from + 1] === "(";
363
+ let depth = 0;
364
+ let j = from;
365
+ let pending: HeredocMarker[] = [];
366
+ while (j < n) {
367
+ const c = text[j];
368
+ if (c === "\\") {
369
+ j += 2;
370
+ continue;
371
+ }
372
+ if (c === "'") {
373
+ const e = scanSingleAt(text, j);
374
+ if (e < 0) return -1;
375
+ j = e;
376
+ continue;
377
+ }
378
+ if (c === '"') {
379
+ const e = scanDoubleAt(text, j);
380
+ if (e < 0) return -1;
381
+ j = e;
382
+ continue;
383
+ }
384
+ if (c === "`") {
385
+ const e = scanBacktickAt(text, j);
386
+ if (e < 0) return -1;
387
+ j = e;
388
+ continue;
389
+ }
390
+ if (!arithmetic && c === "<" && text[j + 1] === "<") {
391
+ if (text[j + 2] === "<") {
392
+ j += 3;
393
+ continue;
394
+ }
395
+ const marker = heredocMarkerAt(text, j);
396
+ if (!marker) return -1;
397
+ pending.push(marker);
398
+ j = marker.end;
399
+ continue;
400
+ }
401
+ if (c === "\n" && pending.length > 0) {
402
+ const e = skipHeredocBodies(text, j, pending);
403
+ if (e < 0) return -1;
404
+ pending = [];
405
+ j = e;
406
+ continue;
407
+ }
408
+ if (!arithmetic && c === "#" && /[\s;&|(]/.test(text[j - 1])) {
409
+ const e = text.indexOf("\n", j);
410
+ if (e < 0) return -1;
411
+ j = e;
412
+ continue;
413
+ }
414
+ if (c === "(") depth++;
415
+ else if (c === ")") {
416
+ depth--;
417
+ if (depth === 0) return pending.length > 0 ? -1 : j + 1;
418
+ }
419
+ j++;
420
+ }
421
+ return -1;
422
+ }
423
+
224
424
  /**
225
425
  * Split a command string into segments and words while recording each word's
226
426
  * byte span in the ORIGINAL string. Quoting is tracked rather than decoded so
@@ -256,86 +456,10 @@ export function lexCommand(command: string): LexResult {
256
456
  pendingRedirect = false;
257
457
  };
258
458
 
259
- const scanSingle = (from: number): number => {
260
- const end = command.indexOf("'", from + 1);
261
- return end === -1 ? -1 : end + 1;
262
- };
263
-
264
- const scanBacktick = (from: number): number => {
265
- let j = from + 1;
266
- while (j < n) {
267
- if (command[j] === "\\") {
268
- j += 2;
269
- continue;
270
- }
271
- if (command[j] === "`") return j + 1;
272
- j++;
273
- }
274
- return -1;
275
- };
276
-
277
- const scanParen = (from: number): number => {
278
- let depth = 0;
279
- let j = from;
280
- while (j < n) {
281
- const c = command[j];
282
- if (c === "\\") {
283
- j += 2;
284
- continue;
285
- }
286
- if (c === "'") {
287
- const e = scanSingle(j);
288
- if (e < 0) return -1;
289
- j = e;
290
- continue;
291
- }
292
- if (c === '"') {
293
- const e = scanDouble(j);
294
- if (e < 0) return -1;
295
- j = e;
296
- continue;
297
- }
298
- if (c === "`") {
299
- const e = scanBacktick(j);
300
- if (e < 0) return -1;
301
- j = e;
302
- continue;
303
- }
304
- if (c === "(") depth++;
305
- else if (c === ")") {
306
- depth--;
307
- if (depth === 0) return j + 1;
308
- }
309
- j++;
310
- }
311
- return -1;
312
- };
313
-
314
- function scanDouble(from: number): number {
315
- let j = from + 1;
316
- while (j < n) {
317
- const c = command[j];
318
- if (c === "\\") {
319
- j += 2;
320
- continue;
321
- }
322
- if (c === '"') return j + 1;
323
- if (c === "`") {
324
- const e = scanBacktick(j);
325
- if (e < 0) return -1;
326
- j = e;
327
- continue;
328
- }
329
- if (c === "$" && command[j + 1] === "(") {
330
- const e = scanParen(j + 1);
331
- if (e < 0) return -1;
332
- j = e;
333
- continue;
334
- }
335
- j++;
336
- }
337
- return -1;
338
- }
459
+ const scanSingle = (from: number): number => scanSingleAt(command, from);
460
+ const scanBacktick = (from: number): number => scanBacktickAt(command, from);
461
+ const scanParen = (from: number): number => scanParenAt(command, from);
462
+ const scanDouble = (from: number): number => scanDoubleAt(command, from);
339
463
 
340
464
  while (i < n) {
341
465
  const ch = command[i];
@@ -2,7 +2,8 @@ import { homedir } from "node:os";
2
2
  import { isAbsolute } from "node:path";
3
3
  import { evaluate as evaluateTrash, findTrashBinary, inspectTrashBinary, scanCommand } from "./hook-trash-guard/src/hook";
4
4
  import { evaluate as evaluateRepos } from "./hook-workspace-repos-guard/src/hook";
5
- import type { CodewithHookInput } from "./codewith-native-common";
5
+ import { evaluate as evaluateSignedLinks } from "./hook-signed-link-guard/src/hook";
6
+ import type { CodewithHookInput, CodewithHookOutput } from "./codewith-native-common";
6
7
 
7
8
  // Bundled with import.meta.main=false, so the original standalone run wrappers
8
9
  // cannot swallow exceptions or emit a second response. Only this entry runs.
@@ -13,6 +14,9 @@ if (!input || Array.isArray(input) || input.hook_event_name !== "PreToolUse" ||
13
14
  || !input.tool_input || typeof input.tool_input !== "object" || Array.isArray(input.tool_input)
14
15
  || typeof input.cwd !== "string" || !isAbsolute(input.cwd)) throw new Error("Invalid safety input");
15
16
  if (input.tool_name === "Bash" && typeof input.tool_input.command !== "string") throw new Error("Invalid shell input");
17
+ // Claude Code's Monitor runs a shell command and streams its stdout to the
18
+ // model; a websocket Monitor carries `ws` and no command.
19
+ if (input.tool_name === "Monitor" && input.tool_input.command !== undefined && typeof input.tool_input.command !== "string") throw new Error("Invalid monitor input");
16
20
  const requestedExecution = (input as CodewithHookInput & { hasna_execution?: { schema: string; agent: string } }).hasna_execution;
17
21
  if (requestedExecution !== undefined && (!requestedExecution || Object.keys(requestedExecution).sort().join() !== "agent,schema"
18
22
  || requestedExecution.schema !== "hasna.hooks.execution.v1" || typeof requestedExecution.agent !== "string"
@@ -27,10 +31,27 @@ if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_n
27
31
  if (typeof patch !== "string") throw new Error("Invalid patch input");
28
32
  input.tool_input = { ...input.tool_input, command: patch, patch };
29
33
  }
30
- const repos = evaluateRepos(input).output;
31
- const handoff = input.tool_name === "Bash" && scanCommand(input.tool_input.command as string, { home: homedir(), cwd: input.cwd }).handoffHits.length > 0;
32
- const verdict = repos.decision === "block"
33
- ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: repos.reason } }
34
- : handoff ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "[workspace-repos-guard] Deletion under a protected repository checkout is refused." } }
35
- : name === "trash-guard" ? evaluateTrash(input, { home: homedir(), cwd: input.cwd, findTrash: findTrashBinary, inspectTrash: inspectTrashBinary, execution }) : { continue: true };
34
+ // Every guard judges a Monitor command exactly as it judges the same Bash command.
35
+ const monitor = input.tool_name === "Monitor";
36
+ const shellCommand = input.tool_name === "Bash" || (monitor && typeof input.tool_input.command === "string");
37
+ const evaluated: CodewithHookInput = monitor && shellCommand ? { ...input, tool_name: "Bash" } : input;
38
+ const deny = (reason: string) => ({ hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: reason } });
39
+ const repos = evaluateRepos(evaluated).output;
40
+ // Every shell command, whatever the capability name: the guard reaches each
41
+ // registration of this entry. Its refusal outranks a Trash rewrite, because an
42
+ // allowed rewrite would also run the composite gh read.
43
+ const signedLinks = evaluateSignedLinks(evaluated);
44
+ const handoff = shellCommand && scanCommand(evaluated.tool_input!.command as string, { home: homedir(), cwd: input.cwd }).handoffHits.length > 0;
45
+ let trash: CodewithHookOutput = name === "trash-guard" && (!monitor || shellCommand)
46
+ ? evaluateTrash(evaluated, { home: homedir(), cwd: input.cwd, findTrash: findTrashBinary, inspectTrash: inspectTrashBinary, execution }) : { continue: true };
47
+ // A Trash rewrite is proven only for Bash. Under Monitor the deletion is
48
+ // refused rather than rewritten: a rewrite the harness dropped would run the
49
+ // raw command.
50
+ if (monitor && trash.hookSpecificOutput?.permissionDecision === "allow") {
51
+ trash = deny("[trash-guard] A deletion inside a Monitor command is refused; run it with the Bash tool, where it is redirected into trash.");
52
+ }
53
+ const verdict: CodewithHookOutput = repos.decision === "block" ? deny(repos.reason!)
54
+ : signedLinks.hookSpecificOutput ? signedLinks
55
+ : handoff ? deny("[workspace-repos-guard] Deletion under a protected repository checkout is refused.")
56
+ : trash;
36
57
  process.stdout.write(JSON.stringify({ verdict: "continue" in verdict ? null : verdict }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.11.7",
3
+ "version": "0.12.0",
4
4
  "description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,7 +45,7 @@
45
45
  "postinstall": "node scripts/ensure-profiles-dir.mjs",
46
46
  "scan:artifact": "bun scripts/artifact-scan.ts",
47
47
  "prepack": "bun run build && bun run scan:artifact",
48
- "build:native-safety": "bun build ./hooks/native-safety-entry.ts --outdir ./bin --target bun --define import.meta.main=false && bun build ./src/native-safety.ts --outdir ./dist --target bun && bun run build:native-readiness",
48
+ "build:native-safety": "bun build ./hooks/native-safety-entry.ts --outdir ./bin --target bun --minify --define import.meta.main=false && bun build ./src/native-safety.ts --outdir ./dist --target bun && bun run build:native-readiness",
49
49
  "build:native-readiness": "bun build ./hooks/native-readiness-entry.ts --outdir ./bin --target node --format=cjs && bun build ./src/native-readiness.ts --outdir ./dist --target bun"
50
50
  },
51
51
  "keywords": [