@veda-hq/kavach 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +107 -2
- package/corpus/corpus.json +1544 -0
- package/dist/rule-checks/args.js +26 -0
- package/dist/rule-checks/cli-entry.js +42 -0
- package/dist/rule-checks/cli.js +146 -0
- package/dist/rule-checks/edit-text.js +23 -0
- package/dist/rule-checks/fire-log.js +81 -0
- package/dist/rule-checks/latency.js +192 -0
- package/dist/rule-checks/live-runs.js +282 -0
- package/dist/rule-checks/registry.js +163 -0
- package/dist/rule-checks/runtime.js +353 -0
- package/dist/rule-checks/shell-comments.js +103 -0
- package/dist/rule-checks/shell-words.js +567 -0
- package/dist/rule-checks/spawn.js +27 -0
- package/package.json +51 -3
- package/python/kavach/__init__.py +9 -0
- package/python/kavach/bash_guard.py +1672 -0
- package/python/kavach/boundary.py +4030 -0
- package/python/kavach/command_guard.py +170 -0
- package/python/kavach/config.py +156 -0
- package/python/kavach/git_safety.py +1980 -0
- package/python/kavach/refusal_log.py +157 -0
- package/python/kavach/worktree_guard.py +482 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Command-line options of the rule-check CLIs: `--name value` pairs and bare flags. */
|
|
2
|
+
/** The value after `--name`; undefined when the option is absent. Throws when it has no value. */
|
|
3
|
+
export function option(argv, name) {
|
|
4
|
+
const at = argv.indexOf(name);
|
|
5
|
+
if (at === -1)
|
|
6
|
+
return undefined;
|
|
7
|
+
const value = argv[at + 1];
|
|
8
|
+
if (value === undefined || value.startsWith('--'))
|
|
9
|
+
throw new Error(`${name} needs a value`);
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Refuse any word that is neither one of `options` (with its value) nor one
|
|
14
|
+
* of `flags`, so a mistyped option fails loudly instead of being ignored.
|
|
15
|
+
*/
|
|
16
|
+
export function onlyKnown(argv, options, flags = []) {
|
|
17
|
+
let value = false;
|
|
18
|
+
for (const word of argv) {
|
|
19
|
+
if (value)
|
|
20
|
+
value = false;
|
|
21
|
+
else if (options.includes(word))
|
|
22
|
+
value = true;
|
|
23
|
+
else if (!flags.includes(word))
|
|
24
|
+
throw new Error(`unknown argument ${JSON.stringify(word)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
/**
|
|
5
|
+
* Is this module the script node was asked to run, rather than an import?
|
|
6
|
+
*
|
|
7
|
+
* Every CLI needs this: run when invoked, stay inert when a test imports it.
|
|
8
|
+
* Getting it wrong in the "inert" direction is silent gate death: the hook
|
|
9
|
+
* invokes the file, nothing runs, and the call passes with a successful exit
|
|
10
|
+
* status and no output at all. A guard that compares `process.argv[1]` to
|
|
11
|
+
* `import.meta.url` by path equality does exactly that through a symlink.
|
|
12
|
+
*
|
|
13
|
+
* The trap: node resolves symlinks when it builds `import.meta.url`, but
|
|
14
|
+
* `process.argv[1]` stays whatever path the caller typed. Any repo reached
|
|
15
|
+
* through a symlink (a linked worktree, a symlinked checkout, `/tmp` on
|
|
16
|
+
* macOS, a package manager's `node_modules/.bin` link) makes the two unequal
|
|
17
|
+
* for the same file. Comparing real paths is what closes it.
|
|
18
|
+
*
|
|
19
|
+
* @param entryPath `process.argv[1]` from the calling module.
|
|
20
|
+
* @param moduleUrl `import.meta.url` from the calling module.
|
|
21
|
+
*/
|
|
22
|
+
export function isCliEntry(entryPath, moduleUrl) {
|
|
23
|
+
if (entryPath === undefined)
|
|
24
|
+
return false;
|
|
25
|
+
const self = fileURLToPath(moduleUrl);
|
|
26
|
+
// Fast path: the common, non-symlinked invocation, and the only one that
|
|
27
|
+
// still works if the module were deleted from disk mid-run.
|
|
28
|
+
if (resolve(entryPath) === self)
|
|
29
|
+
return true;
|
|
30
|
+
try {
|
|
31
|
+
return realpathSync(entryPath) === realpathSync(self);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// An entry path that does not resolve cannot be this file: node resolves
|
|
35
|
+
// argv[1] to a real path before executing it. So this is an import whose
|
|
36
|
+
// host has an exotic argv[1] (a worker thread, a bundler's virtual
|
|
37
|
+
// module), and returning true would run the CLI body during import.
|
|
38
|
+
// Returning false cannot hide a CLI run either: a real invocation always
|
|
39
|
+
// has a resolvable entry.
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
3
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
4
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
5
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
return path;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The rule-check CLI, `dist/rule-checks/cli.js`.
|
|
12
|
+
*
|
|
13
|
+
* cli.js [--checks <module>] [--registry <json>] [--fires-dir <dir>]
|
|
14
|
+
* The hook. Reads one Claude Code hook event on stdin, runs the
|
|
15
|
+
* consumer's checks on it (runtime.ts) and answers on stdout; exits 0.
|
|
16
|
+
* --checks the consumer's checks module, a file Node can import
|
|
17
|
+
* (`.js`, `.mjs`, or `.ts` that Node strips types from). It
|
|
18
|
+
* exports `CHECKS`, or a default export, a list of `Check`
|
|
19
|
+
* (runtime.ts). Env: KAVACH_RULE_CHECKS.
|
|
20
|
+
* --registry the consumer's rule registry JSON (registry.ts). Optional:
|
|
21
|
+
* without one every rule reads as unregistered and a refusal
|
|
22
|
+
* says to ask "the repository owner". Env: KAVACH_RULE_REGISTRY.
|
|
23
|
+
* --fires-dir the fire-log folder. Default: `.tmp/rule-checks` in the
|
|
24
|
+
* primary checkout of the session's `cwd`. Env:
|
|
25
|
+
* KAVACH_RULE_FIRES_DIR.
|
|
26
|
+
* A flag wins over its variable. Relative paths resolve against
|
|
27
|
+
* CLAUDE_PROJECT_DIR, else the current folder.
|
|
28
|
+
* Fails open: a missing or broken checks module, a bad flag, or any crash
|
|
29
|
+
* is answered with a warning and a crash row in the fire log, and the call
|
|
30
|
+
* goes ahead. A crash it could not catch exits non-zero, which Claude Code
|
|
31
|
+
* reports as a non-blocking error.
|
|
32
|
+
*
|
|
33
|
+
* cli.js count [--registry <json>] [--root <dir>] [--json]
|
|
34
|
+
* How many registry rules no enforcer file in the checkout enforces.
|
|
35
|
+
* cli.js latency [--root <dir>] [--rounds N]
|
|
36
|
+
* Time the checkout's Bash PreToolUse hooks (latency.ts).
|
|
37
|
+
* cli.js push-marker write|clear <git-dir> <pid>
|
|
38
|
+
* Write or clear a push's marker, from a pre-push hook (live-runs.ts).
|
|
39
|
+
*
|
|
40
|
+
* A hook registration, in `.claude/settings.json`:
|
|
41
|
+
* "command": "node node_modules/@veda-hq/kavach/dist/rule-checks/cli.js
|
|
42
|
+
* --checks rules/checks.ts --registry rules/registry.json"
|
|
43
|
+
*/
|
|
44
|
+
import { readFileSync } from 'node:fs';
|
|
45
|
+
import { resolve } from 'node:path';
|
|
46
|
+
import { pathToFileURL } from 'node:url';
|
|
47
|
+
import { onlyKnown, option } from './args.js';
|
|
48
|
+
import { isCliEntry } from './cli-entry.js';
|
|
49
|
+
import { FIRES_DIR_ENV } from './fire-log.js';
|
|
50
|
+
import { main as latency } from './latency.js';
|
|
51
|
+
import { liveEffects, main as pushMarker } from './live-runs.js';
|
|
52
|
+
import { main as count, REGISTRY_ENV, STDIO } from './registry.js';
|
|
53
|
+
import { liveDeps, runHook } from './runtime.js';
|
|
54
|
+
/** The environment variable the hook reads the checks module from. */
|
|
55
|
+
export const CHECKS_ENV = 'KAVACH_RULE_CHECKS';
|
|
56
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
57
|
+
const isTexts = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
58
|
+
/** Does `value` have the shape of a `Check`? */
|
|
59
|
+
export function isCheck(value) {
|
|
60
|
+
return (isRecord(value) &&
|
|
61
|
+
typeof value['rule'] === 'string' &&
|
|
62
|
+
isTexts(value['events']) &&
|
|
63
|
+
(value['tools'] === undefined || isTexts(value['tools'])) &&
|
|
64
|
+
typeof value['cost'] === 'string' &&
|
|
65
|
+
typeof value['instead'] === 'string' &&
|
|
66
|
+
typeof value['run'] === 'function');
|
|
67
|
+
}
|
|
68
|
+
/** The checks a loaded module exports as `CHECKS`, else as its default export; throws naming what is wrong. */
|
|
69
|
+
export function checksOf(loaded, where) {
|
|
70
|
+
const exported = isRecord(loaded) ? (loaded['CHECKS'] ?? loaded['default']) : undefined;
|
|
71
|
+
if (!Array.isArray(exported)) {
|
|
72
|
+
throw new Error(`${where} exports no list of checks (as CHECKS or its default export)`);
|
|
73
|
+
}
|
|
74
|
+
const checks = [];
|
|
75
|
+
for (const [i, item] of exported.entries()) {
|
|
76
|
+
if (!isCheck(item)) {
|
|
77
|
+
throw new Error(`${where}: check ${i} is not a check (rule, events, cost, instead and run)`);
|
|
78
|
+
}
|
|
79
|
+
checks.push(item);
|
|
80
|
+
}
|
|
81
|
+
return checks;
|
|
82
|
+
}
|
|
83
|
+
/** Import the consumer's checks module at `path`. */
|
|
84
|
+
export async function loadChecks(path) {
|
|
85
|
+
const loaded = await import(__rewriteRelativeImportExtension(pathToFileURL(path).href));
|
|
86
|
+
return checksOf(loaded, path);
|
|
87
|
+
}
|
|
88
|
+
/** A flag's value, else its variable's; undefined when neither names one. */
|
|
89
|
+
function chosen(argv, flag, env, name) {
|
|
90
|
+
const value = option(argv, flag) ?? env[name];
|
|
91
|
+
return value === '' ? undefined : value;
|
|
92
|
+
}
|
|
93
|
+
/** The hook: the answer to one hook event on `stdin`. Never throws. */
|
|
94
|
+
export async function hook(argv, stdin, env) {
|
|
95
|
+
const base = env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
|
|
96
|
+
const at = (path) => (path === undefined ? undefined : resolve(base, path));
|
|
97
|
+
let registry;
|
|
98
|
+
let firesDir;
|
|
99
|
+
let checks;
|
|
100
|
+
try {
|
|
101
|
+
// The fire-log folder first, so that any failure after it is logged there.
|
|
102
|
+
firesDir = at(chosen(argv, '--fires-dir', env, FIRES_DIR_ENV));
|
|
103
|
+
registry = at(chosen(argv, '--registry', env, REGISTRY_ENV));
|
|
104
|
+
onlyKnown(argv, ['--checks', '--registry', '--fires-dir']);
|
|
105
|
+
const path = at(chosen(argv, '--checks', env, CHECKS_ENV));
|
|
106
|
+
if (path === undefined)
|
|
107
|
+
throw new Error(`no checks module: pass --checks or set ${CHECKS_ENV}`);
|
|
108
|
+
checks = await loadChecks(path);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
// fail-open: the runtime logs the failure as its own crash, under the event.
|
|
112
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
113
|
+
checks = () => {
|
|
114
|
+
throw failure;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return runHook(stdin, { ...liveDeps(env, { registry, firesDir }), checks });
|
|
118
|
+
}
|
|
119
|
+
/** The CLI; returns the exit status. */
|
|
120
|
+
export async function run(argv, io, stdin, env) {
|
|
121
|
+
const [command, ...rest] = argv;
|
|
122
|
+
if (command === 'count')
|
|
123
|
+
return count(rest, io, process.cwd(), env);
|
|
124
|
+
if (command === 'push-marker')
|
|
125
|
+
return pushMarker(rest, liveEffects, io.err);
|
|
126
|
+
if (command === 'latency') {
|
|
127
|
+
try {
|
|
128
|
+
await latency(rest, process.cwd(), io.out);
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
io.err(`latency: ${error instanceof Error ? error.message : String(error)}`);
|
|
133
|
+
return 2;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
io.write(await hook(argv, stdin(), env));
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
if (isCliEntry(process.argv[1], import.meta.url)) {
|
|
140
|
+
process.exitCode = await run(process.argv.slice(2), {
|
|
141
|
+
...STDIO,
|
|
142
|
+
write: (text) => {
|
|
143
|
+
process.stdout.write(text);
|
|
144
|
+
},
|
|
145
|
+
}, () => readFileSync(0).toString(), process.env);
|
|
146
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The text a file-writing tool call proposes: what an edit-time check judges
|
|
3
|
+
* before the tool runs.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* The file's text once the tool has run: a Write's content; an Edit applied to
|
|
7
|
+
* `current`. An Edit whose old text is not in the file fails anyway, so its
|
|
8
|
+
* new text is judged beside the current file.
|
|
9
|
+
*/
|
|
10
|
+
export function afterEdit(tool, given, current) {
|
|
11
|
+
const text = (key) => {
|
|
12
|
+
const value = given[key];
|
|
13
|
+
return typeof value === 'string' ? value : '';
|
|
14
|
+
};
|
|
15
|
+
if (tool === 'Write')
|
|
16
|
+
return text('content');
|
|
17
|
+
const before = current ?? '';
|
|
18
|
+
const old = text('old_string');
|
|
19
|
+
const next = text('new_string');
|
|
20
|
+
if (old === '' || !before.includes(old))
|
|
21
|
+
return `${before}\n${next}`;
|
|
22
|
+
return given['replace_all'] === true ? before.split(old).join(next) : before.replace(old, () => next);
|
|
23
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fire log: one JSON line per check that refused, warned, was passed with
|
|
3
|
+
* a stated reason, or crashed.
|
|
4
|
+
*
|
|
5
|
+
* Where: the folder the caller names (`firesDir`'s override: the runtime's
|
|
6
|
+
* `firesDir` option, the CLI's `--fires-dir` or `KAVACH_RULE_FIRES_DIR`), else
|
|
7
|
+
* `<primary checkout>/.tmp/rule-checks/fires.jsonl`, the checkout that owns
|
|
8
|
+
* the git common directory, so rows from every worktree land in one file and
|
|
9
|
+
* outlive the worktree.
|
|
10
|
+
*
|
|
11
|
+
* Shared state, written by hooks running in parallel across sessions:
|
|
12
|
+
* - Ordering: nothing claims or waits on the file; readers only read history.
|
|
13
|
+
* - Failure cleanup: a failed or short append leaves no row or one partial
|
|
14
|
+
* line, never a stale record another process trusts, and throws so the
|
|
15
|
+
* runtime reports the missed row. A short write is not retried: a second
|
|
16
|
+
* append could land after another writer's row and split this one. The next
|
|
17
|
+
* append starts on a fresh line, so only the partial line fails to parse,
|
|
18
|
+
* and a reader skips it.
|
|
19
|
+
* - Overlap: every append is one write of whole lines (O_APPEND);
|
|
20
|
+
* rows are short (the excerpt is capped), nothing truncates, rewrites or
|
|
21
|
+
* sweeps the file, so writers cannot erase each other's rows. Two writers
|
|
22
|
+
* that both find a partial line both start a fresh one: one empty line.
|
|
23
|
+
* - Input validity: rows are built here from typed values; nothing in this
|
|
24
|
+
* module reads the file back.
|
|
25
|
+
* Accepted limit: an append on a network filesystem may interleave; keep the
|
|
26
|
+
* folder on a local disk.
|
|
27
|
+
*/
|
|
28
|
+
import { closeSync, fstatSync, mkdirSync, openSync, readSync, writeSync } from 'node:fs';
|
|
29
|
+
import { dirname, join } from 'node:path';
|
|
30
|
+
export const EXCERPT_MAX = 120;
|
|
31
|
+
export const FIRES_FILE = 'fires.jsonl';
|
|
32
|
+
/** The environment variable the CLI reads the fire-log folder from. */
|
|
33
|
+
export const FIRES_DIR_ENV = 'KAVACH_RULE_FIRES_DIR';
|
|
34
|
+
/** `value` on one line, at most `max` characters, an ellipsis marking a cut. */
|
|
35
|
+
export function clip(value, max = EXCERPT_MAX) {
|
|
36
|
+
const line = value.replace(/\s+/g, ' ').trim();
|
|
37
|
+
return line.length <= max ? line : `${line.slice(0, max - 1)}…`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The folder the fire log lives in for a session whose checkout is `root`:
|
|
41
|
+
* `override` when it names one, else `.tmp/rule-checks` in the primary checkout.
|
|
42
|
+
*/
|
|
43
|
+
export function firesDir(override, root, git) {
|
|
44
|
+
if (override !== undefined && override !== '')
|
|
45
|
+
return override;
|
|
46
|
+
const common = git(root, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
47
|
+
return join(dirname(common), '.tmp', 'rule-checks');
|
|
48
|
+
}
|
|
49
|
+
/** The branch checked out at `root`; `HEAD` when detached, `unknown` outside git. */
|
|
50
|
+
export function branchAt(root, git) {
|
|
51
|
+
try {
|
|
52
|
+
return git(root, ['branch', '--show-current']) || 'HEAD';
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// fail-open: a row without its branch still records the fire.
|
|
56
|
+
return 'unknown';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** A newline when the log ends in a partial line a failed append left. */
|
|
60
|
+
function freshLine(fd) {
|
|
61
|
+
const { size } = fstatSync(fd);
|
|
62
|
+
if (size === 0)
|
|
63
|
+
return '';
|
|
64
|
+
const last = Buffer.alloc(1);
|
|
65
|
+
readSync(fd, last, 0, 1, size - 1);
|
|
66
|
+
return last.toString() === '\n' ? '' : '\n';
|
|
67
|
+
}
|
|
68
|
+
export function appendFires(dir, rows, write = writeSync) {
|
|
69
|
+
mkdirSync(dir, { recursive: true });
|
|
70
|
+
const fd = openSync(join(dir, FIRES_FILE), 'a+');
|
|
71
|
+
try {
|
|
72
|
+
const lines = rows.map((row) => `${JSON.stringify(row)}\n`).join('');
|
|
73
|
+
const bytes = Buffer.from(freshLine(fd) + lines);
|
|
74
|
+
const wrote = write(fd, bytes);
|
|
75
|
+
if (wrote !== bytes.length)
|
|
76
|
+
throw new Error(`fire log: wrote ${wrote} of ${bytes.length} bytes`);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
closeSync(fd);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook latency on the Bash path: every PreToolUse hook the checkout's
|
|
3
|
+
* `.claude/settings.json` registers for Bash, fed a fixed set of everyday
|
|
4
|
+
* commands, timed alone and all together.
|
|
5
|
+
*
|
|
6
|
+
* Claude Code runs the hooks that match one call in parallel
|
|
7
|
+
* (https://code.claude.com/docs/en/hooks, "All matching hooks run in
|
|
8
|
+
* parallel"), so the "per call" line, every hook started at once and timed to
|
|
9
|
+
* the last exit, is what a Bash call waits; the per-hook lines say which hook
|
|
10
|
+
* sets it. A budget worth holding each check to is p95 ≤ 150 ms.
|
|
11
|
+
*
|
|
12
|
+
* Hooks only read the command they are handed, so replaying these commands
|
|
13
|
+
* runs none of them. The load average and the interpreters on PATH are printed
|
|
14
|
+
* with the numbers, because both move them.
|
|
15
|
+
*
|
|
16
|
+
* The timed hooks run with `KAVACH_RULE_FIRES_DIR` pointed at
|
|
17
|
+
* {@link LATENCY_FIRES}, so their refusals stay out of the real fire log; a
|
|
18
|
+
* hook registered with its own `--fires-dir` keeps writing there.
|
|
19
|
+
*
|
|
20
|
+
* `cli.js latency [--root <dir>] [--rounds N]` (default: the current folder,
|
|
21
|
+
* 5 rounds over the corpus).
|
|
22
|
+
*/
|
|
23
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
import { loadavg } from 'node:os';
|
|
26
|
+
import { join, resolve } from 'node:path';
|
|
27
|
+
import { FIRES_DIR_ENV } from './fire-log.js';
|
|
28
|
+
/** Commands an agent runs every hour, from cheap reads to pushes. */
|
|
29
|
+
export const CORPUS = [
|
|
30
|
+
'git status --short',
|
|
31
|
+
'ls src',
|
|
32
|
+
'grep -n "export" src/index.ts',
|
|
33
|
+
'node --version',
|
|
34
|
+
'git log --oneline -5',
|
|
35
|
+
'git diff --stat origin/main',
|
|
36
|
+
'git add README.md',
|
|
37
|
+
'git commit -m "docs: note"',
|
|
38
|
+
'git push -u origin HEAD',
|
|
39
|
+
'gh pr view --json number,state',
|
|
40
|
+
];
|
|
41
|
+
/** The PreToolUse hook commands whose matcher selects the Bash tool. */
|
|
42
|
+
export function bashHooks(settings) {
|
|
43
|
+
return (settings.hooks?.['PreToolUse'] ?? [])
|
|
44
|
+
.filter((entry) => new RegExp(`^(?:${entry.matcher ?? '.*'})$`).test('Bash'))
|
|
45
|
+
.flatMap((entry) => entry.hooks.map((hook) => hook.command));
|
|
46
|
+
}
|
|
47
|
+
/** Nearest-rank percentile of ascending `sorted`; 0 for no samples. */
|
|
48
|
+
export function percentile(sorted, p) {
|
|
49
|
+
return sorted[Math.min(sorted.length, Math.ceil((p / 100) * sorted.length)) - 1] ?? 0;
|
|
50
|
+
}
|
|
51
|
+
export function hookInput(root, command) {
|
|
52
|
+
return JSON.stringify({
|
|
53
|
+
hook_event_name: 'PreToolUse',
|
|
54
|
+
tool_name: 'Bash',
|
|
55
|
+
cwd: root,
|
|
56
|
+
tool_input: { command, description: 'latency probe' },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** A hook that has not exited by now is hung, not slow. */
|
|
60
|
+
export const HOOK_TIMEOUT_MS = 30_000;
|
|
61
|
+
/**
|
|
62
|
+
* `bash -c command` with `input` on stdin, in its own process group so a
|
|
63
|
+
* hook that hangs is killed with everything it started.
|
|
64
|
+
*/
|
|
65
|
+
function runBash(command, input, env, timeoutMs) {
|
|
66
|
+
return new Promise((done) => {
|
|
67
|
+
const child = spawn('bash', ['-c', command], { env, stdio: ['pipe', 'ignore', 'ignore'], detached: true });
|
|
68
|
+
let settled = false;
|
|
69
|
+
const settle = (outcome) => {
|
|
70
|
+
if (settled)
|
|
71
|
+
return;
|
|
72
|
+
settled = true;
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
done(outcome);
|
|
75
|
+
};
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
try {
|
|
78
|
+
// Never `kill(-0)`: without a pid that would signal this process's own group.
|
|
79
|
+
if (child.pid !== undefined)
|
|
80
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// fail-open: a group that already ended has nothing left to kill.
|
|
84
|
+
}
|
|
85
|
+
settle('timeout');
|
|
86
|
+
}, timeoutMs);
|
|
87
|
+
child.on('error', () => settle('spawn-error'));
|
|
88
|
+
child.on('exit', () => settle('exit'));
|
|
89
|
+
// A hook may exit without reading its input; the broken pipe is no failure.
|
|
90
|
+
child.stdin.on('error', () => undefined);
|
|
91
|
+
child.stdin.end(input);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Milliseconds from starting `command` under bash with `input` on stdin to its
|
|
96
|
+
* exit, whatever its status: a refusal is as much an answer as a pass.
|
|
97
|
+
*/
|
|
98
|
+
export async function timeHook(command, input, env, timeoutMs = HOOK_TIMEOUT_MS, clock = () => performance.now()) {
|
|
99
|
+
const start = clock();
|
|
100
|
+
const outcome = await runBash(command, input, env, timeoutMs);
|
|
101
|
+
if (outcome !== 'exit')
|
|
102
|
+
throw new Error(`${command}: ${outcome}`);
|
|
103
|
+
return clock() - start;
|
|
104
|
+
}
|
|
105
|
+
/** Where the timed hooks' fires go, under the measured checkout. */
|
|
106
|
+
export const LATENCY_FIRES = '.tmp/rule-checks/latency-fires';
|
|
107
|
+
/** Run `steps` one after another: timings taken side by side would load each other. */
|
|
108
|
+
function inTurn(steps) {
|
|
109
|
+
return steps.reduce((done, step) => done.then(step), Promise.resolve());
|
|
110
|
+
}
|
|
111
|
+
/** Every hook alone, then all at once, `rounds` times over `corpus`. */
|
|
112
|
+
export async function measure(hooks, corpus, rounds, root, clock = () => performance.now()) {
|
|
113
|
+
// The corpus's foreground push may be refused; timing runs must not write
|
|
114
|
+
// those refusals into the fire log the count and reviews read.
|
|
115
|
+
const env = {
|
|
116
|
+
...process.env,
|
|
117
|
+
CLAUDE_PROJECT_DIR: root,
|
|
118
|
+
[FIRES_DIR_ENV]: join(root, LATENCY_FIRES),
|
|
119
|
+
};
|
|
120
|
+
const alone = hooks.map((name) => ({ name, ms: [] }));
|
|
121
|
+
const together = { name: 'per call (all in parallel)', ms: [] };
|
|
122
|
+
const calls = Array.from({ length: rounds }, () => corpus).flat();
|
|
123
|
+
await inTurn(calls.flatMap((command) => {
|
|
124
|
+
const input = hookInput(root, command);
|
|
125
|
+
return [
|
|
126
|
+
...alone.map((series) => async () => {
|
|
127
|
+
series.ms.push(await timeHook(series.name, input, env));
|
|
128
|
+
}),
|
|
129
|
+
async () => {
|
|
130
|
+
const start = clock();
|
|
131
|
+
await Promise.all(hooks.map((hook) => timeHook(hook, input, env)));
|
|
132
|
+
together.ms.push(clock() - start);
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
}));
|
|
136
|
+
return [...alone, together];
|
|
137
|
+
}
|
|
138
|
+
/** Where an interpreter resolves on PATH; `missing` when it does not. */
|
|
139
|
+
export function which(program) {
|
|
140
|
+
const found = spawnSync('bash', ['-c', `command -v ${program}`], {
|
|
141
|
+
encoding: 'utf8',
|
|
142
|
+
});
|
|
143
|
+
return found.stdout.trim() || 'missing';
|
|
144
|
+
}
|
|
145
|
+
export function render(series, load, tools) {
|
|
146
|
+
const lines = [
|
|
147
|
+
`load average ${load.map((value) => value.toFixed(2)).join(' ')}`,
|
|
148
|
+
...Object.entries(tools).map(([name, path]) => `${name}: ${path}`),
|
|
149
|
+
'',
|
|
150
|
+
'hook | samples | p50 ms | p95 ms | max ms',
|
|
151
|
+
];
|
|
152
|
+
for (const { name, ms } of series) {
|
|
153
|
+
const sorted = [...ms].sort((a, b) => a - b);
|
|
154
|
+
lines.push([
|
|
155
|
+
name,
|
|
156
|
+
sorted.length,
|
|
157
|
+
percentile(sorted, 50).toFixed(0),
|
|
158
|
+
percentile(sorted, 95).toFixed(0),
|
|
159
|
+
percentile(sorted, 100).toFixed(0),
|
|
160
|
+
].join(' | '));
|
|
161
|
+
}
|
|
162
|
+
return lines.join('\n');
|
|
163
|
+
}
|
|
164
|
+
export function rounds(argv) {
|
|
165
|
+
const at = argv.indexOf('--rounds');
|
|
166
|
+
if (at === -1)
|
|
167
|
+
return 5;
|
|
168
|
+
const value = Number(argv[at + 1]);
|
|
169
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
170
|
+
throw new Error('--rounds needs a positive whole number');
|
|
171
|
+
}
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
/** The checkout whose settings and hooks are timed: `--root`, else `fallback`. */
|
|
175
|
+
export function rootOf(argv, fallback) {
|
|
176
|
+
const at = argv.indexOf('--root');
|
|
177
|
+
if (at === -1)
|
|
178
|
+
return fallback;
|
|
179
|
+
const value = argv[at + 1];
|
|
180
|
+
if (value === undefined)
|
|
181
|
+
throw new Error('--root needs a folder');
|
|
182
|
+
return resolve(value);
|
|
183
|
+
}
|
|
184
|
+
export async function main(argv, fallbackRoot, out) {
|
|
185
|
+
const root = rootOf(argv, fallbackRoot);
|
|
186
|
+
const settings = JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf8'));
|
|
187
|
+
const series = await measure(bashHooks(settings), CORPUS, rounds(argv), root);
|
|
188
|
+
out(render(series, loadavg(), {
|
|
189
|
+
node: which('node'),
|
|
190
|
+
python3: which('python3'),
|
|
191
|
+
}));
|
|
192
|
+
}
|