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