@hasna/hooks 0.3.9 → 0.3.10

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.
@@ -367,6 +367,12 @@ interface ProtectedPathRule {
367
367
  mode: "tree" | "root";
368
368
  }
369
369
 
370
+ interface ProtectedPathContext {
371
+ rules: ProtectedPathRule[];
372
+ workspaceRoots: string[];
373
+ currentManagedRepoRoot: string | null;
374
+ }
375
+
370
376
  function splitPathList(value: unknown): string[] {
371
377
  if (typeof value === "string") return value.split(":").map((v) => v.trim()).filter(Boolean);
372
378
  if (!Array.isArray(value)) return [];
@@ -418,13 +424,14 @@ function hasnaDivisionRuleFor(target: string, workspaceRoot: string): ProtectedP
418
424
  return null;
419
425
  }
420
426
 
421
- async function protectedRulesFor(input: CodewithHookInput, cwd: string): Promise<ProtectedPathRule[]> {
427
+ async function protectedPathContextFor(input: CodewithHookInput, cwd: string): Promise<ProtectedPathContext> {
422
428
  const home = process.env.HOME || homedir();
423
429
  const rules: ProtectedPathRule[] = [
424
430
  { root: join(home, ".hasna"), label: "Hasna state root ~/.hasna", mode: "tree" },
425
431
  ];
432
+ const workspaceRoots = workspaceRootsFor(input, cwd);
426
433
 
427
- for (const root of workspaceRootsFor(input, cwd)) {
434
+ for (const root of workspaceRoots) {
428
435
  rules.push({ root, label: "workspace root", mode: "root" });
429
436
  }
430
437
 
@@ -435,7 +442,17 @@ async function protectedRulesFor(input: CodewithHookInput, cwd: string): Promise
435
442
  rules.push({ root, label: "active repository or worktree root", mode: "root" });
436
443
  }
437
444
 
438
- return [...new Map(rules.map((rule) => [resolve(rule.root), { ...rule, root: resolve(rule.root) }])).values()];
445
+ const worktreesRoot = resolve(defaultWorktreesRoot());
446
+ const isCurrentManagedRepo = repoRoot !== null
447
+ && isInsidePath(cwd, worktreesRoot)
448
+ && isInsidePath(repoRoot, worktreesRoot);
449
+ const currentManagedRepoRoot = isCurrentManagedRepo ? resolve(repoRoot) : null;
450
+
451
+ return {
452
+ rules: [...new Map(rules.map((rule) => [resolve(rule.root), { ...rule, root: resolve(rule.root) }])).values()],
453
+ workspaceRoots,
454
+ currentManagedRepoRoot,
455
+ };
439
456
  }
440
457
 
441
458
  function threatensProtectedPath(targetPath: string, rule: ProtectedPathRule): boolean {
@@ -461,37 +478,22 @@ function broadContentWipeBase(targetPath: string): string | null {
461
478
  return dirname(target);
462
479
  }
463
480
 
464
- function managedLeaseRootForPath(path: string): string | null {
465
- const root = defaultWorktreesRoot();
466
- if (!isInsidePath(path, root)) return null;
467
- const rel = relative(resolve(root), resolve(path));
468
- const parts = rel.split(sep).filter(Boolean);
469
- if (parts.length < 3) return null;
470
- const [machine, repoSlugHash, lease] = parts;
471
- if (!machine || !repoSlugHash || !lease) return null;
472
- if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,80}$/.test(machine)) return null;
473
- if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*-[0-9a-fA-F]{7,16}$/.test(repoSlugHash)) return null;
474
- if (!/^wt_[0-9a-fA-F]{16,64}$/.test(lease)) return null;
475
- return resolve(root, machine, repoSlugHash, lease);
476
- }
477
-
478
- function shouldSkipHasnaTreeRule(targetPath: string, rule: ProtectedPathRule): boolean {
481
+ function shouldSkipHasnaTreeRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean {
479
482
  if (rule.label !== "Hasna state root ~/.hasna") return false;
480
- const leaseRoot = managedLeaseRootForPath(targetPath);
481
- if (!leaseRoot) return false;
483
+ if (!currentManagedRepoRoot) return false;
482
484
  const target = resolve(targetPath);
483
- return target !== leaseRoot && isInsidePath(target, leaseRoot);
485
+ return isInsidePath(target, currentManagedRepoRoot);
484
486
  }
485
487
 
486
- function threatensRule(targetPath: string, rule: ProtectedPathRule): boolean {
487
- if (shouldSkipHasnaTreeRule(targetPath, rule)) return false;
488
+ function threatensRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean {
489
+ if (shouldSkipHasnaTreeRule(targetPath, rule, currentManagedRepoRoot)) return false;
488
490
  const contentBase = broadContentWipeBase(targetPath);
489
491
  if (contentBase && mutatesProtectedPath(contentBase, rule)) return true;
490
492
  return threatensProtectedPath(targetPath, rule);
491
493
  }
492
494
 
493
- function mutatesRule(targetPath: string, rule: ProtectedPathRule): boolean {
494
- if (shouldSkipHasnaTreeRule(targetPath, rule)) return false;
495
+ function mutatesRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean {
496
+ if (shouldSkipHasnaTreeRule(targetPath, rule, currentManagedRepoRoot)) return false;
495
497
  return mutatesProtectedPath(targetPath, rule);
496
498
  }
497
499
 
@@ -825,8 +827,7 @@ function scopedBlockReason(operation: string, targetPath: string, rule: Protecte
825
827
  export async function classifyDangerousOperation(input: CodewithHookInput): Promise<DangerousOperationMatch> {
826
828
  if (input.hook_event_name !== "PreToolUse") return { block: false };
827
829
  const cwd = input.cwd || process.cwd();
828
- const rules = await protectedRulesFor(input, cwd);
829
- const workspaceRoots = workspaceRootsFor(input, cwd);
830
+ const { rules, workspaceRoots, currentManagedRepoRoot } = await protectedPathContextFor(input, cwd);
830
831
 
831
832
  if (input.tool_name === "Bash") {
832
833
  for (const target of destructiveShellTargets(getCommand(input), cwd)) {
@@ -834,7 +835,7 @@ export async function classifyDangerousOperation(input: CodewithHookInput): Prom
834
835
  const extraRule = workspaceRoots.map((root) => hasnaDivisionRuleFor(targetPath, root)).find((rule): rule is ProtectedPathRule => Boolean(rule));
835
836
  const allRules = extraRule ? [...rules, extraRule] : rules;
836
837
  for (const rule of allRules) {
837
- if (threatensRule(targetPath, rule)) {
838
+ if (threatensRule(targetPath, rule, currentManagedRepoRoot)) {
838
839
  return {
839
840
  block: true,
840
841
  targetPath,
@@ -853,7 +854,7 @@ export async function classifyDangerousOperation(input: CodewithHookInput): Prom
853
854
  const extraRule = workspaceRoots.map((root) => hasnaDivisionRuleFor(targetPath, root)).find((rule): rule is ProtectedPathRule => Boolean(rule));
854
855
  const allRules = extraRule ? [...rules, extraRule] : rules;
855
856
  for (const rule of allRules) {
856
- if (mutatesRule(targetPath, rule)) {
857
+ if (mutatesRule(targetPath, rule, currentManagedRepoRoot)) {
857
858
  return {
858
859
  block: true,
859
860
  targetPath,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
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": {