@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.
@@ -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 KNOWN_SURFACES = new Set([
38
- "bash",
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
- return "unknown";
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 safeCommandName(token: string | undefined): string {
122
- if (!token) return "unknown";
123
- const name = token.split(/[\\/]/).at(-1)?.toLowerCase() || "unknown";
124
- return /^[a-z0-9][a-z0-9+._-]{0,31}$/.test(name) ? name : "unknown";
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 knownSignature(tokens: readonly string[], index: number): string {
128
- const executable = safeCommandName(tokens[index]);
129
- const sub = safeCommandName(tokens[index + 1]);
130
- const third = safeCommandName(tokens[index + 2]);
131
- if (executable === "rtk") {
132
- if (sub === "git") return `rtk:git:${GIT_ACTIONS.has(third) ? third : "other"}`;
133
- return `rtk:${RTK_ACTIONS.has(sub) ? sub : "other"}`;
134
- }
135
- if (executable === "git") return `git:${GIT_ACTIONS.has(sub) ? sub : "other"}`;
136
- if (["npm", "pnpm", "yarn", "bun"].includes(executable)) {
137
- const action = sub === "run" ? third : sub;
138
- return `${executable}:${PACKAGE_ACTIONS.has(action) ? action : "custom-script"}`;
139
- }
140
- if (READ_ONLY_COMMANDS.has(executable) || MUTATING_COMMANDS.has(executable) ||
141
- NETWORK_COMMANDS.has(executable) || SHELL_COMMANDS.has(executable)) {
142
- return executable;
143
- }
144
- if (tokens[index]?.includes("/") || tokens[index]?.startsWith(".")) {
145
- return "<path-command>";
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 commandRisk(signature: string, executable: string, command: string): PolicyAuditRisk {
151
- if (/&&|\|\||;|\n|`|\$\(|(^|[^|])\|([^|]|$)/.test(command)) return "arbitrary_shell";
152
- if (["git:push", "git:send-pack", "rtk:git:push", "rtk:git:send-pack"].includes(signature)) return "network_mutation";
153
- if (/^(?:npm|pnpm|yarn|bun):(install|publish|add|upgrade|update)$/.test(signature)) {
154
- return signature.endsWith(":publish") ? "network_mutation" : "project_code_execution";
155
- }
156
- if (/^(?:npm|pnpm|yarn|bun):/.test(signature)) return "project_code_execution";
157
- if (NETWORK_COMMANDS.has(executable)) {
158
- return /(?:^|\s)(?:--upload-file|-T|-X\s*(?:POST|PUT|PATCH|DELETE)|--data|-d)(?:\s|=|$)/i.test(command)
159
- ? "network_mutation"
160
- : "network";
161
- }
162
- if (MUTATING_COMMANDS.has(executable) || ["git:add", "git:commit", "git:reset", "git:clean", "git:checkout", "git:restore", "git:rebase", "rtk:git:add", "rtk:git:commit"].includes(signature)) {
163
- return "workspace_mutation";
164
- }
165
- if (READ_ONLY_COMMANDS.has(executable) || /^(?:rtk:)?git:(?:status|diff|log|show|branch)$/.test(signature)) {
166
- return "read_only";
167
- }
168
- if (SHELL_COMMANDS.has(executable) || signature === "<path-command>") {
169
- return "arbitrary_shell";
170
- }
171
- return "unknown";
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: "<other-command>", bashCategory: "unknown", risk: "unknown", pathClass: "unknown", features: [] };
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 bashCategory = /^(?:rtk:)?git:/.test(signature)
188
- ? "version_control"
189
- : /^(?:npm|pnpm|yarn|bun):/.test(signature)
190
- ? "package_script"
191
- : NETWORK_COMMANDS.has(executable)
192
- ? "network"
193
- : READ_ONLY_COMMANDS.has(executable) || MUTATING_COMMANDS.has(executable)
194
- ? "filesystem"
195
- : "shell";
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.includes("=") ? token.slice(token.indexOf("=") + 1) : token.replace(/^@/, ""))
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: commandRisk(signature, executable, command),
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: [...new Set(features)].sort(),
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 rawSurface = typeof surfaceValue === "string" ? surfaceValue.trim().toLowerCase() : "";
211
- if (rawSurface === "bash" || rawSurface === "bash_escalated") {
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
- return { ...classified, surface: rawSurface || "bash" };
214
- }
215
- const surface = KNOWN_SURFACES.has(rawSurface) ? rawSurface : "custom_tool";
216
- const pathSurface = pathSurfaceInfo(surface);
217
- if (pathSurface || ["read", "write", "edit"].includes(surface)) {
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: surface === "custom_tool" ? "<custom-tool>" : `<${surface}>`,
208
+ surface: validSurface ?? "custom_tool",
209
+ signature: validSurface ?? "<custom-tool>",
235
210
  bashCategory: "not_bash",
236
- risk: "unknown",
237
- pathClass: "unknown",
211
+ risk,
212
+ pathClass,
238
213
  features: [],
214
+ ...(candidate ? { candidate } : {}),
239
215
  };
240
216
  }