@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,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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
"""
|