@hasna/hooks 0.3.7 → 0.3.9

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 CHANGED
@@ -32,8 +32,8 @@ hooks --help
32
32
 
33
33
  - `session-start` — `SessionStart` digest and additional context.
34
34
  - `prompt-guard` — `UserPromptSubmit` guard for pasted fake policy/freeze/run-this-now content.
35
- - `pre-bash` — `PreToolUse` Bash gate for staged secrets scans and risky-op comms checks.
36
- - `worktree-guard` — `PreToolUse` guard for managed repos worktree boundaries.
35
+ - `pre-bash` — `PreToolUse` Bash gate for staged secrets scans, scoped destructive-operation blocks, and risky-op comms checks.
36
+ - `worktree-guard` — `PreToolUse` guard for managed repos worktree boundaries and file-tool-like payloads touching protected Hasna scopes.
37
37
  - `stop-sync` — `Stop` turn-end heartbeat/evidence best effort.
38
38
  - `knowledge-context` — deterministic Knowledge context packs for `SessionStart`, `UserPromptSubmit`, and `SubagentStart`.
39
39
 
@@ -44,6 +44,12 @@ fragment instead of mutating managed `~/.codewith/config.toml`.
44
44
  hooks install session-start prompt-guard pre-bash worktree-guard stop-sync knowledge-context --target codewith
45
45
  ```
46
46
 
47
+ The scoped destructive-operation guard does not block every cleanup command. It
48
+ blocks resolved shell/file-tool targets that threaten `~/.hasna`, configured
49
+ workspace roots, Hasna division/scope roots, or active repo/worktree roots,
50
+ including recursive `rm`, `rsync --delete`, destructive `find`, and destructive
51
+ `git clean` / `git reset --hard` forms.
52
+
47
53
  Apply that fragment through `open-configs` or the managed config renderer. A
48
54
  direct write path exists only for explicit local/test use:
49
55
 
package/bin/index.js CHANGED
@@ -1948,12 +1948,12 @@ var init_registry = __esm(() => {
1948
1948
  {
1949
1949
  name: "worktree-guard",
1950
1950
  displayName: "Worktree Guard",
1951
- description: "Warns on feature work outside managed repos worktrees and blocks commit/push from shared checkouts",
1951
+ description: "Warns on feature work outside managed repos worktrees and blocks scoped destructive operations",
1952
1952
  version: "0.1.0",
1953
1953
  category: "Git Safety",
1954
1954
  event: "PreToolUse",
1955
- matcher: "Bash|Write|Edit|MultiEdit|NotebookEdit",
1956
- tags: ["git", "worktree", "repos", "multi-agent", "safety"]
1955
+ matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch|mcp__.*)$",
1956
+ tags: ["git", "worktree", "repos", "multi-agent", "safety", "dangerous-ops"]
1957
1957
  },
1958
1958
  {
1959
1959
  name: "checktests",
@@ -4304,7 +4304,7 @@ function installHook(name, options = {}) {
4304
4304
  const shortName = shortHookName(name);
4305
4305
  const meta = getHook(shortName);
4306
4306
  if (meta) {
4307
- const unsupportedTargets = ["claude", "gemini", "codewith"].filter((agentTarget) => getHookEvents(meta).some((event) => !isEventSupported(event, agentTarget)));
4307
+ const unsupportedTargets = ["claude", "codewith"].filter((agentTarget) => getHookEvents(meta).some((event) => !isEventSupported(event, agentTarget)));
4308
4308
  if (unsupportedTargets.length > 0) {
4309
4309
  return {
4310
4310
  hook: shortName,
@@ -4322,14 +4322,6 @@ function installHook(name, options = {}) {
4322
4322
  target: "all"
4323
4323
  };
4324
4324
  }
4325
- const geminiResult = installForTarget(name, scope, overwrite, "gemini", profile);
4326
- if (!geminiResult.success) {
4327
- return {
4328
- ...geminiResult,
4329
- error: `Failed for target 'gemini': ${geminiResult.error}`,
4330
- target: "all"
4331
- };
4332
- }
4333
4325
  const codewithResult = installForTarget(name, scope, overwrite, "codewith", profile, codewithMode, codewithConfigPath);
4334
4326
  if (!codewithResult.success) {
4335
4327
  return {
package/dist/index.js CHANGED
@@ -869,12 +869,12 @@ var HOOKS = [
869
869
  {
870
870
  name: "worktree-guard",
871
871
  displayName: "Worktree Guard",
872
- description: "Warns on feature work outside managed repos worktrees and blocks commit/push from shared checkouts",
872
+ description: "Warns on feature work outside managed repos worktrees and blocks scoped destructive operations",
873
873
  version: "0.1.0",
874
874
  category: "Git Safety",
875
875
  event: "PreToolUse",
876
- matcher: "Bash|Write|Edit|MultiEdit|NotebookEdit",
877
- tags: ["git", "worktree", "repos", "multi-agent", "safety"]
876
+ matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch|mcp__.*)$",
877
+ tags: ["git", "worktree", "repos", "multi-agent", "safety", "dangerous-ops"]
878
878
  },
879
879
  {
880
880
  name: "checktests",
@@ -1630,7 +1630,7 @@ function installHook(name, options = {}) {
1630
1630
  const shortName = shortHookName(name);
1631
1631
  const meta = getHook(shortName);
1632
1632
  if (meta) {
1633
- const unsupportedTargets = ["claude", "gemini", "codewith"].filter((agentTarget) => getHookEvents(meta).some((event) => !isEventSupported(event, agentTarget)));
1633
+ const unsupportedTargets = ["claude", "codewith"].filter((agentTarget) => getHookEvents(meta).some((event) => !isEventSupported(event, agentTarget)));
1634
1634
  if (unsupportedTargets.length > 0) {
1635
1635
  return {
1636
1636
  hook: shortName,
@@ -1648,14 +1648,6 @@ function installHook(name, options = {}) {
1648
1648
  target: "all"
1649
1649
  };
1650
1650
  }
1651
- const geminiResult = installForTarget(name, scope, overwrite, "gemini", profile);
1652
- if (!geminiResult.success) {
1653
- return {
1654
- ...geminiResult,
1655
- error: `Failed for target 'gemini': ${geminiResult.error}`,
1656
- target: "all"
1657
- };
1658
- }
1659
1651
  const codewithResult = installForTarget(name, scope, overwrite, "codewith", profile, codewithMode, codewithConfigPath);
1660
1652
  if (!codewithResult.success) {
1661
1653
  return {
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
- import { dirname, isAbsolute, join, relative, resolve, sep } from "path";
2
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "path";
3
3
  import { homedir, tmpdir } from "os";
4
4
 
5
5
  export interface CodewithHookInput {
@@ -214,8 +214,12 @@ function shellWords(segment: string): string[] {
214
214
  }
215
215
 
216
216
  function expandHome(path: string): string {
217
+ const home = process.env.HOME || homedir();
217
218
  if (path === "~") return homedir();
218
219
  if (path.startsWith("~/")) return join(homedir(), path.slice(2));
220
+ if (path === "$HOME" || path === "${HOME}") return home;
221
+ if (path.startsWith("$HOME/")) return join(home, path.slice("$HOME/".length));
222
+ if (path.startsWith("${HOME}/")) return join(home, path.slice("${HOME}/".length));
219
223
  return path;
220
224
  }
221
225
 
@@ -348,6 +352,523 @@ export function isRiskyOperation(command: string): boolean {
348
352
  return patterns.some((pattern) => pattern.test(command));
349
353
  }
350
354
 
355
+ export interface DangerousOperationMatch {
356
+ block: boolean;
357
+ reason?: string;
358
+ targetPath?: string;
359
+ protectedPath?: string;
360
+ protectedLabel?: string;
361
+ operation?: string;
362
+ }
363
+
364
+ interface ProtectedPathRule {
365
+ root: string;
366
+ label: string;
367
+ mode: "tree" | "root";
368
+ }
369
+
370
+ function splitPathList(value: unknown): string[] {
371
+ if (typeof value === "string") return value.split(":").map((v) => v.trim()).filter(Boolean);
372
+ if (!Array.isArray(value)) return [];
373
+ return value.flatMap((item) => splitPathList(item));
374
+ }
375
+
376
+ function inputPathList(input: CodewithHookInput, ...keys: string[]): string[] {
377
+ const out: string[] = [];
378
+ for (const key of keys) out.push(...splitPathList(input[key]));
379
+ return out;
380
+ }
381
+
382
+ function uniqueResolved(paths: string[], cwd: string): string[] {
383
+ return [...new Set(paths.map((path) => resolveFrom(cwd, path)))];
384
+ }
385
+
386
+ function workspaceRootsFor(input: CodewithHookInput, cwd: string): string[] {
387
+ const home = process.env.HOME || homedir();
388
+ const candidates = [
389
+ ...inputPathList(input, "workspace_roots", "workspaceRoots", "workspace_root", "workspaceRoot"),
390
+ ...splitPathList(process.env.CODEWITH_WORKSPACE_ROOTS),
391
+ ...splitPathList(process.env.HASNA_WORKSPACE_ROOTS),
392
+ join(home, "workspace"),
393
+ join(home, "Workspace"),
394
+ ];
395
+ return uniqueResolved(candidates, cwd);
396
+ }
397
+
398
+ function activeRootsFor(input: CodewithHookInput, cwd: string): string[] {
399
+ const candidates = [
400
+ ...inputPathList(input, "active_repo_roots", "activeRepoRoots", "active_worktree_roots", "activeWorktreeRoots"),
401
+ ...splitPathList(process.env.HASNA_ACTIVE_REPO_ROOTS),
402
+ ...splitPathList(process.env.HASNA_ACTIVE_WORKTREE_ROOTS),
403
+ ];
404
+ return uniqueResolved(candidates, cwd);
405
+ }
406
+
407
+ function hasnaDivisionRuleFor(target: string, workspaceRoot: string): ProtectedPathRule | null {
408
+ const rel = relative(resolve(workspaceRoot), resolve(target));
409
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
410
+ const parts = rel.split(sep).filter(Boolean);
411
+ if (!parts[0]?.startsWith("hasna")) return null;
412
+ if (parts.length === 1) {
413
+ return { root: resolve(workspaceRoot, parts[0]), label: `Hasna division root ${parts[0]}`, mode: "root" };
414
+ }
415
+ if (parts.length === 2) {
416
+ return { root: resolve(workspaceRoot, parts[0], parts[1]), label: `Hasna top-level scope ${parts[0]}/${parts[1]}`, mode: "root" };
417
+ }
418
+ return null;
419
+ }
420
+
421
+ async function protectedRulesFor(input: CodewithHookInput, cwd: string): Promise<ProtectedPathRule[]> {
422
+ const home = process.env.HOME || homedir();
423
+ const rules: ProtectedPathRule[] = [
424
+ { root: join(home, ".hasna"), label: "Hasna state root ~/.hasna", mode: "tree" },
425
+ ];
426
+
427
+ for (const root of workspaceRootsFor(input, cwd)) {
428
+ rules.push({ root, label: "workspace root", mode: "root" });
429
+ }
430
+
431
+ const repoRoot = await gitRepoRoot(cwd);
432
+ if (repoRoot) rules.push({ root: repoRoot, label: "active repository root", mode: "root" });
433
+
434
+ for (const root of activeRootsFor(input, cwd)) {
435
+ rules.push({ root, label: "active repository or worktree root", mode: "root" });
436
+ }
437
+
438
+ return [...new Map(rules.map((rule) => [resolve(rule.root), { ...rule, root: resolve(rule.root) }])).values()];
439
+ }
440
+
441
+ function threatensProtectedPath(targetPath: string, rule: ProtectedPathRule): boolean {
442
+ const target = resolve(targetPath);
443
+ const root = resolve(rule.root);
444
+ if (rule.mode === "tree") {
445
+ return isInsidePath(target, root) || isInsidePath(root, target);
446
+ }
447
+ return target === root || isInsidePath(root, target);
448
+ }
449
+
450
+ function mutatesProtectedPath(targetPath: string, rule: ProtectedPathRule): boolean {
451
+ const target = resolve(targetPath);
452
+ const root = resolve(rule.root);
453
+ if (rule.mode === "tree") return isInsidePath(target, root);
454
+ return target === root;
455
+ }
456
+
457
+ function broadContentWipeBase(targetPath: string): string | null {
458
+ const target = resolve(targetPath);
459
+ const last = basename(target);
460
+ if (!/[*?\[]/.test(last)) return null;
461
+ return dirname(target);
462
+ }
463
+
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 {
479
+ if (rule.label !== "Hasna state root ~/.hasna") return false;
480
+ const leaseRoot = managedLeaseRootForPath(targetPath);
481
+ if (!leaseRoot) return false;
482
+ const target = resolve(targetPath);
483
+ return target !== leaseRoot && isInsidePath(target, leaseRoot);
484
+ }
485
+
486
+ function threatensRule(targetPath: string, rule: ProtectedPathRule): boolean {
487
+ if (shouldSkipHasnaTreeRule(targetPath, rule)) return false;
488
+ const contentBase = broadContentWipeBase(targetPath);
489
+ if (contentBase && mutatesProtectedPath(contentBase, rule)) return true;
490
+ return threatensProtectedPath(targetPath, rule);
491
+ }
492
+
493
+ function mutatesRule(targetPath: string, rule: ProtectedPathRule): boolean {
494
+ if (shouldSkipHasnaTreeRule(targetPath, rule)) return false;
495
+ return mutatesProtectedPath(targetPath, rule);
496
+ }
497
+
498
+ interface DestructiveShellTarget {
499
+ path: string;
500
+ operation: string;
501
+ }
502
+
503
+ function rmCommandTargets(command: string): DestructiveShellTarget[] {
504
+ const targets: DestructiveShellTarget[] = [];
505
+ for (const segment of splitShellSegments(command)) {
506
+ const tokens = shellWords(segment);
507
+ const rmIndex = tokens.findIndex((token) => token === "rm" || token.endsWith("/rm"));
508
+ if (rmIndex === -1) continue;
509
+
510
+ let recursive = false;
511
+ let force = false;
512
+ let afterOptions = false;
513
+ const segmentTargets: string[] = [];
514
+
515
+ for (let i = rmIndex + 1; i < tokens.length; i += 1) {
516
+ const token = tokens[i];
517
+ if (!afterOptions && token === "--") {
518
+ afterOptions = true;
519
+ continue;
520
+ }
521
+ if (!afterOptions && token.startsWith("--")) {
522
+ if (token === "--recursive" || token === "--dir") recursive = true;
523
+ if (token === "--force") force = true;
524
+ continue;
525
+ }
526
+ if (!afterOptions && /^-[A-Za-z]+$/.test(token)) {
527
+ if (token.includes("r") || token.includes("R")) recursive = true;
528
+ if (token.includes("f")) force = true;
529
+ continue;
530
+ }
531
+ segmentTargets.push(token);
532
+ }
533
+
534
+ if (recursive) {
535
+ targets.push(...segmentTargets.map((path) => ({ path, operation: force ? "rm -rf" : "rm -r" })));
536
+ }
537
+ }
538
+ return targets;
539
+ }
540
+
541
+ const RSYNC_OPTIONS_WITH_VALUE = new Set([
542
+ "-e",
543
+ "--rsh",
544
+ "--exclude",
545
+ "--exclude-from",
546
+ "--include",
547
+ "--include-from",
548
+ "--filter",
549
+ "--files-from",
550
+ "--rsync-path",
551
+ "--out-format",
552
+ "--log-file",
553
+ "--password-file",
554
+ "--backup-dir",
555
+ "--partial-dir",
556
+ "--compare-dest",
557
+ "--copy-dest",
558
+ "--link-dest",
559
+ ]);
560
+
561
+ function optionTakesValue(token: string, options: Set<string>): boolean {
562
+ if (options.has(token)) return true;
563
+ const eq = token.indexOf("=");
564
+ return eq === -1 ? false : options.has(token.slice(0, eq));
565
+ }
566
+
567
+ function rsyncDeleteTargets(command: string): DestructiveShellTarget[] {
568
+ const targets: DestructiveShellTarget[] = [];
569
+ for (const segment of splitShellSegments(command)) {
570
+ const tokens = shellWords(segment);
571
+ const rsyncIndex = tokens.findIndex((token) => token === "rsync" || token.endsWith("/rsync"));
572
+ if (rsyncIndex === -1) continue;
573
+
574
+ let hasDelete = false;
575
+ let afterOptions = false;
576
+ const operands: string[] = [];
577
+
578
+ for (let i = rsyncIndex + 1; i < tokens.length; i += 1) {
579
+ const token = tokens[i];
580
+ if (!afterOptions && token === "--") {
581
+ afterOptions = true;
582
+ continue;
583
+ }
584
+ if (!afterOptions && (token === "--delete" || token.startsWith("--delete-"))) {
585
+ hasDelete = true;
586
+ continue;
587
+ }
588
+ if (!afterOptions && token.startsWith("-")) {
589
+ if (optionTakesValue(token, RSYNC_OPTIONS_WITH_VALUE) && !token.includes("=")) i += 1;
590
+ continue;
591
+ }
592
+ operands.push(token);
593
+ }
594
+
595
+ if (hasDelete && operands.length > 0) {
596
+ targets.push({ path: operands[operands.length - 1], operation: "rsync --delete" });
597
+ }
598
+ }
599
+ return targets;
600
+ }
601
+
602
+ function findDestructiveTargets(command: string): DestructiveShellTarget[] {
603
+ const targets: DestructiveShellTarget[] = [];
604
+ for (const segment of splitShellSegments(command)) {
605
+ const tokens = shellWords(segment);
606
+ const findIndex = tokens.findIndex((token) => token === "find" || token.endsWith("/find"));
607
+ if (findIndex === -1) continue;
608
+
609
+ let hasDelete = false;
610
+ let hasExecRm = false;
611
+ const roots: string[] = [];
612
+
613
+ for (let i = findIndex + 1; i < tokens.length; i += 1) {
614
+ const token = tokens[i];
615
+ if (token === "-H" || token === "-L" || token === "-P") continue;
616
+ if (token === "-O") {
617
+ i += 1;
618
+ continue;
619
+ }
620
+ if (token.startsWith("-") || token === "!" || token === "(" || token === ")") break;
621
+ roots.push(token);
622
+ }
623
+
624
+ for (let i = findIndex + 1; i < tokens.length; i += 1) {
625
+ const token = tokens[i];
626
+ if (token === "-delete") hasDelete = true;
627
+ if (token === "-exec" || token === "-execdir") {
628
+ const next = tokens[i + 1];
629
+ if (next === "rm" || next?.endsWith("/rm")) hasExecRm = true;
630
+ }
631
+ }
632
+
633
+ if (hasDelete || hasExecRm) {
634
+ targets.push(...(roots.length > 0 ? roots : ["."]).map((path) => ({
635
+ path,
636
+ operation: hasDelete ? "find -delete" : "find -exec rm",
637
+ })));
638
+ }
639
+ }
640
+ return targets;
641
+ }
642
+
643
+ function gitTargetCwdFromTokens(tokens: string[], baseCwd: string): { gitIndex: number; commandIndex: number; targetCwd: string } | null {
644
+ const gitIndex = tokens.findIndex(isGitToken);
645
+ if (gitIndex === -1) return null;
646
+
647
+ let i = gitIndex + 1;
648
+ let cwd = resolve(baseCwd);
649
+ let gitDir: string | undefined;
650
+ let workTree: string | undefined;
651
+
652
+ while (i < tokens.length) {
653
+ const token = tokens[i];
654
+
655
+ const cDir = shortOptionValue(token, tokens[i + 1], "-C");
656
+ if (cDir) {
657
+ if (cDir.value) cwd = resolveFrom(cwd, cDir.value);
658
+ i += cDir.consumed;
659
+ continue;
660
+ }
661
+
662
+ const config = shortOptionValue(token, tokens[i + 1], "-c");
663
+ if (config) {
664
+ i += config.consumed;
665
+ continue;
666
+ }
667
+
668
+ const gitDirValue = optionValue(token, tokens[i + 1], "--git-dir");
669
+ if (gitDirValue) {
670
+ if (gitDirValue.value) gitDir = resolveFrom(cwd, gitDirValue.value);
671
+ i += gitDirValue.consumed;
672
+ continue;
673
+ }
674
+
675
+ const workTreeValue = optionValue(token, tokens[i + 1], "--work-tree");
676
+ if (workTreeValue) {
677
+ if (workTreeValue.value) workTree = resolveFrom(cwd, workTreeValue.value);
678
+ i += workTreeValue.consumed;
679
+ continue;
680
+ }
681
+
682
+ const namespaceValue = optionValue(token, tokens[i + 1], "--namespace");
683
+ if (namespaceValue) {
684
+ i += namespaceValue.consumed;
685
+ continue;
686
+ }
687
+
688
+ const execPathValue = optionValue(token, tokens[i + 1], "--exec-path");
689
+ if (execPathValue) {
690
+ i += execPathValue.consumed;
691
+ continue;
692
+ }
693
+
694
+ if (token === "--config-env") {
695
+ i += tokens[i + 1] === undefined ? 1 : 2;
696
+ continue;
697
+ }
698
+
699
+ if (token === "--") {
700
+ i += 1;
701
+ continue;
702
+ }
703
+
704
+ if (token.startsWith("-")) {
705
+ i += 1;
706
+ continue;
707
+ }
708
+
709
+ const targetCwd = workTree || (gitDir ? (gitDir.endsWith(`${sep}.git`) || gitDir.endsWith("/.git") ? dirname(gitDir) : gitDir) : cwd);
710
+ return { gitIndex, commandIndex: i, targetCwd };
711
+ }
712
+
713
+ return null;
714
+ }
715
+
716
+ function gitDestructiveTargets(command: string, baseCwd: string): DestructiveShellTarget[] {
717
+ const targets: DestructiveShellTarget[] = [];
718
+ for (const segment of splitShellSegments(command)) {
719
+ const tokens = shellWords(segment);
720
+ const git = gitTargetCwdFromTokens(tokens, baseCwd);
721
+ if (!git) continue;
722
+
723
+ const commandName = tokens[git.commandIndex];
724
+ if (commandName === "reset" && tokens.slice(git.commandIndex + 1).includes("--hard")) {
725
+ targets.push({ path: git.targetCwd, operation: "git reset --hard" });
726
+ continue;
727
+ }
728
+
729
+ if (commandName !== "clean") continue;
730
+
731
+ let force = false;
732
+ let recursive = false;
733
+ const pathspecs: string[] = [];
734
+ for (let i = git.commandIndex + 1; i < tokens.length; i += 1) {
735
+ const token = tokens[i];
736
+ if (token === "--") continue;
737
+ if (token === "-f" || token === "--force") {
738
+ force = true;
739
+ continue;
740
+ }
741
+ if (token === "-d") {
742
+ recursive = true;
743
+ continue;
744
+ }
745
+ if (/^-[A-Za-z]+$/.test(token)) {
746
+ if (token.includes("f")) force = true;
747
+ if (token.includes("d")) recursive = true;
748
+ continue;
749
+ }
750
+ if (token.startsWith("-")) continue;
751
+ pathspecs.push(token);
752
+ }
753
+
754
+ if (force && recursive) {
755
+ targets.push(...(pathspecs.length > 0 ? pathspecs : ["."]).map((path) => ({
756
+ path: resolveFrom(git.targetCwd, path),
757
+ operation: "git clean -xfd",
758
+ })));
759
+ }
760
+ }
761
+ return targets;
762
+ }
763
+
764
+ function destructiveShellTargets(command: string, cwd: string): DestructiveShellTarget[] {
765
+ return [
766
+ ...rmCommandTargets(command),
767
+ ...rsyncDeleteTargets(command),
768
+ ...findDestructiveTargets(command),
769
+ ...gitDestructiveTargets(command, cwd),
770
+ ];
771
+ }
772
+
773
+ function isApplyPatchTool(toolName: string): boolean {
774
+ return toolName === "apply_patch" || toolName === "ApplyPatch" || toolName === "functions.apply_patch";
775
+ }
776
+
777
+ function extractFileToolPaths(input: CodewithHookInput): Array<{ path: string; operation: string }> {
778
+ if (input.hook_event_name !== "PreToolUse") return [];
779
+ const toolName = typeof input.tool_name === "string" ? input.tool_name : "";
780
+ const toolInput = input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {};
781
+ const paths: Array<{ path: string; operation: string }> = [];
782
+
783
+ const addString = (value: unknown, operation: string) => {
784
+ if (typeof value === "string" && value.trim()) paths.push({ path: value, operation });
785
+ };
786
+ const addPathFields = (obj: Record<string, unknown>, operation: string) => {
787
+ for (const key of ["file_path", "path", "target_path", "old_path", "new_path", "notebook_path"]) {
788
+ addString(obj[key], operation);
789
+ }
790
+ };
791
+
792
+ if (["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(toolName)) {
793
+ addPathFields(toolInput, `${toolName} file mutation`);
794
+ } else if (/^(?:apply_patch|ApplyPatch|functions\.apply_patch|mcp__.*|.*(?:write|edit|delete|remove|move).*file.*)$/i.test(toolName)) {
795
+ addPathFields(toolInput, `${toolName} file mutation`);
796
+ }
797
+
798
+ const patch = toolInput.patch ?? toolInput.input ?? toolInput.content ?? toolInput.command;
799
+ if (isApplyPatchTool(toolName) && typeof patch === "string") {
800
+ for (const line of patch.split(/\r?\n/)) {
801
+ const match = line.match(/^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$/);
802
+ if (match?.[1]) paths.push({ path: match[1].trim(), operation: "apply_patch file mutation" });
803
+ }
804
+ }
805
+
806
+ const files = toolInput.files;
807
+ if (Array.isArray(files)) {
808
+ for (const item of files) {
809
+ if (typeof item === "string") addString(item, `${toolName} file mutation`);
810
+ else if (item && typeof item === "object") addPathFields(item as Record<string, unknown>, `${toolName} file mutation`);
811
+ }
812
+ }
813
+
814
+ return paths;
815
+ }
816
+
817
+ function scopedBlockReason(operation: string, targetPath: string, rule: ProtectedPathRule): string {
818
+ return [
819
+ `Blocked scoped dangerous operation: ${operation} targets ${targetPath}.`,
820
+ `Protected scope: ${rule.label} (${rule.root}).`,
821
+ "This guard is scoped; destructive commands outside protected roots are not blocked.",
822
+ ].join(" ");
823
+ }
824
+
825
+ export async function classifyDangerousOperation(input: CodewithHookInput): Promise<DangerousOperationMatch> {
826
+ if (input.hook_event_name !== "PreToolUse") return { block: false };
827
+ const cwd = input.cwd || process.cwd();
828
+ const rules = await protectedRulesFor(input, cwd);
829
+ const workspaceRoots = workspaceRootsFor(input, cwd);
830
+
831
+ if (input.tool_name === "Bash") {
832
+ for (const target of destructiveShellTargets(getCommand(input), cwd)) {
833
+ const targetPath = resolveFrom(cwd, target.path);
834
+ const extraRule = workspaceRoots.map((root) => hasnaDivisionRuleFor(targetPath, root)).find((rule): rule is ProtectedPathRule => Boolean(rule));
835
+ const allRules = extraRule ? [...rules, extraRule] : rules;
836
+ for (const rule of allRules) {
837
+ if (threatensRule(targetPath, rule)) {
838
+ return {
839
+ block: true,
840
+ targetPath,
841
+ protectedPath: rule.root,
842
+ protectedLabel: rule.label,
843
+ operation: target.operation,
844
+ reason: scopedBlockReason(target.operation, targetPath, rule),
845
+ };
846
+ }
847
+ }
848
+ }
849
+ }
850
+
851
+ for (const candidate of extractFileToolPaths(input)) {
852
+ const targetPath = resolveFrom(cwd, candidate.path);
853
+ const extraRule = workspaceRoots.map((root) => hasnaDivisionRuleFor(targetPath, root)).find((rule): rule is ProtectedPathRule => Boolean(rule));
854
+ const allRules = extraRule ? [...rules, extraRule] : rules;
855
+ for (const rule of allRules) {
856
+ if (mutatesRule(targetPath, rule)) {
857
+ return {
858
+ block: true,
859
+ targetPath,
860
+ protectedPath: rule.root,
861
+ protectedLabel: rule.label,
862
+ operation: candidate.operation,
863
+ reason: scopedBlockReason(candidate.operation, targetPath, rule),
864
+ };
865
+ }
866
+ }
867
+ }
868
+
869
+ return { block: false };
870
+ }
871
+
351
872
  export function cacheDir(): string {
352
873
  return process.env.HASNA_HOOKS_CACHE_DIR || join(tmpdir(), "hasna-hooks-codewith");
353
874
  }
@@ -1,6 +1,8 @@
1
- # hook-knowledge-context
1
+ # knowledge-context
2
2
 
3
3
  Codewith lifecycle hook that injects deterministic Knowledge context packs.
4
+ This hook is distributed through the `@hasna/hooks` catalog only; do not publish
5
+ this directory as a standalone npm package.
4
6
 
5
7
  ## Installation
6
8
 
@@ -4,6 +4,11 @@ Codewith-native hook installed as `hooks run pre-bash`.
4
4
 
5
5
  This hook is OSS-safe: optional Hasna CLIs are best-effort and missing CLIs fail open with concise warnings. Security gates only fail closed when a guarded commit/push scan runs successfully and finds possible secrets.
6
6
 
7
+ It also blocks scoped destructive shell operations such as recursive `rm`,
8
+ `rsync --delete`, destructive `find`, and destructive `git clean` / `git reset
9
+ --hard` forms only when the resolved target threatens `~/.hasna`, configured
10
+ workspace roots, Hasna division/scope roots, or active repo/worktree roots.
11
+
7
12
  ## Install for Codewith
8
13
 
9
14
  Prefer renderer-managed configuration through open-configs. @hasna/hooks can emit the TOML fragment:
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {
4
4
  commandExists,
5
+ classifyDangerousOperation,
5
6
  gitCommandInfo,
6
7
  getCommand,
7
8
  isBashPreToolUse,
@@ -52,6 +53,12 @@ export async function evaluate(input: CodewithHookInput): Promise<{ output: Reco
52
53
  const command = getCommand(input);
53
54
  if (!command) return { output: { continue: true }, warnings };
54
55
  const cwd = input.cwd || process.cwd();
56
+
57
+ const dangerous = await classifyDangerousOperation(input);
58
+ if (dangerous.block) {
59
+ return { output: { decision: "block", reason: dangerous.reason }, warnings };
60
+ }
61
+
55
62
  const gitInfo = gitCommandInfo(command, cwd);
56
63
 
57
64
  if (gitInfo) {
@@ -4,6 +4,10 @@ Codewith-native hook installed as `hooks run worktree-guard`.
4
4
 
5
5
  This hook is OSS-safe: optional Hasna CLIs are best-effort and missing CLIs fail open with concise warnings. Security gates only fail closed when a guarded commit/push scan runs successfully and finds possible secrets.
6
6
 
7
+ It blocks scoped destructive shell operations and file-tool-like payloads when
8
+ the resolved target threatens `~/.hasna`, configured workspace roots, Hasna
9
+ division/scope roots, or active repo/worktree roots.
10
+
7
11
  ## Install for Codewith
8
12
 
9
13
  Prefer renderer-managed configuration through open-configs. @hasna/hooks can emit the TOML fragment:
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import {
4
+ classifyDangerousOperation,
4
5
  claimCommand,
5
6
  gitCommandInfo,
6
7
  gitRemoteSlug,
@@ -15,7 +16,7 @@ import {
15
16
  type CodewithHookInput,
16
17
  } from "../../codewith-native-common";
17
18
 
18
- const FEATURE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
19
+ const FEATURE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit", "apply_patch", "ApplyPatch", "functions.apply_patch"]);
19
20
 
20
21
  function isFeatureWork(input: CodewithHookInput, command: string): boolean {
21
22
  if (typeof input.tool_name === "string" && FEATURE_TOOLS.has(input.tool_name)) return true;
@@ -27,6 +28,11 @@ export async function evaluate(input: CodewithHookInput): Promise<{ output: Reco
27
28
  const warnings: string[] = [];
28
29
  if (input.hook_event_name !== "PreToolUse") return { output: { continue: true }, warnings };
29
30
 
31
+ const dangerous = await classifyDangerousOperation(input);
32
+ if (dangerous.block) {
33
+ return { output: { decision: "block", reason: dangerous.reason }, warnings };
34
+ }
35
+
30
36
  const cwd = input.cwd || process.cwd();
31
37
  const command = getCommand(input);
32
38
  const gitInfo = gitCommandInfo(command, cwd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
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": {
@@ -1,52 +0,0 @@
1
- {
2
- "name": "@hasna/hook-knowledge-context",
3
- "version": "0.1.6",
4
- "description": "Codewith hook that injects deterministic Knowledge context packs into lifecycle events",
5
- "type": "module",
6
- "main": "./dist/hook.js",
7
- "exports": {
8
- ".": {
9
- "import": "./dist/hook.js",
10
- "types": "./dist/hook.d.ts"
11
- }
12
- },
13
- "files": [
14
- "dist/hook.js",
15
- "dist/hook.d.ts",
16
- "README.md"
17
- ],
18
- "scripts": {
19
- "build": "rm -rf dist && bun build ./src/hook.ts --outdir ./dist --target bun && tsc -p tsconfig.json --emitDeclarationOnly --declaration --declarationMap false --noEmit false",
20
- "prepublishOnly": "bun run build",
21
- "typecheck": "tsc --noEmit"
22
- },
23
- "keywords": [
24
- "codewith",
25
- "hook",
26
- "knowledge",
27
- "context",
28
- "deterministic",
29
- "session-start",
30
- "user-prompt-submit",
31
- "subagent-start"
32
- ],
33
- "author": "Hasna",
34
- "license": "Apache-2.0",
35
- "repository": {
36
- "type": "git",
37
- "url": "https://github.com/hasna/hooks.git"
38
- },
39
- "publishConfig": {
40
- "access": "public",
41
- "registry": "https://registry.npmjs.org/"
42
- },
43
- "engines": {
44
- "node": ">=18",
45
- "bun": ">=1.0"
46
- },
47
- "devDependencies": {
48
- "@types/bun": "^1.3.8",
49
- "@types/node": "^20",
50
- "typescript": "^5.0.0"
51
- }
52
- }
@@ -1,25 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ESNext",
4
- "module": "ESNext",
5
- "lib": ["ESNext"],
6
- "moduleResolution": "bundler",
7
- "allowImportingTsExtensions": true,
8
- "strict": true,
9
- "esModuleInterop": true,
10
- "skipLibCheck": true,
11
- "forceConsistentCasingInFileNames": true,
12
- "resolveJsonModule": true,
13
- "isolatedModules": true,
14
- "noEmit": true,
15
- "noUnusedLocals": true,
16
- "noUnusedParameters": true,
17
- "declaration": true,
18
- "declarationMap": true,
19
- "outDir": "./dist",
20
- "rootDir": "./src",
21
- "types": ["bun-types", "node"]
22
- },
23
- "include": ["src/**/*"],
24
- "exclude": ["node_modules", "dist"]
25
- }