@yagni-app/code 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +30 -0
- package/dist/cli.js +147 -3
- package/dist/connectClaudeCode.d.ts +77 -0
- package/dist/connectClaudeCode.js +228 -0
- package/dist/connectCodex.d.ts +75 -0
- package/dist/connectCodex.js +201 -0
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +1 -1
- package/dist/extension/advisor.d.ts +4 -4
- package/dist/extension/advisor.js +6 -7
- package/dist/extension/approvedPrefixes.d.ts +103 -0
- package/dist/extension/approvedPrefixes.js +282 -0
- package/dist/extension/askAdvisorTool.d.ts +20 -5
- package/dist/extension/askAdvisorTool.js +126 -20
- package/dist/extension/askYagniTool.d.ts +23 -0
- package/dist/extension/askYagniTool.js +91 -2
- package/dist/extension/branding.d.ts +34 -3
- package/dist/extension/branding.js +114 -13
- package/dist/extension/chipEditor.d.ts +30 -9
- package/dist/extension/chipEditor.js +173 -59
- package/dist/extension/claudeRules.d.ts +0 -2
- package/dist/extension/claudeRules.js +0 -8
- package/dist/extension/cmux/dispatcher.d.ts +25 -0
- package/dist/extension/cmux/dispatcher.js +266 -0
- package/dist/extension/cmux/hooks.d.ts +12 -0
- package/dist/extension/cmux/hooks.js +192 -0
- package/dist/extension/cmux/index.d.ts +3 -0
- package/dist/extension/cmux/index.js +155 -0
- package/dist/extension/cmux/naming.d.ts +5 -0
- package/dist/extension/cmux/naming.js +23 -0
- package/dist/extension/cmux/state.d.ts +33 -0
- package/dist/extension/cmux/state.js +142 -0
- package/dist/extension/config.d.ts +44 -1
- package/dist/extension/config.js +37 -4
- package/dist/extension/costHud.d.ts +16 -22
- package/dist/extension/costHud.js +8 -47
- package/dist/extension/crashReport.js +1 -3
- package/dist/extension/execPolicy.d.ts +135 -0
- package/dist/extension/execPolicy.js +936 -0
- package/dist/extension/flywheel.d.ts +44 -0
- package/dist/extension/flywheel.js +53 -0
- package/dist/extension/footer.d.ts +118 -0
- package/dist/extension/footer.js +308 -0
- package/dist/extension/guardian.d.ts +139 -0
- package/dist/extension/guardian.js +237 -0
- package/dist/extension/index.d.ts +35 -7
- package/dist/extension/index.js +338 -33
- package/dist/extension/mineBeat.d.ts +95 -0
- package/dist/extension/mineBeat.js +193 -0
- package/dist/extension/permission.d.ts +124 -10
- package/dist/extension/permission.js +603 -52
- package/dist/extension/pipeline/childRegistry.d.ts +41 -0
- package/dist/extension/pipeline/childRegistry.js +118 -0
- package/dist/extension/pipeline/finish.js +5 -1
- package/dist/extension/pipeline/goCommand.d.ts +1 -1
- package/dist/extension/pipeline/goCommand.js +40 -9
- package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
- package/dist/extension/pipeline/goStatusCommands.js +61 -1
- package/dist/extension/pipeline/personas.js +26 -1
- package/dist/extension/pipeline/resilience.d.ts +2 -1
- package/dist/extension/pipeline/resilience.js +21 -2
- package/dist/extension/pipeline/runRegistry.d.ts +23 -1
- package/dist/extension/pipeline/runRegistry.js +57 -1
- package/dist/extension/pipeline/runner.js +4 -0
- package/dist/extension/pipeline/verify.d.ts +4 -0
- package/dist/extension/pipeline/verify.js +48 -26
- package/dist/extension/recordDecisionTool.d.ts +8 -0
- package/dist/extension/recordDecisionTool.js +24 -0
- package/dist/extension/redact.d.ts +20 -0
- package/dist/extension/redact.js +64 -0
- package/dist/extension/rerouteNotice.d.ts +3 -4
- package/dist/extension/rerouteNotice.js +20 -11
- package/dist/extension/subagentRender.d.ts +129 -0
- package/dist/extension/subagentRender.js +441 -0
- package/dist/extension/subagents.d.ts +11 -8
- package/dist/extension/subagents.js +162 -37
- package/dist/extension/ticketTools.d.ts +37 -0
- package/dist/extension/ticketTools.js +117 -0
- package/dist/extension/todos.d.ts +28 -1
- package/dist/extension/todos.js +76 -1
- package/dist/extension/tokenProvider.js +46 -5
- package/dist/extension/ultra.d.ts +27 -0
- package/dist/extension/ultra.js +76 -0
- package/dist/launch.d.ts +7 -0
- package/dist/launch.js +24 -12
- package/dist/login.d.ts +4 -2
- package/dist/login.js +19 -4
- package/dist/padding.d.ts +22 -0
- package/dist/padding.js +25 -0
- package/dist/promptEnrichment.d.ts +40 -0
- package/dist/promptEnrichment.js +85 -0
- package/dist/signalForward.d.ts +60 -0
- package/dist/signalForward.js +130 -0
- package/dist/token.d.ts +25 -0
- package/dist/token.js +45 -0
- package/package.json +6 -5
- package/dist/extension/boostCommand.d.ts +0 -144
- package/dist/extension/boostCommand.js +0 -263
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exec policy engine — classifies bash commands via prefix rules + lightweight
|
|
3
|
+
* shell tokenization (YAG-504, restructured in YAG-510).
|
|
4
|
+
*
|
|
5
|
+
* Pure: no I/O, no network, no model. Loads at startup and classifies
|
|
6
|
+
* synchronously. The curated default set auto-allows read-only commands
|
|
7
|
+
* (ls, cat, rg, git status/log/diff), forbids destructive ones (rm -rf,
|
|
8
|
+
* git reset --hard, git push --force, pipe-to-shell), and prompts for the
|
|
9
|
+
* ambiguous middle band (npm install, git commit, curl, …).
|
|
10
|
+
*
|
|
11
|
+
* The `prompt` band is what the Guardian arbitrates — see guardian.ts.
|
|
12
|
+
*
|
|
13
|
+
* Classification composes three signals and takes the STRICTEST:
|
|
14
|
+
* 1. prefix-rule matching on every segment (newlines, ;, &&, ||, | split);
|
|
15
|
+
* 2. a construct floor — commands using redirects, substitution, or
|
|
16
|
+
* background & can never be auto-allowed (floor: prompt);
|
|
17
|
+
* 3. dangerScan — a best-effort sweep of command-substitution inner text
|
|
18
|
+
* ($(...) and backticks, including inside double quotes) against the
|
|
19
|
+
* FORBIDDEN rules only. Danger anywhere upgrades to forbidden; the scan
|
|
20
|
+
* can never make anything more permissive.
|
|
21
|
+
* This is the codex two-parser lesson: fail closed to prove safety, scan
|
|
22
|
+
* best-effort to prove danger. A forbidden match must win even when the
|
|
23
|
+
* command also carries constructs (`rm -rf / &` is forbidden, not prompt).
|
|
24
|
+
*
|
|
25
|
+
* Command words are matched through a leading-token strip (env assignments,
|
|
26
|
+
* sudo/env/command wrappers, shell reserved words, a leading backslash) and
|
|
27
|
+
* basename normalization (/bin/rm → rm) — both applied ASYMMETRICALLY: they
|
|
28
|
+
* can make a command land on forbidden/prompt rules, but a stripped or
|
|
29
|
+
* path-prefixed command is never auto-allowed (`sudo ls` and `./ls` stay in
|
|
30
|
+
* the prompt band; an attacker-named local `./rm` binary must not ride the
|
|
31
|
+
* allow list, and `/bin/ls` pays the same price by design).
|
|
32
|
+
*
|
|
33
|
+
* Tokenization is a lightweight inline parser — not shell-quote — because the
|
|
34
|
+
* extension is bundled into @yagni-app/code's dist (a file copy, not a real
|
|
35
|
+
* bundler), and external dependencies aren't resolvable from the bundled path.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* Parse a shell command string into tokens and control operators.
|
|
39
|
+
*
|
|
40
|
+
* Handles:
|
|
41
|
+
* - Single and double quoted strings (preserves spaces inside)
|
|
42
|
+
* - Control operators: |, &&, ||, ;, and newlines (a newline separates
|
|
43
|
+
* commands exactly like `;` — treating it as whitespace let multiline
|
|
44
|
+
* commands smuggle anything behind an allow-listed first line)
|
|
45
|
+
* - `#` comments (start-of-word to end-of-line, outside quotes)
|
|
46
|
+
* - Shell constructs we flag as unanalyzable: $(), backticks (INCLUDING
|
|
47
|
+
* inside double quotes — bash executes those), >, <, background &
|
|
48
|
+
* - Redirect metadata: stdout/stderr redirects carry fd + target so that
|
|
49
|
+
* safe redirects (2>/dev/null, 2>&1) can be distinguished from unsafe ones
|
|
50
|
+
* (> file.txt). Stdin redirects (<, <<) carry no metadata — they always
|
|
51
|
+
* floor. Background & emits a distinct `background` op (not `semi`) so
|
|
52
|
+
* hasUnhandledConstructs can always catch it.
|
|
53
|
+
*
|
|
54
|
+
* Does NOT handle: variable expansion, glob patterns, heredocs beyond the
|
|
55
|
+
* redirect flag, nested subshells beyond depth tracking. Commands using
|
|
56
|
+
* those are classified as "prompt" at minimum (construct floor).
|
|
57
|
+
*/
|
|
58
|
+
export function shellParse(command) {
|
|
59
|
+
const tokens = [];
|
|
60
|
+
let i = 0;
|
|
61
|
+
let current = "";
|
|
62
|
+
let inSingle = false;
|
|
63
|
+
let inDouble = false;
|
|
64
|
+
let hasConstruct = false;
|
|
65
|
+
const pushCurrent = () => {
|
|
66
|
+
if (current.length > 0) {
|
|
67
|
+
tokens.push(current);
|
|
68
|
+
current = "";
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
while (i < command.length) {
|
|
72
|
+
const ch = command[i];
|
|
73
|
+
if (inSingle) {
|
|
74
|
+
if (ch === "'") {
|
|
75
|
+
inSingle = false;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
current += ch;
|
|
79
|
+
}
|
|
80
|
+
i++;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (inDouble) {
|
|
84
|
+
if (ch === '"') {
|
|
85
|
+
inDouble = false;
|
|
86
|
+
i++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// Backslash escapes that bash honors inside double quotes: \$ \` \" \\.
|
|
90
|
+
// Without this, `echo "\$(safe)"` would false-flag as a substitution.
|
|
91
|
+
if (ch === "\\" && (command[i + 1] === "$" || command[i + 1] === "`" || command[i + 1] === '"' || command[i + 1] === "\\")) {
|
|
92
|
+
current += command[i + 1];
|
|
93
|
+
i += 2;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// Bash EXECUTES $(...) and backticks inside double quotes; the old
|
|
97
|
+
// tokenizer treated them as literal text, which made
|
|
98
|
+
// `echo "$(rm -rf x)"` classify as a plain echo → allow. Flag as a
|
|
99
|
+
// construct; dangerScan sweeps the inner text separately.
|
|
100
|
+
if ((ch === "$" && command[i + 1] === "(") || ch === "`") {
|
|
101
|
+
hasConstruct = true;
|
|
102
|
+
}
|
|
103
|
+
current += ch;
|
|
104
|
+
i++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
switch (ch) {
|
|
108
|
+
case "'":
|
|
109
|
+
inSingle = true;
|
|
110
|
+
i++;
|
|
111
|
+
continue;
|
|
112
|
+
case '"':
|
|
113
|
+
inDouble = true;
|
|
114
|
+
i++;
|
|
115
|
+
continue;
|
|
116
|
+
case "#":
|
|
117
|
+
// Comment: only at a word boundary (bash rule). `echo a#b` keeps the #.
|
|
118
|
+
if (current.length === 0) {
|
|
119
|
+
while (i < command.length && command[i] !== "\n")
|
|
120
|
+
i++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
current += ch;
|
|
124
|
+
i++;
|
|
125
|
+
continue;
|
|
126
|
+
case " ":
|
|
127
|
+
case "\t":
|
|
128
|
+
pushCurrent();
|
|
129
|
+
i++;
|
|
130
|
+
continue;
|
|
131
|
+
case "\n":
|
|
132
|
+
case "\r":
|
|
133
|
+
// Newlines separate commands like `;` — NOT whitespace.
|
|
134
|
+
pushCurrent();
|
|
135
|
+
tokens.push({ op: "semi" });
|
|
136
|
+
i++;
|
|
137
|
+
continue;
|
|
138
|
+
case "|":
|
|
139
|
+
if (command[i + 1] === "|") {
|
|
140
|
+
pushCurrent();
|
|
141
|
+
tokens.push({ op: "or" });
|
|
142
|
+
i += 2;
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
pushCurrent();
|
|
146
|
+
tokens.push({ op: "pipe" });
|
|
147
|
+
i++;
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
case "&":
|
|
151
|
+
if (command[i + 1] === "&") {
|
|
152
|
+
pushCurrent();
|
|
153
|
+
tokens.push({ op: "and" });
|
|
154
|
+
i += 2;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
// Single & — background operator. Emits a distinct `background` op
|
|
158
|
+
// (not `semi`) so hasUnhandledConstructs can always catch it and
|
|
159
|
+
// floor the command. The command before it must still be rule-
|
|
160
|
+
// matched: `rm -rf / &` has to stay forbidden, so emit a separator
|
|
161
|
+
// rather than gluing.
|
|
162
|
+
pushCurrent();
|
|
163
|
+
tokens.push({ op: "background" });
|
|
164
|
+
hasConstruct = true;
|
|
165
|
+
i++;
|
|
166
|
+
}
|
|
167
|
+
continue;
|
|
168
|
+
case ";":
|
|
169
|
+
pushCurrent();
|
|
170
|
+
tokens.push({ op: "semi" });
|
|
171
|
+
i++;
|
|
172
|
+
continue;
|
|
173
|
+
case ">": {
|
|
174
|
+
pushCurrent();
|
|
175
|
+
// Check for a preceding fd digit: `2>` → stderr, `1>` → stdout.
|
|
176
|
+
// The digit was emitted as a string token — pop it and use as fd.
|
|
177
|
+
let fd = "stdout";
|
|
178
|
+
if (tokens.length > 0 && typeof tokens[tokens.length - 1] === "string") {
|
|
179
|
+
const last = tokens[tokens.length - 1];
|
|
180
|
+
if (last === "2") {
|
|
181
|
+
fd = "stderr";
|
|
182
|
+
tokens.pop();
|
|
183
|
+
}
|
|
184
|
+
else if (last === "1") {
|
|
185
|
+
fd = "stdout";
|
|
186
|
+
tokens.pop();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
let append = false;
|
|
190
|
+
i++;
|
|
191
|
+
if (command[i] === ">") {
|
|
192
|
+
append = true;
|
|
193
|
+
i++;
|
|
194
|
+
}
|
|
195
|
+
while (command[i] === " " || command[i] === "\t")
|
|
196
|
+
i++;
|
|
197
|
+
// Read the target — handles quoted targets (mirrors the main loop's
|
|
198
|
+
// quote logic), fd merges (&N), and bare words.
|
|
199
|
+
let target = "";
|
|
200
|
+
if (command[i] === "&") {
|
|
201
|
+
// fd merge: &1, &2, etc.
|
|
202
|
+
i++;
|
|
203
|
+
let digits = "";
|
|
204
|
+
while (command[i] >= "0" && command[i] <= "9") {
|
|
205
|
+
digits += command[i];
|
|
206
|
+
i++;
|
|
207
|
+
}
|
|
208
|
+
target = "&" + digits;
|
|
209
|
+
}
|
|
210
|
+
else if (command[i] === "'") {
|
|
211
|
+
i++;
|
|
212
|
+
while (i < command.length && command[i] !== "'") {
|
|
213
|
+
target += command[i];
|
|
214
|
+
i++;
|
|
215
|
+
}
|
|
216
|
+
if (i < command.length)
|
|
217
|
+
i++;
|
|
218
|
+
}
|
|
219
|
+
else if (command[i] === '"') {
|
|
220
|
+
i++;
|
|
221
|
+
while (i < command.length && command[i] !== '"') {
|
|
222
|
+
if (command[i] === "\\" && i + 1 < command.length) {
|
|
223
|
+
target += command[i + 1];
|
|
224
|
+
i += 2;
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
target += command[i];
|
|
228
|
+
i++;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (i < command.length)
|
|
232
|
+
i++;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
while (i < command.length &&
|
|
236
|
+
command[i] !== " " && command[i] !== "\t" &&
|
|
237
|
+
command[i] !== "\n" && command[i] !== "\r" &&
|
|
238
|
+
command[i] !== "|" && command[i] !== "&" &&
|
|
239
|
+
command[i] !== ";" && command[i] !== ">" &&
|
|
240
|
+
command[i] !== "<") {
|
|
241
|
+
target += command[i];
|
|
242
|
+
i++;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
tokens.push({ op: "redirect", direction: "out", fd, target, append });
|
|
246
|
+
hasConstruct = true;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
case "<": {
|
|
250
|
+
pushCurrent();
|
|
251
|
+
// Stdin redirect — always floors (changes program behavior).
|
|
252
|
+
i++;
|
|
253
|
+
if (command[i] === "<")
|
|
254
|
+
i++; // << heredoc — skip delimiter
|
|
255
|
+
while (command[i] === " " || command[i] === "\t")
|
|
256
|
+
i++;
|
|
257
|
+
// Consume the target (filename or heredoc delimiter) so it doesn't
|
|
258
|
+
// appear as a segment token — mirrors the > case's target reading.
|
|
259
|
+
if (command[i] === "'") {
|
|
260
|
+
i++;
|
|
261
|
+
while (i < command.length && command[i] !== "'")
|
|
262
|
+
i++;
|
|
263
|
+
if (i < command.length)
|
|
264
|
+
i++;
|
|
265
|
+
}
|
|
266
|
+
else if (command[i] === '"') {
|
|
267
|
+
i++;
|
|
268
|
+
while (i < command.length && command[i] !== '"') {
|
|
269
|
+
if (command[i] === "\\" && i + 1 < command.length)
|
|
270
|
+
i += 2;
|
|
271
|
+
else
|
|
272
|
+
i++;
|
|
273
|
+
}
|
|
274
|
+
if (i < command.length)
|
|
275
|
+
i++;
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
while (i < command.length &&
|
|
279
|
+
command[i] !== " " && command[i] !== "\t" &&
|
|
280
|
+
command[i] !== "\n" && command[i] !== "\r" &&
|
|
281
|
+
command[i] !== "|" && command[i] !== "&" &&
|
|
282
|
+
command[i] !== ";" && command[i] !== ">" &&
|
|
283
|
+
command[i] !== "<") {
|
|
284
|
+
i++;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
tokens.push({ op: "redirect", direction: "in" });
|
|
288
|
+
hasConstruct = true;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
case "$":
|
|
292
|
+
if (command[i + 1] === "(") {
|
|
293
|
+
pushCurrent();
|
|
294
|
+
tokens.push({ op: "substitution" });
|
|
295
|
+
hasConstruct = true;
|
|
296
|
+
// Skip until matching )
|
|
297
|
+
i += 2;
|
|
298
|
+
let depth = 1;
|
|
299
|
+
while (i < command.length && depth > 0) {
|
|
300
|
+
if (command[i] === "(")
|
|
301
|
+
depth++;
|
|
302
|
+
if (command[i] === ")")
|
|
303
|
+
depth--;
|
|
304
|
+
i++;
|
|
305
|
+
}
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
current += ch;
|
|
309
|
+
i++;
|
|
310
|
+
continue;
|
|
311
|
+
case "`":
|
|
312
|
+
pushCurrent();
|
|
313
|
+
tokens.push({ op: "substitution" });
|
|
314
|
+
hasConstruct = true;
|
|
315
|
+
i++;
|
|
316
|
+
while (i < command.length && command[i] !== "`")
|
|
317
|
+
i++;
|
|
318
|
+
if (i < command.length)
|
|
319
|
+
i++;
|
|
320
|
+
continue;
|
|
321
|
+
default:
|
|
322
|
+
current += ch;
|
|
323
|
+
i++;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
pushCurrent();
|
|
328
|
+
// If we detected constructs but didn't emit them as operator tokens
|
|
329
|
+
// (e.g. double-quoted substitution), surface that via a trailing
|
|
330
|
+
// substitution token so hasUnhandledConstructs sees it. Background & now
|
|
331
|
+
// emits its own distinct op, so it no longer relies on this fallback.
|
|
332
|
+
if (hasConstruct && !tokens.some((t) => typeof t === "object" && (t.op === "redirect" || t.op === "substitution" || t.op === "background"))) {
|
|
333
|
+
tokens.push({ op: "substitution" });
|
|
334
|
+
}
|
|
335
|
+
return tokens;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Extract the inner text of every command substitution — $(...) and
|
|
339
|
+
* backticks — respecting single-quote literalness and backslash escapes.
|
|
340
|
+
* Includes substitutions inside double quotes (bash executes those).
|
|
341
|
+
* Best-effort, used ONLY by dangerScan to prove danger, never safety.
|
|
342
|
+
*/
|
|
343
|
+
export function extractSubstitutions(command) {
|
|
344
|
+
const found = [];
|
|
345
|
+
let i = 0;
|
|
346
|
+
let inSingle = false;
|
|
347
|
+
while (i < command.length) {
|
|
348
|
+
const ch = command[i];
|
|
349
|
+
if (inSingle) {
|
|
350
|
+
if (ch === "'")
|
|
351
|
+
inSingle = false;
|
|
352
|
+
i++;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (ch === "'") {
|
|
356
|
+
inSingle = true;
|
|
357
|
+
i++;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (ch === "\\") {
|
|
361
|
+
i += 2;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (ch === "$" && command[i + 1] === "(") {
|
|
365
|
+
const start = i + 2;
|
|
366
|
+
let depth = 1;
|
|
367
|
+
let j = start;
|
|
368
|
+
while (j < command.length && depth > 0) {
|
|
369
|
+
if (command[j] === "(")
|
|
370
|
+
depth++;
|
|
371
|
+
if (command[j] === ")")
|
|
372
|
+
depth--;
|
|
373
|
+
j++;
|
|
374
|
+
}
|
|
375
|
+
found.push(command.slice(start, depth === 0 ? j - 1 : j));
|
|
376
|
+
i = j;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (ch === "`") {
|
|
380
|
+
const start = i + 1;
|
|
381
|
+
let j = start;
|
|
382
|
+
while (j < command.length && command[j] !== "`")
|
|
383
|
+
j++;
|
|
384
|
+
found.push(command.slice(start, j));
|
|
385
|
+
i = j < command.length ? j + 1 : j;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
i++;
|
|
389
|
+
}
|
|
390
|
+
return found;
|
|
391
|
+
}
|
|
392
|
+
/** Interpreters that, when piped into, indicate code execution — always forbidden. */
|
|
393
|
+
const PIPE_TO_SHELL = new Set([
|
|
394
|
+
"sh", "bash", "zsh", "fish", "nc", "ncat", "socat",
|
|
395
|
+
"python", "python3", "perl", "ruby", "node",
|
|
396
|
+
]);
|
|
397
|
+
/** Wrapper words that forward to another command (`sudo rm …` runs rm). */
|
|
398
|
+
const WRAPPER_WORDS = new Set(["sudo", "env", "command", "builtin", "exec", "nohup", "time", "nice"]);
|
|
399
|
+
/** Shell reserved words that can precede a command inside control flow. */
|
|
400
|
+
const RESERVED_WORDS = new Set(["do", "then", "else", "elif", "if", "while", "until", "done", "fi", "esac", "!"]);
|
|
401
|
+
/** Bash env-assignment prefix: FOO=bar cmd … */
|
|
402
|
+
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
403
|
+
/**
|
|
404
|
+
* Parse a command string into tokens using our lightweight tokenizer. Returns
|
|
405
|
+
* string tokens only (control operators and constructs are filtered out —
|
|
406
|
+
* detected separately).
|
|
407
|
+
*/
|
|
408
|
+
export function tokenize(command) {
|
|
409
|
+
return shellParse(command).filter((t) => typeof t === "string");
|
|
410
|
+
}
|
|
411
|
+
/** Ops that split compound commands into segments (pipe, &&, ||, ;, background &). */
|
|
412
|
+
const SPLIT_OPS = new Set(["pipe", "and", "or", "semi", "background"]);
|
|
413
|
+
/** Ops that are safe splittable separators — they do NOT trigger the construct floor.
|
|
414
|
+
* Background & is in SPLIT_OPS (splits segments) but NOT here (always floors). */
|
|
415
|
+
const SAFE_SPLIT_OPS = new Set(["pipe", "and", "or", "semi"]);
|
|
416
|
+
/** A redirect to /dev/null (discard) or &N (fd merge) is safe — no file created. */
|
|
417
|
+
function isSafeRedirect(t) {
|
|
418
|
+
if (typeof t === "string")
|
|
419
|
+
return false;
|
|
420
|
+
if (t.op !== "redirect")
|
|
421
|
+
return false;
|
|
422
|
+
if (t.direction === "in")
|
|
423
|
+
return false; // stdin redirect — always floor
|
|
424
|
+
if (t.target === "/dev/null")
|
|
425
|
+
return true; // discard stderr/stdout — safe
|
|
426
|
+
if (t.target.startsWith("&") && t.target.length > 1)
|
|
427
|
+
return true; // fd merge (2>&1, 1>&2) — safe; bare "&" (>& with no digit) is NOT safe
|
|
428
|
+
return false; // > file.txt, >> file.txt — unsafe
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Detect whether the command uses shell constructs we can't statically
|
|
432
|
+
* classify (command substitution, unsafe redirects, background &) — anything
|
|
433
|
+
* that is NOT a safe splittable operator or a safe redirect. These impose a
|
|
434
|
+
* floor of `prompt`: a command carrying them is never auto-allowed, but
|
|
435
|
+
* forbidden matches still win.
|
|
436
|
+
*/
|
|
437
|
+
function hasUnhandledConstructs(command) {
|
|
438
|
+
return shellParse(command).some((t) => typeof t === "object" && !SAFE_SPLIT_OPS.has(t.op) && !isSafeRedirect(t));
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Split a command into token-array segments at control operators (|, &&, ||,
|
|
442
|
+
* ;, newline, background &). Redirect targets are consumed inside the
|
|
443
|
+
* tokenizer's > / < cases (stored on the redirect op), so no skipNext logic
|
|
444
|
+
* is needed. Token arrays are carried through (never re-joined into strings)
|
|
445
|
+
* so quoting survives.
|
|
446
|
+
*/
|
|
447
|
+
function splitSegmentsTokens(command) {
|
|
448
|
+
const parsed = shellParse(command);
|
|
449
|
+
const segments = [];
|
|
450
|
+
let current = [];
|
|
451
|
+
for (const t of parsed) {
|
|
452
|
+
if (typeof t === "object") {
|
|
453
|
+
if (SPLIT_OPS.has(t.op)) {
|
|
454
|
+
if (current.length > 0)
|
|
455
|
+
segments.push(current);
|
|
456
|
+
current = [];
|
|
457
|
+
}
|
|
458
|
+
// redirect and substitution ops are construct markers; their targets
|
|
459
|
+
// are consumed inside the tokenizer, and the inner text of $(...) is
|
|
460
|
+
// handled by dangerScan via extractSubstitutions.
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
current.push(t);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (current.length > 0)
|
|
467
|
+
segments.push(current);
|
|
468
|
+
return segments;
|
|
469
|
+
}
|
|
470
|
+
/** basename("/usr/bin/git") → "git"; leaves plain words untouched. */
|
|
471
|
+
function basenameToken(token) {
|
|
472
|
+
const idx = token.lastIndexOf("/");
|
|
473
|
+
return idx >= 0 ? token.slice(idx + 1) : token;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Unified leading-token strip: remove env assignments, wrapper words (plus
|
|
477
|
+
* their immediate dash-flags), shell reserved words, leading `(`/`{` (even
|
|
478
|
+
* glued: `(rm`), and a leading backslash on the command word. Used to FIND
|
|
479
|
+
* the command word for forbidden/prompt matching — callers must treat a
|
|
480
|
+
* stripped result as never-allow (see classifySegmentTokens).
|
|
481
|
+
*/
|
|
482
|
+
function stripLeadingTokens(tokens) {
|
|
483
|
+
const out = [...tokens];
|
|
484
|
+
let stripped = false;
|
|
485
|
+
let guard = 0;
|
|
486
|
+
while (out.length > 0 && guard++ < 32) {
|
|
487
|
+
let t = out[0];
|
|
488
|
+
// Leading ( or { — possibly glued to the command word.
|
|
489
|
+
if (t.startsWith("(") || t.startsWith("{")) {
|
|
490
|
+
const trimmed = t.replace(/^[({]+/, "");
|
|
491
|
+
stripped = true;
|
|
492
|
+
if (trimmed.length === 0) {
|
|
493
|
+
out.shift();
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
out[0] = trimmed;
|
|
497
|
+
}
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
// Trailing ) } on a lone closer token — drop (e.g. segment "rm -rf /)" ).
|
|
501
|
+
if (/^[)}]+$/.test(t)) {
|
|
502
|
+
out.shift();
|
|
503
|
+
stripped = true;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (RESERVED_WORDS.has(t)) {
|
|
507
|
+
out.shift();
|
|
508
|
+
stripped = true;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (ENV_ASSIGNMENT_RE.test(t)) {
|
|
512
|
+
out.shift();
|
|
513
|
+
stripped = true;
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (WRAPPER_WORDS.has(basenameToken(t))) {
|
|
517
|
+
out.shift();
|
|
518
|
+
stripped = true;
|
|
519
|
+
// Wrapper flags (env -i, sudo -n, …). Imperfect for flags that take a
|
|
520
|
+
// separate value (sudo -u alice); worst case the "command word" is the
|
|
521
|
+
// value and we land in the prompt band — never allow.
|
|
522
|
+
while (out.length > 0 && out[0].startsWith("-"))
|
|
523
|
+
out.shift();
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (t.startsWith("\\") && t.length > 1) {
|
|
527
|
+
out[0] = t.slice(1);
|
|
528
|
+
stripped = true;
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
return { tokens: out, stripped };
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* git accepts global options between `git` and the subcommand (`git -C /x
|
|
537
|
+
* push --force`). Skip them so subcommand rules and flagsAnywhere see the
|
|
538
|
+
* real shape. Matching-only — never mutates what actually runs.
|
|
539
|
+
*/
|
|
540
|
+
function normalizeGitTokens(tokens) {
|
|
541
|
+
if (tokens[0] !== "git")
|
|
542
|
+
return tokens;
|
|
543
|
+
const out = ["git"];
|
|
544
|
+
let i = 1;
|
|
545
|
+
while (i < tokens.length) {
|
|
546
|
+
const t = tokens[i];
|
|
547
|
+
if (t === "-C" || t === "-c") {
|
|
548
|
+
i += 2; // option + its value
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
if (t.startsWith("--git-dir") ||
|
|
552
|
+
t.startsWith("--work-tree") ||
|
|
553
|
+
t.startsWith("--exec-path") ||
|
|
554
|
+
t === "-p" ||
|
|
555
|
+
t === "--paginate" ||
|
|
556
|
+
t === "--no-pager") {
|
|
557
|
+
i += 1;
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
562
|
+
out.push(...tokens.slice(i));
|
|
563
|
+
return out;
|
|
564
|
+
}
|
|
565
|
+
/** Glob-aware token match shared by unlessTokens and flagsAnywhere. */
|
|
566
|
+
function tokenMatchesEntry(token, entry) {
|
|
567
|
+
return entry.endsWith("*") ? token.startsWith(entry.slice(0, -1)) : token === entry;
|
|
568
|
+
}
|
|
569
|
+
/** Match a token array against a prefix rule's pattern. */
|
|
570
|
+
function matchRule(tokens, rule) {
|
|
571
|
+
if (tokens.length < rule.pattern.length)
|
|
572
|
+
return false;
|
|
573
|
+
for (let i = 0; i < rule.pattern.length; i++) {
|
|
574
|
+
const pat = rule.pattern[i];
|
|
575
|
+
const tok = tokens[i];
|
|
576
|
+
if (typeof pat === "string") {
|
|
577
|
+
if (pat !== tok)
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
if (!pat.includes(tok))
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (rule.flagsAnywhere) {
|
|
586
|
+
const rest = tokens.slice(rule.pattern.length);
|
|
587
|
+
const hit = rest.some((tok) => rule.flagsAnywhere.some((f) => tokenMatchesEntry(tok, f)));
|
|
588
|
+
if (!hit)
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
if (rule.unlessTokens) {
|
|
592
|
+
for (const tok of tokens.slice(rule.pattern.length)) {
|
|
593
|
+
for (const unless of rule.unlessTokens) {
|
|
594
|
+
if (tokenMatchesEntry(tok, unless))
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
/** Decision severity: forbidden > prompt > allow. */
|
|
602
|
+
const SEVERITY = { allow: 0, prompt: 1, forbidden: 2 };
|
|
603
|
+
function stricter(a, b) {
|
|
604
|
+
return SEVERITY[b.decision] > SEVERITY[a.decision] ? b : a;
|
|
605
|
+
}
|
|
606
|
+
/** Depth cap for substitution/xargs recursion (matches codex's wrapper cap). */
|
|
607
|
+
const MAX_SCAN_DEPTH = 8;
|
|
608
|
+
/** Classify one segment's tokens against the rules. */
|
|
609
|
+
function classifySegmentTokens(rawTokens, policy, opts) {
|
|
610
|
+
if (rawTokens.length === 0) {
|
|
611
|
+
return { decision: "prompt", justification: "empty command segment" };
|
|
612
|
+
}
|
|
613
|
+
const { tokens: strippedTokens, stripped } = stripLeadingTokens(rawTokens);
|
|
614
|
+
if (strippedTokens.length === 0) {
|
|
615
|
+
return opts.forbiddenOnly
|
|
616
|
+
? { decision: "allow", justification: "no forbidden match" }
|
|
617
|
+
: { decision: "prompt", justification: "empty command segment" };
|
|
618
|
+
}
|
|
619
|
+
// Asymmetric basename normalization: /bin/rm → rm for matching, but a
|
|
620
|
+
// path-prefixed command word disqualifies allow (see below).
|
|
621
|
+
const cmdWord = strippedTokens[0];
|
|
622
|
+
const normalizedWord = basenameToken(cmdWord);
|
|
623
|
+
const pathPrefixed = normalizedWord !== cmdWord;
|
|
624
|
+
let tokens = pathPrefixed ? [normalizedWord, ...strippedTokens.slice(1)] : strippedTokens;
|
|
625
|
+
tokens = normalizeGitTokens(tokens);
|
|
626
|
+
const neverAllow = stripped || pathPrefixed;
|
|
627
|
+
// xargs forwards to its argv tail: classify the tail as its own segment so
|
|
628
|
+
// `xargs rm -rf` inherits rm's forbidden. xargs itself is never allow.
|
|
629
|
+
if (tokens[0] === "xargs" && opts.depth < MAX_SCAN_DEPTH) {
|
|
630
|
+
let j = 1;
|
|
631
|
+
while (j < tokens.length && tokens[j].startsWith("-"))
|
|
632
|
+
j++;
|
|
633
|
+
const tail = tokens.slice(j);
|
|
634
|
+
if (tail.length > 0) {
|
|
635
|
+
const tailResult = classifySegmentTokens(tail, policy, { ...opts, depth: opts.depth + 1 });
|
|
636
|
+
if (tailResult.decision === "forbidden")
|
|
637
|
+
return tailResult;
|
|
638
|
+
if (tailResult.decision === "allow" && !neverAllow) {
|
|
639
|
+
return { decision: "allow", justification: "xargs forwards to a read-only command" };
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (opts.forbiddenOnly)
|
|
643
|
+
return { decision: "allow", justification: "no forbidden match" };
|
|
644
|
+
return { decision: "prompt", justification: "xargs executes its argument command — review the target" };
|
|
645
|
+
}
|
|
646
|
+
// First match wins (rules are ordered; more specific rules come first).
|
|
647
|
+
for (const rule of policy.rules) {
|
|
648
|
+
if (opts.forbiddenOnly && rule.decision !== "forbidden")
|
|
649
|
+
continue;
|
|
650
|
+
if (matchRule(tokens, rule)) {
|
|
651
|
+
if (rule.decision === "allow" && neverAllow) {
|
|
652
|
+
return {
|
|
653
|
+
decision: "prompt",
|
|
654
|
+
justification: "wrapper- or path-prefixed command cannot be auto-allowed",
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
return { decision: rule.decision, justification: rule.justification, matchedRule: rule };
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (opts.forbiddenOnly) {
|
|
661
|
+
return { decision: "allow", justification: "no forbidden match" };
|
|
662
|
+
}
|
|
663
|
+
// No rule matched → prompt (fail toward review, not toward allow)
|
|
664
|
+
return { decision: "prompt", justification: `no policy rule matched for "${tokens[0]}"` };
|
|
665
|
+
}
|
|
666
|
+
/** Check if any segment pipes into a known shell/network interpreter. */
|
|
667
|
+
function isPipeToShell(command) {
|
|
668
|
+
const parsed = shellParse(command);
|
|
669
|
+
for (let i = 0; i < parsed.length - 1; i++) {
|
|
670
|
+
const t = parsed[i];
|
|
671
|
+
if (typeof t === "object" && t.op === "pipe") {
|
|
672
|
+
const next = parsed[i + 1];
|
|
673
|
+
if (typeof next !== "string")
|
|
674
|
+
continue;
|
|
675
|
+
const word = basenameToken(next.startsWith("\\") ? next.slice(1) : next);
|
|
676
|
+
if (PIPE_TO_SHELL.has(word))
|
|
677
|
+
return true;
|
|
678
|
+
// `… | env sh` / `… | /usr/bin/env sh`
|
|
679
|
+
if (word === "env") {
|
|
680
|
+
const after = parsed[i + 2];
|
|
681
|
+
if (typeof after === "string" && PIPE_TO_SHELL.has(basenameToken(after)))
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Best-effort danger sweep of command-substitution inner text ($(...) and
|
|
690
|
+
* backticks, including inside double quotes). Matches FORBIDDEN rules only —
|
|
691
|
+
* can upgrade the classification, never relax it.
|
|
692
|
+
*/
|
|
693
|
+
function dangerScanSubstitutions(command, policy, depth) {
|
|
694
|
+
if (depth > MAX_SCAN_DEPTH)
|
|
695
|
+
return null;
|
|
696
|
+
for (const inner of extractSubstitutions(command)) {
|
|
697
|
+
if (inner.trim().length === 0)
|
|
698
|
+
continue;
|
|
699
|
+
if (isPipeToShell(inner)) {
|
|
700
|
+
return {
|
|
701
|
+
decision: "forbidden",
|
|
702
|
+
justification: "piping into a shell or network interpreter is forbidden",
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
for (const seg of splitSegmentsTokens(inner)) {
|
|
706
|
+
const r = classifySegmentTokens(seg, policy, { forbiddenOnly: true, depth: depth + 1 });
|
|
707
|
+
if (r.decision === "forbidden")
|
|
708
|
+
return r;
|
|
709
|
+
}
|
|
710
|
+
const nested = dangerScanSubstitutions(inner, policy, depth + 1);
|
|
711
|
+
if (nested)
|
|
712
|
+
return nested;
|
|
713
|
+
}
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Classify a full bash command string against the exec policy.
|
|
718
|
+
*
|
|
719
|
+
* Compound commands (pipes, &&, ||, ;, newlines) are split into segments and
|
|
720
|
+
* each is classified independently; the strictest decision wins (forbidden >
|
|
721
|
+
* prompt > allow). Commands with shell constructs (substitution, redirects,
|
|
722
|
+
* background &) have a floor of `prompt`, and their substitution inner text
|
|
723
|
+
* is danger-scanned against the forbidden rules. Pipe-to-shell is always
|
|
724
|
+
* forbidden.
|
|
725
|
+
*/
|
|
726
|
+
export function classifyCommand(command, policy) {
|
|
727
|
+
// Pipe-to-shell is always forbidden regardless of other rules.
|
|
728
|
+
if (isPipeToShell(command)) {
|
|
729
|
+
return {
|
|
730
|
+
decision: "forbidden",
|
|
731
|
+
justification: "piping into a shell or network interpreter is forbidden",
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
const constructFloor = hasUnhandledConstructs(command);
|
|
735
|
+
const segments = splitSegmentsTokens(command);
|
|
736
|
+
let result = null;
|
|
737
|
+
for (const seg of segments) {
|
|
738
|
+
const segResult = classifySegmentTokens(seg, policy, { depth: 0 });
|
|
739
|
+
result = result === null ? segResult : stricter(result, segResult);
|
|
740
|
+
if (result.decision === "forbidden")
|
|
741
|
+
break;
|
|
742
|
+
}
|
|
743
|
+
if (result === null) {
|
|
744
|
+
result = { decision: "prompt", justification: "empty command segment" };
|
|
745
|
+
}
|
|
746
|
+
// Danger sweep of substitution inner text — upgrade-only.
|
|
747
|
+
if (result.decision !== "forbidden") {
|
|
748
|
+
const danger = dangerScanSubstitutions(command, policy, 0);
|
|
749
|
+
if (danger)
|
|
750
|
+
result = danger;
|
|
751
|
+
}
|
|
752
|
+
// Construct floor: redirects/substitutions/background can never auto-allow.
|
|
753
|
+
if (constructFloor && result.decision === "allow") {
|
|
754
|
+
result = {
|
|
755
|
+
decision: "prompt",
|
|
756
|
+
justification: "command uses shell constructs (substitution/redirect/background) that cannot be statically analyzed",
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
return result;
|
|
760
|
+
}
|
|
761
|
+
/** Curated default rules — the shipped safety floor. */
|
|
762
|
+
export const DEFAULT_EXEC_POLICY = {
|
|
763
|
+
rules: [
|
|
764
|
+
// --- forbidden: position-independent dangerous-flag rules (checked first;
|
|
765
|
+
// GNU getopt permutes flags, so `rm x -rf` and `git push origin
|
|
766
|
+
// --force` carry the flag after positional args) ---
|
|
767
|
+
{
|
|
768
|
+
pattern: ["rm"],
|
|
769
|
+
flagsAnywhere: ["-r*", "-f*", "--recursive*", "--force*"],
|
|
770
|
+
decision: "forbidden",
|
|
771
|
+
justification: "recursive/forced deletion is destructive and irreversible",
|
|
772
|
+
},
|
|
773
|
+
{
|
|
774
|
+
// Exact --force/-f only: --force-with-lease is the guarded variant and
|
|
775
|
+
// deliberately stays in the prompt band (Guardian reviews it) — but the
|
|
776
|
+
// grants layer fences ALL --force* so a prefix approval never covers it
|
|
777
|
+
// (approvedPrefixes.ts).
|
|
778
|
+
pattern: ["git", "push"],
|
|
779
|
+
flagsAnywhere: ["--force", "-f", "--mirror", "--delete", "-d", "--receive-pack*", "--exec*"],
|
|
780
|
+
decision: "forbidden",
|
|
781
|
+
justification: "force/delete/mirror push rewrites or removes shared history",
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
pattern: ["git", "clean"],
|
|
785
|
+
flagsAnywhere: ["-f*", "-d*", "-x*", "--force*"],
|
|
786
|
+
decision: "forbidden",
|
|
787
|
+
justification: "git clean removes untracked files irreversibly",
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
pattern: ["chmod"],
|
|
791
|
+
flagsAnywhere: ["777", "0777", "a+rwx"],
|
|
792
|
+
decision: "forbidden",
|
|
793
|
+
justification: "world-writable permission change weakens security",
|
|
794
|
+
},
|
|
795
|
+
{
|
|
796
|
+
pattern: ["chown"],
|
|
797
|
+
flagsAnywhere: ["-R*", "--recursive*"],
|
|
798
|
+
decision: "forbidden",
|
|
799
|
+
justification: "recursive ownership change",
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
pattern: ["kill"],
|
|
803
|
+
flagsAnywhere: ["-9", "-KILL", "-SIGKILL"],
|
|
804
|
+
decision: "forbidden",
|
|
805
|
+
justification: "force kill is destructive",
|
|
806
|
+
},
|
|
807
|
+
// --- forbidden: destructive commands (positional) ---
|
|
808
|
+
{
|
|
809
|
+
pattern: ["rm", ["-rf", "-fr", "-r", "-f", "--recursive", "--force"]],
|
|
810
|
+
decision: "forbidden",
|
|
811
|
+
justification: "recursive/forced deletion is destructive and irreversible",
|
|
812
|
+
},
|
|
813
|
+
{ pattern: ["git", "reset", "--hard"], decision: "forbidden", justification: "hard reset discards uncommitted changes irreversibly" },
|
|
814
|
+
{ pattern: ["git", "checkout", "--"], decision: "forbidden", justification: "discards working tree changes" },
|
|
815
|
+
{ pattern: ["dd"], decision: "forbidden", justification: "low-level disk operations are destructive" },
|
|
816
|
+
{ pattern: ["mkfs"], decision: "forbidden", justification: "filesystem formatting is destructive" },
|
|
817
|
+
{ pattern: ["shutdown"], decision: "forbidden", justification: "system shutdown" },
|
|
818
|
+
{ pattern: ["reboot"], decision: "forbidden", justification: "system reboot" },
|
|
819
|
+
{ pattern: ["truncate"], decision: "forbidden", justification: "truncates files destructively" },
|
|
820
|
+
// --- allow: read-only commands ---
|
|
821
|
+
{ pattern: ["ls"], decision: "allow", justification: "list directory contents" },
|
|
822
|
+
{ pattern: ["cat"], decision: "allow", justification: "read file contents" },
|
|
823
|
+
{ pattern: ["head"], decision: "allow", justification: "read file head" },
|
|
824
|
+
{ pattern: ["tail"], decision: "allow", justification: "read file tail" },
|
|
825
|
+
{ pattern: ["wc"], decision: "allow", justification: "count lines/words" },
|
|
826
|
+
{ pattern: ["pwd"], decision: "allow", justification: "print working directory" },
|
|
827
|
+
{ pattern: ["which"], decision: "allow", justification: "locate a command" },
|
|
828
|
+
{ pattern: ["echo"], decision: "allow", justification: "print text" },
|
|
829
|
+
{ pattern: ["true"], decision: "allow", justification: "no-op success" },
|
|
830
|
+
{ pattern: ["false"], decision: "allow", justification: "no-op failure" },
|
|
831
|
+
{ pattern: ["test"], decision: "allow", justification: "test condition" },
|
|
832
|
+
{
|
|
833
|
+
pattern: ["find"],
|
|
834
|
+
decision: "allow",
|
|
835
|
+
justification: "search for files (read-only without -delete/-exec)",
|
|
836
|
+
unlessTokens: ["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint*", "-fls"],
|
|
837
|
+
},
|
|
838
|
+
{ pattern: ["grep"], decision: "allow", justification: "search text" },
|
|
839
|
+
{ pattern: ["rg"], decision: "allow", justification: "search text (ripgrep)" },
|
|
840
|
+
{ pattern: ["ag"], decision: "allow", justification: "search text (silver searcher)" },
|
|
841
|
+
{ pattern: ["cd"], decision: "allow", justification: "change directory (scoped to this bash invocation)" },
|
|
842
|
+
{
|
|
843
|
+
pattern: ["sed"],
|
|
844
|
+
decision: "allow",
|
|
845
|
+
justification: "stream-edit text to stdout (read-only without -i)",
|
|
846
|
+
unlessTokens: ["-i*", "--in-place*"],
|
|
847
|
+
},
|
|
848
|
+
{ pattern: ["awk"], decision: "allow", justification: "text processing to stdout" },
|
|
849
|
+
{ pattern: ["sort"], decision: "allow", justification: "sort lines" },
|
|
850
|
+
{ pattern: ["uniq"], decision: "allow", justification: "filter duplicate lines" },
|
|
851
|
+
{ pattern: ["cut"], decision: "allow", justification: "extract columns" },
|
|
852
|
+
{ pattern: ["tr"], decision: "allow", justification: "translate characters" },
|
|
853
|
+
{ pattern: ["diff"], decision: "allow", justification: "compare files" },
|
|
854
|
+
{ pattern: ["nl"], decision: "allow", justification: "number lines" },
|
|
855
|
+
{ pattern: ["jq"], decision: "allow", justification: "filter JSON to stdout" },
|
|
856
|
+
{ pattern: ["stat"], decision: "allow", justification: "show file metadata" },
|
|
857
|
+
{ pattern: ["file"], decision: "allow", justification: "identify file type" },
|
|
858
|
+
{ pattern: ["strings"], decision: "allow", justification: "extract printable strings from binary files (read-only)" },
|
|
859
|
+
{ pattern: ["basename"], decision: "allow", justification: "strip directory from path" },
|
|
860
|
+
{ pattern: ["dirname"], decision: "allow", justification: "extract directory from path" },
|
|
861
|
+
{ pattern: ["realpath"], decision: "allow", justification: "resolve a path" },
|
|
862
|
+
{ pattern: ["readlink"], decision: "allow", justification: "resolve a symlink" },
|
|
863
|
+
{ pattern: ["tree"], decision: "allow", justification: "list directory tree" },
|
|
864
|
+
{ pattern: ["du"], decision: "allow", justification: "show disk usage" },
|
|
865
|
+
{ pattern: ["df"], decision: "allow", justification: "show filesystem usage" },
|
|
866
|
+
{ pattern: ["date"], decision: "allow", justification: "show date/time" },
|
|
867
|
+
{ pattern: ["printf"], decision: "allow", justification: "print formatted text" },
|
|
868
|
+
{ pattern: ["whoami"], decision: "allow", justification: "show current user" },
|
|
869
|
+
{ pattern: ["uname"], decision: "allow", justification: "show system info" },
|
|
870
|
+
{ pattern: ["git", "status"], decision: "allow", justification: "show working tree status" },
|
|
871
|
+
{ pattern: ["git", "log"], decision: "allow", justification: "show commit log" },
|
|
872
|
+
{ pattern: ["git", "diff"], decision: "allow", justification: "show changes" },
|
|
873
|
+
{ pattern: ["git", "branch"], decision: "allow", justification: "list branches" },
|
|
874
|
+
{ pattern: ["git", "show"], decision: "allow", justification: "show a commit" },
|
|
875
|
+
{ pattern: ["git", "remote"], decision: "allow", justification: "list remotes" },
|
|
876
|
+
{ pattern: ["git", "rev-parse"], decision: "allow", justification: "resolve git refs" },
|
|
877
|
+
{ pattern: ["git", "worktree", "list"], decision: "allow", justification: "list worktrees" },
|
|
878
|
+
{ pattern: ["git", "blame"], decision: "allow", justification: "show line authorship" },
|
|
879
|
+
{ pattern: ["git", "grep"], decision: "allow", justification: "search tracked files" },
|
|
880
|
+
{ pattern: ["git", "ls-files"], decision: "allow", justification: "list tracked files" },
|
|
881
|
+
{ pattern: ["git", "describe"], decision: "allow", justification: "describe a commit" },
|
|
882
|
+
{ pattern: ["git", "shortlog"], decision: "allow", justification: "summarize commit log" },
|
|
883
|
+
{ pattern: ["git", "stash", "list"], decision: "allow", justification: "list stashes" },
|
|
884
|
+
{ pattern: ["gh", "pr", ["view", "list", "diff", "checks", "status"]], decision: "allow", justification: "read pull request data" },
|
|
885
|
+
{ pattern: ["gh", "issue", ["view", "list", "status"]], decision: "allow", justification: "read issue data" },
|
|
886
|
+
{ pattern: ["gh", "run", ["view", "list"]], decision: "allow", justification: "read workflow run data" },
|
|
887
|
+
{ pattern: ["gh", "repo", "view"], decision: "allow", justification: "read repository data" },
|
|
888
|
+
{ pattern: ["gh", "search"], decision: "allow", justification: "search GitHub" },
|
|
889
|
+
{ pattern: ["node", "--version"], decision: "allow", justification: "check node version" },
|
|
890
|
+
{ pattern: ["node", "-v"], decision: "allow", justification: "check node version" },
|
|
891
|
+
{ pattern: ["npm", "ls"], decision: "allow", justification: "list installed packages" },
|
|
892
|
+
{ pattern: ["npm", "list"], decision: "allow", justification: "list installed packages" },
|
|
893
|
+
{ pattern: ["pnpm", "ls"], decision: "allow", justification: "list installed packages" },
|
|
894
|
+
{ pattern: ["pnpm", "list"], decision: "allow", justification: "list installed packages" },
|
|
895
|
+
{ pattern: ["pnpm", "--version"], decision: "allow", justification: "check pnpm version" },
|
|
896
|
+
{ pattern: ["tsc", "--version"], decision: "allow", justification: "check typescript version" },
|
|
897
|
+
// --- prompt: potentially destructive but context-dependent ---
|
|
898
|
+
{ pattern: ["rm"], decision: "prompt", justification: "file deletion — review the target" },
|
|
899
|
+
{ pattern: ["git", "commit"], decision: "prompt", justification: "creates a commit — confirm intent" },
|
|
900
|
+
{ pattern: ["git", "push"], decision: "prompt", justification: "pushes to remote — confirm intent" },
|
|
901
|
+
{ pattern: ["git", "merge"], decision: "prompt", justification: "merges branches — may conflict" },
|
|
902
|
+
{ pattern: ["git", "rebase"], decision: "prompt", justification: "rebases — rewrites history" },
|
|
903
|
+
{ pattern: ["git", "stash"], decision: "prompt", justification: "stashes working changes" },
|
|
904
|
+
{ pattern: ["git", "checkout"], decision: "prompt", justification: "switches branch or restores files" },
|
|
905
|
+
{ pattern: ["git", "switch"], decision: "prompt", justification: "switches branch" },
|
|
906
|
+
{ pattern: ["git", "reset"], decision: "prompt", justification: "resets HEAD — may discard changes" },
|
|
907
|
+
{ pattern: ["git", "revert"], decision: "prompt", justification: "creates a revert commit" },
|
|
908
|
+
{ pattern: ["git", "cherry-pick"], decision: "prompt", justification: "applies a specific commit" },
|
|
909
|
+
{ pattern: ["npm", "install"], decision: "prompt", justification: "installs packages — modifies node_modules" },
|
|
910
|
+
{ pattern: ["npm", "ci"], decision: "prompt", justification: "clean install — modifies node_modules" },
|
|
911
|
+
{ pattern: ["pnpm", "install"], decision: "prompt", justification: "installs packages — modifies node_modules" },
|
|
912
|
+
{ pattern: ["pnpm", "add"], decision: "prompt", justification: "adds a dependency" },
|
|
913
|
+
{ pattern: ["pnpm", "remove"], decision: "prompt", justification: "removes a dependency" },
|
|
914
|
+
{ pattern: ["npm", "run"], decision: "prompt", justification: "runs a script — may have side effects" },
|
|
915
|
+
{ pattern: ["pnpm", ["run", "exec"]], decision: "prompt", justification: "runs a script or binary — may have side effects" },
|
|
916
|
+
{ pattern: ["npx"], decision: "prompt", justification: "executes a package — may have side effects" },
|
|
917
|
+
{ pattern: ["curl"], decision: "prompt", justification: "network request — review the URL" },
|
|
918
|
+
{ pattern: ["wget"], decision: "prompt", justification: "network download — review the URL" },
|
|
919
|
+
{ pattern: ["docker"], decision: "prompt", justification: "docker command — may have side effects" },
|
|
920
|
+
{ pattern: ["psql"], decision: "prompt", justification: "database command — may modify data" },
|
|
921
|
+
{ pattern: ["fly"], decision: "prompt", justification: "Fly.io command — may affect production" },
|
|
922
|
+
{ pattern: ["kubectl"], decision: "prompt", justification: "Kubernetes command — may affect production" },
|
|
923
|
+
{ pattern: ["mv"], decision: "prompt", justification: "moves files — may overwrite" },
|
|
924
|
+
{ pattern: ["cp"], decision: "prompt", justification: "copies files" },
|
|
925
|
+
{ pattern: ["mkdir"], decision: "prompt", justification: "creates directories" },
|
|
926
|
+
{ pattern: ["touch"], decision: "prompt", justification: "creates or updates file timestamps" },
|
|
927
|
+
{ pattern: ["tar"], decision: "prompt", justification: "archive operation" },
|
|
928
|
+
{ pattern: ["zip"], decision: "prompt", justification: "archive operation" },
|
|
929
|
+
{ pattern: ["unzip"], decision: "prompt", justification: "archive operation" },
|
|
930
|
+
{ pattern: ["ps"], decision: "prompt", justification: "lists processes" },
|
|
931
|
+
{ pattern: ["kill"], decision: "prompt", justification: "sends a signal to a process" },
|
|
932
|
+
{ pattern: ["chmod"], decision: "prompt", justification: "permission change — review the mode" },
|
|
933
|
+
{ pattern: ["chown"], decision: "prompt", justification: "ownership change" },
|
|
934
|
+
],
|
|
935
|
+
};
|
|
936
|
+
//# sourceMappingURL=execPolicy.js.map
|