@erichll/pi-auto-review 0.13.0 → 0.15.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/CHANGELOG.md +67 -0
- package/README.md +48 -10
- package/package.json +5 -5
- package/src/broker/broker.ts +10 -1
- package/src/broker/grants.ts +3 -1
- package/src/broker/overrides.ts +17 -9
- package/src/index.ts +130 -13
- package/src/policy-audit/classifier.ts +117 -141
- package/src/policy-audit/report.ts +221 -36
- package/src/policy-audit/store.ts +74 -14
- package/src/user-feedback.ts +62 -6
|
@@ -23,8 +23,18 @@ export type PolicyAuditCommandFeature =
|
|
|
23
23
|
| "env_prefix"
|
|
24
24
|
| "compound"
|
|
25
25
|
| "pipeline"
|
|
26
|
+
| "redirection"
|
|
26
27
|
| "relative_executable";
|
|
27
28
|
|
|
29
|
+
export type PolicyConfigCandidate = {
|
|
30
|
+
surface: string;
|
|
31
|
+
pattern?: string;
|
|
32
|
+
action: "allow";
|
|
33
|
+
safetyClass: "observed_bash_template" | "observed_surface";
|
|
34
|
+
eligible: boolean;
|
|
35
|
+
blocker?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
28
38
|
export type ClassifiedPermission = {
|
|
29
39
|
surface: string;
|
|
30
40
|
signature: string;
|
|
@@ -32,49 +42,12 @@ export type ClassifiedPermission = {
|
|
|
32
42
|
risk: PolicyAuditRisk;
|
|
33
43
|
pathClass: PolicyAuditPathClass;
|
|
34
44
|
features: readonly PolicyAuditCommandFeature[];
|
|
45
|
+
candidate?: PolicyConfigCandidate;
|
|
35
46
|
};
|
|
36
47
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
"bash_escalated",
|
|
40
|
-
"read",
|
|
41
|
-
"write",
|
|
42
|
-
"edit",
|
|
43
|
-
"grep",
|
|
44
|
-
"find",
|
|
45
|
-
"ls",
|
|
46
|
-
"path",
|
|
47
|
-
"external_directory",
|
|
48
|
-
"path_read",
|
|
49
|
-
"path_write",
|
|
50
|
-
"external_directory_read",
|
|
51
|
-
"external_directory_write",
|
|
52
|
-
"skill",
|
|
53
|
-
"mcp",
|
|
54
|
-
"network",
|
|
55
|
-
]);
|
|
56
|
-
|
|
48
|
+
const SAFE_SURFACE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
|
|
49
|
+
const SAFE_COMMAND_WORD = /^[A-Za-z0-9][A-Za-z0-9+._:-]{0,63}$/;
|
|
57
50
|
const SENSITIVE_PATH = /(?:^|[/\\])(?:\.ssh|\.aws|\.gnupg|\.kube|\.azure|\.npmrc|\.netrc|\.env(?:\.[^/\\]+)?|auth\.json|credentials?)(?:[/\\]|$)/i;
|
|
58
|
-
const READ_ONLY_COMMANDS = new Set([
|
|
59
|
-
"cat", "find", "grep", "head", "ls", "pwd", "rg", "sed", "sort",
|
|
60
|
-
"stat", "tail", "wc", "which",
|
|
61
|
-
]);
|
|
62
|
-
const MUTATING_COMMANDS = new Set([
|
|
63
|
-
"chmod", "chown", "cp", "install", "ln", "mkdir", "mv", "rm", "rmdir",
|
|
64
|
-
"touch", "truncate",
|
|
65
|
-
]);
|
|
66
|
-
const NETWORK_COMMANDS = new Set(["curl", "dig", "host", "nc", "ping", "ssh", "wget"]);
|
|
67
|
-
const SHELL_COMMANDS = new Set(["bash", "dash", "fish", "sh", "zsh"]);
|
|
68
|
-
const GIT_ACTIONS = new Set([
|
|
69
|
-
"add", "branch", "checkout", "clean", "clone", "commit", "diff", "fetch",
|
|
70
|
-
"log", "merge", "pull", "push", "rebase", "remote", "reset", "restore",
|
|
71
|
-
"send-pack", "show", "status", "switch", "tag",
|
|
72
|
-
]);
|
|
73
|
-
const RTK_ACTIONS = new Set(["diff", "find", "grep", "ls", "read", "rg", "smart", "wc"]);
|
|
74
|
-
const PACKAGE_ACTIONS = new Set([
|
|
75
|
-
"add", "build", "check", "coverage", "dev", "format", "install", "lint",
|
|
76
|
-
"publish", "start", "test", "typecheck", "uninstall", "update", "upgrade",
|
|
77
|
-
]);
|
|
78
51
|
|
|
79
52
|
function within(path: string, parent: string): boolean {
|
|
80
53
|
const rel = relative(parent, path);
|
|
@@ -90,12 +63,8 @@ export function classifyPath(
|
|
|
90
63
|
if (typeof value !== "string" || !value.trim()) return "unknown";
|
|
91
64
|
const raw = value.trim();
|
|
92
65
|
if (SENSITIVE_PATH.test(raw)) return "sensitive";
|
|
93
|
-
if (!raw.startsWith("/") && !raw.startsWith("~") && !raw.startsWith("."))
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
const expanded = raw === "~" || raw.startsWith("~/")
|
|
97
|
-
? resolve(home, raw.slice(2))
|
|
98
|
-
: resolve(cwd, raw);
|
|
66
|
+
if (!raw.startsWith("/") && !raw.startsWith("~") && !raw.startsWith(".")) return "unknown";
|
|
67
|
+
const expanded = raw === "~" || raw.startsWith("~/") ? resolve(home, raw.slice(2)) : resolve(cwd, raw);
|
|
99
68
|
if (within(expanded, resolve(cwd))) return "workspace";
|
|
100
69
|
if (within(expanded, resolve(temp))) return "temp";
|
|
101
70
|
if (within(expanded, resolve(home))) return "home";
|
|
@@ -103,138 +72,145 @@ export function classifyPath(
|
|
|
103
72
|
}
|
|
104
73
|
|
|
105
74
|
function shellTokens(command: string): string[] {
|
|
106
|
-
return command
|
|
107
|
-
.trim()
|
|
108
|
-
.split(/\s+/u)
|
|
109
|
-
.map((token) => token.replace(/^["']|["']$/g, ""));
|
|
75
|
+
return command.trim().split(/\s+/u);
|
|
110
76
|
}
|
|
111
77
|
|
|
112
78
|
function executableIndex(tokens: readonly string[]): number {
|
|
113
79
|
let index = 0;
|
|
114
80
|
if (tokens[index] === "env") index++;
|
|
115
|
-
while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(tokens[index]))
|
|
116
|
-
index++;
|
|
117
|
-
}
|
|
81
|
+
while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(tokens[index])) index++;
|
|
118
82
|
return index;
|
|
119
83
|
}
|
|
120
84
|
|
|
121
|
-
function
|
|
122
|
-
if (!token)
|
|
123
|
-
const
|
|
124
|
-
return
|
|
85
|
+
function safeExecutable(token: string | undefined): string | undefined {
|
|
86
|
+
if (!token || token.includes("/") || token.startsWith(".")) return undefined;
|
|
87
|
+
const unquoted = token.replace(/^["']|["']$/g, "");
|
|
88
|
+
return SAFE_COMMAND_WORD.test(unquoted) ? unquoted : undefined;
|
|
125
89
|
}
|
|
126
90
|
|
|
127
|
-
function
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
return "<other-command>";
|
|
91
|
+
function reliableShellText(command: string): boolean {
|
|
92
|
+
let quote = "";
|
|
93
|
+
let escaped = false;
|
|
94
|
+
for (const character of command) {
|
|
95
|
+
if (escaped) {
|
|
96
|
+
escaped = false;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (character === "\\" && quote !== "'") {
|
|
100
|
+
escaped = true;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (quote) {
|
|
104
|
+
if (character === quote) quote = "";
|
|
105
|
+
} else if (character === "'" || character === '"') {
|
|
106
|
+
quote = character;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return !quote && !escaped;
|
|
148
110
|
}
|
|
149
111
|
|
|
150
|
-
function
|
|
151
|
-
if (
|
|
152
|
-
if ([
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
112
|
+
function sanitizedTemplateToken(token: string, followsOption: boolean): string {
|
|
113
|
+
if (!token || /^['"]/.test(token) || /[/\\@]/.test(token) || /:\/\//.test(token)) return "*";
|
|
114
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) return "*";
|
|
115
|
+
if (token.startsWith("--") && token.includes("=")) {
|
|
116
|
+
const name = token.slice(0, token.indexOf("="));
|
|
117
|
+
return /^--[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name) ? `${name}=*` : "*";
|
|
118
|
+
}
|
|
119
|
+
if (/^--?[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(token)) return token;
|
|
120
|
+
if (followsOption) return "*";
|
|
121
|
+
return SAFE_COMMAND_WORD.test(token) ? token : "*";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function observedBashTemplate(tokens: readonly string[], index: number): string | undefined {
|
|
125
|
+
const executable = safeExecutable(tokens[index]);
|
|
126
|
+
if (!executable) return undefined;
|
|
127
|
+
const template = [executable];
|
|
128
|
+
let followsOption = false;
|
|
129
|
+
for (const rawToken of tokens.slice(index + 1)) {
|
|
130
|
+
const token = sanitizedTemplateToken(rawToken, followsOption);
|
|
131
|
+
if (template.at(-1) !== "*" || token !== "*") template.push(token);
|
|
132
|
+
followsOption = /^--?[A-Za-z0-9]/.test(rawToken) && !rawToken.includes("=");
|
|
133
|
+
}
|
|
134
|
+
return template.join(" ");
|
|
172
135
|
}
|
|
173
136
|
|
|
174
137
|
export function classifyBash(command: unknown, cwd: string): ClassifiedPermission {
|
|
175
138
|
if (typeof command !== "string" || !command.trim()) {
|
|
176
|
-
return { surface: "bash", signature: "<
|
|
139
|
+
return { surface: "bash", signature: "<command>", bashCategory: "unknown", risk: "unknown", pathClass: "unknown", features: [] };
|
|
177
140
|
}
|
|
178
141
|
const tokens = shellTokens(command);
|
|
179
142
|
const index = executableIndex(tokens);
|
|
180
|
-
const executable = safeCommandName(tokens[index]);
|
|
181
|
-
const signature = knownSignature(tokens, index);
|
|
182
143
|
const features: PolicyAuditCommandFeature[] = [];
|
|
183
144
|
if (index > 0) features.push("env_prefix");
|
|
184
145
|
if (/&&|\|\||;|\n|`|\$\(/.test(command)) features.push("compound");
|
|
185
146
|
if (/(^|[^|])\|([^|]|$)/.test(command)) features.push("pipeline");
|
|
147
|
+
if (/[<>]/.test(command)) features.push("redirection");
|
|
186
148
|
if (tokens[index]?.includes("/") || tokens[index]?.startsWith(".")) features.push("relative_executable");
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
149
|
+
const uniqueFeatures = [...new Set(features)].sort();
|
|
150
|
+
const structuralBoundary = command.search(/&&|\|\||[;|<>\n]|`|\$\(/);
|
|
151
|
+
const templateTokens = shellTokens(structuralBoundary >= 0 ? command.slice(0, structuralBoundary) : command);
|
|
152
|
+
const template = observedBashTemplate(templateTokens, executableIndex(templateTokens));
|
|
153
|
+
let blocker: string | undefined;
|
|
154
|
+
if (!reliableShellText(command) || !template) blocker = "command could not be templated reliably";
|
|
155
|
+
else if (uniqueFeatures.length > 0) blocker = `unsafe command feature: ${uniqueFeatures.join(", ")}`;
|
|
156
|
+
const candidate: PolicyConfigCandidate | undefined = template ? {
|
|
157
|
+
surface: "bash",
|
|
158
|
+
pattern: template,
|
|
159
|
+
action: "allow",
|
|
160
|
+
safetyClass: "observed_bash_template",
|
|
161
|
+
eligible: blocker === undefined,
|
|
162
|
+
...(blocker ? { blocker } : {}),
|
|
163
|
+
} : undefined;
|
|
196
164
|
const pathToken = tokens.slice(index + 1)
|
|
197
|
-
.map((token) => token.
|
|
165
|
+
.map((token) => token.replace(/^@/, ""))
|
|
198
166
|
.find((token) => token.startsWith("/") || token.startsWith("./") || token.startsWith("../") || token.startsWith("~/"));
|
|
199
167
|
return {
|
|
200
168
|
surface: "bash",
|
|
201
|
-
signature,
|
|
202
|
-
bashCategory,
|
|
203
|
-
risk:
|
|
169
|
+
signature: template ?? "<command>",
|
|
170
|
+
bashCategory: uniqueFeatures.length === 0 ? "simple" : "structured",
|
|
171
|
+
risk: uniqueFeatures.includes("compound") || uniqueFeatures.includes("pipeline")
|
|
172
|
+
? "arbitrary_shell"
|
|
173
|
+
: uniqueFeatures.includes("redirection") ? "workspace_mutation" : "unknown",
|
|
204
174
|
pathClass: classifyPath(pathToken, cwd),
|
|
205
|
-
features:
|
|
175
|
+
features: uniqueFeatures,
|
|
176
|
+
...(candidate ? { candidate } : {}),
|
|
206
177
|
};
|
|
207
178
|
}
|
|
208
179
|
|
|
209
180
|
export function classifyPermission(surfaceValue: unknown, value: unknown, cwd: string): ClassifiedPermission {
|
|
210
|
-
const
|
|
211
|
-
|
|
181
|
+
const suppliedSurface = typeof surfaceValue === "string" ? surfaceValue.trim() : "";
|
|
182
|
+
const normalizedSurface = suppliedSurface.toLowerCase();
|
|
183
|
+
if (normalizedSurface === "bash" || normalizedSurface === "bash_escalated") {
|
|
212
184
|
const classified = classifyBash(value, cwd);
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const pathClass = classifyPath(value, cwd);
|
|
219
|
-
const risk = pathSurface?.effect === "read" || surface === "read"
|
|
220
|
-
? "read_only"
|
|
221
|
-
: pathSurface?.effect === "write" || surface === "write" || surface === "edit"
|
|
222
|
-
? "workspace_mutation"
|
|
223
|
-
: "unknown";
|
|
224
|
-
return { surface, signature: `<${surface}>`, bashCategory: "not_bash", risk, pathClass, features: [] };
|
|
225
|
-
}
|
|
226
|
-
if (["grep", "find", "ls"].includes(surface)) {
|
|
227
|
-
return { surface, signature: `<${surface}>`, bashCategory: "not_bash", risk: "read_only", pathClass: "unknown", features: [] };
|
|
228
|
-
}
|
|
229
|
-
if (surface === "network") {
|
|
230
|
-
return { surface, signature: "<network>", bashCategory: "not_bash", risk: "network", pathClass: "unknown", features: [] };
|
|
185
|
+
if (normalizedSurface === "bash_escalated") {
|
|
186
|
+
const { candidate: _candidate, ...statisticsOnly } = classified;
|
|
187
|
+
return { ...statisticsOnly, surface: normalizedSurface };
|
|
188
|
+
}
|
|
189
|
+
return classified;
|
|
231
190
|
}
|
|
191
|
+
|
|
192
|
+
const validSurface = SAFE_SURFACE.test(suppliedSurface) ? suppliedSurface : undefined;
|
|
193
|
+
const pathSurface = pathSurfaceInfo(normalizedSurface);
|
|
194
|
+
const pathLike = pathSurface !== undefined || normalizedSurface === "read" || normalizedSurface === "write" || normalizedSurface === "edit";
|
|
195
|
+
const pathClass = pathLike ? classifyPath(value, cwd) : "unknown";
|
|
196
|
+
const risk = pathSurface?.effect === "read" || normalizedSurface === "read"
|
|
197
|
+
? "read_only"
|
|
198
|
+
: pathSurface?.effect === "write" || normalizedSurface === "write" || normalizedSurface === "edit"
|
|
199
|
+
? "workspace_mutation"
|
|
200
|
+
: "unknown";
|
|
201
|
+
const candidate: PolicyConfigCandidate | undefined = validSurface ? {
|
|
202
|
+
surface: validSurface,
|
|
203
|
+
action: "allow",
|
|
204
|
+
safetyClass: "observed_surface",
|
|
205
|
+
eligible: true,
|
|
206
|
+
} : undefined;
|
|
232
207
|
return {
|
|
233
|
-
surface,
|
|
234
|
-
signature:
|
|
208
|
+
surface: validSurface ?? "custom_tool",
|
|
209
|
+
signature: validSurface ?? "<custom-tool>",
|
|
235
210
|
bashCategory: "not_bash",
|
|
236
|
-
risk
|
|
237
|
-
pathClass
|
|
211
|
+
risk,
|
|
212
|
+
pathClass,
|
|
238
213
|
features: [],
|
|
214
|
+
...(candidate ? { candidate } : {}),
|
|
239
215
|
};
|
|
240
216
|
}
|