@hasna/hooks 0.6.5 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -399,7 +399,61 @@ var init_db = __esm(() => {
399
399
  init_legacy_import();
400
400
  });
401
401
 
402
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/util.js
402
+ // src/lib/redact.ts
403
+ function redactText(value) {
404
+ let out = value;
405
+ for (const pattern of SECRET_SHAPES) {
406
+ out = out.replace(pattern, REDACTED);
407
+ }
408
+ return out;
409
+ }
410
+ function redactValue(value) {
411
+ if (typeof value === "string")
412
+ return redactText(value);
413
+ if (Array.isArray(value))
414
+ return value.map(redactValue);
415
+ if (value !== null && typeof value === "object") {
416
+ const out = {};
417
+ for (const [key, child] of Object.entries(value)) {
418
+ out[key] = SECRET_KEY_NAMES.test(key) ? REDACTED : redactValue(child);
419
+ }
420
+ return out;
421
+ }
422
+ return value;
423
+ }
424
+ function redactEventPayload(text) {
425
+ if (text === null || text === undefined)
426
+ return null;
427
+ try {
428
+ const parsed = JSON.parse(text);
429
+ return redactText(JSON.stringify(redactValue(parsed)));
430
+ } catch {
431
+ return redactText(text);
432
+ }
433
+ }
434
+ var REDACTED = "[REDACTED]", SECRET_KEY_NAMES, SECRET_SHAPES;
435
+ var init_redact = __esm(() => {
436
+ SECRET_KEY_NAMES = /^(key|keys|token|tokens|secret|secrets|password|passwd|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|refresh[_-]?token|authorization|auth|cookie|credential|credentials|session[_-]?id|otp|mfa[_-]?code|two[_-]?fa[_-]?code|verification[_-]?code|database_url|db_url|connection_string|dsn)$/i;
437
+ SECRET_SHAPES = [
438
+ /\bsk-(?:proj|svc|ant)-[A-Za-z0-9_-]{10,}/g,
439
+ /\bsk-[A-Za-z0-9]{16,}/g,
440
+ /\btpe_[A-Za-z0-9]{8,}/g,
441
+ /\b(?:rk|sk)_(?:live|test)_[A-Za-z0-9]{8,}/g,
442
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
443
+ /gh[pousr]_[A-Za-z0-9]{20,}/g,
444
+ /xox[baprs]-[A-Za-z0-9-]{20,}/g,
445
+ /AKIA[0-9A-Z]{16}/g,
446
+ /ASIA[0-9A-Z]{16}/g,
447
+ /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
448
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/g,
449
+ /-----BEGIN OPENSSH PRIVATE KEY-----/g,
450
+ /\bBearer\s+[A-Za-z0-9._~+/=-]{6,}/g,
451
+ /\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/gi,
452
+ /\b(?:password|passwd|pwd|token|api[_-]?key|secret|access[_-]?key|client[_-]?secret|authorization|auth|credential|database_url|db_url|connection_string|dsn)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;"']{6,})/gi
453
+ ];
454
+ });
455
+
456
+ // node_modules/zod/v3/helpers/util.js
403
457
  var util, objectUtil, ZodParsedType, getParsedType = (data) => {
404
458
  const t = typeof data;
405
459
  switch (t) {
@@ -530,7 +584,7 @@ var init_util = __esm(() => {
530
584
  ]);
531
585
  });
532
586
 
533
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/ZodError.js
587
+ // node_modules/zod/v3/ZodError.js
534
588
  var ZodIssueCode, quotelessJson = (obj) => {
535
589
  const json = JSON.stringify(obj, null, 2);
536
590
  return json.replace(/"([^"]+)":/g, "$1:");
@@ -651,7 +705,7 @@ var init_ZodError = __esm(() => {
651
705
  };
652
706
  });
653
707
 
654
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/locales/en.js
708
+ // node_modules/zod/v3/locales/en.js
655
709
  var errorMap = (issue, _ctx) => {
656
710
  let message;
657
711
  switch (issue.code) {
@@ -758,7 +812,7 @@ var init_en = __esm(() => {
758
812
  en_default = errorMap;
759
813
  });
760
814
 
761
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/errors.js
815
+ // node_modules/zod/v3/errors.js
762
816
  function setErrorMap(map) {
763
817
  overrideErrorMap = map;
764
818
  }
@@ -771,7 +825,7 @@ var init_errors = __esm(() => {
771
825
  overrideErrorMap = en_default;
772
826
  });
773
827
 
774
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/parseUtil.js
828
+ // node_modules/zod/v3/helpers/parseUtil.js
775
829
  function addIssueToContext(ctx, issueData) {
776
830
  const overrideMap = getErrorMap();
777
831
  const issue = makeIssue({
@@ -876,10 +930,10 @@ var init_parseUtil = __esm(() => {
876
930
  });
877
931
  });
878
932
 
879
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/typeAliases.js
933
+ // node_modules/zod/v3/helpers/typeAliases.js
880
934
  var init_typeAliases = () => {};
881
935
 
882
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/errorUtil.js
936
+ // node_modules/zod/v3/helpers/errorUtil.js
883
937
  var errorUtil;
884
938
  var init_errorUtil = __esm(() => {
885
939
  (function(errorUtil2) {
@@ -888,7 +942,7 @@ var init_errorUtil = __esm(() => {
888
942
  })(errorUtil || (errorUtil = {}));
889
943
  });
890
944
 
891
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/types.js
945
+ // node_modules/zod/v3/types.js
892
946
  class ParseInputLazyPath {
893
947
  constructor(parent, value, path, key) {
894
948
  this._cachedPath = [];
@@ -4239,7 +4293,7 @@ var init_types = __esm(() => {
4239
4293
  NEVER = INVALID;
4240
4294
  });
4241
4295
 
4242
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/external.js
4296
+ // node_modules/zod/v3/external.js
4243
4297
  var exports_external = {};
4244
4298
  __export(exports_external, {
4245
4299
  void: () => voidType,
@@ -4359,7 +4413,7 @@ var init_external = __esm(() => {
4359
4413
  init_ZodError();
4360
4414
  });
4361
4415
 
4362
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/index.js
4416
+ // node_modules/zod/index.js
4363
4417
  var init_zod = __esm(() => {
4364
4418
  init_external();
4365
4419
  init_external();
@@ -4414,24 +4468,30 @@ function resolveApiUrl() {
4414
4468
  const cfg = readConfig();
4415
4469
  return cfg.api_url ? cfg.api_url.replace(/\/+$/, "") : undefined;
4416
4470
  }
4417
- function resolveApiKey(flagKey) {
4418
- if (flagKey && flagKey.length > 0)
4419
- return flagKey;
4471
+ function resolveApiKey() {
4420
4472
  return process.env.HASNA_HOOKS_API_KEY ?? process.env.HOOKS_API_KEY;
4421
4473
  }
4422
4474
  var init_config = () => {};
4423
4475
 
4476
+ // src/lib/semver.ts
4477
+ var SEMVER_PATTERN;
4478
+ var init_semver = __esm(() => {
4479
+ SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
4480
+ });
4481
+
4424
4482
  // src/lib/manifest.ts
4425
4483
  var exports_manifest = {};
4426
4484
  __export(exports_manifest, {
4427
4485
  writeCustomHook: () => writeCustomHook,
4428
4486
  shortManifestName: () => shortManifestName,
4487
+ scriptRelFor: () => scriptRelFor,
4429
4488
  resolveScript: () => resolveScript,
4430
4489
  readCustomManifest: () => readCustomManifest,
4431
4490
  parseManifest: () => parseManifest,
4432
4491
  normalizeManifestName: () => normalizeManifestName,
4433
4492
  manifestSchema: () => manifestSchema,
4434
4493
  listCustomHooks: () => listCustomHooks,
4494
+ isInlineScript: () => isInlineScript,
4435
4495
  customHookDir: () => customHookDir,
4436
4496
  assertContained: () => assertContained,
4437
4497
  ScriptContainmentError: () => ScriptContainmentError,
@@ -4484,16 +4544,25 @@ function assertContained(target, root, raw) {
4484
4544
  assertContainedReal(root, lexical, raw);
4485
4545
  return lexical;
4486
4546
  }
4487
- function resolveScript(manifest, manifestDir) {
4488
- if (manifest.script.includes(`
4489
- `)) {
4547
+ function isInlineScript(manifest) {
4548
+ return manifest.script_kind === "inline" || manifest.script_kind === undefined && manifest.script.includes(`
4549
+ `);
4550
+ }
4551
+ function scriptRelFor(manifest) {
4552
+ if (isInlineScript(manifest)) {
4490
4553
  const ext = manifest.script.trim().startsWith("#!") ? ".sh" : ".ts";
4491
- return { path: `script${ext}`, content: manifest.script };
4554
+ return `script${ext}`;
4492
4555
  }
4493
4556
  const rel = normalize(manifest.script);
4494
4557
  if (isAbsolute(rel)) {
4495
4558
  throw new Error(`manifest script must be relative or inline, got absolute path '${manifest.script}'`);
4496
4559
  }
4560
+ return rel;
4561
+ }
4562
+ function resolveScript(manifest, manifestDir) {
4563
+ if (isInlineScript(manifest))
4564
+ return { path: scriptRelFor(manifest), content: manifest.script };
4565
+ const rel = scriptRelFor(manifest);
4497
4566
  const resolved = assertContained(join4(manifestDir, rel), manifestDir, manifest.script);
4498
4567
  if (!existsSync4(resolved)) {
4499
4568
  throw new Error(`manifest script file not found: ${resolved}`);
@@ -4511,8 +4580,8 @@ function readCustomManifest(name) {
4511
4580
  try {
4512
4581
  const manifest = parseManifest(readFileSync3(manifestPath, "utf-8"));
4513
4582
  const script = resolveScript(manifest, dir);
4514
- return { manifest, scriptPath: join4(dir, script.path), scriptContent: script.content, scriptIsInline: manifest.script.includes(`
4515
- `) };
4583
+ const scriptIsInline = isInlineScript(manifest);
4584
+ return { manifest, scriptPath: join4(dir, script.path), scriptContent: script.content, scriptIsInline };
4516
4585
  } catch (err) {
4517
4586
  if (err instanceof ScriptContainmentError)
4518
4587
  throw err;
@@ -4559,13 +4628,15 @@ var HOOK_NAME_RE, manifestSchema, ScriptContainmentError;
4559
4628
  var init_manifest = __esm(() => {
4560
4629
  init_zod();
4561
4630
  init_config();
4631
+ init_semver();
4562
4632
  HOOK_NAME_RE = /^[\w-]+$/;
4563
4633
  manifestSchema = exports_external.object({
4564
4634
  name: exports_external.string().regex(HOOK_NAME_RE, "hook name must be /^[\\w-]+$/"),
4565
- version: exports_external.string().regex(/^\d+\.\d+\.\d+/, "version must be semver (e.g. 1.2.3)"),
4635
+ version: exports_external.string().regex(SEMVER_PATTERN, "version must be full semver (e.g. 1.2.3 or 1.2.3-beta.1)"),
4566
4636
  description: exports_external.string().optional(),
4567
4637
  events: exports_external.array(exports_external.string()).min(1, "at least one event is required"),
4568
4638
  script: exports_external.string().min(1, "script must name a relative path or inline content"),
4639
+ script_kind: exports_external.enum(["inline", "file"]).optional(),
4569
4640
  args: exports_external.array(exports_external.string()).optional(),
4570
4641
  timeout_ms: exports_external.number().int().positive().optional()
4571
4642
  });
@@ -4595,11 +4666,13 @@ __export(exports_store, {
4595
4666
  listHookRecords: () => listHookRecords,
4596
4667
  getPinnedHook: () => getPinnedHook,
4597
4668
  getHookRecord: () => getHookRecord,
4598
- checkScriptHash: () => checkScriptHash
4669
+ checkScriptHash: () => checkScriptHash,
4670
+ LockFileError: () => LockFileError
4599
4671
  });
4600
4672
  import { createHash } from "crypto";
4601
- import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
4673
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3, renameSync } from "fs";
4602
4674
  import { readFile } from "fs/promises";
4675
+ import { randomBytes } from "crypto";
4603
4676
  function sha256Of(input) {
4604
4677
  return createHash("sha256").update(input).digest("hex");
4605
4678
  }
@@ -4642,16 +4715,39 @@ function removeHookRecord(db, name) {
4642
4715
  const res = db.run("DELETE FROM hooks WHERE name = ?", [name]);
4643
4716
  return res.changes > 0;
4644
4717
  }
4718
+ function assertLockShape(parsed, path) {
4719
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
4720
+ throw new LockFileError(`hooks.lock (${path}) is malformed: expected a JSON object with a "hooks" map. ` + `Repair: fix the file by hand, or if it is unrecoverable move it aside and run 'hooks sync' to rebuild it. ` + `Nothing was trusted or rewritten \u2014 hooks are left in the refuse-to-run state until the lock is repaired.`);
4721
+ }
4722
+ const hooks = parsed.hooks;
4723
+ if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) {
4724
+ throw new LockFileError(`hooks.lock (${path}) is malformed: missing or invalid "hooks" map. ` + `Repair: fix the file by hand, or if it is unrecoverable move it aside and run 'hooks sync' to rebuild it. ` + `Nothing was trusted or rewritten \u2014 hooks are left in the refuse-to-run state until the lock is repaired.`);
4725
+ }
4726
+ for (const [name, entry] of Object.entries(hooks)) {
4727
+ const e = entry;
4728
+ if (e === null || typeof e !== "object" || typeof e.version !== "string" || typeof e.sha256 !== "string") {
4729
+ throw new LockFileError(`hooks.lock (${path}) is malformed: entry for '${name}' is not a valid pin (version and sha256 strings required). ` + `Repair: fix the file by hand, or if it is unrecoverable move it aside and run 'hooks sync' to rebuild it.`);
4730
+ }
4731
+ }
4732
+ }
4645
4733
  function readLock() {
4734
+ const path = getLockPath();
4735
+ if (!existsSync6(path))
4736
+ return { hooks: {} };
4737
+ let raw;
4646
4738
  try {
4647
- const path = getLockPath();
4648
- if (!existsSync6(path))
4649
- return { hooks: {} };
4650
- const parsed = JSON.parse(readFileSync4(path, "utf-8"));
4651
- return { hooks: parsed.hooks ?? {} };
4739
+ raw = readFileSync4(path, "utf-8");
4652
4740
  } catch {
4653
- return { hooks: {} };
4741
+ throw new LockFileError(`hooks.lock (${path}) could not be read. Repair: fix permissions or move the file aside and run 'hooks sync'.`);
4742
+ }
4743
+ let parsed;
4744
+ try {
4745
+ parsed = JSON.parse(raw);
4746
+ } catch {
4747
+ throw new LockFileError(`hooks.lock (${path}) is malformed: not valid JSON (${String((raw ?? "").slice(0, 80))}). ` + `Repair: fix the file by hand, or if it is unrecoverable move it aside and run 'hooks sync' to rebuild it. ` + `Nothing was trusted or rewritten \u2014 hooks are left in the refuse-to-run state until the lock is repaired.`);
4654
4748
  }
4749
+ assertLockShape(parsed, path);
4750
+ return parsed;
4655
4751
  }
4656
4752
  function writeLock(lock) {
4657
4753
  const path = getLockPath();
@@ -4660,8 +4756,10 @@ function writeLock(lock) {
4660
4756
  for (const name of Object.keys(lock.hooks).sort()) {
4661
4757
  sorted[name] = lock.hooks[name];
4662
4758
  }
4663
- writeFileSync3(path, JSON.stringify({ hooks: sorted }, null, 2) + `
4759
+ const tmp = `${path}.tmp-${randomBytes(8).toString("hex")}`;
4760
+ writeFileSync3(tmp, JSON.stringify({ hooks: sorted }, null, 2) + `
4664
4761
  `, "utf-8");
4762
+ renameSync(tmp, path);
4665
4763
  return path;
4666
4764
  }
4667
4765
  function setPinnedHook(name, entry) {
@@ -4747,9 +4845,16 @@ function retrustHook(name, scriptPath, version, source) {
4747
4845
  setPinnedHook(name, { version, sha256: actual, source });
4748
4846
  return { ok: true, pinned: true, expected: actual, actual, name };
4749
4847
  }
4848
+ var LockFileError;
4750
4849
  var init_store = __esm(() => {
4751
4850
  init_db();
4752
4851
  init_config();
4852
+ LockFileError = class LockFileError extends Error {
4853
+ constructor(message) {
4854
+ super(message);
4855
+ this.name = "LockFileError";
4856
+ }
4857
+ };
4753
4858
  });
4754
4859
 
4755
4860
  // src/lib/profiles.ts
@@ -4895,13 +5000,73 @@ var init_profiles = __esm(() => {
4895
5000
  PROFILES_DIR = resolveProfilesDir();
4896
5001
  });
4897
5002
 
5003
+ // src/lib/hook-env.ts
5004
+ function isDeniedEnvName(name) {
5005
+ const upper = name.toUpperCase();
5006
+ if (DENY_PREFIXES.some((prefix) => upper.startsWith(prefix)))
5007
+ return true;
5008
+ if (DENY_SUFFIXES.some((suffix) => upper.endsWith(suffix)))
5009
+ return true;
5010
+ return DENY_CONTAINS.some((token) => upper.includes(token));
5011
+ }
5012
+ function buildHookEnv(source, extra) {
5013
+ const out = {};
5014
+ for (const name of ALLOWLIST) {
5015
+ const value = source[name] ?? extra?.[name];
5016
+ if (value !== undefined && value !== null)
5017
+ out[name] = value;
5018
+ }
5019
+ for (const [from, to] of CONFIG_PROJECTIONS) {
5020
+ const value = source[from] ?? extra?.[from];
5021
+ if (value !== undefined && value !== null)
5022
+ out[to] = value;
5023
+ }
5024
+ const merged = { ...source, ...extra ?? {} };
5025
+ for (const [name, value] of Object.entries(merged)) {
5026
+ if (out[name] !== undefined)
5027
+ continue;
5028
+ if (isDeniedEnvName(name))
5029
+ continue;
5030
+ if (value !== undefined && value !== null)
5031
+ out[name] = value;
5032
+ }
5033
+ return out;
5034
+ }
5035
+ var ALLOWLIST, DENY_SUFFIXES, DENY_PREFIXES, DENY_CONTAINS, CONFIG_PROJECTIONS;
5036
+ var init_hook_env = __esm(() => {
5037
+ ALLOWLIST = new Set(["PATH", "HOME", "LANG", "TZ", "SHELL", "TERM", "USER", "PWD"]);
5038
+ DENY_SUFFIXES = ["KEY", "TOKEN", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", "CREDENTIALS", "URL", "URI"];
5039
+ DENY_PREFIXES = [
5040
+ "HASNA_",
5041
+ "AWS_",
5042
+ "AZURE_",
5043
+ "GCP_",
5044
+ "VAULT_",
5045
+ "GOOGLE_",
5046
+ "OPENAI_",
5047
+ "ANTHROPIC_",
5048
+ "POSTGRES_",
5049
+ "MYSQL_",
5050
+ "REDIS_",
5051
+ "MONGO_",
5052
+ "MEMENTOS_"
5053
+ ];
5054
+ DENY_CONTAINS = ["DATABASE_URL"];
5055
+ CONFIG_PROJECTIONS = [
5056
+ ["HASNA_HOOKS_DATA_DIR", "HOOKS_DATA_DIR"],
5057
+ ["HASNA_HOOKS_DB_PATH", "HOOKS_DB_PATH"],
5058
+ ["HASNA_HOOKS_LOCK_PATH", "HOOKS_LOCK_PATH"],
5059
+ ["HASNA_HOOKS_CONFIG_PATH", "HOOKS_CONFIG_PATH"]
5060
+ ];
5061
+ });
5062
+
4898
5063
  // src/lib/run.ts
4899
5064
  var exports_run = {};
4900
5065
  __export(exports_run, {
4901
5066
  executeVerifiedScript: () => executeVerifiedScript,
4902
5067
  HookTimeoutError: () => HookTimeoutError
4903
5068
  });
4904
- import { randomBytes } from "crypto";
5069
+ import { randomBytes as randomBytes2 } from "crypto";
4905
5070
  import { rmSync as rmSync4, writeFileSync as writeFileSync6 } from "fs";
4906
5071
  import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute2, join as join9 } from "path";
4907
5072
  function extensionOf(scriptPath) {
@@ -4974,7 +5139,7 @@ async function readPipeWithDeadline(stream, deadlineMs) {
4974
5139
  async function executeVerifiedScript(options) {
4975
5140
  const scriptDir = dirname4(options.scriptPath);
4976
5141
  const interpreter = interpreterFor(options.name, options.scriptPath, options.content);
4977
- const tempPath = join9(scriptDir, `.hooks-verified-${randomBytes(12).toString("hex")}.${interpreter.tempExt}`);
5142
+ const tempPath = join9(scriptDir, `.hooks-verified-${randomBytes2(12).toString("hex")}.${interpreter.tempExt}`);
4978
5143
  let proc = null;
4979
5144
  let timedOut = false;
4980
5145
  const timeoutTimer = options.timeout ? setTimeout(() => {
@@ -4987,7 +5152,7 @@ async function executeVerifiedScript(options) {
4987
5152
  stdin: new Response(options.stdin),
4988
5153
  stdout: "pipe",
4989
5154
  stderr: "pipe",
4990
- env: options.env ?? process.env,
5155
+ env: buildHookEnv(process.env, options.env),
4991
5156
  detached: true
4992
5157
  });
4993
5158
  const exitCode = await proc.exited;
@@ -5045,6 +5210,7 @@ function killGroup(proc, options) {
5045
5210
  }
5046
5211
  var HookTimeoutError, BASH_EXTENSIONS, BUN_EXTENSIONS;
5047
5212
  var init_run = __esm(() => {
5213
+ init_hook_env();
5048
5214
  HookTimeoutError = class HookTimeoutError extends Error {
5049
5215
  timeoutMs;
5050
5216
  constructor(timeoutMs) {
@@ -5073,6 +5239,9 @@ function writeHookEvent(event) {
5073
5239
  const db = getDb();
5074
5240
  const id = nanoid2();
5075
5241
  const timestamp = event.timestamp ?? new Date().toISOString();
5242
+ const toolInput = redactEventPayload(event.tool_input);
5243
+ const error = redactEventPayload(event.error);
5244
+ const metadata = redactEventPayload(event.metadata);
5076
5245
  db.run(`INSERT INTO hook_events
5077
5246
  (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
5078
5247
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
@@ -5082,12 +5251,12 @@ function writeHookEvent(event) {
5082
5251
  event.hook_name,
5083
5252
  event.event_type,
5084
5253
  event.tool_name ?? null,
5085
- event.tool_input ? event.tool_input.slice(0, 500) : null,
5254
+ toolInput ? toolInput.slice(0, 500) : null,
5086
5255
  event.result ?? null,
5087
- event.error ?? null,
5256
+ error ?? null,
5088
5257
  event.duration_ms ?? null,
5089
5258
  event.project_dir ?? null,
5090
- event.metadata ?? null
5259
+ metadata ?? null
5091
5260
  ]);
5092
5261
  } catch (err) {
5093
5262
  process.stderr.write(`[hooks db-writer] failed to write event: ${err}
@@ -5137,6 +5306,7 @@ function recordHookRun(record) {
5137
5306
  var ALLOWED_EVENT_TYPES;
5138
5307
  var init_db_writer = __esm(() => {
5139
5308
  init_db();
5309
+ init_redact();
5140
5310
  ALLOWED_EVENT_TYPES = new Set([
5141
5311
  "PreToolUse",
5142
5312
  "PostToolUse",
@@ -5159,7 +5329,7 @@ var PG_MIGRATIONS = [
5159
5329
  timestamp TEXT NOT NULL,
5160
5330
  session_id TEXT NOT NULL,
5161
5331
  hook_name TEXT NOT NULL,
5162
- event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit')),
5332
+ event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'SubagentStart')),
5163
5333
  tool_name TEXT,
5164
5334
  tool_input TEXT,
5165
5335
  result TEXT CHECK (result IN ('continue', 'block', NULL)),
@@ -5192,7 +5362,10 @@ var PG_MIGRATIONS = [
5192
5362
  )`,
5193
5363
  `ALTER TABLE hook_events DROP CONSTRAINT IF EXISTS hook_events_event_type_check`,
5194
5364
  `ALTER TABLE hook_events ADD CONSTRAINT hook_events_event_type_check
5195
- CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit'))`
5365
+ CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit'))`,
5366
+ `ALTER TABLE hook_events DROP CONSTRAINT IF EXISTS hook_events_event_type_check`,
5367
+ `ALTER TABLE hook_events ADD CONSTRAINT hook_events_event_type_check
5368
+ CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'SubagentStart'))`
5196
5369
  ];
5197
5370
 
5198
5371
  // src/db/remote-storage.ts
@@ -5206,7 +5379,34 @@ function normalizeParams(params) {
5206
5379
  return flat.map((value) => value === undefined ? null : value);
5207
5380
  }
5208
5381
  function sslConfigFor(connectionString) {
5209
- return connectionString.includes("sslmode=require") || connectionString.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
5382
+ const wantsSsl = wantsSslFor(connectionString);
5383
+ if (!wantsSsl)
5384
+ return;
5385
+ const insecure = process.env.HASNA_HOOKS_PG_INSECURE_TLS === "1" || process.env.HOOKS_PG_INSECURE_TLS === "1";
5386
+ if (insecure && false) {}
5387
+ return insecure ? { rejectUnauthorized: false } : { rejectUnauthorized: true };
5388
+ }
5389
+ function wantsSslFor(connectionString) {
5390
+ let raw;
5391
+ try {
5392
+ raw = new URL(connectionString).search.replace(/^\?/, "");
5393
+ } catch {
5394
+ raw = connectionString;
5395
+ }
5396
+ try {
5397
+ raw = decodeURIComponent(raw);
5398
+ } catch {}
5399
+ const paramValue = (name) => {
5400
+ const match = raw.match(new RegExp(`(?:^|[&\\s])${name}\\s*=\\s*([^&\\s]+)`, "i"));
5401
+ return match ? match[1].toLowerCase() : undefined;
5402
+ };
5403
+ const sslmode = paramValue("sslmode");
5404
+ if (sslmode !== undefined)
5405
+ return sslmode !== "disable" && sslmode !== "allow";
5406
+ const ssl = paramValue("ssl");
5407
+ if (ssl !== undefined)
5408
+ return ssl !== "false" && ssl !== "0" && ssl !== "no";
5409
+ return false;
5210
5410
  }
5211
5411
 
5212
5412
  class PgAdapterAsync {
@@ -5228,6 +5428,7 @@ class PgAdapterAsync {
5228
5428
  }
5229
5429
 
5230
5430
  // src/db/storage-sync.ts
5431
+ init_redact();
5231
5432
  var STORAGE_TABLES = [
5232
5433
  "hook_events",
5233
5434
  "schema_migrations",
@@ -5404,9 +5605,15 @@ async function pushTable(db, remote, table) {
5404
5605
  result.rowsRead = rows.length;
5405
5606
  if (rows.length === 0)
5406
5607
  return result;
5608
+ const projected = table === "hook_events" ? rows.map((row) => ({
5609
+ ...row,
5610
+ tool_input: typeof row.tool_input === "string" ? redactEventPayload(row.tool_input) : row.tool_input,
5611
+ error: typeof row.error === "string" ? redactEventPayload(row.error) : row.error,
5612
+ metadata: typeof row.metadata === "string" ? redactEventPayload(row.metadata) : row.metadata
5613
+ })) : rows;
5407
5614
  const remoteColumns = await getRemoteColumns(remote, table);
5408
- const columns = filterRemoteColumns(remoteColumns, Object.keys(rows[0]));
5409
- result.rowsWritten = await upsertPg(remote, table, columns, rows, remoteColumns);
5615
+ const columns = filterRemoteColumns(remoteColumns, Object.keys(projected[0]));
5616
+ result.rowsWritten = await upsertPg(remote, table, columns, projected, remoteColumns);
5410
5617
  } catch (error) {
5411
5618
  result.errors.push(error instanceof Error ? error.message : String(error));
5412
5619
  }
@@ -5421,8 +5628,14 @@ async function pullTable(remote, db, table) {
5421
5628
  result.rowsRead = rows.length;
5422
5629
  if (rows.length === 0)
5423
5630
  return result;
5424
- const columns = filterLocalColumns(db, table, Object.keys(rows[0]));
5425
- result.rowsWritten = upsertSqlite(db, table, columns, rows);
5631
+ const projected = table === "hook_events" ? rows.map((row) => ({
5632
+ ...row,
5633
+ tool_input: typeof row.tool_input === "string" ? redactEventPayload(row.tool_input) : row.tool_input,
5634
+ error: typeof row.error === "string" ? redactEventPayload(row.error) : row.error,
5635
+ metadata: typeof row.metadata === "string" ? redactEventPayload(row.metadata) : row.metadata
5636
+ })) : rows;
5637
+ const columns = filterLocalColumns(db, table, Object.keys(projected[0]));
5638
+ result.rowsWritten = upsertSqlite(db, table, columns, projected);
5426
5639
  } catch (error) {
5427
5640
  result.errors.push(error instanceof Error ? error.message : String(error));
5428
5641
  }
@@ -6236,7 +6449,7 @@ function getGlobalSettingsPathOverride(target) {
6236
6449
  function getSettingsPath(scope = "global", target = "claude", codewithConfigPath) {
6237
6450
  if (target === "codewith" && codewithConfigPath)
6238
6451
  return codewithConfigPath;
6239
- if (target === "codewith" && process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH) {
6452
+ if (target === "codewith" && scope === "global" && process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH) {
6240
6453
  return process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH;
6241
6454
  }
6242
6455
  const globalOverride = scope === "global" ? getGlobalSettingsPathOverride(target) : undefined;
@@ -6670,8 +6883,8 @@ function escapeRegExp(value) {
6670
6883
  function codewithHasHookEntryInText(configText, name) {
6671
6884
  return new RegExp(`hooks run ${escapeRegExp(name)}(?:\\s+--profile\\s+[\\w-]+)?(?:$|\\s|")`).test(configText);
6672
6885
  }
6673
- function codewithHasHookEntry(name) {
6674
- const path = getSettingsPath("global", "codewith");
6886
+ function codewithHasHookEntry(name, scope = "global") {
6887
+ const path = getSettingsPath(scope, "codewith");
6675
6888
  if (!existsSync7(path))
6676
6889
  return false;
6677
6890
  try {
@@ -6688,7 +6901,7 @@ function uninstallHook(name, scope = "global", target = "claude") {
6688
6901
  const registeredClaudeOrGemini = target === "all" ? existsInTarget("claude") || existsInTarget("gemini") : false;
6689
6902
  const registeredGlobal = registeredClaudeOrGemini || getRegisteredHooksForTarget("global", target === "all" ? "claude" : target).includes(shortName);
6690
6903
  const registeredProject = registeredClaudeOrGemini || getRegisteredHooksForTarget("project", target === "all" ? "claude" : target).includes(shortName);
6691
- const codewithOnly = (target === "codewith" || target === "all") && codewithHasHookEntry(shortName);
6904
+ const codewithOnly = (target === "codewith" || target === "all") && codewithHasHookEntry(shortName, scope);
6692
6905
  if (!custom2 && !bundledMeta && !registeredGlobal && !registeredProject && !codewithOnly) {
6693
6906
  return { name: shortName, removed: false, source: null, settingsScopes: [], storeDirRemoved: false, pinRemoved: false, dbRecordRemoved: false, registrationsRemaining: [], error: `Hook '${shortName}' not found` };
6694
6907
  }
@@ -6705,7 +6918,7 @@ function uninstallHook(name, scope = "global", target = "claude") {
6705
6918
  }
6706
6919
  const registrationsRemaining = [];
6707
6920
  if (target === "codewith" || target === "all") {
6708
- const codewithPath = getSettingsPath("global", "codewith");
6921
+ const codewithPath = getSettingsPath(scope, "codewith");
6709
6922
  if (existsSync7(codewithPath)) {
6710
6923
  const before = readFileSync5(codewithPath, "utf-8");
6711
6924
  const after = removeCodewithHookEntry(before, shortName);
@@ -6831,19 +7044,20 @@ async function installCustomSource(source) {
6831
7044
  return copyManifestDir(source, source, "local");
6832
7045
  }
6833
7046
  async function installManifestUrl(url) {
6834
- const res = await fetch(url, { redirect: "follow" });
7047
+ const res = await fetch(url, { redirect: "error" });
6835
7048
  if (!res.ok) {
6836
7049
  throw new Error(`Failed to fetch manifest (${res.status}): ${url}`);
6837
7050
  }
6838
7051
  const manifest = parseManifest(await res.text());
6839
7052
  let script;
6840
- if (manifest.script.includes(`
6841
- `)) {
7053
+ const inline = manifest.script_kind === "inline" || manifest.script_kind === undefined && manifest.script.includes(`
7054
+ `);
7055
+ if (inline) {
6842
7056
  script = { path: "script.ts", content: manifest.script };
6843
7057
  } else {
6844
7058
  const base = url.substring(0, url.lastIndexOf("/"));
6845
7059
  const scriptUrl = `${base}/${manifest.script}`;
6846
- const scriptRes = await fetch(scriptUrl, { redirect: "follow" });
7060
+ const scriptRes = await fetch(scriptUrl, { redirect: "error" });
6847
7061
  if (!scriptRes.ok) {
6848
7062
  throw new Error(`Failed to fetch script (${scriptRes.status}): ${scriptUrl}`);
6849
7063
  }
@@ -6880,7 +7094,7 @@ function collectBundledCatalog() {
6880
7094
  }
6881
7095
  return out;
6882
7096
  }
6883
- async function fetchJson(base, path) {
7097
+ async function fetchJson(base, path, opts) {
6884
7098
  const headers = { accept: "application/json" };
6885
7099
  const apiKey = resolveApiKey();
6886
7100
  if (apiKey)
@@ -6896,7 +7110,11 @@ async function fetchJson(base, path) {
6896
7110
  if (!res.ok) {
6897
7111
  throw new Error(`GET ${path} failed with status ${res.status}`);
6898
7112
  }
6899
- return res.json();
7113
+ const body = await res.json();
7114
+ if (opts?.withHeader) {
7115
+ body[opts.withHeader] = res.headers.get("x-hook-sha256");
7116
+ }
7117
+ return body;
6900
7118
  }
6901
7119
  async function fetchRemoteState(apiUrl) {
6902
7120
  const catalog = await fetchJson(apiUrl, "/api/v1/catalog");
@@ -6909,6 +7127,16 @@ async function fetchRemoteState(apiUrl) {
6909
7127
  }
6910
7128
  return { catalog: catalog.hooks, remoteLock };
6911
7129
  }
7130
+ function classifyRemotePin(pin, remoteVersion, remoteSha) {
7131
+ const pinnedLocally = pin !== undefined && pin.version === remoteVersion && pin.sha256 === remoteSha;
7132
+ if (pinnedLocally)
7133
+ return "unchanged";
7134
+ if (pin !== undefined && pin.pinned === true && pin.version !== remoteVersion)
7135
+ return "unchanged";
7136
+ if (pin !== undefined)
7137
+ return "updated";
7138
+ return "added";
7139
+ }
6912
7140
  function computeDiff(catalog, remoteLock) {
6913
7141
  const local = readLock();
6914
7142
  const diff = { added: [], updated: [], unchanged: [], skipped: [] };
@@ -6916,30 +7144,30 @@ function computeDiff(catalog, remoteLock) {
6916
7144
  if (!entry.name || !entry.version || !entry.sha256)
6917
7145
  continue;
6918
7146
  const pin = local.hooks[entry.name];
6919
- const remotePin = remoteLock ? remoteLock.hooks[entry.name] : entry;
6920
- if (remoteLock && !remotePin) {
6921
- diff.skipped.push(entry.name);
6922
- continue;
6923
- }
6924
- const pinnedLocally = pin && pin.version === entry.version && pin.sha256 === entry.sha256;
6925
- if (pinnedLocally) {
6926
- diff.unchanged.push(entry.name);
6927
- } else if (pin) {
6928
- diff.updated.push(entry.name);
7147
+ if (remoteLock) {
7148
+ const remotePin = remoteLock.hooks[entry.name];
7149
+ if (!remotePin) {
7150
+ diff.skipped.push(entry.name);
7151
+ continue;
7152
+ }
7153
+ if (remotePin.version !== entry.version) {
7154
+ throw new Error(`remote registry is ambiguous for '${entry.name}': lock pins ${remotePin.version} but catalog latest is ${entry.version}. ` + `Fix the registry (or its lock) before syncing \u2014 nothing was changed.`);
7155
+ }
7156
+ diff[classifyRemotePin(pin, remotePin.version, remotePin.sha256)].push(entry.name);
6929
7157
  } else {
6930
- diff.added.push(entry.name);
7158
+ diff[classifyRemotePin(pin, entry.version, entry.sha256)].push(entry.name);
6931
7159
  }
6932
7160
  }
6933
7161
  return diff;
6934
7162
  }
6935
- async function planSync() {
7163
+ async function planSync(options = {}) {
6936
7164
  const apiUrl = resolveApiUrl();
6937
7165
  if (apiUrl) {
6938
7166
  const { catalog, remoteLock } = await fetchRemoteState(apiUrl);
6939
- return { apiUrl, dryRun: false, diff: computeDiff(catalog, remoteLock) };
7167
+ return { apiUrl, dryRun: options.dryRun ?? false, diff: computeDiff(catalog, remoteLock) };
6940
7168
  }
6941
7169
  const diff = computeDiff(collectBundledCatalog(), null);
6942
- return { apiUrl: null, dryRun: false, diff };
7170
+ return { apiUrl: null, dryRun: options.dryRun ?? false, diff };
6943
7171
  }
6944
7172
  async function syncHooks(options = {}) {
6945
7173
  const apiUrl = resolveApiUrl();
@@ -6947,10 +7175,10 @@ async function syncHooks(options = {}) {
6947
7175
  if (options.dryRun)
6948
7176
  return { ...plan, dryRun: true };
6949
7177
  if (!apiUrl) {
6950
- const db2 = getDb();
7178
+ const db = getDb();
6951
7179
  for (const entry of collectBundledCatalog()) {
6952
7180
  setPinnedHook(entry.name, { version: entry.version, sha256: entry.sha256, source: "bundled" });
6953
- upsertHookRecord(db2, {
7181
+ upsertHookRecord(db, {
6954
7182
  name: entry.name,
6955
7183
  version: entry.version,
6956
7184
  sha256: entry.sha256,
@@ -6963,13 +7191,17 @@ async function syncHooks(options = {}) {
6963
7191
  }
6964
7192
  const { catalog, remoteLock } = await fetchRemoteState(apiUrl);
6965
7193
  const diff = computeDiff(catalog, remoteLock);
6966
- const toFetch = [...diff.added, ...diff.updated];
6967
- const artifacts = new Map;
6968
- for (const name of toFetch) {
7194
+ const staged = await stageSyncArtifacts(apiUrl, [...diff.added, ...diff.updated], remoteLock);
7195
+ await commitSyncArtifacts(staged, apiUrl, remoteLock);
7196
+ return { apiUrl, dryRun: false, diff };
7197
+ }
7198
+ async function stageSyncArtifacts(apiUrl, names, remoteLock) {
7199
+ const staged = [];
7200
+ for (const name of names) {
6969
7201
  const entry = remoteLock.hooks[name];
6970
7202
  if (!entry)
6971
7203
  continue;
6972
- const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${name}/${entry.version}`);
7204
+ const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${encodeURIComponent(name)}/${encodeURIComponent(entry.version)}`);
6973
7205
  if (!artifact.manifest || typeof artifact.script !== "string") {
6974
7206
  throw new Error(`artifact for '${name}@${entry.version}' is malformed`);
6975
7207
  }
@@ -6977,34 +7209,64 @@ async function syncHooks(options = {}) {
6977
7209
  if (actualSha !== entry.sha256) {
6978
7210
  throw new Error(`sha256 mismatch for '${name}@${entry.version}': lock says ${entry.sha256}, artifact has ${actualSha}`);
6979
7211
  }
6980
- artifacts.set(name, artifact);
6981
- }
6982
- const db = getDb();
6983
- for (const name of toFetch) {
6984
- const artifact = artifacts.get(name);
6985
- const entry = remoteLock.hooks[name];
6986
- if (!artifact || !entry)
6987
- continue;
6988
7212
  const manifest = parseManifest(JSON.stringify({ ...artifact.manifest, name: shortManifestName(artifact.manifest.name || name) }));
6989
- const scriptRel = artifact.manifest.script.includes(`
6990
- `) ? "script.ts" : artifact.manifest.script;
6991
- writeCustomHook(name, manifest, artifact.script, scriptRel);
6992
- const scriptPath = resolveScriptPath(name);
6993
- const actual = await sha256File(scriptPath);
6994
- if (actual !== entry.sha256) {
6995
- throw new Error(`sha256 mismatch after write for '${name}@${entry.version}': lock says ${entry.sha256}, on disk ${actual}`);
7213
+ if (manifest.version !== entry.version) {
7214
+ throw new Error(`artifact for '${name}@${entry.version}' declares a different version ('${manifest.version}')`);
6996
7215
  }
6997
- setPinnedHook(name, { version: entry.version, sha256: entry.sha256, source: entry.source ?? "remote" });
6998
- upsertHookRecord(db, {
7216
+ const scriptRel = scriptRelFor(manifest);
7217
+ staged.push({
6999
7218
  name,
7000
7219
  version: entry.version,
7001
7220
  sha256: entry.sha256,
7002
- source_type: entry.source ?? "remote",
7003
- source_ref: apiUrl,
7004
- last_verified_at: new Date().toISOString()
7221
+ source: entry.source ?? "remote",
7222
+ manifest,
7223
+ scriptContent: artifact.script,
7224
+ scriptRel
7005
7225
  });
7006
7226
  }
7007
- return { apiUrl, dryRun: false, diff };
7227
+ return staged;
7228
+ }
7229
+ async function commitSyncArtifacts(staged, apiUrl, remoteLock) {
7230
+ const db = getDb();
7231
+ for (const item of staged) {
7232
+ const entry = remoteLock.hooks[item.name];
7233
+ if (!entry)
7234
+ continue;
7235
+ writeCustomHook(item.name, item.manifest, item.scriptContent, item.scriptRel);
7236
+ const scriptPath = resolveScriptPath(item.name);
7237
+ const actual = await sha256File(scriptPath);
7238
+ if (actual !== entry.sha256) {
7239
+ throw new Error(`sha256 mismatch after write for '${item.name}@${entry.version}': lock says ${entry.sha256}, on disk ${actual}`);
7240
+ }
7241
+ }
7242
+ const lock = readLock();
7243
+ for (const item of staged) {
7244
+ const entry = remoteLock.hooks[item.name];
7245
+ if (!entry)
7246
+ continue;
7247
+ lock.hooks[item.name] = { version: entry.version, sha256: entry.sha256, source: entry.source ?? "remote" };
7248
+ }
7249
+ writeLock(lock);
7250
+ db.exec("BEGIN");
7251
+ try {
7252
+ for (const item of staged) {
7253
+ const entry = remoteLock.hooks[item.name];
7254
+ if (!entry)
7255
+ continue;
7256
+ upsertHookRecord(db, {
7257
+ name: item.name,
7258
+ version: entry.version,
7259
+ sha256: entry.sha256,
7260
+ source_type: entry.source ?? "remote",
7261
+ source_ref: apiUrl,
7262
+ last_verified_at: new Date().toISOString()
7263
+ });
7264
+ }
7265
+ db.exec("COMMIT");
7266
+ } catch (err) {
7267
+ db.exec("ROLLBACK");
7268
+ throw err;
7269
+ }
7008
7270
  }
7009
7271
  async function fetchPinnedHook(name, version, apiUrl) {
7010
7272
  const remoteLock = await fetchJson(apiUrl, "/api/v1/lock");
@@ -7012,10 +7274,11 @@ async function fetchPinnedHook(name, version, apiUrl) {
7012
7274
  if (!entry) {
7013
7275
  throw new Error(`Hook '${name}' is not in the remote registry lock`);
7014
7276
  }
7015
- if (entry.version !== version) {
7016
- throw new Error(`Hook '${name}' version ${version} is not in the remote registry (available: ${entry.version})`);
7277
+ const isLatest = entry.version === version;
7278
+ if (!isLatest && !(entry.versions ?? []).includes(version)) {
7279
+ throw new Error(`Hook '${name}' version ${version} is not in the remote registry (available: ${[entry.version, ...entry.versions ?? []].join(", ")})`);
7017
7280
  }
7018
- const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${name}/${version}`);
7281
+ const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, { withHeader: "_headerSha" });
7019
7282
  if (!artifact.manifest || typeof artifact.script !== "string") {
7020
7283
  throw new Error(`artifact for '${name}@${version}' is malformed`);
7021
7284
  }
@@ -7027,24 +7290,31 @@ async function fetchPinnedHook(name, version, apiUrl) {
7027
7290
  throw new Error(`artifact for '${name}@${version}' declares a different version ('${artifact.manifest.version}')`);
7028
7291
  }
7029
7292
  const actualSha = sha256OfText(artifact.script);
7030
- if (actualSha !== entry.sha256) {
7031
- throw new Error(`sha256 mismatch for '${name}@${version}': lock says ${entry.sha256}, artifact has ${actualSha}`);
7293
+ let expectedSha = entry.sha256;
7294
+ if (!isLatest) {
7295
+ const headerSha = artifact._headerSha;
7296
+ if (!headerSha) {
7297
+ throw new Error(`registry did not return a sha256 header for '${name}@${version}' (older pin); refusing to trust an unverified artifact`);
7298
+ }
7299
+ expectedSha = headerSha;
7300
+ }
7301
+ if (actualSha !== expectedSha) {
7302
+ throw new Error(`sha256 mismatch for '${name}@${version}': expected ${expectedSha}, artifact has ${actualSha}`);
7032
7303
  }
7033
7304
  const manifest = parseManifest(JSON.stringify({ ...artifact.manifest, name: shortManifestName(artifact.manifest.name || name) }));
7034
- const scriptRel = artifact.manifest.script.includes(`
7035
- `) ? "script.ts" : artifact.manifest.script;
7305
+ const scriptRel = scriptRelFor(manifest);
7036
7306
  writeCustomHook(name, manifest, artifact.script, scriptRel);
7037
7307
  const scriptPath = resolveScriptPath(name);
7038
7308
  const actual = await sha256File(scriptPath);
7039
- if (actual !== entry.sha256) {
7040
- throw new Error(`sha256 mismatch after write for '${name}@${version}': lock says ${entry.sha256}, on disk ${actual}`);
7309
+ if (actual !== expectedSha) {
7310
+ throw new Error(`sha256 mismatch after write for '${name}@${version}': expected ${expectedSha}, on disk ${actual}`);
7041
7311
  }
7042
7312
  const db = getDb();
7043
- setPinnedHook(name, { version, sha256: entry.sha256, source: entry.source ?? "remote" });
7313
+ setPinnedHook(name, { version, sha256: expectedSha, source: entry.source ?? "remote", pinned: true });
7044
7314
  upsertHookRecord(db, {
7045
7315
  name,
7046
7316
  version,
7047
- sha256: entry.sha256,
7317
+ sha256: expectedSha,
7048
7318
  source_type: entry.source ?? "remote",
7049
7319
  source_ref: apiUrl,
7050
7320
  last_verified_at: new Date().toISOString()
@@ -7052,7 +7322,7 @@ async function fetchPinnedHook(name, version, apiUrl) {
7052
7322
  return {
7053
7323
  name,
7054
7324
  version,
7055
- sha256: entry.sha256,
7325
+ sha256: expectedSha,
7056
7326
  source: entry.source ?? "remote",
7057
7327
  source_ref: apiUrl,
7058
7328
  artifact,
@@ -7078,6 +7348,7 @@ function secureEqual(a, b) {
7078
7348
  }
7079
7349
 
7080
7350
  // src/serve.ts
7351
+ init_semver();
7081
7352
  var DEFAULT_SERVE_PORT = 39428;
7082
7353
  var SERVE_HOST = "127.0.0.1";
7083
7354
  var SERVE_SERVICE_NAME = "hooks-registry";
@@ -7087,24 +7358,29 @@ async function buildCatalog() {
7087
7358
  const scriptPath = resolveHook(meta.name)?.scriptPath;
7088
7359
  if (!scriptPath)
7089
7360
  continue;
7361
+ const sha = await sha256File(scriptPath);
7090
7362
  byName.set(meta.name, {
7091
7363
  name: meta.name,
7092
7364
  version: meta.version,
7093
- sha256: await sha256File(scriptPath),
7365
+ sha256: sha,
7094
7366
  events: meta.events && meta.events.length > 0 ? meta.events : [meta.event],
7095
7367
  description: meta.description,
7096
- source: "bundled"
7368
+ source: "bundled",
7369
+ versions: [meta.version]
7097
7370
  });
7098
7371
  }
7099
7372
  for (const custom2 of listCustomHooks()) {
7100
7373
  const name = shortManifestName(custom2.manifest.name);
7374
+ const sha = await sha256File(custom2.scriptPath);
7375
+ const existing = byName.get(name);
7101
7376
  byName.set(name, {
7102
7377
  name,
7103
7378
  version: custom2.manifest.version,
7104
- sha256: await sha256File(custom2.scriptPath),
7379
+ sha256: sha,
7105
7380
  events: custom2.manifest.events,
7106
7381
  description: custom2.manifest.description ?? "Custom hook",
7107
- source: byName.has(name) ? "custom-overrides-bundled" : "custom"
7382
+ source: existing ? "custom-overrides-bundled" : "custom",
7383
+ versions: existing ? [...new Set([...existing.versions, custom2.manifest.version])] : [custom2.manifest.version]
7108
7384
  });
7109
7385
  }
7110
7386
  return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
@@ -7122,6 +7398,7 @@ async function artifactFor(name, version) {
7122
7398
  description: resolved.description,
7123
7399
  events: resolved.events,
7124
7400
  script: custom2 ? custom2.manifest.script : `src/hook.ts`,
7401
+ script_kind: custom2?.manifest.script_kind,
7125
7402
  args: custom2?.manifest.args,
7126
7403
  timeout_ms: custom2?.manifest.timeout_ms
7127
7404
  };
@@ -7151,11 +7428,32 @@ function handleServeRequest(req, apiKey) {
7151
7428
  return buildCatalog().then((catalog) => json({ hooks: catalog }));
7152
7429
  }
7153
7430
  if (url.pathname === "/api/v1/lock" && req.method === "GET") {
7154
- return Promise.resolve(json(readLock()));
7431
+ return buildCatalog().then((catalog) => {
7432
+ const lock = readLock();
7433
+ const byVersion = {};
7434
+ for (const entry of catalog)
7435
+ byVersion[entry.name] = entry.versions;
7436
+ const hooks = {};
7437
+ for (const [name, pin] of Object.entries(lock.hooks)) {
7438
+ hooks[name] = { ...pin, versions: byVersion[name] ?? [pin.version] };
7439
+ }
7440
+ return json({ hooks });
7441
+ });
7155
7442
  }
7156
- const artifactMatch = /^\/api\/v1\/hooks\/([\w-]+)\/(\d+\.\d+\.\d+)$/.exec(url.pathname);
7443
+ const artifactMatch = /^\/api\/v1\/hooks\/([\w-]+)\/([0-9A-Za-z.%+_-]+)$/.exec(url.pathname);
7157
7444
  if (artifactMatch && req.method === "GET") {
7158
- const [, name, version] = artifactMatch;
7445
+ const [, rawName, rawVersion] = artifactMatch;
7446
+ let name;
7447
+ let version;
7448
+ try {
7449
+ name = decodeURIComponent(rawName);
7450
+ version = decodeURIComponent(rawVersion);
7451
+ } catch {
7452
+ return Promise.resolve(json({ error: "invalid URL encoding" }, 400));
7453
+ }
7454
+ if (!SEMVER_PATTERN.test(version)) {
7455
+ return Promise.resolve(json({ error: `invalid semver version '${version}'` }, 400));
7456
+ }
7159
7457
  return artifactFor(name, version).then(async (artifact) => {
7160
7458
  if (!artifact)
7161
7459
  return json({ error: `Hook '${name}@${version}' not found locally` }, 404);
@@ -7186,7 +7484,7 @@ function handleServeRequest(req, apiKey) {
7186
7484
  function startServeServer(options) {
7187
7485
  const port = options.port ?? DEFAULT_SERVE_PORT;
7188
7486
  const host = options.host ?? SERVE_HOST;
7189
- const apiKey = resolveApiKey(options.apiKey);
7487
+ const apiKey = resolveApiKey();
7190
7488
  const server = Bun.serve({
7191
7489
  hostname: host,
7192
7490
  port,
@@ -7332,7 +7630,6 @@ async function runHook(name, input, options = {}) {
7332
7630
  content,
7333
7631
  args: custom2?.manifest.args ?? [],
7334
7632
  stdin: JSON.stringify(hookInput),
7335
- env: process.env,
7336
7633
  timeout: effectiveTimeout ?? undefined
7337
7634
  }));
7338
7635
  } catch (err) {