@hasna/hooks 0.8.0 → 0.9.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.
Files changed (52) hide show
  1. package/bin/index.js +315 -67
  2. package/bin/serve.js +12 -0
  3. package/dist/index.js +52 -4
  4. package/dist/lib/installer.d.ts +40 -1
  5. package/dist/lib/registration.d.ts +75 -0
  6. package/dist/lib/registry.d.ts +14 -0
  7. package/hooks/hook-agent-rules-version-check/README.md +1 -1
  8. package/hooks/hook-trash-guard/README.md +132 -0
  9. package/hooks/hook-trash-guard/package.json +12 -0
  10. package/hooks/hook-trash-guard/src/hook.ts +1114 -0
  11. package/package.json +4 -2
  12. package/hooks/codewith-native-common.test.ts +0 -1935
  13. package/hooks/hook-affected-tests/tsconfig.json +0 -25
  14. package/hooks/hook-agent-rules-version-check/src/hook.test.ts +0 -104
  15. package/hooks/hook-agent-rules-version-check/tsconfig.json +0 -25
  16. package/hooks/hook-announce-start/tsconfig.json +0 -25
  17. package/hooks/hook-announce-stop/tsconfig.json +0 -25
  18. package/hooks/hook-autoformat/tsconfig.json +0 -25
  19. package/hooks/hook-branchprotect/tsconfig.json +0 -25
  20. package/hooks/hook-checkbugs/tsconfig.json +0 -15
  21. package/hooks/hook-checkdocs/tsconfig.json +0 -15
  22. package/hooks/hook-checkfiles/tsconfig.json +0 -15
  23. package/hooks/hook-checklint/tsconfig.json +0 -15
  24. package/hooks/hook-checkpoint/tsconfig.json +0 -25
  25. package/hooks/hook-checksecurity/tsconfig.json +0 -15
  26. package/hooks/hook-checktasks/tsconfig.json +0 -20
  27. package/hooks/hook-checktests/tsconfig.json +0 -15
  28. package/hooks/hook-conflict-detect/tsconfig.json +0 -25
  29. package/hooks/hook-contextrefresh/tsconfig.json +0 -25
  30. package/hooks/hook-desktopnotify/tsconfig.json +0 -25
  31. package/hooks/hook-dm-inject/tsconfig.json +0 -25
  32. package/hooks/hook-envsetup/tsconfig.json +0 -25
  33. package/hooks/hook-failure-to-task/tsconfig.json +0 -25
  34. package/hooks/hook-filelock/tsconfig.json +0 -25
  35. package/hooks/hook-fleet-blockers-gate/src/hook.test.ts +0 -302
  36. package/hooks/hook-fleet-blockers-gate/tsconfig.json +0 -25
  37. package/hooks/hook-fleet-catchup/src/hook.test.ts +0 -156
  38. package/hooks/hook-fleet-catchup/tsconfig.json +0 -25
  39. package/hooks/hook-gitguard/tsconfig.json +0 -25
  40. package/hooks/hook-knowledge-context/src/hook.test.ts +0 -379
  41. package/hooks/hook-packageage/tsconfig.json +0 -25
  42. package/hooks/hook-permissionguard/tsconfig.json +0 -25
  43. package/hooks/hook-phonenotify/tsconfig.json +0 -25
  44. package/hooks/hook-precompact/tsconfig.json +0 -25
  45. package/hooks/hook-protectfiles/tsconfig.json +0 -25
  46. package/hooks/hook-scanoutput/src/hook.test.ts +0 -217
  47. package/hooks/hook-spiral-detector/src/hook.test.ts +0 -72
  48. package/hooks/hook-stylescheck/tsconfig.json +0 -25
  49. package/hooks/hook-typecheck-gate/tsconfig.json +0 -25
  50. package/hooks/hook-workspace-repos-guard/src/hook.test.ts +0 -466
  51. package/hooks/hook-workspace-repos-guard/tsconfig.json +0 -21
  52. package/hooks/mention-context/src/hook.test.ts +0 -68
package/bin/index.js CHANGED
@@ -2083,6 +2083,18 @@ var init_registry = __esm(() => {
2083
2083
  matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$",
2084
2084
  tags: ["workspace", "repos", "structure", "guard", "safety", "orgs", "multi-agent"]
2085
2085
  },
2086
+ {
2087
+ name: "trash-guard",
2088
+ displayName: "Trash Guard",
2089
+ description: "Rewrites rm issued through Bash into `trash guard`, so the delete lands in a recoverable trash store; refuses the delete when there is nothing to redirect to (trash absent, a delete verb it cannot rewrite, or the protected class: /, ~, ~/.hasna, ~/.ssh, ~/.aws)",
2090
+ version: "0.1.0",
2091
+ category: "Git Safety",
2092
+ event: "PreToolUse",
2093
+ matcher: "Bash",
2094
+ tags: ["rm", "delete", "trash", "recoverable", "guard", "safety", "bash"],
2095
+ rewritesInput: true,
2096
+ timeoutSeconds: 5
2097
+ },
2086
2098
  {
2087
2099
  name: "checktests",
2088
2100
  displayName: "Check Tests",
@@ -4477,6 +4489,111 @@ var init_resolve = __esm(() => {
4477
4489
  init_manifest();
4478
4490
  });
4479
4491
 
4492
+ // src/lib/registration.ts
4493
+ function registrationName(command) {
4494
+ if (typeof command !== "string")
4495
+ return;
4496
+ const match = command.trim().match(REGISTRATION_COMMAND_RE);
4497
+ return match ? match[1] ?? match[2] : undefined;
4498
+ }
4499
+ function findStaleRegistrations(settings, file, resolves = (name) => resolveHook(name) !== undefined) {
4500
+ const hooks = settings.hooks;
4501
+ if (!hooks || typeof hooks !== "object")
4502
+ return [];
4503
+ const stale = [];
4504
+ for (const eventKey of Object.keys(hooks)) {
4505
+ const entries = hooks[eventKey];
4506
+ if (!Array.isArray(entries))
4507
+ continue;
4508
+ for (const entry of entries) {
4509
+ if (!Array.isArray(entry?.hooks))
4510
+ continue;
4511
+ for (const hook of entry.hooks) {
4512
+ const name = registrationName(hook?.command);
4513
+ if (name === undefined || resolves(name))
4514
+ continue;
4515
+ stale.push({ file, event: eventKey, hook: name, command: hook.command });
4516
+ }
4517
+ }
4518
+ }
4519
+ return stale;
4520
+ }
4521
+ function matchersOverlap(a, b) {
4522
+ return a === b || a.includes(b) || b.includes(a);
4523
+ }
4524
+ function hookEventsOf(info) {
4525
+ return info.events?.length ? info.events : info.event ? [info.event] : [];
4526
+ }
4527
+ function findRewriteOverlaps(names, lookup) {
4528
+ const rewriting = names.map((name) => ({ name, info: lookup(name) })).filter((row) => Boolean(row.info?.rewritesInput && row.info.matcher));
4529
+ const overlaps = [];
4530
+ for (let i = 0;i < rewriting.length; i++) {
4531
+ for (let j = i + 1;j < rewriting.length; j++) {
4532
+ const a = rewriting[i];
4533
+ const b = rewriting[j];
4534
+ const aEvents = hookEventsOf(a.info);
4535
+ const bEvents = hookEventsOf(b.info);
4536
+ const event = aEvents.find((name) => name === "PreToolUse" && bEvents.includes(name));
4537
+ if (!event)
4538
+ continue;
4539
+ const aMatcher = a.info.matcher;
4540
+ const bMatcher = b.info.matcher;
4541
+ if (!matchersOverlap(aMatcher.toLowerCase(), bMatcher.toLowerCase()))
4542
+ continue;
4543
+ overlaps.push({ hooks: [a.name, b.name], event, matchers: [aMatcher, bMatcher] });
4544
+ }
4545
+ }
4546
+ return overlaps;
4547
+ }
4548
+ function safeMatcherTest(pattern, value) {
4549
+ try {
4550
+ return new RegExp(pattern).test(value);
4551
+ } catch {
4552
+ return false;
4553
+ }
4554
+ }
4555
+ function countSettingsWiring(settings) {
4556
+ const hooks = settings.hooks;
4557
+ if (!hooks || typeof hooks !== "object")
4558
+ return 0;
4559
+ let count = 0;
4560
+ for (const eventKey of Object.keys(hooks)) {
4561
+ const entries = hooks[eventKey];
4562
+ if (!Array.isArray(entries))
4563
+ continue;
4564
+ for (const entry of entries) {
4565
+ if (Array.isArray(entry?.hooks))
4566
+ count += entry.hooks.length;
4567
+ }
4568
+ }
4569
+ return count;
4570
+ }
4571
+ function hookRegisteredInSettings(settings, name, eventSpec, hookMatcher) {
4572
+ const separator = eventSpec.indexOf(":");
4573
+ const eventName = separator === -1 ? eventSpec : eventSpec.slice(0, separator);
4574
+ const specMatcher = separator === -1 ? "" : eventSpec.slice(separator + 1);
4575
+ const matcher = specMatcher || hookMatcher || "";
4576
+ const hooks = settings.hooks?.[eventName];
4577
+ if (!Array.isArray(hooks))
4578
+ return false;
4579
+ return hooks.some((entry) => entry?.hooks?.some((h) => {
4580
+ const match = h?.command?.match(/^hooks run ([\w-]+)/);
4581
+ if (!match || match[1] !== name)
4582
+ return false;
4583
+ if (!matcher)
4584
+ return true;
4585
+ const entryMatcher = typeof entry.matcher === "string" ? entry.matcher : "";
4586
+ if (!entryMatcher)
4587
+ return true;
4588
+ return entryMatcher === matcher || safeMatcherTest(entryMatcher, matcher) || safeMatcherTest(matcher, entryMatcher);
4589
+ }));
4590
+ }
4591
+ var REGISTRATION_COMMAND_RE;
4592
+ var init_registration = __esm(() => {
4593
+ init_resolve();
4594
+ REGISTRATION_COMMAND_RE = /^(?:hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?|hook-([\w-]+))$/;
4595
+ });
4596
+
4480
4597
  // src/db/schema.ts
4481
4598
  var CREATE_HOOK_EVENTS_TABLE = `
4482
4599
  CREATE TABLE IF NOT EXISTS hook_events (
@@ -5090,8 +5207,10 @@ var init_store = __esm(() => {
5090
5207
  var exports_installer = {};
5091
5208
  __export(exports_installer, {
5092
5209
  uninstallHook: () => uninstallHook,
5210
+ scanStaleRegistrations: () => scanStaleRegistrations,
5093
5211
  removeHook: () => removeHook,
5094
5212
  removeCodewithHookEntry: () => removeCodewithHookEntry,
5213
+ pruneStaleRegistrations: () => pruneStaleRegistrations,
5095
5214
  isEventSupported: () => isEventSupported,
5096
5215
  installHooks: () => installHooks,
5097
5216
  installHook: () => installHook,
@@ -5101,6 +5220,7 @@ __export(exports_installer, {
5101
5220
  getRegisteredHooks: () => getRegisteredHooks,
5102
5221
  getInstalledHooks: () => getInstalledHooks,
5103
5222
  getHookPath: () => getHookPath,
5223
+ detectRewriteConflict: () => detectRewriteConflict,
5104
5224
  buildCodewithTomlFragment: () => buildCodewithTomlFragment
5105
5225
  });
5106
5226
  import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync3, rmdirSync, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
@@ -5207,6 +5327,7 @@ function codewithTimeout(name) {
5207
5327
  case "prompt-guard":
5208
5328
  return 3;
5209
5329
  case "worktree-guard":
5330
+ case "trash-guard":
5210
5331
  case "stop-sync":
5211
5332
  return 5;
5212
5333
  default:
@@ -5225,6 +5346,8 @@ function codewithStatusMessage(name) {
5225
5346
  return "Checking prompt safety";
5226
5347
  case "worktree-guard":
5227
5348
  return "Checking worktree safety";
5349
+ case "trash-guard":
5350
+ return "Redirecting rm into trash";
5228
5351
  case "stop-sync":
5229
5352
  return "Syncing turn-end heartbeat";
5230
5353
  default:
@@ -5292,14 +5415,32 @@ function detectConflict(name, scope, target) {
5292
5415
  continue;
5293
5416
  if (!getHookEvents(existing).some((event) => events.has(event)))
5294
5417
  continue;
5295
- const a = meta.matcher.toLowerCase();
5296
- const b = existing.matcher.toLowerCase();
5297
- if (a === b || a.includes(b) || b.includes(a)) {
5418
+ if (matchersOverlap(meta.matcher.toLowerCase(), existing.matcher.toLowerCase())) {
5298
5419
  return `conflicts with '${existingName}' (same event ${meta.event}, overlapping matcher '${existing.matcher}')`;
5299
5420
  }
5300
5421
  }
5301
5422
  return;
5302
5423
  }
5424
+ function detectRewriteConflict(name, scope, target, lookup = getHook) {
5425
+ const meta = resolveHookMeta(name);
5426
+ if (!meta?.matcher || !meta.rewritesInput)
5427
+ return;
5428
+ if (!getHookEvents(meta).includes("PreToolUse"))
5429
+ return;
5430
+ for (const existingName of getRegisteredHooksForTarget(scope, target)) {
5431
+ if (existingName === name)
5432
+ continue;
5433
+ const existing = lookup(existingName);
5434
+ if (!existing?.matcher || !existing.rewritesInput)
5435
+ continue;
5436
+ if (!getHookEvents(existing).includes("PreToolUse"))
5437
+ continue;
5438
+ if (!matchersOverlap(meta.matcher.toLowerCase(), existing.matcher.toLowerCase()))
5439
+ continue;
5440
+ return `both '${name}' and '${existingName}' rewrite the tool input on overlapping PreToolUse matchers ('${meta.matcher}' / '${existing.matcher}'). Only one rewrite per tool call is applied, so one guard would silently disappear \u2014 install one of them, or narrow a matcher so they no longer overlap.`;
5441
+ }
5442
+ return;
5443
+ }
5303
5444
  function installForTarget(name, scope, overwrite, target, profile, codewithMode = "fragment", codewithConfigPath) {
5304
5445
  const shortName = shortHookName(name);
5305
5446
  if (!hookExists(shortName)) {
@@ -5359,6 +5500,10 @@ function installForTarget(name, scope, overwrite, target, profile, codewithMode
5359
5500
  return { hook: shortName, success: false, error: "Already installed. Use --overwrite to replace.", scope, target };
5360
5501
  }
5361
5502
  const conflict = detectConflict(shortName, scope, target);
5503
+ const rewriteConflict = detectRewriteConflict(shortName, scope, target);
5504
+ if (rewriteConflict) {
5505
+ return { hook: shortName, success: false, error: `Refused: ${rewriteConflict}`, scope, target };
5506
+ }
5362
5507
  try {
5363
5508
  registerHook(shortName, scope, target, profile);
5364
5509
  return { hook: shortName, success: true, scope, target, ...conflict ? { conflict } : {} };
@@ -5430,8 +5575,12 @@ function registerHook(name, scope = "global", target = "claude", profile) {
5430
5575
  for (const eventKey of uniqueEventKeys) {
5431
5576
  if (!settings.hooks[eventKey])
5432
5577
  settings.hooks[eventKey] = [];
5578
+ const hookEntry = { type: "command", command: hookCommand };
5579
+ if (typeof meta.timeoutSeconds === "number" && meta.timeoutSeconds > 0) {
5580
+ hookEntry.timeout = meta.timeoutSeconds;
5581
+ }
5433
5582
  const entry = {
5434
- hooks: [{ type: "command", command: hookCommand }]
5583
+ hooks: [hookEntry]
5435
5584
  };
5436
5585
  if (meta.matcher) {
5437
5586
  entry.matcher = meta.matcher;
@@ -5515,6 +5664,74 @@ function removeHookForTarget(name, scope, target) {
5515
5664
  unregisterHook(name, scope, target);
5516
5665
  return true;
5517
5666
  }
5667
+ function readSettingsAt(path) {
5668
+ try {
5669
+ if (!existsSync10(path))
5670
+ return;
5671
+ const raw = readFileSync4(path, "utf-8");
5672
+ return { raw, settings: JSON.parse(raw) };
5673
+ } catch {
5674
+ return;
5675
+ }
5676
+ }
5677
+ function scanStaleRegistrations(scope = "global") {
5678
+ const stale = [];
5679
+ for (const target of JSON_SETTINGS_TARGETS) {
5680
+ const path = getSettingsPath(scope, target);
5681
+ const parsed = readSettingsAt(path);
5682
+ if (!parsed)
5683
+ continue;
5684
+ stale.push(...findStaleRegistrations(parsed.settings, path));
5685
+ }
5686
+ return stale;
5687
+ }
5688
+ function pruneStaleRegistrations(scope = "global") {
5689
+ const results = [];
5690
+ for (const target of JSON_SETTINGS_TARGETS) {
5691
+ const path = getSettingsPath(scope, target);
5692
+ const parsed = readSettingsAt(path);
5693
+ if (!parsed)
5694
+ continue;
5695
+ const stale = findStaleRegistrations(parsed.settings, path);
5696
+ if (stale.length === 0)
5697
+ continue;
5698
+ const staleCommandsByEvent = new Map;
5699
+ for (const finding of stale) {
5700
+ const commands = staleCommandsByEvent.get(finding.event) ?? new Set;
5701
+ commands.add(finding.command);
5702
+ staleCommandsByEvent.set(finding.event, commands);
5703
+ }
5704
+ const settings = parsed.settings;
5705
+ for (const [eventKey, commands] of staleCommandsByEvent) {
5706
+ const entries = settings.hooks?.[eventKey];
5707
+ if (!Array.isArray(entries))
5708
+ continue;
5709
+ const keptEntries = [];
5710
+ for (const entry of entries) {
5711
+ if (!Array.isArray(entry?.hooks)) {
5712
+ keptEntries.push(entry);
5713
+ continue;
5714
+ }
5715
+ const keptHooks = entry.hooks.filter((hook) => !(typeof hook?.command === "string" && commands.has(hook.command)));
5716
+ if (keptHooks.length === entry.hooks.length)
5717
+ keptEntries.push(entry);
5718
+ else if (keptHooks.length > 0)
5719
+ keptEntries.push({ ...entry, hooks: keptHooks });
5720
+ }
5721
+ if (keptEntries.length > 0)
5722
+ settings.hooks[eventKey] = keptEntries;
5723
+ else
5724
+ delete settings.hooks[eventKey];
5725
+ }
5726
+ if (settings.hooks && Object.keys(settings.hooks).length === 0)
5727
+ delete settings.hooks;
5728
+ const backupPath = `${path}.bak`;
5729
+ writeFileSync3(backupPath, parsed.raw);
5730
+ writeSettings(settings, scope, target);
5731
+ results.push({ file: path, removed: stale, backupPath });
5732
+ }
5733
+ return results;
5734
+ }
5518
5735
  function removeCodewithHookEntry(configText, name) {
5519
5736
  const sections = configText.split(/\n\s*\n/);
5520
5737
  const nameRe = new RegExp(`^command\\s*=\\s*"hooks run ${escapeRegExp(name)}(?:\\s+--profile\\s+[\\w-]+)?"$`);
@@ -5659,11 +5876,12 @@ function uninstallHook(name, scope = "global", target = "claude") {
5659
5876
  registrationsRemaining
5660
5877
  };
5661
5878
  }
5662
- var __dirname2, HOOKS_DIR, EVENT_MAP, getInstalledHooks;
5879
+ var __dirname2, HOOKS_DIR, EVENT_MAP, getInstalledHooks, JSON_SETTINGS_TARGETS;
5663
5880
  var init_installer = __esm(() => {
5664
5881
  init_registry();
5665
5882
  init_resolve();
5666
5883
  init_manifest();
5884
+ init_registration();
5667
5885
  init_store();
5668
5886
  __dirname2 = dirname3(fileURLToPath2(import.meta.url));
5669
5887
  HOOKS_DIR = existsSync10(join10(__dirname2, "..", "..", "hooks", "hook-gitguard")) ? join10(__dirname2, "..", "..", "hooks") : join10(__dirname2, "..", "hooks");
@@ -5700,53 +5918,9 @@ var init_installer = __esm(() => {
5700
5918
  }
5701
5919
  };
5702
5920
  getInstalledHooks = getRegisteredHooks;
5921
+ JSON_SETTINGS_TARGETS = ["claude", "gemini"];
5703
5922
  });
5704
5923
 
5705
- // src/lib/registration.ts
5706
- function safeMatcherTest(pattern, value) {
5707
- try {
5708
- return new RegExp(pattern).test(value);
5709
- } catch {
5710
- return false;
5711
- }
5712
- }
5713
- function countSettingsWiring(settings) {
5714
- const hooks = settings.hooks;
5715
- if (!hooks || typeof hooks !== "object")
5716
- return 0;
5717
- let count = 0;
5718
- for (const eventKey of Object.keys(hooks)) {
5719
- const entries = hooks[eventKey];
5720
- if (!Array.isArray(entries))
5721
- continue;
5722
- for (const entry of entries) {
5723
- if (Array.isArray(entry?.hooks))
5724
- count += entry.hooks.length;
5725
- }
5726
- }
5727
- return count;
5728
- }
5729
- function hookRegisteredInSettings(settings, name, eventSpec, hookMatcher) {
5730
- const separator = eventSpec.indexOf(":");
5731
- const eventName = separator === -1 ? eventSpec : eventSpec.slice(0, separator);
5732
- const specMatcher = separator === -1 ? "" : eventSpec.slice(separator + 1);
5733
- const matcher = specMatcher || hookMatcher || "";
5734
- const hooks = settings.hooks?.[eventName];
5735
- if (!Array.isArray(hooks))
5736
- return false;
5737
- return hooks.some((entry) => entry?.hooks?.some((h) => {
5738
- const match = h?.command?.match(/^hooks run ([\w-]+)/);
5739
- if (!match || match[1] !== name)
5740
- return false;
5741
- if (!matcher)
5742
- return true;
5743
- const entryMatcher = typeof entry.matcher === "string" ? entry.matcher : "";
5744
- if (!entryMatcher)
5745
- return true;
5746
- return entryMatcher === matcher || safeMatcherTest(entryMatcher, matcher) || safeMatcherTest(matcher, entryMatcher);
5747
- }));
5748
- }
5749
-
5750
5924
  // src/lib/redact.ts
5751
5925
  function redactText(value) {
5752
5926
  let out = value;
@@ -5922,7 +6096,7 @@ import { isIP as isIP2 } from "net";
5922
6096
  import { spawnSync } from "child_process";
5923
6097
  import { closeSync, fstatSync, openSync, readFileSync as readFileSync6 } from "fs";
5924
6098
  import { O_NOFOLLOW, O_NONBLOCK, O_RDONLY } from "constants";
5925
- import { createRequire } from "module";
6099
+ import { createRequire as createRequire2 } from "module";
5926
6100
  import { hostname as osHostname } from "os";
5927
6101
  import { isAbsolute as isAbsolute2, join as join12 } from "path";
5928
6102
  function envToken(name) {
@@ -6043,7 +6217,7 @@ function readAppConfigFile(path) {
6043
6217
  const uid = process.getuid?.() ?? process.geteuid?.();
6044
6218
  if (uid !== undefined && before.uid !== uid)
6045
6219
  unsafe("the file is not owned by the current user");
6046
- if (before.size > MAX_CREDENTIAL_FILE_BYTES)
6220
+ if (before.size > MAX_CREDENTIAL_FILE_BYTES2)
6047
6221
  unsafe("the file exceeds the size limit");
6048
6222
  const bytes = readFileSync6(fd);
6049
6223
  const after = fstatSync(fd);
@@ -6127,13 +6301,13 @@ function sealCredential(fields) {
6127
6301
  configurable: false
6128
6302
  });
6129
6303
  }
6130
- Object.defineProperty(sealed, INSPECT_CUSTOM, {
6304
+ Object.defineProperty(sealed, INSPECT_CUSTOM2, {
6131
6305
  value: () => ({ ...visible, apiKey: "[redacted]" }),
6132
6306
  enumerable: false,
6133
6307
  writable: false,
6134
6308
  configurable: false
6135
6309
  });
6136
- Object.defineProperty(sealed, CREDENTIAL_SEAL, {
6310
+ Object.defineProperty(sealed, CREDENTIAL_SEAL2, {
6137
6311
  value: true,
6138
6312
  enumerable: false,
6139
6313
  writable: false,
@@ -6152,7 +6326,7 @@ function firstEnvValue(env, keys) {
6152
6326
  return null;
6153
6327
  }
6154
6328
  function isAmbientEnvironment(env) {
6155
- return env === process.env || env[AMBIENT_ENVIRONMENT] === true;
6329
+ return env === process.env || env[AMBIENT_ENVIRONMENT2] === true;
6156
6330
  }
6157
6331
  function defaultKeychainRunner(argv) {
6158
6332
  const result = spawnSync(KEYCHAIN_SECURITY_BIN, [...argv], {
@@ -6246,7 +6420,7 @@ function snapshotClientEnvironment(name, env) {
6246
6420
  snapshot[key] = descriptor.value;
6247
6421
  }
6248
6422
  if (ambient) {
6249
- Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT, {
6423
+ Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT2, {
6250
6424
  value: true,
6251
6425
  enumerable: false,
6252
6426
  writable: false,
@@ -6597,7 +6771,7 @@ function credentialDiskSourcesForMessage(name, env) {
6597
6771
  const paths = credentialDiskSources(name, env);
6598
6772
  return paths.length > 0 ? paths.join(" or ") : "<no HOME or HASNA_HOME set in this environment, so no credential file was consulted>";
6599
6773
  }
6600
- var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE", CredentialResolutionError, CredentialFileUnsafeError, HASNA_HOME_ENV_KEY = "HASNA_HOME", HASNA_CONFIG_HOME_ENV_KEY = "HASNA_CONFIG_HOME", KEYCHAIN_STATION_ENV_KEY = "HASNA_STATION", HASNA_HOME_DIR = ".hasna", CONFIG_SUBDIR = "config", CREDENTIALS_FILE = "credentials", KEYCHAIN_SECURITY_BIN = "/usr/bin/security", KEYCHAIN_SERVICE_PREFIX = "hasna.credentials", KEYCHAIN_ITEM_NOT_FOUND_STATUS = 44, KEYCHAIN_SPAWN_TIMEOUT_MS = 1e4, MAX_CREDENTIAL_FILE_BYTES, SAFE_APP_SLUG, SAFE_PROFILE, ILLEGAL_IN_HEADER_VALUE, VAULT_POINTER_SHAPE, CREDENTIAL_SHAPED_KEY, INSPECT_CUSTOM, CREDENTIAL_SEAL, AMBIENT_ENVIRONMENT, SECRETS_PACKAGE_SPECIFIER, requireSecretsSdk, DEFAULT_FLEET_GATEWAY_ORIGIN = "https://api.hasna.com", DEFAULT_AUTHORITY_SOURCE = "default", ASCII_CONTROL_PATTERN, DNS_LABEL_PATTERN, ClientTransportConfigurationError, IDEMPOTENT_METHODS, AUTHORITY_OVERRIDE_HEADERS;
6774
+ var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE", CredentialResolutionError, CredentialFileUnsafeError, HASNA_HOME_ENV_KEY = "HASNA_HOME", HASNA_CONFIG_HOME_ENV_KEY = "HASNA_CONFIG_HOME", KEYCHAIN_STATION_ENV_KEY = "HASNA_STATION", HASNA_HOME_DIR = ".hasna", CONFIG_SUBDIR = "config", CREDENTIALS_FILE = "credentials", KEYCHAIN_SECURITY_BIN = "/usr/bin/security", KEYCHAIN_SERVICE_PREFIX = "hasna.credentials", KEYCHAIN_ITEM_NOT_FOUND_STATUS = 44, KEYCHAIN_SPAWN_TIMEOUT_MS = 1e4, MAX_CREDENTIAL_FILE_BYTES2, SAFE_APP_SLUG, SAFE_PROFILE, ILLEGAL_IN_HEADER_VALUE, VAULT_POINTER_SHAPE, CREDENTIAL_SHAPED_KEY, INSPECT_CUSTOM2, CREDENTIAL_SEAL2, AMBIENT_ENVIRONMENT2, SECRETS_PACKAGE_SPECIFIER2, requireSecretsSdk2, DEFAULT_FLEET_GATEWAY_ORIGIN = "https://api.hasna.com", DEFAULT_AUTHORITY_SOURCE = "default", ASCII_CONTROL_PATTERN, DNS_LABEL_PATTERN, ClientTransportConfigurationError, IDEMPOTENT_METHODS2, AUTHORITY_OVERRIDE_HEADERS2;
6601
6775
  var init_transport = __esm(() => {
6602
6776
  CredentialResolutionError = class CredentialResolutionError extends Error {
6603
6777
  appName;
@@ -6617,17 +6791,17 @@ var init_transport = __esm(() => {
6617
6791
  this.path = path;
6618
6792
  }
6619
6793
  };
6620
- MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
6794
+ MAX_CREDENTIAL_FILE_BYTES2 = 64 * 1024;
6621
6795
  SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
6622
6796
  SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
6623
6797
  ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
6624
6798
  VAULT_POINTER_SHAPE = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
6625
6799
  CREDENTIAL_SHAPED_KEY = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
6626
- INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
6627
- CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
6628
- AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
6629
- SECRETS_PACKAGE_SPECIFIER = "@hasna/" + "secrets";
6630
- requireSecretsSdk = createRequire(import.meta.url);
6800
+ INSPECT_CUSTOM2 = Symbol.for("nodejs.util.inspect.custom");
6801
+ CREDENTIAL_SEAL2 = Symbol.for("hasna:contracts:sealedCredential");
6802
+ AMBIENT_ENVIRONMENT2 = Symbol.for("hasna:contracts:ambientClientEnvironment");
6803
+ SECRETS_PACKAGE_SPECIFIER2 = "@hasna/" + "secrets";
6804
+ requireSecretsSdk2 = createRequire2(import.meta.url);
6631
6805
  ASCII_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
6632
6806
  DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
6633
6807
  ClientTransportConfigurationError = class ClientTransportConfigurationError extends Error {
@@ -6640,8 +6814,8 @@ var init_transport = __esm(() => {
6640
6814
  this.sources = Object.freeze([...sources]);
6641
6815
  }
6642
6816
  };
6643
- IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
6644
- AUTHORITY_OVERRIDE_HEADERS = new Set([
6817
+ IDEMPOTENT_METHODS2 = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
6818
+ AUTHORITY_OVERRIDE_HEADERS2 = new Set([
6645
6819
  "host",
6646
6820
  ":authority",
6647
6821
  "forwarded",
@@ -8785,6 +8959,13 @@ function createHooksServer() {
8785
8959
  if (hookHealthy)
8786
8960
  healthy.push(name);
8787
8961
  }
8962
+ for (const overlap of findRewriteOverlaps(registered, (name) => getHook(name))) {
8963
+ issues.push({
8964
+ hook: overlap.hooks.join(" + "),
8965
+ issue: `Both rewrite the tool input on overlapping ${overlap.event} matchers ('${overlap.matchers[0]}' / '${overlap.matchers[1]}'); only one rewrite is applied per tool call, so one of these guards is silently disarmed. Remove one, or narrow a matcher so they no longer overlap.`,
8966
+ severity: "error"
8967
+ });
8968
+ }
8788
8969
  return { content: [{ type: "text", text: JSON.stringify({ healthy: issues.length === 0, healthy_hooks: healthy, issues, registered, scope }) }] };
8789
8970
  });
8790
8971
  defineTool("hooks_categories", "List all hook categories with counts", {}, async () => {
@@ -9517,6 +9698,7 @@ var init_server = __esm(() => {
9517
9698
  init_profiles();
9518
9699
  init_manifest();
9519
9700
  init_resolve();
9701
+ init_registration();
9520
9702
  init_store();
9521
9703
  init_redact();
9522
9704
  init_storage();
@@ -9612,6 +9794,7 @@ import { spawn } from "child_process";
9612
9794
  import { request as nodeHttpRequest } from "http";
9613
9795
  import { request as nodeHttpsRequest } from "https";
9614
9796
  import { randomUUID as randomUUID2 } from "crypto";
9797
+ import { createRequire } from "module";
9615
9798
  function getPathValue(input, path) {
9616
9799
  return path.split(".").reduce((value, part) => {
9617
9800
  if (value && typeof value === "object" && part in value) {
@@ -10709,6 +10892,22 @@ function setPath(input, path, replacement) {
10709
10892
  if (last && last in cursor)
10710
10893
  cursor[last] = replacement;
10711
10894
  }
10895
+ var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
10896
+ var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
10897
+ var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
10898
+ var AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
10899
+ var SECRETS_PACKAGE_SPECIFIER = "@hasna/" + "secrets";
10900
+ var requireSecretsSdk = createRequire(import.meta.url);
10901
+ var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
10902
+ var AUTHORITY_OVERRIDE_HEADERS = new Set([
10903
+ "host",
10904
+ ":authority",
10905
+ "forwarded",
10906
+ "x-forwarded-host",
10907
+ "x-original-host"
10908
+ ]);
10909
+ var MAX_ENVELOPE_BYTES = 256 * 1024;
10910
+ var MAX_REQUEST_BYTES = MAX_ENVELOPE_BYTES * 2 + 8192;
10712
10911
  function createEvent(input) {
10713
10912
  return {
10714
10913
  id: input.id ?? randomUUID2(),
@@ -12427,6 +12626,7 @@ function App({ initialHooks, overwrite = false }) {
12427
12626
  // src/cli/index.tsx
12428
12627
  init_registry();
12429
12628
  init_installer();
12629
+ init_registration();
12430
12630
  init_redact();
12431
12631
  init_profiles();
12432
12632
  init_manifest();
@@ -13192,11 +13392,21 @@ ${meta.displayName}
13192
13392
  console.log(chalk2.dim(" \u25CB Not installed in project"));
13193
13393
  }
13194
13394
  });
13195
- program2.command("doctor").option("-g, --global", "Check global settings", false).option("-p, --project", "Check project settings", false).option("-j, --json", "Output as JSON", false).description("Check health of installed hooks").action((options) => {
13395
+ program2.command("doctor").option("-g, --global", "Check global settings", false).option("-p, --project", "Check project settings", false).option("-f, --fix", "Remove stale registrations (a .bak is written beside each settings file)", false).option("-j, --json", "Output as JSON", false).description("Check health of installed hooks; --fix removes registrations whose hook cannot resolve").action((options) => {
13196
13396
  const scope = resolveScope(options);
13197
13397
  const settingsPath = getSettingsPath(scope);
13198
13398
  const issues = [];
13199
13399
  const healthy = [];
13400
+ const fixed = options.fix ? pruneStaleRegistrations(scope) : [];
13401
+ const stale = scanStaleRegistrations(scope);
13402
+ for (const finding of stale) {
13403
+ issues.push({
13404
+ hook: finding.hook,
13405
+ issue: `Stale registration in ${finding.file} (${finding.event}): \`${finding.command}\` does not resolve to a hook`,
13406
+ severity: "error"
13407
+ });
13408
+ }
13409
+ const staleNames = new Set(stale.map((finding) => finding.hook));
13200
13410
  const settingsExist = existsSync15(settingsPath);
13201
13411
  if (!settingsExist) {
13202
13412
  issues.push({ hook: "(settings)", issue: `${settingsPath} not found`, severity: "warning" });
@@ -13210,6 +13420,8 @@ program2.command("doctor").option("-g, --global", "Check global settings", false
13210
13420
  } catch {}
13211
13421
  }
13212
13422
  for (const name of registered) {
13423
+ if (staleNames.has(name))
13424
+ continue;
13213
13425
  const custom = readCustomManifest(name);
13214
13426
  const meta = custom ? resolveHookMeta(name) : getHook(name);
13215
13427
  let hookHealthy = true;
@@ -13245,17 +13457,53 @@ program2.command("doctor").option("-g, --global", "Check global settings", false
13245
13457
  healthy.push(name);
13246
13458
  }
13247
13459
  }
13460
+ for (const overlap of findRewriteOverlaps(registered, (name) => getHook(name))) {
13461
+ issues.push({
13462
+ hook: overlap.hooks.join(" + "),
13463
+ issue: `Both rewrite the tool input on overlapping ${overlap.event} matchers ('${overlap.matchers[0]}' / '${overlap.matchers[1]}'); only one rewrite is applied per tool call, so one of these guards is silently disarmed. Remove one, or narrow a matcher so they no longer overlap.`,
13464
+ severity: "error"
13465
+ });
13466
+ }
13248
13467
  if (options.json) {
13249
- console.log(JSON.stringify({ healthy: issues.length === 0, healthy_hooks: healthy, issues, registered, wiring_count: wiringCount, scope }));
13468
+ console.log(JSON.stringify({
13469
+ healthy: issues.length === 0,
13470
+ healthy_hooks: healthy,
13471
+ issues,
13472
+ stale,
13473
+ fixed: fixed.flatMap((result) => result.removed),
13474
+ backups: fixed.map((result) => result.backupPath),
13475
+ registered,
13476
+ wiring_count: wiringCount,
13477
+ scope
13478
+ }));
13479
+ if (issues.some((issue) => issue.severity === "error"))
13480
+ process.exitCode = 1;
13250
13481
  return;
13251
13482
  }
13252
13483
  console.log(chalk2.bold(`
13253
13484
  Hook Health Check (${scope})
13254
13485
  `));
13486
+ if (fixed.length > 0) {
13487
+ for (const result of fixed) {
13488
+ console.log(chalk2.green(` \u2713 removed ${result.removed.length} stale registration(s) from ${result.file}`));
13489
+ for (const finding of result.removed) {
13490
+ console.log(chalk2.dim(` ${finding.hook} (${finding.event}: ${finding.command})`));
13491
+ }
13492
+ console.log(chalk2.dim(` backup: ${result.backupPath}`));
13493
+ }
13494
+ console.log();
13495
+ }
13255
13496
  if (registered.length === 0) {
13256
13497
  console.log(chalk2.dim(" No hooks registered."));
13257
13498
  console.log(chalk2.dim(" Run: hooks install gitguard"));
13258
13499
  console.log(chalk2.dim(` (checked 0 registered \`hooks run\` entries of ${wiringCount} settings wiring entries; direct-path wiring outside the registered surface is not covered by this check)`));
13500
+ if (issues.length > 0) {
13501
+ console.log();
13502
+ for (const issue of issues) {
13503
+ const icon = issue.severity === "error" ? chalk2.red("\u2717") : chalk2.yellow("!");
13504
+ console.log(` ${icon} ${chalk2.cyan(issue.hook)}: ${issue.issue}`);
13505
+ }
13506
+ }
13259
13507
  if (issues.length > 0)
13260
13508
  process.exitCode = 1;
13261
13509
  return;
package/bin/serve.js CHANGED
@@ -182,6 +182,18 @@ var HOOKS = [
182
182
  matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$",
183
183
  tags: ["workspace", "repos", "structure", "guard", "safety", "orgs", "multi-agent"]
184
184
  },
185
+ {
186
+ name: "trash-guard",
187
+ displayName: "Trash Guard",
188
+ description: "Rewrites rm issued through Bash into `trash guard`, so the delete lands in a recoverable trash store; refuses the delete when there is nothing to redirect to (trash absent, a delete verb it cannot rewrite, or the protected class: /, ~, ~/.hasna, ~/.ssh, ~/.aws)",
189
+ version: "0.1.0",
190
+ category: "Git Safety",
191
+ event: "PreToolUse",
192
+ matcher: "Bash",
193
+ tags: ["rm", "delete", "trash", "recoverable", "guard", "safety", "bash"],
194
+ rewritesInput: true,
195
+ timeoutSeconds: 5
196
+ },
185
197
  {
186
198
  name: "checktests",
187
199
  displayName: "Check Tests",