@hasna/hooks 0.4.0 → 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,4 +1,4 @@
1
- import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "fs";
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, writeSync } from "fs";
2
2
  import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "path";
3
3
  import { homedir, tmpdir } from "os";
4
4
 
@@ -57,7 +57,15 @@ export function readInput(): CodewithHookInput {
57
57
  }
58
58
 
59
59
  export function respond(output: CodewithHookOutput): void {
60
- process.stdout.write(`${JSON.stringify(output)}\n`);
60
+ // Written synchronously: `process.stdout.write` is async on a pipe, so a verdict
61
+ // larger than the pipe buffer is silently truncated if the process exits before it
62
+ // drains — and a truncated verdict is unparseable, so the caller sees no decision.
63
+ const payload = `${JSON.stringify(output)}\n`;
64
+ try {
65
+ writeSync(1, payload);
66
+ } catch {
67
+ process.stdout.write(payload);
68
+ }
61
69
  }
62
70
 
63
71
  export function warn(message: string): void {
@@ -516,12 +524,41 @@ function hasUnsafeTargetComponent(worktreesRoot: string, target: string): boolea
516
524
  return false;
517
525
  }
518
526
 
519
- function managedLeaseRoot(worktreesRoot: string, target: string): string | null {
520
- const relativeTarget = relative(worktreesRoot, target);
521
- const parts = relativeTarget.split(sep).filter(Boolean);
522
- if (parts.length < 3) return null;
523
- const leaseRoot = resolve(worktreesRoot, ...parts.slice(0, 3));
524
- return managedWorktreeInfo(leaseRoot).managed ? leaseRoot : null;
527
+ /**
528
+ * Candidate worktree roots for an absolute target, canonical shape first.
529
+ *
530
+ * The canonical root sits at `<worktrees-root>/<repo-name>/<worktree-name>`
531
+ * (CANONICAL_WORKTREE_SEGMENTS). The deprecated station-id lease layout sits one
532
+ * level deeper. Order matters: a canonical worktree that happens to contain a
533
+ * subdirectory must resolve to the canonical root, never to the subdirectory.
534
+ */
535
+ function managedWorktreeRootCandidates(worktreesRoot: string, target: string): string[] {
536
+ const parts = relative(worktreesRoot, target).split(sep).filter(Boolean);
537
+ const depths = [CANONICAL_WORKTREE_SEGMENTS, LEGACY_LEASE_WORKTREE_SEGMENTS];
538
+ return depths
539
+ .filter((depth) => parts.length >= depth)
540
+ .map((depth) => resolve(worktreesRoot, ...parts.slice(0, depth)));
541
+ }
542
+
543
+ /**
544
+ * Worktree roots that could own `target`, for the scoped dangerous-operation
545
+ * carve-out only.
546
+ *
547
+ * This is a structural lookup ("could a real managed worktree own this path?"),
548
+ * not a policy check ("is this path canonical?"). It therefore keeps the
549
+ * deprecated station-id lease layout as a candidate, so that worktrees created
550
+ * before rule 8 keep their `~/.hasna` write carve-out during migration. Policy
551
+ * enforcement lives in managedWorktreeInfo() / worktree-guard.
552
+ *
553
+ * Both depths are returned when both are plausible, because path shape alone
554
+ * cannot tell a canonical root from a legacy lease container. Each candidate is
555
+ * still verified against Git provenance by the caller, which fails closed.
556
+ */
557
+ function managedLeaseRootCandidates(worktreesRoot: string, target: string): string[] {
558
+ return managedWorktreeRootCandidates(worktreesRoot, target).filter((candidate) => {
559
+ const info = managedWorktreeInfo(candidate);
560
+ return info.managed || info.layout === "legacy-station-lease";
561
+ });
525
562
  }
526
563
 
527
564
  async function verifiedLinkedWorktreeRoot(leaseRoot: string): Promise<string | null> {
@@ -587,8 +624,19 @@ async function managedRepoRootForAbsoluteTarget(
587
624
  return null;
588
625
  }
589
626
 
590
- const leaseRoot = managedLeaseRoot(worktreesRoot, target);
591
- if (!leaseRoot) return null;
627
+ for (const leaseRoot of managedLeaseRootCandidates(worktreesRoot, target)) {
628
+ const repoRoot = await verifiedManagedRepoRoot(leaseRoot, target, physicalWorktreesRoot, repoRootCache);
629
+ if (repoRoot) return repoRoot;
630
+ }
631
+ return null;
632
+ }
633
+
634
+ async function verifiedManagedRepoRoot(
635
+ leaseRoot: string,
636
+ target: string,
637
+ physicalWorktreesRoot: string,
638
+ repoRootCache: Map<string, Promise<string | null>>,
639
+ ): Promise<string | null> {
592
640
  let repoRootPromise = repoRootCache.get(leaseRoot);
593
641
  if (!repoRootPromise) {
594
642
  repoRootPromise = verifiedLinkedWorktreeRoot(leaseRoot);
@@ -1086,22 +1134,265 @@ export function isInsidePath(child: string, parent: string): boolean {
1086
1134
  return rel === "" || (!!rel && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
1087
1135
  }
1088
1136
 
1089
- export function managedWorktreeInfo(cwd: string): { managed: boolean; root: string; reason?: string } {
1137
+ /**
1138
+ * Canonical managed-worktree path shape.
1139
+ *
1140
+ * Source of truth: Hasna Agent Operating Rules rule 8, as published by the
1141
+ * @hasna/identities 0.4.4 global agent rules, verbatim:
1142
+ *
1143
+ * "must happen in a task-specific worktree at
1144
+ * $HOME/.hasna/repos/worktrees/<repo-name>/<worktree-name>
1145
+ * (repo name then worktree name; no station-id or machine segment,
1146
+ * never flat under the worktrees root)"
1147
+ *
1148
+ * So, relative to the worktrees root, a compliant worktree root is exactly two
1149
+ * segments deep: <repo-name>/<worktree-name>.
1150
+ */
1151
+ export const CANONICAL_WORKTREE_SEGMENTS = 2;
1152
+
1153
+ /**
1154
+ * Depth of the DEPRECATED station-id lease layout
1155
+ * (`<station-id>/<repo-slug>-<hex>/wt_<hex>`) that predates rule 8.
1156
+ *
1157
+ * Read-only migration tolerance: it is never a compliant target shape, and it is
1158
+ * never reported as `managed`. It is recognised only so that (a) guard messages
1159
+ * can name it precisely and (b) the scoped dangerous-operation carve-out keeps
1160
+ * working for worktrees created before the canonical shape was mandated.
1161
+ */
1162
+ export const LEGACY_LEASE_WORKTREE_SEGMENTS = 3;
1163
+
1164
+ // Any ordinary directory name, bounded by the filesystem's own limit rather than an
1165
+ // allowlist — repo and worktree names are user data, and an over-narrow pattern would
1166
+ // reject legitimate work (real fleet names include `_base`). Refused: a leading `.`,
1167
+ // so `.`, `..` and `.git` can never be read as a segment; a leading `-`, so a segment
1168
+ // can never read as an option in the remediation command; and control characters.
1169
+ const WORKTREE_SEGMENT_PATTERN = /^[^.\-\/\x00-\x1f][^\/\x00-\x1f]{0,254}$/;
1170
+ const LEGACY_LEASE_REPO_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*-[0-9a-fA-F]{7,16}$/;
1171
+ const LEGACY_LEASE_ID_PATTERN = /^wt_[0-9a-fA-F]{16,64}$/;
1172
+
1173
+ /**
1174
+ * Whether the deprecated station-id lease layout still gets its migration tolerance.
1175
+ *
1176
+ * Default on, so the change does not strand worktrees created before rule 8. It is a
1177
+ * kill switch, not a policy knob: the layout is non-compliant either way, and the
1178
+ * tolerance only softens the verdict from blocked to warned. Set
1179
+ * `HASNA_HOOKS_LEGACY_WORKTREE_TOLERANCE=0` once those worktrees are re-homed; the
1180
+ * whole branch goes away after that.
1181
+ *
1182
+ * Known limitation while it is on: the tolerance keys off the path name, so a newly
1183
+ * created worktree deliberately named to match also gets the warn tier. That is an
1184
+ * opt-out from a guardrail by a cooperating agent, not a security boundary — the
1185
+ * boundary is the provenance proof above, which applies to both tiers.
1186
+ */
1187
+ export function legacyWorktreeToleranceEnabled(): boolean {
1188
+ return process.env.HASNA_HOOKS_LEGACY_WORKTREE_TOLERANCE !== "0";
1189
+ }
1190
+
1191
+ export type ManagedWorktreeLayout = "canonical" | "legacy-station-lease";
1192
+
1193
+ export interface ManagedWorktreeInfo {
1194
+ managed: boolean;
1195
+ /** The worktrees root the path was classified against. */
1196
+ root: string;
1197
+ /** Recognised layout, set for compliant and for deprecated-but-recognised paths. */
1198
+ layout?: ManagedWorktreeLayout;
1199
+ /** True when the layout is recognised but no longer permitted by rule 8. */
1200
+ deprecated?: boolean;
1201
+ repo?: string;
1202
+ worktree?: string;
1203
+ /** Absolute path of the worktree root that owns `cwd`. */
1204
+ worktreeRoot?: string;
1205
+ reason?: string;
1206
+ }
1207
+
1208
+ /** The canonical worktree path template, for user-facing guard messages. */
1209
+ export function canonicalWorktreeTemplate(root: string = defaultWorktreesRoot()): string {
1210
+ return join(root, "<repo-name>", "<worktree-name>");
1211
+ }
1212
+
1213
+ /**
1214
+ * Prove that `worktreeRoot` owns its own git history, synchronously.
1215
+ *
1216
+ * Shape is not evidence and neither is the mere presence of `.git`. A `.git` file is
1217
+ * two lines of text: pointing it at a shared checkout's `.git` grafts a second working
1218
+ * tree onto that checkout, so `git commit`/`git push` from the forged directory lands
1219
+ * on the shared checkout — the exact outcome rule 10 forbids. So a `.git` file must
1220
+ * carry real linked-worktree provenance:
1221
+ *
1222
+ * - its `gitdir:` target must live under `<common-dir>/worktrees/`, and
1223
+ * - that target's `gitdir` back-pointer must resolve to this very control file.
1224
+ *
1225
+ * A `.git` directory is accepted only as a self-contained repository. A `commondir`
1226
+ * grafts it onto another repository's history outright, and symlinked `objects` or
1227
+ * `refs` graft it onto another repository's refs — reaching the same end state as a
1228
+ * forged `.git` file without writing anything inside the victim.
1229
+ *
1230
+ * This is a structural proof only. It is deliberately close to, but not the same as,
1231
+ * the async verifiedLinkedWorktreeRoot() used for the write carve-out, which is
1232
+ * stricter still (regular-file control file, nlink === 1, worktrees dir directly
1233
+ * under the common dir).
1234
+ */
1235
+ function worktreeProvenanceReason(worktreeRoot: string): string | null {
1236
+ const controlPath = join(worktreeRoot, ".git");
1237
+ let control;
1238
+ try {
1239
+ control = lstatSync(controlPath);
1240
+ } catch {
1241
+ return `${worktreeRoot} is not a git worktree root (no .git)`;
1242
+ }
1243
+ if (control.isSymbolicLink()) return `worktree .git is a symlink at ${worktreeRoot}`;
1244
+
1245
+ if (control.isDirectory()) {
1246
+ if (existsSync(join(controlPath, "commondir"))) {
1247
+ return `worktree .git is grafted onto another repository at ${worktreeRoot}`;
1248
+ }
1249
+ if (!existsSync(join(controlPath, "HEAD"))) return `worktree .git is not a repository at ${worktreeRoot}`;
1250
+ // A self-contained repository owns its object and ref storage. Symlinking either
1251
+ // into another repository makes commits here land on that repository's refs.
1252
+ for (const store of ["objects", "refs"]) {
1253
+ let metadata;
1254
+ try {
1255
+ metadata = lstatSync(join(controlPath, store));
1256
+ } catch {
1257
+ return `worktree .git is missing ${store} at ${worktreeRoot}`;
1258
+ }
1259
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
1260
+ return `worktree .git ${store} is grafted onto another repository at ${worktreeRoot}`;
1261
+ }
1262
+ }
1263
+ return null;
1264
+ }
1265
+ if (!control.isFile()) return `worktree .git is not a file or directory at ${worktreeRoot}`;
1266
+
1267
+ try {
1268
+ const pointer = readFileSync(controlPath, "utf-8").trim();
1269
+ const match = pointer.match(/^gitdir:\s*(.+)$/);
1270
+ if (!match?.[1]) return `worktree .git is not a git worktree pointer at ${worktreeRoot}`;
1271
+ const gitDir = resolveFrom(worktreeRoot, match[1].trim());
1272
+ const commonDir = resolveFrom(gitDir, readFileSync(join(gitDir, "commondir"), "utf-8").trim());
1273
+ const physicalGitDir = realpathSync(gitDir);
1274
+ const physicalWorktreesDir = realpathSync(join(commonDir, "worktrees"));
1275
+ if (physicalGitDir === physicalWorktreesDir || !isInsidePath(physicalGitDir, physicalWorktreesDir)) {
1276
+ return `worktree .git points outside its repository's worktrees directory at ${worktreeRoot}`;
1277
+ }
1278
+ const backPointer = resolveFrom(gitDir, readFileSync(join(gitDir, "gitdir"), "utf-8").trim());
1279
+ if (realpathSync(backPointer) !== realpathSync(controlPath)) {
1280
+ return `worktree .git is not registered by its repository at ${worktreeRoot}`;
1281
+ }
1282
+ } catch {
1283
+ return `worktree .git provenance could not be verified at ${worktreeRoot}`;
1284
+ }
1285
+ return null;
1286
+ }
1287
+
1288
+ /**
1289
+ * Verify that `<root>/<repo>/<worktree>` is a real, non-symlinked, provenance-checked
1290
+ * git worktree root.
1291
+ *
1292
+ * Path shape alone is not evidence: `<root>/<flat-worktree>/<subdir>` has exactly the
1293
+ * same shape as `<root>/<repo>/<worktree>`, so without this check a `cd` into any
1294
+ * subdirectory of a flat worktree would launder it into a compliant-looking path.
1295
+ * Symlinks are refused at every level (hence lstat, not existsSync, which follows
1296
+ * them) because a symlinked segment can aim a canonical-looking path at a shared
1297
+ * checkout.
1298
+ */
1299
+ function groundedWorktreeRootReason(root: string, segments: string[]): string | null {
1300
+ let probe = resolve(root);
1301
+ for (const segment of segments) {
1302
+ probe = join(probe, segment);
1303
+ let metadata;
1304
+ try {
1305
+ metadata = lstatSync(probe);
1306
+ } catch {
1307
+ return `no worktree exists at ${probe}`;
1308
+ }
1309
+ if (metadata.isSymbolicLink()) return `worktree path traverses a symlink at ${probe}`;
1310
+ if (!metadata.isDirectory()) return `worktree path is not a directory at ${probe}`;
1311
+ }
1312
+ return worktreeProvenanceReason(probe);
1313
+ }
1314
+
1315
+ /**
1316
+ * Classify a path against the canonical managed-worktree shape (rule 8).
1317
+ *
1318
+ * Accepted: a real git worktree root at `<worktrees-root>/<repo-name>/<worktree-name>`,
1319
+ * and any path inside it. Rejected, each with a reason: paths outside the worktrees
1320
+ * root, the root itself, flat single-segment worktrees, station-id/machine segments,
1321
+ * deeper nesting, and canonical-shaped paths that are not actually a worktree root
1322
+ * (invented, symlinked, or a subdirectory of a flat worktree).
1323
+ */
1324
+ export function managedWorktreeInfo(cwd: string): ManagedWorktreeInfo {
1090
1325
  const root = defaultWorktreesRoot();
1326
+ const canonical = canonicalWorktreeTemplate(root);
1091
1327
  if (!isInsidePath(cwd, root)) return { managed: false, root, reason: "outside worktrees root" };
1092
- const rel = relative(resolve(root), resolve(cwd));
1093
- const parts = rel.split(sep).filter(Boolean);
1094
- if (parts.length < 3) return { managed: false, root, reason: "path is inside worktrees root but not deep enough" };
1095
- const [machine, repoSlugHash, lease] = parts;
1096
- if (!machine || !repoSlugHash || !lease) return { managed: false, root, reason: "missing machine/repo/lease path segments" };
1097
- if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,80}$/.test(machine)) return { managed: false, root, reason: "machine segment is malformed" };
1098
- if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*-[0-9a-fA-F]{7,16}$/.test(repoSlugHash)) {
1099
- return { managed: false, root, reason: "repo segment must end in a hex hash suffix" };
1328
+
1329
+ const parts = relative(resolve(root), resolve(cwd)).split(sep).filter(Boolean);
1330
+ if (parts.length === 0) {
1331
+ return { managed: false, root, reason: `path is the worktrees root itself; canonical worktrees live at ${canonical}` };
1332
+ }
1333
+ if (parts.length < CANONICAL_WORKTREE_SEGMENTS) {
1334
+ return {
1335
+ managed: false,
1336
+ root,
1337
+ reason: `worktree is flat under the worktrees root, which rule 8 forbids; canonical shape is ${canonical}`,
1338
+ };
1339
+ }
1340
+
1341
+ const [repo, worktree] = parts;
1342
+ for (const [label, segment] of [["repo-name", repo], ["worktree-name", worktree]] as const) {
1343
+ if (!segment || !WORKTREE_SEGMENT_PATTERN.test(segment)) {
1344
+ return { managed: false, root, reason: `${label} segment is malformed; canonical shape is ${canonical}` };
1345
+ }
1346
+ }
1347
+
1348
+ // A canonical classification must be grounded in a real worktree root at depth 2,
1349
+ // never in path shape alone: at depth 2 the shape is ambiguous with a subdirectory
1350
+ // of a forbidden flat worktree, and at any depth it is ambiguous with an invented
1351
+ // or symlinked path.
1352
+ const worktreeRoot = resolve(root, repo!, worktree!);
1353
+ const rootReason = groundedWorktreeRootReason(root, [repo!, worktree!]);
1354
+ if (!rootReason) {
1355
+ return { managed: true, root, layout: "canonical", repo, worktree, worktreeRoot };
1356
+ }
1357
+
1358
+ if (parts.length === CANONICAL_WORKTREE_SEGMENTS) {
1359
+ return { managed: false, root, reason: `${rootReason}; canonical shape is ${canonical}` };
1100
1360
  }
1101
- if (!/^wt_[0-9a-fA-F]{16,64}$/.test(lease)) {
1102
- return { managed: false, root, reason: "lease segment must look like wt_<hex hash>" };
1361
+
1362
+ // Recognised at or inside a legacy lease root, mirroring how a canonical worktree
1363
+ // covers its own subdirectories — an agent cwd'd into `src/` of a legacy worktree
1364
+ // is in the same non-compliant worktree, and must get the same migration message.
1365
+ //
1366
+ // The migration tolerance grants a weaker verdict than "blocked", so it has to clear
1367
+ // the same grounding as the canonical branch. Otherwise the lease name pattern is a
1368
+ // forgery kit: two directories named to match would launder a symlinked or grafted
1369
+ // path into a warn-and-allow.
1370
+ if (legacyWorktreeToleranceEnabled()
1371
+ && parts.length >= LEGACY_LEASE_WORKTREE_SEGMENTS
1372
+ && LEGACY_LEASE_REPO_PATTERN.test(parts[1]!)
1373
+ && LEGACY_LEASE_ID_PATTERN.test(parts[2]!)) {
1374
+ // The layout has two historical variants: the checkout sits at the lease dir, or
1375
+ // one level below it in a `repo/` child. Try both, nothing deeper.
1376
+ for (const depth of [LEGACY_LEASE_WORKTREE_SEGMENTS, LEGACY_LEASE_WORKTREE_SEGMENTS + 1]) {
1377
+ if (parts.length < depth) break;
1378
+ const segments = parts.slice(0, depth);
1379
+ if (groundedWorktreeRootReason(root, segments)) continue;
1380
+ return {
1381
+ managed: false,
1382
+ root,
1383
+ layout: "legacy-station-lease",
1384
+ deprecated: true,
1385
+ worktreeRoot: resolve(root, ...segments),
1386
+ reason: `deprecated station-id lease layout <station-id>/<repo-slug>-<hex>/wt_<hex>; rule 8 forbids a station-id or machine segment — re-home to ${canonical}`,
1387
+ };
1388
+ }
1103
1389
  }
1104
- return { managed: true, root };
1390
+
1391
+ return {
1392
+ managed: false,
1393
+ root,
1394
+ reason: `worktree root is ${parts.length} segments under the worktrees root (station-id/machine segment or extra nesting); rule 8 requires the worktree to be created at exactly ${canonical}`,
1395
+ };
1105
1396
  }
1106
1397
 
1107
1398
  export async function gitRepoRoot(cwd: string): Promise<string | null> {
@@ -1121,8 +1412,105 @@ export async function gitRemoteSlug(cwd: string): Promise<string | null> {
1121
1412
  return match?.[1] || null;
1122
1413
  }
1123
1414
 
1124
- export function claimCommand(repo: string | null, taskId: string | null, runId: string | null): string {
1125
- return `repos worktrees claim --repo ${repo || "<repo>"} --task-id ${taskId || "<task-id>"} --run-id ${runId || "<run-id>"} --base main --mode required --json`;
1415
+ /** `origin` normalised to the `host/org/name` form the repos CLI resolves exactly. */
1416
+ export async function gitRemoteHostSlug(cwd: string): Promise<string | null> {
1417
+ if (!commandExists("git")) return null;
1418
+ const result = await runCommand(["git", "remote", "get-url", "origin"], { cwd, timeoutMs: 2000 });
1419
+ if (result.exitCode !== 0) return null;
1420
+ const remote = result.stdout.trim().replace(/\.git$/, "");
1421
+ if (!remote) return null;
1422
+ const match = remote.match(/^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:\s]+)[:/](.+)$/i);
1423
+ const host = match?.[1];
1424
+ const path = match?.[2]?.replace(/^\/+/, "");
1425
+ if (!host || !path || !/^[^/\s]+\/[^/\s]+$/.test(path)) return null;
1426
+ return `${host}/${path}`;
1427
+ }
1428
+
1429
+ export interface CanonicalRepoIdentity {
1430
+ /** The repo name that forms the `<repo-name>` segment of the canonical path. */
1431
+ name: string | null;
1432
+ defaultBranch: string | null;
1433
+ }
1434
+
1435
+ /**
1436
+ * Resolve the canonical repo name via the repos CLI, as rule 8 requires:
1437
+ * "Locate repos with the repos CLI (`repos repo <name> --json` for the exact
1438
+ * lookup; never fuzzy `repos cd` or 'did you mean' output for targeting)".
1439
+ *
1440
+ * This matters because the repos-CLI name is frequently NOT the git remote
1441
+ * basename — on this fleet 46 of 50 indexed repos differ (`open-hooks` is
1442
+ * `github.com/hasna/hooks`, `open-mailery` is `.../emails`). Deriving the
1443
+ * canonical path segment from the remote would send every agent to the wrong
1444
+ * directory, so the remote is only ever used as the exact lookup key.
1445
+ *
1446
+ * `--remote host/org/name` is the exact-match form, so no fuzzy "did you mean"
1447
+ * output can be mistaken for a hit. OSS-safe: a missing or failing repos CLI
1448
+ * yields nulls and the caller falls back to local information.
1449
+ */
1450
+ export async function canonicalRepoIdentity(cwd: string): Promise<CanonicalRepoIdentity> {
1451
+ const empty: CanonicalRepoIdentity = { name: null, defaultBranch: null };
1452
+ if (!commandExists("repos")) return empty;
1453
+ const remote = await gitRemoteHostSlug(cwd);
1454
+ if (!remote) return empty;
1455
+ // Hard ceiling on the lookup. runCommand's timeout kills the direct child but still
1456
+ // awaits its pipes, which a forking CLI can hold open indefinitely; this hook sits on
1457
+ // the PreToolUse path, so it must degrade to local information rather than stall.
1458
+ const result = await Promise.race([
1459
+ runCommand(["repos", "repo", "--remote", remote, "--json"], { cwd, timeoutMs: 1000 }),
1460
+ new Promise<null>((done) => setTimeout(() => done(null), 1500).unref?.()),
1461
+ ]);
1462
+ if (!result || result.exitCode !== 0) return empty;
1463
+ try {
1464
+ const parsed = JSON.parse(result.stdout) as { name?: unknown; default_branch?: unknown; path?: unknown };
1465
+ const name = typeof parsed.name === "string" && parsed.name ? parsed.name : null;
1466
+ const defaultBranch = typeof parsed.default_branch === "string" && parsed.default_branch
1467
+ ? parsed.default_branch
1468
+ : null;
1469
+
1470
+ // The index holds worktree directories as first-class rows, so an exact remote
1471
+ // match can resolve to a worktree rather than the repo. Such a row's name is a
1472
+ // worktree name and its default_branch is that worktree's branch — both wrong for
1473
+ // the canonical path. When the row lives under the worktrees root, the real repo
1474
+ // name is its first segment there; the branch is not recoverable, so drop it.
1475
+ const worktreesRoot = resolve(defaultWorktreesRoot());
1476
+ const rowPath = typeof parsed.path === "string" && parsed.path ? resolve(parsed.path) : null;
1477
+ if (rowPath && isInsidePath(rowPath, worktreesRoot) && rowPath !== worktreesRoot) {
1478
+ const segment = relative(worktreesRoot, rowPath).split(sep).filter(Boolean)[0];
1479
+ return { name: segment || null, defaultBranch: null };
1480
+ }
1481
+ return { name, defaultBranch };
1482
+ } catch {
1483
+ return empty;
1484
+ }
1485
+ }
1486
+
1487
+ /**
1488
+ * Remediation command for work happening outside a canonical worktree.
1489
+ *
1490
+ * Rule 8: create the worktree at `<worktrees-root>/<repo-name>/<worktree-name>`,
1491
+ * named after the todos task where one exists, then `repos scan`. The repos CLI
1492
+ * has no worktree verb, so `git worktree` is the creation path.
1493
+ *
1494
+ * `repo` must be a canonical repo name (see canonicalRepoIdentity) — never a
1495
+ * remote slug, which names a different directory for most repos.
1496
+ *
1497
+ * This is the boundary where names become a command an operator may paste, so every
1498
+ * interpolated value is validated here rather than trusted from its source: a repo
1499
+ * name is attacker-influenced via the remote, and a task id is unvalidated hook input.
1500
+ * Anything unsafe degrades to the explicit placeholder instead of being emitted.
1501
+ */
1502
+ const SAFE_COMMAND_VALUE = /^[a-zA-Z0-9_][a-zA-Z0-9_.\/-]{0,120}$/;
1503
+
1504
+ export function claimCommand(repo: string | null, taskId: string | null, defaultBranch: string | null = null): string {
1505
+ // A repo name is one path segment: a slug would silently add a third segment.
1506
+ const safeRepo = repo && SAFE_COMMAND_VALUE.test(repo) && !repo.includes("/") ? repo : null;
1507
+ const safeTask = taskId && SAFE_COMMAND_VALUE.test(taskId) ? taskId : null;
1508
+ const safeBase = defaultBranch && SAFE_COMMAND_VALUE.test(defaultBranch) ? defaultBranch : null;
1509
+
1510
+ const repoName = safeRepo || "<repo-name>";
1511
+ const worktreeName = safeTask || "<worktree-name>";
1512
+ const path = join(defaultWorktreesRoot(), repoName, worktreeName);
1513
+ return `git worktree add -b ${worktreeName} ${path} origin/${safeBase || "<default-branch>"} && repos scan`;
1126
1514
  }
1127
1515
 
1128
1516
  export function taskIdFrom(input: CodewithHookInput): string | null {
@@ -8,6 +8,50 @@ It blocks scoped destructive shell operations and file-tool-like payloads when
8
8
  the resolved target threatens `~/.hasna`, configured workspace roots, Hasna
9
9
  division/scope roots, or active repo/worktree roots.
10
10
 
11
+ ## Canonical worktree path
12
+
13
+ Git work is expected to happen in a task-specific worktree at the canonical path
14
+ from Hasna Agent Operating Rules rule 8 (published by `@hasna/identities`):
15
+
16
+ ```
17
+ $HOME/.hasna/repos/worktrees/<repo-name>/<worktree-name>
18
+ ```
19
+
20
+ Repo name, then worktree name. Anything else is reported as unmanaged, with a
21
+ reason: a flat single-segment worktree directly under the worktrees root, a
22
+ station-id or machine segment in front of the repo name, and any deeper nesting
23
+ are all rejected. Subdirectories of a canonical worktree are accepted.
24
+
25
+ The classification is grounded in verified git provenance, not path shape. The
26
+ two-segment path must be a real worktree root, no segment may be a symlink, and
27
+ its `.git` must prove it owns its own history: a `.git` file's `gitdir:` target
28
+ has to live under its repository's `worktrees/` directory and point back at this
29
+ control file, and a `.git` directory must not be grafted on by a `commondir`.
30
+
31
+ Shape alone proves nothing. `<root>/<flat-worktree>/<subdir>` has exactly the
32
+ canonical shape, so a `cd` would otherwise launder a forbidden flat worktree into
33
+ a compliant one; and a symlink or a two-line forged `.git` file would aim a
34
+ compliant-looking path at a shared checkout, making `git commit` land there.
35
+
36
+ Override the worktrees root with `HASNA_REPOS_WORKTREES_ROOT`.
37
+
38
+ ## Deprecated: the station-id lease layout
39
+
40
+ The pre-rule-8 layout `<station-id>/<repo-slug>-<hex>/wt_<hex>` is deprecated and
41
+ is never classified as a compliant worktree. Two migration tolerances keep
42
+ existing worktrees working while they are re-homed:
43
+
44
+ - git work there warns instead of being blocked, so in-flight tasks can still land;
45
+ - it keeps its scoped `~/.hasna` write carve-out.
46
+
47
+ Both are temporary. Re-home these worktrees to the canonical path, then set
48
+ `HASNA_HOOKS_LEGACY_WORKTREE_TOLERANCE=0`; the branch is removed after that.
49
+
50
+ The tolerance still requires the same provenance proof as the canonical path — it
51
+ softens the verdict, it does not skip the check. It does key off the path name, so
52
+ a worktree deliberately named to match also gets the warn tier; that is an opt-out
53
+ from a guardrail by a cooperating agent, not a way to reach a shared checkout.
54
+
11
55
  ## Install for Codewith
12
56
 
13
57
  Prefer renderer-managed configuration through open-configs. @hasna/hooks can emit the TOML fragment:
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import {
4
+ canonicalRepoIdentity,
5
+ canonicalWorktreeTemplate,
4
6
  classifyDangerousOperation,
5
7
  claimCommand,
6
8
  gitCommandInfo,
@@ -10,7 +12,6 @@ import {
10
12
  managedWorktreeInfo,
11
13
  readInput,
12
14
  respond,
13
- runIdFrom,
14
15
  taskIdFrom,
15
16
  warn,
16
17
  type CodewithHookInput,
@@ -40,27 +41,63 @@ export async function evaluate(input: CodewithHookInput): Promise<{ output: Reco
40
41
  const managed = managedWorktreeInfo(targetCwd);
41
42
  if (managed.managed) return { output: { continue: true }, warnings };
42
43
 
44
+ // Migration shim, temporary and deliberately narrow: worktrees created under the
45
+ // pre-rule-8 station-id lease layout are non-compliant, but they are real, active
46
+ // worktrees. Hard-blocking their git work on day one would strand in-flight tasks
47
+ // with no way to land, so this one recognised layout warns instead of blocking.
48
+ // Everything else non-canonical is still blocked. Remove once they are re-homed.
49
+ if (managed.layout === "legacy-station-lease") {
50
+ const message = `${managed.reason}. This layout will stop being tolerated; re-home this worktree.`;
51
+ warnings.push(message);
52
+ return {
53
+ output: {
54
+ continue: true,
55
+ hookSpecificOutput: {
56
+ hookEventName: "PreToolUse",
57
+ additionalContext: `[worktree-guard] ${message}`,
58
+ },
59
+ },
60
+ warnings,
61
+ };
62
+ }
63
+
43
64
  const repoRoot = await gitRepoRoot(targetCwd);
44
- const repo = await gitRemoteSlug(targetCwd) || (repoRoot ? repoRoot.split("/").filter(Boolean).pop() || null : null);
65
+ // Rule 8 resolution order: the repos CLI is the source of truth for <repo-name>.
66
+ // The local checkout directory is the next best guess, and the remote slug is the
67
+ // last resort — for most repos the remote basename is NOT the canonical repo name.
68
+ const identity = await canonicalRepoIdentity(targetCwd);
69
+ const repo = identity.name
70
+ || (repoRoot ? repoRoot.split("/").filter(Boolean).pop() || null : null)
71
+ || (await gitRemoteSlug(targetCwd))?.split("/").filter(Boolean).pop()
72
+ || null;
45
73
  const taskId = taskIdFrom(input);
46
- const runId = runIdFrom(input);
47
- const recommended = claimCommand(repo, taskId, runId);
74
+ const recommended = claimCommand(repo, taskId, identity.defaultBranch);
48
75
  const action = gitInfo?.action || null;
76
+ const canonical = canonicalWorktreeTemplate(managed.root);
77
+ // Rule 8 requires an exact repos-CLI lookup for <repo-name>. When that lookup did
78
+ // not resolve, the name below is a local guess, so say so rather than imply it is
79
+ // authoritative — the repo directory name and the remote basename often differ.
80
+ const unverifiedRepoName = identity.name
81
+ ? null
82
+ : `Confirm <repo-name> with an exact repos CLI lookup first: repos repo ${repo || "<name>"} --json`;
49
83
 
50
84
  if (action) {
51
85
  const reason = [
52
- `Blocked git ${action} outside a managed repos worktree (${managed.reason || "not under managed root"}).`,
86
+ `Blocked git ${action} outside a canonical task worktree (${managed.reason || "not under managed root"}).`,
53
87
  `Command target cwd: ${targetCwd}`,
54
- `Managed root: ${managed.root}`,
55
- `Claim a task worktree first: ${recommended}`,
88
+ `Canonical worktree path (Agent Operating Rules rule 8): ${canonical}`,
89
+ `Create one first: ${recommended}`,
90
+ ...(unverifiedRepoName ? [unverifiedRepoName] : []),
56
91
  ].join(" ");
57
92
  return { output: { decision: "block", reason }, warnings };
58
93
  }
59
94
 
60
95
  if (isFeatureWork(input, command)) {
61
96
  warnings.push([
62
- `Feature work appears to be outside a managed repos worktree (${managed.reason || "not under managed root"}).`,
97
+ `Feature work appears to be outside a canonical task worktree (${managed.reason || "not under managed root"}).`,
98
+ `Canonical worktree path (Agent Operating Rules rule 8): ${canonical}.`,
63
99
  `Use: ${recommended}`,
100
+ ...(unverifiedRepoName ? [unverifiedRepoName] : []),
64
101
  ].join(" "));
65
102
  return {
66
103
  output: {
@@ -91,4 +128,9 @@ export async function run(): Promise<void> {
91
128
 
92
129
  if (import.meta.main) {
93
130
  await run();
131
+ // The verdict is written, so the hook is done. Exit rather than waiting for the
132
+ // event loop to drain: an optional CLI consulted during evaluation may have left a
133
+ // grandchild holding a pipe open, and a hook that has already answered must never
134
+ // keep the caller's PreToolUse path waiting on it.
135
+ process.exit(0);
94
136
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.4.0",
3
+ "version": "0.4.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": {