@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,5 +1,5 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "path";
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, writeSync } from "fs";
2
+ import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "path";
3
3
  import { homedir, tmpdir } from "os";
4
4
 
5
5
  export interface CodewithHookInput {
@@ -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 {
@@ -485,6 +493,188 @@ function shouldSkipHasnaTreeRule(targetPath: string, rule: ProtectedPathRule, cu
485
493
  return isInsidePath(target, currentManagedRepoRoot);
486
494
  }
487
495
 
496
+ function isMissingPathError(error: unknown): boolean {
497
+ return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT";
498
+ }
499
+
500
+ function hasUnsafeTargetComponent(worktreesRoot: string, target: string): boolean {
501
+ const relativeTarget = relative(worktreesRoot, target);
502
+ const parts = relativeTarget.split(sep).filter(Boolean);
503
+ if (parts.some((part) => part.toLowerCase() === ".git")) return true;
504
+
505
+ const filesystemRoot = parse(target).root;
506
+ const absoluteParts = relative(filesystemRoot, target).split(sep).filter(Boolean);
507
+ let probe = filesystemRoot;
508
+ try {
509
+ if (lstatSync(probe).isSymbolicLink()) return true;
510
+ } catch {
511
+ return true;
512
+ }
513
+ for (const part of absoluteParts) {
514
+ probe = join(probe, part);
515
+ try {
516
+ const metadata = lstatSync(probe);
517
+ if (metadata.isSymbolicLink()) return true;
518
+ if (probe === target && metadata.isFile() && metadata.nlink > 1) return true;
519
+ } catch (error) {
520
+ if (isMissingPathError(error)) return false;
521
+ return true;
522
+ }
523
+ }
524
+ return false;
525
+ }
526
+
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
+ });
562
+ }
563
+
564
+ async function verifiedLinkedWorktreeRoot(leaseRoot: string): Promise<string | null> {
565
+ const controlFile = join(leaseRoot, ".git");
566
+ try {
567
+ const metadata = lstatSync(controlFile);
568
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1) return null;
569
+ } catch {
570
+ return null;
571
+ }
572
+
573
+ if (!commandExists("git")) return null;
574
+ const result = await runCommand([
575
+ "git",
576
+ "rev-parse",
577
+ "--show-toplevel",
578
+ "--absolute-git-dir",
579
+ "--git-common-dir",
580
+ ], { cwd: leaseRoot, timeoutMs: 2000 });
581
+ if (result.exitCode !== 0) return null;
582
+ const [repoRootRaw, gitDirRaw, commonDirRaw] = result.stdout.trim().split(/\r?\n/);
583
+ if (!repoRootRaw || !gitDirRaw || !commonDirRaw) return null;
584
+
585
+ const repoRoot = resolve(repoRootRaw);
586
+ const gitDir = resolveFrom(leaseRoot, gitDirRaw);
587
+ const commonDir = resolveFrom(leaseRoot, commonDirRaw);
588
+ if (repoRoot !== resolve(leaseRoot)) return null;
589
+ try {
590
+ const physicalGitDir = realpathSync(gitDir);
591
+ const physicalCommonDir = realpathSync(commonDir);
592
+ const physicalWorktreesDir = realpathSync(join(commonDir, "worktrees"));
593
+ if (physicalGitDir === physicalWorktreesDir || !isInsidePath(physicalGitDir, physicalWorktreesDir)) return null;
594
+ if (dirname(physicalWorktreesDir) !== physicalCommonDir) return null;
595
+
596
+ const commondirPointer = readFileSync(join(gitDir, "commondir"), "utf-8").trim();
597
+ const gitdirPointer = readFileSync(join(gitDir, "gitdir"), "utf-8").trim();
598
+ if (!commondirPointer || !gitdirPointer) return null;
599
+ if (realpathSync(resolveFrom(gitDir, commondirPointer)) !== physicalCommonDir) return null;
600
+ const expectedControlFile = resolve(controlFile);
601
+ const backPointer = resolveFrom(gitDir, gitdirPointer);
602
+ if (backPointer !== expectedControlFile) return null;
603
+ if (realpathSync(backPointer) !== realpathSync(expectedControlFile)) return null;
604
+ } catch {
605
+ return null;
606
+ }
607
+ return repoRoot;
608
+ }
609
+
610
+ async function managedRepoRootForAbsoluteTarget(
611
+ targetPath: string,
612
+ repoRootCache: Map<string, Promise<string | null>>,
613
+ ): Promise<string | null> {
614
+ if (!isAbsolute(targetPath)) return null;
615
+ const worktreesRoot = resolve(defaultWorktreesRoot());
616
+ const target = resolve(targetPath);
617
+ if (target === worktreesRoot || !isInsidePath(target, worktreesRoot)) return null;
618
+ if (hasUnsafeTargetComponent(worktreesRoot, target)) return null;
619
+
620
+ let physicalWorktreesRoot: string;
621
+ try {
622
+ physicalWorktreesRoot = realpathSync(worktreesRoot);
623
+ } catch {
624
+ return null;
625
+ }
626
+
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> {
640
+ let repoRootPromise = repoRootCache.get(leaseRoot);
641
+ if (!repoRootPromise) {
642
+ repoRootPromise = verifiedLinkedWorktreeRoot(leaseRoot);
643
+ repoRootCache.set(leaseRoot, repoRootPromise);
644
+ }
645
+ const repoRoot = await repoRootPromise;
646
+ if (!repoRoot) return null;
647
+ const resolvedRepoRoot = resolve(repoRoot);
648
+ if (resolvedRepoRoot !== resolve(leaseRoot)) return null;
649
+ try {
650
+ const physicalRepoRoot = realpathSync(resolvedRepoRoot);
651
+ if (physicalRepoRoot === physicalWorktreesRoot || !isInsidePath(physicalRepoRoot, physicalWorktreesRoot)) return null;
652
+ const probe = dirname(target);
653
+ let existingProbe = probe;
654
+ while (true) {
655
+ try {
656
+ lstatSync(existingProbe);
657
+ break;
658
+ } catch (error) {
659
+ if (!isMissingPathError(error)) return null;
660
+ }
661
+ const parent = dirname(existingProbe);
662
+ if (parent === existingProbe || !isInsidePath(parent, resolvedRepoRoot)) return null;
663
+ existingProbe = parent;
664
+ }
665
+ const physicalProbe = realpathSync(existingProbe);
666
+ const missingSuffix = relative(existingProbe, target);
667
+ if (!missingSuffix || missingSuffix === ".." || missingSuffix.startsWith(`..${sep}`) || isAbsolute(missingSuffix)) return null;
668
+ const physicalTarget = existsSync(target)
669
+ ? realpathSync(target)
670
+ : resolve(physicalProbe, missingSuffix);
671
+ if (physicalTarget === physicalRepoRoot || !isInsidePath(physicalTarget, physicalRepoRoot)) return null;
672
+ } catch {
673
+ return null;
674
+ }
675
+ return resolvedRepoRoot;
676
+ }
677
+
488
678
  function threatensRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean {
489
679
  if (shouldSkipHasnaTreeRule(targetPath, rule, currentManagedRepoRoot)) return false;
490
680
  const contentBase = broadContentWipeBase(targetPath);
@@ -849,12 +1039,22 @@ export async function classifyDangerousOperation(input: CodewithHookInput): Prom
849
1039
  }
850
1040
  }
851
1041
 
1042
+ const managedRepoRootCache = new Map<string, Promise<string | null>>();
1043
+ const worktreesRoot = resolve(defaultWorktreesRoot());
852
1044
  for (const candidate of extractFileToolPaths(input)) {
853
1045
  const targetPath = resolveFrom(cwd, candidate.path);
1046
+ const hasUnsafeManagedComponent = isInsidePath(targetPath, worktreesRoot)
1047
+ && hasUnsafeTargetComponent(worktreesRoot, targetPath);
1048
+ const targetManagedRepoRoot = hasUnsafeManagedComponent
1049
+ ? null
1050
+ : await managedRepoRootForAbsoluteTarget(targetPath, managedRepoRootCache);
1051
+ const exemptManagedRepoRoot = hasUnsafeManagedComponent
1052
+ ? null
1053
+ : targetManagedRepoRoot;
854
1054
  const extraRule = workspaceRoots.map((root) => hasnaDivisionRuleFor(targetPath, root)).find((rule): rule is ProtectedPathRule => Boolean(rule));
855
1055
  const allRules = extraRule ? [...rules, extraRule] : rules;
856
1056
  for (const rule of allRules) {
857
- if (mutatesRule(targetPath, rule, currentManagedRepoRoot)) {
1057
+ if (mutatesRule(targetPath, rule, exemptManagedRepoRoot)) {
858
1058
  return {
859
1059
  block: true,
860
1060
  targetPath,
@@ -934,22 +1134,265 @@ export function isInsidePath(child: string, parent: string): boolean {
934
1134
  return rel === "" || (!!rel && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
935
1135
  }
936
1136
 
937
- 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 {
938
1325
  const root = defaultWorktreesRoot();
1326
+ const canonical = canonicalWorktreeTemplate(root);
939
1327
  if (!isInsidePath(cwd, root)) return { managed: false, root, reason: "outside worktrees root" };
940
- const rel = relative(resolve(root), resolve(cwd));
941
- const parts = rel.split(sep).filter(Boolean);
942
- if (parts.length < 3) return { managed: false, root, reason: "path is inside worktrees root but not deep enough" };
943
- const [machine, repoSlugHash, lease] = parts;
944
- if (!machine || !repoSlugHash || !lease) return { managed: false, root, reason: "missing machine/repo/lease path segments" };
945
- if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,80}$/.test(machine)) return { managed: false, root, reason: "machine segment is malformed" };
946
- if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*-[0-9a-fA-F]{7,16}$/.test(repoSlugHash)) {
947
- 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}` };
948
1332
  }
949
- if (!/^wt_[0-9a-fA-F]{16,64}$/.test(lease)) {
950
- return { managed: false, root, reason: "lease segment must look like wt_<hex hash>" };
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 };
951
1356
  }
952
- return { managed: true, root };
1357
+
1358
+ if (parts.length === CANONICAL_WORKTREE_SEGMENTS) {
1359
+ return { managed: false, root, reason: `${rootReason}; canonical shape is ${canonical}` };
1360
+ }
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
+ }
1389
+ }
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
+ };
953
1396
  }
954
1397
 
955
1398
  export async function gitRepoRoot(cwd: string): Promise<string | null> {
@@ -969,8 +1412,105 @@ export async function gitRemoteSlug(cwd: string): Promise<string | null> {
969
1412
  return match?.[1] || null;
970
1413
  }
971
1414
 
972
- export function claimCommand(repo: string | null, taskId: string | null, runId: string | null): string {
973
- 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`;
974
1514
  }
975
1515
 
976
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: