@hasna/hooks 0.5.0 → 0.6.1

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 (37) hide show
  1. package/README.md +81 -8
  2. package/bin/index.js +1756 -382
  3. package/bin/serve.js +5258 -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 +5351 -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 +15 -2
  22. package/hooks/hook-scanoutput/README.md +151 -0
  23. package/hooks/hook-scanoutput/package.json +12 -0
  24. package/hooks/hook-scanoutput/src/hook.test.ts +217 -0
  25. package/hooks/hook-scanoutput/src/hook.ts +319 -0
  26. package/hooks/hook-workspace-repos-guard/README.md +63 -0
  27. package/hooks/hook-workspace-repos-guard/package.json +12 -0
  28. package/hooks/hook-workspace-repos-guard/src/hook.test.ts +312 -0
  29. package/hooks/hook-workspace-repos-guard/src/hook.ts +352 -0
  30. package/hooks/hook-workspace-repos-guard/tsconfig.json +21 -0
  31. package/hooks/mention-context/README.md +109 -0
  32. package/hooks/mention-context/package.json +9 -0
  33. package/hooks/mention-context/src/hasna-mention-context.py +1218 -0
  34. package/hooks/mention-context/src/hasna-mention-warm.py +521 -0
  35. package/hooks/mention-context/src/hook.test.ts +68 -0
  36. package/hooks/mention-context/src/test_run_capture.py +345 -0
  37. 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,63 @@
1
+ # workspace-repos-guard
2
+
3
+ Codewith-native hook installed as `hooks run workspace-repos-guard`.
4
+
5
+ PreToolUse guard for the canonical workspace structure (knowledge
6
+ `k_mssu9jdq_dgnnu2`): `$HOME/workspace` contains ONLY `repos/` + `scratch/` +
7
+ `AGENTS.md`, and `repos/` contains ONLY GitHub-org folders. Checkouts at
8
+ `$HOME/workspace/repos/<org>/<repo>/` are read/context only.
9
+
10
+ ## What it blocks
11
+
12
+ - Any write to `$HOME/workspace/repos` itself (file tools and Bash).
13
+ - Any write that would create a top-level entry directly under `repos/`
14
+ (depth 1 — a stray folder or file at the org level).
15
+ - Any write whose second path segment is not an allowed GitHub org.
16
+ - Any delete (`rm`, `rmdir`, `git clean`, `git rm`, `unlink`, `shred`,
17
+ `trash`, ...) anywhere under `$HOME/workspace/repos`, at any depth,
18
+ including deep inside org checkouts.
19
+
20
+ ## What it allows
21
+
22
+ - Reads, always.
23
+ - Writes deeper inside an allowed org folder
24
+ (`repos/<org>/<repo>/...`). Structure only: it deliberately does NOT
25
+ duplicate the `worktree-guard` hook, which owns edits-in-shared-checkouts
26
+ semantics.
27
+
28
+ Home spellings (`~`, `$HOME`, `${HOME}`, quoted or not, including split-quote
29
+ forms like `"$HOME"/workspace/repos`, which Bash treats identically to the
30
+ unquoted spelling) are expanded before classification; `apply_patch` tools
31
+ are inspected through their `Add File` / `Update File` / `Delete File`
32
+ markers; Bash relative operands are resolved against the command's cwd when
33
+ it sits under `repos/`; parenthesized command groups
34
+ (`(cd ... && rm -rf ...)`) are unwrapped.
35
+
36
+ ## Configuration
37
+
38
+ Allowed orgs default to `hasna,hasnaxyz,hasna-internal,hasna-products` and
39
+ are overridable with the `WORKSPACE_REPOS_GUARD_ORGS` env var
40
+ (comma-separated). The home directory is resolved with `os.homedir()` —
41
+ never hardcoded.
42
+
43
+ ## Failure mode
44
+
45
+ Fail-open: on any parse or evaluation error the hook responds `continue` so a
46
+ guard defect can never wedge the agent.
47
+
48
+ ## Known limitation
49
+
50
+ The guard is a best-effort **structural** guard, not an execution sandbox.
51
+ Variable indirection cannot be caught by pre-expansion inspection: a command
52
+ that builds its target dynamically (`R=...; rm -rf $R`, loops over computed
53
+ paths, scripts downloaded and executed at runtime) is undetectable at hook
54
+ time. The hook inspects literal spellings of the protected path (`~/...`,
55
+ `$HOME/...`, `${HOME}/...`, split-quote forms such as `"$HOME"/...`, the
56
+ resolved absolute home) and relative operands resolved from the command's
57
+ cwd, so anything the shell would expand or indirect through a variable is
58
+ outside its reach. Because it also fails open, it must never be relied on as
59
+ the only protection layer.
60
+
61
+ ## License
62
+
63
+ Apache-2.0
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "workspace-repos-guard",
3
+ "version": "0.1.0",
4
+ "description": "Codewith-native Workspace Repos Guard 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
+ }