@hasna/hooks 0.3.11 → 0.4.0
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 +627 -150
- package/hooks/codewith-native-common.ts +155 -3
- 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 } 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 {
|
|
@@ -485,6 +485,148 @@ function shouldSkipHasnaTreeRule(targetPath: string, rule: ProtectedPathRule, cu
|
|
|
485
485
|
return isInsidePath(target, currentManagedRepoRoot);
|
|
486
486
|
}
|
|
487
487
|
|
|
488
|
+
function isMissingPathError(error: unknown): boolean {
|
|
489
|
+
return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function hasUnsafeTargetComponent(worktreesRoot: string, target: string): boolean {
|
|
493
|
+
const relativeTarget = relative(worktreesRoot, target);
|
|
494
|
+
const parts = relativeTarget.split(sep).filter(Boolean);
|
|
495
|
+
if (parts.some((part) => part.toLowerCase() === ".git")) return true;
|
|
496
|
+
|
|
497
|
+
const filesystemRoot = parse(target).root;
|
|
498
|
+
const absoluteParts = relative(filesystemRoot, target).split(sep).filter(Boolean);
|
|
499
|
+
let probe = filesystemRoot;
|
|
500
|
+
try {
|
|
501
|
+
if (lstatSync(probe).isSymbolicLink()) return true;
|
|
502
|
+
} catch {
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
for (const part of absoluteParts) {
|
|
506
|
+
probe = join(probe, part);
|
|
507
|
+
try {
|
|
508
|
+
const metadata = lstatSync(probe);
|
|
509
|
+
if (metadata.isSymbolicLink()) return true;
|
|
510
|
+
if (probe === target && metadata.isFile() && metadata.nlink > 1) return true;
|
|
511
|
+
} catch (error) {
|
|
512
|
+
if (isMissingPathError(error)) return false;
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function managedLeaseRoot(worktreesRoot: string, target: string): string | null {
|
|
520
|
+
const relativeTarget = relative(worktreesRoot, target);
|
|
521
|
+
const parts = relativeTarget.split(sep).filter(Boolean);
|
|
522
|
+
if (parts.length < 3) return null;
|
|
523
|
+
const leaseRoot = resolve(worktreesRoot, ...parts.slice(0, 3));
|
|
524
|
+
return managedWorktreeInfo(leaseRoot).managed ? leaseRoot : null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function verifiedLinkedWorktreeRoot(leaseRoot: string): Promise<string | null> {
|
|
528
|
+
const controlFile = join(leaseRoot, ".git");
|
|
529
|
+
try {
|
|
530
|
+
const metadata = lstatSync(controlFile);
|
|
531
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1) return null;
|
|
532
|
+
} catch {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (!commandExists("git")) return null;
|
|
537
|
+
const result = await runCommand([
|
|
538
|
+
"git",
|
|
539
|
+
"rev-parse",
|
|
540
|
+
"--show-toplevel",
|
|
541
|
+
"--absolute-git-dir",
|
|
542
|
+
"--git-common-dir",
|
|
543
|
+
], { cwd: leaseRoot, timeoutMs: 2000 });
|
|
544
|
+
if (result.exitCode !== 0) return null;
|
|
545
|
+
const [repoRootRaw, gitDirRaw, commonDirRaw] = result.stdout.trim().split(/\r?\n/);
|
|
546
|
+
if (!repoRootRaw || !gitDirRaw || !commonDirRaw) return null;
|
|
547
|
+
|
|
548
|
+
const repoRoot = resolve(repoRootRaw);
|
|
549
|
+
const gitDir = resolveFrom(leaseRoot, gitDirRaw);
|
|
550
|
+
const commonDir = resolveFrom(leaseRoot, commonDirRaw);
|
|
551
|
+
if (repoRoot !== resolve(leaseRoot)) return null;
|
|
552
|
+
try {
|
|
553
|
+
const physicalGitDir = realpathSync(gitDir);
|
|
554
|
+
const physicalCommonDir = realpathSync(commonDir);
|
|
555
|
+
const physicalWorktreesDir = realpathSync(join(commonDir, "worktrees"));
|
|
556
|
+
if (physicalGitDir === physicalWorktreesDir || !isInsidePath(physicalGitDir, physicalWorktreesDir)) return null;
|
|
557
|
+
if (dirname(physicalWorktreesDir) !== physicalCommonDir) return null;
|
|
558
|
+
|
|
559
|
+
const commondirPointer = readFileSync(join(gitDir, "commondir"), "utf-8").trim();
|
|
560
|
+
const gitdirPointer = readFileSync(join(gitDir, "gitdir"), "utf-8").trim();
|
|
561
|
+
if (!commondirPointer || !gitdirPointer) return null;
|
|
562
|
+
if (realpathSync(resolveFrom(gitDir, commondirPointer)) !== physicalCommonDir) return null;
|
|
563
|
+
const expectedControlFile = resolve(controlFile);
|
|
564
|
+
const backPointer = resolveFrom(gitDir, gitdirPointer);
|
|
565
|
+
if (backPointer !== expectedControlFile) return null;
|
|
566
|
+
if (realpathSync(backPointer) !== realpathSync(expectedControlFile)) return null;
|
|
567
|
+
} catch {
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
return repoRoot;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function managedRepoRootForAbsoluteTarget(
|
|
574
|
+
targetPath: string,
|
|
575
|
+
repoRootCache: Map<string, Promise<string | null>>,
|
|
576
|
+
): Promise<string | null> {
|
|
577
|
+
if (!isAbsolute(targetPath)) return null;
|
|
578
|
+
const worktreesRoot = resolve(defaultWorktreesRoot());
|
|
579
|
+
const target = resolve(targetPath);
|
|
580
|
+
if (target === worktreesRoot || !isInsidePath(target, worktreesRoot)) return null;
|
|
581
|
+
if (hasUnsafeTargetComponent(worktreesRoot, target)) return null;
|
|
582
|
+
|
|
583
|
+
let physicalWorktreesRoot: string;
|
|
584
|
+
try {
|
|
585
|
+
physicalWorktreesRoot = realpathSync(worktreesRoot);
|
|
586
|
+
} catch {
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const leaseRoot = managedLeaseRoot(worktreesRoot, target);
|
|
591
|
+
if (!leaseRoot) return null;
|
|
592
|
+
let repoRootPromise = repoRootCache.get(leaseRoot);
|
|
593
|
+
if (!repoRootPromise) {
|
|
594
|
+
repoRootPromise = verifiedLinkedWorktreeRoot(leaseRoot);
|
|
595
|
+
repoRootCache.set(leaseRoot, repoRootPromise);
|
|
596
|
+
}
|
|
597
|
+
const repoRoot = await repoRootPromise;
|
|
598
|
+
if (!repoRoot) return null;
|
|
599
|
+
const resolvedRepoRoot = resolve(repoRoot);
|
|
600
|
+
if (resolvedRepoRoot !== resolve(leaseRoot)) return null;
|
|
601
|
+
try {
|
|
602
|
+
const physicalRepoRoot = realpathSync(resolvedRepoRoot);
|
|
603
|
+
if (physicalRepoRoot === physicalWorktreesRoot || !isInsidePath(physicalRepoRoot, physicalWorktreesRoot)) return null;
|
|
604
|
+
const probe = dirname(target);
|
|
605
|
+
let existingProbe = probe;
|
|
606
|
+
while (true) {
|
|
607
|
+
try {
|
|
608
|
+
lstatSync(existingProbe);
|
|
609
|
+
break;
|
|
610
|
+
} catch (error) {
|
|
611
|
+
if (!isMissingPathError(error)) return null;
|
|
612
|
+
}
|
|
613
|
+
const parent = dirname(existingProbe);
|
|
614
|
+
if (parent === existingProbe || !isInsidePath(parent, resolvedRepoRoot)) return null;
|
|
615
|
+
existingProbe = parent;
|
|
616
|
+
}
|
|
617
|
+
const physicalProbe = realpathSync(existingProbe);
|
|
618
|
+
const missingSuffix = relative(existingProbe, target);
|
|
619
|
+
if (!missingSuffix || missingSuffix === ".." || missingSuffix.startsWith(`..${sep}`) || isAbsolute(missingSuffix)) return null;
|
|
620
|
+
const physicalTarget = existsSync(target)
|
|
621
|
+
? realpathSync(target)
|
|
622
|
+
: resolve(physicalProbe, missingSuffix);
|
|
623
|
+
if (physicalTarget === physicalRepoRoot || !isInsidePath(physicalTarget, physicalRepoRoot)) return null;
|
|
624
|
+
} catch {
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
return resolvedRepoRoot;
|
|
628
|
+
}
|
|
629
|
+
|
|
488
630
|
function threatensRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean {
|
|
489
631
|
if (shouldSkipHasnaTreeRule(targetPath, rule, currentManagedRepoRoot)) return false;
|
|
490
632
|
const contentBase = broadContentWipeBase(targetPath);
|
|
@@ -849,12 +991,22 @@ export async function classifyDangerousOperation(input: CodewithHookInput): Prom
|
|
|
849
991
|
}
|
|
850
992
|
}
|
|
851
993
|
|
|
994
|
+
const managedRepoRootCache = new Map<string, Promise<string | null>>();
|
|
995
|
+
const worktreesRoot = resolve(defaultWorktreesRoot());
|
|
852
996
|
for (const candidate of extractFileToolPaths(input)) {
|
|
853
997
|
const targetPath = resolveFrom(cwd, candidate.path);
|
|
998
|
+
const hasUnsafeManagedComponent = isInsidePath(targetPath, worktreesRoot)
|
|
999
|
+
&& hasUnsafeTargetComponent(worktreesRoot, targetPath);
|
|
1000
|
+
const targetManagedRepoRoot = hasUnsafeManagedComponent
|
|
1001
|
+
? null
|
|
1002
|
+
: await managedRepoRootForAbsoluteTarget(targetPath, managedRepoRootCache);
|
|
1003
|
+
const exemptManagedRepoRoot = hasUnsafeManagedComponent
|
|
1004
|
+
? null
|
|
1005
|
+
: targetManagedRepoRoot;
|
|
854
1006
|
const extraRule = workspaceRoots.map((root) => hasnaDivisionRuleFor(targetPath, root)).find((rule): rule is ProtectedPathRule => Boolean(rule));
|
|
855
1007
|
const allRules = extraRule ? [...rules, extraRule] : rules;
|
|
856
1008
|
for (const rule of allRules) {
|
|
857
|
-
if (mutatesRule(targetPath, rule,
|
|
1009
|
+
if (mutatesRule(targetPath, rule, exemptManagedRepoRoot)) {
|
|
858
1010
|
return {
|
|
859
1011
|
block: true,
|
|
860
1012
|
targetPath,
|
package/package.json
CHANGED