@yagni-app/code-staging 0.3.0-staging.1067.1 → 0.3.0-staging.1071.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/extension/execPolicy.d.ts +73 -0
- package/dist/extension/execPolicy.js +399 -0
- package/dist/extension/guardian.d.ts +107 -0
- package/dist/extension/guardian.js +175 -0
- package/dist/extension/index.d.ts +5 -1
- package/dist/extension/index.js +27 -2
- package/dist/extension/permission.d.ts +48 -6
- package/dist/extension/permission.js +233 -24
- package/dist/extension/pipeline/personas.js +22 -0
- package/dist/promptEnrichment.d.ts +1 -1
- package/dist/promptEnrichment.js +17 -0
- package/package.json +2 -2
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exec policy engine — classifies bash commands via prefix rules + lightweight
|
|
3
|
+
* shell tokenization (YAG-504).
|
|
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
|
+
* Tokenization is a lightweight inline parser — not shell-quote — because the
|
|
14
|
+
* extension is bundled into @yagni-app/code's dist (a file copy, not a real
|
|
15
|
+
* bundler), and external dependencies aren't resolvable from the bundled path.
|
|
16
|
+
* We only need: split on whitespace (respecting single/double quotes), detect
|
|
17
|
+
* control operators (|, &&, ||, ;), and flag shell constructs ($(...),
|
|
18
|
+
* backticks, redirects) that we can't statically analyze.
|
|
19
|
+
*/
|
|
20
|
+
export type TokenEntry = string | {
|
|
21
|
+
op: "pipe" | "and" | "or" | "semi" | "redirect" | "substitution";
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Parse a shell command string into tokens and control operators.
|
|
25
|
+
*
|
|
26
|
+
* Handles:
|
|
27
|
+
* - Single and double quoted strings (preserves spaces inside)
|
|
28
|
+
* - Control operators: |, &&, ||, ;
|
|
29
|
+
* - Shell constructs we flag as unanalyzable: $(), backticks, >, <
|
|
30
|
+
*
|
|
31
|
+
* Does NOT handle: variable expansion, glob patterns, heredocs, nested
|
|
32
|
+
* subshells beyond the first level. Commands using those are classified
|
|
33
|
+
* as "prompt" (let the Guardian review).
|
|
34
|
+
*/
|
|
35
|
+
export declare function shellParse(command: string): TokenEntry[];
|
|
36
|
+
export type ExecDecision = "allow" | "prompt" | "forbidden";
|
|
37
|
+
export interface PrefixRule {
|
|
38
|
+
/** Ordered tokens; a string[] element means alternatives (any match). */
|
|
39
|
+
pattern: (string | string[])[];
|
|
40
|
+
decision: ExecDecision;
|
|
41
|
+
justification: string;
|
|
42
|
+
/** Positive test invocations (validated at load if present). */
|
|
43
|
+
match?: string[][];
|
|
44
|
+
/** Negative test invocations (validated at load if present). */
|
|
45
|
+
notMatch?: string[][];
|
|
46
|
+
}
|
|
47
|
+
export interface ExecPolicy {
|
|
48
|
+
rules: PrefixRule[];
|
|
49
|
+
}
|
|
50
|
+
export interface ExecClassification {
|
|
51
|
+
decision: ExecDecision;
|
|
52
|
+
justification: string;
|
|
53
|
+
matchedRule?: PrefixRule;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Parse a command string into tokens using our lightweight tokenizer. Returns
|
|
57
|
+
* string tokens only (control operators and constructs are filtered out —
|
|
58
|
+
* detected separately).
|
|
59
|
+
*/
|
|
60
|
+
export declare function tokenize(command: string): string[];
|
|
61
|
+
/**
|
|
62
|
+
* Classify a full bash command string against the exec policy.
|
|
63
|
+
*
|
|
64
|
+
* Compound commands (pipes, &&, ||, ;) are split into segments and each is
|
|
65
|
+
* classified independently. The strictest decision wins (forbidden > prompt >
|
|
66
|
+
* allow). Commands with shell constructs we can't parse (command substitution,
|
|
67
|
+
* redirects beyond pipe) are classified as prompt. Pipe-to-shell is always
|
|
68
|
+
* forbidden.
|
|
69
|
+
*/
|
|
70
|
+
export declare function classifyCommand(command: string, policy: ExecPolicy): ExecClassification;
|
|
71
|
+
/** Curated default rules — the shipped safety floor. */
|
|
72
|
+
export declare const DEFAULT_EXEC_POLICY: ExecPolicy;
|
|
73
|
+
//# sourceMappingURL=execPolicy.d.ts.map
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exec policy engine — classifies bash commands via prefix rules + lightweight
|
|
3
|
+
* shell tokenization (YAG-504).
|
|
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
|
+
* Tokenization is a lightweight inline parser — not shell-quote — because the
|
|
14
|
+
* extension is bundled into @yagni-app/code's dist (a file copy, not a real
|
|
15
|
+
* bundler), and external dependencies aren't resolvable from the bundled path.
|
|
16
|
+
* We only need: split on whitespace (respecting single/double quotes), detect
|
|
17
|
+
* control operators (|, &&, ||, ;), and flag shell constructs ($(...),
|
|
18
|
+
* backticks, redirects) that we can't statically analyze.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Parse a shell command string into tokens and control operators.
|
|
22
|
+
*
|
|
23
|
+
* Handles:
|
|
24
|
+
* - Single and double quoted strings (preserves spaces inside)
|
|
25
|
+
* - Control operators: |, &&, ||, ;
|
|
26
|
+
* - Shell constructs we flag as unanalyzable: $(), backticks, >, <
|
|
27
|
+
*
|
|
28
|
+
* Does NOT handle: variable expansion, glob patterns, heredocs, nested
|
|
29
|
+
* subshells beyond the first level. Commands using those are classified
|
|
30
|
+
* as "prompt" (let the Guardian review).
|
|
31
|
+
*/
|
|
32
|
+
export function shellParse(command) {
|
|
33
|
+
const tokens = [];
|
|
34
|
+
let i = 0;
|
|
35
|
+
let current = "";
|
|
36
|
+
let inSingle = false;
|
|
37
|
+
let inDouble = false;
|
|
38
|
+
let hasConstruct = false;
|
|
39
|
+
const pushCurrent = () => {
|
|
40
|
+
if (current.length > 0) {
|
|
41
|
+
tokens.push(current);
|
|
42
|
+
current = "";
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
while (i < command.length) {
|
|
46
|
+
const ch = command[i];
|
|
47
|
+
if (inSingle) {
|
|
48
|
+
if (ch === "'") {
|
|
49
|
+
inSingle = false;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
current += ch;
|
|
53
|
+
}
|
|
54
|
+
i++;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (inDouble) {
|
|
58
|
+
if (ch === '"') {
|
|
59
|
+
inDouble = false;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
current += ch;
|
|
63
|
+
}
|
|
64
|
+
i++;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
switch (ch) {
|
|
68
|
+
case "'":
|
|
69
|
+
inSingle = true;
|
|
70
|
+
i++;
|
|
71
|
+
continue;
|
|
72
|
+
case '"':
|
|
73
|
+
inDouble = true;
|
|
74
|
+
i++;
|
|
75
|
+
continue;
|
|
76
|
+
case " ":
|
|
77
|
+
case "\t":
|
|
78
|
+
case "\n":
|
|
79
|
+
pushCurrent();
|
|
80
|
+
i++;
|
|
81
|
+
continue;
|
|
82
|
+
case "|":
|
|
83
|
+
if (command[i + 1] === "|") {
|
|
84
|
+
pushCurrent();
|
|
85
|
+
tokens.push({ op: "or" });
|
|
86
|
+
i += 2;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
pushCurrent();
|
|
90
|
+
tokens.push({ op: "pipe" });
|
|
91
|
+
i++;
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
case "&":
|
|
95
|
+
if (command[i + 1] === "&") {
|
|
96
|
+
pushCurrent();
|
|
97
|
+
tokens.push({ op: "and" });
|
|
98
|
+
i += 2;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
// Single & — background operator, treat as construct
|
|
102
|
+
hasConstruct = true;
|
|
103
|
+
current += ch;
|
|
104
|
+
i++;
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
case ";":
|
|
108
|
+
pushCurrent();
|
|
109
|
+
tokens.push({ op: "semi" });
|
|
110
|
+
i++;
|
|
111
|
+
continue;
|
|
112
|
+
case ">":
|
|
113
|
+
case "<":
|
|
114
|
+
pushCurrent();
|
|
115
|
+
tokens.push({ op: "redirect" });
|
|
116
|
+
hasConstruct = true;
|
|
117
|
+
// Skip the operator char(s) and any following space
|
|
118
|
+
i++;
|
|
119
|
+
if (command[i] === ch)
|
|
120
|
+
i++; // >> or <<
|
|
121
|
+
while (command[i] === " " || command[i] === "\t")
|
|
122
|
+
i++;
|
|
123
|
+
continue;
|
|
124
|
+
case "$":
|
|
125
|
+
if (command[i + 1] === "(") {
|
|
126
|
+
pushCurrent();
|
|
127
|
+
tokens.push({ op: "substitution" });
|
|
128
|
+
hasConstruct = true;
|
|
129
|
+
// Skip until matching )
|
|
130
|
+
i += 2;
|
|
131
|
+
let depth = 1;
|
|
132
|
+
while (i < command.length && depth > 0) {
|
|
133
|
+
if (command[i] === "(")
|
|
134
|
+
depth++;
|
|
135
|
+
if (command[i] === ")")
|
|
136
|
+
depth--;
|
|
137
|
+
i++;
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
current += ch;
|
|
142
|
+
i++;
|
|
143
|
+
continue;
|
|
144
|
+
case "`":
|
|
145
|
+
pushCurrent();
|
|
146
|
+
tokens.push({ op: "substitution" });
|
|
147
|
+
hasConstruct = true;
|
|
148
|
+
i++;
|
|
149
|
+
while (i < command.length && command[i] !== "`")
|
|
150
|
+
i++;
|
|
151
|
+
if (i < command.length)
|
|
152
|
+
i++;
|
|
153
|
+
continue;
|
|
154
|
+
default:
|
|
155
|
+
current += ch;
|
|
156
|
+
i++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
pushCurrent();
|
|
161
|
+
// If we detected constructs but didn't emit them as operator tokens
|
|
162
|
+
// (e.g. background &), surface that via a substitution token.
|
|
163
|
+
if (hasConstruct && !tokens.some((t) => typeof t === "object")) {
|
|
164
|
+
tokens.push({ op: "substitution" });
|
|
165
|
+
}
|
|
166
|
+
return tokens;
|
|
167
|
+
}
|
|
168
|
+
/** Interpreters that, when piped into, indicate code execution — always forbidden. */
|
|
169
|
+
const PIPE_TO_SHELL = new Set([
|
|
170
|
+
"sh", "bash", "zsh", "fish", "nc", "ncat", "socat",
|
|
171
|
+
"python", "python3", "perl", "ruby", "node",
|
|
172
|
+
]);
|
|
173
|
+
/**
|
|
174
|
+
* Parse a command string into tokens using our lightweight tokenizer. Returns
|
|
175
|
+
* string tokens only (control operators and constructs are filtered out —
|
|
176
|
+
* detected separately).
|
|
177
|
+
*/
|
|
178
|
+
export function tokenize(command) {
|
|
179
|
+
return shellParse(command).filter((t) => typeof t === "string");
|
|
180
|
+
}
|
|
181
|
+
/** Operator tokens we can safely split on (compound command segments). */
|
|
182
|
+
const SPLIT_OPS = new Set(["pipe", "and", "or", "semi"]);
|
|
183
|
+
/**
|
|
184
|
+
* Detect whether the command uses shell constructs we can't statically classify
|
|
185
|
+
* (command substitution, redirects, etc.) — anything that is NOT a splittable
|
|
186
|
+
* operator (pipe, and, or, semi).
|
|
187
|
+
*/
|
|
188
|
+
function hasUnhandledConstructs(command) {
|
|
189
|
+
return shellParse(command).some((t) => typeof t === "object" && "op" in t && !SPLIT_OPS.has(t.op));
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Split a command into segments at control operators (|, &&, ||, ;).
|
|
193
|
+
* Returns the raw string of each segment.
|
|
194
|
+
*/
|
|
195
|
+
function splitSegments(command) {
|
|
196
|
+
const tokens = shellParse(command);
|
|
197
|
+
const segments = [];
|
|
198
|
+
let current = [];
|
|
199
|
+
for (const t of tokens) {
|
|
200
|
+
if (typeof t === "object") {
|
|
201
|
+
if (SPLIT_OPS.has(t.op)) {
|
|
202
|
+
if (current.length > 0)
|
|
203
|
+
segments.push(current.join(" "));
|
|
204
|
+
current = [];
|
|
205
|
+
}
|
|
206
|
+
// Non-splittable operators (redirect, substitution) are already
|
|
207
|
+
// handled by hasUnhandledConstructs above — we won't reach splitSegments.
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
current.push(t);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (current.length > 0)
|
|
214
|
+
segments.push(current.join(" "));
|
|
215
|
+
return segments;
|
|
216
|
+
}
|
|
217
|
+
/** Check if any segment pipes into a known shell/network interpreter. */
|
|
218
|
+
function isPipeToShell(command) {
|
|
219
|
+
const tokens = shellParse(command);
|
|
220
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
221
|
+
const t = tokens[i];
|
|
222
|
+
if (typeof t === "object" && t.op === "pipe") {
|
|
223
|
+
const next = tokens[i + 1];
|
|
224
|
+
if (typeof next === "string" && PIPE_TO_SHELL.has(next))
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
/** Match a token array against a prefix rule's pattern. */
|
|
231
|
+
function matchRule(tokens, rule) {
|
|
232
|
+
if (tokens.length < rule.pattern.length)
|
|
233
|
+
return false;
|
|
234
|
+
for (let i = 0; i < rule.pattern.length; i++) {
|
|
235
|
+
const pat = rule.pattern[i];
|
|
236
|
+
const tok = tokens[i];
|
|
237
|
+
if (typeof pat === "string") {
|
|
238
|
+
if (pat !== tok)
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
if (!pat.includes(tok))
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
/** Classify a single command segment (no shell constructs). */
|
|
249
|
+
function classifySegment(segment, rules) {
|
|
250
|
+
const tokens = tokenize(segment);
|
|
251
|
+
if (tokens.length === 0) {
|
|
252
|
+
return { decision: "prompt", justification: "empty command segment" };
|
|
253
|
+
}
|
|
254
|
+
// First match wins (rules are ordered; more specific rules come first).
|
|
255
|
+
for (const rule of rules) {
|
|
256
|
+
if (matchRule(tokens, rule)) {
|
|
257
|
+
return { decision: rule.decision, justification: rule.justification, matchedRule: rule };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// No rule matched → prompt (fail toward review, not toward allow)
|
|
261
|
+
return { decision: "prompt", justification: `no policy rule matched for "${tokens[0]}"` };
|
|
262
|
+
}
|
|
263
|
+
/** Decision severity: forbidden > prompt > allow. */
|
|
264
|
+
const SEVERITY = { allow: 0, prompt: 1, forbidden: 2 };
|
|
265
|
+
/**
|
|
266
|
+
* Classify a full bash command string against the exec policy.
|
|
267
|
+
*
|
|
268
|
+
* Compound commands (pipes, &&, ||, ;) are split into segments and each is
|
|
269
|
+
* classified independently. The strictest decision wins (forbidden > prompt >
|
|
270
|
+
* allow). Commands with shell constructs we can't parse (command substitution,
|
|
271
|
+
* redirects beyond pipe) are classified as prompt. Pipe-to-shell is always
|
|
272
|
+
* forbidden.
|
|
273
|
+
*/
|
|
274
|
+
export function classifyCommand(command, policy) {
|
|
275
|
+
// Pipe-to-shell is always forbidden regardless of other rules.
|
|
276
|
+
if (isPipeToShell(command)) {
|
|
277
|
+
return {
|
|
278
|
+
decision: "forbidden",
|
|
279
|
+
justification: "piping into a shell or network interpreter is forbidden",
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
// Command substitution, redirects, and other constructs we can't statically
|
|
283
|
+
// analyze → prompt (let the Guardian review). Only checked for constructs
|
|
284
|
+
// that are NOT splittable (|, &&, ||, ;) — those are handled below.
|
|
285
|
+
if (hasUnhandledConstructs(command)) {
|
|
286
|
+
return {
|
|
287
|
+
decision: "prompt",
|
|
288
|
+
justification: "command uses shell constructs (substitution/redirect) that cannot be statically analyzed",
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const segments = splitSegments(command);
|
|
292
|
+
if (segments.length <= 1) {
|
|
293
|
+
return classifySegment(command, policy.rules);
|
|
294
|
+
}
|
|
295
|
+
// Compound command: classify each segment, take the strictest.
|
|
296
|
+
let result = { decision: "allow", justification: "all segments allowed" };
|
|
297
|
+
for (const seg of segments) {
|
|
298
|
+
const segResult = classifySegment(seg, policy.rules);
|
|
299
|
+
if (SEVERITY[segResult.decision] > SEVERITY[result.decision]) {
|
|
300
|
+
result = segResult;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
/** Curated default rules — the shipped safety floor. */
|
|
306
|
+
export const DEFAULT_EXEC_POLICY = {
|
|
307
|
+
rules: [
|
|
308
|
+
// --- forbidden: destructive commands (highest priority) ---
|
|
309
|
+
{
|
|
310
|
+
pattern: ["rm", ["-rf", "-fr", "-r", "-f", "--recursive", "--force"]],
|
|
311
|
+
decision: "forbidden",
|
|
312
|
+
justification: "recursive/forced deletion is destructive and irreversible",
|
|
313
|
+
},
|
|
314
|
+
{ pattern: ["rm", "-r"], decision: "forbidden", justification: "recursive deletion is destructive" },
|
|
315
|
+
{ pattern: ["rm", "-f"], decision: "forbidden", justification: "forced deletion bypasses prompts" },
|
|
316
|
+
{ pattern: ["rm", "--recursive"], decision: "forbidden", justification: "recursive deletion is destructive" },
|
|
317
|
+
{ pattern: ["rm", "--force"], decision: "forbidden", justification: "forced deletion bypasses prompts" },
|
|
318
|
+
{ pattern: ["git", "reset", "--hard"], decision: "forbidden", justification: "hard reset discards uncommitted changes irreversibly" },
|
|
319
|
+
{ pattern: ["git", "push", ["--force", "-f"]], decision: "forbidden", justification: "force-push rewrites shared history" },
|
|
320
|
+
{ pattern: ["git", "clean", ["-fd", "-df", "-f", "-d"]], decision: "forbidden", justification: "git clean removes untracked files irreversibly" },
|
|
321
|
+
{ pattern: ["git", "checkout", "--"], decision: "forbidden", justification: "discards working tree changes" },
|
|
322
|
+
{ pattern: ["chmod", "-R", "777"], decision: "forbidden", justification: "recursive world-writable permission change weakens security" },
|
|
323
|
+
{ pattern: ["chown", "-R"], decision: "forbidden", justification: "recursive ownership change" },
|
|
324
|
+
{ pattern: ["dd"], decision: "forbidden", justification: "low-level disk operations are destructive" },
|
|
325
|
+
{ pattern: ["mkfs"], decision: "forbidden", justification: "filesystem formatting is destructive" },
|
|
326
|
+
{ pattern: ["shutdown"], decision: "forbidden", justification: "system shutdown" },
|
|
327
|
+
{ pattern: ["reboot"], decision: "forbidden", justification: "system reboot" },
|
|
328
|
+
{ pattern: ["kill", "-9"], decision: "forbidden", justification: "force kill is destructive" },
|
|
329
|
+
{ pattern: ["kill", "-KILL"], decision: "forbidden", justification: "force kill is destructive" },
|
|
330
|
+
{ pattern: ["truncate"], decision: "forbidden", justification: "truncates files destructively" },
|
|
331
|
+
// --- allow: read-only commands ---
|
|
332
|
+
{ pattern: ["ls"], decision: "allow", justification: "list directory contents" },
|
|
333
|
+
{ pattern: ["cat"], decision: "allow", justification: "read file contents" },
|
|
334
|
+
{ pattern: ["head"], decision: "allow", justification: "read file head" },
|
|
335
|
+
{ pattern: ["tail"], decision: "allow", justification: "read file tail" },
|
|
336
|
+
{ pattern: ["wc"], decision: "allow", justification: "count lines/words" },
|
|
337
|
+
{ pattern: ["pwd"], decision: "allow", justification: "print working directory" },
|
|
338
|
+
{ pattern: ["which"], decision: "allow", justification: "locate a command" },
|
|
339
|
+
{ pattern: ["echo"], decision: "allow", justification: "print text" },
|
|
340
|
+
{ pattern: ["true"], decision: "allow", justification: "no-op success" },
|
|
341
|
+
{ pattern: ["false"], decision: "allow", justification: "no-op failure" },
|
|
342
|
+
{ pattern: ["test"], decision: "allow", justification: "test condition" },
|
|
343
|
+
{ pattern: ["find", ".", "-name"], decision: "allow", justification: "search for files by name" },
|
|
344
|
+
{ pattern: ["find", ".", "-type"], decision: "allow", justification: "search for files by type" },
|
|
345
|
+
{ pattern: ["grep"], decision: "allow", justification: "search text" },
|
|
346
|
+
{ pattern: ["rg"], decision: "allow", justification: "search text (ripgrep)" },
|
|
347
|
+
{ pattern: ["ag"], decision: "allow", justification: "search text (silver searcher)" },
|
|
348
|
+
{ pattern: ["git", "status"], decision: "allow", justification: "show working tree status" },
|
|
349
|
+
{ pattern: ["git", "log"], decision: "allow", justification: "show commit log" },
|
|
350
|
+
{ pattern: ["git", "diff"], decision: "allow", justification: "show changes" },
|
|
351
|
+
{ pattern: ["git", "branch"], decision: "allow", justification: "list branches" },
|
|
352
|
+
{ pattern: ["git", "show"], decision: "allow", justification: "show a commit" },
|
|
353
|
+
{ pattern: ["git", "remote"], decision: "allow", justification: "list remotes" },
|
|
354
|
+
{ pattern: ["git", "rev-parse"], decision: "allow", justification: "resolve git refs" },
|
|
355
|
+
{ pattern: ["git", "worktree", "list"], decision: "allow", justification: "list worktrees" },
|
|
356
|
+
{ pattern: ["node", "--version"], decision: "allow", justification: "check node version" },
|
|
357
|
+
{ pattern: ["node", "-v"], decision: "allow", justification: "check node version" },
|
|
358
|
+
{ pattern: ["npm", "ls"], decision: "allow", justification: "list installed packages" },
|
|
359
|
+
{ pattern: ["pnpm", "ls"], decision: "allow", justification: "list installed packages" },
|
|
360
|
+
{ pattern: ["pnpm", "--version"], decision: "allow", justification: "check pnpm version" },
|
|
361
|
+
{ pattern: ["tsc", "--version"], decision: "allow", justification: "check typescript version" },
|
|
362
|
+
// --- prompt: potentially destructive but context-dependent ---
|
|
363
|
+
{ pattern: ["rm"], decision: "prompt", justification: "file deletion — review the target" },
|
|
364
|
+
{ pattern: ["git", "commit"], decision: "prompt", justification: "creates a commit — confirm intent" },
|
|
365
|
+
{ pattern: ["git", "push"], decision: "prompt", justification: "pushes to remote — confirm intent" },
|
|
366
|
+
{ pattern: ["git", "merge"], decision: "prompt", justification: "merges branches — may conflict" },
|
|
367
|
+
{ pattern: ["git", "rebase"], decision: "prompt", justification: "rebases — rewrites history" },
|
|
368
|
+
{ pattern: ["git", "stash"], decision: "prompt", justification: "stashes working changes" },
|
|
369
|
+
{ pattern: ["git", "checkout"], decision: "prompt", justification: "switches branch or restores files" },
|
|
370
|
+
{ pattern: ["git", "switch"], decision: "prompt", justification: "switches branch" },
|
|
371
|
+
{ pattern: ["git", "reset"], decision: "prompt", justification: "resets HEAD — may discard changes" },
|
|
372
|
+
{ pattern: ["git", "revert"], decision: "prompt", justification: "creates a revert commit" },
|
|
373
|
+
{ pattern: ["git", "cherry-pick"], decision: "prompt", justification: "applies a specific commit" },
|
|
374
|
+
{ pattern: ["npm", "install"], decision: "prompt", justification: "installs packages — modifies node_modules" },
|
|
375
|
+
{ pattern: ["npm", "ci"], decision: "prompt", justification: "clean install — modifies node_modules" },
|
|
376
|
+
{ pattern: ["pnpm", "install"], decision: "prompt", justification: "installs packages — modifies node_modules" },
|
|
377
|
+
{ pattern: ["pnpm", "add"], decision: "prompt", justification: "adds a dependency" },
|
|
378
|
+
{ pattern: ["pnpm", "remove"], decision: "prompt", justification: "removes a dependency" },
|
|
379
|
+
{ pattern: ["npm", "run"], decision: "prompt", justification: "runs a script — may have side effects" },
|
|
380
|
+
{ pattern: ["pnpm", ["run", "exec"]], decision: "prompt", justification: "runs a script or binary — may have side effects" },
|
|
381
|
+
{ pattern: ["npx"], decision: "prompt", justification: "executes a package — may have side effects" },
|
|
382
|
+
{ pattern: ["curl"], decision: "prompt", justification: "network request — review the URL" },
|
|
383
|
+
{ pattern: ["wget"], decision: "prompt", justification: "network download — review the URL" },
|
|
384
|
+
{ pattern: ["docker"], decision: "prompt", justification: "docker command — may have side effects" },
|
|
385
|
+
{ pattern: ["psql"], decision: "prompt", justification: "database command — may modify data" },
|
|
386
|
+
{ pattern: ["fly"], decision: "prompt", justification: "Fly.io command — may affect production" },
|
|
387
|
+
{ pattern: ["kubectl"], decision: "prompt", justification: "Kubernetes command — may affect production" },
|
|
388
|
+
{ pattern: ["mv"], decision: "prompt", justification: "moves files — may overwrite" },
|
|
389
|
+
{ pattern: ["cp"], decision: "prompt", justification: "copies files" },
|
|
390
|
+
{ pattern: ["mkdir"], decision: "prompt", justification: "creates directories" },
|
|
391
|
+
{ pattern: ["touch"], decision: "prompt", justification: "creates or updates file timestamps" },
|
|
392
|
+
{ pattern: ["tar"], decision: "prompt", justification: "archive operation" },
|
|
393
|
+
{ pattern: ["zip"], decision: "prompt", justification: "archive operation" },
|
|
394
|
+
{ pattern: ["unzip"], decision: "prompt", justification: "archive operation" },
|
|
395
|
+
{ pattern: ["ps"], decision: "prompt", justification: "lists processes" },
|
|
396
|
+
{ pattern: ["kill"], decision: "prompt", justification: "sends a signal to a process" },
|
|
397
|
+
],
|
|
398
|
+
};
|
|
399
|
+
//# sourceMappingURL=execPolicy.js.map
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Guardian — LLM auto-review of prompt-band bash commands (YAG-504).
|
|
3
|
+
*
|
|
4
|
+
* Pure half: verdict types, state tracking, circuit breaker, JSON parsing.
|
|
5
|
+
* I/O half: reviewCommand spawns a locked-down child pi (same runStage seam
|
|
6
|
+
* the advisor uses) on the efficient tier with read-only tools and a risk
|
|
7
|
+
* policy persona. The child returns a JSON verdict; the gate acts on it.
|
|
8
|
+
*
|
|
9
|
+
* Same pure/IO split as advisor.ts (decideConsult pure, askAdvisorTool I/O)
|
|
10
|
+
* and permission.ts (decideGate pure, registerPermissionGate I/O), for the
|
|
11
|
+
* same reason: the rules are what need exhaustive tests, and they must not
|
|
12
|
+
* require a child process to exercise.
|
|
13
|
+
*
|
|
14
|
+
* Trigger: the exec policy classifies a bash command as "prompt" (not clearly
|
|
15
|
+
* safe, not clearly forbidden). The Guardian reviews it instead of interrupting
|
|
16
|
+
* the user. On allow, the command runs. On deny, the agent sees the rationale
|
|
17
|
+
* and is told to find a safer alternative or ask the user. On timeout/error,
|
|
18
|
+
* auto mode fails closed (block); review mode falls back to the user prompt.
|
|
19
|
+
*
|
|
20
|
+
* Circuit breaker: 3 consecutive denials in one turn → turn interrupted.
|
|
21
|
+
*/
|
|
22
|
+
import { runStage as defaultRunStage } from "./pipeline/runner.js";
|
|
23
|
+
import type { PipelineStage } from "./pipeline/types.js";
|
|
24
|
+
export type GuardianOutcome = "allow" | "deny";
|
|
25
|
+
export type GuardianRiskLevel = "low" | "medium" | "high" | "critical";
|
|
26
|
+
export interface GuardianVerdict {
|
|
27
|
+
outcome: GuardianOutcome;
|
|
28
|
+
riskLevel: GuardianRiskLevel;
|
|
29
|
+
rationale: string;
|
|
30
|
+
}
|
|
31
|
+
export interface GuardianLimits {
|
|
32
|
+
/** Session cap on total Guardian reviews. */
|
|
33
|
+
maxReviews: number;
|
|
34
|
+
/** Consecutive denials per turn before the circuit breaker trips. */
|
|
35
|
+
maxConsecutiveDenials: number;
|
|
36
|
+
/** Hard timeout for the Guardian consult in ms. */
|
|
37
|
+
timeoutMs: number;
|
|
38
|
+
}
|
|
39
|
+
export declare const DEFAULT_GUARDIAN_LIMITS: GuardianLimits;
|
|
40
|
+
/** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
|
|
41
|
+
export declare const GUARDIAN_MODEL_TIER = "efficient";
|
|
42
|
+
/** Read-only tools — the Guardian can read files for context but cannot write or execute. */
|
|
43
|
+
export declare const GUARDIAN_TOOLS: string[];
|
|
44
|
+
export interface GuardianState {
|
|
45
|
+
reviews: number;
|
|
46
|
+
consecutiveDenials: number;
|
|
47
|
+
}
|
|
48
|
+
export interface GuardianStateHandle {
|
|
49
|
+
read(): GuardianState;
|
|
50
|
+
recordReview(outcome: GuardianOutcome): GuardianState;
|
|
51
|
+
resetTurn(): void;
|
|
52
|
+
}
|
|
53
|
+
export declare function makeGuardianState(): GuardianStateHandle;
|
|
54
|
+
export interface CircuitBreakerResult {
|
|
55
|
+
tripped: boolean;
|
|
56
|
+
reason?: string;
|
|
57
|
+
}
|
|
58
|
+
export declare function checkCircuitBreaker(state: GuardianState, limits: GuardianLimits): CircuitBreakerResult;
|
|
59
|
+
export declare function parseVerdict(raw: string): GuardianVerdict | null;
|
|
60
|
+
export declare function formatGuardianSubtotal(state: GuardianState, limits: GuardianLimits): string;
|
|
61
|
+
export type GuardianError = "timeout" | "malformed" | "network" | "empty";
|
|
62
|
+
export interface ReviewResult {
|
|
63
|
+
verdict: GuardianVerdict | null;
|
|
64
|
+
error?: GuardianError;
|
|
65
|
+
cost: number;
|
|
66
|
+
}
|
|
67
|
+
export interface ReviewCommandDeps {
|
|
68
|
+
runStage?: typeof defaultRunStage;
|
|
69
|
+
cwd: string;
|
|
70
|
+
signal?: AbortSignal;
|
|
71
|
+
/** Override the model tier (default: efficient). */
|
|
72
|
+
modelTier?: string;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The synthetic stage a Guardian consult runs as. Borrows the `plan` StageId
|
|
76
|
+
* (same pattern as the advisor) so it doesn't ripple into feed/reducers. The
|
|
77
|
+
* agent name selects the guardian persona from PERSONA_BODIES.
|
|
78
|
+
*/
|
|
79
|
+
export declare function guardianStage(modelTier?: string): PipelineStage;
|
|
80
|
+
/**
|
|
81
|
+
* Run a Guardian consult: spawn a locked-down child pi with the risk policy
|
|
82
|
+
* persona and the command as the task. Parse the JSON verdict from the output.
|
|
83
|
+
* Returns { verdict, cost } on success, { verdict: null, error, cost } on failure.
|
|
84
|
+
*/
|
|
85
|
+
export declare function reviewCommand(command: string, deps: ReviewCommandDeps): Promise<ReviewResult>;
|
|
86
|
+
export interface GuardianDiagnosticEvent {
|
|
87
|
+
event: "guardian_review";
|
|
88
|
+
outcome: GuardianOutcome | GuardianError;
|
|
89
|
+
durationMs?: number;
|
|
90
|
+
tier?: string;
|
|
91
|
+
/** Debug-only: command hash for correlation (never the raw command). */
|
|
92
|
+
commandHash?: string;
|
|
93
|
+
/** Debug-only: the Guardian's rationale. */
|
|
94
|
+
rationale?: string;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Create a sanitized diagnostic event. Never includes the raw command text
|
|
98
|
+
* (could contain secrets). YAGNI_DEBUG=1 adds rationale and a command hash.
|
|
99
|
+
*/
|
|
100
|
+
export declare function buildDiagnosticEvent(outcome: GuardianOutcome | GuardianError, opts: {
|
|
101
|
+
durationMs?: number;
|
|
102
|
+
tier?: string;
|
|
103
|
+
rationale?: string;
|
|
104
|
+
commandHash?: string;
|
|
105
|
+
debug?: boolean;
|
|
106
|
+
}): GuardianDiagnosticEvent;
|
|
107
|
+
//# sourceMappingURL=guardian.d.ts.map
|