@hasna/hooks 0.4.1 → 0.6.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.
Files changed (35) hide show
  1. package/README.md +96 -12
  2. package/bin/index.js +2089 -470
  3. package/bin/serve.js +5248 -0
  4. package/dist/cf/provision.d.ts +24 -0
  5. package/dist/config.d.ts +18 -0
  6. package/dist/db/legacy-import.d.ts +1 -1
  7. package/dist/db/migrations/004_hooks_table.d.ts +9 -0
  8. package/dist/db/pg-migrations.d.ts +1 -1
  9. package/dist/db/storage-sync.d.ts +26 -6
  10. package/dist/index.d.ts +18 -2
  11. package/dist/index.js +5341 -286
  12. package/dist/lib/custom-install.d.ts +19 -0
  13. package/dist/lib/manifest.d.ts +70 -0
  14. package/dist/lib/resolve.d.ts +19 -0
  15. package/dist/lib/run.d.ts +37 -0
  16. package/dist/lib/store.d.ts +69 -0
  17. package/dist/lib/sync.d.ts +35 -0
  18. package/dist/serve.d.ts +36 -0
  19. package/dist/storage.d.ts +2 -2
  20. package/dist/storage.js +133 -42
  21. package/hooks/codewith-native-common.test.ts +1521 -3
  22. package/hooks/codewith-native-common.ts +1627 -37
  23. package/hooks/hook-scanoutput/README.md +151 -0
  24. package/hooks/hook-scanoutput/package.json +12 -0
  25. package/hooks/hook-scanoutput/src/hook.test.ts +217 -0
  26. package/hooks/hook-scanoutput/src/hook.ts +319 -0
  27. package/hooks/mention-context/README.md +109 -0
  28. package/hooks/mention-context/package.json +9 -0
  29. package/hooks/mention-context/src/hasna-mention-context.py +1218 -0
  30. package/hooks/mention-context/src/hasna-mention-warm.py +521 -0
  31. package/hooks/mention-context/src/hook.test.ts +68 -0
  32. package/hooks/mention-context/src/test_run_capture.py +345 -0
  33. package/hooks/pre-bash/README.md +72 -2
  34. package/hooks/worktree-guard/README.md +9 -2
  35. package/package.json +9 -5
@@ -0,0 +1,319 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Claude Code Hook: scanoutput
5
+ *
6
+ * PostToolUse hook that scans tool output for credential shapes and WARNS.
7
+ *
8
+ * THIS IS DETECTION, NOT PREVENTION, AND THE DISTINCTION IS THE WHOLE DESIGN.
9
+ *
10
+ * PostToolUse fires after the tool has run and after its result exists. Measured
11
+ * across the 48-hook catalog at @hasna/hooks 0.5.0: no output-rewriting field
12
+ * exists on any event — `continue`, `decision`, `hookSpecificOutput`,
13
+ * `additionalContext`, `suppressOutput` and `permissionDecision` are the whole
14
+ * surface, and `suppressOutput` hides THE HOOK'S OWN stdout rather than the
15
+ * tool's. So by the time this hook can see a credential, that credential is
16
+ * already in the transcript. This hook cannot and does not stop that.
17
+ *
18
+ * It is worth running anyway because today nothing notices at all: a credential
19
+ * emitted by a third-party tool into ordinary output is indistinguishable from
20
+ * ordinary output until a human happens to look. This converts a silent
21
+ * exposure into a recorded one with a named blast radius, which is what the
22
+ * incident duty needs and currently cannot get.
23
+ *
24
+ * Do not describe it as prevention anywhere — in docs, help text, commit
25
+ * messages or channel posts. A guard advertised as preventing a leak it only
26
+ * reports afterwards retires the worry without retiring the exposure, and that
27
+ * is worse than no guard.
28
+ *
29
+ * It never blocks. Every path answers `{ continue: true }`, including its own
30
+ * failures, following the fail-open idiom the catalog already uses.
31
+ */
32
+
33
+ import { readFileSync } from "fs";
34
+
35
+ /** The scanner surface this hook uses: `scanInputExposures` from @hasna/secrets. */
36
+ export type ScanFn = (options: { text?: string; maxBytes?: number; timeoutMs?: number }) => {
37
+ schema: string;
38
+ version: number;
39
+ source: string;
40
+ root: string;
41
+ redacted: true;
42
+ limits: { findings: number; maxFileBytes?: number; timeoutMs?: number };
43
+ stats: {
44
+ filesScanned: number;
45
+ filesSkipped: number;
46
+ bytesScanned: number;
47
+ errors: string[];
48
+ skipped?: { path: string; reason: string; bytes?: number }[];
49
+ };
50
+ truncated: boolean;
51
+ truncatedReason?: string;
52
+ findings: {
53
+ id: string;
54
+ detector: string;
55
+ severity: string;
56
+ path: string;
57
+ line: number;
58
+ column: number;
59
+ /** Already redacted by the scanner — the value never appears here. */
60
+ preview: string;
61
+ evidencePath: string;
62
+ }[];
63
+ };
64
+
65
+ export interface HookInput {
66
+ session_id?: string;
67
+ cwd?: string;
68
+ hook_event_name?: string;
69
+ tool_name?: string;
70
+ tool_input?: Record<string, unknown>;
71
+ /**
72
+ * THE FIELD CLAUDE CODE ACTUALLY SENDS. Verified against the Claude Code
73
+ * 2.1.226 binary on 2026-08-10, in both its embedded documentation and its
74
+ * implementation:
75
+ *
76
+ * "tool_input": { ... },
77
+ * "tool_response": { "success": true } // PostToolUse only
78
+ *
79
+ * hook_event_name:"PostToolUse", tool_name:e, tool_input:r,
80
+ * tool_response:n, tool_use_id:t, duration_ms:l
81
+ *
82
+ * Counted in the same binary: `tool_response` 21 occurrences,
83
+ * `tool_output` 2 — and both of those are the telemetry event name
84
+ * `tengu_dead_probe_hook_updated_mcp_tool_output`, neither adjacent to
85
+ * `PostToolUse`. Control: a deliberately absent string returns 0.
86
+ *
87
+ * Read this before "simplifying" the two fields below into one. Every other
88
+ * hook in this catalog reads `tool_output` only, so on Claude Code they
89
+ * receive `undefined` and silently observe nothing — which for a scanner
90
+ * means a clean verdict over an empty string. That is the vacuous-gate
91
+ * defect this hook exists to notice, and it nearly shipped inside it.
92
+ */
93
+ tool_response?: Record<string, unknown> | string;
94
+ /** Compatibility only: the pre-existing catalog convention, and some runtimes may use it. */
95
+ tool_output?: Record<string, unknown> | string;
96
+ }
97
+
98
+ export interface HookOutput {
99
+ continue: true;
100
+ }
101
+
102
+ export interface ScanOutcome {
103
+ /**
104
+ * `unscanned` exists so that "I looked at all of it and it was clean" and "I
105
+ * could not look" do not share an answer. A guard that reports those
106
+ * identically is the defect this hook was built to notice, committed inside
107
+ * the hook itself.
108
+ */
109
+ status: "clean" | "found" | "unscanned" | "empty" | "error";
110
+ findingCount: number;
111
+ detectors: string[];
112
+ previews: string[];
113
+ bytesScanned?: number;
114
+ reason?: string;
115
+ error?: string;
116
+ }
117
+
118
+ /** Ceiling on what we hand the scanner. Above it the outcome is `unscanned`, never `clean`. */
119
+ export const MAX_SCAN_BYTES = 4_000_000;
120
+
121
+ /**
122
+ * Wall-clock bound on the scan, well under the scanner's own 10s default.
123
+ *
124
+ * This sits on a tool call's critical path, and the Claude installer target
125
+ * writes no `timeout` into settings.json (only the Codewith target does), so
126
+ * the wiring supplies no outer bound. Measured cost is 1.4-7.3ms for 11 KB and
127
+ * 64-90ms for 2.2 MB, so 2s is generous by more than an order of magnitude and
128
+ * still bounds a pathological input. Exceeding it marks the scan truncated,
129
+ * which this hook reports as `unscanned` rather than clean.
130
+ */
131
+ export const SCAN_TIMEOUT_MS = 2_000;
132
+
133
+ const OUTPUT_FIELDS = ["stdout", "stderr", "output", "content", "text", "error", "result"] as const;
134
+
135
+ /**
136
+ * Pull the text out of a tool result, whether it arrived as a string or a record.
137
+ *
138
+ * `tool_response` is what Claude Code sends (see HookInput above); `tool_output`
139
+ * is accepted as a fallback so this also works on any runtime using the older
140
+ * catalog convention. Reading only one of them is how a scanner ends up
141
+ * reporting a confident clean over an empty string.
142
+ */
143
+ export function extractToolOutputText(input: HookInput): string {
144
+ const output = input.tool_response ?? input.tool_output;
145
+ if (output === undefined || output === null) return "";
146
+ if (typeof output === "string") return output;
147
+ if (typeof output !== "object") return String(output);
148
+
149
+ const parts: string[] = [];
150
+ for (const field of OUTPUT_FIELDS) {
151
+ const value = (output as Record<string, unknown>)[field];
152
+ if (typeof value === "string" && value.length > 0) parts.push(value);
153
+ }
154
+ // Nothing recognised but the record is non-empty: scan its serialised form rather
155
+ // than silently skipping an output shape this list does not know about.
156
+ if (parts.length === 0) {
157
+ try {
158
+ const serialised = JSON.stringify(output);
159
+ if (serialised && serialised !== "{}") return serialised;
160
+ } catch {
161
+ return "";
162
+ }
163
+ return "";
164
+ }
165
+ return parts.join("\n");
166
+ }
167
+
168
+ /**
169
+ * Scan the text and classify the result.
170
+ *
171
+ * `scan` is injected so the hook's own behaviour is testable without the
172
+ * scanner, and so a missing or broken @hasna/secrets degrades to `error` and a
173
+ * warning rather than taking the session down with it.
174
+ */
175
+ export function analyzeToolOutput(text: string, scan: ScanFn): ScanOutcome {
176
+ if (!text || text.length === 0) {
177
+ return { status: "empty", findingCount: 0, detectors: [], previews: [] };
178
+ }
179
+
180
+ let result: ReturnType<ScanFn>;
181
+ try {
182
+ result = scan({ text, maxBytes: MAX_SCAN_BYTES, timeoutMs: SCAN_TIMEOUT_MS });
183
+ } catch (err) {
184
+ return {
185
+ status: "error",
186
+ findingCount: 0,
187
+ detectors: [],
188
+ previews: [],
189
+ error: err instanceof Error ? err.message : String(err),
190
+ };
191
+ }
192
+
193
+ const findings = result?.findings ?? [];
194
+ if (findings.length > 0) {
195
+ return {
196
+ status: "found",
197
+ findingCount: findings.length,
198
+ detectors: [...new Set(findings.map((f) => f.detector))],
199
+ previews: findings.slice(0, 5).map((f) => `${f.evidencePath} ${f.detector}/${f.severity} ${f.preview}`),
200
+ bytesScanned: result.stats?.bytesScanned,
201
+ };
202
+ }
203
+
204
+ // No findings is only good news if the whole input was actually read.
205
+ const stats = result?.stats;
206
+ const skipped = stats?.skipped?.length ?? 0;
207
+ const errors = stats?.errors?.length ?? 0;
208
+ const bytes = stats?.bytesScanned ?? 0;
209
+ if (result?.truncated || skipped > 0 || errors > 0 || bytes <= 0) {
210
+ const reason =
211
+ result?.truncatedReason ??
212
+ stats?.skipped?.[0]?.reason ??
213
+ stats?.errors?.[0] ??
214
+ (bytes <= 0 ? "zero bytes scanned" : "incomplete scan");
215
+ return { status: "unscanned", findingCount: 0, detectors: [], previews: [], bytesScanned: bytes, reason };
216
+ }
217
+
218
+ return { status: "clean", findingCount: 0, detectors: [], previews: [], bytesScanned: bytes };
219
+ }
220
+
221
+ /** The stderr line(s) a human or agent actually reads. Empty string means stay silent. */
222
+ export function formatWarning(outcome: ScanOutcome): string {
223
+ if (outcome.status === "clean" || outcome.status === "empty") return "";
224
+
225
+ if (outcome.status === "error") {
226
+ return `[scanoutput] could not scan this tool output: ${outcome.error ?? "unknown error"} — treat it as UNSCANNED, not clean.`;
227
+ }
228
+
229
+ if (outcome.status === "unscanned") {
230
+ return `[scanoutput] UNSCANNED tool output (${outcome.reason ?? "incomplete"}) — no finding here is not evidence of no credential.`;
231
+ }
232
+
233
+ const lines = [
234
+ `[scanoutput] ${outcome.findingCount} credential shape(s) in tool output ` +
235
+ `[${outcome.detectors.join(", ")}] — this output is ALREADY in the transcript; ` +
236
+ `this hook reports it and cannot remove it.`,
237
+ ...outcome.previews.map((p) => ` ${p}`),
238
+ ` Record it to the incidents channel by NAME and SCOPE only, never the value, then continue.`,
239
+ ];
240
+ return lines.join("\n");
241
+ }
242
+
243
+ export function buildHookOutput(_outcome: ScanOutcome): HookOutput {
244
+ // Unconditional. A guard that fails closed on its own bug gets uninstalled.
245
+ return { continue: true };
246
+ }
247
+
248
+ function readStdinJson(): HookInput | null {
249
+ try {
250
+ const raw = readFileSync(0, "utf-8").trim();
251
+ if (!raw) return null;
252
+ return JSON.parse(raw) as HookInput;
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Resolve the real scanner. Imported lazily and in-process: a CLI spawn costs a
260
+ * fixed ~1.3-1.8s on this fleet regardless of payload, while the in-process call
261
+ * is ~25ms of import plus ~2ms for 11 KB and ~80ms for 2.2 MB (measured
262
+ * 2026-08-10, station01). The spawn is the dominant term and is avoidable.
263
+ */
264
+ async function loadScanner(): Promise<ScanFn> {
265
+ const mod = (await import("@hasna/secrets/scanner")) as unknown as { scanInputExposures: ScanFn };
266
+ if (typeof mod?.scanInputExposures !== "function") {
267
+ throw new Error("@hasna/secrets/scanner does not export scanInputExposures");
268
+ }
269
+ return mod.scanInputExposures;
270
+ }
271
+
272
+ function respond(output: HookOutput): void {
273
+ console.log(JSON.stringify(output));
274
+ }
275
+
276
+ function warn(message: string): void {
277
+ if (message) console.error(message);
278
+ }
279
+
280
+ export async function run(): Promise<void> {
281
+ try {
282
+ const input = readStdinJson();
283
+ if (!input) {
284
+ respond({ continue: true });
285
+ return;
286
+ }
287
+
288
+ const text = extractToolOutputText(input);
289
+ if (!text) {
290
+ respond({ continue: true });
291
+ return;
292
+ }
293
+
294
+ let scan: ScanFn;
295
+ try {
296
+ scan = await loadScanner();
297
+ } catch (err) {
298
+ warn(
299
+ `[scanoutput] could not scan this tool output: ${
300
+ err instanceof Error ? err.message : String(err)
301
+ } — treat it as UNSCANNED, not clean.`,
302
+ );
303
+ respond({ continue: true });
304
+ return;
305
+ }
306
+
307
+ const outcome = analyzeToolOutput(text, scan);
308
+ warn(formatWarning(outcome));
309
+ respond(buildHookOutput(outcome));
310
+ } catch (err) {
311
+ // Nothing this hook does may end a session.
312
+ warn(`[scanoutput] hook failed: ${err instanceof Error ? err.message : String(err)}`);
313
+ respond({ continue: true });
314
+ }
315
+ }
316
+
317
+ if (import.meta.main) {
318
+ void run();
319
+ }
@@ -0,0 +1,109 @@
1
+ # mention-context
2
+
3
+ A `UserPromptSubmit` hook. When a prompt mentions `hasna/<repo>` or `hasnaxyz/<repo>`,
4
+ it injects a short context block for that repository — local checkout HEAD, installed
5
+ version, worktrees, npm version, GitHub default branch and open PRs.
6
+
7
+ This is the first Python hook in this repository. Everything else under `hooks/` is
8
+ TypeScript. The language is not a preference: this hook runs on **every prompt
9
+ submission** under a sub-second budget, and starting a Python interpreter that exits
10
+ immediately when no token matches is measurably cheaper here than the alternative that
11
+ was available when it was written. A TypeScript port is a reasonable future change; it
12
+ is a rewrite, not a move, and it is out of scope for the defect this directory was
13
+ created to fix.
14
+
15
+ ## Layout
16
+
17
+ ```
18
+ src/hasna-mention-context.py the hook
19
+ src/hasna-mention-warm.py the out-of-band cache warmer — see "The two files are a pair"
20
+ src/hook.test.ts bun-test wrapper — runs the Python suite under `bun test`
21
+ src/test_run_capture.py the Python regression suite
22
+ ```
23
+
24
+ ## Running the tests
25
+
26
+ ```bash
27
+ bun test hooks/mention-context # via the wrapper, as CI runs it
28
+ python3 hooks/mention-context/src/test_run_capture.py -v # directly
29
+ ```
30
+
31
+ The wrapper exists so the Python suite runs under the repository's existing `bun test`
32
+ step with no CI workflow change. It **fails** rather than skips when `python3` is
33
+ absent: a skip is indistinguishable from a pass in the summary line, and a regression
34
+ test that can silently not run is not a regression test.
35
+
36
+ ## The two files are a pair, and each resolves the other by directory
37
+
38
+ `hasna-mention-context.py` and `hasna-mention-warm.py` must be installed **into the same
39
+ directory**. Neither takes a configured path for the other; each derives the other's
40
+ location from its own `__file__`. The coupling runs in both directions and the two
41
+ failures look nothing alike.
42
+
43
+ **Warmer missing, hook present — silent.** The hook computes
44
+ `WARM_BIN = <its own dir>/hasna-mention-warm.py` and guards the call with
45
+ `os.path.isfile`, so `request_warm()` simply returns. Nothing raises, nothing is logged,
46
+ and the prompt block still renders. What stops is the cold-cache self-heal: the hook
47
+ hands every degraded or GitHub-less token to the warmer precisely so the *next* prompt
48
+ is clean, and that hand-off never happens. A repository mentioned for the first time is
49
+ degraded once and then stays degraded on every later prompt, instead of being clean
50
+ thereafter. It does not recover on its own, because the hook's own live GitHub probe
51
+ cannot close the gap — by the measurement recorded in the hook's header, `gh api graphql`
52
+ takes 1.02–1.18 s against a whole-hook deadline of 0.900 s, so the probe is killed before
53
+ it can write the cache. The warmer is the only thing that reliably fills it.
54
+
55
+ **Hook missing, warmer present — loud.** The warmer computes
56
+ `HOOK_PATH = <its own dir>/hasna-mention-context.py` and imports it at *module scope*
57
+ (`H = load_hook()`), with no guard, to keep one definition of the cache paths, the entry
58
+ shapes and the sanitizer. An absent hook is an immediate `FileNotFoundError` and the
59
+ warmer does not start at all.
60
+
61
+ Because the warmer imports the hook, the hook's `CACHE_DIR`, cache entry shape and
62
+ sanitizer are a **contract with a second program in this directory**, not private
63
+ details. Changing them means changing both files together.
64
+
65
+ Note that the scheduled warming path does not depend on co-location: the cron entry
66
+ invokes the warmer by absolute path. Co-location is what the hook's self-heal path needs.
67
+
68
+ ## Installation
69
+
70
+ This directory is the source. The hook is installed by copying
71
+ `src/hasna-mention-context.py` to the path registered in the agent's settings
72
+ (`~/.hasna/hooks/bin/hasna-mention-context.py` on the current fleet) and is registered
73
+ there by absolute path under `UserPromptSubmit`.
74
+
75
+ Copy `src/hasna-mention-warm.py` to that **same directory** in the same step, and
76
+ schedule it. On the current fleet it runs from cron at minutes 1, 11, 21, 31, 41 and 51
77
+ under `flock`, and it holds its own lock, so overlapping runs are harmless. Installing
78
+ the hook alone is a supported thing to do — the hook works without the warmer — but it
79
+ costs the self-heal described above, silently, so it should be a decision rather than an
80
+ oversight.
81
+
82
+ Installation is deliberately **not** performed by merging this directory. The hook
83
+ renders into every agent's prompt on every firing, so landing the source and updating
84
+ the live path are two separately verified steps.
85
+
86
+ ## The defect this directory was created to fix
87
+
88
+ `run_capture` derived its temp-file path from its `tag` argument alone, and
89
+ `probe_local_head` passed a constant `tag="gitlog"`. Repository probes run concurrently
90
+ against one shared temp directory, so every mentioned repository's `git log` wrote to
91
+ and read back the same `gitlog.out`, and the reader got whatever the last writer left.
92
+
93
+ The emitted value was always a **real sha from a real repository — just the wrong
94
+ one**, which is why it read as correct. Four consecutive firings, each a different wrong
95
+ pairing:
96
+
97
+ ```
98
+ 17:44 loops=82a3acf (right) accounts=27cffd7 (right) logs=absent
99
+ 17:48 loops=27cffd7 (WRONG) logs=82a3acf (WRONG) <- clean swap
100
+ 18:11 loops=27cffd7 (WRONG) logs=146a70e (right)
101
+ 18:17 loops=82a3acf (right) logs=82a3acf (WRONG) <- duplicate sha
102
+ ```
103
+
104
+ The race needs two or three mentions in one prompt. `MAX_TOKENS = 3`, so a single-repo
105
+ mention produces one probe, no concurrency, and always the correct answer.
106
+
107
+ The fix is in `run_capture`, which owns path construction, rather than at the call site:
108
+ threading `org`/`word` into `probe_local_head` would have matched its siblings but left
109
+ the invariant unenforced, so a fifth call site added later would inherit the bug.
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "mention-context",
3
+ "version": "0.1.0",
4
+ "description": "UserPromptSubmit hook that injects repository context for @hasna/<repo> mentions",
5
+ "type": "module",
6
+ "main": "./src/hasna-mention-context.py",
7
+ "author": "Hasna",
8
+ "license": "Apache-2.0"
9
+ }