@hasna/hooks 0.8.0 → 0.9.1

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 (63) hide show
  1. package/README.md +33 -5
  2. package/bin/hooks-mcp.js +4862 -0
  3. package/bin/index.js +592 -170
  4. package/bin/serve.js +33 -10
  5. package/dist/db/index.d.ts +8 -0
  6. package/dist/index.d.ts +4 -1
  7. package/dist/index.js +2870 -2709
  8. package/dist/lib/db-writer.d.ts +30 -1
  9. package/dist/lib/installer.d.ts +40 -1
  10. package/dist/lib/local-opt-in.d.ts +26 -0
  11. package/dist/lib/registration.d.ts +75 -0
  12. package/dist/lib/registry.d.ts +14 -0
  13. package/dist/lib/sync.d.ts +17 -1
  14. package/dist/sdk/index.d.ts +103 -0
  15. package/dist/sdk/index.js +1050 -0
  16. package/dist/storage.js +15 -1
  17. package/hooks/hook-agent-rules-version-check/README.md +1 -1
  18. package/hooks/hook-trash-guard/README.md +147 -0
  19. package/hooks/hook-trash-guard/package.json +12 -0
  20. package/hooks/hook-trash-guard/src/hook.ts +1142 -0
  21. package/package.json +10 -3
  22. package/scripts/validate-package.ts +31 -4
  23. package/hooks/codewith-native-common.test.ts +0 -1935
  24. package/hooks/hook-affected-tests/tsconfig.json +0 -25
  25. package/hooks/hook-agent-rules-version-check/src/hook.test.ts +0 -104
  26. package/hooks/hook-agent-rules-version-check/tsconfig.json +0 -25
  27. package/hooks/hook-announce-start/tsconfig.json +0 -25
  28. package/hooks/hook-announce-stop/tsconfig.json +0 -25
  29. package/hooks/hook-autoformat/tsconfig.json +0 -25
  30. package/hooks/hook-branchprotect/tsconfig.json +0 -25
  31. package/hooks/hook-checkbugs/tsconfig.json +0 -15
  32. package/hooks/hook-checkdocs/tsconfig.json +0 -15
  33. package/hooks/hook-checkfiles/tsconfig.json +0 -15
  34. package/hooks/hook-checklint/tsconfig.json +0 -15
  35. package/hooks/hook-checkpoint/tsconfig.json +0 -25
  36. package/hooks/hook-checksecurity/tsconfig.json +0 -15
  37. package/hooks/hook-checktasks/tsconfig.json +0 -20
  38. package/hooks/hook-checktests/tsconfig.json +0 -15
  39. package/hooks/hook-conflict-detect/tsconfig.json +0 -25
  40. package/hooks/hook-contextrefresh/tsconfig.json +0 -25
  41. package/hooks/hook-desktopnotify/tsconfig.json +0 -25
  42. package/hooks/hook-dm-inject/tsconfig.json +0 -25
  43. package/hooks/hook-envsetup/tsconfig.json +0 -25
  44. package/hooks/hook-failure-to-task/tsconfig.json +0 -25
  45. package/hooks/hook-filelock/tsconfig.json +0 -25
  46. package/hooks/hook-fleet-blockers-gate/src/hook.test.ts +0 -302
  47. package/hooks/hook-fleet-blockers-gate/tsconfig.json +0 -25
  48. package/hooks/hook-fleet-catchup/src/hook.test.ts +0 -156
  49. package/hooks/hook-fleet-catchup/tsconfig.json +0 -25
  50. package/hooks/hook-gitguard/tsconfig.json +0 -25
  51. package/hooks/hook-knowledge-context/src/hook.test.ts +0 -379
  52. package/hooks/hook-packageage/tsconfig.json +0 -25
  53. package/hooks/hook-permissionguard/tsconfig.json +0 -25
  54. package/hooks/hook-phonenotify/tsconfig.json +0 -25
  55. package/hooks/hook-precompact/tsconfig.json +0 -25
  56. package/hooks/hook-protectfiles/tsconfig.json +0 -25
  57. package/hooks/hook-scanoutput/src/hook.test.ts +0 -217
  58. package/hooks/hook-spiral-detector/src/hook.test.ts +0 -72
  59. package/hooks/hook-stylescheck/tsconfig.json +0 -25
  60. package/hooks/hook-typecheck-gate/tsconfig.json +0 -25
  61. package/hooks/hook-workspace-repos-guard/src/hook.test.ts +0 -466
  62. package/hooks/hook-workspace-repos-guard/tsconfig.json +0 -21
  63. 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.1",
2091
+ category: "Git Safety",
2092
+ event: "PreToolUse",
2093
+ matcher: "^(Bash|apply_patch|ApplyPatch|functions\\.apply_patch)$",
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 (
@@ -4802,14 +4919,30 @@ function runRetention(db, days) {
4802
4919
  // src/db/index.ts
4803
4920
  var exports_db = {};
4804
4921
  __export(exports_db, {
4922
+ refuseLocalStore: () => refuseLocalStore,
4923
+ localStoreRefusalMessage: () => localStoreRefusalMessage,
4924
+ isLocalStoreRefused: () => isLocalStoreRefused,
4805
4925
  getDbPath: () => getDbPath,
4806
4926
  getDb: () => getDb,
4807
4927
  createTestDb: () => createTestDb,
4808
- closeDb: () => closeDb
4928
+ closeDb: () => closeDb,
4929
+ allowLocalStore: () => allowLocalStore
4809
4930
  });
4810
4931
  import { Database } from "bun:sqlite";
4811
4932
  import { existsSync as existsSync8, mkdirSync as mkdirSync2, cpSync } from "fs";
4812
4933
  import { join as join9 } from "path";
4934
+ function refuseLocalStore(message) {
4935
+ localStoreRefusal = message;
4936
+ }
4937
+ function allowLocalStore() {
4938
+ localStoreRefusal = null;
4939
+ }
4940
+ function isLocalStoreRefused() {
4941
+ return localStoreRefusal !== null;
4942
+ }
4943
+ function localStoreRefusalMessage() {
4944
+ return localStoreRefusal;
4945
+ }
4813
4946
  function resolveDataDir() {
4814
4947
  const effective = getEffectiveDataRoot();
4815
4948
  const oldDir = join9(getHomeDir(), ".hooks");
@@ -4833,6 +4966,8 @@ function ensureDir(dbPath) {
4833
4966
  }
4834
4967
  }
4835
4968
  function getDb() {
4969
+ if (localStoreRefusal !== null)
4970
+ throw new Error(localStoreRefusal);
4836
4971
  if (instance)
4837
4972
  return instance;
4838
4973
  const dbPath = getDbPath();
@@ -4870,7 +5005,7 @@ function createTestDb() {
4870
5005
  db.exec("PRAGMA foreign_keys=ON");
4871
5006
  return db;
4872
5007
  }
4873
- var instance = null;
5008
+ var instance = null, localStoreRefusal = null;
4874
5009
  var init_db = __esm(() => {
4875
5010
  init_app_home();
4876
5011
  init_migrations();
@@ -4909,6 +5044,9 @@ async function sha256File(path) {
4909
5044
  const buf = await readFile2(path);
4910
5045
  return sha256Of(buf);
4911
5046
  }
5047
+ function storeDb() {
5048
+ return isLocalStoreRefused() ? null : getDb();
5049
+ }
4912
5050
  function getHookRecord(db, name) {
4913
5051
  return db.query("SELECT * FROM hooks WHERE name = ?").get(name);
4914
5052
  }
@@ -5009,26 +5147,28 @@ function removePinnedHook(name) {
5009
5147
  }
5010
5148
  function pinInstalledHook(name, version, sha256, source, sourceRef) {
5011
5149
  const now2 = new Date().toISOString();
5012
- const db = getDb();
5013
- upsertHookRecord(db, {
5014
- name,
5015
- version,
5016
- sha256,
5017
- source_type: source,
5018
- source_ref: sourceRef ?? null,
5019
- last_verified_at: now2
5020
- });
5150
+ const db = storeDb();
5151
+ if (db) {
5152
+ upsertHookRecord(db, {
5153
+ name,
5154
+ version,
5155
+ sha256,
5156
+ source_type: source,
5157
+ source_ref: sourceRef ?? null,
5158
+ last_verified_at: now2
5159
+ });
5160
+ }
5021
5161
  setPinnedHook(name, { version, sha256, source });
5022
5162
  }
5023
5163
  function removeHookFromStore(name) {
5024
5164
  const removedPin = removePinnedHook(name);
5025
- const db = getDb();
5026
- const removedRecord = removeHookRecord(db, name);
5165
+ const db = storeDb();
5166
+ const removedRecord = db ? removeHookRecord(db, name) : false;
5027
5167
  return { removedPin, removedRecord };
5028
5168
  }
5029
5169
  function checkScriptHash(name, actual) {
5030
- const db = getDb();
5031
- const record = getHookRecord(db, name);
5170
+ const db = storeDb();
5171
+ const record = db ? getHookRecord(db, name) : null;
5032
5172
  const pin = getPinnedHook(name);
5033
5173
  const expected = record?.sha256 ?? pin?.sha256 ?? null;
5034
5174
  if (expected !== null && expected !== actual) {
@@ -5038,24 +5178,28 @@ function checkScriptHash(name, actual) {
5038
5178
  const now3 = new Date().toISOString();
5039
5179
  const version = pin?.version ?? record?.version ?? "0.0.0";
5040
5180
  const source = pin?.source ?? record?.source_type ?? "local";
5041
- upsertHookRecord(db, {
5042
- name,
5043
- version,
5044
- sha256: actual,
5045
- source_type: source,
5046
- last_verified_at: now3
5047
- });
5181
+ if (db) {
5182
+ upsertHookRecord(db, {
5183
+ name,
5184
+ version,
5185
+ sha256: actual,
5186
+ source_type: source,
5187
+ last_verified_at: now3
5188
+ });
5189
+ }
5048
5190
  setPinnedHook(name, { version, sha256: actual, source });
5049
5191
  return { ok: true, pinned: false, expected: null, actual, name };
5050
5192
  }
5051
5193
  const now2 = new Date().toISOString();
5052
- upsertHookRecord(db, {
5053
- name,
5054
- version: record?.version ?? pin?.version ?? "0.0.0",
5055
- sha256: expected,
5056
- source_type: record?.source_type ?? pin?.source ?? "local",
5057
- last_verified_at: now2
5058
- });
5194
+ if (db) {
5195
+ upsertHookRecord(db, {
5196
+ name,
5197
+ version: record?.version ?? pin?.version ?? "0.0.0",
5198
+ sha256: expected,
5199
+ source_type: record?.source_type ?? pin?.source ?? "local",
5200
+ last_verified_at: now2
5201
+ });
5202
+ }
5059
5203
  return { ok: true, pinned: true, expected, actual, name };
5060
5204
  }
5061
5205
  async function verifyScriptHash(name, scriptPath) {
@@ -5063,14 +5207,16 @@ async function verifyScriptHash(name, scriptPath) {
5063
5207
  }
5064
5208
  function retrustHook(name, scriptPath, version, source) {
5065
5209
  const actual = sha256Of(readFileSync3(scriptPath));
5066
- const db = getDb();
5067
- upsertHookRecord(db, {
5068
- name,
5069
- version,
5070
- sha256: actual,
5071
- source_type: source,
5072
- last_verified_at: new Date().toISOString()
5073
- });
5210
+ const db = storeDb();
5211
+ if (db) {
5212
+ upsertHookRecord(db, {
5213
+ name,
5214
+ version,
5215
+ sha256: actual,
5216
+ source_type: source,
5217
+ last_verified_at: new Date().toISOString()
5218
+ });
5219
+ }
5074
5220
  setPinnedHook(name, { version, sha256: actual, source });
5075
5221
  return { ok: true, pinned: true, expected: actual, actual, name };
5076
5222
  }
@@ -5090,8 +5236,10 @@ var init_store = __esm(() => {
5090
5236
  var exports_installer = {};
5091
5237
  __export(exports_installer, {
5092
5238
  uninstallHook: () => uninstallHook,
5239
+ scanStaleRegistrations: () => scanStaleRegistrations,
5093
5240
  removeHook: () => removeHook,
5094
5241
  removeCodewithHookEntry: () => removeCodewithHookEntry,
5242
+ pruneStaleRegistrations: () => pruneStaleRegistrations,
5095
5243
  isEventSupported: () => isEventSupported,
5096
5244
  installHooks: () => installHooks,
5097
5245
  installHook: () => installHook,
@@ -5101,6 +5249,7 @@ __export(exports_installer, {
5101
5249
  getRegisteredHooks: () => getRegisteredHooks,
5102
5250
  getInstalledHooks: () => getInstalledHooks,
5103
5251
  getHookPath: () => getHookPath,
5252
+ detectRewriteConflict: () => detectRewriteConflict,
5104
5253
  buildCodewithTomlFragment: () => buildCodewithTomlFragment
5105
5254
  });
5106
5255
  import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync3, rmdirSync, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
@@ -5207,6 +5356,7 @@ function codewithTimeout(name) {
5207
5356
  case "prompt-guard":
5208
5357
  return 3;
5209
5358
  case "worktree-guard":
5359
+ case "trash-guard":
5210
5360
  case "stop-sync":
5211
5361
  return 5;
5212
5362
  default:
@@ -5225,6 +5375,8 @@ function codewithStatusMessage(name) {
5225
5375
  return "Checking prompt safety";
5226
5376
  case "worktree-guard":
5227
5377
  return "Checking worktree safety";
5378
+ case "trash-guard":
5379
+ return "Redirecting rm into trash";
5228
5380
  case "stop-sync":
5229
5381
  return "Syncing turn-end heartbeat";
5230
5382
  default:
@@ -5292,14 +5444,32 @@ function detectConflict(name, scope, target) {
5292
5444
  continue;
5293
5445
  if (!getHookEvents(existing).some((event) => events.has(event)))
5294
5446
  continue;
5295
- const a = meta.matcher.toLowerCase();
5296
- const b = existing.matcher.toLowerCase();
5297
- if (a === b || a.includes(b) || b.includes(a)) {
5447
+ if (matchersOverlap(meta.matcher.toLowerCase(), existing.matcher.toLowerCase())) {
5298
5448
  return `conflicts with '${existingName}' (same event ${meta.event}, overlapping matcher '${existing.matcher}')`;
5299
5449
  }
5300
5450
  }
5301
5451
  return;
5302
5452
  }
5453
+ function detectRewriteConflict(name, scope, target, lookup = getHook) {
5454
+ const meta = resolveHookMeta(name);
5455
+ if (!meta?.matcher || !meta.rewritesInput)
5456
+ return;
5457
+ if (!getHookEvents(meta).includes("PreToolUse"))
5458
+ return;
5459
+ for (const existingName of getRegisteredHooksForTarget(scope, target)) {
5460
+ if (existingName === name)
5461
+ continue;
5462
+ const existing = lookup(existingName);
5463
+ if (!existing?.matcher || !existing.rewritesInput)
5464
+ continue;
5465
+ if (!getHookEvents(existing).includes("PreToolUse"))
5466
+ continue;
5467
+ if (!matchersOverlap(meta.matcher.toLowerCase(), existing.matcher.toLowerCase()))
5468
+ continue;
5469
+ 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.`;
5470
+ }
5471
+ return;
5472
+ }
5303
5473
  function installForTarget(name, scope, overwrite, target, profile, codewithMode = "fragment", codewithConfigPath) {
5304
5474
  const shortName = shortHookName(name);
5305
5475
  if (!hookExists(shortName)) {
@@ -5359,6 +5529,10 @@ function installForTarget(name, scope, overwrite, target, profile, codewithMode
5359
5529
  return { hook: shortName, success: false, error: "Already installed. Use --overwrite to replace.", scope, target };
5360
5530
  }
5361
5531
  const conflict = detectConflict(shortName, scope, target);
5532
+ const rewriteConflict = detectRewriteConflict(shortName, scope, target);
5533
+ if (rewriteConflict) {
5534
+ return { hook: shortName, success: false, error: `Refused: ${rewriteConflict}`, scope, target };
5535
+ }
5362
5536
  try {
5363
5537
  registerHook(shortName, scope, target, profile);
5364
5538
  return { hook: shortName, success: true, scope, target, ...conflict ? { conflict } : {} };
@@ -5430,8 +5604,12 @@ function registerHook(name, scope = "global", target = "claude", profile) {
5430
5604
  for (const eventKey of uniqueEventKeys) {
5431
5605
  if (!settings.hooks[eventKey])
5432
5606
  settings.hooks[eventKey] = [];
5607
+ const hookEntry = { type: "command", command: hookCommand };
5608
+ if (typeof meta.timeoutSeconds === "number" && meta.timeoutSeconds > 0) {
5609
+ hookEntry.timeout = meta.timeoutSeconds;
5610
+ }
5433
5611
  const entry = {
5434
- hooks: [{ type: "command", command: hookCommand }]
5612
+ hooks: [hookEntry]
5435
5613
  };
5436
5614
  if (meta.matcher) {
5437
5615
  entry.matcher = meta.matcher;
@@ -5515,6 +5693,74 @@ function removeHookForTarget(name, scope, target) {
5515
5693
  unregisterHook(name, scope, target);
5516
5694
  return true;
5517
5695
  }
5696
+ function readSettingsAt(path) {
5697
+ try {
5698
+ if (!existsSync10(path))
5699
+ return;
5700
+ const raw = readFileSync4(path, "utf-8");
5701
+ return { raw, settings: JSON.parse(raw) };
5702
+ } catch {
5703
+ return;
5704
+ }
5705
+ }
5706
+ function scanStaleRegistrations(scope = "global") {
5707
+ const stale = [];
5708
+ for (const target of JSON_SETTINGS_TARGETS) {
5709
+ const path = getSettingsPath(scope, target);
5710
+ const parsed = readSettingsAt(path);
5711
+ if (!parsed)
5712
+ continue;
5713
+ stale.push(...findStaleRegistrations(parsed.settings, path));
5714
+ }
5715
+ return stale;
5716
+ }
5717
+ function pruneStaleRegistrations(scope = "global") {
5718
+ const results = [];
5719
+ for (const target of JSON_SETTINGS_TARGETS) {
5720
+ const path = getSettingsPath(scope, target);
5721
+ const parsed = readSettingsAt(path);
5722
+ if (!parsed)
5723
+ continue;
5724
+ const stale = findStaleRegistrations(parsed.settings, path);
5725
+ if (stale.length === 0)
5726
+ continue;
5727
+ const staleCommandsByEvent = new Map;
5728
+ for (const finding of stale) {
5729
+ const commands = staleCommandsByEvent.get(finding.event) ?? new Set;
5730
+ commands.add(finding.command);
5731
+ staleCommandsByEvent.set(finding.event, commands);
5732
+ }
5733
+ const settings = parsed.settings;
5734
+ for (const [eventKey, commands] of staleCommandsByEvent) {
5735
+ const entries = settings.hooks?.[eventKey];
5736
+ if (!Array.isArray(entries))
5737
+ continue;
5738
+ const keptEntries = [];
5739
+ for (const entry of entries) {
5740
+ if (!Array.isArray(entry?.hooks)) {
5741
+ keptEntries.push(entry);
5742
+ continue;
5743
+ }
5744
+ const keptHooks = entry.hooks.filter((hook) => !(typeof hook?.command === "string" && commands.has(hook.command)));
5745
+ if (keptHooks.length === entry.hooks.length)
5746
+ keptEntries.push(entry);
5747
+ else if (keptHooks.length > 0)
5748
+ keptEntries.push({ ...entry, hooks: keptHooks });
5749
+ }
5750
+ if (keptEntries.length > 0)
5751
+ settings.hooks[eventKey] = keptEntries;
5752
+ else
5753
+ delete settings.hooks[eventKey];
5754
+ }
5755
+ if (settings.hooks && Object.keys(settings.hooks).length === 0)
5756
+ delete settings.hooks;
5757
+ const backupPath = `${path}.bak`;
5758
+ writeFileSync3(backupPath, parsed.raw);
5759
+ writeSettings(settings, scope, target);
5760
+ results.push({ file: path, removed: stale, backupPath });
5761
+ }
5762
+ return results;
5763
+ }
5518
5764
  function removeCodewithHookEntry(configText, name) {
5519
5765
  const sections = configText.split(/\n\s*\n/);
5520
5766
  const nameRe = new RegExp(`^command\\s*=\\s*"hooks run ${escapeRegExp(name)}(?:\\s+--profile\\s+[\\w-]+)?"$`);
@@ -5659,11 +5905,12 @@ function uninstallHook(name, scope = "global", target = "claude") {
5659
5905
  registrationsRemaining
5660
5906
  };
5661
5907
  }
5662
- var __dirname2, HOOKS_DIR, EVENT_MAP, getInstalledHooks;
5908
+ var __dirname2, HOOKS_DIR, EVENT_MAP, getInstalledHooks, JSON_SETTINGS_TARGETS;
5663
5909
  var init_installer = __esm(() => {
5664
5910
  init_registry();
5665
5911
  init_resolve();
5666
5912
  init_manifest();
5913
+ init_registration();
5667
5914
  init_store();
5668
5915
  __dirname2 = dirname3(fileURLToPath2(import.meta.url));
5669
5916
  HOOKS_DIR = existsSync10(join10(__dirname2, "..", "..", "hooks", "hook-gitguard")) ? join10(__dirname2, "..", "..", "hooks") : join10(__dirname2, "..", "hooks");
@@ -5700,53 +5947,9 @@ var init_installer = __esm(() => {
5700
5947
  }
5701
5948
  };
5702
5949
  getInstalledHooks = getRegisteredHooks;
5950
+ JSON_SETTINGS_TARGETS = ["claude", "gemini"];
5703
5951
  });
5704
5952
 
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
5953
  // src/lib/redact.ts
5751
5954
  function redactText(value) {
5752
5955
  let out = value;
@@ -5917,12 +6120,12 @@ var init_profiles = __esm(() => {
5917
6120
  init_app_home();
5918
6121
  });
5919
6122
 
5920
- // ../contracts/dist/client/transport.js
6123
+ // ../../node_modules/.bun/@hasna+contracts@1.0.2+e0db24ae03127387/node_modules/@hasna/contracts/dist/client/transport.js
5921
6124
  import { isIP as isIP2 } from "net";
5922
6125
  import { spawnSync } from "child_process";
5923
6126
  import { closeSync, fstatSync, openSync, readFileSync as readFileSync6 } from "fs";
5924
6127
  import { O_NOFOLLOW, O_NONBLOCK, O_RDONLY } from "constants";
5925
- import { createRequire } from "module";
6128
+ import { createRequire as createRequire2 } from "module";
5926
6129
  import { hostname as osHostname } from "os";
5927
6130
  import { isAbsolute as isAbsolute2, join as join12 } from "path";
5928
6131
  function envToken(name) {
@@ -6043,7 +6246,7 @@ function readAppConfigFile(path) {
6043
6246
  const uid = process.getuid?.() ?? process.geteuid?.();
6044
6247
  if (uid !== undefined && before.uid !== uid)
6045
6248
  unsafe("the file is not owned by the current user");
6046
- if (before.size > MAX_CREDENTIAL_FILE_BYTES)
6249
+ if (before.size > MAX_CREDENTIAL_FILE_BYTES2)
6047
6250
  unsafe("the file exceeds the size limit");
6048
6251
  const bytes = readFileSync6(fd);
6049
6252
  const after = fstatSync(fd);
@@ -6127,13 +6330,13 @@ function sealCredential(fields) {
6127
6330
  configurable: false
6128
6331
  });
6129
6332
  }
6130
- Object.defineProperty(sealed, INSPECT_CUSTOM, {
6333
+ Object.defineProperty(sealed, INSPECT_CUSTOM2, {
6131
6334
  value: () => ({ ...visible, apiKey: "[redacted]" }),
6132
6335
  enumerable: false,
6133
6336
  writable: false,
6134
6337
  configurable: false
6135
6338
  });
6136
- Object.defineProperty(sealed, CREDENTIAL_SEAL, {
6339
+ Object.defineProperty(sealed, CREDENTIAL_SEAL2, {
6137
6340
  value: true,
6138
6341
  enumerable: false,
6139
6342
  writable: false,
@@ -6152,7 +6355,7 @@ function firstEnvValue(env, keys) {
6152
6355
  return null;
6153
6356
  }
6154
6357
  function isAmbientEnvironment(env) {
6155
- return env === process.env || env[AMBIENT_ENVIRONMENT] === true;
6358
+ return env === process.env || env[AMBIENT_ENVIRONMENT2] === true;
6156
6359
  }
6157
6360
  function defaultKeychainRunner(argv) {
6158
6361
  const result = spawnSync(KEYCHAIN_SECURITY_BIN, [...argv], {
@@ -6246,7 +6449,7 @@ function snapshotClientEnvironment(name, env) {
6246
6449
  snapshot[key] = descriptor.value;
6247
6450
  }
6248
6451
  if (ambient) {
6249
- Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT, {
6452
+ Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT2, {
6250
6453
  value: true,
6251
6454
  enumerable: false,
6252
6455
  writable: false,
@@ -6597,7 +6800,7 @@ function credentialDiskSourcesForMessage(name, env) {
6597
6800
  const paths = credentialDiskSources(name, env);
6598
6801
  return paths.length > 0 ? paths.join(" or ") : "<no HOME or HASNA_HOME set in this environment, so no credential file was consulted>";
6599
6802
  }
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;
6803
+ 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_SPECIFIER, 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
6804
  var init_transport = __esm(() => {
6602
6805
  CredentialResolutionError = class CredentialResolutionError extends Error {
6603
6806
  appName;
@@ -6617,17 +6820,17 @@ var init_transport = __esm(() => {
6617
6820
  this.path = path;
6618
6821
  }
6619
6822
  };
6620
- MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
6823
+ MAX_CREDENTIAL_FILE_BYTES2 = 64 * 1024;
6621
6824
  SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
6622
6825
  SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
6623
6826
  ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
6624
6827
  VAULT_POINTER_SHAPE = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
6625
6828
  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");
6829
+ INSPECT_CUSTOM2 = Symbol.for("nodejs.util.inspect.custom");
6830
+ CREDENTIAL_SEAL2 = Symbol.for("hasna:contracts:sealedCredential");
6831
+ AMBIENT_ENVIRONMENT2 = Symbol.for("hasna:contracts:ambientClientEnvironment");
6629
6832
  SECRETS_PACKAGE_SPECIFIER = "@hasna/" + "secrets";
6630
- requireSecretsSdk = createRequire(import.meta.url);
6833
+ requireSecretsSdk2 = createRequire2(import.meta.url);
6631
6834
  ASCII_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
6632
6835
  DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
6633
6836
  ClientTransportConfigurationError = class ClientTransportConfigurationError extends Error {
@@ -6640,8 +6843,8 @@ var init_transport = __esm(() => {
6640
6843
  this.sources = Object.freeze([...sources]);
6641
6844
  }
6642
6845
  };
6643
- IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
6644
- AUTHORITY_OVERRIDE_HEADERS = new Set([
6846
+ IDEMPOTENT_METHODS2 = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
6847
+ AUTHORITY_OVERRIDE_HEADERS2 = new Set([
6645
6848
  "host",
6646
6849
  ":authority",
6647
6850
  "forwarded",
@@ -6670,6 +6873,15 @@ function hasHooksEnvAuthorityIntent(env = process.env) {
6670
6873
  function selectsHooksLocalStore(env = process.env) {
6671
6874
  return !hasHooksEnvAuthorityIntent(env) && isHooksLocalOptIn(env);
6672
6875
  }
6876
+ function hooksFailClosedLine(detail) {
6877
+ return `REMOTE_API_CONFIG_MISSING: hooks: no registry credential resolved from ${HOOKS_CREDENTIAL_TIERS} ` + `(with HASNA_HOOKS_API_URL or the fleet gateway default), and local mode is not enabled ` + `(${HOOKS_LOCAL_OPT_IN_HINT}) \u2014 refusing to silently fall back to local storage.` + (detail ? ` ${detail}` : "");
6878
+ }
6879
+ function hooksHostedRouteLocalStoreRefusal(authority) {
6880
+ return "REMOTE_COMMAND_UNSUPPORTED: this command is local-only \u2014 it reads the on-box SQLite store " + `(hooks.db), which the hosted hooks registry authority (${authority}) does not serve; local SQLite ` + `is opt-in only (${HOOKS_LOCAL_OPT_IN_HINT}) and is disabled by default \u2014 refusing to open a local ` + "store on the hosted route.";
6881
+ }
6882
+ function hooksEventSinkRefusal() {
6883
+ return "REMOTE_COMMAND_UNSUPPORTED: hook event not recorded \u2014 the hook event log is local-only (on-box " + "SQLite hooks.db) and the hosted hooks registry has no event route; local SQLite is opt-in only " + `(${HOOKS_LOCAL_OPT_IN_HINT}) and is disabled by default. Nothing was written.`;
6884
+ }
6673
6885
  function hooksResolverEnv(env) {
6674
6886
  const blanks = hooksAuthorityEnvKeys().filter((key) => (key in env) && (env[key] ?? "").trim() === "");
6675
6887
  if (blanks.length === 0)
@@ -6694,7 +6906,7 @@ function hooksResolverInputs(env, credentials = {}) {
6694
6906
  }
6695
6907
  return { env: normalised, credentials: { ...credentials, keychain } };
6696
6908
  }
6697
- var HOOKS_LOCAL_OPT_IN_ENV_KEYS, CONTRACTS_AMBIENT_ENVIRONMENT;
6909
+ var HOOKS_LOCAL_OPT_IN_ENV_KEYS, HOOKS_CREDENTIAL_TIERS = "the Keychain item hasna.credentials.hooks.api-key, ~/.hasna/hooks/config/credentials, or HASNA_HOOKS_API_KEY", HOOKS_LOCAL_OPT_IN_HINT = "HASNA_HOOKS_LOCAL=1 (alias HOOKS_LOCAL=1)", CONTRACTS_AMBIENT_ENVIRONMENT;
6698
6910
  var init_local_opt_in = __esm(() => {
6699
6911
  init_transport();
6700
6912
  HOOKS_LOCAL_OPT_IN_ENV_KEYS = ["HASNA_HOOKS_LOCAL", "HOOKS_LOCAL"];
@@ -6797,14 +7009,35 @@ var init_transport2 = __esm(() => {
6797
7009
  var exports_db_writer = {};
6798
7010
  __export(exports_db_writer, {
6799
7011
  writeHookEvent: () => writeHookEvent,
7012
+ resolveHookEventSink: () => resolveHookEventSink,
6800
7013
  resolveEventType: () => resolveEventType,
6801
7014
  recordHookRun: () => recordHookRun,
6802
- normalizeEventType: () => normalizeEventType
7015
+ normalizeEventType: () => normalizeEventType,
7016
+ __resetHookEventSinkNotice: () => __resetHookEventSinkNotice
6803
7017
  });
7018
+ function resolveHookEventSink(env = process.env) {
7019
+ if (isLocalStoreRefused())
7020
+ return { kind: "refused", reason: localStoreRefusalMessage() ?? hooksEventSinkRefusal() };
7021
+ if (selectsHooksLocalStore(env))
7022
+ return { kind: "local" };
7023
+ return { kind: "refused", reason: hooksEventSinkRefusal() };
7024
+ }
7025
+ function __resetHookEventSinkNotice() {
7026
+ sinkRefusalPrinted = false;
7027
+ }
6804
7028
  function nanoid2() {
6805
7029
  return crypto.randomUUID().replace(/-/g, "").slice(0, 21);
6806
7030
  }
6807
7031
  function writeHookEvent(event) {
7032
+ const sink = resolveHookEventSink();
7033
+ if (sink.kind !== "local") {
7034
+ if (!sinkRefusalPrinted) {
7035
+ sinkRefusalPrinted = true;
7036
+ process.stderr.write(`[hooks db-writer] ${sink.reason}
7037
+ `);
7038
+ }
7039
+ return;
7040
+ }
6808
7041
  try {
6809
7042
  const db = getDb();
6810
7043
  const id = nanoid2();
@@ -6873,9 +7106,10 @@ function recordHookRun(record) {
6873
7106
  metadata: JSON.stringify(metadata)
6874
7107
  });
6875
7108
  }
6876
- var ALLOWED_EVENT_TYPES;
7109
+ var sinkRefusalPrinted = false, ALLOWED_EVENT_TYPES;
6877
7110
  var init_db_writer = __esm(() => {
6878
7111
  init_db();
7112
+ init_local_opt_in();
6879
7113
  init_redact();
6880
7114
  ALLOWED_EVENT_TYPES = new Set([
6881
7115
  "PreToolUse",
@@ -6891,7 +7125,8 @@ var init_db_writer = __esm(() => {
6891
7125
 
6892
7126
  // src/lib/hook-env.ts
6893
7127
  import { dirname as dirname4, isAbsolute as isAbsolute3 } from "path";
6894
- import { existsSync as existsSync12, statSync } from "fs";
7128
+ import { existsSync as existsSync12, realpathSync as realpathSync2, statSync } from "fs";
7129
+ import { tmpdir } from "os";
6895
7130
  function isDeniedEnvName(name) {
6896
7131
  const upper = name.toUpperCase();
6897
7132
  if (DENY_PREFIXES.some((prefix) => upper.startsWith(prefix)))
@@ -6917,6 +7152,19 @@ function isUnsafePathEntry(entry, home) {
6917
7152
  return true;
6918
7153
  if (entry === "/var/tmp" || entry.startsWith("/var/tmp/"))
6919
7154
  return true;
7155
+ const canonical = (path) => {
7156
+ try {
7157
+ return realpathSync2(path);
7158
+ } catch {
7159
+ return path;
7160
+ }
7161
+ };
7162
+ const resolved = canonical(entry);
7163
+ for (const root of [home, tmpdir(), "/tmp", "/var/tmp"].filter((value) => Boolean(value))) {
7164
+ const blocked = canonical(root);
7165
+ if (resolved === blocked || resolved.startsWith(blocked + "/"))
7166
+ return true;
7167
+ }
6920
7168
  try {
6921
7169
  if ((statSync(entry).mode & 2) !== 0)
6922
7170
  return true;
@@ -7036,6 +7284,7 @@ var init_hook_env = __esm(() => {
7036
7284
  DENY_CONTAINS = ["DATABASE_URL"];
7037
7285
  PATH_BASELINE = ["/usr/bin", "/bin", "/usr/local/bin", "/sbin", "/usr/sbin"];
7038
7286
  CONFIG_PROJECTIONS = [
7287
+ ["HASNA_HOOKS_LOCAL", "HOOKS_LOCAL"],
7039
7288
  ["HASNA_HOOKS_DATA_DIR", "HOOKS_DATA_DIR"],
7040
7289
  ["HASNA_HOOKS_DB_PATH", "HOOKS_DB_PATH"],
7041
7290
  ["HASNA_HOOKS_LOCK_PATH", "HOOKS_LOCK_PATH"],
@@ -7215,7 +7464,7 @@ __export(exports_custom_install, {
7215
7464
  });
7216
7465
  import { existsSync as existsSync13, mkdirSync as mkdirSync6, mkdtempSync, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
7217
7466
  import { join as join14 } from "path";
7218
- import { tmpdir } from "os";
7467
+ import { tmpdir as tmpdir2 } from "os";
7219
7468
  function manifestAt(dir) {
7220
7469
  const path = join14(dir, "manifest.json");
7221
7470
  if (!existsSync13(path))
@@ -7240,7 +7489,7 @@ function writeFromManifest(manifest, srcDir, source, kind) {
7240
7489
  return { name, version: manifest.version, kind, source, dir, scriptPath };
7241
7490
  }
7242
7491
  async function cloneToTemp(gitUrl) {
7243
- const tmp = mkdtempSync(join14(tmpdir(), "hooks-install-"));
7492
+ const tmp = mkdtempSync(join14(tmpdir2(), "hooks-install-"));
7244
7493
  const proc = Bun.spawn(["git", "clone", "--depth", "1", "--quiet", gitUrl, join14(tmp, "repo")], {
7245
7494
  stdout: "pipe",
7246
7495
  stderr: "pipe"
@@ -7327,6 +7576,7 @@ __export(exports_sync, {
7327
7576
  syncHooks: () => syncHooks,
7328
7577
  stageSyncArtifacts: () => stageSyncArtifacts,
7329
7578
  planSync: () => planSync,
7579
+ installPinnedFromBundled: () => installPinnedFromBundled,
7330
7580
  fetchPinnedHook: () => fetchPinnedHook,
7331
7581
  commitSyncArtifacts: () => commitSyncArtifacts
7332
7582
  });
@@ -7490,7 +7740,7 @@ async function stageSyncArtifacts(apiUrl, apiKey, names, remoteLock) {
7490
7740
  return staged;
7491
7741
  }
7492
7742
  async function commitSyncArtifacts(staged, apiUrl, remoteLock) {
7493
- const db = getDb();
7743
+ const db = isLocalStoreRefused() ? null : getDb();
7494
7744
  for (const item of staged) {
7495
7745
  const entry = remoteLock.hooks[item.name];
7496
7746
  if (!entry)
@@ -7510,6 +7760,8 @@ async function commitSyncArtifacts(staged, apiUrl, remoteLock) {
7510
7760
  lock.hooks[item.name] = { version: entry.version, sha256: entry.sha256, source: entry.source ?? "remote" };
7511
7761
  }
7512
7762
  writeLock(lock);
7763
+ if (!db)
7764
+ return;
7513
7765
  db.exec("BEGIN");
7514
7766
  try {
7515
7767
  for (const item of staged) {
@@ -7572,23 +7824,56 @@ async function fetchPinnedHook(name, version, apiUrl, apiKey) {
7572
7824
  if (actual !== expectedSha) {
7573
7825
  throw new Error(`sha256 mismatch after write for '${name}@${version}': expected ${expectedSha}, on disk ${actual}`);
7574
7826
  }
7575
- const db = getDb();
7576
7827
  setPinnedHook(name, { version, sha256: expectedSha, source: entry.source ?? "remote", pinned: true });
7577
- upsertHookRecord(db, {
7828
+ if (!isLocalStoreRefused()) {
7829
+ upsertHookRecord(getDb(), {
7830
+ name,
7831
+ version,
7832
+ sha256: expectedSha,
7833
+ source_type: entry.source ?? "remote",
7834
+ source_ref: apiUrl,
7835
+ last_verified_at: new Date().toISOString()
7836
+ });
7837
+ }
7838
+ return {
7578
7839
  name,
7579
7840
  version,
7580
7841
  sha256: expectedSha,
7581
- source_type: entry.source ?? "remote",
7842
+ source: entry.source ?? "remote",
7582
7843
  source_ref: apiUrl,
7844
+ artifact,
7845
+ scriptPath
7846
+ };
7847
+ }
7848
+ async function installPinnedFromBundled(name, version) {
7849
+ const bundled = collectBundledCatalog().find((entry) => entry.name === name);
7850
+ if (!bundled) {
7851
+ throw new Error(`Hook '${name}' is not in the bundled registry`);
7852
+ }
7853
+ if (bundled.version !== version) {
7854
+ throw new Error(`Hook '${name}' version ${version} is not in the bundled registry (available: ${bundled.version})`);
7855
+ }
7856
+ const scriptPath = resolveScriptPath(name);
7857
+ if (!scriptPath) {
7858
+ throw new Error(`Hook '${name}' has no script in the bundled registry`);
7859
+ }
7860
+ const db = getDb();
7861
+ setPinnedHook(name, { version, sha256: bundled.sha256, source: "bundled", pinned: true });
7862
+ upsertHookRecord(db, {
7863
+ name,
7864
+ version,
7865
+ sha256: bundled.sha256,
7866
+ source_type: "bundled",
7867
+ source_ref: "bundled registry",
7583
7868
  last_verified_at: new Date().toISOString()
7584
7869
  });
7585
7870
  return {
7586
7871
  name,
7587
7872
  version,
7588
- sha256: expectedSha,
7589
- source: entry.source ?? "remote",
7590
- source_ref: apiUrl,
7591
- artifact,
7873
+ sha256: bundled.sha256,
7874
+ source: "bundled",
7875
+ source_ref: "bundled registry",
7876
+ artifact: null,
7592
7877
  scriptPath
7593
7878
  };
7594
7879
  }
@@ -8421,7 +8706,7 @@ function startServeServer(options) {
8421
8706
  return handleServeRequest(req, resolveHooksServePublishKey());
8422
8707
  }
8423
8708
  });
8424
- console.error(`hooks registry listening on http://${host}:${port} (publish requires an API key)`);
8709
+ console.error(`hooks registry listening on http://${host}:${server.port} (publish requires an API key)`);
8425
8710
  return server;
8426
8711
  }
8427
8712
  var DEFAULT_SERVE_PORT = 39428, SERVE_HOST = "127.0.0.1", SERVE_SERVICE_NAME = "hooks-registry";
@@ -8517,6 +8802,32 @@ async function provisionCloudflareResources(options) {
8517
8802
  }
8518
8803
  var CF_API = "https://api.cloudflare.com/client/v4";
8519
8804
 
8805
+ // src/mcp/authority.ts
8806
+ var exports_authority = {};
8807
+ __export(exports_authority, {
8808
+ resolveHooksMcpAuthority: () => resolveHooksMcpAuthority,
8809
+ decideHooksMcpAuthority: () => decideHooksMcpAuthority
8810
+ });
8811
+ function resolveHooksMcpAuthority(env = process.env) {
8812
+ const transport = resolveHooksTransport(env);
8813
+ if (transport.mode === "local" || !transport.authority) {
8814
+ return { route: "local", source: "local-opt-in" };
8815
+ }
8816
+ return { route: "hosted", v1BaseUrl: transport.authority.v1BaseUrl, source: transport.source };
8817
+ }
8818
+ function decideHooksMcpAuthority(env = process.env) {
8819
+ const authority = resolveHooksMcpAuthority(env);
8820
+ if (authority.route === "hosted") {
8821
+ refuseLocalStore(hooksHostedRouteLocalStoreRefusal(authority.v1BaseUrl));
8822
+ }
8823
+ return authority;
8824
+ }
8825
+ var init_authority = __esm(() => {
8826
+ init_db();
8827
+ init_local_opt_in();
8828
+ init_transport2();
8829
+ });
8830
+
8520
8831
  // src/mcp/server.ts
8521
8832
  var exports_server = {};
8522
8833
  __export(exports_server, {
@@ -8785,6 +9096,13 @@ function createHooksServer() {
8785
9096
  if (hookHealthy)
8786
9097
  healthy.push(name);
8787
9098
  }
9099
+ for (const overlap of findRewriteOverlaps(registered, (name) => getHook(name))) {
9100
+ issues.push({
9101
+ hook: overlap.hooks.join(" + "),
9102
+ 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.`,
9103
+ severity: "error"
9104
+ });
9105
+ }
8788
9106
  return { content: [{ type: "text", text: JSON.stringify({ healthy: issues.length === 0, healthy_hooks: healthy, issues, registered, scope }) }] };
8789
9107
  });
8790
9108
  defineTool("hooks_categories", "List all hook categories with counts", {}, async () => {
@@ -9451,6 +9769,7 @@ async function startSSEServer(options = {}) {
9451
9769
  if (!loopback && !authToken) {
9452
9770
  throw new Error(`Refusing to serve MCP SSE on non-loopback host '${host}' without an auth token. Set HASNA_HOOKS_MCP_TOKEN (or HOOKS_MCP_TOKEN) to authorize a non-loopback bind.`);
9453
9771
  }
9772
+ decideHooksMcpAuthority();
9454
9773
  const server = createHooksServer();
9455
9774
  const transports = new Map;
9456
9775
  function authorized2(headers) {
@@ -9500,6 +9819,13 @@ async function startSSEServer(options = {}) {
9500
9819
  });
9501
9820
  }
9502
9821
  async function startStdioServer() {
9822
+ try {
9823
+ decideHooksMcpAuthority();
9824
+ } catch (err) {
9825
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}
9826
+ `);
9827
+ process.exit(1);
9828
+ }
9503
9829
  try {
9504
9830
  const server = createHooksServer();
9505
9831
  const transport = new StdioServerTransport;
@@ -9517,8 +9843,10 @@ var init_server = __esm(() => {
9517
9843
  init_profiles();
9518
9844
  init_manifest();
9519
9845
  init_resolve();
9846
+ init_registration();
9520
9847
  init_store();
9521
9848
  init_redact();
9849
+ init_authority();
9522
9850
  init_storage();
9523
9851
  __dirname3 = dirname7(fileURLToPath4(import.meta.url));
9524
9852
  pkg = { name: "@hasna/hooks", version: "0.0.0" };
@@ -9612,6 +9940,7 @@ import { spawn } from "child_process";
9612
9940
  import { request as nodeHttpRequest } from "http";
9613
9941
  import { request as nodeHttpsRequest } from "https";
9614
9942
  import { randomUUID as randomUUID2 } from "crypto";
9943
+ import { createRequire } from "module";
9615
9944
  function getPathValue(input, path) {
9616
9945
  return path.split(".").reduce((value, part) => {
9617
9946
  if (value && typeof value === "object" && part in value) {
@@ -10709,6 +11038,22 @@ function setPath(input, path, replacement) {
10709
11038
  if (last && last in cursor)
10710
11039
  cursor[last] = replacement;
10711
11040
  }
11041
+ var MAX_PACKAGE_BYTES = 1024 * 1024;
11042
+ var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
11043
+ var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
11044
+ var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
11045
+ var AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
11046
+ var requireSecretsSdk = createRequire(import.meta.url);
11047
+ var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
11048
+ var AUTHORITY_OVERRIDE_HEADERS = new Set([
11049
+ "host",
11050
+ ":authority",
11051
+ "forwarded",
11052
+ "x-forwarded-host",
11053
+ "x-original-host"
11054
+ ]);
11055
+ var MAX_ENVELOPE_BYTES = 256 * 1024;
11056
+ var MAX_REQUEST_BYTES = MAX_ENVELOPE_BYTES * 2 + 8192;
10712
11057
  function createEvent(input) {
10713
11058
  return {
10714
11059
  id: input.id ?? randomUUID2(),
@@ -12427,6 +12772,7 @@ function App({ initialHooks, overwrite = false }) {
12427
12772
  // src/cli/index.tsx
12428
12773
  init_registry();
12429
12774
  init_installer();
12775
+ init_registration();
12430
12776
  init_redact();
12431
12777
  init_profiles();
12432
12778
  init_manifest();
@@ -12437,6 +12783,7 @@ init_app_home();
12437
12783
  init_config();
12438
12784
  init_local_opt_in();
12439
12785
  init_transport2();
12786
+ init_db();
12440
12787
  import { jsxDEV as jsxDEV8 } from "react/jsx-dev-runtime";
12441
12788
  var __dirname4 = dirname8(fileURLToPath5(import.meta.url));
12442
12789
  var pkgPath = existsSync15(join17(__dirname4, "..", "package.json")) ? join17(__dirname4, "..", "package.json") : join17(__dirname4, "..", "..", "package.json");
@@ -12471,6 +12818,19 @@ function truncateText2(value, max = 96) {
12471
12818
  function projectLogRows2(rows) {
12472
12819
  return rows.map((row) => projectEventRowForRead({ ...row }));
12473
12820
  }
12821
+ async function openLocalStoreOrExit(json2) {
12822
+ try {
12823
+ const { getDb: getDb2 } = await Promise.resolve().then(() => (init_db(), exports_db));
12824
+ return getDb2();
12825
+ } catch (error) {
12826
+ const message = error instanceof Error ? error.message : String(error);
12827
+ if (json2)
12828
+ console.log(JSON.stringify({ error: message }));
12829
+ else
12830
+ console.error(chalk2.red(`\u2717 ${message}`));
12831
+ process.exit(1);
12832
+ }
12833
+ }
12474
12834
  function readToken(tokenFile) {
12475
12835
  if (tokenFile) {
12476
12836
  try {
@@ -12535,6 +12895,11 @@ function suggestHooks(name, max = 3) {
12535
12895
  }
12536
12896
  program2.name("hooks").description("Install hooks for AI coding agents").version(pkg2.version);
12537
12897
  program2.command("interactive", { isDefault: true }).alias("i").description("Interactive hook browser").action(() => {
12898
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
12899
+ console.error(chalk2.red("The interactive hook browser requires a TTY terminal.") + `
12900
+ ` + chalk2.dim("Use `hooks search <query>`, `hooks list`, or `hooks categories` for non-interactive use."));
12901
+ process.exit(1);
12902
+ }
12538
12903
  render(/* @__PURE__ */ jsxDEV8(App, {}, undefined, false, undefined, this));
12539
12904
  });
12540
12905
  program2.command("init").description("Register a new agent profile with a unique ID").option("-a, --agent <type>", "Agent type: claude, gemini, custom", "claude").option("-n, --name <name>", "Optional display name for the agent").option("-j, --json", "Output as JSON", false).option("--cloudflare", "Configure a remote registry (Cloudflare worker)", false).option("--api-url <url>", "Remote registry API URL (with --cloudflare)").option("--api-key <ref>", "Vault key NAME for the registry API key (never the value; with --cloudflare)").action(async (options) => {
@@ -12770,6 +13135,7 @@ program2.command("install").alias("add").argument("[hooks...]", "Hooks to instal
12770
13135
  const { readCustomManifest: readCustomManifest2, HOOK_NAME_RE: HOOK_NAME_RE2 } = await Promise.resolve().then(() => (init_manifest(), exports_manifest));
12771
13136
  const { resolveHooksTransport: resolveHooksTransport2 } = await Promise.resolve().then(() => (init_transport2(), exports_transport));
12772
13137
  const { sha256Of: sha256Of2, pinInstalledHook: pinInstalledHook2 } = await Promise.resolve().then(() => (init_store(), exports_store));
13138
+ const { installPinnedFromBundled: installPinnedFromBundled2, fetchPinnedHook: fetchPinnedHook2 } = await Promise.resolve().then(() => (init_sync(), exports_sync));
12773
13139
  const { readFileSync: readFileSyncFs } = await import("fs");
12774
13140
  function parseNameVersion(arg) {
12775
13141
  if (isCustomSource2(arg))
@@ -12862,17 +13228,13 @@ Dry run \u2014 would install (${scope}, ${target}):
12862
13228
  for (const { arg, pinned } of pinnedRequests) {
12863
13229
  try {
12864
13230
  const transport = resolveHooksTransport2();
12865
- if (transport.mode !== "remote" || !transport.authority) {
12866
- throw new Error(`Cannot install '${arg}': no remote registry configured (set HASNA_HOOKS_API_URL and HASNA_HOOKS_API_KEY, or opt into local mode with HASNA_HOOKS_LOCAL=1)`);
12867
- }
12868
- const { origin, apiKey } = transport.authority;
12869
- const { fetchPinnedHook: fetchPinnedHook2 } = await Promise.resolve().then(() => (init_sync(), exports_sync));
12870
- const pinnedInstall = await fetchPinnedHook2(pinned.name, pinned.version, origin, apiKey);
13231
+ const pinnedInstall = transport.mode === "remote" && transport.authority ? await fetchPinnedHook2(pinned.name, pinned.version, transport.authority.origin, transport.authority.apiKey) : await installPinnedFromBundled2(pinned.name, pinned.version);
12871
13232
  installedPinned.push(pinnedInstall.name);
12872
13233
  if (options.json) {
12873
13234
  console.log(JSON.stringify({ pinned: { request: arg, name: pinnedInstall.name, version: pinnedInstall.version, sha256: pinnedInstall.sha256, source: pinnedInstall.source } }));
12874
13235
  } else {
12875
- console.log(chalk2.green(`\u2713 Installed '${pinnedInstall.name}' v${pinnedInstall.version} from registry (sha256 ${pinnedInstall.sha256.slice(0, 12)}\u2026)`));
13236
+ const registryLabel = transport.mode === "remote" ? "registry" : "bundled registry";
13237
+ console.log(chalk2.green(`\u2713 Installed '${pinnedInstall.name}' v${pinnedInstall.version} from ${registryLabel} (sha256 ${pinnedInstall.sha256.slice(0, 12)}\u2026)`));
12876
13238
  }
12877
13239
  } catch (error) {
12878
13240
  const message = error instanceof Error ? error.message : String(error);
@@ -13192,11 +13554,21 @@ ${meta.displayName}
13192
13554
  console.log(chalk2.dim(" \u25CB Not installed in project"));
13193
13555
  }
13194
13556
  });
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) => {
13557
+ 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
13558
  const scope = resolveScope(options);
13197
13559
  const settingsPath = getSettingsPath(scope);
13198
13560
  const issues = [];
13199
13561
  const healthy = [];
13562
+ const fixed = options.fix ? pruneStaleRegistrations(scope) : [];
13563
+ const stale = scanStaleRegistrations(scope);
13564
+ for (const finding of stale) {
13565
+ issues.push({
13566
+ hook: finding.hook,
13567
+ issue: `Stale registration in ${finding.file} (${finding.event}): \`${finding.command}\` does not resolve to a hook`,
13568
+ severity: "error"
13569
+ });
13570
+ }
13571
+ const staleNames = new Set(stale.map((finding) => finding.hook));
13200
13572
  const settingsExist = existsSync15(settingsPath);
13201
13573
  if (!settingsExist) {
13202
13574
  issues.push({ hook: "(settings)", issue: `${settingsPath} not found`, severity: "warning" });
@@ -13210,6 +13582,8 @@ program2.command("doctor").option("-g, --global", "Check global settings", false
13210
13582
  } catch {}
13211
13583
  }
13212
13584
  for (const name of registered) {
13585
+ if (staleNames.has(name))
13586
+ continue;
13213
13587
  const custom = readCustomManifest(name);
13214
13588
  const meta = custom ? resolveHookMeta(name) : getHook(name);
13215
13589
  let hookHealthy = true;
@@ -13245,17 +13619,53 @@ program2.command("doctor").option("-g, --global", "Check global settings", false
13245
13619
  healthy.push(name);
13246
13620
  }
13247
13621
  }
13622
+ for (const overlap of findRewriteOverlaps(registered, (name) => getHook(name))) {
13623
+ issues.push({
13624
+ hook: overlap.hooks.join(" + "),
13625
+ 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.`,
13626
+ severity: "error"
13627
+ });
13628
+ }
13248
13629
  if (options.json) {
13249
- console.log(JSON.stringify({ healthy: issues.length === 0, healthy_hooks: healthy, issues, registered, wiring_count: wiringCount, scope }));
13630
+ console.log(JSON.stringify({
13631
+ healthy: issues.length === 0,
13632
+ healthy_hooks: healthy,
13633
+ issues,
13634
+ stale,
13635
+ fixed: fixed.flatMap((result) => result.removed),
13636
+ backups: fixed.map((result) => result.backupPath),
13637
+ registered,
13638
+ wiring_count: wiringCount,
13639
+ scope
13640
+ }));
13641
+ if (issues.some((issue) => issue.severity === "error"))
13642
+ process.exitCode = 1;
13250
13643
  return;
13251
13644
  }
13252
13645
  console.log(chalk2.bold(`
13253
13646
  Hook Health Check (${scope})
13254
13647
  `));
13648
+ if (fixed.length > 0) {
13649
+ for (const result of fixed) {
13650
+ console.log(chalk2.green(` \u2713 removed ${result.removed.length} stale registration(s) from ${result.file}`));
13651
+ for (const finding of result.removed) {
13652
+ console.log(chalk2.dim(` ${finding.hook} (${finding.event}: ${finding.command})`));
13653
+ }
13654
+ console.log(chalk2.dim(` backup: ${result.backupPath}`));
13655
+ }
13656
+ console.log();
13657
+ }
13255
13658
  if (registered.length === 0) {
13256
13659
  console.log(chalk2.dim(" No hooks registered."));
13257
13660
  console.log(chalk2.dim(" Run: hooks install gitguard"));
13258
13661
  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)`));
13662
+ if (issues.length > 0) {
13663
+ console.log();
13664
+ for (const issue of issues) {
13665
+ const icon = issue.severity === "error" ? chalk2.red("\u2717") : chalk2.yellow("!");
13666
+ console.log(` ${icon} ${chalk2.cyan(issue.hook)}: ${issue.issue}`);
13667
+ }
13668
+ }
13259
13669
  if (issues.length > 0)
13260
13670
  process.exitCode = 1;
13261
13671
  return;
@@ -13299,8 +13709,9 @@ program2.command("update").argument("[hooks...]", "Hooks to update (defaults to
13299
13709
  }
13300
13710
  const { resolveHook: resolveHook2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
13301
13711
  const { sha256File: sha256File2, setPinnedHook: setPinnedHook2, upsertHookRecord: upsertHookRecord2 } = await Promise.resolve().then(() => (init_store(), exports_store));
13302
- const { getDb: getDb2 } = await Promise.resolve().then(() => (init_db(), exports_db));
13712
+ const { getDb: getDb2, isLocalStoreRefused: isLocalStoreRefused2 } = await Promise.resolve().then(() => (init_db(), exports_db));
13303
13713
  const { resolveHooksTransport: resolveHooksTransport2 } = await Promise.resolve().then(() => (init_transport2(), exports_transport));
13714
+ const { installPinnedFromBundled: installPinnedFromBundled2, fetchPinnedHook: fetchPinnedHook2 } = await Promise.resolve().then(() => (init_sync(), exports_sync));
13304
13715
  const { isCustomSource: isCustomSource2 } = await Promise.resolve().then(() => (init_custom_install(), exports_custom_install));
13305
13716
  const { HOOK_NAME_RE: HOOK_NAME_RE2 } = await Promise.resolve().then(() => (init_manifest(), exports_manifest));
13306
13717
  function parseNameVersion(arg) {
@@ -13323,12 +13734,7 @@ program2.command("update").argument("[hooks...]", "Hooks to update (defaults to
13323
13734
  if (pinned) {
13324
13735
  try {
13325
13736
  const transport = resolveHooksTransport2();
13326
- if (transport.mode !== "remote" || !transport.authority) {
13327
- throw new Error(`Cannot update '${arg}': no remote registry configured (set HASNA_HOOKS_API_URL and HASNA_HOOKS_API_KEY, or opt into local mode with HASNA_HOOKS_LOCAL=1)`);
13328
- }
13329
- const { origin, apiKey } = transport.authority;
13330
- const { fetchPinnedHook: fetchPinnedHook2 } = await Promise.resolve().then(() => (init_sync(), exports_sync));
13331
- const pinnedInstall = await fetchPinnedHook2(pinned.name, pinned.version, origin, apiKey);
13737
+ const pinnedInstall = transport.mode === "remote" && transport.authority ? await fetchPinnedHook2(pinned.name, pinned.version, transport.authority.origin, transport.authority.apiKey) : await installPinnedFromBundled2(pinned.name, pinned.version);
13332
13738
  const registered = installHook(pinnedInstall.name, { scope, overwrite: true });
13333
13739
  if (!registered.success) {
13334
13740
  results.push({ hook: arg, success: false, error: registered.error ?? "registration failed" });
@@ -13353,13 +13759,15 @@ program2.command("update").argument("[hooks...]", "Hooks to update (defaults to
13353
13759
  if (result.success && resolved) {
13354
13760
  const hash = await sha256File2(resolved.scriptPath);
13355
13761
  setPinnedHook2(resolved.name, { version: resolved.version, sha256: hash, source: resolved.source });
13356
- upsertHookRecord2(getDb2(), {
13357
- name: resolved.name,
13358
- version: resolved.version,
13359
- sha256: hash,
13360
- source_type: resolved.source,
13361
- last_verified_at: new Date().toISOString()
13362
- });
13762
+ if (!isLocalStoreRefused2()) {
13763
+ upsertHookRecord2(getDb2(), {
13764
+ name: resolved.name,
13765
+ version: resolved.version,
13766
+ sha256: hash,
13767
+ source_type: resolved.source,
13768
+ last_verified_at: new Date().toISOString()
13769
+ });
13770
+ }
13363
13771
  results.push({ ...result, pinned: { version: resolved.version, sha256: hash } });
13364
13772
  continue;
13365
13773
  }
@@ -13637,8 +14045,7 @@ program2.command("profile-import").argument("<file>", "JSON file to import profi
13637
14045
  });
13638
14046
  var logCmd = program2.command("log").description(`Query hook event logs from SQLite (${getReportedDbPath()})`);
13639
14047
  logCmd.command("list").description("List hook events").option("--hook <name>", "Filter by hook name").option("--session <id>", "Filter by session ID").option("-n, --limit <n>", "Number of rows to show", "50").option("-j, --json", "Output as JSON", false).action(async (options) => {
13640
- const { getDb: getDb2 } = await Promise.resolve().then(() => (init_db(), exports_db));
13641
- const db = getDb2();
14048
+ const db = await openLocalStoreOrExit(options.json);
13642
14049
  const limit = parseInt(options.limit) || 50;
13643
14050
  let sql = "SELECT * FROM hook_events WHERE 1=1";
13644
14051
  const params = [];
@@ -13675,8 +14082,7 @@ logCmd.command("list").description("List hook events").option("--hook <name>", "
13675
14082
  Compact rows shown. Use --json for full event records or --limit <n> to change row count.`));
13676
14083
  });
13677
14084
  logCmd.command("search <text>").description("Search hook events by tool_input or error text").option("-n, --limit <n>", "Number of rows to show", "50").option("-j, --json", "Output as JSON", false).action(async (text, options) => {
13678
- const { getDb: getDb2 } = await Promise.resolve().then(() => (init_db(), exports_db));
13679
- const db = getDb2();
14085
+ const db = await openLocalStoreOrExit(options.json);
13680
14086
  const limit = parseInt(options.limit) || 50;
13681
14087
  const q = `%${text}%`;
13682
14088
  const rows = db.query("SELECT * FROM hook_events WHERE tool_input LIKE ? OR error LIKE ? ORDER BY timestamp DESC LIMIT ?").all(q, q, limit);
@@ -13701,8 +14107,7 @@ logCmd.command("search <text>").description("Search hook events by tool_input or
13701
14107
  Compact rows shown. Use --json for full event records or --limit <n> to change row count.`));
13702
14108
  });
13703
14109
  logCmd.command("tail").description("Show most recent hook events").option("-n <n>", "Number of rows", "20").option("-j, --json", "Output as JSON", false).action(async (options) => {
13704
- const { getDb: getDb2 } = await Promise.resolve().then(() => (init_db(), exports_db));
13705
- const db = getDb2();
14110
+ const db = await openLocalStoreOrExit(options.json);
13706
14111
  const limit = parseInt(options.n) || 20;
13707
14112
  const rows = db.query("SELECT * FROM hook_events ORDER BY timestamp DESC LIMIT ?").all(limit);
13708
14113
  const projected = projectLogRows2(rows);
@@ -13727,8 +14132,7 @@ logCmd.command("tail").description("Show most recent hook events").option("-n <n
13727
14132
  Compact rows shown. Use --json for full event records or -n <n> to change row count.`));
13728
14133
  });
13729
14134
  logCmd.command("errors").description("Show hook events that contain errors").option("--since <duration>", "Only show errors since this duration (e.g. 1h, 30m, 7d)", "24h").option("-n, --limit <n>", "Number of rows to show", "50").option("-j, --json", "Output as JSON", false).action(async (options) => {
13730
- const { getDb: getDb2 } = await Promise.resolve().then(() => (init_db(), exports_db));
13731
- const db = getDb2();
14135
+ const db = await openLocalStoreOrExit(options.json);
13732
14136
  const limit = parseInt(options.limit) || 50;
13733
14137
  function parseDuration(s) {
13734
14138
  const m = s.match(/^(\d+)(s|m|h|d)$/);
@@ -13770,8 +14174,7 @@ logCmd.command("errors").description("Show hook events that contain errors").opt
13770
14174
  Compact rows shown. Use --json for full event records or --limit <n> to change row count.`));
13771
14175
  });
13772
14176
  logCmd.command("clear").description("Delete hook event logs").option("--hook <name>", "Only delete events for this hook").option("-y, --yes", "Skip confirmation prompt", false).action(async (options) => {
13773
- const { getDb: getDb2 } = await Promise.resolve().then(() => (init_db(), exports_db));
13774
- const db = getDb2();
14177
+ const db = await openLocalStoreOrExit(false);
13775
14178
  const countRow = options.hook ? db.query("SELECT COUNT(*) as n FROM hook_events WHERE hook_name = ?").get(options.hook) : db.query("SELECT COUNT(*) as n FROM hook_events").get();
13776
14179
  const count = countRow?.n ?? 0;
13777
14180
  if (count === 0) {
@@ -13793,19 +14196,28 @@ logCmd.command("clear").description("Delete hook event logs").option("--hook <na
13793
14196
  });
13794
14197
  var storageCmd = program2.command("storage").description("Sync local hook data with storage PostgreSQL");
13795
14198
  storageCmd.command("status").description("Show storage sync status").option("-j, --json", "Output as JSON", false).action(async (options) => {
13796
- const { getStorageStatus: getStorageStatus2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
13797
- const status = getStorageStatus2();
13798
- if (options.json) {
13799
- console.log(JSON.stringify(status, null, 2));
13800
- return;
13801
- }
13802
- console.log(chalk2.bold(`
14199
+ try {
14200
+ const { getStorageStatus: getStorageStatus2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
14201
+ const status = getStorageStatus2();
14202
+ if (options.json) {
14203
+ console.log(JSON.stringify(status, null, 2));
14204
+ return;
14205
+ }
14206
+ console.log(chalk2.bold(`
13803
14207
  Storage Status
13804
14208
  `));
13805
- console.log(` Configured: ${status.configured ? chalk2.green(`yes (${status.activeEnv})`) : chalk2.red("no")}`);
13806
- console.log(` Backend: ${status.backend}`);
13807
- console.log(` Tables: ${status.tables.join(", ")}`);
13808
- console.log(` Sync rows: ${status.sync.length}`);
14209
+ console.log(` Configured: ${status.configured ? chalk2.green(`yes (${status.activeEnv})`) : chalk2.red("no")}`);
14210
+ console.log(` Backend: ${status.backend}`);
14211
+ console.log(` Tables: ${status.tables.join(", ")}`);
14212
+ console.log(` Sync rows: ${status.sync.length}`);
14213
+ } catch (error) {
14214
+ const message = error instanceof Error ? error.message : String(error);
14215
+ if (options.json)
14216
+ console.log(JSON.stringify({ error: message }));
14217
+ else
14218
+ console.error(chalk2.red(`\u2717 ${message}`));
14219
+ process.exitCode = 1;
14220
+ }
13809
14221
  });
13810
14222
  storageCmd.command("push").description("Push local hook data to storage PostgreSQL").option("-t, --tables <tables>", "Comma-separated table names").option("-j, --json", "Output as JSON", false).action(async (options) => {
13811
14223
  try {
@@ -14015,6 +14427,13 @@ program2.command("mcp").option("-s, --stdio", "Use stdio transport (one process
14015
14427
  const { startSSEServer: startSSEServer2 } = await Promise.resolve().then(() => (init_server(), exports_server));
14016
14428
  await startSSEServer2({ port: options.port ? parseInt(options.port) : 39427 });
14017
14429
  } else {
14430
+ const { decideHooksMcpAuthority: decideHooksMcpAuthority2 } = await Promise.resolve().then(() => (init_authority(), exports_authority));
14431
+ try {
14432
+ decideHooksMcpAuthority2();
14433
+ } catch (error) {
14434
+ console.error(error instanceof Error ? error.message : String(error));
14435
+ process.exit(1);
14436
+ }
14018
14437
  const { createHooksServer: createHooksServer2 } = await Promise.resolve().then(() => (init_server(), exports_server));
14019
14438
  const { resolveMcpHttpPort: resolveMcpHttpPort2, startMcpHttpServer: startMcpHttpServer2 } = await Promise.resolve().then(() => (init_http(), exports_http));
14020
14439
  const args = options.port ? ["--port", options.port] : [];
@@ -14023,7 +14442,6 @@ program2.command("mcp").option("-s, --stdio", "Use stdio transport (one process
14023
14442
  });
14024
14443
  registerEventsCommands(program2, { source: "hooks" });
14025
14444
  var API_INDEPENDENT_COMMANDS = new Set([
14026
- "run",
14027
14445
  "serve",
14028
14446
  "mcp",
14029
14447
  "cf",
@@ -14035,11 +14453,11 @@ var API_INDEPENDENT_COMMANDS = new Set([
14035
14453
  "events"
14036
14454
  ]);
14037
14455
  function failClosedForMissingApiEnv(detail) {
14038
- console.error(chalk2.red(`hooks: no registry credential resolved and local mode is not explicitly enabled.
14456
+ console.error(chalk2.red(hooksFailClosedLine() + `
14039
14457
  ` + `The remote registry requires a STRICT pair \u2014 set HASNA_HOOKS_API_URL and HASNA_HOOKS_API_KEY, or the
14040
14458
  ` + `Keychain items hasna.credentials.hooks.api-url / .api-key, or ~/.hasna/hooks/config/credentials.
14041
- ` + `config.json (api_url / api_key_ref) is RETIRED and no longer read. Or set
14042
- ` + `HASNA_HOOKS_LOCAL=1 to explicitly opt into local mode (bundled registry + local store).
14459
+ config.json (api_url / api_key_ref) is RETIRED and no longer read. Or set
14460
+ HASNA_HOOKS_LOCAL=1 to explicitly opt into local mode (bundled registry + local store).
14043
14461
  ` + (detail ? `${detail}
14044
14462
  ` : "") + "Refusing to silently fall back to local storage."));
14045
14463
  process.exit(1);
@@ -14063,21 +14481,25 @@ function enforceTransportGate() {
14063
14481
  return;
14064
14482
  if (token !== null && API_INDEPENDENT_COMMANDS.has(token))
14065
14483
  return;
14066
- if (hasHooksEnvAuthorityIntent(process.env))
14484
+ if (hasHooksEnvAuthorityIntent(process.env)) {
14485
+ refuseLocalStore(hooksHostedRouteLocalStoreRefusal("configured by HASNA_HOOKS_API_URL / HASNA_HOOKS_API_KEY"));
14067
14486
  return;
14487
+ }
14068
14488
  if (isHooksLocalOptIn(process.env)) {
14069
14489
  announceHooksLocalMode();
14070
14490
  return;
14071
14491
  }
14492
+ let transport;
14072
14493
  try {
14073
- const transport = resolveHooksTransport(process.env);
14074
- if (transport.mode !== "remote" || !transport.authority) {
14075
- failClosedForMissingApiEnv();
14076
- }
14494
+ transport = resolveHooksTransport(process.env);
14077
14495
  } catch (error) {
14078
14496
  const message = error instanceof Error ? error.message : String(error);
14079
14497
  failClosedForMissingApiEnv(message);
14080
14498
  }
14499
+ if (transport.mode !== "remote" || !transport.authority) {
14500
+ failClosedForMissingApiEnv();
14501
+ }
14502
+ refuseLocalStore(hooksHostedRouteLocalStoreRefusal(transport.authority.v1BaseUrl));
14081
14503
  }
14082
14504
  enforceTransportGate();
14083
14505
  program2.parse();