@hasna/hooks 0.6.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.
package/bin/index.js CHANGED
@@ -1955,6 +1955,16 @@ var init_registry = __esm(() => {
1955
1955
  matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch|mcp__.*)$",
1956
1956
  tags: ["git", "worktree", "repos", "multi-agent", "safety", "dangerous-ops"]
1957
1957
  },
1958
+ {
1959
+ name: "workspace-repos-guard",
1960
+ displayName: "Workspace Repos Guard",
1961
+ description: "Blocks writes and deletes that break the ~/workspace/repos structure: writes to the repos root, top-level entry creation, non-org folders, and any delete under repos",
1962
+ version: "0.1.0",
1963
+ category: "Git Safety",
1964
+ event: "PreToolUse",
1965
+ matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$",
1966
+ tags: ["workspace", "repos", "structure", "guard", "safety", "orgs", "multi-agent"]
1967
+ },
1958
1968
  {
1959
1969
  name: "checktests",
1960
1970
  displayName: "Check Tests",
@@ -4353,7 +4363,7 @@ function resolveHook(name) {
4353
4363
  meta
4354
4364
  };
4355
4365
  }
4356
- var __dirname = "/home/hasna/.hasna/repos/worktrees/apps/1ec6ba31-cdbc-42ea-8b1e-705e4198abbf/apps/hooks/src/lib";
4366
+ var __dirname = "/home/hasna/.hasna/repos/worktrees/hasna-apps/workspace-repos-guard/apps/hooks/src/lib";
4357
4367
  var init_resolve = __esm(() => {
4358
4368
  init_registry();
4359
4369
  init_manifest();
package/bin/serve.js CHANGED
@@ -57,6 +57,16 @@ var HOOKS = [
57
57
  matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch|mcp__.*)$",
58
58
  tags: ["git", "worktree", "repos", "multi-agent", "safety", "dangerous-ops"]
59
59
  },
60
+ {
61
+ name: "workspace-repos-guard",
62
+ displayName: "Workspace Repos Guard",
63
+ description: "Blocks writes and deletes that break the ~/workspace/repos structure: writes to the repos root, top-level entry creation, non-org folders, and any delete under repos",
64
+ version: "0.1.0",
65
+ category: "Git Safety",
66
+ event: "PreToolUse",
67
+ matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$",
68
+ tags: ["workspace", "repos", "structure", "guard", "safety", "orgs", "multi-agent"]
69
+ },
60
70
  {
61
71
  name: "checktests",
62
72
  displayName: "Check Tests",
@@ -5043,7 +5053,7 @@ function retrustHook(name, scriptPath, version, source) {
5043
5053
  // src/lib/resolve.ts
5044
5054
  import { join as join5 } from "path";
5045
5055
  import { existsSync as existsSync5 } from "fs";
5046
- var __dirname = "/home/hasna/.hasna/repos/worktrees/apps/1ec6ba31-cdbc-42ea-8b1e-705e4198abbf/apps/hooks/src/lib";
5056
+ var __dirname = "/home/hasna/.hasna/repos/worktrees/hasna-apps/workspace-repos-guard/apps/hooks/src/lib";
5047
5057
  function resolveHookDir(name) {
5048
5058
  const custom2 = readCustomManifest(name);
5049
5059
  if (custom2)
package/dist/index.js CHANGED
@@ -5336,6 +5336,16 @@ var HOOKS = [
5336
5336
  matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch|mcp__.*)$",
5337
5337
  tags: ["git", "worktree", "repos", "multi-agent", "safety", "dangerous-ops"]
5338
5338
  },
5339
+ {
5340
+ name: "workspace-repos-guard",
5341
+ displayName: "Workspace Repos Guard",
5342
+ description: "Blocks writes and deletes that break the ~/workspace/repos structure: writes to the repos root, top-level entry creation, non-org folders, and any delete under repos",
5343
+ version: "0.1.0",
5344
+ category: "Git Safety",
5345
+ event: "PreToolUse",
5346
+ matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$",
5347
+ tags: ["workspace", "repos", "structure", "guard", "safety", "orgs", "multi-agent"]
5348
+ },
5339
5349
  {
5340
5350
  name: "checktests",
5341
5351
  displayName: "Check Tests",
@@ -5811,7 +5821,7 @@ import { fileURLToPath } from "url";
5811
5821
  import { join as join5 } from "path";
5812
5822
  import { existsSync as existsSync5 } from "fs";
5813
5823
  init_manifest();
5814
- var __dirname = "/home/hasna/.hasna/repos/worktrees/apps/1ec6ba31-cdbc-42ea-8b1e-705e4198abbf/apps/hooks/src/lib";
5824
+ var __dirname = "/home/hasna/.hasna/repos/worktrees/hasna-apps/workspace-repos-guard/apps/hooks/src/lib";
5815
5825
  function resolveHookDir(name) {
5816
5826
  const custom2 = readCustomManifest(name);
5817
5827
  if (custom2)
@@ -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
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
5
5
  "type": "module",
6
6
  "bin": {