@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.
- package/README.md +22 -0
- package/bin/index.js +324 -102
- package/hooks/codewith-native-common.test.ts +364 -12
- package/hooks/codewith-native-common.ts +558 -18
- package/hooks/worktree-guard/README.md +44 -0
- package/hooks/worktree-guard/src/hook.ts +50 -8
- package/package.json +1 -1
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
941
|
-
const parts =
|
|
942
|
-
if (parts.length
|
|
943
|
-
|
|
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 (
|
|
950
|
-
return {
|
|
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
|
-
|
|
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
|
-
|
|
973
|
-
|
|
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:
|