@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.
@@ -0,0 +1,567 @@
1
+ /**
2
+ * The simple commands of a shell command line, each as its words.
3
+ *
4
+ * For checks that look for a command (`git merge main`), not for text that
5
+ * merely contains one: a commit message, an `echo`, a quoted heredoc body. A
6
+ * static reading, like `shell-comments.ts`: single, double and `$'…'` quotes
7
+ * join a word (`$'…'` escapes decoded); a backslash escapes the next
8
+ * character, and before a newline joins two lines; `;`, `&`, `|`, `(`, `)` and
9
+ * a newline end a command; a redirection drops its target (and a
10
+ * file-descriptor number before it); a `#` that starts a word comments out the
11
+ * rest of its line. `$(…)` and backquotes are read as commands of their own,
12
+ * also inside double quotes and unquoted heredoc bodies, where the shell runs
13
+ * them, and leave `FILLED` in the word they sit in; a quoted heredoc body is
14
+ * skipped. A plain `${…}` or arithmetic `$((…))` leaves `FILLED` too; a
15
+ * `${…}` that could nest or run a command stops the reading.
16
+ *
17
+ * A subshell's parentheses nest, so its `)` never closes an enclosing `$(…)`.
18
+ * The script of `bash -c` (`sh`, `zsh`, `dash`) reads as commands after its
19
+ * own, so a command hidden in one is still seen.
20
+ *
21
+ * Where it cannot follow the shell (an unterminated quote or substitution, a
22
+ * heredoc word it cannot settle, a `case` inside a substitution, whose
23
+ * patterns end in an unpaired `)`) it stops, and `unread` holds the text from
24
+ * the start of the command it stopped in, so a check can fail closed on it;
25
+ * `everyCommand` adds a rough reading of that text for checks that refuse.
26
+ *
27
+ * One pass over the characters, so no input can make it loop.
28
+ *
29
+ * `invoked`, `program`, `subcommand` and `gitSubcommand` then read what a
30
+ * command runs: a commit, a container build, a merge of the main branch, a git
31
+ * or pull-request command.
32
+ *
33
+ * `programOf` reads the program past runners, `resolvePath` the paths a word
34
+ * names, and `steps` the folder each command runs in.
35
+ */
36
+ import { dirname, isAbsolute, resolve } from 'node:path';
37
+ /** Characters that end a simple command. */
38
+ const OPERATORS = ';&|()\n';
39
+ /** Characters a backslash escapes inside double quotes. */
40
+ const DOUBLE_ESCAPES = '"\\$`\n';
41
+ const HEREDOC = /^<<(-?)[ \t]*(?:'([^']*)'|"([^"\\$`]*)"|([A-Za-z_]\w*))(?=[ \t\n;|&()<>]|$)/;
42
+ /**
43
+ * What `$(…)`, backquotes and `${…}` leave in a word: the value the shell
44
+ * fills in, unknown here, so a check can tell `origin/${B}` from `origin/`.
45
+ */
46
+ export const FILLED = '$';
47
+ /** What a `${…}` this reader reads through must not hold. */
48
+ const NESTING = /[$`"'{\\]/;
49
+ /**
50
+ * What `arithmeticLength` stops at: a parenthesis; or a substitution or a
51
+ * newline, which it does not read through. A heredoc's end line can follow a
52
+ * newline, and each scan ends before the next `$((`, so a line of them is
53
+ * read in one pass.
54
+ */
55
+ const ARITHMETIC_STOPS = /[()`\n]|\$\(/g;
56
+ /** The rest of a redirection operator after its first character. */
57
+ const REDIRECTION_TAIL = /^[<>&|]*/;
58
+ /**
59
+ * A `$'…'` escape: hex, Unicode or octal code, else the escaped character as
60
+ * itself. `\n` reads as `n`, not a newline: a control character never makes a
61
+ * command word, so only the codes that can spell one are decoded.
62
+ */
63
+ const ANSI_ESCAPE = /^\\(?:x([0-9A-Fa-f]{1,2})|[uU]([0-9A-Fa-f]{1,8})|([0-7]{1,3})|[\s\S])/;
64
+ const MAX_CODE_POINT = 0x10ffff;
65
+ /** Whether a body line is the word that ends its heredoc. */
66
+ const ends = (body, line) => (body.tabs ? line.replace(/^\t+/, '') : line) === body.word;
67
+ /** The escape `text` starts with; null for a backslash that ends the text. */
68
+ function ansiEscape(text) {
69
+ const match = ANSI_ESCAPE.exec(text);
70
+ if (match === null)
71
+ return null;
72
+ const [whole, hex, unicode, octal] = match;
73
+ const code = hex === undefined
74
+ ? unicode === undefined
75
+ ? octal === undefined
76
+ ? null
77
+ : parseInt(octal, 8)
78
+ : parseInt(unicode, 16)
79
+ : parseInt(hex, 16);
80
+ return {
81
+ value: code === null
82
+ ? whole.slice(1)
83
+ : // Past Unicode's last code point no character can spell a word,
84
+ // and String.fromCodePoint would throw.
85
+ code > MAX_CODE_POINT
86
+ ? ''
87
+ : String.fromCodePoint(code),
88
+ length: whole.length,
89
+ };
90
+ }
91
+ /**
92
+ * How far past its `$` an arithmetic `$((…))` at `i` runs: it runs no
93
+ * command, so it reads as `FILLED`. 0 when `i` starts none, when its `))`
94
+ * never comes (`$( (a) )` is a subshell), or when it holds a substitution,
95
+ * which is then read as one.
96
+ */
97
+ function arithmeticLength(text, i) {
98
+ if (!text.startsWith('$((', i))
99
+ return 0;
100
+ ARITHMETIC_STOPS.lastIndex = i + 3;
101
+ let depth = 0;
102
+ for (let stop = ARITHMETIC_STOPS.exec(text); stop !== null; stop = ARITHMETIC_STOPS.exec(text)) {
103
+ if (stop[0] === '(') {
104
+ depth++;
105
+ }
106
+ else if (stop[0] !== ')') {
107
+ return 0;
108
+ }
109
+ else if (depth > 0) {
110
+ depth--;
111
+ }
112
+ else {
113
+ return text[stop.index + 1] === ')' ? stop.index + 1 - i : 0;
114
+ }
115
+ }
116
+ return 0;
117
+ }
118
+ /** A command not yet begun: at the start of the text and inside a substitution. */
119
+ const fresh = () => ({ words: [], word: null, target: false, mode: 'plain' });
120
+ export function simpleCommands(text) {
121
+ const commands = [];
122
+ let { words, word, target, mode } = fresh();
123
+ // Characters already read as part of the one before them.
124
+ let skip = 0;
125
+ let escaped = false;
126
+ // Heredocs opened on this line; their bodies start after its newline.
127
+ let opened = [];
128
+ let bodies = [];
129
+ // The body line read so far; every body newline empties it, so it is empty
130
+ // whenever a body starts.
131
+ let line = '';
132
+ const frames = [];
133
+ let start = 0;
134
+ // What the first `bash -c` script the reader could not follow left unread.
135
+ let scriptUnread = '';
136
+ const stop = () => {
137
+ const rest = text.slice(frames[0]?.start ?? start);
138
+ return {
139
+ commands,
140
+ unread: scriptUnread === '' ? rest : `${scriptUnread}\n${rest}`,
141
+ };
142
+ };
143
+ const add = (char) => {
144
+ word = (word ?? '') + char;
145
+ };
146
+ // A `case` inside a frame: its patterns' `)` would close the frame early.
147
+ let unfollowable = false;
148
+ const endWord = () => {
149
+ if (word === null)
150
+ return;
151
+ if (word === 'case' && words.length === 0 && frames.length > 0) {
152
+ unfollowable = true;
153
+ }
154
+ if (target)
155
+ target = false;
156
+ else
157
+ words.push(word);
158
+ word = null;
159
+ };
160
+ const endCommand = (at) => {
161
+ endWord();
162
+ if (words.length > 0)
163
+ commands.push(words);
164
+ const script = shellScript(invoked(words));
165
+ if (script !== null) {
166
+ const inner = simpleCommands(script);
167
+ commands.push(...inner.commands);
168
+ if (scriptUnread === '')
169
+ scriptUnread = inner.unread;
170
+ }
171
+ words = [];
172
+ target = false;
173
+ start = at;
174
+ };
175
+ const open = (closer, substitution = true) => {
176
+ frames.push({ words, word, target, mode, closer, substitution, start });
177
+ ({ words, word, target, mode } = fresh());
178
+ };
179
+ /**
180
+ * Reads `${…}` as `FILLED`. False when it never closes, or when it holds
181
+ * what could nest or run a command (`${x:-$(git merge main)}`): a
182
+ * substitution, quote, brace or escape.
183
+ */
184
+ const braced = (i) => {
185
+ const close = text.indexOf('}', i);
186
+ if (close === -1 || NESTING.test(text.slice(i + 2, close)))
187
+ return false;
188
+ add(FILLED);
189
+ skip = close - i;
190
+ return true;
191
+ };
192
+ for (const [i, char] of text.split('').entries()) {
193
+ if (unfollowable)
194
+ return stop();
195
+ if (skip > 0) {
196
+ skip--;
197
+ continue;
198
+ }
199
+ const arithmetic = arithmeticLength(text, i);
200
+ // Body mode always has a body open: entering it moves at least one there.
201
+ const [body] = bodies;
202
+ if (mode === 'body' && body !== undefined) {
203
+ if (arithmetic > 0) {
204
+ line += text.slice(i, i + arithmetic + 1);
205
+ skip = arithmetic;
206
+ }
207
+ else if (body.expands && text.startsWith('$(', i)) {
208
+ open(')');
209
+ skip = 1;
210
+ }
211
+ else if (body.expands && char === '`') {
212
+ open('`');
213
+ }
214
+ else if (char !== '\n') {
215
+ line += char;
216
+ }
217
+ else {
218
+ if (ends(body, line)) {
219
+ bodies.shift();
220
+ }
221
+ line = '';
222
+ if (bodies.length === 0)
223
+ [mode, start] = ['plain', i + 1];
224
+ }
225
+ continue;
226
+ }
227
+ if (escaped) {
228
+ escaped = false;
229
+ if (char !== '\n')
230
+ add(char);
231
+ continue;
232
+ }
233
+ if (mode === "'") {
234
+ if (char === "'")
235
+ mode = 'plain';
236
+ else
237
+ add(char);
238
+ continue;
239
+ }
240
+ if (mode === "$'") {
241
+ if (char === "'") {
242
+ mode = 'plain';
243
+ }
244
+ else if (char === '\\') {
245
+ const decoded = ansiEscape(text.slice(i));
246
+ if (decoded === null)
247
+ return stop();
248
+ add(decoded.value);
249
+ skip = decoded.length - 1;
250
+ }
251
+ else {
252
+ add(char);
253
+ }
254
+ continue;
255
+ }
256
+ if (mode === '"') {
257
+ const next = text[i + 1];
258
+ if (char === '"') {
259
+ mode = 'plain';
260
+ }
261
+ else if (arithmetic > 0) {
262
+ add(FILLED);
263
+ skip = arithmetic;
264
+ }
265
+ else if (text.startsWith('$(', i)) {
266
+ open(')');
267
+ skip = 1;
268
+ }
269
+ else if (char === '`') {
270
+ open('`');
271
+ }
272
+ else if (text.startsWith('${', i)) {
273
+ if (!braced(i))
274
+ return stop();
275
+ }
276
+ else if (char === '\\' && next !== undefined && DOUBLE_ESCAPES.includes(next)) {
277
+ escaped = true;
278
+ }
279
+ else {
280
+ add(char);
281
+ }
282
+ continue;
283
+ }
284
+ if (mode === 'comment') {
285
+ if (char !== '\n')
286
+ continue;
287
+ mode = 'plain';
288
+ }
289
+ const frame = frames.at(-1);
290
+ if (frame !== undefined && char === frame.closer) {
291
+ endCommand(start);
292
+ frames.pop();
293
+ ({ words, word, target, mode, start } = frame);
294
+ if (frame.substitution && mode !== 'body')
295
+ add(FILLED);
296
+ }
297
+ else if (char === '(') {
298
+ endCommand(i + 1);
299
+ open(')', false);
300
+ }
301
+ else if (char === '\\') {
302
+ escaped = true;
303
+ }
304
+ else if (arithmetic > 0) {
305
+ add(FILLED);
306
+ skip = arithmetic;
307
+ }
308
+ else if (text.startsWith('$(', i)) {
309
+ open(')');
310
+ skip = 1;
311
+ }
312
+ else if (char === '`') {
313
+ open('`');
314
+ }
315
+ else if (text.startsWith('${', i)) {
316
+ if (!braced(i))
317
+ return stop();
318
+ }
319
+ else if (char === "'" || char === '"') {
320
+ add('');
321
+ mode = char;
322
+ }
323
+ else if (text.startsWith("$'", i)) {
324
+ add('');
325
+ mode = "$'";
326
+ skip = 1;
327
+ }
328
+ else if (char === '#' && word === null) {
329
+ mode = 'comment';
330
+ }
331
+ else if (char === ' ' || char === '\t') {
332
+ endWord();
333
+ }
334
+ else if (text.startsWith('<<', i) && !text.startsWith('<<<', i)) {
335
+ const marker = HEREDOC.exec(text.slice(i));
336
+ if (marker === null)
337
+ return stop();
338
+ endWord();
339
+ const [whole, dash, single, double, bare] = marker;
340
+ opened.push({
341
+ word: single ?? double ?? bare ?? '',
342
+ tabs: dash === '-',
343
+ expands: bare !== undefined,
344
+ });
345
+ skip = whole.length - 1;
346
+ }
347
+ else if (char === '<' || char === '>' || text.startsWith('&>', i)) {
348
+ if (word !== null && /^\d+$/.test(word))
349
+ word = null;
350
+ endWord();
351
+ target = true;
352
+ skip = REDIRECTION_TAIL.exec(text.slice(i + 1))?.[0].length ?? 0;
353
+ }
354
+ else if (char === '\n') {
355
+ endCommand(i + 1);
356
+ if (opened.length > 0)
357
+ [mode, bodies, opened] = ['body', opened, []];
358
+ }
359
+ else if (OPERATORS.includes(char)) {
360
+ endCommand(i + 1);
361
+ }
362
+ else {
363
+ add(char);
364
+ }
365
+ }
366
+ // A body may end at the end of the text, without a newline after its word.
367
+ // Bodies are open only in body mode or a substitution inside one, which
368
+ // stops below either way.
369
+ const [last] = bodies;
370
+ if (bodies.length === 1 && last !== undefined && ends(last, line)) {
371
+ mode = 'plain';
372
+ }
373
+ if (mode !== 'plain' && mode !== 'comment')
374
+ return stop();
375
+ if (opened.length > 0 || frames.length > 0)
376
+ return stop();
377
+ endCommand(text.length);
378
+ return { commands, unread: scriptUnread };
379
+ }
380
+ /** Where `roughCommands` splits: every operator, parenthesis and backquote. */
381
+ const ROUGH_BREAKS = /[;&|()\n`]/;
382
+ /**
383
+ * A rough reading of text the reader could not follow: split at every
384
+ * operator, quotes and backslashes dropped. A quoted mention may read as a
385
+ * command here, so checks that refuse a command read it and fail closed.
386
+ */
387
+ export function roughCommands(unread) {
388
+ return unread
389
+ .split(ROUGH_BREAKS)
390
+ .map((part) => part
391
+ .replace(/['"\\]/g, '')
392
+ .split(/[ \t]+/)
393
+ .filter((word) => word !== ''))
394
+ .filter((words) => words.length > 0);
395
+ }
396
+ /**
397
+ * Every command a check that refuses one reads: the commands read, then a
398
+ * rough reading of any text after where the reader stopped, so a command
399
+ * past it (`echo "$(case …)"; git commit`) is still seen.
400
+ */
401
+ export function everyCommand(text) {
402
+ const reading = simpleCommands(text);
403
+ return [...reading.commands, ...roughCommands(reading.unread)];
404
+ }
405
+ /** Shell keywords a command can follow (`if git push; then …`). */
406
+ const KEYWORDS = ['!', '{', 'if', 'then', 'else', 'elif', 'do', 'while', 'until'];
407
+ /** Leading words that run the command after them, and their valued options. */
408
+ const WRAPPERS = new Map([
409
+ ...KEYWORDS.map((keyword) => [keyword, new Set()]),
410
+ ['command', new Set()],
411
+ ['exec', new Set(['-a'])],
412
+ ['time', new Set(['-o', '-f'])],
413
+ ['nohup', new Set()],
414
+ ['env', new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string'])],
415
+ ['nice', new Set(['-n'])],
416
+ ['sudo', new Set(['-u', '-g', '-C', '-D'])],
417
+ ]);
418
+ /** Shells whose `-c` script is read as commands too (`bash -c 'git push'`). */
419
+ const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash']);
420
+ /** The script a shell runs with `-c` (`sh -ec '…'`), if any. */
421
+ export function shellScript(words) {
422
+ const [name, ...args] = words;
423
+ if (name === undefined || !SHELLS.has(program(name)))
424
+ return null;
425
+ const flag = args.findIndex((word) => /^-[a-z]*c[a-z]*$/.test(word));
426
+ return flag === -1 ? null : (args[flag + 1] ?? null);
427
+ }
428
+ const ASSIGNMENT = /^[A-Za-z_]\w*=/;
429
+ /** The base name of a program word: `/usr/bin/git` reads as `git`. */
430
+ export function program(word) {
431
+ return word.slice(word.lastIndexOf('/') + 1);
432
+ }
433
+ /** The index past the options at `i` (`-x`, and `-o value` for each valued one). */
434
+ function pastOptions(words, i, takesValue) {
435
+ let at = i;
436
+ for (let option = words[at]; option?.startsWith('-') === true; option = words[at]) {
437
+ at += takesValue.has(option) ? 2 : 1;
438
+ }
439
+ return at;
440
+ }
441
+ /**
442
+ * The words a simple command runs, past assignments (`A=1 git …`), shell
443
+ * keywords and the wrappers above with their options. Empty when only those
444
+ * remain.
445
+ */
446
+ export function invoked(words) {
447
+ let i = 0;
448
+ for (let word = words[i]; word !== undefined; word = words[i]) {
449
+ const takesValue = WRAPPERS.get(program(word));
450
+ if (ASSIGNMENT.test(word)) {
451
+ i++;
452
+ }
453
+ else if (takesValue !== undefined) {
454
+ i = pastOptions(words, i + 1, takesValue);
455
+ }
456
+ else {
457
+ break;
458
+ }
459
+ }
460
+ return words.slice(i);
461
+ }
462
+ /**
463
+ * A tool's subcommand and the words after it: `git -C x commit -m y` reads as
464
+ * `commit` and `-m y`, skipping the options before it and the value of each
465
+ * option in `takesValue` (given as its own word). `null` when none follows.
466
+ */
467
+ export function subcommand(args, takesValue) {
468
+ const i = pastOptions(args, 0, takesValue);
469
+ const name = args[i];
470
+ return name === undefined ? null : { name, rest: args.slice(i + 1) };
471
+ }
472
+ /** Global options of git whose value is the next word (`git -C dir push`). */
473
+ export const GIT_VALUED = new Set([
474
+ '-C',
475
+ '-c',
476
+ '--config-env',
477
+ '--git-dir',
478
+ '--namespace',
479
+ '--work-tree',
480
+ ]);
481
+ /** `git [options] <subcommand> …` in a simple command; `null` for any other. */
482
+ export function gitSubcommand(words) {
483
+ const [name, ...args] = invoked(words);
484
+ if (name === undefined || program(name) !== 'git')
485
+ return null;
486
+ return subcommand(args, GIT_VALUED);
487
+ }
488
+ /** Runners `programOf` also reads past, with their valued options. */
489
+ const RUNNERS = new Map([
490
+ ['timeout', new Set(['-s', '--signal', '-k', '--kill-after'])],
491
+ ['npx', new Set(['-p', '--package', '-c', '--call'])],
492
+ ]);
493
+ /**
494
+ * The program a simple command runs, as `invoked` reads it, keeping the
495
+ * assignments' values, and also past `timeout <duration>`, `npx`, and
496
+ * `pnpm exec` / `pnpm dev:exec`. Null when nothing is left to run.
497
+ */
498
+ export function programOf(words) {
499
+ const env = new Map();
500
+ let i = 0;
501
+ for (let word = words[i]; word !== undefined; word = words[i]) {
502
+ const name = program(word);
503
+ const valued = WRAPPERS.get(name) ?? RUNNERS.get(name);
504
+ if (ASSIGNMENT.test(word)) {
505
+ const at = word.indexOf('=');
506
+ env.set(word.slice(0, at), word.slice(at + 1));
507
+ i += 1;
508
+ }
509
+ else if (valued !== undefined) {
510
+ i = pastOptions(words, i + 1, valued);
511
+ // timeout's duration comes before the program it runs.
512
+ if (name === 'timeout')
513
+ i += 1;
514
+ }
515
+ else if (name === 'pnpm' && ['exec', 'dev:exec'].includes(String(words[i + 1]))) {
516
+ i += 2;
517
+ }
518
+ else {
519
+ return { env, args: words.slice(i) };
520
+ }
521
+ }
522
+ return null;
523
+ }
524
+ /**
525
+ * The absolute path a word names, from folder `dir` (null when unknown): `~`
526
+ * expands to `home` and `~user` to its sibling, as on macOS and Linux; `$HOME`
527
+ * and `${HOME}` expand anywhere in the word. Null when any other expansion is
528
+ * left, or the word is relative to an unknown folder.
529
+ */
530
+ export function resolvePath(word, dir, home) {
531
+ const expanded = word
532
+ .replace(/^~(?=\/|$)/, home)
533
+ .replace(/^~(?=[^/])/, `${dirname(home)}/`)
534
+ .replace(/\$HOME(?!\w)|\$\{HOME\}/g, home);
535
+ if (expanded.includes('$'))
536
+ return null;
537
+ if (isAbsolute(expanded))
538
+ return resolve(expanded);
539
+ return dir === null ? null : resolve(dir, expanded);
540
+ }
541
+ /** `cd` and `pushd` options before the folder (`cd -P -- dir`). */
542
+ const CD_OPTION = /^-[LPe@]+$|^--$/;
543
+ /**
544
+ * Each simple command with the folder it runs in. A `cd` (or `pushd`) moves
545
+ * the folder for the commands after it; after one whose target is unknown
546
+ * (`cd $X`, `cd -`), later commands run in an unknown folder, where only
547
+ * absolute paths can be judged.
548
+ */
549
+ export function steps(command, cwd, home) {
550
+ const found = [];
551
+ let dir = cwd;
552
+ for (const words of everyCommand(command)) {
553
+ const run = programOf(words);
554
+ if (run === null)
555
+ continue;
556
+ const [name, ...args] = run.args;
557
+ if (name === 'cd' || name === 'pushd') {
558
+ const operands = args.filter((arg) => !CD_OPTION.test(arg));
559
+ const [target = home] = operands;
560
+ // `cd -` goes back to a folder this reading never saw.
561
+ dir = target === '-' ? null : resolvePath(target, dir, home);
562
+ continue;
563
+ }
564
+ found.push({ ...run, dir });
565
+ }
566
+ return found;
567
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The processes the rule checks start: git in a checkout, and any other
3
+ * program with its output. Its own module so the checks can use them without
4
+ * importing the runtime that runs the checks.
5
+ */
6
+ import { spawnSync } from 'node:child_process';
7
+ export const run = (program, args) => {
8
+ // Buffers; a program that cannot start has none.
9
+ const result = spawnSync(program, args);
10
+ return {
11
+ status: result.status,
12
+ output: `${result.stdout ?? ''}${result.stderr ?? ''}`,
13
+ };
14
+ };
15
+ /**
16
+ * Git in `root`. Its complaints go into the thrown error, where the fire log's
17
+ * failure note can name them, never onto the hook's own stderr.
18
+ */
19
+ export const git = (root, args) => {
20
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
21
+ if (result.status !== 0) {
22
+ // A git that cannot start leaves no stderr; its reason is in `error`.
23
+ const reason = result.error?.message ?? String(result.stderr).trim();
24
+ throw new Error(`git ${args.join(' ')}: ${reason}`);
25
+ }
26
+ return result.stdout.trim();
27
+ };
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@veda-hq/kavach",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.3.0",
4
+ "description": "Guards for coding agents: refuses writes outside the agent's worktree, git-hook bypasses and history-destroying pushes, and runs a project's own rule checks on each tool call. Read-only commands pass.",
5
+ "keywords": [
6
+ "coding agents",
7
+ "claude code",
8
+ "hooks",
9
+ "guard",
10
+ "worktree",
11
+ "git safety"
12
+ ],
13
+ "license": "MIT",
14
+ "type": "module",
15
+ "engines": {
16
+ "node": ">=24.15"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/veda-hq/veda.git",
21
+ "directory": "packages/kavach"
22
+ },
23
+ "homepage": "https://github.com/veda-hq/veda/tree/main/packages/kavach#readme",
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "provenance": true
27
+ },
28
+ "bin": {
29
+ "kavach-rule-checks": "dist/rule-checks/cli.js"
30
+ },
31
+ "exports": {
32
+ "./rule-checks": "./dist/rule-checks/runtime.js",
33
+ "./rule-checks/registry": "./dist/rule-checks/registry.js",
34
+ "./rule-checks/shell-words": "./dist/rule-checks/shell-words.js",
35
+ "./corpus.json": "./corpus/corpus.json",
36
+ "./package.json": "./package.json"
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "python/kavach/*.py",
41
+ "corpus/corpus.json"
42
+ ],
43
+ "devDependencies": {
44
+ "@types/node": "24.19.2",
45
+ "typescript": "7.0.2"
46
+ },
47
+ "scripts": {
48
+ "build": "tsc -p tsconfig.build.json",
49
+ "typecheck": "tsc -p tsconfig.json",
50
+ "test": "node --test --experimental-test-coverage --test-coverage-lines=95 --test-coverage-functions=95 --test-coverage-branches=90 --test-coverage-exclude=src/**/*.test.ts --test-coverage-exclude=corpus/**/*.test.ts src/**/*.test.ts corpus/**/*.test.ts",
51
+ "test:python": "python3 -B -m unittest discover -s python/tests -t python",
52
+ "check": "pnpm typecheck && pnpm test:python && pnpm test && pnpm build"
53
+ }
6
54
  }
@@ -0,0 +1,9 @@
1
+ """kavach: guards that keep a coding agent inside its bounds.
2
+
3
+ Entry points (each reads one Claude Code PreToolUse event on stdin; exit 0
4
+ allows, exit 2 refuses with the reason on stderr, any other end refuses):
5
+
6
+ - worktree_guard.py: no write outside the agent's own worktree.
7
+ - command_guard.py: no git-hook bypass, no history-destroying push, and the
8
+ consumer's own command rules (config.py).
9
+ """