@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.
- package/bin/index.js +48 -303
- package/hooks/codewith-native-common.test.ts +364 -12
- package/hooks/codewith-native-common.ts +412 -24
- package/hooks/worktree-guard/README.md +44 -0
- package/hooks/worktree-guard/src/hook.ts +50 -8
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
591
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1093
|
-
const parts =
|
|
1094
|
-
if (parts.length
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
if (
|
|
1098
|
-
|
|
1099
|
-
|
|
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
|
-
|
|
1102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1125
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
86
|
+
`Blocked git ${action} outside a canonical task worktree (${managed.reason || "not under managed root"}).`,
|
|
53
87
|
`Command target cwd: ${targetCwd}`,
|
|
54
|
-
`
|
|
55
|
-
`
|
|
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
|
|
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