omnirush 0.8.6 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/CHANGELOG.md +85 -0
- package/assets/extensions/omnirush/agents-lib.ts +134 -13
- package/assets/extensions/omnirush/agents.ts +51 -107
- package/assets/extensions/omnirush/bgshell-lib.ts +400 -0
- package/assets/extensions/omnirush/bgshell.ts +392 -0
- package/assets/extensions/omnirush/collector.ts +72 -11
- package/assets/extensions/omnirush/commands.ts +2 -0
- package/assets/extensions/omnirush/deliveries.ts +145 -0
- package/assets/extensions/omnirush/guard/UPSTREAM +2 -0
- package/assets/extensions/omnirush/guard/git-command-policy.ts +885 -0
- package/assets/extensions/omnirush/guard-lib.ts +230 -0
- package/assets/extensions/omnirush/guard.ts +340 -0
- package/assets/extensions/omnirush/index.ts +12 -0
- package/assets/extensions/omnirush/pi-engine.ts +65 -1
- package/assets/extensions/omnirush/sota.ts +52 -4
- package/assets/extensions/omnirush/status-lib.ts +3 -0
- package/assets/extensions/omnirush/subagents-lib.ts +623 -0
- package/assets/extensions/omnirush/subagents.ts +305 -0
- package/assets/extensions/omnirush/swarm-lib.ts +142 -0
- package/assets/extensions/omnirush/swarm.ts +95 -0
- package/assets/extensions/omnirush/voice/capture.ts +502 -0
- package/assets/extensions/omnirush/voice/core/UPSTREAM +16 -0
- package/assets/extensions/omnirush/voice/core/file-source.ts +70 -0
- package/assets/extensions/omnirush/voice/core/index.ts +21 -0
- package/assets/extensions/omnirush/voice/core/keyterms.ts +117 -0
- package/assets/extensions/omnirush/voice/core/resample.ts +63 -0
- package/assets/extensions/omnirush/voice/core/segmenter.ts +231 -0
- package/assets/extensions/omnirush/voice/core/session.ts +403 -0
- package/assets/extensions/omnirush/voice/core/text.ts +81 -0
- package/assets/extensions/omnirush/voice/core/transcriber.ts +135 -0
- package/assets/extensions/omnirush/voice/core/types.ts +102 -0
- package/assets/extensions/omnirush/voice/core/wav.ts +95 -0
- package/assets/extensions/omnirush/voice/keys.ts +435 -0
- package/assets/extensions/omnirush/voice/kitty.ts +64 -0
- package/assets/extensions/omnirush/voice/pvrecorder-worker.cjs +43 -0
- package/assets/extensions/omnirush/voice/settings.ts +67 -0
- package/assets/extensions/omnirush/voice.ts +838 -0
- package/assets/extensions/omnirush/yolo-lib.ts +80 -0
- package/assets/extensions/omnirush/yolo.ts +85 -0
- package/package.json +7 -3
- package/scripts/brand-engine.js +526 -0
- package/scripts/build-all-packages.py +29 -1
- package/scripts/smoke-packages.py +32 -1
- package/src/bin.js +205 -33
- package/src/compat.js +272 -0
- package/src/lib.js +64 -0
- package/scripts/patch-pi-branding.js +0 -251
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git and pull-request execution policy shared by the server-side permission
|
|
3
|
+
* rules (omnirush-runtime-config → legacyExecutionPermissions) and the
|
|
4
|
+
* engine-side managed-policy plugin.
|
|
5
|
+
*
|
|
6
|
+
* Three tiers, evaluated per shell segment (the part of a command line
|
|
7
|
+
* between `&&`, `||`, `;`, `|`, newlines and substitutions):
|
|
8
|
+
*
|
|
9
|
+
* read status, log, diff, fetch, gh pr list ... → runs without a prompt
|
|
10
|
+
* write commit, push, gh pr create, worktree add … → asks once per
|
|
11
|
+
* session per command family (the engine remembers "Allow for
|
|
12
|
+
* session" by command family)
|
|
13
|
+
* destructive push --force, reset --hard, clean -fd, rm -rf …
|
|
14
|
+
* → always asks
|
|
15
|
+
*
|
|
16
|
+
* The bundled engine's default ruleset allows every shell command, so the
|
|
17
|
+
* write and destructive tiers are the ones that create prompts; the read tier
|
|
18
|
+
* is spelled out so a stricter user ruleset never turns `git status` into a
|
|
19
|
+
* prompt.
|
|
20
|
+
*
|
|
21
|
+
* The engine walks every `command` node of the parsed command line (also the
|
|
22
|
+
* ones inside `if`, `while`, `!`, subshells and substitutions) and remembers
|
|
23
|
+
* a "for session" approval by command family ("git push *"), which would
|
|
24
|
+
* silently cover `git push --force` after a plain push was approved. The
|
|
25
|
+
* plugin therefore prefixes every destructive command, wherever it sits in
|
|
26
|
+
* the line, with DESTRUCTIVE_MARKER (a harmless environment assignment): the
|
|
27
|
+
* marked text no longer matches the remembered family, matches the always-ask
|
|
28
|
+
* marker rule, and still yields the plain family for the engine's own
|
|
29
|
+
* bookkeeping. Commands hidden behind an interpreter (`bash -c`, `sh -c`,
|
|
30
|
+
* `eval`, `env -S`, `xargs`, `find -exec`) are classified through the
|
|
31
|
+
* interpreter and the marker goes in front of the interpreter.
|
|
32
|
+
*
|
|
33
|
+
* Heredoc bodies, comments and quoted strings are data, not commands: they are
|
|
34
|
+
* never classified or rewritten (an unquoted heredoc still has its `$(...)`
|
|
35
|
+
* substitutions classified, because the shell runs those).
|
|
36
|
+
*
|
|
37
|
+
* Dependency-free on purpose: the plugin bundle must not pull the server or
|
|
38
|
+
* an engine SDK.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
export const DESTRUCTIVE_MARKER = "OMNIRUSH_DESTRUCTIVE=1";
|
|
42
|
+
|
|
43
|
+
export type ShellCommandKind = "read" | "write" | "destructive" | "other";
|
|
44
|
+
|
|
45
|
+
export interface ShellSegment {
|
|
46
|
+
/** Raw text of the segment, untrimmed, as it appears in the command line. */
|
|
47
|
+
text: string;
|
|
48
|
+
/** Offset of the first non-blank character of the segment in the command line. */
|
|
49
|
+
start: number;
|
|
50
|
+
/**
|
|
51
|
+
* Offset of the command proper: after reserved words such as `if`, `then`,
|
|
52
|
+
* `!` or `time` that may precede it. A marker inserted here stays part of
|
|
53
|
+
* the command the shell (and the engine's parser) sees.
|
|
54
|
+
*/
|
|
55
|
+
commandStart: number;
|
|
56
|
+
/** Tokens with quotes removed and leading reserved words dropped; leading assignments and wrappers are kept. */
|
|
57
|
+
tokens: string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Which halves of a commit identity a command supplies or writes. */
|
|
61
|
+
export interface IdentityFields {
|
|
62
|
+
name: boolean;
|
|
63
|
+
email: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ClassifiedCommand {
|
|
67
|
+
kind: ShellCommandKind;
|
|
68
|
+
/** Human-readable command family, e.g. "git commit" or "gh pr create"; null for other programs. */
|
|
69
|
+
family: string | null;
|
|
70
|
+
/** Directory git was pointed at with -C (relative to the segment's working directory), if any. */
|
|
71
|
+
gitDirectory: string | null;
|
|
72
|
+
/** The segment records or rewrites commits, so it needs an author identity. */
|
|
73
|
+
createsCommit: boolean;
|
|
74
|
+
/** Identity the command carries for its own commit: `git -c user.name=…` or a `GIT_AUTHOR_NAME=… GIT_COMMITTER_NAME=…` prefix. */
|
|
75
|
+
commitIdentity: IdentityFields;
|
|
76
|
+
/** Identity the command writes for the commands after it: `git config user.name …` or `export GIT_AUTHOR_NAME=…`. */
|
|
77
|
+
identityWrite: (IdentityFields & { scope: "repository" | "global" }) | null;
|
|
78
|
+
/** The segment of the command line the classification belongs to (the interpreter's segment for commands run through `bash -c` and friends). */
|
|
79
|
+
segment: ShellSegment;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const SEPARATORS = ["&&", "||", ";;", "|&", ";", "|", "&", "\n", "(", ")", "{", "}", "`", "$("];
|
|
83
|
+
/** Reserved words that may precede a command inside a segment without being part of it. */
|
|
84
|
+
const RESERVED_PREFIX = new Set(["if", "then", "else", "elif", "while", "until", "do", "!", "coproc", "time"]);
|
|
85
|
+
const MAX_INTERPRETER_DEPTH = 4;
|
|
86
|
+
|
|
87
|
+
interface Token {
|
|
88
|
+
text: string;
|
|
89
|
+
/** Offset of the token in the segment text. */
|
|
90
|
+
offset: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface PendingHeredoc {
|
|
94
|
+
delimiter: string;
|
|
95
|
+
/** A quoted delimiter (`<<'EOF'`) disables expansion: the body is literal text. */
|
|
96
|
+
quoted: boolean;
|
|
97
|
+
stripTabs: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Split a command line into segments at control operators, respecting quotes,
|
|
102
|
+
* comments and heredocs. `offset` shifts the reported positions when the text
|
|
103
|
+
* is a slice of a larger command line.
|
|
104
|
+
*/
|
|
105
|
+
export function splitShellCommand(command: string, offset = 0): ShellSegment[] {
|
|
106
|
+
const segments: ShellSegment[] = [];
|
|
107
|
+
const stack: Array<'"' | null> = [];
|
|
108
|
+
const heredocs: PendingHeredoc[] = [];
|
|
109
|
+
let quote: "'" | '"' | null = null;
|
|
110
|
+
let start = 0;
|
|
111
|
+
let index = 0;
|
|
112
|
+
const flush = (end: number) => {
|
|
113
|
+
const text = command.slice(start, end);
|
|
114
|
+
const tokens = tokenizeSegment(text);
|
|
115
|
+
let first = 0;
|
|
116
|
+
while (first < tokens.length && RESERVED_PREFIX.has(tokens[first].text)) {
|
|
117
|
+
if (tokens[first].text === "time" && tokens[first + 1]?.text === "-p") first += 1;
|
|
118
|
+
first += 1;
|
|
119
|
+
}
|
|
120
|
+
const rest = tokens.slice(first);
|
|
121
|
+
// A stray closing quote after a substitution tokenizes to one empty token: not a command.
|
|
122
|
+
if (rest.every((token) => token.text.length === 0)) return;
|
|
123
|
+
const leading = text.length - text.trimStart().length;
|
|
124
|
+
segments.push({ text, start: offset + start + leading, commandStart: offset + start + rest[0].offset, tokens: rest.map((token) => token.text) });
|
|
125
|
+
};
|
|
126
|
+
while (index < command.length) {
|
|
127
|
+
const char = command[index];
|
|
128
|
+
if (quote === "'") {
|
|
129
|
+
if (char === "'") quote = null;
|
|
130
|
+
index += 1;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (char === "\\" && index + 1 < command.length) {
|
|
134
|
+
index += 2;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (quote === '"') {
|
|
138
|
+
if (char === '"') quote = null;
|
|
139
|
+
else if (command.startsWith("$(", index)) {
|
|
140
|
+
stack.push('"');
|
|
141
|
+
quote = null;
|
|
142
|
+
flush(index);
|
|
143
|
+
index += 2;
|
|
144
|
+
start = index;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
index += 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (char === "'" || char === '"') {
|
|
151
|
+
quote = char;
|
|
152
|
+
index += 1;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (char === "#" && (index === 0 || /[\s;|&(){}`]/.test(command[index - 1]))) {
|
|
156
|
+
// A comment runs to the end of the line; nothing in it executes.
|
|
157
|
+
const newline = command.indexOf("\n", index);
|
|
158
|
+
index = newline < 0 ? command.length : newline;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (command.startsWith("<<", index) && command[index + 2] !== "<") {
|
|
162
|
+
const heredoc = readHeredocOperator(command, index + 2);
|
|
163
|
+
if (heredoc.delimiter) heredocs.push(heredoc);
|
|
164
|
+
index = heredoc.end;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (char === "\n" && heredocs.length) {
|
|
168
|
+
// The heredoc bodies start on the next line; they are data, except for
|
|
169
|
+
// the substitutions the shell expands in an unquoted body.
|
|
170
|
+
flush(index);
|
|
171
|
+
let cursor = index + 1;
|
|
172
|
+
for (const heredoc of heredocs.splice(0)) {
|
|
173
|
+
const body = readHeredocBody(command, cursor, heredoc);
|
|
174
|
+
if (!heredoc.quoted) {
|
|
175
|
+
for (const range of substitutionRanges(command.slice(cursor, body.end))) {
|
|
176
|
+
segments.push(...splitShellCommand(command.slice(cursor + range.start, cursor + range.end), offset + cursor + range.start));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
cursor = body.next;
|
|
180
|
+
}
|
|
181
|
+
index = cursor;
|
|
182
|
+
start = index;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (char === ")" && stack.length) {
|
|
186
|
+
flush(index);
|
|
187
|
+
quote = stack.pop() ?? null;
|
|
188
|
+
index += 1;
|
|
189
|
+
start = index;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (command.startsWith("$(", index)) {
|
|
193
|
+
stack.push(null);
|
|
194
|
+
flush(index);
|
|
195
|
+
index += 2;
|
|
196
|
+
start = index;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const separator = SEPARATORS.find((candidate) => command.startsWith(candidate, index));
|
|
200
|
+
// `2>&1`, `<&0` and `&>file` are redirections, not background operators.
|
|
201
|
+
const redirection = separator === "&" && (">" === command[index - 1] || "<" === command[index - 1] || command[index + 1] === ">");
|
|
202
|
+
// Braces group commands only as words of their own (`{ cmd; }`); `{}` in
|
|
203
|
+
// xargs and find, `${var}` and `{a,b}` are ordinary text.
|
|
204
|
+
const braceWord = (separator === "{" || separator === "}")
|
|
205
|
+
&& (index === 0 || /[\s;|&()]/.test(command[index - 1]))
|
|
206
|
+
&& (index + 1 >= command.length || /[\s;|&()]/.test(command[index + 1]));
|
|
207
|
+
if (separator && !redirection && (braceWord || (separator !== "{" && separator !== "}"))) {
|
|
208
|
+
flush(index);
|
|
209
|
+
index += separator.length;
|
|
210
|
+
start = index;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
index += 1;
|
|
214
|
+
}
|
|
215
|
+
flush(command.length);
|
|
216
|
+
return segments;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Parse the delimiter word after `<<`; returns the position after it. */
|
|
220
|
+
function readHeredocOperator(command: string, from: number): PendingHeredoc & { end: number } {
|
|
221
|
+
let index = from;
|
|
222
|
+
let stripTabs = false;
|
|
223
|
+
if (command[index] === "-") {
|
|
224
|
+
stripTabs = true;
|
|
225
|
+
index += 1;
|
|
226
|
+
}
|
|
227
|
+
while (command[index] === " " || command[index] === "\t") index += 1;
|
|
228
|
+
let delimiter = "";
|
|
229
|
+
let quoted = false;
|
|
230
|
+
while (index < command.length && !/[\s;&|<>()]/.test(command[index])) {
|
|
231
|
+
const char = command[index];
|
|
232
|
+
if (char === "'" || char === '"') {
|
|
233
|
+
const close = command.indexOf(char, index + 1);
|
|
234
|
+
const end = close < 0 ? command.length : close;
|
|
235
|
+
delimiter += command.slice(index + 1, end);
|
|
236
|
+
quoted = true;
|
|
237
|
+
index = end + 1;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (char === "\\" && index + 1 < command.length) {
|
|
241
|
+
delimiter += command[index + 1];
|
|
242
|
+
quoted = true;
|
|
243
|
+
index += 2;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
delimiter += char;
|
|
247
|
+
index += 1;
|
|
248
|
+
}
|
|
249
|
+
return { delimiter, quoted, stripTabs, end: index };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Find the end of a heredoc body that starts at `from`: the body end and the position after the terminator line. */
|
|
253
|
+
function readHeredocBody(command: string, from: number, heredoc: PendingHeredoc): { end: number; next: number } {
|
|
254
|
+
let cursor = from;
|
|
255
|
+
while (cursor <= command.length) {
|
|
256
|
+
const newline = command.indexOf("\n", cursor);
|
|
257
|
+
const lineEnd = newline < 0 ? command.length : newline;
|
|
258
|
+
const line = command.slice(cursor, lineEnd);
|
|
259
|
+
const compare = heredoc.stripTabs ? line.replace(/^\t+/, "") : line;
|
|
260
|
+
if (compare === heredoc.delimiter) return { end: cursor, next: newline < 0 ? command.length : newline + 1 };
|
|
261
|
+
if (newline < 0) break;
|
|
262
|
+
cursor = newline + 1;
|
|
263
|
+
}
|
|
264
|
+
// No terminator: the body runs to the end of the command line.
|
|
265
|
+
return { end: command.length, next: command.length };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Ranges (relative to `text`) of the `$(...)` and backtick substitutions the shell expands in unquoted heredoc bodies. */
|
|
269
|
+
function substitutionRanges(text: string): Array<{ start: number; end: number }> {
|
|
270
|
+
const ranges: Array<{ start: number; end: number }> = [];
|
|
271
|
+
let index = 0;
|
|
272
|
+
while (index < text.length) {
|
|
273
|
+
if (text[index] === "\\") {
|
|
274
|
+
index += 2;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (text.startsWith("$(", index)) {
|
|
278
|
+
let depth = 1;
|
|
279
|
+
let cursor = index + 2;
|
|
280
|
+
while (cursor < text.length && depth > 0) {
|
|
281
|
+
if (text[cursor] === "\\") {
|
|
282
|
+
cursor += 2;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (text[cursor] === "(") depth += 1;
|
|
286
|
+
else if (text[cursor] === ")") depth -= 1;
|
|
287
|
+
if (depth > 0) cursor += 1;
|
|
288
|
+
}
|
|
289
|
+
ranges.push({ start: index + 2, end: cursor });
|
|
290
|
+
index = cursor + 1;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (text[index] === "`") {
|
|
294
|
+
const close = text.indexOf("`", index + 1);
|
|
295
|
+
const end = close < 0 ? text.length : close;
|
|
296
|
+
ranges.push({ start: index + 1, end });
|
|
297
|
+
index = end + 1;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
index += 1;
|
|
301
|
+
}
|
|
302
|
+
return ranges;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function tokenizeSegment(text: string): Token[] {
|
|
306
|
+
const tokens: Token[] = [];
|
|
307
|
+
let current = "";
|
|
308
|
+
let offset = -1;
|
|
309
|
+
let quote: "'" | '"' | null = null;
|
|
310
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
311
|
+
const char = text[index];
|
|
312
|
+
if (quote === "'") {
|
|
313
|
+
if (char === "'") quote = null;
|
|
314
|
+
else current += char;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (char === "\\" && index + 1 < text.length) {
|
|
318
|
+
if (text[index + 1] === "\n" && quote === null) {
|
|
319
|
+
// Line continuation.
|
|
320
|
+
index += 1;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (offset < 0) offset = index;
|
|
324
|
+
current += text[index + 1];
|
|
325
|
+
index += 1;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (quote === '"') {
|
|
329
|
+
if (char === '"') quote = null;
|
|
330
|
+
else current += char;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (char === "'" || char === '"') {
|
|
334
|
+
quote = char;
|
|
335
|
+
if (offset < 0) offset = index;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (/\s/.test(char)) {
|
|
339
|
+
if (offset >= 0) tokens.push({ text: current, offset });
|
|
340
|
+
current = "";
|
|
341
|
+
offset = -1;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (char === "#" && offset < 0) break;
|
|
345
|
+
if (offset < 0) offset = index;
|
|
346
|
+
current += char;
|
|
347
|
+
}
|
|
348
|
+
if (offset >= 0) tokens.push({ text: current, offset });
|
|
349
|
+
return tokens;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
353
|
+
/** Programs that run another program given as their arguments. */
|
|
354
|
+
const WRAPPERS = new Set(["sudo", "doas", "command", "exec", "env", "nohup", "time", "builtin", "nice", "caffeinate", "timeout", "xargs"]);
|
|
355
|
+
const WRAPPER_VALUE_FLAGS: Record<string, ReadonlySet<string>> = {
|
|
356
|
+
sudo: new Set(["-u", "-g", "-h", "-p", "-C", "-D", "-r", "-t", "-T", "-U"]),
|
|
357
|
+
doas: new Set(["-u", "-C"]),
|
|
358
|
+
env: new Set(["-u", "--unset", "-C", "--chdir"]),
|
|
359
|
+
nice: new Set(["-n", "--adjustment"]),
|
|
360
|
+
caffeinate: new Set(["-t", "-w"]),
|
|
361
|
+
timeout: new Set(["-s", "--signal", "-k", "--kill-after"]),
|
|
362
|
+
xargs: new Set(["-I", "-n", "-P", "-L", "-s", "-d", "-E", "-a", "-J", "-R", "-S", "--max-args", "--max-procs", "--max-lines",
|
|
363
|
+
"--max-chars", "--delimiter", "--eof", "--arg-file", "--process-slot-var", "--replace"]),
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
/** Split a segment's tokens into the leading environment assignments and the program with its arguments. */
|
|
367
|
+
export function splitProgramTokens(tokens: string[]): { assignments: string[]; program: string[] } {
|
|
368
|
+
const assignments: string[] = [];
|
|
369
|
+
let list = tokens;
|
|
370
|
+
let index = 0;
|
|
371
|
+
let hops = 0;
|
|
372
|
+
while (index < list.length && hops < 16) {
|
|
373
|
+
const token = list[index];
|
|
374
|
+
if (ASSIGNMENT.test(token)) {
|
|
375
|
+
assignments.push(token);
|
|
376
|
+
index += 1;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const wrapper = programName(token);
|
|
380
|
+
if (!WRAPPERS.has(wrapper)) break;
|
|
381
|
+
hops += 1;
|
|
382
|
+
index += 1;
|
|
383
|
+
const valueFlags = WRAPPER_VALUE_FLAGS[wrapper];
|
|
384
|
+
while (index < list.length) {
|
|
385
|
+
const option = list[index];
|
|
386
|
+
if (wrapper === "env" && ASSIGNMENT.test(option)) {
|
|
387
|
+
assignments.push(option);
|
|
388
|
+
index += 1;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (option === "--") {
|
|
392
|
+
index += 1;
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
if (!option.startsWith("-")) break;
|
|
396
|
+
if (wrapper === "env" && (option === "-S" || option === "--split-string" || option.startsWith("-S") || option.startsWith("--split-string="))) {
|
|
397
|
+
// `env -S 'git push --force'`: the string is split into a command line again.
|
|
398
|
+
const inline = option === "-S" || option === "--split-string" ? null : option.startsWith("-S") ? option.slice(2) : option.slice("--split-string=".length);
|
|
399
|
+
const script = inline ?? list[index + 1] ?? "";
|
|
400
|
+
const split = tokenizeSegment(script).map((entry) => entry.text);
|
|
401
|
+
list = [...list.slice(0, index), ...split, ...list.slice(index + (inline === null ? 2 : 1))];
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (valueFlags?.has(option)) {
|
|
405
|
+
index += 2;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (wrapper === "xargs" && /^-[InPLsdEaJRS]./.test(option)) {
|
|
409
|
+
// Attached value, e.g. `-I{}` or `-n1`.
|
|
410
|
+
index += 1;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
index += 1;
|
|
414
|
+
}
|
|
415
|
+
// `timeout [options] DURATION command …`
|
|
416
|
+
if (wrapper === "timeout" && index < list.length) index += 1;
|
|
417
|
+
}
|
|
418
|
+
return { assignments, program: list.slice(index) };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Drop leading environment assignments and privilege/exec wrappers. */
|
|
422
|
+
export function programTokens(tokens: string[]): string[] {
|
|
423
|
+
return splitProgramTokens(tokens).program;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function programName(token: string | undefined): string {
|
|
427
|
+
if (!token) return "";
|
|
428
|
+
const base = token.replaceAll("\\", "/").split("/").pop() ?? "";
|
|
429
|
+
return base.toLowerCase().replace(/\.exe$/, "");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function shortFlagHas(token: string, letter: string): boolean {
|
|
433
|
+
return /^-[A-Za-z0-9]+$/.test(token) && token.slice(1).includes(letter);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function hasFlag(args: string[], ...flags: string[]): boolean {
|
|
437
|
+
return args.some((arg) => flags.some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const GIT_OPTIONS_WITH_VALUE = new Set([
|
|
441
|
+
"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--config-env", "--super-prefix",
|
|
442
|
+
"--list-cmds", "--attr-source",
|
|
443
|
+
]);
|
|
444
|
+
|
|
445
|
+
function joinGitDirectory(previous: string | null, next: string): string {
|
|
446
|
+
if (!previous || next.startsWith("/") || /^[A-Za-z]:[\\/]/.test(next)) return next;
|
|
447
|
+
return `${previous.replace(/\/+$/, "")}/${next}`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const GIT_READ = new Set([
|
|
451
|
+
"status", "log", "diff", "show", "fetch", "ls-files", "ls-tree", "ls-remote", "cat-file", "rev-parse", "rev-list",
|
|
452
|
+
"describe", "blame", "annotate", "shortlog", "grep", "count-objects", "check-ignore", "check-attr", "check-ref-format",
|
|
453
|
+
"check-mailmap", "name-rev", "merge-base", "merge-tree", "for-each-ref", "var", "version", "help", "whatchanged",
|
|
454
|
+
"show-ref", "show-branch", "verify-commit", "verify-tag", "diff-tree", "diff-index", "diff-files", "cherry",
|
|
455
|
+
"range-diff", "format-patch", "patch-id", "stripspace", "interpret-trailers", "fsck", "get-tar-commit-id",
|
|
456
|
+
]);
|
|
457
|
+
const GIT_COMMIT_STOP_FLAGS = ["--abort", "--quit", "--skip"];
|
|
458
|
+
const BRANCH_READ_FLAGS = new Set([
|
|
459
|
+
"--list", "-l", "-a", "--all", "-r", "--remotes", "-v", "-vv", "-vvv", "--verbose", "--show-current", "--merged",
|
|
460
|
+
"--no-merged", "--contains", "--no-contains", "--points-at", "--sort", "--format", "--color", "--no-color", "--column",
|
|
461
|
+
"--no-column", "-i", "--ignore-case", "--abbrev", "--no-abbrev",
|
|
462
|
+
]);
|
|
463
|
+
const BRANCH_PATTERN_FLAGS = ["--list", "-l", "--contains", "--no-contains", "--merged", "--no-merged", "--points-at"];
|
|
464
|
+
const CONFIG_READ_FLAGS = [
|
|
465
|
+
"-l", "--list", "--get", "--get-all", "--get-regexp", "--get-urlmatch", "--get-color", "--get-colorbool", "--show-origin",
|
|
466
|
+
"--show-scope",
|
|
467
|
+
];
|
|
468
|
+
const CONFIG_WRITE_FLAGS = ["--add", "--unset", "--unset-all", "--replace-all", "--rename-section", "--remove-section", "--edit", "-e"];
|
|
469
|
+
const CONFIG_VALUE_FLAGS = new Set(["-f", "--file", "--blob", "--type", "--default"]);
|
|
470
|
+
const CONFIG_READ_SUBCOMMANDS = new Set(["get", "list"]);
|
|
471
|
+
|
|
472
|
+
type GitVerdict = { kind: ShellCommandKind; createsCommit?: boolean; identityWrite?: ClassifiedCommand["identityWrite"] };
|
|
473
|
+
|
|
474
|
+
function classifyGitConfig(args: string[]): GitVerdict {
|
|
475
|
+
const positional: string[] = [];
|
|
476
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
477
|
+
const arg = args[index];
|
|
478
|
+
if (CONFIG_VALUE_FLAGS.has(arg)) {
|
|
479
|
+
index += 1;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (!arg.startsWith("-")) positional.push(arg);
|
|
483
|
+
}
|
|
484
|
+
if (CONFIG_WRITE_FLAGS.some((flag) => hasFlag(args, flag))) return { kind: "write" };
|
|
485
|
+
if (CONFIG_READ_FLAGS.some((flag) => hasFlag(args, flag))) return { kind: "read" };
|
|
486
|
+
const subcommand = positional[0] && CONFIG_READ_SUBCOMMANDS.has(positional[0]) ? positional[0] : positional[0] === "set" ? "set" : null;
|
|
487
|
+
if (subcommand && subcommand !== "set") return { kind: "read" };
|
|
488
|
+
const [key, value] = subcommand === "set" ? positional.slice(1, 3) : positional.slice(0, 2);
|
|
489
|
+
if (value === undefined) return { kind: "read" };
|
|
490
|
+
const scope = hasFlag(args, "--global", "--system", "-f", "--file", "--blob") ? "global" : "repository";
|
|
491
|
+
const identityWrite = key === "user.name" || key === "user.email"
|
|
492
|
+
? { name: key === "user.name", email: key === "user.email", scope: scope as "repository" | "global" }
|
|
493
|
+
: undefined;
|
|
494
|
+
return { kind: "write", identityWrite };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function classifyGit(sub: string | undefined, args: string[]): GitVerdict {
|
|
498
|
+
if (!sub || sub === "--version" || sub === "--help" || sub === "-h") return { kind: "read" };
|
|
499
|
+
const positional = args.filter((arg) => !arg.startsWith("-"));
|
|
500
|
+
switch (sub) {
|
|
501
|
+
case "push": {
|
|
502
|
+
const refspecDelete = args.some((arg) => /^\+/.test(arg) || /^:./.test(arg));
|
|
503
|
+
const forced = args.some((arg) => arg === "--force" || arg.startsWith("--force-with-lease") || arg === "--force-if-includes"
|
|
504
|
+
|| arg === "--delete" || arg === "--mirror" || arg === "--prune" || shortFlagHas(arg, "f") || shortFlagHas(arg, "d"));
|
|
505
|
+
return { kind: forced || refspecDelete ? "destructive" : "write" };
|
|
506
|
+
}
|
|
507
|
+
case "reset":
|
|
508
|
+
return { kind: hasFlag(args, "--hard") ? "destructive" : "write" };
|
|
509
|
+
case "clean": {
|
|
510
|
+
if (hasFlag(args, "-n", "--dry-run") || args.some((arg) => shortFlagHas(arg, "n"))) return { kind: "read" };
|
|
511
|
+
const forced = hasFlag(args, "--force") || args.some((arg) => shortFlagHas(arg, "f"));
|
|
512
|
+
return { kind: forced ? "destructive" : "write" };
|
|
513
|
+
}
|
|
514
|
+
case "branch": {
|
|
515
|
+
if (args.some((arg) => arg === "-D" || shortFlagHas(arg, "D"))) return { kind: "destructive" };
|
|
516
|
+
if ((hasFlag(args, "-d", "--delete") || args.some((arg) => shortFlagHas(arg, "d")))
|
|
517
|
+
&& (hasFlag(args, "-f", "--force") || args.some((arg) => shortFlagHas(arg, "f")))) return { kind: "destructive" };
|
|
518
|
+
if (args.length === 0) return { kind: "read" };
|
|
519
|
+
const flags = args.filter((arg) => arg.startsWith("-"));
|
|
520
|
+
const readFlags = flags.every((flag) => BRANCH_READ_FLAGS.has(flag.includes("=") ? flag.slice(0, flag.indexOf("=")) : flag)
|
|
521
|
+
|| (/^-[alrvi]+$/.test(flag)));
|
|
522
|
+
if (!readFlags) return { kind: "write" };
|
|
523
|
+
if (positional.length === 0) return { kind: "read" };
|
|
524
|
+
return { kind: BRANCH_PATTERN_FLAGS.some((flag) => hasFlag(args, flag)) ? "read" : "write" };
|
|
525
|
+
}
|
|
526
|
+
case "remote": {
|
|
527
|
+
const verb = args[0];
|
|
528
|
+
if (!verb || verb === "-v" || verb === "--verbose" || verb === "show" || verb === "get-url") return { kind: "read" };
|
|
529
|
+
return { kind: "write" };
|
|
530
|
+
}
|
|
531
|
+
case "worktree": {
|
|
532
|
+
const verb = args[0];
|
|
533
|
+
if (!verb || verb === "list") return { kind: "read" };
|
|
534
|
+
if (verb === "remove" && (hasFlag(args, "--force", "-f") || args.some((arg) => shortFlagHas(arg, "f")))) return { kind: "destructive" };
|
|
535
|
+
return { kind: "write" };
|
|
536
|
+
}
|
|
537
|
+
case "tag": {
|
|
538
|
+
const annotated = hasFlag(args, "-a", "--annotate", "-s", "--sign", "-m", "--message", "-F", "--file", "-u", "--local-user")
|
|
539
|
+
|| args.some((arg) => /^-[asuF]/.test(arg) || /^-m./.test(arg));
|
|
540
|
+
if (hasFlag(args, "-d", "--delete") || args.some((arg) => shortFlagHas(arg, "d"))) return { kind: "write" };
|
|
541
|
+
if (args.length === 0 || (!annotated && (hasFlag(args, "-l", "--list", "--contains", "--no-contains", "--points-at", "--merged", "--no-merged")
|
|
542
|
+
|| args.some((arg) => /^-n\d*$/.test(arg) || shortFlagHas(arg, "l"))))) return { kind: "read" };
|
|
543
|
+
return { kind: "write", createsCommit: annotated };
|
|
544
|
+
}
|
|
545
|
+
case "stash": {
|
|
546
|
+
const verb = args[0];
|
|
547
|
+
if (verb === "list" || verb === "show") return { kind: "read" };
|
|
548
|
+
if (verb === "drop" || verb === "clear") return { kind: "destructive" };
|
|
549
|
+
const records = !verb || verb.startsWith("-") || verb === "push" || verb === "save" || verb === "create";
|
|
550
|
+
return { kind: "write", createsCommit: records };
|
|
551
|
+
}
|
|
552
|
+
case "config":
|
|
553
|
+
return classifyGitConfig(args);
|
|
554
|
+
case "reflog": {
|
|
555
|
+
const verb = args[0];
|
|
556
|
+
if (!verb || verb === "show" || verb.startsWith("-")) return { kind: "read" };
|
|
557
|
+
return { kind: verb === "expire" || verb === "delete" ? "destructive" : "write" };
|
|
558
|
+
}
|
|
559
|
+
case "notes": {
|
|
560
|
+
const verb = args[0];
|
|
561
|
+
if (!verb || verb === "list" || verb === "show" || verb === "get-ref") return { kind: "read" };
|
|
562
|
+
return { kind: "write", createsCommit: true };
|
|
563
|
+
}
|
|
564
|
+
case "submodule": {
|
|
565
|
+
const verb = args[0];
|
|
566
|
+
return { kind: !verb || verb === "status" || verb === "summary" ? "read" : "write" };
|
|
567
|
+
}
|
|
568
|
+
case "symbolic-ref":
|
|
569
|
+
return { kind: positional.length <= 1 ? "read" : "write" };
|
|
570
|
+
case "sparse-checkout":
|
|
571
|
+
return { kind: args[0] === "list" ? "read" : "write" };
|
|
572
|
+
case "lfs": {
|
|
573
|
+
const verb = args[0];
|
|
574
|
+
return { kind: ["ls-files", "status", "env", "version", "logs"].includes(verb ?? "") ? "read" : "write" };
|
|
575
|
+
}
|
|
576
|
+
case "update-ref":
|
|
577
|
+
return { kind: hasFlag(args, "-d") ? "destructive" : "write" };
|
|
578
|
+
case "hash-object":
|
|
579
|
+
return { kind: hasFlag(args, "-w") ? "write" : "read" };
|
|
580
|
+
case "rebase": {
|
|
581
|
+
if (hasFlag(args, "-i", "--interactive") || args.some((arg) => shortFlagHas(arg, "i"))) return { kind: "destructive" };
|
|
582
|
+
return { kind: "write", createsCommit: !GIT_COMMIT_STOP_FLAGS.some((flag) => hasFlag(args, flag)) };
|
|
583
|
+
}
|
|
584
|
+
case "filter-branch":
|
|
585
|
+
case "filter-repo":
|
|
586
|
+
return { kind: "destructive" };
|
|
587
|
+
case "commit":
|
|
588
|
+
case "commit-tree":
|
|
589
|
+
return { kind: "write", createsCommit: true };
|
|
590
|
+
case "merge":
|
|
591
|
+
case "cherry-pick":
|
|
592
|
+
case "revert":
|
|
593
|
+
case "am":
|
|
594
|
+
return { kind: "write", createsCommit: !GIT_COMMIT_STOP_FLAGS.some((flag) => hasFlag(args, flag)) };
|
|
595
|
+
default:
|
|
596
|
+
return { kind: GIT_READ.has(sub) ? "read" : "write" };
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function classifyGitTokens(tokens: string[]): { verdict: GitVerdict; family: string; gitDirectory: string | null; inline: IdentityFields } {
|
|
601
|
+
let index = 1;
|
|
602
|
+
let gitDirectory: string | null = null;
|
|
603
|
+
const inline: IdentityFields = { name: false, email: false };
|
|
604
|
+
while (index < tokens.length) {
|
|
605
|
+
const token = tokens[index];
|
|
606
|
+
if (!token.startsWith("-") || token === "--") break;
|
|
607
|
+
const equals = token.indexOf("=");
|
|
608
|
+
const name = equals > 0 ? token.slice(0, equals) : token;
|
|
609
|
+
const inlineValue = equals > 0 ? token.slice(equals + 1) : undefined;
|
|
610
|
+
if (GIT_OPTIONS_WITH_VALUE.has(name)) {
|
|
611
|
+
const value = inlineValue ?? tokens[index + 1] ?? "";
|
|
612
|
+
if (name === "-C") gitDirectory = joinGitDirectory(gitDirectory, value);
|
|
613
|
+
if (name === "-c") {
|
|
614
|
+
if (/^user\.name=./.test(value)) inline.name = true;
|
|
615
|
+
if (/^user\.email=./.test(value)) inline.email = true;
|
|
616
|
+
}
|
|
617
|
+
index += inlineValue === undefined ? 2 : 1;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (/^-C.+/.test(token)) {
|
|
621
|
+
gitDirectory = joinGitDirectory(gitDirectory, token.slice(2));
|
|
622
|
+
index += 1;
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
index += 1;
|
|
626
|
+
}
|
|
627
|
+
const sub = tokens[index];
|
|
628
|
+
const args = tokens.slice(index + 1);
|
|
629
|
+
const verdict = classifyGit(sub, args);
|
|
630
|
+
const family = sub && !sub.startsWith("-") ? `git ${sub}` : "git";
|
|
631
|
+
return { verdict, family, gitDirectory, inline };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const GH_GLOBAL_VALUE_FLAGS = new Set(["-R", "--repo"]);
|
|
635
|
+
const GH_READ_VERBS = new Set(["list", "view", "status", "checks", "diff", "watch"]);
|
|
636
|
+
|
|
637
|
+
function classifyGhTokens(tokens: string[]): { kind: ShellCommandKind; family: string } {
|
|
638
|
+
let index = 1;
|
|
639
|
+
while (index < tokens.length && tokens[index].startsWith("-")) {
|
|
640
|
+
const token = tokens[index];
|
|
641
|
+
if (token === "--version" || token === "--help") return { kind: "read", family: "gh" };
|
|
642
|
+
index += GH_GLOBAL_VALUE_FLAGS.has(token) ? 2 : 1;
|
|
643
|
+
}
|
|
644
|
+
const command = tokens[index];
|
|
645
|
+
const verb = tokens[index + 1];
|
|
646
|
+
const rest = tokens.slice(index + 1);
|
|
647
|
+
const family = command
|
|
648
|
+
? (verb && !verb.startsWith("-") && command !== "api" ? `gh ${command} ${verb}` : `gh ${command}`)
|
|
649
|
+
: "gh";
|
|
650
|
+
if (!command || ["version", "help", "status", "search"].includes(command)) return { kind: "read", family };
|
|
651
|
+
if (command === "auth") return { kind: verb === "status" ? "read" : "write", family };
|
|
652
|
+
if (command === "api") {
|
|
653
|
+
const method = rest.find((arg, position) => position > 0 && (rest[position - 1] === "-X" || rest[position - 1] === "--method"))
|
|
654
|
+
?? rest.find((arg) => arg.startsWith("--method="))?.slice("--method=".length);
|
|
655
|
+
const mutating = (method && method.toUpperCase() !== "GET") || rest.some((arg) =>
|
|
656
|
+
["-f", "-F", "--field", "--raw-field", "--input"].includes(arg) || /^--(?:raw-)?field=/.test(arg) || /^-[fF]./.test(arg));
|
|
657
|
+
return { kind: mutating ? "write" : "read", family };
|
|
658
|
+
}
|
|
659
|
+
if (command === "repo" && verb === "delete") return { kind: "destructive", family };
|
|
660
|
+
if (command === "config") return { kind: verb === "get" || verb === "list" ? "read" : "write", family };
|
|
661
|
+
if (["alias", "extension", "ssh-key", "gpg-key", "cache", "label", "secret", "variable", "ruleset", "attestation"].includes(command)) {
|
|
662
|
+
return { kind: verb === "list" ? "read" : "write", family };
|
|
663
|
+
}
|
|
664
|
+
if (verb && GH_READ_VERBS.has(verb)) return { kind: "read", family };
|
|
665
|
+
return { kind: "write", family };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function classifyRm(tokens: string[]): ShellCommandKind {
|
|
669
|
+
const flags = tokens.slice(1).filter((token) => token.startsWith("-"));
|
|
670
|
+
const recursive = flags.some((flag) => flag === "--recursive" || shortFlagHas(flag, "r") || shortFlagHas(flag, "R"));
|
|
671
|
+
const forced = flags.some((flag) => flag === "--force" || shortFlagHas(flag, "f"));
|
|
672
|
+
return recursive && forced ? "destructive" : "other";
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh", "mksh", "ash", "fish"]);
|
|
676
|
+
const FIND_EXEC_FLAGS = new Set(["-exec", "-execdir", "-ok", "-okdir"]);
|
|
677
|
+
|
|
678
|
+
/** The command line an interpreter segment runs, when it is spelled out in the arguments. */
|
|
679
|
+
function interpreterScript(program: string, tokens: string[]): { script: string } | { tokens: string[] } | null {
|
|
680
|
+
if (SHELLS.has(program)) {
|
|
681
|
+
let sawCommandFlag = false;
|
|
682
|
+
let index = 1;
|
|
683
|
+
while (index < tokens.length) {
|
|
684
|
+
const token = tokens[index];
|
|
685
|
+
if (token === "--") {
|
|
686
|
+
index += 1;
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
if (token.startsWith("--command=")) return { script: token.slice("--command=".length) };
|
|
690
|
+
if (token === "-o" || token === "+o" || token === "-O" || token === "+O") {
|
|
691
|
+
index += 2;
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
if (!token.startsWith("-") && !token.startsWith("+")) break;
|
|
695
|
+
if (/^-[A-Za-z]*c[A-Za-z]*$/.test(token) || token === "--command") sawCommandFlag = true;
|
|
696
|
+
index += 1;
|
|
697
|
+
}
|
|
698
|
+
return sawCommandFlag && tokens[index] !== undefined ? { script: tokens[index] } : null;
|
|
699
|
+
}
|
|
700
|
+
if (program === "eval") {
|
|
701
|
+
const script = tokens.slice(1).join(" ");
|
|
702
|
+
return script ? { script } : null;
|
|
703
|
+
}
|
|
704
|
+
if (program === "su") {
|
|
705
|
+
const flag = tokens.findIndex((token, position) => position > 0 && (token === "-c" || token === "--command"));
|
|
706
|
+
if (flag > 0 && tokens[flag + 1] !== undefined) return { script: tokens[flag + 1] };
|
|
707
|
+
const inline = tokens.find((token, position) => position > 0 && token.startsWith("--command="));
|
|
708
|
+
return inline ? { script: inline.slice("--command=".length) } : null;
|
|
709
|
+
}
|
|
710
|
+
if (program === "find") {
|
|
711
|
+
const flag = tokens.findIndex((token, position) => position > 0 && FIND_EXEC_FLAGS.has(token));
|
|
712
|
+
if (flag < 0) return null;
|
|
713
|
+
const end = tokens.findIndex((token, position) => position > flag && (token === ";" || token === "+"));
|
|
714
|
+
const inner = tokens.slice(flag + 1, end < 0 ? tokens.length : end);
|
|
715
|
+
return inner.length ? { tokens: inner } : null;
|
|
716
|
+
}
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function identityFromAssignments(assignments: string[]): IdentityFields {
|
|
721
|
+
const names = new Set(assignments.filter((entry) => /^[A-Z_]+=./.test(entry)).map((entry) => entry.slice(0, entry.indexOf("="))));
|
|
722
|
+
return {
|
|
723
|
+
name: names.has("GIT_AUTHOR_NAME") && names.has("GIT_COMMITTER_NAME"),
|
|
724
|
+
email: names.has("GIT_AUTHOR_EMAIL") && names.has("GIT_COMMITTER_EMAIL"),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function classifyTokens(segmentTokens: string[], segment: ShellSegment, depth: number): ClassifiedCommand[] {
|
|
729
|
+
const { assignments, program: tokens } = splitProgramTokens(segmentTokens);
|
|
730
|
+
const program = programName(tokens[0]);
|
|
731
|
+
const commitIdentity = identityFromAssignments(assignments);
|
|
732
|
+
const other = (): ClassifiedCommand[] => [{
|
|
733
|
+
kind: "other", family: null, gitDirectory: null, createsCommit: false, commitIdentity, identityWrite: null, segment,
|
|
734
|
+
}];
|
|
735
|
+
if (program === "git") {
|
|
736
|
+
const { verdict, family, gitDirectory, inline } = classifyGitTokens(tokens);
|
|
737
|
+
return [{
|
|
738
|
+
kind: verdict.kind,
|
|
739
|
+
family,
|
|
740
|
+
gitDirectory,
|
|
741
|
+
createsCommit: verdict.createsCommit === true,
|
|
742
|
+
commitIdentity: { name: commitIdentity.name || inline.name, email: commitIdentity.email || inline.email },
|
|
743
|
+
identityWrite: verdict.identityWrite ?? null,
|
|
744
|
+
segment,
|
|
745
|
+
}];
|
|
746
|
+
}
|
|
747
|
+
if (program === "gh") {
|
|
748
|
+
const { kind, family } = classifyGhTokens(tokens);
|
|
749
|
+
return [{ kind, family, gitDirectory: null, createsCommit: false, commitIdentity, identityWrite: null, segment }];
|
|
750
|
+
}
|
|
751
|
+
if (program === "rm") {
|
|
752
|
+
const kind = classifyRm(tokens);
|
|
753
|
+
return [{ kind, family: kind === "destructive" ? "rm -rf" : "rm", gitDirectory: null, createsCommit: false, commitIdentity, identityWrite: null, segment }];
|
|
754
|
+
}
|
|
755
|
+
if (program === "export" || program === "declare" || program === "typeset") {
|
|
756
|
+
const exported = identityFromAssignments(tokens.slice(1).filter((token) => ASSIGNMENT.test(token)));
|
|
757
|
+
const identityWrite = exported.name || exported.email ? { ...exported, scope: "global" as const } : null;
|
|
758
|
+
return [{ kind: "other", family: null, gitDirectory: null, createsCommit: false, commitIdentity, identityWrite, segment }];
|
|
759
|
+
}
|
|
760
|
+
if (depth < MAX_INTERPRETER_DEPTH) {
|
|
761
|
+
const inner = interpreterScript(program, tokens);
|
|
762
|
+
if (inner) {
|
|
763
|
+
const nested = "script" in inner
|
|
764
|
+
? splitShellCommand(inner.script).flatMap((entry) => classifyTokens(entry.tokens, segment, depth + 1))
|
|
765
|
+
: classifyTokens(inner.tokens, segment, depth + 1);
|
|
766
|
+
const classified = nested.filter((entry) => entry.kind !== "other");
|
|
767
|
+
if (classified.length === 0) return other();
|
|
768
|
+
// A `GIT_*` prefix on the interpreter reaches the inner git the same way.
|
|
769
|
+
return classified.map((entry) => ({
|
|
770
|
+
...entry,
|
|
771
|
+
commitIdentity: { name: entry.commitIdentity.name || commitIdentity.name, email: entry.commitIdentity.email || commitIdentity.email },
|
|
772
|
+
}));
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return other();
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Classify one segment. Interpreter segments (`bash -c '…'`, `eval …`,
|
|
780
|
+
* `find … -exec …`) yield the classification of the commands they run, each
|
|
781
|
+
* attached to the interpreter's segment.
|
|
782
|
+
*/
|
|
783
|
+
export function classifySegment(segment: ShellSegment): ClassifiedCommand[] {
|
|
784
|
+
return classifyTokens(segment.tokens, segment, 0);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** Classify every segment of a command line. */
|
|
788
|
+
export function classifyShellCommand(command: string): ClassifiedCommand[] {
|
|
789
|
+
return splitShellCommand(command).flatMap(classifySegment);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** Remove any marker the model wrote itself so the plugin stays the only author of it. */
|
|
793
|
+
export function stripDestructiveMarkers(command: string): string {
|
|
794
|
+
return command.replace(new RegExp(`(?:^|(?<=\\s))${DESTRUCTIVE_MARKER}\\s+`, "g"), "");
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Prefix every destructive command with the marker. The engine parses the
|
|
799
|
+
* assignment as part of the command, so the permission pattern starts with
|
|
800
|
+
* the marker while the command family ("git push *") stays unchanged. The
|
|
801
|
+
* marker goes after reserved words (`if`, `!`, `time`) and in front of an
|
|
802
|
+
* interpreter that runs the destructive command (`bash -c`, `sudo`).
|
|
803
|
+
*/
|
|
804
|
+
export function markDestructiveCommands(command: string): { command: string; destructive: ClassifiedCommand[] } {
|
|
805
|
+
const clean = stripDestructiveMarkers(command);
|
|
806
|
+
const classified = classifyShellCommand(clean);
|
|
807
|
+
const destructive = classified.filter((entry) => entry.kind === "destructive");
|
|
808
|
+
if (destructive.length === 0) return { command: clean, destructive };
|
|
809
|
+
const offsets = [...new Set(destructive.map((entry) => entry.segment.commandStart))].sort((left, right) => right - left);
|
|
810
|
+
let result = clean;
|
|
811
|
+
for (const offset of offsets) {
|
|
812
|
+
result = `${result.slice(0, offset)}${DESTRUCTIVE_MARKER} ${result.slice(offset)}`;
|
|
813
|
+
}
|
|
814
|
+
return { command: result, destructive };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Read-only command shapes the engine may run without asking (pattern syntax: `*` any run, trailing ` *` optional). */
|
|
818
|
+
const READ_ONLY_GIT_PATTERNS = [
|
|
819
|
+
"git status *", "git log *", "git diff *", "git show *", "git fetch *", "git remote", "git remote -v", "git remote --verbose",
|
|
820
|
+
"git remote show *", "git remote get-url *", "git worktree list *", "git branch", "git branch --list *", "git branch -l *",
|
|
821
|
+
"git branch -a", "git branch --all", "git branch -r", "git branch --remotes", "git branch -v", "git branch -vv",
|
|
822
|
+
"git branch --verbose", "git branch -av", "git branch -avv", "git branch -ra", "git branch -rv", "git branch --show-current",
|
|
823
|
+
"git branch --merged *", "git branch --no-merged *", "git branch --contains *", "git branch --points-at *", "git tag",
|
|
824
|
+
"git tag -l *", "git tag --list *", "git tag -n*", "git tag --contains *", "git tag --points-at *", "git stash list *",
|
|
825
|
+
"git stash show *", "git config --get *", "git config --get-all *", "git config --get-regexp *", "git config --list *",
|
|
826
|
+
"git config -l *", "git config --show-origin *", "git config user.name", "git config user.email", "git config --local user.name",
|
|
827
|
+
"git config --local user.email", "git rev-parse *", "git rev-list *", "git ls-files *", "git ls-tree *", "git ls-remote *",
|
|
828
|
+
"git cat-file *", "git describe *", "git blame *", "git shortlog *", "git grep *", "git count-objects *", "git check-ignore *",
|
|
829
|
+
"git name-rev *", "git merge-base *", "git for-each-ref *", "git reflog", "git reflog show *", "git show-ref *",
|
|
830
|
+
"git version", "git --version", "git help *", "git --help", "git diff-tree *", "git whatchanged *", "git cherry *",
|
|
831
|
+
"git range-diff *", "git var *", "git submodule status *", "git submodule summary *", "git lfs ls-files *", "git lfs status *",
|
|
832
|
+
"git lfs env", "git notes list *", "git notes show *", "git sparse-checkout list", "git clean -n *", "git clean --dry-run *",
|
|
833
|
+
];
|
|
834
|
+
const READ_ONLY_GH_PATTERNS = [
|
|
835
|
+
"gh pr list *", "gh pr view *", "gh pr status *", "gh pr checks *", "gh pr diff *", "gh issue list *", "gh issue view *",
|
|
836
|
+
"gh issue status *", "gh repo view *", "gh repo list *", "gh run list *", "gh run view *", "gh run watch *",
|
|
837
|
+
"gh workflow list *", "gh workflow view *", "gh release list *", "gh release view *", "gh auth status *", "gh search *",
|
|
838
|
+
"gh label list *", "gh gist list *", "gh gist view *", "gh config get *", "gh config list *", "gh alias list *",
|
|
839
|
+
"gh extension list *", "gh ssh-key list *", "gh cache list *", "gh status *", "gh version", "gh --version", "gh help *",
|
|
840
|
+
"gh --help",
|
|
841
|
+
];
|
|
842
|
+
/**
|
|
843
|
+
* Interpreter wrappers hide the real command line from the engine's
|
|
844
|
+
* token-based patterns (`bash -c 'git push'` is a `bash` command to it), so
|
|
845
|
+
* they ask once per session like a write command. Destructive commands inside
|
|
846
|
+
* them are still marked and always ask.
|
|
847
|
+
*/
|
|
848
|
+
const SHELL_PATH_PREFIXES = ["", "/bin/", "/usr/bin/", "/usr/local/bin/", "/opt/homebrew/bin/"];
|
|
849
|
+
const INTERPRETER_PATTERNS = [
|
|
850
|
+
...SHELL_PATH_PREFIXES.flatMap((prefix) => ["bash", "sh", "zsh", "dash", "ksh"].map((shell) => `${prefix}${shell} -*c *`)),
|
|
851
|
+
"eval *", "env -S *", "env --split-string*", "su *",
|
|
852
|
+
];
|
|
853
|
+
/** Destructive shapes spelled out for engines running without the plugin. */
|
|
854
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
855
|
+
"git push --force*", "git push -f*", "git push * --force*", "git push * -f*", "git push --delete *", "git push -d *",
|
|
856
|
+
"git push * --delete *", "git push * :*", "git push --mirror*", "git push * --mirror*", "git push --prune*", "git push * --prune*",
|
|
857
|
+
"git reset --hard*", "git reset * --hard*", "git clean -*f*", "git clean --force*", "git clean * --force*",
|
|
858
|
+
"git branch -D *", "git branch * -D*", "git branch --delete --force *", "git branch --force --delete *",
|
|
859
|
+
"git rebase -i*", "git rebase --interactive*", "git rebase * -i*", "git rebase * --interactive*", "git filter-branch*",
|
|
860
|
+
"git filter-repo*", "git stash drop*", "git stash clear*", "git reflog expire*", "git reflog delete*",
|
|
861
|
+
"git worktree remove --force*", "git worktree remove -f *", "git worktree remove * --force*", "git worktree remove * -f*",
|
|
862
|
+
"git update-ref -d *", "gh repo delete *", "rm -rf *", "rm -fr *", "rm -Rf *", "rm -fR *", "rm -r -f *", "rm -f -r *",
|
|
863
|
+
"rm -R -f *", "rm -f -R *", "rm --recursive --force *", "rm --force --recursive *", "rm -r --force *", "rm -f --recursive *",
|
|
864
|
+
];
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Engine `permission.bash` rules for git, gh and shell deletions, in
|
|
868
|
+
* evaluation order (the engine picks the last matching rule): every git and
|
|
869
|
+
* gh command asks, the read-only shapes are allowed again, interpreter
|
|
870
|
+
* wrappers ask, destructive shapes and the marker ask.
|
|
871
|
+
*/
|
|
872
|
+
export function gitWorkflowPermissionRules(): Record<string, "allow" | "ask"> {
|
|
873
|
+
const rules: Record<string, "allow" | "ask"> = { "git *": "ask", "gh *": "ask", "sudo *": "ask" };
|
|
874
|
+
for (const pattern of READ_ONLY_GIT_PATTERNS) {
|
|
875
|
+
rules[pattern] = "allow";
|
|
876
|
+
const rest = pattern.slice("git ".length);
|
|
877
|
+
rules[`git --no-pager ${rest}`] = "allow";
|
|
878
|
+
rules[`git -P ${rest}`] = "allow";
|
|
879
|
+
}
|
|
880
|
+
for (const pattern of READ_ONLY_GH_PATTERNS) rules[pattern] = "allow";
|
|
881
|
+
for (const pattern of INTERPRETER_PATTERNS) rules[pattern] = "ask";
|
|
882
|
+
for (const pattern of DESTRUCTIVE_PATTERNS) rules[pattern] = "ask";
|
|
883
|
+
rules[`${DESTRUCTIVE_MARKER} *`] = "ask";
|
|
884
|
+
return rules;
|
|
885
|
+
}
|