@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,312 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ import { evaluate, resolveAllowedOrgs } from "./hook";
5
+
6
+ const HOME = homedir();
7
+ const REPOS = join(HOME, "workspace", "repos");
8
+
9
+ function preToolUse(tool_name: string, tool_input: Record<string, unknown>, cwd = join(REPOS, "hasna", "apps")) {
10
+ return { hook_event_name: "PreToolUse", tool_name, tool_input, cwd };
11
+ }
12
+
13
+ function isBlocked(result: { output: { decision?: string; reason?: string } }): string | null {
14
+ if (result.output.decision === "block") return result.output.reason ?? "blocked";
15
+ return null;
16
+ }describe("hook-workspace-repos-guard", () => {
17
+ describe("resolveAllowedOrgs", () => {
18
+ test("defaults to the four canonical GitHub orgs", () => {
19
+ expect([...resolveAllowedOrgs({})].sort()).toEqual(["hasna", "hasna-internal", "hasna-products", "hasnaxyz"]);
20
+ });
21
+
22
+ test("parses comma-separated env override, trimming whitespace and empties", () => {
23
+ expect([...resolveAllowedOrgs({ WORKSPACE_REPOS_GUARD_ORGS: " hasna , myorg ,, " })].sort()).toEqual(["hasna", "myorg"]);
24
+ });
25
+ });
26
+
27
+ describe("positive controls — must continue", () => {
28
+ test("Write deep inside an allowed org checkout continues", () => {
29
+ const result = evaluate(preToolUse("Write", { file_path: join(REPOS, "hasna", "apps", "apps", "foo", "src", "index.ts") }));
30
+ expect(result.output.continue).toBe(true);
31
+ expect(isBlocked(result)).toBeNull();
32
+ });
33
+
34
+ test("Edit inside hasna-internal org continues", () => {
35
+ const result = evaluate(preToolUse("Edit", { file_path: join(REPOS, "hasna-internal", "platform", "docs", "readme.md") }));
36
+ expect(result.output.continue).toBe(true);
37
+ });
38
+
39
+ test("MultiEdit with relative file_path resolves against cwd and continues inside allowed org", () => {
40
+ const result = evaluate(preToolUse("MultiEdit", { file_path: "apps/hooks/src/lib/registry.ts" }));
41
+ expect(result.output.continue).toBe(true);
42
+ });
43
+
44
+ test("NotebookEdit inside allowed org continues", () => {
45
+ const result = evaluate(preToolUse("NotebookEdit", { notebook_path: join(REPOS, "hasnaxyz", "iapp-probe", "probe.ipynb") }));
46
+ expect(result.output.continue).toBe(true);
47
+ });
48
+
49
+ test("Bash read commands under repos are never blocked", () => {
50
+ const reads = [
51
+ `ls ${REPOS}`,
52
+ `cat ${join(REPOS, "hasna", "apps", "README.md")}`,
53
+ `git -C ${join(REPOS, "hasna", "apps")} status`,
54
+ `cd ${REPOS} && git pull`,
55
+ `find ${join(REPOS, "hasna")} -name AGENTS.md`,
56
+ ];
57
+ for (const command of reads) {
58
+ const result = evaluate(preToolUse("Bash", { command }));
59
+ expect(result.output.continue).toBe(true);
60
+ }
61
+ });
62
+
63
+ test("Bash write deep inside an allowed org checkout continues", () => {
64
+ const commands = [
65
+ `echo note > ${join(REPOS, "hasna", "apps", "scratch.md")}`,
66
+ `touch ${join(REPOS, "hasna", "apps", "apps", "foo", "x.txt")}`,
67
+ `git clone https://github.com/hasna/foo.git ${join(REPOS, "hasna", "foo")}`,
68
+ `mkdir -p ${join(REPOS, "hasna-internal", "platform", "apps")}`,
69
+ ];
70
+ for (const command of commands) {
71
+ const result = evaluate(preToolUse("Bash", { command }));
72
+ expect(result.output.continue).toBe(true);
73
+ }
74
+ });
75
+
76
+ test("tilde/$HOME spellings of deep allowed-org writes continue", () => {
77
+ const commands = [
78
+ `touch ~/workspace/repos/hasna/apps/foo.md`,
79
+ `echo x > ~/workspace/repos/hasnaxyz/iapp-probe/notes.md`,
80
+ `mkdir -p "${HOME}/workspace/repos/hasna-internal/platform/apps"`,
81
+ `touch "$HOME"/workspace/repos/hasna/apps/x.txt`,
82
+ `echo x > "\${HOME}"/workspace/repos/hasnaxyz/iapp-probe/notes.md`,
83
+ ];
84
+ for (const command of commands) {
85
+ const result = evaluate(preToolUse("Bash", { command }));
86
+ expect(result.output.continue).toBe(true);
87
+ }
88
+ });
89
+
90
+ test("file-tool tilde/$HOME paths deep inside an allowed org continue", () => {
91
+ const results = [
92
+ evaluate(preToolUse("Write", { file_path: `~/workspace/repos/hasna/apps/src/x.ts` })),
93
+ evaluate(preToolUse("Edit", { file_path: `$HOME/workspace/repos/hasnaxyz/iapp-probe/n.ipynb` })),
94
+ evaluate(preToolUse("Write", { file_path: `"${HOME}/workspace/repos/hasna/apps/notes.md"` })),
95
+ evaluate(preToolUse("Write", { file_path: `"$HOME"/workspace/repos/hasna/apps/notes2.md` })),
96
+ ];
97
+ for (const result of results) {
98
+ expect(result.output.continue).toBe(true);
99
+ }
100
+ });
101
+
102
+ test("apply_patch Add/Update File deep inside an allowed org continues", () => {
103
+ const result = evaluate(
104
+ preToolUse("apply_patch", {
105
+ patch: `*** Begin Patch\n*** Add File: apps/hooks/src/lib/registry.ts\n+export const x = 1;\n*** End Patch`,
106
+ })
107
+ );
108
+ expect(result.output.continue).toBe(true);
109
+ });
110
+
111
+ test("non-mutating sed without -i, and interpreter without a path, continue", () => {
112
+ const commands = [
113
+ `sed 's/a/b/' ${join(REPOS, "hasna", "apps", "README.md")}`,
114
+ `node -e "console.log('no path here')"`,
115
+ `python3 -c "print('no path here')"`,
116
+ ];
117
+ for (const command of commands) {
118
+ const result = evaluate(preToolUse("Bash", { command }));
119
+ expect(result.output.continue).toBe(true);
120
+ }
121
+ });
122
+
123
+ test("non-PreToolUse events and non-file tools are ignored", () => {
124
+ const result = evaluate({ hook_event_name: "PostToolUse", tool_name: "Write", tool_input: { file_path: join(REPOS, "hasna", "x.ts") } });
125
+ expect(result.output.continue).toBe(true);
126
+ const read = evaluate(preToolUse("Read", { file_path: join(REPOS, "hasna", "apps", "README.md") }));
127
+ expect(read.output.continue).toBe(true);
128
+ });
129
+ });
130
+
131
+ describe("negative controls — must block", () => {
132
+ test("Write to ~/workspace/repos itself is blocked", () => {
133
+ const result = evaluate(preToolUse("Write", { file_path: join(REPOS, "stray.txt") }));
134
+ const reason = isBlocked(result);
135
+ expect(reason).not.toBeNull();
136
+ expect(reason).toContain("workspace-repos-guard");
137
+ });
138
+
139
+ test("Write to the repos root directory entry is blocked", () => {
140
+ const result = evaluate(preToolUse("Edit", { file_path: REPOS }));
141
+ expect(isBlocked(result)).not.toBeNull();
142
+ });
143
+
144
+ test("Write whose second segment is not an allowed org is blocked", () => {
145
+ const result = evaluate(preToolUse("Write", { file_path: join(REPOS, "notanorg", "x", "file.ts") }));
146
+ const reason = isBlocked(result);
147
+ expect(reason).not.toBeNull();
148
+ expect(reason).toContain("notanorg");
149
+ });
150
+
151
+ test("Write creating a top-level entry under repos (org-level, no deeper path) is blocked", () => {
152
+ const result = evaluate(preToolUse("Write", { file_path: join(REPOS, "hasna") }));
153
+ expect(isBlocked(result)).not.toBeNull();
154
+ });
155
+
156
+ test("rm -rf deep inside an allowed org checkout is blocked", () => {
157
+ const result = evaluate(preToolUse("Bash", { command: `rm -rf ${join(REPOS, "hasna", "apps", ".git")}` }));
158
+ const reason = isBlocked(result);
159
+ expect(reason).not.toBeNull();
160
+ expect(reason).toContain("delete");
161
+ });
162
+
163
+ test("rmdir of an org folder is blocked", () => {
164
+ const result = evaluate(preToolUse("Bash", { command: `rmdir ${join(REPOS, "oldorg")}` }));
165
+ expect(isBlocked(result)).not.toBeNull();
166
+ });
167
+
168
+ test("git clean inside a checkout is blocked", () => {
169
+ const result = evaluate(preToolUse("Bash", { command: `git -C ${join(REPOS, "hasna", "apps")} clean -fd` }));
170
+ expect(isBlocked(result)).not.toBeNull();
171
+ });
172
+
173
+ test("rm -rf of the repos root itself is blocked", () => {
174
+ const result = evaluate(preToolUse("Bash", { command: `rm -rf ${REPOS}` }));
175
+ expect(isBlocked(result)).not.toBeNull();
176
+ });
177
+
178
+ test("cd into repos followed by rm -rf . is blocked via relative resolution", () => {
179
+ const result = evaluate(preToolUse("Bash", { command: `cd ${REPOS} && rm -rf .` }));
180
+ expect(isBlocked(result)).not.toBeNull();
181
+ });
182
+
183
+ test("top-level non-org folder creation via Bash is blocked", () => {
184
+ const commands = [
185
+ `mkdir -p ${join(REPOS, "notanorg")}`,
186
+ `mkdir -p ${join(REPOS, "notanorg", "sub")}`,
187
+ `touch ${join(REPOS, "neworg", "x")}`,
188
+ `mv ${join(REPOS, "hasna", "apps", "a")} ${join(REPOS, "anotherorg")}`,
189
+ ];
190
+ for (const command of commands) {
191
+ const result = evaluate(preToolUse("Bash", { command }));
192
+ expect(isBlocked(result)).not.toBeNull();
193
+ }
194
+ });
195
+
196
+ test("Bash write into a non-allowed org folder is blocked", () => {
197
+ const result = evaluate(preToolUse("Bash", { command: `echo x > ${join(REPOS, "notanorg", "deep", "f.ts")}` }));
198
+ const reason = isBlocked(result);
199
+ expect(reason).not.toBeNull();
200
+ expect(reason).toContain("notanorg");
201
+ });
202
+
203
+ test("delete via git rm is blocked", () => {
204
+ const result = evaluate(preToolUse("Bash", { command: `git -C ${join(REPOS, "hasna", "apps")} rm src/foo.ts` }));
205
+ expect(isBlocked(result)).not.toBeNull();
206
+ });
207
+
208
+ test("tilde/$HOME spellings of the protected path are blocked", () => {
209
+ const commands = [
210
+ `rm -rf ~/workspace/repos`,
211
+ `rm -rf ~/workspace/repos/hasna/apps`,
212
+ `rm -rf $HOME/workspace/repos/hasna`,
213
+ `rm -rf "${HOME}/workspace/repos"`,
214
+ `rm -rf "$HOME"/workspace/repos`,
215
+ `rm -rf "$HOME"/workspace/repos/hasna/apps`,
216
+ `rm -rf "\${HOME}"/workspace/repos/hasna`,
217
+ `rm -rf "${HOME}"/workspace/repos/hasna`,
218
+ `touch ~/workspace/repos/stray.txt`,
219
+ `mkdir -p ~/workspace/repos/notanorg`,
220
+ `echo x > ~/workspace/repos/notanorg/f.ts`,
221
+ `mv /tmp/x ~/workspace/repos`,
222
+ `truncate -s 0 ~/workspace/repos/stray.log`,
223
+ `rsync -a /tmp/x ~/workspace/repos/`,
224
+ `scp f.txt ~/workspace/repos/`,
225
+ ];
226
+ for (const command of commands) {
227
+ const result = evaluate(preToolUse("Bash", { command }));
228
+ expect(isBlocked(result), `expected BLOCK for: ${command}`).not.toBeNull();
229
+ }
230
+ });
231
+
232
+ test("file-tool tilde/$HOME spellings are blocked", () => {
233
+ const results = [
234
+ evaluate(preToolUse("Write", { file_path: `~/workspace/repos/stray.txt` })),
235
+ evaluate(preToolUse("Write", { file_path: `$HOME/workspace/repos/notanorg/f.ts` })),
236
+ evaluate(preToolUse("Edit", { file_path: `~/workspace/repos/hasna` })),
237
+ evaluate(preToolUse("Write", { file_path: `~/workspace/repos` })),
238
+ evaluate(preToolUse("Write", { file_path: `"$HOME"/workspace/repos/stray.txt` })),
239
+ ];
240
+ for (const result of results) {
241
+ expect(isBlocked(result)).not.toBeNull();
242
+ }
243
+ });
244
+
245
+ test("subshell-grouped cd + rm is blocked", () => {
246
+ const commands = [
247
+ `(cd ~/workspace/repos && rm -rf hasna)`,
248
+ `(cd ${REPOS} && rm -rf hasna)`,
249
+ `( cd ${REPOS} && touch stray.txt )`,
250
+ ];
251
+ for (const command of commands) {
252
+ const result = evaluate(preToolUse("Bash", { command }));
253
+ expect(isBlocked(result), `expected BLOCK for: ${command}`).not.toBeNull();
254
+ }
255
+ });
256
+
257
+ test("cwd-relative deletes without an explicit cd are blocked", () => {
258
+ const fromCheckout = evaluate(preToolUse("Bash", { command: `rm -rf ../` }, join(REPOS, "hasna", "apps")));
259
+ expect(isBlocked(fromCheckout)).not.toBeNull();
260
+ const fromRoot = evaluate(preToolUse("Bash", { command: `mkdir -p notanorg` }, REPOS));
261
+ expect(isBlocked(fromRoot)).not.toBeNull();
262
+ const bareRm = evaluate(preToolUse("Bash", { command: `rm -rf .` }, join(REPOS, "hasnaxyz")));
263
+ expect(isBlocked(bareRm)).not.toBeNull();
264
+ });
265
+
266
+ test("mutating verbs writing under the protected root are blocked", () => {
267
+ const commands = [
268
+ `sed -i 's/x/y/' ~/workspace/repos/stray.txt`,
269
+ `rsync -a ${join(REPOS, "hasna")} /tmp/out`,
270
+ `scp -r ${join(REPOS, "hasna")} other:/tmp/`,
271
+ `truncate -s 0 ${join(REPOS, "notanorg", "f.ts")}`,
272
+ `python3 -c "open('~/workspace/repos/stray.txt','w')"`,
273
+ `node -e "require('fs').writeFileSync('${join(REPOS, "notanorg", "f.ts")}','x')"`,
274
+ ];
275
+ for (const command of commands) {
276
+ const result = evaluate(preToolUse("Bash", { command }));
277
+ expect(isBlocked(result), `expected BLOCK for: ${command}`).not.toBeNull();
278
+ }
279
+ });
280
+
281
+ test("apply_patch Delete File and shallow Add File are blocked", () => {
282
+ const del = evaluate(
283
+ preToolUse("ApplyPatch", {
284
+ patch: `*** Begin Patch\n*** Delete File: src/foo.ts\n*** End Patch`,
285
+ })
286
+ );
287
+ expect(isBlocked(del)).not.toBeNull();
288
+ const shallow = evaluate(
289
+ preToolUse("functions.apply_patch", {
290
+ patch: `*** Begin Patch\n*** Add File: ../../stray.txt\n+x\n*** End Patch`,
291
+ })
292
+ );
293
+ expect(isBlocked(shallow)).not.toBeNull();
294
+ });
295
+ });
296
+
297
+ describe("orgs env override", () => {
298
+ test("override widens and narrows the allowed set", () => {
299
+ const previous = process.env.WORKSPACE_REPOS_GUARD_ORGS;
300
+ try {
301
+ process.env.WORKSPACE_REPOS_GUARD_ORGS = "hasna,myorg";
302
+ const allowed = evaluate(preToolUse("Write", { file_path: join(REPOS, "myorg", "proj", "f.ts") }));
303
+ expect(allowed.output.continue).toBe(true);
304
+ const narrowed = evaluate(preToolUse("Write", { file_path: join(REPOS, "hasnaxyz", "iapp-x", "f.ts") }));
305
+ expect(isBlocked(narrowed)).not.toBeNull();
306
+ } finally {
307
+ if (previous === undefined) delete process.env.WORKSPACE_REPOS_GUARD_ORGS;
308
+ else process.env.WORKSPACE_REPOS_GUARD_ORGS = previous;
309
+ }
310
+ });
311
+ });
312
+ });
@@ -0,0 +1,352 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * PreToolUse hook: workspace-repos-guard
5
+ *
6
+ * Guards the canonical workspace structure (knowledge k_mssu9jdq_dgnnu2):
7
+ * $HOME/workspace contains ONLY repos/ + scratch/ + AGENTS.md, and repos/
8
+ * contains ONLY GitHub-org folders. Checkouts at
9
+ * $HOME/workspace/repos/<org>/<repo>/ are read/context only.
10
+ *
11
+ * Structure-only guard — it deliberately does NOT duplicate the worktree-guard
12
+ * hook, which owns edits-in-shared-checkouts semantics.
13
+ *
14
+ * BLOCKS:
15
+ * - any write to $HOME/workspace/repos itself (file tools and Bash),
16
+ * - any write that would create a top-level entry under repos/ (depth <= 1),
17
+ * - any write whose second path segment is not an allowed GitHub org,
18
+ * - any delete (rm, rmdir, git clean, git rm, unlink, shred, trash, ...)
19
+ * anywhere under $HOME/workspace/repos, at any depth.
20
+ *
21
+ * ALLOWS (structure only):
22
+ * - reads, always,
23
+ * - writes deeper inside an allowed org folder (repos/<org>/<repo>/...).
24
+ *
25
+ * Home spellings (~, $HOME, ${HOME}, including quoted forms) are expanded
26
+ * before classification in Bash targets, file-tool paths, cd operands and
27
+ * apply_patch file markers. Bash relative operands (`.`, `..`, bare names)
28
+ * are resolved against the command's cwd when it sits under the repos root,
29
+ * and against an explicit `cd` into the root when one is present. apply_patch
30
+ * tools are inspected through their `*** Add File:` / `*** Update File:` /
31
+ * `*** Delete File:` markers. Parenthesized command groups are unwrapped.
32
+ *
33
+ * Allowed orgs default to hasna,hasnaxyz,hasna-internal,hasna-products and are
34
+ * overridable with the WORKSPACE_REPOS_GUARD_ORGS env var (comma-separated).
35
+ * Home is resolved with os.homedir(); never hardcoded. Fail-open on any parse
36
+ * or evaluation error so a guard defect cannot wedge the agent.
37
+ */
38
+
39
+ import { homedir } from "os";
40
+ import { isAbsolute, join, normalize, relative, resolve, sep } from "path";
41
+ import {
42
+ getCommand,
43
+ readInput,
44
+ respond,
45
+ warn,
46
+ type CodewithHookInput,
47
+ type CodewithHookOutput,
48
+ } from "../../codewith-native-common";
49
+
50
+ const FILE_WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
51
+
52
+ const PATCH_TOOLS = new Set(["apply_patch", "ApplyPatch", "functions.apply_patch"]);
53
+
54
+ const DEFAULT_ORGS = ["hasna", "hasnaxyz", "hasna-internal", "hasna-products"];
55
+
56
+ const RULE = "workspace-repos-guard";
57
+
58
+ function reposRoot(home: string): string {
59
+ return join(home, "workspace", "repos");
60
+ }
61
+
62
+ export function resolveAllowedOrgs(env: NodeJS.ProcessEnv = process.env): Set<string> {
63
+ const raw = env.WORKSPACE_REPOS_GUARD_ORGS;
64
+ if (typeof raw !== "string" || !raw.trim()) return new Set(DEFAULT_ORGS);
65
+ return new Set(
66
+ raw
67
+ .split(",")
68
+ .map((s) => s.trim())
69
+ .filter(Boolean)
70
+ );
71
+ }
72
+
73
+ type Operation = "read" | "write" | "delete";
74
+
75
+ interface Verdict {
76
+ blocked: boolean;
77
+ reason?: string;
78
+ }
79
+
80
+ /**
81
+ * Classify a single absolute target path against the repos root. Structure
82
+ * only: a write at depth >= 2 inside an allowed org passes, and anything else
83
+ * (deletes at any depth, writes at the root or org level, writes into a
84
+ * non-allowed org) is blocked. Paths outside the repos root are not this
85
+ * hook's concern and pass.
86
+ */
87
+ export function classifyPath(target: string, root: string, orgs: Set<string>, op: Operation): Verdict {
88
+ if (op === "read") return { blocked: false };
89
+ let rel = relative(root, target);
90
+ if (rel === "") {
91
+ if (op === "delete") return { blocked: true, reason: `[${RULE}] delete of the repos root is forbidden: ${root}` };
92
+ return { blocked: true, reason: `[${RULE}] writes directly to the repos root are forbidden: ${root}` };
93
+ }
94
+ if (rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
95
+ return { blocked: false };
96
+ }
97
+ const segments = rel.split(sep).filter(Boolean);
98
+
99
+ if (op === "delete") {
100
+ return { blocked: true, reason: `[${RULE}] delete under ~/workspace/repos is forbidden at any depth: ${target}` };
101
+ }
102
+
103
+ if (segments.length <= 1) {
104
+ return {
105
+ blocked: true,
106
+ reason: `[${RULE}] writing would create a top-level entry directly under repos/; only GitHub-org folders belong there: ${target}`,
107
+ };
108
+ }
109
+
110
+ const org = segments[0];
111
+ if (!orgs.has(org)) {
112
+ return {
113
+ blocked: true,
114
+ reason: `[${RULE}] '${org}' is not an allowed GitHub org under ~/workspace/repos (allowed: ${[...orgs].join(", ")}): ${target}`,
115
+ };
116
+ }
117
+
118
+ return { blocked: false };
119
+ }
120
+
121
+ const WRITE_FLAGS = /\b(?:-o|--output|-O|--output-document|-out)\b/;
122
+
123
+ /**
124
+ * Classify the operation of one command segment (a `&&`/`||`/`;`-delimited
125
+ * unit). Git is handled by its subcommand: clean|rm delete, clone|init write,
126
+ * everything else (status/pull/fetch/log/diff/...) is read. Sed without an
127
+ * in-place flag is a stream filter (redirection is caught separately);
128
+ * rsync/scp/truncate and inline interpreters (python3 -c, node -e, bun -e)
129
+ * are treated as writes — conservative: they only matter when a path under
130
+ * the protected root is also present.
131
+ */
132
+ function segmentOperation(segment: string): Operation {
133
+ const trimmed = segment.trim();
134
+ if (!trimmed) return "read";
135
+
136
+ if (/(?:^|\s)(?:rm|rmdir|unlink|shred|trash|rmtree|del)(?:\s|$)/.test(trimmed)) return "delete";
137
+ if (/\bgit\b/.test(trimmed)) {
138
+ if (/\bgit\b[^;&|]*\b(?:clean|rm)\b/.test(trimmed)) return "delete";
139
+ if (/\bgit\b[^;&|]*\b(?:clone|init)\b/.test(trimmed)) return "write";
140
+ return "read";
141
+ }
142
+ if (/(?:^|\s)(?:mkdir|mkfile|touch|mv|cp|ln|tee|install|dd)(?:\s|$)/.test(trimmed)) return "write";
143
+ if (/\b(?:curl|wget)\b/.test(trimmed)) {
144
+ if (WRITE_FLAGS.test(trimmed)) return "write";
145
+ return "read";
146
+ }
147
+ if (/\bsed\b/.test(trimmed)) {
148
+ if (/(?:^|\s)-i\S*(?:\s|$)|--in-place(?:\s|$)/.test(trimmed)) return "write";
149
+ // sed without -i is a filter; `>` redirection is caught by the check below.
150
+ } else if (/\b(?:rsync|scp|truncate)\b/.test(trimmed)) {
151
+ return "write";
152
+ } else if (/(?:^|\s)(?:python3?|node|bun)\b[^;&|]*\s+-[ce](?:\s|$)/.test(trimmed)) {
153
+ return "write";
154
+ }
155
+ if (/(?:^|[;&|])\s*echo\b/.test(trimmed) || /[>&]/.test(trimmed)) return "write";
156
+ return "read";
157
+ }
158
+
159
+ interface PathTarget {
160
+ path: string;
161
+ op: Operation;
162
+ }
163
+
164
+ /**
165
+ * Expand one token's home spelling (`~`, `~/...`, `$HOME`, `$HOME/...`,
166
+ * `${HOME}`, `${HOME}/...`) to the resolved home, tolerating a single layer
167
+ * of wrapping single/double quotes and a closing quote between the home
168
+ * token and its path suffix (`"$HOME"/x`, `"${HOME}"/x` — Bash treats these
169
+ * identically to the unquoted form, because double quotes allow parameter
170
+ * expansion and adjacent quoted/unquoted parts concatenate into one word).
171
+ * Non-home spellings are returned unchanged.
172
+ */
173
+ export function expandHomeSpelling(token: string, home: string): string {
174
+ let body = token.trim();
175
+ const quoted = body.match(/^(['"])([\s\S]*)\1$/);
176
+ if (quoted) body = quoted[2];
177
+ // Split-quote forms — `"$HOME"/x`, `"${HOME}"/x`, `"/home/hasna"/x`: the
178
+ // quotes wrap only the home token, and Bash concatenates the adjacent
179
+ // quoted and unquoted parts into the same word as the unquoted spelling.
180
+ body = body.replace(/^"*\$HOME"*\//, "$HOME/");
181
+ body = body.replace(/^"*\$\{HOME\}"*\//, "${HOME}/");
182
+ if (body === "~") return home;
183
+ if (body.startsWith("~/")) return join(home, body.slice(2));
184
+ if (body === "$HOME" || body === "${HOME}") return home;
185
+ if (body.startsWith("$HOME/")) return join(home, body.slice("$HOME/".length));
186
+ if (body.startsWith("${HOME}/")) return join(home, body.slice("${HOME}/".length));
187
+ const homeMatch = body.match(new RegExp(`^"*${regexEscape(home)}"*/`));
188
+ if (homeMatch) return home + "/" + body.slice(homeMatch[0].length);
189
+ return token;
190
+ }
191
+
192
+ /**
193
+ * Strip one level of wrapping shell-group punctuation from a segment so a
194
+ * subshell group like `(cd ~/workspace/repos && rm -rf hasna)` is analysed as
195
+ * its parts after the `&&` split.
196
+ */
197
+ function unwrapSegment(segment: string): string {
198
+ let s = segment.trim();
199
+ while (s.startsWith("(") || s.startsWith("{")) s = s.slice(1).trim();
200
+ while (s.endsWith(")") || s.endsWith("}")) s = s.slice(0, -1).trim();
201
+ return s;
202
+ }
203
+
204
+ function regexEscape(text: string): string {
205
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
206
+ }
207
+
208
+ /**
209
+ * Extract workspace-repos targets from a Bash command. Recognises explicit
210
+ * `~/...`, `$HOME/...`, `${HOME}/...` and literal-home references (expanded
211
+ * before classification), plus a trailing relative operand (`.`, `..`, a bare
212
+ * name) when the command `cd`s into the repos root or the command's cwd
213
+ * already sits under it. Tokens outside the repos root are ignored; reads are
214
+ * returned so callers can decide (they are never blocked).
215
+ */
216
+ export function bashTargets(command: string, home: string, cwd: string): PathTarget[] {
217
+ if (!command) return [];
218
+ const root = reposRoot(home);
219
+ const segments = command.split(/\s*&&\s*|\s*\|\|\s*|;\s*|\n+/);
220
+
221
+ const targets: PathTarget[] = [];
222
+ let cwdUnderRepos: string | null = null;
223
+
224
+ for (const rawSegment of segments) {
225
+ const segment = unwrapSegment(rawSegment);
226
+ const op = segmentOperation(segment);
227
+
228
+ const cdMatch = segment.match(/(?:^|\s)cd(?:\s+(?:-[A-Za-z]+|--))*\s+([^\s;&|<>()\x60]+)/);
229
+ if (cdMatch) {
230
+ const cdTarget = expandHomeSpelling(cdMatch[1], home);
231
+ if (cdTarget && (cdTarget === root || cdTarget.startsWith(`${root}${sep}`))) cwdUnderRepos = cdTarget;
232
+ }
233
+
234
+ const homeLiteral = regexEscape(home);
235
+ const prefixRe = new RegExp(`(?:~|\\$HOME"*|\\$\\{HOME\\}"*|${homeLiteral}"*)/workspace/repos`);
236
+ const re = new RegExp(`(${prefixRe.source})([^\\s"';&|<>()\x60]*|$)`, "g");
237
+ let m: RegExpExecArray | null;
238
+ let foundExplicit = false;
239
+ while ((m = re.exec(segment)) !== null) {
240
+ foundExplicit = true;
241
+ const expanded = expandHomeSpelling(m[0], home);
242
+ targets.push({ path: normalize(expanded).replace(/\/+$/, ""), op });
243
+ }
244
+
245
+ if (!foundExplicit && (op === "delete" || op === "write")) {
246
+ const base =
247
+ cwdUnderRepos ?? (cwd === root || cwd.startsWith(`${root}${sep}`) ? cwd : null);
248
+ if (base) {
249
+ const relMatch = segment.match(/(?:^|\s)(\.\.?|[^\s"';&|<>()\x60/]+(?:\/[^\s"';&|<>()\x60]*)?)\s*$/);
250
+ if (relMatch) {
251
+ targets.push({ path: normalize(resolve(base, relMatch[1])), op });
252
+ }
253
+ }
254
+ }
255
+ }
256
+
257
+ return targets;
258
+ }
259
+
260
+ const PATCH_FILE_RE = /^\s*\*\*\*\s+(Add File|Update File|Delete File):\s+(\S+)\s*$/gm;
261
+
262
+ /**
263
+ * Extract targets from an apply_patch payload: the `*** Add File:` /
264
+ * `*** Update File:` / `*** Delete File:` markers, resolved against cwd.
265
+ * Add/Update classify as write; Delete classifies as delete.
266
+ */
267
+ export function patchTargets(patch: string, cwd: string): PathTarget[] {
268
+ const targets: PathTarget[] = [];
269
+ let m: RegExpExecArray | null;
270
+ while ((m = PATCH_FILE_RE.exec(patch)) !== null) {
271
+ const op: Operation = m[1] === "Delete File" ? "delete" : "write";
272
+ targets.push({ path: normalize(resolve(cwd, m[2])), op });
273
+ }
274
+ return targets;
275
+ }
276
+
277
+ export function evaluate(input: CodewithHookInput): { output: CodewithHookOutput; warnings: string[] } {
278
+ const warnings: string[] = [];
279
+ if (input.hook_event_name !== "PreToolUse") return { output: { continue: true }, warnings };
280
+
281
+ const tool = typeof input.tool_name === "string" ? input.tool_name : "";
282
+ const home = homedir();
283
+ const root = reposRoot(home);
284
+ const orgs = resolveAllowedOrgs();
285
+
286
+ if (tool === "Bash") {
287
+ const command = getCommand(input);
288
+ if (!command) return { output: { continue: true }, warnings };
289
+ const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
290
+ const targets = bashTargets(command, home, cwd);
291
+ for (const target of targets) {
292
+ const verdict = classifyPath(target.path, root, orgs, target.op);
293
+ if (verdict.blocked) {
294
+ return { output: { decision: "block", reason: verdict.reason }, warnings };
295
+ }
296
+ }
297
+ return { output: { continue: true }, warnings };
298
+ }
299
+
300
+ const toolInput = input.tool_input ?? {};
301
+ const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
302
+
303
+ if (PATCH_TOOLS.has(tool)) {
304
+ const patch = typeof toolInput.patch === "string" ? toolInput.patch : "";
305
+ if (!patch) return { output: { continue: true }, warnings };
306
+ const targets = patchTargets(patch, cwd);
307
+ for (const target of targets) {
308
+ const verdict = classifyPath(target.path, root, orgs, target.op);
309
+ if (verdict.blocked) {
310
+ return { output: { decision: "block", reason: verdict.reason }, warnings };
311
+ }
312
+ }
313
+ return { output: { continue: true }, warnings };
314
+ }
315
+
316
+ if (!FILE_WRITE_TOOLS.has(tool)) return { output: { continue: true }, warnings };
317
+
318
+ const raw =
319
+ typeof toolInput.file_path === "string"
320
+ ? toolInput.file_path
321
+ : typeof toolInput.path === "string"
322
+ ? toolInput.path
323
+ : typeof toolInput.notebook_path === "string"
324
+ ? toolInput.notebook_path
325
+ : null;
326
+ if (!raw) return { output: { continue: true }, warnings };
327
+
328
+ const expanded = expandHomeSpelling(raw, home);
329
+ const target = isAbsolute(expanded) ? normalize(expanded) : resolve(cwd, expanded);
330
+ const verdict = classifyPath(target, root, orgs, "write");
331
+ if (verdict.blocked) {
332
+ return { output: { decision: "block", reason: verdict.reason }, warnings };
333
+ }
334
+ return { output: { continue: true }, warnings };
335
+ }
336
+
337
+ export async function run(): Promise<void> {
338
+ const input = readInput();
339
+ try {
340
+ const { output, warnings } = evaluate(input);
341
+ for (const message of warnings) warn(message);
342
+ respond(output);
343
+ } catch (error) {
344
+ warn(`${RULE} failed open: ${error instanceof Error ? error.message : String(error)}`);
345
+ respond({ continue: true });
346
+ }
347
+ }
348
+
349
+ if (import.meta.main) {
350
+ await run();
351
+ process.exit(0);
352
+ }
@@ -0,0 +1,21 @@
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
+ "types": ["bun-types"]
18
+ },
19
+ "include": ["src/**/*"],
20
+ "exclude": ["node_modules", "dist"]
21
+ }