@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.
@@ -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, currentManagedRepoRoot)) {
1009
+ if (mutatesRule(targetPath, rule, exemptManagedRepoRoot)) {
858
1010
  return {
859
1011
  block: true,
860
1012
  targetPath,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.3.11",
3
+ "version": "0.4.0",
4
4
  "description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
5
5
  "type": "module",
6
6
  "bin": {