pi-auto-permissions 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +304 -0
- package/THIRD_PARTY_NOTICES.md +70 -0
- package/docs/implementation-plan.md +311 -0
- package/docs/invariants.md +555 -0
- package/package.json +59 -0
- package/src/canonical.ts +314 -0
- package/src/commands/index.ts +362 -0
- package/src/domain.ts +198 -0
- package/src/extension.ts +430 -0
- package/src/guardian/circuit-breaker.ts +102 -0
- package/src/guardian/index.ts +74 -0
- package/src/guardian/policy.ts +135 -0
- package/src/guardian/prompt.ts +379 -0
- package/src/guardian/reviewer.ts +599 -0
- package/src/guardian/types.ts +149 -0
- package/src/guardian/verdict.ts +211 -0
- package/src/pi/index.ts +13 -0
- package/src/pi/model-reviewer.ts +235 -0
- package/src/pi/transcript.ts +524 -0
- package/src/policy/dangerous-command.ts +312 -0
- package/src/policy/path-policy.ts +501 -0
- package/src/runtime/index.ts +1 -0
- package/src/runtime/permission-engine.ts +474 -0
- package/src/sandbox/config.ts +188 -0
- package/src/sandbox/controller.ts +354 -0
- package/src/sandbox/index.ts +26 -0
- package/src/sandbox/runtime.ts +28 -0
- package/src/sandbox/types.ts +125 -0
- package/src/state/config-store.ts +580 -0
- package/src/state/index.ts +2 -0
- package/src/tools/bash.ts +101 -0
- package/src/tools/gate.ts +74 -0
- package/src/tools/index.ts +2 -0
- package/vendor/openai-codex/NOTICE +6 -0
- package/vendor/openai-codex/README.md +17 -0
- package/vendor/openai-codex/guardian/policy.md +42 -0
- package/vendor/openai-codex/guardian/policy_template.md +58 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derived from OpenAI Codex:
|
|
3
|
+
* - codex-rs/shell-command/src/command_safety/is_dangerous_command.rs
|
|
4
|
+
* - codex-rs/shell-command/src/bash.rs
|
|
5
|
+
* revision 0fb559f0f6e231a88ac02ea002d3ecd248e2b515, Apache-2.0.
|
|
6
|
+
*
|
|
7
|
+
* Modified 2026 for Pi and the web-tree-sitter WASM API. The decision rule,
|
|
8
|
+
* wrapper recursion limit, and literal-shell-word semantics are retained.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { basename } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { Language, type Node, Parser } from "web-tree-sitter";
|
|
14
|
+
|
|
15
|
+
export type DangerousCommandMatch = "forced-rm";
|
|
16
|
+
|
|
17
|
+
export interface DangerousCommandDetector {
|
|
18
|
+
detect(command: string): DangerousCommandMatch | undefined;
|
|
19
|
+
close(): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MAX_WRAPPER_DEPTH = 8;
|
|
23
|
+
|
|
24
|
+
let runtimeInitialization: Promise<void> | undefined;
|
|
25
|
+
let bashLanguage: Promise<Language> | undefined;
|
|
26
|
+
|
|
27
|
+
function initializeRuntime(): Promise<void> {
|
|
28
|
+
runtimeInitialization ??= Parser.init();
|
|
29
|
+
return runtimeInitialization;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function loadBashLanguage(): Promise<Language> {
|
|
33
|
+
bashLanguage ??= (async () => {
|
|
34
|
+
await initializeRuntime();
|
|
35
|
+
const grammarUrl = import.meta.resolve("tree-sitter-bash/tree-sitter-bash.wasm");
|
|
36
|
+
return Language.load(fileURLToPath(grammarUrl));
|
|
37
|
+
})();
|
|
38
|
+
return bashLanguage;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Loads the pinned Bash grammar once, then returns a session-local parser.
|
|
43
|
+
* Initialization failure is intentionally observable so callers can route the
|
|
44
|
+
* command to Auto review instead of silently skipping the Codex rule.
|
|
45
|
+
*/
|
|
46
|
+
export async function createDangerousCommandDetector(): Promise<DangerousCommandDetector> {
|
|
47
|
+
const language = await loadBashLanguage();
|
|
48
|
+
const parser = new Parser();
|
|
49
|
+
parser.setLanguage(language);
|
|
50
|
+
let closed = false;
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
detect(command: string): DangerousCommandMatch | undefined {
|
|
54
|
+
if (closed) {
|
|
55
|
+
throw new Error("dangerous-command detector is closed");
|
|
56
|
+
}
|
|
57
|
+
return dangerousCommandMatch(["bash", "-lc", command], 0, parser);
|
|
58
|
+
},
|
|
59
|
+
close(): void {
|
|
60
|
+
if (!closed) {
|
|
61
|
+
closed = true;
|
|
62
|
+
parser.delete();
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function dangerousCommandMatch(
|
|
69
|
+
command: readonly string[],
|
|
70
|
+
wrapperDepth: number,
|
|
71
|
+
parser: Parser,
|
|
72
|
+
): DangerousCommandMatch | undefined {
|
|
73
|
+
if (wrapperDepth > MAX_WRAPPER_DEPTH) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const direct = dangerousMatchForExec(command, wrapperDepth, parser);
|
|
78
|
+
if (direct !== undefined) {
|
|
79
|
+
return direct;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const literalCommands = parseShellLiteralCommands(command, parser);
|
|
83
|
+
if (literalCommands !== undefined) {
|
|
84
|
+
for (const literalCommand of literalCommands) {
|
|
85
|
+
const nested = dangerousCommandMatch(literalCommand, wrapperDepth + 1, parser);
|
|
86
|
+
if (nested !== undefined) {
|
|
87
|
+
return nested;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function dangerousMatchForExec(
|
|
96
|
+
command: readonly string[],
|
|
97
|
+
wrapperDepth: number,
|
|
98
|
+
parser: Parser,
|
|
99
|
+
): DangerousCommandMatch | undefined {
|
|
100
|
+
const executable = executableName(command[0]);
|
|
101
|
+
|
|
102
|
+
if (executable === "rm" && rmArgsIncludeForce(command.slice(1))) {
|
|
103
|
+
return "forced-rm";
|
|
104
|
+
}
|
|
105
|
+
if (executable === "sudo") {
|
|
106
|
+
return dangerousCommandMatch(command.slice(1), wrapperDepth + 1, parser);
|
|
107
|
+
}
|
|
108
|
+
if (executable === "env") {
|
|
109
|
+
return dangerousMatchForEnv(command, wrapperDepth, parser);
|
|
110
|
+
}
|
|
111
|
+
if (executable === "trap") {
|
|
112
|
+
return dangerousMatchForTrap(command, wrapperDepth, parser);
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function executableName(raw: string | undefined): string | undefined {
|
|
118
|
+
if (raw === undefined || raw.length === 0) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
return basename(raw);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function dangerousMatchForEnv(
|
|
125
|
+
command: readonly string[],
|
|
126
|
+
wrapperDepth: number,
|
|
127
|
+
parser: Parser,
|
|
128
|
+
): DangerousCommandMatch | undefined {
|
|
129
|
+
let commandIndex = 1;
|
|
130
|
+
while (commandIndex < command.length) {
|
|
131
|
+
const argument = command[commandIndex];
|
|
132
|
+
if (argument === "--") {
|
|
133
|
+
commandIndex += 1;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
if (
|
|
137
|
+
argument === "-i" ||
|
|
138
|
+
argument === "--ignore-environment" ||
|
|
139
|
+
isEnvironmentAssignment(argument)
|
|
140
|
+
) {
|
|
141
|
+
commandIndex += 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
return dangerousCommandMatch(command.slice(commandIndex), wrapperDepth + 1, parser);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isEnvironmentAssignment(argument: string | undefined): boolean {
|
|
150
|
+
if (argument === undefined) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
const equals = argument.indexOf("=");
|
|
154
|
+
return equals > 0 && argument[0] !== "-";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function dangerousMatchForTrap(
|
|
158
|
+
command: readonly string[],
|
|
159
|
+
wrapperDepth: number,
|
|
160
|
+
parser: Parser,
|
|
161
|
+
): DangerousCommandMatch | undefined {
|
|
162
|
+
let actionIndex = 1;
|
|
163
|
+
if (command[actionIndex] === "--") {
|
|
164
|
+
actionIndex += 1;
|
|
165
|
+
}
|
|
166
|
+
const action = command[actionIndex];
|
|
167
|
+
if (action === undefined || action.startsWith("-")) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
return dangerousCommandMatch(["sh", "-c", action], wrapperDepth + 1, parser);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function rmArgsIncludeForce(args: readonly string[]): boolean {
|
|
174
|
+
for (const argument of args) {
|
|
175
|
+
if (argument === "--") {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
if (argument === "--force") {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
if (
|
|
182
|
+
argument.startsWith("-") &&
|
|
183
|
+
!argument.startsWith("--") &&
|
|
184
|
+
argument.slice(1).includes("f")
|
|
185
|
+
) {
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function parseShellLiteralCommands(
|
|
193
|
+
command: readonly string[],
|
|
194
|
+
parser: Parser,
|
|
195
|
+
): string[][] | undefined {
|
|
196
|
+
const script = extractShellScript(command);
|
|
197
|
+
if (script === undefined) {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const tree = parser.parse(script);
|
|
202
|
+
if (tree === null) {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
if (tree.rootNode.hasError) {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
const commands: string[][] = [];
|
|
210
|
+
const stack: Node[] = [tree.rootNode];
|
|
211
|
+
while (stack.length > 0) {
|
|
212
|
+
const node = stack.pop();
|
|
213
|
+
if (node === undefined) {
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
if (node.type === "command") {
|
|
217
|
+
const parsed = parseLiteralCommand(node);
|
|
218
|
+
if (parsed !== undefined) {
|
|
219
|
+
commands.push(parsed);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const child of node.namedChildren) {
|
|
223
|
+
if (child !== null) {
|
|
224
|
+
stack.push(child);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return commands;
|
|
229
|
+
} finally {
|
|
230
|
+
tree.delete();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function extractShellScript(command: readonly string[]): string | undefined {
|
|
235
|
+
if (command.length !== 3) {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
const executable = executableName(command[0]);
|
|
239
|
+
if (
|
|
240
|
+
(executable !== "bash" && executable !== "zsh" && executable !== "sh") ||
|
|
241
|
+
(command[1] !== "-lc" && command[1] !== "-c")
|
|
242
|
+
) {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
return command[2];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function parseLiteralCommand(command: Node): string[] | undefined {
|
|
249
|
+
const words: string[] = [];
|
|
250
|
+
let foundCommandName = false;
|
|
251
|
+
|
|
252
|
+
for (const child of command.namedChildren) {
|
|
253
|
+
if (child === null) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (child.type === "command_name") {
|
|
257
|
+
const nameNode = child.namedChild(0);
|
|
258
|
+
if (nameNode === null) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
const name = parseLiteralShellWord(nameNode);
|
|
262
|
+
if (name === undefined) {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
words.push(name);
|
|
266
|
+
foundCommandName = true;
|
|
267
|
+
} else if (foundCommandName) {
|
|
268
|
+
const word = parseLiteralShellWord(child);
|
|
269
|
+
if (word !== undefined) {
|
|
270
|
+
words.push(word);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return foundCommandName ? words : undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function parseLiteralShellWord(node: Node): string | undefined {
|
|
279
|
+
if ((node.type === "word" || node.type === "number") && node.namedChildCount === 0) {
|
|
280
|
+
return node.text;
|
|
281
|
+
}
|
|
282
|
+
if (node.type === "string") {
|
|
283
|
+
if (node.namedChildren.some((child) => child !== null && child.type !== "string_content")) {
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
return stripMatchingQuotes(node.text, '"');
|
|
287
|
+
}
|
|
288
|
+
if (node.type === "raw_string") {
|
|
289
|
+
return stripMatchingQuotes(node.text, "'");
|
|
290
|
+
}
|
|
291
|
+
if (node.type === "concatenation") {
|
|
292
|
+
let result = "";
|
|
293
|
+
for (const child of node.namedChildren) {
|
|
294
|
+
if (child === null) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const part = parseLiteralShellWord(child);
|
|
298
|
+
if (part === undefined) {
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
result += part;
|
|
302
|
+
}
|
|
303
|
+
return result.length > 0 ? result : undefined;
|
|
304
|
+
}
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function stripMatchingQuotes(text: string, quote: string): string | undefined {
|
|
309
|
+
return text.startsWith(quote) && text.endsWith(quote)
|
|
310
|
+
? text.slice(quote.length, -quote.length)
|
|
311
|
+
: undefined;
|
|
312
|
+
}
|