@hasna/hooks 0.2.19 → 0.3.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 (61) hide show
  1. package/README.md +45 -5
  2. package/bin/index.js +1262 -9569
  3. package/dist/db/index.d.ts +11 -0
  4. package/dist/db/legacy-import.d.ts +11 -0
  5. package/dist/db/migrations/001_initial.d.ts +6 -0
  6. package/dist/db/migrations/002_session_events.d.ts +10 -0
  7. package/dist/db/migrations/003_user_prompt_submit_event.d.ts +10 -0
  8. package/dist/db/migrations/index.d.ts +7 -0
  9. package/dist/db/pg-migrations.d.ts +7 -0
  10. package/dist/db/remote-storage.d.ts +9 -0
  11. package/dist/db/retention.d.ts +8 -0
  12. package/dist/db/schema.d.ts +22 -0
  13. package/dist/db/storage-sync.d.ts +57 -0
  14. package/dist/index.d.ts +72 -0
  15. package/dist/index.js +1042 -58
  16. package/dist/lib/installer.d.ts +56 -0
  17. package/dist/lib/profiles.d.ts +33 -0
  18. package/dist/lib/registry.d.ts +21 -0
  19. package/dist/storage.d.ts +4 -0
  20. package/dist/storage.js +696 -0
  21. package/hooks/codewith-native-common.test.ts +61 -0
  22. package/hooks/codewith-native-common.ts +478 -0
  23. package/hooks/hook-agent-rules-version-check/LICENSE +191 -0
  24. package/hooks/hook-agent-rules-version-check/README.md +50 -0
  25. package/hooks/hook-agent-rules-version-check/package.json +53 -0
  26. package/hooks/hook-agent-rules-version-check/src/hook.test.ts +104 -0
  27. package/hooks/hook-agent-rules-version-check/src/hook.ts +215 -0
  28. package/hooks/hook-agent-rules-version-check/tsconfig.json +25 -0
  29. package/hooks/hook-announce-start/README.md +7 -5
  30. package/hooks/hook-announce-start/package.json +2 -2
  31. package/hooks/hook-announce-start/src/hook.ts +33 -14
  32. package/hooks/hook-fleet-blockers-gate/LICENSE +191 -0
  33. package/hooks/hook-fleet-blockers-gate/README.md +39 -0
  34. package/hooks/hook-fleet-blockers-gate/package.json +52 -0
  35. package/hooks/hook-fleet-blockers-gate/src/hook.test.ts +106 -0
  36. package/hooks/hook-fleet-blockers-gate/src/hook.ts +242 -0
  37. package/hooks/hook-fleet-blockers-gate/tsconfig.json +25 -0
  38. package/hooks/hook-fleet-catchup/LICENSE +191 -0
  39. package/hooks/hook-fleet-catchup/README.md +40 -0
  40. package/hooks/hook-fleet-catchup/package.json +52 -0
  41. package/hooks/hook-fleet-catchup/src/hook.test.ts +156 -0
  42. package/hooks/hook-fleet-catchup/src/hook.ts +283 -0
  43. package/hooks/hook-fleet-catchup/tsconfig.json +25 -0
  44. package/hooks/pre-bash/README.md +17 -0
  45. package/hooks/pre-bash/package.json +12 -0
  46. package/hooks/pre-bash/src/hook.ts +103 -0
  47. package/hooks/prompt-guard/README.md +17 -0
  48. package/hooks/prompt-guard/package.json +12 -0
  49. package/hooks/prompt-guard/src/hook.ts +76 -0
  50. package/hooks/session-start/README.md +17 -0
  51. package/hooks/session-start/package.json +12 -0
  52. package/hooks/session-start/src/hook.ts +150 -0
  53. package/hooks/stop-sync/README.md +17 -0
  54. package/hooks/stop-sync/package.json +12 -0
  55. package/hooks/stop-sync/src/hook.ts +69 -0
  56. package/hooks/worktree-guard/README.md +17 -0
  57. package/hooks/worktree-guard/package.json +12 -0
  58. package/hooks/worktree-guard/src/hook.ts +88 -0
  59. package/package.json +14 -7
  60. package/hooks/hook-affected-tests/dist/hook.js +0 -106
  61. package/hooks/hook-announce-start/dist/hook.js +0 -98
@@ -0,0 +1,156 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ parseJsonList,
4
+ hasJsonList,
5
+ resolveSince,
6
+ formatCatchup,
7
+ truncate,
8
+ safeIdentifier,
9
+ safeSince,
10
+ } from "./hook";
11
+
12
+ describe("hook-fleet-catchup", () => {
13
+ describe("parseJsonList", () => {
14
+ test("parses a bare array", () => {
15
+ expect(parseJsonList('[1,2]')).toEqual([1, 2]);
16
+ });
17
+
18
+ test("unwraps named keys in order", () => {
19
+ expect(parseJsonList('{"blockers":[1]}', "blockers", "messages")).toEqual([1]);
20
+ expect(parseJsonList('{"messages":[2]}', "blockers", "messages")).toEqual([2]);
21
+ });
22
+
23
+ test("returns empty for garbage and null", () => {
24
+ expect(parseJsonList("nope")).toEqual([]);
25
+ expect(parseJsonList(null)).toEqual([]);
26
+ expect(parseJsonList('{"other":true}')).toEqual([]);
27
+ });
28
+ });
29
+
30
+ describe("hasJsonList", () => {
31
+ test("accepts bare arrays and configured array envelopes", () => {
32
+ expect(hasJsonList("[]", "notifications", "messages")).toBe(true);
33
+ expect(hasJsonList('{"notifications":[]}', "notifications", "messages")).toBe(true);
34
+ expect(hasJsonList('{"messages":[1]}', "notifications", "messages")).toBe(true);
35
+ });
36
+
37
+ test("rejects invalid JSON and unexpected envelopes", () => {
38
+ expect(hasJsonList(null, "notifications", "messages")).toBe(false);
39
+ expect(hasJsonList("not json", "notifications", "messages")).toBe(false);
40
+ expect(hasJsonList('{"other":[]}', "notifications", "messages")).toBe(false);
41
+ });
42
+ });
43
+
44
+ describe("resolveSince", () => {
45
+ const now = new Date("2026-07-06T12:00:00.000Z");
46
+
47
+ test("uses the stored timestamp when valid and in the past", () => {
48
+ expect(resolveSince(now, "2026-07-06T09:30:00.000Z")).toBe("2026-07-06T09:30:00.000Z");
49
+ });
50
+
51
+ test("falls back to 24h lookback when state is missing", () => {
52
+ expect(resolveSince(now, null)).toBe("2026-07-05T12:00:00.000Z");
53
+ });
54
+
55
+ test("falls back when state is garbage", () => {
56
+ expect(resolveSince(now, "not-a-date")).toBe("2026-07-05T12:00:00.000Z");
57
+ });
58
+
59
+ test("falls back when state is in the future (clock skew)", () => {
60
+ expect(resolveSince(now, "2027-01-01T00:00:00.000Z")).toBe("2026-07-05T12:00:00.000Z");
61
+ });
62
+ });
63
+
64
+ describe("formatCatchup", () => {
65
+ test("returns null when everything is empty", () => {
66
+ expect(formatCatchup([], [], null)).toBeNull();
67
+ expect(formatCatchup([], [], '{"messages":[]}')).toBeNull();
68
+ });
69
+
70
+ test("includes blockers section with read-duty reminder", () => {
71
+ const out = formatCatchup(
72
+ [{ from: "chief", content: "[FREEZE] hold publishes", created_at: "2026-07-06T10:00:00Z" }],
73
+ [],
74
+ null
75
+ );
76
+ expect(out).toContain("UNREAD BLOCKING MESSAGES (1)");
77
+ expect(out).toContain("[FREEZE] hold publishes");
78
+ expect(out).toContain("stop and escalate");
79
+ });
80
+
81
+ test("includes notifications and digest sections", () => {
82
+ const out = formatCatchup(
83
+ [],
84
+ [{ from: "bot", channel: "git-releases", preview: "released x@1.0.0" }],
85
+ JSON.stringify({ messages: [{ from: "chief", content: "[POLICY] HACP v1.1" }] })
86
+ );
87
+ expect(out).toContain("CHANNEL NOTIFICATIONS since last catchup (1)");
88
+ expect(out).toContain("#git-releases");
89
+ expect(out).toContain("ANNOUNCEMENTS digest");
90
+ expect(out).toContain("[POLICY] HACP v1.1");
91
+ });
92
+
93
+ test("skips digest section when digest JSON is invalid", () => {
94
+ const out = formatCatchup([{ content: "x" }], [], "garbage");
95
+ expect(out).toContain("UNREAD BLOCKING MESSAGES");
96
+ expect(out).not.toContain("ANNOUNCEMENTS digest");
97
+ });
98
+
99
+ test("caps listed blockers at 10", () => {
100
+ const blockers = Array.from({ length: 25 }, (_, i) => ({ content: `blocker-${i}` }));
101
+ const out = formatCatchup(blockers, [], null)!;
102
+ expect(out).toContain("UNREAD BLOCKING MESSAGES (25)");
103
+ expect(out).toContain("blocker-9");
104
+ expect(out).not.toContain("blocker-10");
105
+ expect(out.split("\n").filter((l) => l.startsWith("- ")).length).toBe(10);
106
+ });
107
+ });
108
+
109
+ describe("truncate", () => {
110
+ test("passes short strings through", () => {
111
+ expect(truncate("abc", 10)).toBe("abc");
112
+ });
113
+
114
+ test("truncates long strings with ellipsis", () => {
115
+ const out = truncate("a".repeat(50), 10);
116
+ expect(out.length).toBe(10);
117
+ expect(out.endsWith("…")).toBe(true);
118
+ });
119
+ });
120
+
121
+ describe("safeIdentifier", () => {
122
+ test("accepts normal agent names", () => {
123
+ expect(safeIdentifier("chief")).toBe("chief");
124
+ expect(safeIdentifier("build-hooks_01.a")).toBe("build-hooks_01.a");
125
+ });
126
+
127
+ test("rejects shell metacharacters", () => {
128
+ expect(safeIdentifier("$(rm -rf /)")).toBeNull();
129
+ expect(safeIdentifier("a;b")).toBeNull();
130
+ expect(safeIdentifier("a b")).toBeNull();
131
+ expect(safeIdentifier("`x`")).toBeNull();
132
+ });
133
+
134
+ test("rejects empty/undefined", () => {
135
+ expect(safeIdentifier(undefined)).toBeNull();
136
+ expect(safeIdentifier("")).toBeNull();
137
+ });
138
+ });
139
+
140
+ describe("safeSince", () => {
141
+ test("accepts relative durations", () => {
142
+ expect(safeSince("7d", "1d")).toBe("7d");
143
+ expect(safeSince("24h", "1d")).toBe("24h");
144
+ });
145
+
146
+ test("accepts ISO timestamps", () => {
147
+ expect(safeSince("2026-07-06T00:00:00Z", "1d")).toBe("2026-07-06T00:00:00Z");
148
+ });
149
+
150
+ test("falls back on anything else", () => {
151
+ expect(safeSince("; rm -rf /", "7d")).toBe("7d");
152
+ expect(safeSince("$(x)", "7d")).toBe("7d");
153
+ expect(safeSince(undefined, "7d")).toBe("7d");
154
+ });
155
+ });
156
+ });
@@ -0,0 +1,283 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Claude Code Hook: fleet-catchup
5
+ *
6
+ * SessionStart hook that catches an agent up on fleet communications:
7
+ * - `conversations blockers -j` → unread blocking messages
8
+ * - `conversations notifications --since <last>` → channel notifications since last catchup
9
+ * - `conversations digest announcements --unread --since 7d` → bounded announcements digest
10
+ *
11
+ * All reads are deterministic CLI calls against the local conversations
12
+ * service. Every step is fail-open: a missing CLI, timeout, or parse error
13
+ * skips that section and never blocks the session. Output is injected via
14
+ * SessionStart's `hookSpecificOutput.additionalContext`.
15
+ *
16
+ * Environment:
17
+ * - HOOKS_FLEET_CATCHUP_DISABLE=1 → skip entirely
18
+ * - HOOKS_FLEET_AGENT=<name> → identity passed as --from
19
+ * - HOOKS_FLEET_TIMEOUT_MS=<n> → per-command exec timeout (default 1500)
20
+ * - HOOKS_FLEET_SINCE=<duration> → announcements digest window (default 7d)
21
+ */
22
+
23
+ import { readFileSync, existsSync, mkdirSync, writeFileSync } from "fs";
24
+ import { join } from "path";
25
+ import { homedir } from "os";
26
+ import { exec } from "child_process";
27
+
28
+ interface HookInput {
29
+ session_id: string;
30
+ cwd: string;
31
+ hook_event_name: string;
32
+ source?: string;
33
+ }
34
+
35
+ interface HookOutput {
36
+ continue: boolean;
37
+ hookSpecificOutput?: {
38
+ hookEventName: "SessionStart";
39
+ additionalContext: string;
40
+ };
41
+ }
42
+
43
+ const DEFAULT_TIMEOUT_MS = 1500;
44
+ const DEFAULT_LOOKBACK_MS = 24 * 60 * 60 * 1000; // 24h when no last-seen state
45
+ const STATE_DIR = join(homedir(), ".hasna", "hooks", "state");
46
+ const LAST_SEEN_FILE = join(STATE_DIR, "fleet-catchup.last_seen");
47
+
48
+ function readStdinJson(): HookInput | null {
49
+ try {
50
+ const input = readFileSync(0, "utf-8").trim();
51
+ if (!input) return null;
52
+ return JSON.parse(input);
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function respond(output: HookOutput): void {
59
+ console.log(JSON.stringify(output));
60
+ }
61
+
62
+ function timeoutMs(): number {
63
+ const raw = Number(process.env.HOOKS_FLEET_TIMEOUT_MS);
64
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS;
65
+ }
66
+
67
+ /** Only pass through shell-safe identifier values (defense against env-borne injection). */
68
+ export function safeIdentifier(value: string | undefined): string | null {
69
+ if (!value) return null;
70
+ return /^[A-Za-z0-9._-]{1,64}$/.test(value) ? value : null;
71
+ }
72
+
73
+ /** Only accept relative durations (7d, 24h) or ISO-ish timestamps for --since. */
74
+ export function safeSince(value: string | undefined, fallback: string): string {
75
+ if (!value) return fallback;
76
+ if (/^[0-9]{1,4}[smhdw]$/.test(value)) return value;
77
+ if (/^[0-9]{4}-[0-9]{2}-[0-9]{2}[T0-9:.Z+-]*$/.test(value)) return value;
78
+ return fallback;
79
+ }
80
+
81
+ function fromFlag(): string {
82
+ const agent = safeIdentifier(process.env.HOOKS_FLEET_AGENT);
83
+ return agent ? ` --from ${agent}` : "";
84
+ }
85
+
86
+ /**
87
+ * Run a CLI command, resolving stdout or null on any failure (fail-open).
88
+ * Async so the three catchup reads run in parallel — worst-case session-start
89
+ * cost is one timeout, not the sum of three.
90
+ */
91
+ function tryExec(command: string): Promise<string | null> {
92
+ return new Promise((resolve) => {
93
+ try {
94
+ exec(command, { encoding: "utf-8", timeout: timeoutMs() }, (err, stdout) => {
95
+ resolve(err ? null : (stdout ?? "").trim() || null);
96
+ });
97
+ } catch {
98
+ resolve(null);
99
+ }
100
+ });
101
+ }
102
+
103
+ /** Parse CLI JSON that may be an array or an object wrapping an array. */
104
+ export function parseJsonList(raw: string | null, ...wrapperKeys: string[]): unknown[] {
105
+ if (!raw) return [];
106
+ try {
107
+ const data = JSON.parse(raw);
108
+ if (Array.isArray(data)) return data;
109
+ if (data && typeof data === "object") {
110
+ for (const key of wrapperKeys) {
111
+ const inner = (data as Record<string, unknown>)[key];
112
+ if (Array.isArray(inner)) return inner;
113
+ }
114
+ }
115
+ return [];
116
+ } catch {
117
+ return [];
118
+ }
119
+ }
120
+
121
+ /** True when raw CLI output was parseable as the expected list shape. */
122
+ export function hasJsonList(raw: string | null, ...wrapperKeys: string[]): boolean {
123
+ if (!raw) return false;
124
+ try {
125
+ const data = JSON.parse(raw);
126
+ if (Array.isArray(data)) return true;
127
+ if (data && typeof data === "object") {
128
+ return wrapperKeys.some((key) => Array.isArray((data as Record<string, unknown>)[key]));
129
+ }
130
+ return false;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ /** Read the last catchup timestamp; fall back to a bounded 24h lookback. */
137
+ export function resolveSince(now: Date, stored: string | null): string {
138
+ if (stored) {
139
+ const parsed = new Date(stored.trim());
140
+ if (!Number.isNaN(parsed.getTime()) && parsed.getTime() <= now.getTime()) {
141
+ return parsed.toISOString();
142
+ }
143
+ }
144
+ return new Date(now.getTime() - DEFAULT_LOOKBACK_MS).toISOString();
145
+ }
146
+
147
+ function readLastSeen(): string | null {
148
+ try {
149
+ if (!existsSync(LAST_SEEN_FILE)) return null;
150
+ return readFileSync(LAST_SEEN_FILE, "utf-8");
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ function writeLastSeen(now: Date): void {
157
+ try {
158
+ mkdirSync(STATE_DIR, { recursive: true });
159
+ writeFileSync(LAST_SEEN_FILE, now.toISOString());
160
+ } catch {
161
+ // fail-open: state is an optimization, not a requirement
162
+ }
163
+ }
164
+
165
+ function describeItem(item: unknown): string {
166
+ if (!item || typeof item !== "object") return String(item);
167
+ const m = item as Record<string, unknown>;
168
+ const from = typeof m.from === "string" ? m.from : typeof m.sender === "string" ? m.sender : "unknown";
169
+ const channel = typeof m.channel === "string" ? m.channel : typeof m.channel_name === "string" ? m.channel_name : null;
170
+ const body =
171
+ typeof m.preview === "string" ? m.preview :
172
+ typeof m.content === "string" ? m.content :
173
+ typeof m.message === "string" ? m.message : JSON.stringify(m).slice(0, 200);
174
+ const time = typeof m.created_at === "string" ? m.created_at : typeof m.timestamp === "string" ? m.timestamp : "";
175
+ const where = channel ? ` #${channel}` : "";
176
+ return `- [${time}]${where} ${from}: ${truncate(body, 300)}`;
177
+ }
178
+
179
+ export function truncate(text: string, max: number): string {
180
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
181
+ }
182
+
183
+ /** Build the injected context from the three catchup sections. */
184
+ export function formatCatchup(
185
+ blockers: unknown[],
186
+ notifications: unknown[],
187
+ digestRaw: string | null
188
+ ): string | null {
189
+ const sections: string[] = [];
190
+
191
+ if (blockers.length > 0) {
192
+ sections.push(
193
+ `UNREAD BLOCKING MESSAGES (${blockers.length}) — resolve before starting work:\n` +
194
+ blockers.slice(0, 10).map(describeItem).join("\n")
195
+ );
196
+ }
197
+
198
+ if (notifications.length > 0) {
199
+ sections.push(
200
+ `CHANNEL NOTIFICATIONS since last catchup (${notifications.length}):\n` +
201
+ notifications.slice(0, 15).map(describeItem).join("\n")
202
+ );
203
+ }
204
+
205
+ if (digestRaw) {
206
+ try {
207
+ const digest = JSON.parse(digestRaw) as Record<string, unknown>;
208
+ const messages = Array.isArray(digest.messages) ? digest.messages : [];
209
+ if (messages.length > 0) {
210
+ sections.push(
211
+ `ANNOUNCEMENTS digest (unread, last 7d, ${messages.length} shown):\n` +
212
+ messages.slice(0, 15).map(describeItem).join("\n")
213
+ );
214
+ }
215
+ } catch {
216
+ // unparseable digest → skip section
217
+ }
218
+ }
219
+
220
+ if (sections.length === 0) return null;
221
+
222
+ return (
223
+ `[hook-fleet-catchup] Fleet communications catchup:\n\n` +
224
+ sections.join("\n\n") +
225
+ `\n\nRead-duty: handle blockers before claiming work; an unread [FREEZE] means stop and escalate to #help.`
226
+ );
227
+ }
228
+
229
+ export async function run(): Promise<void> {
230
+ if (process.env.HOOKS_FLEET_CATCHUP_DISABLE === "1") {
231
+ respond({ continue: true });
232
+ return;
233
+ }
234
+
235
+ const input = readStdinJson();
236
+ if (!input) {
237
+ respond({ continue: true });
238
+ return;
239
+ }
240
+
241
+ const now = new Date();
242
+ const from = fromFlag();
243
+ const since = resolveSince(now, readLastSeen());
244
+ const window = safeSince(process.env.HOOKS_FLEET_SINCE, "7d");
245
+
246
+ // The three catchup reads run in parallel — all fail-open independently:
247
+ // 1. unread blocking messages
248
+ // 2. channel notifications since last catchup (bounded lookback)
249
+ // 3. bounded announcements digest (never hands a session full history)
250
+ const [blockersRaw, notificationsRaw, digestRaw] = await Promise.all([
251
+ tryExec(`conversations blockers -j${from}`),
252
+ tryExec(`conversations notifications --since ${since} -j${from}`),
253
+ tryExec(`conversations digest announcements --unread --since ${window} -j${from}`),
254
+ ]);
255
+
256
+ const blockers = parseJsonList(blockersRaw, "blockers", "messages");
257
+ const notifications = parseJsonList(notificationsRaw, "notifications", "messages");
258
+
259
+ // Advance the last-seen cursor ONLY when the notifications read succeeded
260
+ // and returned the expected JSON shape. Otherwise bad output at session
261
+ // start would silently swallow the window.
262
+ if (hasJsonList(notificationsRaw, "notifications", "messages")) {
263
+ writeLastSeen(now);
264
+ }
265
+
266
+ const context = formatCatchup(blockers, notifications, digestRaw);
267
+ if (context) {
268
+ respond({
269
+ continue: true,
270
+ hookSpecificOutput: {
271
+ hookEventName: "SessionStart",
272
+ additionalContext: context,
273
+ },
274
+ });
275
+ return;
276
+ }
277
+
278
+ respond({ continue: true });
279
+ }
280
+
281
+ if (import.meta.main) {
282
+ void run();
283
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "lib": ["ESNext"],
6
+ "moduleResolution": "bundler",
7
+ "allowImportingTsExtensions": true,
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "noEmit": true,
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "declaration": true,
18
+ "declarationMap": true,
19
+ "outDir": "./dist",
20
+ "rootDir": "./src",
21
+ "types": ["bun-types"]
22
+ },
23
+ "include": ["src/**/*"],
24
+ "exclude": ["node_modules", "dist"]
25
+ }
@@ -0,0 +1,17 @@
1
+ # pre-bash
2
+
3
+ Codewith-native hook installed as `hooks run pre-bash`.
4
+
5
+ This hook is OSS-safe: optional Hasna CLIs are best-effort and missing CLIs fail open with concise warnings. Security gates only fail closed when a guarded commit/push scan runs successfully and finds possible secrets.
6
+
7
+ ## Install for Codewith
8
+
9
+ Prefer renderer-managed configuration through open-configs. @hasna/hooks can emit the TOML fragment:
10
+
11
+ ```bash
12
+ hooks install pre-bash --target codewith
13
+ ```
14
+
15
+ ## License
16
+
17
+ Apache-2.0
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "pre-bash",
3
+ "version": "0.1.0",
4
+ "description": "Codewith-native Pre Bash 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,103 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import {
4
+ commandExists,
5
+ gitCommandInfo,
6
+ getCommand,
7
+ isBashPreToolUse,
8
+ isRiskyOperation,
9
+ readCache,
10
+ readInput,
11
+ redactGitleaksOutput,
12
+ respond,
13
+ runCommand,
14
+ warn,
15
+ type CodewithHookInput,
16
+ } from "../../codewith-native-common";
17
+
18
+ interface SessionDigest {
19
+ context: string;
20
+ warnings: string[];
21
+ }
22
+
23
+ async function runStagedSecretsScan(cwd: string): Promise<{ block: boolean; reason?: string; warning?: string }> {
24
+ if (!commandExists("gitleaks")) {
25
+ return { block: false, warning: "gitleaks unavailable; staged secrets scan skipped fail-open." };
26
+ }
27
+
28
+ const result = await runCommand([
29
+ "gitleaks", "git", "--staged", "--no-banner", "--redact", "--report-format", "json", "--report-path", "-", ".",
30
+ ], { cwd, timeoutMs: 15_000 });
31
+
32
+ if (result.exitCode === 0) return { block: false };
33
+ if (result.exitCode === 1) {
34
+ return { block: true, reason: redactGitleaksOutput(result.stdout, result.stderr) };
35
+ }
36
+ return { block: false, warning: `gitleaks scan did not complete cleanly (exit ${result.exitCode ?? "unknown"}${result.timedOut ? ", timed out" : ""}); fail-open.` };
37
+ }
38
+
39
+ function riskyCommsBlock(): string | null {
40
+ const cached = readCache<SessionDigest>("session-start-digest", 5 * 60_000);
41
+ if (!cached) return null;
42
+ const context = cached.context || "";
43
+ if (/\[FREEZE\]/i.test(context)) return "Cached announcements contain a [FREEZE]. Re-check conversations before running risky operations.";
44
+ if (/Unread blockers JSON:\n\s*\[(?!\s*\])/i.test(context)) return "Cached conversations blockers are non-empty. Resolve or verify blockers before risky operations.";
45
+ if (/"message_ids"\s*:\s*\[(?!\s*\])/i.test(context) && /blockers/i.test(context)) return "Cached blocker digest appears non-empty. Re-check conversations before risky operations.";
46
+ return null;
47
+ }
48
+
49
+ export async function evaluate(input: CodewithHookInput): Promise<{ output: Record<string, unknown>; warnings: string[] }> {
50
+ const warnings: string[] = [];
51
+ if (!isBashPreToolUse(input)) return { output: { continue: true }, warnings };
52
+ const command = getCommand(input);
53
+ if (!command) return { output: { continue: true }, warnings };
54
+ const cwd = input.cwd || process.cwd();
55
+ const gitInfo = gitCommandInfo(command, cwd);
56
+
57
+ if (gitInfo) {
58
+ const scan = await runStagedSecretsScan(gitInfo.targetCwd);
59
+ if (scan.warning) warnings.push(scan.warning);
60
+ if (scan.block) {
61
+ return { output: { decision: "block", reason: scan.reason }, warnings };
62
+ }
63
+ }
64
+
65
+ if (isRiskyOperation(command)) {
66
+ const reason = riskyCommsBlock();
67
+ if (reason) return { output: { decision: "block", reason }, warnings };
68
+ if (!readCache<SessionDigest>("session-start-digest", 5 * 60_000)) {
69
+ warnings.push("No fresh session-start comms cache; risky operation allowed fail-open, but manually re-check blockers/announcements first.");
70
+ }
71
+ }
72
+
73
+ if (warnings.length > 0) {
74
+ return {
75
+ output: {
76
+ continue: true,
77
+ hookSpecificOutput: {
78
+ hookEventName: "PreToolUse",
79
+ additionalContext: warnings.map((w) => `[pre-bash] ${w}`).join("\n"),
80
+ },
81
+ },
82
+ warnings,
83
+ };
84
+ }
85
+
86
+ return { output: { continue: true }, warnings };
87
+ }
88
+
89
+ export async function run(): Promise<void> {
90
+ const input = readInput();
91
+ try {
92
+ const { output, warnings } = await evaluate(input);
93
+ for (const message of warnings) warn(message);
94
+ respond(output);
95
+ } catch (error) {
96
+ warn(`pre-bash failed open: ${error instanceof Error ? error.message : String(error)}`);
97
+ respond({ continue: true });
98
+ }
99
+ }
100
+
101
+ if (import.meta.main) {
102
+ await run();
103
+ }
@@ -0,0 +1,17 @@
1
+ # prompt-guard
2
+
3
+ Codewith-native hook installed as `hooks run prompt-guard`.
4
+
5
+ This hook is OSS-safe: optional Hasna CLIs are best-effort and missing CLIs fail open with concise warnings. Security gates only fail closed when a guarded commit/push scan runs successfully and finds possible secrets.
6
+
7
+ ## Install for Codewith
8
+
9
+ Prefer renderer-managed configuration through open-configs. @hasna/hooks can emit the TOML fragment:
10
+
11
+ ```bash
12
+ hooks install prompt-guard --target codewith
13
+ ```
14
+
15
+ ## License
16
+
17
+ Apache-2.0
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "prompt-guard",
3
+ "version": "0.1.0",
4
+ "description": "Codewith-native Prompt 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
+ }