@blogic-cz/agent-tools 1.11.1 → 1.13.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/README.md +18 -43
- package/dist/credential-guard/bash.d.ts +3 -0
- package/dist/credential-guard/bash.d.ts.map +1 -0
- package/dist/credential-guard/claude-hook.d.ts.map +1 -1
- package/dist/credential-guard/engine.test.d.ts +5 -0
- package/dist/credential-guard/engine.test.d.ts.map +1 -0
- package/dist/credential-guard/index.d.ts +8 -35
- package/dist/credential-guard/index.d.ts.map +1 -1
- package/dist/credential-guard/ir.d.ts +150 -0
- package/dist/credential-guard/ir.d.ts.map +1 -0
- package/dist/credential-guard/literal-output.d.ts +2 -0
- package/dist/credential-guard/literal-output.d.ts.map +1 -0
- package/dist/credential-guard/passive-awk.d.ts +2 -0
- package/dist/credential-guard/passive-awk.d.ts.map +1 -0
- package/dist/credential-guard/passive-jq.d.ts.map +1 -1
- package/dist/credential-guard/passive-sed.d.ts +2 -0
- package/dist/credential-guard/passive-sed.d.ts.map +1 -0
- package/dist/credential-guard/passive.test.d.ts +2 -0
- package/dist/credential-guard/passive.test.d.ts.map +1 -0
- package/dist/credential-guard/paths.d.ts +14 -0
- package/dist/credential-guard/paths.d.ts.map +1 -0
- package/dist/credential-guard/policy.d.ts +47 -0
- package/dist/credential-guard/policy.d.ts.map +1 -0
- package/dist/credential-guard/powershell.d.ts +3 -0
- package/dist/credential-guard/powershell.d.ts.map +1 -0
- package/dist/credential-guard/registry.d.ts +89 -0
- package/dist/credential-guard/registry.d.ts.map +1 -0
- package/dist/credential-guard/review4.test.d.ts +2 -0
- package/dist/credential-guard/review4.test.d.ts.map +1 -0
- package/dist/credential-guard/review7.test.d.ts +2 -0
- package/dist/credential-guard/review7.test.d.ts.map +1 -0
- package/dist/credential-guard/shell-oracle.d.ts +2 -0
- package/dist/credential-guard/shell-oracle.d.ts.map +1 -0
- package/dist/credential-guard/xargs.d.ts +7 -0
- package/dist/credential-guard/xargs.d.ts.map +1 -0
- package/dist/gh-tool/pr/checkbox-markdown.d.ts +22 -0
- package/dist/gh-tool/pr/checkbox-markdown.d.ts.map +1 -0
- package/dist/gh-tool/pr/checkbox.d.ts +21 -0
- package/dist/gh-tool/pr/checkbox.d.ts.map +1 -0
- package/dist/gh-tool/pr/commands.d.ts +9 -0
- package/dist/gh-tool/pr/commands.d.ts.map +1 -1
- package/dist/gh-tool/pr/index.d.ts +1 -1
- package/dist/gh-tool/pr/index.d.ts.map +1 -1
- package/dist/gh-tool/read/api.d.ts +8 -0
- package/dist/gh-tool/read/api.d.ts.map +1 -1
- package/dist/gh-tool/read/cache.d.ts.map +1 -1
- package/dist/gh-tool/read/commands.d.ts +5 -0
- package/dist/gh-tool/read/commands.d.ts.map +1 -1
- package/dist/gh-tool/read/pulls.d.ts +1 -0
- package/dist/gh-tool/read/pulls.d.ts.map +1 -1
- package/dist/gh-tool/read/reads.d.ts +39 -0
- package/dist/gh-tool/read/reads.d.ts.map +1 -1
- package/dist/gh-tool/read/telemetry-command.d.ts +7 -0
- package/dist/gh-tool/read/telemetry-command.d.ts.map +1 -0
- package/dist/gh-tool/read/telemetry-store.d.ts +3 -0
- package/dist/gh-tool/read/telemetry-store.d.ts.map +1 -0
- package/dist/gh-tool/read/telemetry.d.ts +59 -0
- package/dist/gh-tool/read/telemetry.d.ts.map +1 -0
- package/dist/gh-tool/read/transport.d.ts +9 -0
- package/dist/gh-tool/read/transport.d.ts.map +1 -1
- package/dist/gh-tool/read/writes.d.ts +2 -1
- package/dist/gh-tool/read/writes.d.ts.map +1 -1
- package/package.json +2 -1
- package/schemas/agent-tools.schema.json +1 -1
- package/src/credential-guard/bash.ts +769 -0
- package/src/credential-guard/claude-hook.ts +14 -14
- package/src/credential-guard/engine.test.ts +2050 -0
- package/src/credential-guard/index.ts +285 -4268
- package/src/credential-guard/ir.ts +139 -0
- package/src/credential-guard/literal-output.ts +8 -0
- package/src/credential-guard/passive-awk.ts +239 -0
- package/src/credential-guard/passive-jq.ts +183 -24
- package/src/credential-guard/passive-sed.ts +78 -0
- package/src/credential-guard/passive.test.ts +141 -0
- package/src/credential-guard/paths.ts +108 -0
- package/src/credential-guard/policy.ts +1576 -0
- package/src/credential-guard/powershell.ts +220 -0
- package/src/credential-guard/registry.ts +4720 -0
- package/src/credential-guard/review4.test.ts +83 -0
- package/src/credential-guard/review7.test.ts +51 -0
- package/src/credential-guard/rg-types.json +389 -0
- package/src/credential-guard/shell-oracle.ts +187 -0
- package/src/credential-guard/xargs.ts +65 -0
- package/src/gh-tool/main.ts +32 -6
- package/src/gh-tool/pr/checkbox-markdown.ts +317 -0
- package/src/gh-tool/pr/checkbox.ts +113 -0
- package/src/gh-tool/pr/commands.ts +55 -2
- package/src/gh-tool/pr/core.ts +2 -2
- package/src/gh-tool/pr/index.ts +1 -0
- package/src/gh-tool/read/api.ts +73 -6
- package/src/gh-tool/read/cache.ts +13 -4
- package/src/gh-tool/read/commands.ts +17 -1
- package/src/gh-tool/read/pulls.ts +19 -0
- package/src/gh-tool/read/reads.ts +86 -2
- package/src/gh-tool/read/telemetry-command.ts +45 -0
- package/src/gh-tool/read/telemetry-store.ts +85 -0
- package/src/gh-tool/read/telemetry.ts +191 -0
- package/src/gh-tool/read/transport.ts +95 -6
- package/src/gh-tool/read/writes.ts +19 -2
- package/src/gh-tool/workflow.ts +2 -2
|
@@ -1,25 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
* Credential Guard
|
|
3
|
-
*
|
|
4
|
-
* Security patterns and functions for detecting sensitive files and secrets.
|
|
5
|
-
* Used by AI coding agent hooks/plugins.
|
|
6
|
-
*
|
|
7
|
-
* Security layers:
|
|
8
|
-
* 1. Path-based blocking (files that should never be read)
|
|
9
|
-
* 2. Content scanning (detect secrets in write operations)
|
|
10
|
-
* 3. Dangerous bash command detection
|
|
11
|
-
* 4. CLI tool blocking (must use wrapper tools)
|
|
12
|
-
*
|
|
13
|
-
* Note: This is a convenience layer. Real security should be enforced
|
|
14
|
-
* at infrastructure level (K8s RBAC, file permissions, etc.)
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { CliToolOverride, CredentialGuardConfig } from "#config/types";
|
|
1
|
+
import type { CredentialGuardConfig } from "#config/types";
|
|
18
2
|
import { redactSensitiveText } from "#shared/content-security";
|
|
19
3
|
import { SECRET_PATTERNS } from "#shared/credential-patterns";
|
|
20
4
|
import { posix } from "node:path";
|
|
21
5
|
import { lstatSync, realpathSync } from "node:fs";
|
|
22
|
-
import {
|
|
6
|
+
import { parseBash } from "./bash";
|
|
7
|
+
import { parsePowerShell } from "./powershell";
|
|
8
|
+
import { judge } from "./policy";
|
|
9
|
+
import type { Decision, PolicyContext } from "./policy";
|
|
10
|
+
import { buildRegistry, executableName } from "./registry";
|
|
11
|
+
import { DEFAULT_ALLOWED_PATH_PATTERNS, isSensitivePath, normalizePath } from "./paths";
|
|
23
12
|
|
|
24
13
|
export { findSecretMatches } from "#shared/credential-patterns";
|
|
25
14
|
export { redactSensitiveText } from "#shared/content-security";
|
|
@@ -38,12 +27,6 @@ export type HookOutput = {
|
|
|
38
27
|
args: Record<string, unknown>;
|
|
39
28
|
};
|
|
40
29
|
|
|
41
|
-
type BlockedCliTool = {
|
|
42
|
-
pattern: RegExp;
|
|
43
|
-
name: string;
|
|
44
|
-
wrapper: string;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
30
|
/** Object returned by createCredentialGuard */
|
|
48
31
|
export type CredentialGuard = {
|
|
49
32
|
handleToolExecuteBefore: (input: HookInput, output: HookOutput) => void;
|
|
@@ -56,82 +39,6 @@ export type CredentialGuard = {
|
|
|
56
39
|
detectSleepPolling: (command: string) => string | null;
|
|
57
40
|
};
|
|
58
41
|
|
|
59
|
-
// ============================================================================
|
|
60
|
-
// DEFAULT PATTERNS
|
|
61
|
-
// ============================================================================
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Paths that should NEVER be accessed by AI agents.
|
|
65
|
-
* These patterns match files containing credentials, keys, and secrets.
|
|
66
|
-
*/
|
|
67
|
-
const DEFAULT_BLOCKED_PATH_PATTERNS: RegExp[] = [
|
|
68
|
-
/\.env$/,
|
|
69
|
-
/\.env\.[^.]+$/, // .env.local, .env.production, etc.
|
|
70
|
-
/\.pem$/,
|
|
71
|
-
/\.key$/,
|
|
72
|
-
/\.p12$/,
|
|
73
|
-
/\.pfx$/,
|
|
74
|
-
/\/secrets?\//i,
|
|
75
|
-
/^secrets?\//i,
|
|
76
|
-
/\/credentials?\//i,
|
|
77
|
-
/^credentials?\//i,
|
|
78
|
-
/\.aws\//,
|
|
79
|
-
/\.ssh\//,
|
|
80
|
-
/\.kube\//,
|
|
81
|
-
// Every kubeconfig segment is protected except a final non-prefix Markdown prose basename.
|
|
82
|
-
/(?:^|\/)(?:kubeconfig[^/]*|(?![^/]+\.md(?![\s\S]))[^/]*kubeconfig[^/]*)(?:\/|$)/i,
|
|
83
|
-
/\.sentryclirc$/,
|
|
84
|
-
];
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Exceptions - files that match blocked patterns but are safe to access.
|
|
88
|
-
* Only truly generic defaults (no project-specific paths).
|
|
89
|
-
*/
|
|
90
|
-
const DEFAULT_ALLOWED_PATH_PATTERNS: RegExp[] = [
|
|
91
|
-
/\.env\.example$/,
|
|
92
|
-
/\.env\.template$/,
|
|
93
|
-
/\.env\.sample$/,
|
|
94
|
-
];
|
|
95
|
-
|
|
96
|
-
const PROCESS_ENVIRONMENT_PATH =
|
|
97
|
-
/^(?:\/|(?:\.\.\/)*)proc\/(?:self|thread-self|[0-9]+|\$\$|\$\{\$\})(?:\/task\/(?:[0-9]+|\$\$|\$\{\$\}))?\/environ$/;
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* CLI tools that must use wrapper tools for security and audit.
|
|
101
|
-
*/
|
|
102
|
-
const DEFAULT_BLOCKED_CLI_TOOLS: BlockedCliTool[] = [
|
|
103
|
-
{
|
|
104
|
-
pattern: /(?:^|[;&|]\s*)gh\s/,
|
|
105
|
-
name: "gh",
|
|
106
|
-
wrapper: "agent-tools-gh",
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
pattern: /(?:^|[;&|]\s*)kubectl\s/,
|
|
110
|
-
name: "kubectl",
|
|
111
|
-
wrapper: "agent-tools-k8s",
|
|
112
|
-
},
|
|
113
|
-
{
|
|
114
|
-
pattern: /(?:^|[;&|]\s*)psql\s/,
|
|
115
|
-
name: "psql",
|
|
116
|
-
wrapper: "agent-tools-db",
|
|
117
|
-
},
|
|
118
|
-
{
|
|
119
|
-
pattern: /(?:^|[;&|]\s*)az\s+(?:devops|pipelines|repos|boards|artifacts)\b/,
|
|
120
|
-
name: "az (Azure DevOps)",
|
|
121
|
-
wrapper: "agent-tools-azdo",
|
|
122
|
-
},
|
|
123
|
-
{
|
|
124
|
-
pattern: /(?:^|[;&|]\s*)az\s/,
|
|
125
|
-
name: "az",
|
|
126
|
-
wrapper: "agent-tools-az",
|
|
127
|
-
},
|
|
128
|
-
{
|
|
129
|
-
pattern: /(?:^|[;&|]\s*)curl\s.*dev\.azure\.com/,
|
|
130
|
-
name: "curl (Azure DevOps)",
|
|
131
|
-
wrapper: "agent-tools-azdo",
|
|
132
|
-
},
|
|
133
|
-
];
|
|
134
|
-
|
|
135
42
|
type PollingDetectionRule = {
|
|
136
43
|
pattern: RegExp;
|
|
137
44
|
suggestion: string;
|
|
@@ -160,4223 +67,333 @@ const DEFAULT_POLLING_DETECTION_RULES: PollingDetectionRule[] = [
|
|
|
160
67
|
},
|
|
161
68
|
];
|
|
162
69
|
|
|
163
|
-
/**
|
|
164
|
-
|
|
165
|
-
*/
|
|
166
|
-
const GH_ALLOWED_READONLY_SUBCOMMANDS = new Set([
|
|
167
|
-
"issue list",
|
|
168
|
-
"issue view",
|
|
169
|
-
"issue search",
|
|
170
|
-
"pr list",
|
|
171
|
-
"pr view",
|
|
172
|
-
"pr diff",
|
|
173
|
-
"pr checks",
|
|
174
|
-
"release list",
|
|
175
|
-
"release view",
|
|
176
|
-
"repo view",
|
|
177
|
-
"search issues",
|
|
178
|
-
"search prs",
|
|
179
|
-
"search repos",
|
|
180
|
-
]);
|
|
181
|
-
|
|
182
|
-
function isAllowedGhArgv(argv: string[]): boolean {
|
|
183
|
-
const subcommand = argv.slice(1, 3).join(" ");
|
|
184
|
-
return (
|
|
185
|
-
argv.some((arg, index) => (arg === "-R" || arg === "--repo") && Boolean(argv[index + 1])) &&
|
|
186
|
-
GH_ALLOWED_READONLY_SUBCOMMANDS.has(subcommand)
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function getLeadingLiteralAssignments(
|
|
191
|
-
command: string,
|
|
192
|
-
): Map<string, { value: string; end: number }> {
|
|
193
|
-
const assignments = new Map<string, { value: string; end: number }>();
|
|
194
|
-
let offset = 0;
|
|
195
|
-
while (offset === 0 || command[offset - 1] === ";" || command[offset - 1] === "\n") {
|
|
196
|
-
while (/[ \t\n]/.test(command[offset] ?? "")) offset++;
|
|
197
|
-
const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9_./-]+)/.exec(command.slice(offset));
|
|
198
|
-
if (!assignment) break;
|
|
199
|
-
const end = offset + assignment[0].length;
|
|
200
|
-
const next = command.slice(end).match(/^[ \t]*/)?.[0].length ?? 0;
|
|
201
|
-
const separator = command[end + next];
|
|
202
|
-
if (separator !== ";" && separator !== "\n") break;
|
|
203
|
-
assignments.set(assignment[1] ?? "", { value: assignment[2] ?? "", end });
|
|
204
|
-
offset = end + next + 1;
|
|
205
|
-
}
|
|
206
|
-
return assignments;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function isSafeHerdrTabCreate(argv: string[]): boolean {
|
|
210
|
-
if (argv[0] !== "herdr" || argv[1] !== "tab" || argv[2] !== "create") return false;
|
|
211
|
-
const seen = new Set<string>();
|
|
212
|
-
for (let index = 3; index < argv.length; index++) {
|
|
213
|
-
const arg = argv[index] ?? "";
|
|
214
|
-
const [option, ...attached] = arg.split("=");
|
|
215
|
-
if (option === "--focus" || option === "--no-focus") {
|
|
216
|
-
if (attached.length || seen.has("focus")) return false;
|
|
217
|
-
seen.add("focus");
|
|
218
|
-
continue;
|
|
219
|
-
}
|
|
220
|
-
if (!["--workspace", "--cwd", "--label"].includes(option ?? "") || seen.has(option ?? ""))
|
|
221
|
-
return false;
|
|
222
|
-
const value = attached.length ? attached.join("=") : argv[++index];
|
|
223
|
-
if (!value || (!attached.length && value.startsWith("--"))) return false;
|
|
224
|
-
seen.add(option ?? "");
|
|
225
|
-
}
|
|
226
|
-
return true;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function hasSafeLocalAssignmentCommands(
|
|
230
|
-
command: string,
|
|
231
|
-
assignments: Map<string, { value: string; end: number }>,
|
|
232
|
-
statusCaptures: Set<string>,
|
|
233
|
-
): boolean {
|
|
234
|
-
if (!assignments.size) return true;
|
|
235
|
-
const parsed = parseStaticShellCommands(command);
|
|
236
|
-
if (!parsed || typeof parsed === "string") return false;
|
|
237
|
-
return parsed.pipelines.flat().every((words) => {
|
|
238
|
-
if (words.length === 1 && statusCaptures.has(words[0] ?? "")) return true;
|
|
239
|
-
const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9_./-]+)$/.exec(words[0] ?? "");
|
|
240
|
-
if (assignment) {
|
|
241
|
-
return (
|
|
242
|
-
words.length === 1 &&
|
|
243
|
-
assignment[1] !== "IFS" &&
|
|
244
|
-
assignments.get(assignment[1] ?? "")?.value === assignment[2]
|
|
245
|
-
);
|
|
246
|
-
}
|
|
247
|
-
return isSafeLocalAssignmentCommand(words);
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function isSafeLocalAssignmentCommand(words: string[]): boolean {
|
|
252
|
-
const argv = unwrapStaticCommand(words);
|
|
253
|
-
if (isSafeHerdrTabCreate(argv)) return true;
|
|
254
|
-
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
255
|
-
if (name === "wc" && hasExecutionOption(argv.slice(1), ["files0-from"])) return false;
|
|
256
|
-
if (name === "cd") return isNavigationOperand(argv, "LOCALVALUE");
|
|
257
|
-
if (name === "git") {
|
|
258
|
-
let index = 1;
|
|
259
|
-
while (argv[index] === "-C" && argv[index + 1]) index += 2;
|
|
260
|
-
return (
|
|
261
|
-
["branch", "diff", "log", "rev-parse", "rev-list", "show", "status"].includes(
|
|
262
|
-
argv[index] ?? "",
|
|
263
|
-
) && !hasExecutionOption(argv.slice(index + 1), ["exec", "ext-diff", "textconv"])
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
if (name === "find") {
|
|
267
|
-
return !argv.slice(1).some((arg) => /^-(?:exec|ok|delete|fprint|fprintf)/.test(arg));
|
|
268
|
-
}
|
|
269
|
-
if (name === "bun") return argv[1] === "run" && ["db-tool", "gh-tool"].includes(argv[2] ?? "");
|
|
270
|
-
if (name === "sed") return isReadonlySedPrint(argv);
|
|
70
|
+
/** Extract file path from hook arguments. */
|
|
71
|
+
export function extractFilePath(args: Record<string, unknown>): string {
|
|
271
72
|
return (
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
(
|
|
275
|
-
|
|
73
|
+
(args.filePath as string) ||
|
|
74
|
+
(args.file_path as string) ||
|
|
75
|
+
(args.notebook_path as string) ||
|
|
76
|
+
(args.path as string) ||
|
|
77
|
+
""
|
|
276
78
|
);
|
|
277
79
|
}
|
|
278
80
|
|
|
279
|
-
|
|
81
|
+
/** Extract content from hook arguments. */
|
|
82
|
+
export function extractContent(args: Record<string, unknown>): string {
|
|
280
83
|
return (
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
84
|
+
(args.content as string) ||
|
|
85
|
+
(args.newString as string) ||
|
|
86
|
+
(args.new_string as string) ||
|
|
87
|
+
(args.new_source as string) ||
|
|
88
|
+
""
|
|
284
89
|
);
|
|
285
90
|
}
|
|
286
91
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
): boolean {
|
|
291
|
-
let heredoc = boundedHeredoc(command);
|
|
292
|
-
while (heredoc) {
|
|
293
|
-
if (!heredoc.closed) return true;
|
|
294
|
-
command = [heredoc.header, heredoc.following].join("\n");
|
|
295
|
-
heredoc = boundedHeredoc(command);
|
|
296
|
-
}
|
|
297
|
-
const parsed = parseStaticShellCommands(command, true);
|
|
298
|
-
if (!parsed || typeof parsed === "string") return false;
|
|
299
|
-
const assignments = getLeadingLiteralAssignments(command);
|
|
300
|
-
return parsed.redirects.some(({ target, dynamic }) => {
|
|
301
|
-
if (!dynamic) return isPathBlocked(target);
|
|
302
|
-
const materialized = target.replace(
|
|
303
|
-
/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
304
|
-
(match, braced: string, plain: string) => assignments.get(braced ?? plain)?.value ?? match,
|
|
305
|
-
);
|
|
306
|
-
return /[$`]/.test(materialized) || isPathBlocked(materialized);
|
|
307
|
-
});
|
|
92
|
+
/** Extract command from hook arguments. */
|
|
93
|
+
export function extractCommand(args: Record<string, unknown>): string {
|
|
94
|
+
return (args.command as string) || "";
|
|
308
95
|
}
|
|
309
96
|
|
|
310
97
|
// ============================================================================
|
|
311
|
-
//
|
|
98
|
+
// FACTORY
|
|
312
99
|
// ============================================================================
|
|
313
100
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
* literalOperandsOnly retains recognizable paths/commands on unsupported expansions;
|
|
320
|
-
* callers may use it to deny, never to establish a safe exception.
|
|
101
|
+
/**
|
|
102
|
+
* Create a credential guard with optional extra patterns merged into defaults.
|
|
103
|
+
*
|
|
104
|
+
* @param config - Optional overrides. Arrays are concatenated with defaults (not replaced).
|
|
105
|
+
* @returns Object with all guard functions bound to the merged pattern sets.
|
|
321
106
|
*/
|
|
322
|
-
function parseStaticShellCommands(
|
|
323
|
-
command: string,
|
|
324
|
-
literalOperandsOnly = false,
|
|
325
|
-
onUnterminatedQuote?: () => void,
|
|
326
|
-
):
|
|
327
|
-
| {
|
|
328
|
-
pipelines: string[][][];
|
|
329
|
-
redirects: { target: string; dynamic: boolean; pipeline: number }[];
|
|
330
|
-
pathnameExpansion: boolean;
|
|
331
|
-
}
|
|
332
|
-
| "brace-expansion"
|
|
333
|
-
| undefined {
|
|
334
|
-
const pipelines: string[][][] = [];
|
|
335
|
-
const redirects: { target: string; dynamic: boolean; pipeline: number }[] = [];
|
|
336
|
-
let redirect = false;
|
|
337
|
-
let dynamic = false;
|
|
338
|
-
let commands: string[][] = [];
|
|
339
|
-
let argv: string[] = [];
|
|
340
|
-
let word = "";
|
|
341
|
-
let started = false;
|
|
342
|
-
let quote: "'" | '"' | undefined;
|
|
343
|
-
let pathnameExpansion = false;
|
|
344
|
-
|
|
345
|
-
const finishWord = () => {
|
|
346
|
-
if (started) {
|
|
347
|
-
if (redirect) redirects.push({ target: word, dynamic, pipeline: pipelines.length });
|
|
348
|
-
else argv.push(word);
|
|
349
|
-
redirect = false;
|
|
350
|
-
}
|
|
351
|
-
word = "";
|
|
352
|
-
started = false;
|
|
353
|
-
dynamic = false;
|
|
354
|
-
};
|
|
355
|
-
const finishCommand = () => {
|
|
356
|
-
finishWord();
|
|
357
|
-
if (argv.length) commands.push(argv);
|
|
358
|
-
argv = [];
|
|
359
|
-
};
|
|
360
|
-
const finishPipeline = () => {
|
|
361
|
-
finishCommand();
|
|
362
|
-
if (commands.length) pipelines.push(commands);
|
|
363
|
-
commands = [];
|
|
364
|
-
};
|
|
365
|
-
|
|
366
|
-
for (let i = 0; i < command.length; i++) {
|
|
367
|
-
const char = command.charAt(i);
|
|
368
|
-
if (quote === "'") {
|
|
369
|
-
if (char === quote) quote = undefined;
|
|
370
|
-
else word += char;
|
|
371
|
-
} else if (char === "\\") {
|
|
372
|
-
const next = command[++i];
|
|
373
|
-
if (next === undefined || next === "\n" || next === "\r") return undefined;
|
|
374
|
-
if (quote === '"' && !/[\\$`"]/.test(next)) word += "\\";
|
|
375
|
-
word += next;
|
|
376
|
-
started = true;
|
|
377
|
-
} else if (char === "$" || char === "`") {
|
|
378
|
-
if (!literalOperandsOnly) return undefined;
|
|
379
|
-
dynamic = true;
|
|
380
|
-
word += char;
|
|
381
|
-
started = true;
|
|
382
|
-
} else if (quote === '"') {
|
|
383
|
-
if (char === quote) quote = undefined;
|
|
384
|
-
else word += char;
|
|
385
|
-
} else if (char === "'" || char === '"') {
|
|
386
|
-
quote = char;
|
|
387
|
-
started = true;
|
|
388
|
-
} else if (
|
|
389
|
-
!literalOperandsOnly &&
|
|
390
|
-
char === "{" &&
|
|
391
|
-
/^\{[^{}]*(?:,|\.\.)[^{}]*\}/.test(command.slice(i))
|
|
392
|
-
) {
|
|
393
|
-
return "brace-expansion";
|
|
394
|
-
} else if (char === ">" || char === "<") {
|
|
395
|
-
if (char === "<" && command[i + 1] === "<") {
|
|
396
|
-
if (literalOperandsOnly) break;
|
|
397
|
-
return undefined;
|
|
398
|
-
}
|
|
399
|
-
if (/^\d+$/.test(word)) {
|
|
400
|
-
word = "";
|
|
401
|
-
started = false;
|
|
402
|
-
} else {
|
|
403
|
-
finishWord();
|
|
404
|
-
}
|
|
405
|
-
if (redirect) return undefined;
|
|
406
|
-
if (command[i + 1] === char) i++;
|
|
407
|
-
if (command[i + 1] === "&" && /[0-9-]/.test(command[i + 2] ?? "")) {
|
|
408
|
-
i += 2;
|
|
409
|
-
while (/\d/.test(command[i + 1] ?? "")) i++;
|
|
410
|
-
} else {
|
|
411
|
-
redirect = true;
|
|
412
|
-
}
|
|
413
|
-
} else if (char === "#" && !started) {
|
|
414
|
-
while (i + 1 < command.length && command[i + 1] !== "\n") i++;
|
|
415
|
-
} else if (/[()]/.test(char)) {
|
|
416
|
-
if (!literalOperandsOnly) return undefined;
|
|
417
|
-
finishWord();
|
|
418
|
-
} else if (char === "|" && command[i - 1] !== "|" && command[i + 1] !== "|") {
|
|
419
|
-
finishCommand();
|
|
420
|
-
if (command[i + 1] === "&") i++;
|
|
421
|
-
} else if (/[;&|\r\n]/.test(char)) {
|
|
422
|
-
finishPipeline();
|
|
423
|
-
} else if (/\s/.test(char)) {
|
|
424
|
-
finishWord();
|
|
425
|
-
} else {
|
|
426
|
-
if (/[*?[\]]/.test(char) || (char === "~" && !started)) pathnameExpansion = true;
|
|
427
|
-
word += char;
|
|
428
|
-
started = true;
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
if (quote && !literalOperandsOnly) {
|
|
433
|
-
onUnterminatedQuote?.();
|
|
434
|
-
return undefined;
|
|
435
|
-
}
|
|
436
|
-
finishPipeline();
|
|
437
|
-
if (redirect) return undefined;
|
|
438
|
-
return { pipelines, redirects, pathnameExpansion };
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/** A lexical binding proof, preserving shell quotes and rejecting every other expansion. */
|
|
442
|
-
function literalBindingText(
|
|
443
|
-
command: string,
|
|
444
|
-
name: string,
|
|
445
|
-
value: string,
|
|
446
|
-
allowBrackets = false,
|
|
447
|
-
): { text: string; syntax: string } | undefined {
|
|
448
|
-
let text = "";
|
|
449
|
-
let syntax = "";
|
|
450
|
-
let quote: "'" | '"' | undefined;
|
|
451
|
-
for (let i = 0; i < command.length; i++) {
|
|
452
|
-
const char = command[i] ?? "";
|
|
453
|
-
if (!quote && /[^\S \t\n]/.test(char)) return undefined;
|
|
454
|
-
if (char === "\\" && quote !== "'") {
|
|
455
|
-
const next = command[++i];
|
|
456
|
-
if (next === undefined || /[\r\n]/.test(next)) return undefined;
|
|
457
|
-
text += char + next;
|
|
458
|
-
syntax += " ";
|
|
459
|
-
} else if (quote === "'") {
|
|
460
|
-
text += char;
|
|
461
|
-
syntax += " ";
|
|
462
|
-
if (char === quote) quote = undefined;
|
|
463
|
-
} else if (char === "'" && !quote) {
|
|
464
|
-
quote = char;
|
|
465
|
-
text += char;
|
|
466
|
-
syntax += " ";
|
|
467
|
-
} else if (char === '"') {
|
|
468
|
-
quote = quote ? undefined : char;
|
|
469
|
-
text += char;
|
|
470
|
-
syntax += " ";
|
|
471
|
-
} else if (char === "$") {
|
|
472
|
-
const match = /^\$\{([A-Za-z_]\w*)\}|^\$([A-Za-z_]\w*)/.exec(command.slice(i));
|
|
473
|
-
if (!match || (match[1] ?? match[2]) !== name) return undefined;
|
|
474
|
-
text += value;
|
|
475
|
-
syntax += quote ? " ".repeat(value.length) : value;
|
|
476
|
-
i += match[0].length - 1;
|
|
477
|
-
} else {
|
|
478
|
-
if (
|
|
479
|
-
char === "`" ||
|
|
480
|
-
(!quote && (/[(){}*?#]/.test(char) || (!allowBrackets && (char === "[" || char === "]"))))
|
|
481
|
-
)
|
|
482
|
-
return undefined;
|
|
483
|
-
text += char;
|
|
484
|
-
syntax += quote ? " " : char;
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
return quote ? undefined : { text, syntax };
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
/** These single-letter binders cannot name shell lookup, splitting or generated variables. */
|
|
491
|
-
function isLiteralBinder(name: string): boolean {
|
|
492
|
-
return /^(?:[a-z]|T)$/.test(name);
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
function unwrapProofCommand(words: string[], marker: string): string[] | undefined {
|
|
496
|
-
let index = 0;
|
|
497
|
-
while (words[index] === "rtk" || words[index] === "command") {
|
|
498
|
-
if (words[index++] === "rtk") {
|
|
499
|
-
if (words[index] === "proxy") index++;
|
|
500
|
-
// RTK forwards this verified external binary without changing its argv roles.
|
|
501
|
-
else if (words[index] !== "herdr") return undefined;
|
|
502
|
-
} else if (words[index] === "--") index++;
|
|
503
|
-
}
|
|
504
|
-
if (words.slice(0, index + 1).some((word) => word.includes(marker))) return undefined;
|
|
505
|
-
const argv = words.slice(index);
|
|
506
|
-
if (!argv.length || /^[A-Za-z_]\w*=/.test(argv[0] ?? "")) return undefined;
|
|
507
|
-
return argv;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
function isLiteralLoopCommand(words: string[], marker: string, value: string): boolean {
|
|
511
|
-
const marked = unwrapProofCommand(words, marker);
|
|
512
|
-
if (!marked) return false;
|
|
513
|
-
const argv = marked.map((word) => word.replaceAll(marker, () => value));
|
|
514
|
-
const name = argv[0];
|
|
515
|
-
const uses = marked.flatMap((word, index) => (word.includes(marker) ? [index] : []));
|
|
516
|
-
const jqProgram = passiveJqProgramIndex(argv);
|
|
517
|
-
if (jqProgram !== undefined)
|
|
518
|
-
return uses.every(
|
|
519
|
-
(index) => index > jqProgram && /^[A-Za-z0-9_./][A-Za-z0-9_./-]*$/.test(argv[index] ?? ""),
|
|
520
|
-
);
|
|
521
|
-
if (name === "cat")
|
|
522
|
-
return (
|
|
523
|
-
argv.slice(1).every((word) => word === "--" || !word.startsWith("-")) &&
|
|
524
|
-
uses.every((index) => index > 0 && !argv[index]?.startsWith("-"))
|
|
525
|
-
);
|
|
526
|
-
if (name === "echo") return uses.every((index) => index > 0 && !argv[index]?.startsWith("-"));
|
|
527
|
-
if (name === "printf") {
|
|
528
|
-
const format = passivePrintfFormatIndex(argv);
|
|
529
|
-
return format !== undefined && uses.every((index) => index > format);
|
|
530
|
-
}
|
|
531
|
-
if (name === "herdr") return isLiteralHerdrRead(argv) && uses.every((index) => index === 3);
|
|
532
|
-
if (name === "bun") {
|
|
533
|
-
if (argv.slice(0, 4).join(" ") !== "bun run gh-tool pr") return false;
|
|
534
|
-
if (!["checks", "checks-failed", "info", "review-triage"].includes(argv[4] ?? "")) return false;
|
|
535
|
-
let selector = -1;
|
|
536
|
-
for (let index = 5; index < argv.length; index += 2) {
|
|
537
|
-
if (argv[index] === "--repo" && /^[A-Za-z0-9_./-]+$/.test(argv[index + 1] ?? "")) continue;
|
|
538
|
-
if (argv[index] !== "--pr" || selector !== -1 || !/^\d+$/.test(argv[index + 1] ?? ""))
|
|
539
|
-
return false;
|
|
540
|
-
selector = index + 1;
|
|
541
|
-
}
|
|
542
|
-
return selector !== -1 && uses.every((index) => index === selector && marked[index] === marker);
|
|
543
|
-
}
|
|
544
|
-
if (name === "git") {
|
|
545
|
-
const boundary = argv.indexOf("--", 2);
|
|
546
|
-
return (
|
|
547
|
-
argv[1] === "diff" &&
|
|
548
|
-
(boundary === 2 || boundary === 3) &&
|
|
549
|
-
argv
|
|
550
|
-
.slice(2, boundary)
|
|
551
|
-
.every((revision) => /^[A-Za-z0-9_][A-Za-z0-9_./-]*$/.test(revision)) &&
|
|
552
|
-
argv.length > boundary + 1 &&
|
|
553
|
-
argv.slice(boundary + 1).every((file) => /^[A-Za-z0-9_./][A-Za-z0-9_./-]*$/.test(file)) &&
|
|
554
|
-
uses.every(
|
|
555
|
-
(index) => index > boundary && /^[A-Za-z0-9_./][A-Za-z0-9_./-]*$/.test(argv[index] ?? ""),
|
|
556
|
-
)
|
|
557
|
-
);
|
|
558
|
-
}
|
|
559
|
-
return (
|
|
560
|
-
uses.length === 0 &&
|
|
561
|
-
((["grep", "head", "tail"].includes(name ?? "") && isPassiveTextCommand(argv)) ||
|
|
562
|
-
isLiteralStdinJsonDisplay(argv))
|
|
563
|
-
);
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
function isLiteralHerdrRead(argv: string[]): boolean {
|
|
567
|
-
return (
|
|
568
|
-
argv[0] === "herdr" &&
|
|
569
|
-
!argv[3]?.startsWith("-") &&
|
|
570
|
-
/^[A-Za-z0-9_.:-]+$/.test(argv[3] ?? "") &&
|
|
571
|
-
((argv.length === 4 && argv[1] === "agent" && argv[2] === "get") ||
|
|
572
|
-
(argv.length === 8 &&
|
|
573
|
-
["pane", "agent"].includes(argv[1] ?? "") &&
|
|
574
|
-
argv[2] === "read" &&
|
|
575
|
-
argv[4] === "--source" &&
|
|
576
|
-
argv[5] === "recent-unwrapped" &&
|
|
577
|
-
argv[6] === "--lines" &&
|
|
578
|
-
/^[1-9]\d*$/.test(argv[7] ?? "")))
|
|
579
|
-
);
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
/** Conventional platform shell names and numeric versions; not arbitrary renamed executables. */
|
|
583
|
-
function isShellWord(word: string): boolean {
|
|
584
|
-
return /^(?:sh|bash|rbash|zsh|dash|ash|ksh|rksh|csh|tcsh|fish)(?:[.-]?[0-9]+(?:[.][0-9]+)*)?$/.test(
|
|
585
|
-
word.split("/").at(-1) ?? "",
|
|
586
|
-
);
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/** Shell-named operands are data only under an existing non-executing argument role. */
|
|
590
|
-
function hasProvedShellDataOperands(argv: string[]): boolean {
|
|
591
|
-
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
592
|
-
if (isPassiveTextCommand(argv) || isLiteralHerdrRead(argv) || isStaticNavigationCommand(argv))
|
|
593
|
-
return true;
|
|
594
|
-
if (["cat", "ls", "uniq", "cut"].includes(name)) return true;
|
|
595
|
-
if (["find", "wc", "sort"].includes(name)) return isProvedCwdSubprocessRole(argv);
|
|
596
|
-
return name === "test" && argv.length === 3 && argv[1] === "-e" && !/[$`]/.test(argv[2] ?? "");
|
|
597
|
-
}
|
|
598
107
|
|
|
599
|
-
const
|
|
600
|
-
"
|
|
601
|
-
"
|
|
602
|
-
|
|
603
|
-
"
|
|
604
|
-
|
|
605
|
-
"
|
|
606
|
-
"
|
|
607
|
-
"
|
|
608
|
-
"
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
)
|
|
622
|
-
return undefined;
|
|
623
|
-
for (const words of commands) {
|
|
624
|
-
const argv = unwrapStaticCommand(words);
|
|
625
|
-
if (
|
|
626
|
-
!isShellWord(argv[0] ?? "") &&
|
|
627
|
-
argv.some(
|
|
628
|
-
(word, index) =>
|
|
629
|
-
index !== inlineProgramIndex(argv) &&
|
|
630
|
-
(isShellWord(word) ||
|
|
631
|
-
(/^--?[A-Za-z0-9-]+=/.test(word) && isShellWord(word.slice(word.indexOf("=") + 1)))),
|
|
632
|
-
) &&
|
|
633
|
-
!hasProvedShellDataOperands(argv) &&
|
|
634
|
-
!(isLiteralHerdrPrompt(argv) && isStaticHerdrPrompt(command))
|
|
635
|
-
)
|
|
636
|
-
return undefined;
|
|
637
|
-
}
|
|
638
|
-
const shells = commands.filter((words) => isShellWord(unwrapStaticCommand(words)[0] ?? ""));
|
|
639
|
-
if (!shells.length) return [];
|
|
640
|
-
if (
|
|
641
|
-
!parsed ||
|
|
642
|
-
typeof parsed === "string" ||
|
|
643
|
-
!literalBindingText(command, "", "") ||
|
|
644
|
-
new TextEncoder().encode(command).byteLength > 65_536 ||
|
|
645
|
-
commands.some((words) => unwrapStaticCommand(words)[0]?.split("/").at(-1) === "cd")
|
|
646
|
-
)
|
|
647
|
-
return undefined;
|
|
648
|
-
const bodies: string[] = [];
|
|
649
|
-
for (const words of shells) {
|
|
650
|
-
const argv = unwrapProofCommand(words, "\0");
|
|
651
|
-
if (!argv || !SHELL_IDENTITIES.has(argv[0] ?? "")) return undefined;
|
|
652
|
-
const name = argv[0]?.split("/").at(-1);
|
|
653
|
-
if (argv.length === 2 && ["--version", "--help"].includes(argv[1] ?? "")) continue;
|
|
654
|
-
const index = name === "zsh" && argv[1] === "-f" ? 2 : 1;
|
|
655
|
-
if ((name === "zsh" && index !== 2) || argv[index] !== "-c" || argv.length !== index + 2)
|
|
656
|
-
return undefined;
|
|
657
|
-
bodies.push(argv[index + 1] ?? "");
|
|
658
|
-
}
|
|
659
|
-
return bodies;
|
|
660
|
-
}
|
|
108
|
+
const recovery: Readonly<Record<string, string>> = {
|
|
109
|
+
unparseable: "Use complete literal shell syntax within the supported subset.",
|
|
110
|
+
"ambient-expansion":
|
|
111
|
+
"Use a local binding or an exact name in credentialGuard.allowedEnvironmentVariables.",
|
|
112
|
+
"secret-source":
|
|
113
|
+
"Use a bounded wrapper operation with filtered output. Do not dump credentials or the environment.",
|
|
114
|
+
"sensitive-path": "Use a nonsensitive example or template, or a bounded wrapper operation.",
|
|
115
|
+
"opaque-path": "Use literal nonsensitive file paths.",
|
|
116
|
+
"opaque-executable": "Use a literal executable name.",
|
|
117
|
+
"opaque-code": "Use a supported literal shell body or a repository-owned bounded operation.",
|
|
118
|
+
"opaque-env-name": "Use an exact approved environment variable name.",
|
|
119
|
+
"no-analyzer":
|
|
120
|
+
"Use a repository-owned bounded operation. The guard cannot inspect this code form.",
|
|
121
|
+
"wrapper-required": "Use the configured wrapper. Check its help and the project README.",
|
|
122
|
+
"configured-pattern": "Use the bounded operation allowed by the project configuration.",
|
|
123
|
+
"startup-mutation":
|
|
124
|
+
"Run the operation without changing executable lookup or startup configuration.",
|
|
125
|
+
"unsupported-option": "Use supported options with complete literal values.",
|
|
126
|
+
"analysis-limit": "Split the operation into smaller bounded commands.",
|
|
127
|
+
"copy-destination":
|
|
128
|
+
"Use a literal destination below a configured chainCopyDestinations directory, without symlinks.",
|
|
129
|
+
};
|
|
661
130
|
|
|
662
|
-
function
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
onProvedPredicate: () => void,
|
|
131
|
+
function proveCopyDestination(
|
|
132
|
+
target: string,
|
|
133
|
+
source: string,
|
|
134
|
+
directories: readonly string[],
|
|
667
135
|
): boolean {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
let proved = false;
|
|
136
|
+
try {
|
|
137
|
+
const literalPath = (path: string) =>
|
|
138
|
+
/^(?:\/|[A-Za-z]:\/)[A-Za-z0-9_./ -]+$/.test(path) &&
|
|
139
|
+
!path.split("/").some((part) => part === "." || part === "..");
|
|
673
140
|
if (
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
!proved
|
|
141
|
+
!literalPath(target) ||
|
|
142
|
+
!/^(?:~\/)?[A-Za-z0-9_./-]+$/.test(source) ||
|
|
143
|
+
source.startsWith("-")
|
|
678
144
|
)
|
|
679
145
|
return false;
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
);
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
)
|
|
698
|
-
|
|
146
|
+
const targetPath = target.replace(/\/+$/, "");
|
|
147
|
+
const targetStat = lstatSync(targetPath, { throwIfNoEntry: false });
|
|
148
|
+
if (target.endsWith("/") && !targetStat?.isDirectory()) return false;
|
|
149
|
+
const destination = targetStat?.isDirectory()
|
|
150
|
+
? posix.join(targetPath, posix.basename(source))
|
|
151
|
+
: targetPath;
|
|
152
|
+
for (const directory of directories) {
|
|
153
|
+
if (!literalPath(directory)) continue;
|
|
154
|
+
const root = directory.replace(/\/+$/, "");
|
|
155
|
+
if (destination !== root && !destination.startsWith(root + "/")) continue;
|
|
156
|
+
if (!lstatSync(root).isDirectory() || realpathSync(root).replace(/\\/g, "/") !== root)
|
|
157
|
+
return false;
|
|
158
|
+
const parts = destination.slice(root.length).split("/").filter(Boolean);
|
|
159
|
+
let current = root;
|
|
160
|
+
for (const [index, part] of parts.entries()) {
|
|
161
|
+
current = posix.join(current, part);
|
|
162
|
+
const stat = lstatSync(current, { throwIfNoEntry: false });
|
|
163
|
+
if (!stat) return index === parts.length - 1;
|
|
164
|
+
if (stat.isSymbolicLink() || (index < parts.length - 1 && !stat.isDirectory()))
|
|
165
|
+
return false;
|
|
166
|
+
if (realpathSync(current).replace(/\\/g, "/") !== current) return false;
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
699
169
|
}
|
|
700
|
-
|
|
701
|
-
isProvedCwdSubprocessRole(unwrapStaticCommand(argv)) ||
|
|
702
|
-
(name === "bun" && isSafeLocalAssignmentCommand(argv) && !isUnsupportedInlineRuntime(argv)) ||
|
|
703
|
-
isStaticNavigationCommand(argv) ||
|
|
704
|
-
isLiteralHerdrRead(argv) ||
|
|
705
|
-
(isLiteralHerdrPrompt(argv) && isStaticHerdrPrompt(materialized)) ||
|
|
706
|
-
(argv.length === 1 && ["true", "false", "pwd"].includes(name))
|
|
707
|
-
);
|
|
708
|
-
});
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
/** A complete stdin-only JSON projection, with no suffix, imports or executable input. */
|
|
712
|
-
function isLiteralStdinJsonDisplay(argv: string[]): boolean {
|
|
713
|
-
if (argv.length !== 3 || !["python", "python3"].includes(argv[0] ?? "") || argv[1] !== "-c")
|
|
170
|
+
} catch {
|
|
714
171
|
return false;
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
const parts = programParts(program, true);
|
|
718
|
-
if (!parts || parts.literals.join("\0") !== "result\0text\0\0result") return false;
|
|
719
|
-
// ponytail: one JSON projection with a single-letter local; extend only with another complete grammar.
|
|
720
|
-
const code = parts.code.replace(/[ \t]+/g, " ").trim();
|
|
721
|
-
return /^import json\s*,\s*sys\s*(?:;|\r?\n)\s*([a-z])\s*=\s*json\s*\.\s*load\s*\(\s*sys\s*\.\s*stdin\s*\)\s*(?:;|\r?\n)\s*print\s*\(\s*\1\s*\[\s*STRING\s*\]\s*\.\s*get\s*\(\s*STRING\s*,\s*STRING\s*\)\s+if isinstance\s*\(\s*\1\s*\.\s*get\s*\(\s*STRING\s*\)\s*,\s*dict\s*\)\s+else \1\s*\)\s*;?$/.test(
|
|
722
|
-
code,
|
|
723
|
-
);
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
724
174
|
}
|
|
725
175
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
/** Bounded observed shell forms only; failure leaves the original command under normal policy. */
|
|
729
|
-
function materializeLiteralShell(command: string): LiteralShellProof | undefined {
|
|
730
|
-
if (new TextEncoder().encode(command).byteLength > 65_536) return undefined;
|
|
731
|
-
const marker = "GUARDLITERALBINDING";
|
|
732
|
-
if (command.includes(marker)) return undefined;
|
|
733
|
-
let script = command.replace(/^[ \t\n]+|[ \t\n]+$/g, "");
|
|
734
|
-
// Quote removal is safe only for this literal ASCII path value, never a shell expansion.
|
|
735
|
-
script = script.replace(
|
|
736
|
-
/^([A-Za-z])=(["'])([A-Za-z0-9_./][A-Za-z0-9_./-]*)\2(?=[ \t]*(?:;|\n|&&))/,
|
|
737
|
-
"$1=$3",
|
|
738
|
-
);
|
|
739
|
-
const wrapped = parseStaticShellCommands(script);
|
|
176
|
+
export function createCredentialGuard(config?: CredentialGuardConfig): CredentialGuard {
|
|
177
|
+
const names = config?.allowedEnvironmentVariables;
|
|
740
178
|
if (
|
|
741
|
-
|
|
742
|
-
typeof
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
)
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
179
|
+
names !== undefined &&
|
|
180
|
+
(!Array.isArray(names) || names.some((name) => typeof name !== "string"))
|
|
181
|
+
)
|
|
182
|
+
throw new Error("allowedEnvironmentVariables must be an array of strings");
|
|
183
|
+
const approvedNames = new Set(names ?? []);
|
|
184
|
+
for (const name of approvedNames)
|
|
185
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
|
|
186
|
+
throw new Error(`Invalid allowed environment variable name: ${JSON.stringify(name)}`);
|
|
187
|
+
const context: PolicyContext = {
|
|
188
|
+
home: process.env.HOME ?? process.env.USERPROFILE ?? "~",
|
|
189
|
+
get cwd() {
|
|
190
|
+
return process.cwd();
|
|
191
|
+
},
|
|
192
|
+
approvedNames,
|
|
193
|
+
registry: buildRegistry(config?.additionalBlockedCliTools),
|
|
194
|
+
additionalBlockedPaths: config?.additionalBlockedPaths?.map((p) => new RegExp(p)),
|
|
195
|
+
additionalAllowedPaths: config?.additionalAllowedPaths?.map((p) => new RegExp(p)),
|
|
196
|
+
dangerousPatterns: config?.additionalDangerousBashPatterns?.map((p) => new RegExp(p)),
|
|
197
|
+
chainCopyDestinations: config?.chainCopyDestinations,
|
|
198
|
+
proveCopyDestination: (path, source) =>
|
|
199
|
+
proveCopyDestination(path, source, config?.chainCopyDestinations ?? []),
|
|
200
|
+
};
|
|
201
|
+
const decision = (command: string, powershell = false): Decision =>
|
|
202
|
+
judge(powershell ? parsePowerShell(command) : parseBash(command), context);
|
|
203
|
+
const isPathBlocked = (path: string): boolean => isSensitivePath(path, context);
|
|
204
|
+
const isPathAllowed = (path: string): boolean =>
|
|
205
|
+
!isPathBlocked(path) &&
|
|
206
|
+
[...DEFAULT_ALLOWED_PATH_PATTERNS, ...(context.additionalAllowedPaths ?? [])].some((p) =>
|
|
207
|
+
new RegExp(p.source, p.flags.replace(/[gy]/g, "")).test(
|
|
208
|
+
normalizePath(path, context.home, context.cwd),
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
function detectSecrets(content: string): { name: string; match: string } | null {
|
|
212
|
+
for (const { name, pattern } of SECRET_PATTERNS) {
|
|
213
|
+
const match = content.match(pattern);
|
|
214
|
+
if (match)
|
|
215
|
+
return {
|
|
216
|
+
name,
|
|
217
|
+
match: match[0].substring(0, 8) + "..." + match[0].substring(match[0].length - 4),
|
|
218
|
+
};
|
|
751
219
|
}
|
|
220
|
+
return null;
|
|
752
221
|
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
)
|
|
770
|
-
|
|
771
|
-
const values = rawValues.map((item) => item.replace(/^['"]|['"]$/g, ""));
|
|
772
|
-
if (!values.length || values.length > 16) return undefined;
|
|
773
|
-
const marked = literalBindingText(loop[3] ?? "", name, marker);
|
|
774
|
-
if (!marked || /&&|\|\||[<>]|&/.test(marked.syntax.replace(/\b2>&1(?=[ \t\n;|]|$)/g, "")))
|
|
775
|
-
return undefined;
|
|
776
|
-
const parsed = parseStaticShellCommands(marked.text);
|
|
777
|
-
if (
|
|
778
|
-
!parsed ||
|
|
779
|
-
typeof parsed === "string" ||
|
|
780
|
-
parsed.redirects.length ||
|
|
781
|
-
parsed.pipelines.flat().length > 16 ||
|
|
782
|
-
!parsed.pipelines.flat().length
|
|
783
|
-
)
|
|
784
|
-
return undefined;
|
|
785
|
-
if (
|
|
786
|
-
!values.every((value) =>
|
|
787
|
-
parsed.pipelines.flat().every((words) => isLiteralLoopCommand(words, marker, value)),
|
|
788
|
-
)
|
|
789
|
-
)
|
|
790
|
-
return undefined;
|
|
791
|
-
const size = values.reduce(
|
|
792
|
-
(total, value) =>
|
|
793
|
-
total +
|
|
794
|
-
new TextEncoder().encode(marked.text).byteLength +
|
|
795
|
-
(marked.text.split(marker).length - 1) * (value.length - marker.length) +
|
|
796
|
-
2,
|
|
797
|
-
0,
|
|
798
|
-
);
|
|
799
|
-
if (size > 65_536) return undefined;
|
|
800
|
-
return {
|
|
801
|
-
command: values.map((value) => marked.text.replaceAll(marker, () => value)).join(";\n"),
|
|
802
|
-
paths: [],
|
|
803
|
-
};
|
|
222
|
+
function getDangerousBashReason(command: string): string | null {
|
|
223
|
+
const result = decision(command);
|
|
224
|
+
return result.kind === "pass"
|
|
225
|
+
? null
|
|
226
|
+
: result.findings
|
|
227
|
+
.slice(0, 8)
|
|
228
|
+
.map((f) => `${f.rule}: ${f.detail.slice(0, 250)}`)
|
|
229
|
+
.join("; ");
|
|
230
|
+
}
|
|
231
|
+
const isDangerousBashCommand = (command: string): boolean =>
|
|
232
|
+
getDangerousBashReason(command) !== null;
|
|
233
|
+
function getBlockedCliTool(command: string): { name: string; wrapper: string } | null {
|
|
234
|
+
const result = decision(command);
|
|
235
|
+
if (result.kind === "pass") return null;
|
|
236
|
+
const finding = result.findings.find((f) => f.rule === "wrapper-required");
|
|
237
|
+
if (!finding) return null;
|
|
238
|
+
const split = finding.detail.indexOf(": ");
|
|
239
|
+
return { name: finding.detail.slice(0, split), wrapper: finding.detail.slice(split + 2) };
|
|
804
240
|
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
241
|
+
function isGhCommandAllowed(command: string): boolean {
|
|
242
|
+
let found = false;
|
|
243
|
+
const result = judge(parseBash(command), {
|
|
244
|
+
...context,
|
|
245
|
+
onInvocation: (name) => {
|
|
246
|
+
found ||= executableName(name) === "gh";
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
return found && result.kind === "pass";
|
|
250
|
+
}
|
|
251
|
+
function detectSleepPolling(command: string): string | null {
|
|
252
|
+
const invocations: string[] = [];
|
|
253
|
+
let sleep = false;
|
|
254
|
+
judge(parseBash(command), {
|
|
255
|
+
...context,
|
|
256
|
+
onInvocation: (name, argv) => {
|
|
257
|
+
const executable = executableName(name);
|
|
258
|
+
if (executable === "sleep" && /^\d/.test(argv[1] ?? "")) sleep = true;
|
|
259
|
+
invocations.push([executable, ...argv.slice(1)].join(" "));
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
if (!sleep) return null;
|
|
263
|
+
return (
|
|
264
|
+
DEFAULT_POLLING_DETECTION_RULES.find(({ pattern }) =>
|
|
265
|
+
invocations.some((invocation) => pattern.test(invocation)),
|
|
266
|
+
)?.suggestion ?? null
|
|
815
267
|
);
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
if (assignment[5] !== "&&") return undefined;
|
|
822
|
-
const boundary = rest.syntax.search(/[;\r\n]/);
|
|
823
|
-
if (boundary >= 0 && rest.text.slice(boundary).includes(marker)) return undefined;
|
|
824
|
-
}
|
|
825
|
-
const parsed = parseStaticShellCommands(rest.text);
|
|
826
|
-
if (
|
|
827
|
-
!parsed ||
|
|
828
|
-
typeof parsed === "string" ||
|
|
829
|
-
parsed.redirects.some(({ target }) => target.includes(marker)) ||
|
|
830
|
-
!parsed.pipelines.flat().length ||
|
|
831
|
-
parsed.pipelines.flat().length > 32
|
|
832
|
-
)
|
|
833
|
-
return undefined;
|
|
834
|
-
const value = assignment[4] ?? "";
|
|
835
|
-
const paths: string[] = [];
|
|
836
|
-
// One local directory value may occupy only the first gated cd operand.
|
|
837
|
-
const navigation = parsed.pipelines[0];
|
|
838
|
-
const navigationArgv = unwrapProofCommand(navigation?.[0] ?? [], marker);
|
|
839
|
-
const directoryBinding =
|
|
840
|
-
!assignment[1] &&
|
|
841
|
-
navigation?.length === 1 &&
|
|
842
|
-
navigationArgv?.[0] === "cd" &&
|
|
843
|
-
navigationArgv[navigationArgv[1] === "--" ? 2 : 1] === marker;
|
|
844
|
-
if (directoryBinding && !hasProvedNavigationSequence(rest.text.replaceAll(marker, () => value)))
|
|
845
|
-
return undefined;
|
|
846
|
-
for (const words of parsed.pipelines.flat()) {
|
|
847
|
-
const marked = unwrapProofCommand(words, marker);
|
|
848
|
-
if (!marked) return undefined;
|
|
849
|
-
const argv = marked.map((word) => word.replaceAll(marker, () => value));
|
|
850
|
-
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
851
|
-
const uses = marked.flatMap((word, index) => (word.includes(marker) ? [index] : []));
|
|
852
|
-
const checksum = isChecksumCommand(argv);
|
|
853
|
-
if (directoryBinding && uses.length && words !== navigation?.[0]) return undefined;
|
|
854
|
-
if (!uses.length) {
|
|
855
|
-
if (
|
|
856
|
-
!isSafeLocalAssignmentCommand(argv) &&
|
|
857
|
-
inlineProgramIndex(argv) === undefined &&
|
|
858
|
-
!(argv[0] === "git" && argv[1] === "commit") &&
|
|
859
|
-
!(
|
|
860
|
-
directoryBinding &&
|
|
861
|
-
argv[0] === "git" &&
|
|
862
|
-
argv[1] === "clone" &&
|
|
863
|
-
!hasExecutionOption(argv.slice(2), ["config", "upload-pack"], "c")
|
|
864
|
-
) &&
|
|
865
|
-
!checksum
|
|
866
|
-
)
|
|
867
|
-
return undefined;
|
|
868
|
-
continue;
|
|
869
|
-
}
|
|
268
|
+
}
|
|
269
|
+
function handleToolExecuteBefore(input: HookInput, output: HookOutput): void {
|
|
270
|
+
try {
|
|
271
|
+
const tool = input.tool.toLowerCase().replace(/^mcp_/, "");
|
|
272
|
+
const path = extractFilePath(output.args);
|
|
870
273
|
if (
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
274
|
+
["read", "write", "edit", "multiedit", "notebookedit", "grep", "glob"].includes(tool) &&
|
|
275
|
+
path &&
|
|
276
|
+
isSensitivePath(path, {
|
|
277
|
+
...context,
|
|
278
|
+
contentRead:
|
|
279
|
+
tool !== "glob" &&
|
|
280
|
+
(tool !== "grep" ||
|
|
281
|
+
(!["files_with_matches", "count"].includes(String(output.args.output_mode)) &&
|
|
282
|
+
/\.[^/]+$/.test(path))),
|
|
283
|
+
})
|
|
874
284
|
)
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
285
|
+
throw new Error(`Access blocked: "${path}" is a sensitive file.`);
|
|
286
|
+
if (tool === "grep") {
|
|
287
|
+
for (const [field, option] of Object.entries({ glob: "-g", type: "-t" })) {
|
|
288
|
+
const value = output.args[field];
|
|
289
|
+
if (value === undefined) continue;
|
|
290
|
+
if (typeof value !== "string") throw new Error("Invalid Grep filter");
|
|
291
|
+
const quoted = "'" + value.replaceAll("'", "'\\''") + "'";
|
|
292
|
+
const checked = decision("rg " + option + " " + quoted + " -- example");
|
|
293
|
+
if (checked.kind !== "pass")
|
|
294
|
+
throw new Error("Access blocked: Grep filter can select a sensitive file.");
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (tool === "multiedit") {
|
|
298
|
+
if (!Array.isArray(output.args.edits)) throw new Error("Invalid MultiEdit edits");
|
|
299
|
+
for (const edit of output.args.edits) {
|
|
300
|
+
if (
|
|
301
|
+
!edit ||
|
|
302
|
+
typeof edit !== "object" ||
|
|
303
|
+
!("new_string" in edit) ||
|
|
304
|
+
typeof edit.new_string !== "string"
|
|
305
|
+
)
|
|
306
|
+
throw new Error("Invalid MultiEdit replacement");
|
|
307
|
+
handleToolExecuteBefore(
|
|
308
|
+
{ tool: "Edit" },
|
|
309
|
+
{ args: { file_path: path, new_string: edit.new_string } },
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (["write", "edit", "notebookedit"].includes(tool) && !isPathAllowed(path)) {
|
|
314
|
+
const secret = detectSecrets(extractContent(output.args));
|
|
315
|
+
if (secret)
|
|
316
|
+
throw new Error(
|
|
317
|
+
`Secret detected: Potential ${secret.name} found in content. Matched: [REDACTED]. Never commit secrets to code.`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
if (tool === "bash" || tool === "monitor" || tool === "powershell" || tool === "pwsh") {
|
|
321
|
+
const command = extractCommand(output.args);
|
|
322
|
+
const result = decision(command, tool === "powershell" || tool === "pwsh");
|
|
323
|
+
if (result.kind !== "pass") {
|
|
324
|
+
const excerpt =
|
|
325
|
+
command.length > 2000 ? command.slice(0, 2000) + " [command elided]" : command;
|
|
326
|
+
const families = [...new Set(result.findings.slice(0, 8).map((f) => f.rule))];
|
|
327
|
+
throw new Error(
|
|
328
|
+
`Command blocked: ${result.findings
|
|
329
|
+
.slice(0, 8)
|
|
330
|
+
.map((f) => `${f.rule}: ${f.detail.slice(0, 250)}`)
|
|
331
|
+
.join("; ")}.\n\n` +
|
|
332
|
+
`Command: ${excerpt}\n\n` +
|
|
333
|
+
families.map((rule) => recovery[rule]).join("\n") +
|
|
334
|
+
"\nTool inventory and usage: https://github.com/blogic-cz/agent-tools#tools",
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
const suggestion = detectSleepPolling(command);
|
|
338
|
+
if (suggestion) throw new Error(`Sleep-polling detected. Use instead: ${suggestion}`);
|
|
339
|
+
}
|
|
340
|
+
} catch (error) {
|
|
341
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
342
|
+
const sanitized = redactSensitiveText(message);
|
|
343
|
+
if (error instanceof Error) {
|
|
344
|
+
if (sanitized !== message) {
|
|
345
|
+
error.message = sanitized;
|
|
346
|
+
if (error.stack) error.stack = error.stack.replace(message, () => sanitized);
|
|
347
|
+
}
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
// eslint-disable-next-line eslint/preserve-caught-error
|
|
351
|
+
throw new Error(sanitized);
|
|
885
352
|
}
|
|
886
|
-
const prefix = assignment[1] ? `cd ${assignment[1]}${assignment[2]} ` : "";
|
|
887
|
-
const size =
|
|
888
|
-
new TextEncoder().encode(prefix + rest.text).byteLength +
|
|
889
|
-
(rest.text.split(marker).length - 1) * (value.length - marker.length);
|
|
890
|
-
if (paths.length > 32 || size > 65_536) return undefined;
|
|
891
|
-
return { command: prefix + rest.text.replaceAll(marker, () => value), paths };
|
|
892
353
|
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
if (!braces) {
|
|
903
|
-
expanded += word;
|
|
904
|
-
marked += word;
|
|
905
|
-
return true;
|
|
906
|
-
}
|
|
907
|
-
// ponytail: one comma-list per path, no nesting; extend only with a bounded shell parser.
|
|
908
|
-
const match =
|
|
909
|
-
/^([A-Za-z0-9_./-]*\/[A-Za-z0-9_./-]*)\{([A-Za-z0-9_./-]+(?:,[A-Za-z0-9_./-]+)+)\}([A-Za-z0-9_./-]*)$/.exec(
|
|
910
|
-
word,
|
|
911
|
-
);
|
|
912
|
-
if (!match) return false;
|
|
913
|
-
const members = (match[2] ?? "").split(",");
|
|
914
|
-
if (members.length > 16 || paths.length + members.length > 32) return false;
|
|
915
|
-
const values = members.map((member) => (match[1] ?? "") + member + (match[3] ?? ""));
|
|
916
|
-
const text = values.join(" ");
|
|
917
|
-
if (expanded.length + text.length + command.length - end > 65_536) return false;
|
|
918
|
-
paths.push(...values);
|
|
919
|
-
expanded += text;
|
|
920
|
-
marked += marker;
|
|
921
|
-
return true;
|
|
354
|
+
return {
|
|
355
|
+
handleToolExecuteBefore,
|
|
356
|
+
detectSecrets,
|
|
357
|
+
isPathAllowed,
|
|
358
|
+
isPathBlocked,
|
|
359
|
+
isDangerousBashCommand,
|
|
360
|
+
getBlockedCliTool,
|
|
361
|
+
isGhCommandAllowed,
|
|
362
|
+
detectSleepPolling,
|
|
922
363
|
};
|
|
923
|
-
for (let i = 0; i <= command.length; i++) {
|
|
924
|
-
const char = command[i];
|
|
925
|
-
if (quote === "'") {
|
|
926
|
-
if (char === quote) quote = undefined;
|
|
927
|
-
} else if (char === "\\") {
|
|
928
|
-
if (command[i + 1] === undefined || /[\r\n]/.test(command[i + 1] ?? "")) return undefined;
|
|
929
|
-
i++;
|
|
930
|
-
} else if (quote === '"') {
|
|
931
|
-
if (char === quote) quote = undefined;
|
|
932
|
-
} else if (char === "'" || char === '"') {
|
|
933
|
-
quote = char;
|
|
934
|
-
} else if (char === "{" || char === "}") {
|
|
935
|
-
braces = true;
|
|
936
|
-
} else if (char === undefined || /[ \t\n]/.test(char)) {
|
|
937
|
-
if (!finishWord(i)) return undefined;
|
|
938
|
-
expanded += char ?? "";
|
|
939
|
-
marked += char ?? "";
|
|
940
|
-
start = i + 1;
|
|
941
|
-
braces = false;
|
|
942
|
-
} else if (/[\s;&|<>#]/.test(char)) {
|
|
943
|
-
// Other JS whitespace is a literal shell word character, not a token boundary.
|
|
944
|
-
return undefined;
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
if (quote || !paths.length || new TextEncoder().encode(expanded).byteLength > 65_536)
|
|
948
|
-
return undefined;
|
|
949
|
-
const parsed = parseStaticShellCommands(marked);
|
|
950
|
-
if (
|
|
951
|
-
!parsed ||
|
|
952
|
-
typeof parsed === "string" ||
|
|
953
|
-
parsed.redirects.length ||
|
|
954
|
-
parsed.pipelines.length !== 1 ||
|
|
955
|
-
parsed.pipelines[0]?.length !== 1
|
|
956
|
-
)
|
|
957
|
-
return undefined;
|
|
958
|
-
const argv = unwrapProofCommand(parsed.pipelines[0]?.[0] ?? [], marker);
|
|
959
|
-
const expandedCommands = parseStaticShellCommands(expanded);
|
|
960
|
-
const expandedArgv =
|
|
961
|
-
expandedCommands && typeof expandedCommands !== "string"
|
|
962
|
-
? unwrapProofCommand(expandedCommands.pipelines[0]?.[0] ?? [], marker)
|
|
963
|
-
: undefined;
|
|
964
|
-
// Classify actual options too: a marked path can hide an executor such as git grep -O/path.
|
|
965
|
-
if (
|
|
966
|
-
!argv ||
|
|
967
|
-
!expandedArgv ||
|
|
968
|
-
(!isPassiveTextCommand(expandedArgv) && expandedArgv[0]?.split("/").at(-1) !== "cat")
|
|
969
|
-
)
|
|
970
|
-
return undefined;
|
|
971
|
-
return { command: expanded, paths };
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
/** Complete package metadata loops: no prelude, loader shadowing, mutation or arbitrary suffix. */
|
|
975
|
-
function staticPackageInventory(program: string): string[] | undefined {
|
|
976
|
-
if (program.length > 65_536) return undefined;
|
|
977
|
-
const prefix =
|
|
978
|
-
/^for\s*\(\s*const\s+([a-z])\s+of\s+(\[\s*(?:"[A-Za-z0-9_./@-]+"|'[A-Za-z0-9_./@-]+')(?:\s*,\s*(?:"[A-Za-z0-9_./@-]+"|'[A-Za-z0-9_./@-]+'))*\s*\])\s*\)\s*\{\s*(try\s*\{\s*)?const\s+([a-z])\s*=\s*require\s*\(\s*(["'])\.\/\5\s*\+\s*\1\s*\)\s*;\s*/.exec(
|
|
979
|
-
program.trim(),
|
|
980
|
-
);
|
|
981
|
-
if (!prefix || prefix[1] === prefix[4]) return undefined;
|
|
982
|
-
const paths = [...(prefix[2] ?? "").matchAll(/["']([^"']+)["']/g)].map(
|
|
983
|
-
(match) => "./" + (match[1] ?? ""),
|
|
984
|
-
);
|
|
985
|
-
if (
|
|
986
|
-
!paths.length ||
|
|
987
|
-
paths.length > 16 ||
|
|
988
|
-
// Four bytes per character bounds encoded text and shell quote escaping before allocation.
|
|
989
|
-
(program.length + Math.max(...paths.map((path) => path.length)) + 64) * paths.length * 4 >
|
|
990
|
-
65_536 ||
|
|
991
|
-
paths.some((path) => !/(?:^|\/)package\.json$/.test(path))
|
|
992
|
-
)
|
|
993
|
-
return undefined;
|
|
994
|
-
const name = prefix[1];
|
|
995
|
-
const result = prefix[4];
|
|
996
|
-
const end = prefix[3] ? String.raw`\s*\}\s*catch\s*\{\s*\}\s*\}` : String.raw`\s*\}`;
|
|
997
|
-
const suffix = program.trim().slice(prefix[0].length);
|
|
998
|
-
const bin = new RegExp(
|
|
999
|
-
String.raw`^console\.log\(\s*${name}\s*,\s*${result}\.bin\s*\)\s*;?${end}\s*$`,
|
|
1000
|
-
);
|
|
1001
|
-
const scripts = new RegExp(
|
|
1002
|
-
String.raw`^console\.log\(\s*${name}\s*,\s*Object\.entries\(\s*${result}\.scripts\s*\|\|\s*\{\}\s*\)\.filter\(\(\[n\]\)=>n\.endsWith\("-tool"\)\|\|n\.endsWith\(":cli"\)\|\|n\.endsWith\(":auth"\)\|\|n==="ui:check"\)\s*\)\s*;?${end}\s*$`,
|
|
1003
|
-
);
|
|
1004
|
-
return bin.test(suffix) || scripts.test(suffix) ? paths : undefined;
|
|
1005
364
|
}
|
|
1006
365
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
const name =
|
|
1010
|
-
executable
|
|
1011
|
-
?.split(/[/\\]/)
|
|
1012
|
-
.at(-1)
|
|
1013
|
-
?.replace(/\.exe$/i, "") ?? "";
|
|
1014
|
-
if (/^python(?:\d+(?:\.\d+)*)?t?$/i.test(name)) return "python";
|
|
1015
|
-
if (/^node$/i.test(name)) return "node";
|
|
1016
|
-
if (/^bun$/i.test(name)) return "bun";
|
|
1017
|
-
return undefined;
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
function mentionsInlineRuntime(command: string): boolean {
|
|
1021
|
-
return command.split(/[\s'"`()<>;&|]+/).some((word) => inlineRuntimeKind(word) !== undefined);
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
/** Locate code only in an actual inline-program operand, after supported runtime flags. */
|
|
1025
|
-
function inlineProgramIndex(argv: string[]): number | undefined {
|
|
1026
|
-
const executable = argv[0]?.split("/").at(-1) ?? "";
|
|
1027
|
-
const name = inlineRuntimeKind(argv[0]);
|
|
1028
|
-
// Other recognized runtime spellings retain conservative refusal; this exception is bounded.
|
|
1029
|
-
if (!name || !["python", "python3", "node", "bun"].includes(executable)) return undefined;
|
|
1030
|
-
const python = name === "python";
|
|
1031
|
-
const flags = python ? ["-u", "-B", "-I", "-S"] : name === "bun" ? ["--no-env-file"] : [];
|
|
1032
|
-
let index = 1;
|
|
1033
|
-
while (flags.includes(argv[index] ?? "")) index++;
|
|
1034
|
-
if (
|
|
1035
|
-
!(python ? argv[index] === "-c" : ["-e", "--eval"].includes(argv[index] ?? "")) ||
|
|
1036
|
-
argv[index + 1] === undefined
|
|
1037
|
-
)
|
|
1038
|
-
return undefined;
|
|
1039
|
-
// Python consumes the remaining words as sys.argv. Node can still parse startup options
|
|
1040
|
-
// after eval, until -- or an ordinary positional operand. Bun suffixes require explicit --.
|
|
1041
|
-
const suffix = argv[index + 2];
|
|
1042
|
-
if (name === "bun" && suffix !== undefined && suffix !== "--") return undefined;
|
|
1043
|
-
if (name === "node" && suffix?.startsWith("-") && suffix !== "--") return undefined;
|
|
1044
|
-
return index + 1;
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
function isUnsupportedInlineRuntime(argv: string[]): boolean {
|
|
1048
|
-
const runtime = inlineRuntimeKind(argv[0]);
|
|
1049
|
-
if (!runtime) return false;
|
|
1050
|
-
if (pythonCompileFiles(argv)) return false;
|
|
1051
|
-
if (inlineProgramIndex(argv) !== undefined) return false;
|
|
1052
|
-
let index = 1;
|
|
1053
|
-
for (; index < argv.length; index++) {
|
|
1054
|
-
const arg = argv[index] ?? "";
|
|
1055
|
-
if (arg === "--" || !arg.startsWith("-")) break;
|
|
1056
|
-
if (
|
|
1057
|
-
/^(?:-[A-Za-z]*[cep]|-[rmp]|--(?:eval|print|require|import|loader|preload|experimental-loader))(?:$|=|[^-])/.test(
|
|
1058
|
-
arg,
|
|
1059
|
-
)
|
|
1060
|
-
)
|
|
1061
|
-
return true;
|
|
1062
|
-
if (
|
|
1063
|
-
(runtime === "python" && arg === "--check-hash-based-pycs") ||
|
|
1064
|
-
(runtime === "node" &&
|
|
1065
|
-
["--conditions", "-C", "--icu-data-dir", "--dns-result-order"].includes(arg))
|
|
1066
|
-
) {
|
|
1067
|
-
if (argv[index + 1] === undefined) return true;
|
|
1068
|
-
index++;
|
|
1069
|
-
continue;
|
|
1070
|
-
}
|
|
1071
|
-
// Unknown startup-option arity cannot establish where the script/tool begins.
|
|
1072
|
-
if (
|
|
1073
|
-
!(
|
|
1074
|
-
(runtime === "python" && /^-[uBISsEOqvV]+$/.test(arg)) ||
|
|
1075
|
-
(runtime === "node" &&
|
|
1076
|
-
/^(?:--(?:trace-warnings|no-warnings|version|help)|--(?:conditions|icu-data-dir|dns-result-order)=.+)$/.test(
|
|
1077
|
-
arg,
|
|
1078
|
-
)) ||
|
|
1079
|
-
(runtime === "bun" && ["--no-env-file", "--version", "--help"].includes(arg)) ||
|
|
1080
|
-
["--version", "--help", "-h"].includes(arg)
|
|
1081
|
-
)
|
|
1082
|
-
)
|
|
1083
|
-
return true;
|
|
1084
|
-
}
|
|
1085
|
-
if (runtime === "bun" && ["exec", "repl"].includes(argv[index] ?? "")) return true;
|
|
1086
|
-
// A separate -- after a literal task/file is an argv boundary, not proof of task contents.
|
|
1087
|
-
let boundary = argv.length;
|
|
1088
|
-
const target = argv[index] === "run" ? index + 1 : index;
|
|
1089
|
-
if (
|
|
1090
|
-
runtime === "bun" &&
|
|
1091
|
-
/^[A-Za-z0-9_./][A-Za-z0-9_./-]*$/.test(argv[target] ?? "") &&
|
|
1092
|
-
![
|
|
1093
|
-
"exec",
|
|
1094
|
-
"repl",
|
|
1095
|
-
"x",
|
|
1096
|
-
"run",
|
|
1097
|
-
"test",
|
|
1098
|
-
"build",
|
|
1099
|
-
"install",
|
|
1100
|
-
"add",
|
|
1101
|
-
"remove",
|
|
1102
|
-
"update",
|
|
1103
|
-
"pm",
|
|
1104
|
-
"create",
|
|
1105
|
-
"link",
|
|
1106
|
-
"unlink",
|
|
1107
|
-
"init",
|
|
1108
|
-
"upgrade",
|
|
1109
|
-
].includes(argv[target] ?? "")
|
|
1110
|
-
) {
|
|
1111
|
-
const separator = argv.indexOf("--", target + 1);
|
|
1112
|
-
if (separator !== -1) boundary = separator;
|
|
1113
|
-
}
|
|
1114
|
-
// Before that boundary, unknown eval/loader controls retain conservative refusal.
|
|
1115
|
-
return (
|
|
1116
|
-
runtime === "bun" &&
|
|
1117
|
-
argv
|
|
1118
|
-
.slice(index, boundary)
|
|
1119
|
-
.some((arg) =>
|
|
1120
|
-
/^(?:-[A-Za-z]*[ep]|-[rmp]|--(?:eval|print|require|import|loader|preload|experimental-loader))(?:$|=|[^-])/.test(
|
|
1121
|
-
arg,
|
|
1122
|
-
),
|
|
1123
|
-
)
|
|
1124
|
-
);
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
/** -I excludes cwd/user imports and PYTHON* startup controls; -S disables site hooks. */
|
|
1128
|
-
function pythonCompileFiles(argv: string[]): string[] | undefined {
|
|
1129
|
-
if (!["python", "python3"].includes(argv[0]?.split("/").at(-1) ?? "")) return undefined;
|
|
1130
|
-
let index = 1;
|
|
1131
|
-
let isolation = "";
|
|
1132
|
-
while (/^-[ISB]+$/.test(argv[index] ?? "")) isolation += argv[index++]?.slice(1);
|
|
1133
|
-
if (!isolation.includes("I") || !isolation.includes("S")) return undefined;
|
|
1134
|
-
if (argv[index] !== "-m" || argv[index + 1] !== "py_compile") return undefined;
|
|
1135
|
-
const files = argv.slice(index + 2);
|
|
1136
|
-
return files.length > 0 &&
|
|
1137
|
-
files.length <= 32 &&
|
|
1138
|
-
files.every((file) => /^[A-Za-z0-9_./][A-Za-z0-9_./ -]*\.py$/.test(file))
|
|
1139
|
-
? files
|
|
1140
|
-
: undefined;
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
function isPythonCompileModule(argv: string[]): boolean {
|
|
1144
|
-
return (
|
|
1145
|
-
inlineRuntimeKind(argv[0]) === "python" &&
|
|
1146
|
-
argv
|
|
1147
|
-
.slice(1)
|
|
1148
|
-
.some(
|
|
1149
|
-
(arg, index) =>
|
|
1150
|
-
(arg === "-m" && argv[index + 2] === "py_compile") || arg === "-mpy_compile",
|
|
1151
|
-
)
|
|
1152
|
-
);
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
/** Preserve the startup proof through the entire invocation, without inspecting target code. */
|
|
1156
|
-
function hasProvedPythonCompileInvocation(command: string): boolean {
|
|
1157
|
-
if (new TextEncoder().encode(command).byteLength > 65_536 || !literalBindingText(command, "", ""))
|
|
1158
|
-
return false;
|
|
1159
|
-
const parsed = parseStaticShellCommands(command);
|
|
1160
|
-
if (
|
|
1161
|
-
!parsed ||
|
|
1162
|
-
typeof parsed === "string" ||
|
|
1163
|
-
parsed.pipelines.flat().length > 32 ||
|
|
1164
|
-
!hasProvedNavigationSequence(command)
|
|
1165
|
-
)
|
|
1166
|
-
return false;
|
|
1167
|
-
return parsed.pipelines.every((pipeline) =>
|
|
1168
|
-
pipeline.every((words, index) => {
|
|
1169
|
-
const argv = unwrapProofCommand(words, "\0");
|
|
1170
|
-
if (!argv) return false;
|
|
1171
|
-
if (pythonCompileFiles(argv)) return index === 0;
|
|
1172
|
-
if (isStaticNavigationCommand(argv)) return index === 0 && pipeline.length === 1;
|
|
1173
|
-
return isReadonlyJqConsumer(argv);
|
|
1174
|
-
}),
|
|
1175
|
-
);
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
/** Only literal navigation may precede an unqualified writer; shell state can change what runs. */
|
|
1179
|
-
function isLiteralWriterHeader(command: string, pipelines: string[][][]): boolean {
|
|
1180
|
-
const commands = pipelines.flat();
|
|
1181
|
-
const writer = commands.at(-1);
|
|
1182
|
-
return (
|
|
1183
|
-
!command.includes("\r") &&
|
|
1184
|
-
pipelines.at(-1)?.length === 1 &&
|
|
1185
|
-
(writer?.[0] === "cat" || writer?.[0] === "tee") &&
|
|
1186
|
-
commands
|
|
1187
|
-
.slice(0, -1)
|
|
1188
|
-
.every(
|
|
1189
|
-
(words) =>
|
|
1190
|
-
(words[0] === "cd" && isStaticNavigationCommand(words)) ||
|
|
1191
|
-
(words.length === 1 && words[0] === "pwd"),
|
|
1192
|
-
)
|
|
1193
|
-
);
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
/** Move a complete, literal Python stdin program into the existing inline argv boundary. */
|
|
1197
|
-
function normalizeProgramHeredocs(command: string, depth = 0): string {
|
|
1198
|
-
if (depth >= 32 || new TextEncoder().encode(command).byteLength > 65_536) return command;
|
|
1199
|
-
const heredoc = boundedHeredoc(command);
|
|
1200
|
-
if (!heredoc || !heredoc.closed || (!heredoc.quoted && /[$`\\]/.test(heredoc.body)))
|
|
1201
|
-
return command;
|
|
1202
|
-
const parsed = parseStaticShellCommands(heredoc.header);
|
|
1203
|
-
if (!parsed || typeof parsed === "string") return command;
|
|
1204
|
-
const last = parsed.pipelines.at(-1)?.at(-1);
|
|
1205
|
-
const argv = last ? unwrapStaticCommand(last) : [];
|
|
1206
|
-
const name = argv[0]?.split("/").at(-1);
|
|
1207
|
-
// A quoted delimiter disables expansion, so a first cat/tee body is data too.
|
|
1208
|
-
if (
|
|
1209
|
-
(depth > 0 || (heredoc.quoted && isLiteralWriterHeader(command, parsed.pipelines))) &&
|
|
1210
|
-
((name === "cat" && argv.slice(1).every((arg) => arg === "--" || !arg.startsWith("-"))) ||
|
|
1211
|
-
(name === "tee" &&
|
|
1212
|
-
argv
|
|
1213
|
-
.slice(1)
|
|
1214
|
-
.every((arg) => ["-a", "--append", "--"].includes(arg) || !arg.startsWith("-"))))
|
|
1215
|
-
) {
|
|
1216
|
-
const following = normalizeProgramHeredocs(heredoc.following, depth + 1);
|
|
1217
|
-
const tail = parseStaticShellCommands(following);
|
|
1218
|
-
if (
|
|
1219
|
-
!tail ||
|
|
1220
|
-
typeof tail === "string" ||
|
|
1221
|
-
!tail.pipelines.flat().every((words) => {
|
|
1222
|
-
const next = unwrapProofCommand(words, "\0");
|
|
1223
|
-
return next !== undefined && isReadonlyJqConsumer(next);
|
|
1224
|
-
})
|
|
1225
|
-
)
|
|
1226
|
-
return command;
|
|
1227
|
-
// Literal writer bodies are data. Every header, file operand and following command is retained.
|
|
1228
|
-
return heredoc.header + "\n" + following;
|
|
1229
|
-
}
|
|
1230
|
-
if (
|
|
1231
|
-
inlineRuntimeKind(argv[0]) !== "python" ||
|
|
1232
|
-
inlineProgramIndex([argv[0] ?? "", "-c", ""]) === undefined ||
|
|
1233
|
-
argv.length !== 2 ||
|
|
1234
|
-
argv[1] !== "-"
|
|
1235
|
-
)
|
|
1236
|
-
return command;
|
|
1237
|
-
if (!/-\s*$/.test(heredoc.header)) return command;
|
|
1238
|
-
const quoted = "'" + heredoc.body.replaceAll("'", "'\"'\"'") + "'";
|
|
1239
|
-
return (
|
|
1240
|
-
heredoc.header.replace(/-\s*$/, () => "-c " + quoted) +
|
|
1241
|
-
"\n" +
|
|
1242
|
-
normalizeProgramHeredocs(heredoc.following, depth + 1)
|
|
1243
|
-
);
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
function hasUnboundedHeredoc(command: string): boolean {
|
|
1247
|
-
if (!boundedHeredoc(command)) return false;
|
|
1248
|
-
if (new TextEncoder().encode(command).byteLength > 65_536) return true;
|
|
1249
|
-
for (let count = 0; count <= 32; count++) {
|
|
1250
|
-
const heredoc = boundedHeredoc(command);
|
|
1251
|
-
if (!heredoc) return false;
|
|
1252
|
-
if (!heredoc.closed || count === 32) return true;
|
|
1253
|
-
command = heredoc.header + "\n" + heredoc.following;
|
|
1254
|
-
}
|
|
1255
|
-
return true;
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
/** Strings are data in these languages; retain their values separately for path checks. */
|
|
1259
|
-
function programParts(
|
|
1260
|
-
program: string,
|
|
1261
|
-
python: boolean,
|
|
1262
|
-
):
|
|
1263
|
-
| { code: string; literals: string[]; pathLiterals: string[]; loadedModules: string[] }
|
|
1264
|
-
| undefined {
|
|
1265
|
-
const literals: string[] = [];
|
|
1266
|
-
const pathLiterals: string[] = [];
|
|
1267
|
-
const loadedModules: string[] = [];
|
|
1268
|
-
let code = "";
|
|
1269
|
-
for (let i = 0; i < program.length; i++) {
|
|
1270
|
-
const char = program[i] ?? "";
|
|
1271
|
-
if ((python && char === "#") || (!python && program.startsWith("//", i))) {
|
|
1272
|
-
const terminator = python ? /[\r\n]/ : /[\r\n\u2028\u2029]/;
|
|
1273
|
-
while (i < program.length && !terminator.test(program[i] ?? "")) i++;
|
|
1274
|
-
code += "\n";
|
|
1275
|
-
continue;
|
|
1276
|
-
}
|
|
1277
|
-
if (!python && program.startsWith("/*", i)) {
|
|
1278
|
-
const end = program.indexOf("*/", i + 2);
|
|
1279
|
-
if (end < 0) return undefined;
|
|
1280
|
-
i = end + 1;
|
|
1281
|
-
code += " ";
|
|
1282
|
-
continue;
|
|
1283
|
-
}
|
|
1284
|
-
if (!python && char === "/") {
|
|
1285
|
-
// Recognize regex only at an expression start; ambiguous slash syntax stays closed.
|
|
1286
|
-
if (!/[=(,:!&|?;[{]\s*$/.test(code) && !/\breturn\s+$/.test(code)) return undefined;
|
|
1287
|
-
let inClass = false;
|
|
1288
|
-
let closed = false;
|
|
1289
|
-
for (i++; i < program.length; i++) {
|
|
1290
|
-
const next = program[i];
|
|
1291
|
-
if (next === "\\") {
|
|
1292
|
-
i++;
|
|
1293
|
-
continue;
|
|
1294
|
-
}
|
|
1295
|
-
if (next === "\n") return undefined;
|
|
1296
|
-
if (next === "[") inClass = true;
|
|
1297
|
-
else if (next === "]") inClass = false;
|
|
1298
|
-
else if (next === "/" && !inClass) {
|
|
1299
|
-
closed = true;
|
|
1300
|
-
break;
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1303
|
-
if (!closed) return undefined;
|
|
1304
|
-
code += " REGEX ";
|
|
1305
|
-
continue;
|
|
1306
|
-
}
|
|
1307
|
-
if (char !== "'" && char !== '"' && char !== "`") {
|
|
1308
|
-
code += char;
|
|
1309
|
-
continue;
|
|
1310
|
-
}
|
|
1311
|
-
// Bun.write(PATH, TEXT) differs from file-object write(TEXT).
|
|
1312
|
-
const literalTextArgument =
|
|
1313
|
-
(/\.(?:write_text|write)\s*\(\s*$/.test(code) && !/\bBun\s*\.\s*write\s*\(\s*$/.test(code)) ||
|
|
1314
|
-
/\bBun\s*\.\s*write\s*\(\s*STRING\s*,\s*$/.test(code);
|
|
1315
|
-
const interpolated = python && /[fF]/.test(/[rRuUbBfF]+$/.exec(program.slice(0, i))?.[0] ?? "");
|
|
1316
|
-
const delimiter = program.startsWith(char.repeat(3), i) ? char.repeat(3) : char;
|
|
1317
|
-
i += delimiter.length;
|
|
1318
|
-
let value = "";
|
|
1319
|
-
let expressionDepth = 0;
|
|
1320
|
-
while (i < program.length && !program.startsWith(delimiter, i)) {
|
|
1321
|
-
if (program[i] === "\\") {
|
|
1322
|
-
value += program[i] + (program[++i] ?? "");
|
|
1323
|
-
i++;
|
|
1324
|
-
} else {
|
|
1325
|
-
const current = program[i];
|
|
1326
|
-
if (expressionDepth > 0 && (current === "'" || current === '"')) {
|
|
1327
|
-
if (program.startsWith(current.repeat(3), i)) return undefined;
|
|
1328
|
-
let end = i + 1;
|
|
1329
|
-
while (end < program.length && program[end] !== current) {
|
|
1330
|
-
if (program[end] === "\\") end++;
|
|
1331
|
-
end++;
|
|
1332
|
-
}
|
|
1333
|
-
if (end >= program.length) return undefined;
|
|
1334
|
-
value += program.slice(i, end + 1);
|
|
1335
|
-
i = end + 1;
|
|
1336
|
-
continue;
|
|
1337
|
-
}
|
|
1338
|
-
if (
|
|
1339
|
-
interpolated &&
|
|
1340
|
-
expressionDepth === 0 &&
|
|
1341
|
-
(program.startsWith("{{", i) || program.startsWith("}}", i))
|
|
1342
|
-
) {
|
|
1343
|
-
value += program.slice(i, i + 2);
|
|
1344
|
-
i += 2;
|
|
1345
|
-
continue;
|
|
1346
|
-
}
|
|
1347
|
-
if (
|
|
1348
|
-
(interpolated && current === "{") ||
|
|
1349
|
-
(char === "`" && current === "{" && (expressionDepth > 0 || program[i - 1] === "$"))
|
|
1350
|
-
)
|
|
1351
|
-
expressionDepth++;
|
|
1352
|
-
if ((interpolated || char === "`") && current === "}" && expressionDepth > 0)
|
|
1353
|
-
expressionDepth--;
|
|
1354
|
-
value += program[i++];
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1357
|
-
if (i >= program.length || expressionDepth !== 0) return undefined;
|
|
1358
|
-
// Path values need exact decoding. Other escapes (including continuations and
|
|
1359
|
-
// identity escapes) remain unproved; payload strings do not require this proof.
|
|
1360
|
-
if (
|
|
1361
|
-
/\b(?:open|Path|readFile|readFileSync|writeFile|writeFileSync|file|Bun\s*\.\s*write)\s*\(\s*$/.test(
|
|
1362
|
-
code,
|
|
1363
|
-
) &&
|
|
1364
|
-
value.replace(/\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4})/g, "").includes("\\")
|
|
1365
|
-
)
|
|
1366
|
-
return undefined;
|
|
1367
|
-
value = value.replace(
|
|
1368
|
-
/\\(?:x([0-9a-fA-F]{2})|u([0-9a-fA-F]{4})|([0-7]{1,3}))/g,
|
|
1369
|
-
(_match, hex: string | undefined, unicode: string | undefined, octal: string | undefined) =>
|
|
1370
|
-
String.fromCharCode(Number.parseInt(hex ?? unicode ?? octal ?? "0", octal ? 8 : 16)),
|
|
1371
|
-
);
|
|
1372
|
-
if (
|
|
1373
|
-
!python &&
|
|
1374
|
-
/\b(?:require|import)\s*\(\s*$|\bimport\s*$|\b(?:import|export)\s+[^;()\n]*\bfrom\s*$/.test(
|
|
1375
|
-
code,
|
|
1376
|
-
)
|
|
1377
|
-
)
|
|
1378
|
-
loadedModules.push(value);
|
|
1379
|
-
literals.push(value);
|
|
1380
|
-
if (!literalTextArgument) pathLiterals.push(value);
|
|
1381
|
-
// Template expressions remain executable; do not use their quoted boundary as an exemption.
|
|
1382
|
-
if (interpolated || (char === "`" && value.includes("${"))) code += value;
|
|
1383
|
-
code += " STRING ";
|
|
1384
|
-
i += delimiter.length - 1;
|
|
1385
|
-
}
|
|
1386
|
-
return { code, literals, pathLiterals, loadedModules };
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
/** One immutable literal argv list passed once to subprocess.run, without a shell. */
|
|
1390
|
-
function staticSubprocessCommand(program: string): string | undefined {
|
|
1391
|
-
// The binding and use must be adjacent top-level statements at the start of the program.
|
|
1392
|
-
// A literal-looking assignment in a string, branch, or imported scope proves nothing.
|
|
1393
|
-
const prefix =
|
|
1394
|
-
/^import subprocess[ \t]*\r?\n([A-Za-z_]\w*)[ \t]*=[ \t]*(\[\s*(?:"[^"\\]*"|'[^'\\]*')(?:\s*,\s*(?:"[^"\\]*"|'[^'\\]*'))*\s*,?\s*\])[ \t]*\r?\n(?:[A-Za-z_]\w*[ \t]*=[ \t]*)?subprocess\.run\(/.exec(
|
|
1395
|
-
program.trimStart(),
|
|
1396
|
-
);
|
|
1397
|
-
if (!prefix) return undefined;
|
|
1398
|
-
const variable = prefix[1] ?? "";
|
|
1399
|
-
const rest = program.trimStart().slice(prefix[0].length);
|
|
1400
|
-
const call = new RegExp(
|
|
1401
|
-
`^${variable}\\s*(?:,\\s*(?:cwd\\s*=\\s*(?:"[^"\\\\]*"|'[^'\\\\]*')|(?:capture_output|text|check)\\s*=\\s*(?:True|False)|timeout\\s*=\\s*\\d+))*\\s*\\)`,
|
|
1402
|
-
).exec(rest);
|
|
1403
|
-
if (!call) return undefined;
|
|
1404
|
-
const parts = programParts(program, true);
|
|
1405
|
-
if (
|
|
1406
|
-
!parts ||
|
|
1407
|
-
(parts.code.match(new RegExp(`\\b${variable}\\b`, "g"))?.length ?? 0) !== 2 ||
|
|
1408
|
-
(parts.code.match(/\bsubprocess\b/g)?.length ?? 0) !== 2
|
|
1409
|
-
)
|
|
1410
|
-
return undefined;
|
|
1411
|
-
const values = [...(prefix[2] ?? "").matchAll(/"([^"\\]*)"|'([^'\\]*)'/g)].map(
|
|
1412
|
-
(match) => match[1] ?? match[2] ?? "",
|
|
1413
|
-
);
|
|
1414
|
-
if (!values.length) return undefined;
|
|
1415
|
-
const quote = (value: string) => "'" + value.replaceAll("'", "'\"'\"'") + "'";
|
|
1416
|
-
const command = values.map(quote).join(" ");
|
|
1417
|
-
const directories = [...call[0].matchAll(/\bcwd\s*=\s*(?:"([^"\\]*)"|'([^'\\]*)')/g)];
|
|
1418
|
-
if (!directories.length) return command;
|
|
1419
|
-
const cwd = directories[0]?.[1] ?? directories[0]?.[2] ?? "";
|
|
1420
|
-
// Never replay a cwd-bearing call as if it ran in the original shell directory.
|
|
1421
|
-
if (directories.length !== 1 || !isStaticNavigationCommand(["cd", "--", cwd])) return undefined;
|
|
1422
|
-
const argv = unwrapCwdSubprocessCommand(values);
|
|
1423
|
-
if (!argv || !isProvedCwdSubprocessRole(argv)) return undefined;
|
|
1424
|
-
// Keep wrapper assignments/options in policy replay after proving the nested command role.
|
|
1425
|
-
const replay = `cd -- ${quote(cwd)} && ${command}`;
|
|
1426
|
-
return hasProvedNavigationSequence(replay) ? replay : undefined;
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
function unwrapCwdSubprocessCommand(words: string[]): string[] | undefined {
|
|
1430
|
-
let argv = words;
|
|
1431
|
-
// Each successful wrapper removes words; the original word count bounds recursion.
|
|
1432
|
-
for (let depth = 0; depth < words.length; depth++) {
|
|
1433
|
-
const unwrapped = unwrapProofCommand(argv, "\0");
|
|
1434
|
-
if (!unwrapped) return undefined;
|
|
1435
|
-
argv = unwrapped;
|
|
1436
|
-
if (argv[0]?.split("/").at(-1) !== "env") return argv;
|
|
1437
|
-
const nested = unwrapEnvironmentCommand(argv);
|
|
1438
|
-
if (!nested) return undefined;
|
|
1439
|
-
// Inspect only env's prefix, not options belonging to its child command.
|
|
1440
|
-
// env directory, split-string and unknown options do not preserve the proved context.
|
|
1441
|
-
if (
|
|
1442
|
-
argv
|
|
1443
|
-
.slice(1, argv.length - nested.length)
|
|
1444
|
-
.some(
|
|
1445
|
-
(arg) =>
|
|
1446
|
-
arg.startsWith("-") && arg !== "--" && arg !== "-i" && arg !== "--ignore-environment",
|
|
1447
|
-
)
|
|
1448
|
-
)
|
|
1449
|
-
return undefined;
|
|
1450
|
-
argv = nested;
|
|
1451
|
-
}
|
|
1452
|
-
return undefined;
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
function isProvedCwdSubprocessRole(argv: string[]): boolean {
|
|
1456
|
-
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
1457
|
-
if (inlineProgramIndex(argv) !== undefined || isUnsupportedInlineRuntime(argv)) return false;
|
|
1458
|
-
if (isPassiveTextCommand(argv)) return true;
|
|
1459
|
-
if (name === "find") return isSafeLocalAssignmentCommand(argv);
|
|
1460
|
-
if (["ls", "cat", "uniq", "cut"].includes(name)) return true;
|
|
1461
|
-
if (name === "wc") return !hasExecutionOption(argv.slice(1), ["files0-from"]);
|
|
1462
|
-
if (name === "sort")
|
|
1463
|
-
return !hasExecutionOption(argv.slice(1), ["compress-program", "files0-from"]);
|
|
1464
|
-
if (name === "git") {
|
|
1465
|
-
let index = 1;
|
|
1466
|
-
while (argv[index] === "-C" && argv[index + 1]) index += 2;
|
|
1467
|
-
return ["ls-files", "ls-tree", "status", "rev-parse", "rev-list"].includes(argv[index] ?? "");
|
|
1468
|
-
}
|
|
1469
|
-
// A literal Bun script keeps the existing supported test-runner route.
|
|
1470
|
-
if (name === "bun") return /^[A-Za-z0-9_./-]+\.(?:[cm]?[jt]s)$/.test(argv[1] ?? "");
|
|
1471
|
-
// Retain extraction so the nested CLI policy returns its ordinary descriptor.
|
|
1472
|
-
return DEFAULT_BLOCKED_CLI_TOOLS.some((tool) => tool.name === name);
|
|
1473
|
-
}
|
|
1474
|
-
|
|
1475
|
-
function inlineSubprocessCommands(command: string): string[] {
|
|
1476
|
-
const parsed = parseStaticShellCommands(command);
|
|
1477
|
-
if (!parsed || typeof parsed === "string") return [];
|
|
1478
|
-
return parsed.pipelines.flat().flatMap((words) => {
|
|
1479
|
-
const argv = unwrapStaticCommand(words);
|
|
1480
|
-
const index = inlineProgramIndex(argv);
|
|
1481
|
-
const nested = index === undefined ? undefined : staticSubprocessCommand(argv[index] ?? "");
|
|
1482
|
-
return nested ? [nested] : [];
|
|
1483
|
-
});
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
/** The complete invocation must preserve the package loader's startup and lookup identity. */
|
|
1487
|
-
function hasProvedPackageInventoryInvocation(command: string): boolean {
|
|
1488
|
-
if (hasEnvironmentMutationPrefix(command)) return false;
|
|
1489
|
-
const parsed = parseStaticShellCommands(command);
|
|
1490
|
-
if (!parsed || typeof parsed === "string") return false;
|
|
1491
|
-
return parsed.pipelines.flat().every((words) => {
|
|
1492
|
-
const argv = unwrapStaticCommand(words);
|
|
1493
|
-
if (
|
|
1494
|
-
argv[0]?.split("/").at(-1) === "cat" ||
|
|
1495
|
-
isPassiveTextCommand(argv) ||
|
|
1496
|
-
isLiteralInlineWriter(argv)
|
|
1497
|
-
)
|
|
1498
|
-
return true;
|
|
1499
|
-
const index = inlineProgramIndex(argv);
|
|
1500
|
-
return (
|
|
1501
|
-
inlineRuntimeKind(argv[0]) === "node" &&
|
|
1502
|
-
index !== undefined &&
|
|
1503
|
-
staticPackageInventory(argv[index] ?? "") !== undefined
|
|
1504
|
-
);
|
|
1505
|
-
});
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1508
|
-
/** Replay each proved JSON loader operand through ordinary command policies as well. */
|
|
1509
|
-
function inlinePackageCommands(command: string, allowInventory: boolean): string[] {
|
|
1510
|
-
if (!allowInventory) return [];
|
|
1511
|
-
const parsed = parseStaticShellCommands(command);
|
|
1512
|
-
if (!parsed || typeof parsed === "string") return [];
|
|
1513
|
-
return parsed.pipelines.flat().flatMap((words) => {
|
|
1514
|
-
const argv = unwrapStaticCommand(words);
|
|
1515
|
-
const index = inlineProgramIndex(argv);
|
|
1516
|
-
if (index === undefined || inlineRuntimeKind(argv[0]) !== "node") return [];
|
|
1517
|
-
const program = argv[index] ?? "";
|
|
1518
|
-
const paths = staticPackageInventory(program);
|
|
1519
|
-
return (paths ?? []).map((path) =>
|
|
1520
|
-
["node", "-e", program]
|
|
1521
|
-
.map((word, position) => {
|
|
1522
|
-
const materialized =
|
|
1523
|
-
position === 2
|
|
1524
|
-
? program.replace(
|
|
1525
|
-
/\brequire\s*\(\s*(["'])\.\/\1\s*\+\s*[a-z]\s*\)/,
|
|
1526
|
-
() => `require(${JSON.stringify(path)})`,
|
|
1527
|
-
)
|
|
1528
|
-
: word;
|
|
1529
|
-
return "'" + materialized.replaceAll("'", "'\"'\"'") + "'";
|
|
1530
|
-
})
|
|
1531
|
-
.join(" "),
|
|
1532
|
-
);
|
|
1533
|
-
});
|
|
1534
|
-
}
|
|
1535
|
-
|
|
1536
|
-
function hasInlineProgramExecution(
|
|
1537
|
-
program: string,
|
|
1538
|
-
python: boolean,
|
|
1539
|
-
allowInventory = false,
|
|
1540
|
-
): boolean {
|
|
1541
|
-
const parts = programParts(program, python);
|
|
1542
|
-
if (!parts) return true;
|
|
1543
|
-
if (parts.loadedModules.some((name) => /^(?:node:)?vm$/.test(name))) return true;
|
|
1544
|
-
// Dynamic loading and reflective access cannot establish a non-executing code operand.
|
|
1545
|
-
if (
|
|
1546
|
-
(/\b(?:require|import)\s*\((?!\s*STRING\s*\))/.test(parts.code) &&
|
|
1547
|
-
(!allowInventory || !staticPackageInventory(program))) ||
|
|
1548
|
-
/\[\s*STRING\s*\+/.test(parts.code) ||
|
|
1549
|
-
/\b(?:process|os)\s*\[/.test(parts.code)
|
|
1550
|
-
)
|
|
1551
|
-
return true;
|
|
1552
|
-
const code = staticSubprocessCommand(program)
|
|
1553
|
-
? parts.code.replace(/\bsubprocess\b/g, "")
|
|
1554
|
-
: parts.code;
|
|
1555
|
-
return (
|
|
1556
|
-
/\b(?:subprocess|child_process|execSync|execFile|execFileSync|spawn|spawnSync|eval|exec|Function|Reflect|getattr|setattr|globals|locals|vars|compile|__import__)\b|\bos\s*\.\s*(?:system|popen)|\bBun\s*\.\s*(?:spawn|spawnSync)|\bDeno\s*\.\s*Command/.test(
|
|
1557
|
-
code,
|
|
1558
|
-
) || parts.literals.some((value) => /^(?:node:)?child_process$/.test(value))
|
|
1559
|
-
);
|
|
1560
|
-
}
|
|
1561
|
-
|
|
1562
|
-
function mentionsEnvironmentRead(text: string): boolean {
|
|
1563
|
-
// `--env dev` is a flag of another command, not the env command.
|
|
1564
|
-
return /(?<![\w.-])printenv\b(?!-)|(?<![\w.-])-\w*Oprintenv\b|(?<![\w.-])\benv\b(?!-)/i.test(
|
|
1565
|
-
text.replace(/['"\\]/g, ""),
|
|
1566
|
-
);
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
function hasBraceExpansion(text: string): boolean {
|
|
1570
|
-
return /\{[^{}]*(?:,|\.\.)[^{}]*\}/.test(text);
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
function hasNonExecutingAwkProgram(text: string): boolean {
|
|
1574
|
-
const match = /\bawk\s+(['"])([\s\S]*?)\1/.exec(text);
|
|
1575
|
-
if (!match) return false;
|
|
1576
|
-
const program = match[2] ?? "";
|
|
1577
|
-
return !/\b(?:system|getline)\s*\(|\||\b(?:print|printf)\b[^\n]*>/.test(program);
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
function hasShellBraceExpansion(text: string): boolean {
|
|
1581
|
-
if (!hasBraceExpansion(text)) return false;
|
|
1582
|
-
if (!hasNonExecutingAwkProgram(text)) return true;
|
|
1583
|
-
return hasBraceExpansion(text.replace(/(\bawk\s+(['"]))[\s\S]*?\2/, "awk "));
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
/**
|
|
1587
|
-
* The command without quoted regex quantifiers such as `{0,80}`. Many commands re-parse quoted
|
|
1588
|
-
* text as shell code, so every other quoted brace stays. When both sides are digits, the expansion
|
|
1589
|
-
* cannot spell a command name. An empty side is not safe: `e{,}nv` and `e{0,}nv` expand to `env`.
|
|
1590
|
-
* An unclosed quote keeps the raw command.
|
|
1591
|
-
*/
|
|
1592
|
-
function withoutQuotedQuantifiers(command: string): string {
|
|
1593
|
-
let text = "";
|
|
1594
|
-
let quote: "'" | '"' | "$'" | undefined;
|
|
1595
|
-
for (let i = 0; i < command.length; i++) {
|
|
1596
|
-
const char = command.charAt(i);
|
|
1597
|
-
const quantifier = quote ? /^\{\d+,\d+\}/.exec(command.slice(i))?.[0] : undefined;
|
|
1598
|
-
if (quantifier) {
|
|
1599
|
-
i += quantifier.length - 1;
|
|
1600
|
-
continue;
|
|
1601
|
-
}
|
|
1602
|
-
text += char;
|
|
1603
|
-
if (quote === "'") {
|
|
1604
|
-
if (char === "'") quote = undefined;
|
|
1605
|
-
} else if (char === "\\") {
|
|
1606
|
-
text += command[++i] ?? "";
|
|
1607
|
-
} else if (quote) {
|
|
1608
|
-
if (char === quote.at(-1)) quote = undefined;
|
|
1609
|
-
} else if (char === "$" && command[i + 1] === "'") {
|
|
1610
|
-
quote = "$'";
|
|
1611
|
-
text += command[++i];
|
|
1612
|
-
} else if (char === "'" || char === '"') {
|
|
1613
|
-
quote = char;
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
|
-
return quote ? command : text;
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
/** Braces in static words, except `{m,n}` with digits on both sides (see above). */
|
|
1620
|
-
function hasArgumentBraceExpansion(text: string): boolean {
|
|
1621
|
-
return hasShellBraceExpansion(text.replace(/\{\d+,\d+\}/g, ""));
|
|
1622
|
-
}
|
|
1623
|
-
|
|
1624
|
-
function hasCommandArgumentBraceExpansion(argv: string[]): boolean {
|
|
1625
|
-
const apiData = apiRequestDataIndexes(argv);
|
|
1626
|
-
if (apiData)
|
|
1627
|
-
return argv.some((arg, index) => !apiData.has(index) && hasArgumentBraceExpansion(arg));
|
|
1628
|
-
// LogQL is data for this wrapper; its quoted label selectors are never shell-evaluated.
|
|
1629
|
-
if (
|
|
1630
|
-
argv[0]?.split("/").at(-1) === "bun" &&
|
|
1631
|
-
argv[1] === "run" &&
|
|
1632
|
-
argv[2] === "observability-tool" &&
|
|
1633
|
-
argv[3] === "logs" &&
|
|
1634
|
-
argv[4] === "query"
|
|
1635
|
-
)
|
|
1636
|
-
return false;
|
|
1637
|
-
const sql = literalSqlArgumentIndex(argv);
|
|
1638
|
-
if (sql !== undefined)
|
|
1639
|
-
return argv.some((arg, index) => index !== sql && hasArgumentBraceExpansion(arg));
|
|
1640
|
-
return (argv[0]?.split("/").at(-1) === "awk" && !isAwkExecution(argv)) ||
|
|
1641
|
-
inlineProgramIndex(argv) !== undefined ||
|
|
1642
|
-
isPassiveJqCommand(argv)
|
|
1643
|
-
? false
|
|
1644
|
-
: hasArgumentBraceExpansion(argv.join(" "));
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
function apiRequestDataIndexes(argv: string[]): Set<number> | undefined {
|
|
1648
|
-
const start =
|
|
1649
|
-
argv[0] === "bun" && argv[1] === "run" && argv[2] === "api-tool"
|
|
1650
|
-
? 3
|
|
1651
|
-
: ["api-tool", "agent-tools-api"].includes(argv[0] ?? "")
|
|
1652
|
-
? 1
|
|
1653
|
-
: -1;
|
|
1654
|
-
if (start < 0 || argv[start] !== "request" || argv.length > 18) return undefined;
|
|
1655
|
-
const data = new Set<number>();
|
|
1656
|
-
const seen = new Set<string>();
|
|
1657
|
-
for (let index = start + 1; index < argv.length; index++) {
|
|
1658
|
-
const [flag = "", ...attached] = (argv[index] ?? "").split("=");
|
|
1659
|
-
if (
|
|
1660
|
-
!["--config", "--profile", "--path", "--method", "--query", "--body"].includes(flag) ||
|
|
1661
|
-
seen.has(flag)
|
|
1662
|
-
)
|
|
1663
|
-
return undefined;
|
|
1664
|
-
seen.add(flag);
|
|
1665
|
-
const value = attached.length ? attached.join("=") : argv[++index];
|
|
1666
|
-
if (!value || value.startsWith("--")) return undefined;
|
|
1667
|
-
if (flag === "--query" || flag === "--body") {
|
|
1668
|
-
try {
|
|
1669
|
-
JSON.parse(value);
|
|
1670
|
-
} catch {
|
|
1671
|
-
return undefined;
|
|
1672
|
-
}
|
|
1673
|
-
data.add(index);
|
|
1674
|
-
} else if (flag === "--method") {
|
|
1675
|
-
if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(value)) return undefined;
|
|
1676
|
-
} else if (flag === "--profile") {
|
|
1677
|
-
if (!/^[A-Za-z0-9_-]{1,128}$/.test(value)) return undefined;
|
|
1678
|
-
} else if (flag === "--config") {
|
|
1679
|
-
if (!/^(?:\.?\.?\/)?[A-Za-z0-9_./-]+$/.test(value)) return undefined;
|
|
1680
|
-
} else if (flag === "--path") {
|
|
1681
|
-
if (
|
|
1682
|
-
value.length > 16384 ||
|
|
1683
|
-
!/^\/[A-Za-z0-9_~!$&'()+,=:@./-]*$/.test(value) ||
|
|
1684
|
-
value.includes("//") ||
|
|
1685
|
-
value.split("/").some((part) => part.endsWith("."))
|
|
1686
|
-
)
|
|
1687
|
-
return undefined;
|
|
1688
|
-
}
|
|
1689
|
-
}
|
|
1690
|
-
return data;
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
function literalSqlArgumentIndex(argv: string[]): number | undefined {
|
|
1694
|
-
if (
|
|
1695
|
-
argv[0]?.split("/").at(-1) !== "bun" ||
|
|
1696
|
-
argv[1] !== "run" ||
|
|
1697
|
-
argv[2] !== "db-tool" ||
|
|
1698
|
-
!["sql", "query"].includes(argv[3] ?? "")
|
|
1699
|
-
)
|
|
1700
|
-
return undefined;
|
|
1701
|
-
let sql: number | undefined;
|
|
1702
|
-
const seen = new Set<string>();
|
|
1703
|
-
for (let index = 4; index < argv.length; index++) {
|
|
1704
|
-
const [option = "", ...attached] = (argv[index] ?? "").split("=");
|
|
1705
|
-
if (
|
|
1706
|
-
!["--sql", "--env", "--profile", "--limit", "--format"].includes(option) ||
|
|
1707
|
-
seen.has(option)
|
|
1708
|
-
)
|
|
1709
|
-
return undefined;
|
|
1710
|
-
seen.add(option);
|
|
1711
|
-
const value = attached.length ? attached.join("=") : argv[++index];
|
|
1712
|
-
if (!value || value.startsWith("--")) return undefined;
|
|
1713
|
-
if (option === "--sql") sql = index;
|
|
1714
|
-
else if (!/^[A-Za-z0-9_./-]+$/.test(value)) return undefined;
|
|
1715
|
-
}
|
|
1716
|
-
return sql;
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
function isLiteralTextProducer(argv: string[]): boolean {
|
|
1720
|
-
return (
|
|
1721
|
-
(argv[0]?.split("/").at(-1) === "git" && argv[1] === "commit") ||
|
|
1722
|
-
(isPassiveTextCommand(argv) && argv[0]?.split("/").at(-1) !== "head")
|
|
1723
|
-
);
|
|
1724
|
-
}
|
|
1725
|
-
|
|
1726
|
-
function awkProgramIndex(argv: string[]): number | undefined {
|
|
1727
|
-
if (argv[0]?.split("/").at(-1) !== "awk") return undefined;
|
|
1728
|
-
let index = 1;
|
|
1729
|
-
while (index < argv.length) {
|
|
1730
|
-
const arg = argv[index] ?? "";
|
|
1731
|
-
if (arg === "--") return index + 1 < argv.length ? index + 1 : undefined;
|
|
1732
|
-
if (arg === "-F") {
|
|
1733
|
-
if (argv[index + 1] === undefined) return undefined;
|
|
1734
|
-
index += 2;
|
|
1735
|
-
continue;
|
|
1736
|
-
}
|
|
1737
|
-
if (arg.startsWith("-F") && arg.length > 2) {
|
|
1738
|
-
index++;
|
|
1739
|
-
continue;
|
|
1740
|
-
}
|
|
1741
|
-
if (arg.startsWith("-")) return undefined;
|
|
1742
|
-
return index;
|
|
1743
|
-
}
|
|
1744
|
-
return undefined;
|
|
1745
|
-
}
|
|
1746
|
-
|
|
1747
|
-
function isAwkExecution(argv: string[]): boolean {
|
|
1748
|
-
if (argv[0]?.split("/").at(-1) !== "awk") return false;
|
|
1749
|
-
// Only a literal inline program with known-safe options establishes passive behavior.
|
|
1750
|
-
const index = awkProgramIndex(argv);
|
|
1751
|
-
const program = index === undefined ? undefined : argv[index];
|
|
1752
|
-
return (
|
|
1753
|
-
!program ||
|
|
1754
|
-
/\b(?:system|getline|ENVIRON)\b|\||\b(?:print|printf)\b[^\n]*>|@(?:include|load)/.test(
|
|
1755
|
-
program,
|
|
1756
|
-
) ||
|
|
1757
|
-
argv.slice((index ?? -1) + 1).some((arg) => arg.startsWith("-"))
|
|
1758
|
-
);
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
|
-
function isPassiveJqCommand(argv: string[]): boolean {
|
|
1762
|
-
return passiveJqProgramIndex(argv) !== undefined;
|
|
1763
|
-
}
|
|
1764
|
-
|
|
1765
|
-
/** Preserve explicit startup and cwd context across every command in a jq compound. */
|
|
1766
|
-
function hasProvedJqContext(words: string[]): boolean {
|
|
1767
|
-
let index = 0;
|
|
1768
|
-
while (["rtk", "command"].includes(words[index]?.split("/").at(-1) ?? "")) {
|
|
1769
|
-
const wrapper = words[index++]?.split("/").at(-1);
|
|
1770
|
-
if (
|
|
1771
|
-
(wrapper === "rtk" && words[index] === "proxy") ||
|
|
1772
|
-
(wrapper === "command" && words[index] === "--")
|
|
1773
|
-
)
|
|
1774
|
-
index++;
|
|
1775
|
-
}
|
|
1776
|
-
let argv = words.slice(index);
|
|
1777
|
-
if (argv[0]?.split("/").at(-1) === "env") {
|
|
1778
|
-
if (argv[1] !== "HOME=/dev/null") return false;
|
|
1779
|
-
argv = argv.slice(2);
|
|
1780
|
-
return isPassiveJqCommand(argv);
|
|
1781
|
-
}
|
|
1782
|
-
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
1783
|
-
if (/^[A-Za-z_]\w*=/.test(argv[0] ?? "")) return false;
|
|
1784
|
-
return (
|
|
1785
|
-
isReadonlyJqConsumer(argv) ||
|
|
1786
|
-
isProvedCwdSubprocessRole(argv) ||
|
|
1787
|
-
isStaticNavigationCommand(argv) ||
|
|
1788
|
-
isSafeLocalAssignmentCommand(argv) ||
|
|
1789
|
-
isLiteralHerdrRead(argv) ||
|
|
1790
|
-
isLiteralHerdrPrompt(argv) ||
|
|
1791
|
-
isLiteralInlineWriter(argv) ||
|
|
1792
|
-
isChecksumCommand(argv) ||
|
|
1793
|
-
pythonCompileFiles(argv) !== undefined ||
|
|
1794
|
-
DEFAULT_BLOCKED_CLI_TOOLS.some((tool) => tool.wrapper === name) ||
|
|
1795
|
-
(argv.length === 1 && ["true", "false", "pwd"].includes(name))
|
|
1796
|
-
);
|
|
1797
|
-
}
|
|
1798
|
-
|
|
1799
|
-
function jqFileArguments(argv: string[]): string[] {
|
|
1800
|
-
const program = passiveJqProgramIndex(argv);
|
|
1801
|
-
if (program !== undefined) return argv.slice(program + 1);
|
|
1802
|
-
// Unsupported forms retain conservative path evidence, including loader operands.
|
|
1803
|
-
const files: string[] = [];
|
|
1804
|
-
let index = 1;
|
|
1805
|
-
for (; index < argv.length; index++) {
|
|
1806
|
-
const arg = argv[index] ?? "";
|
|
1807
|
-
if (arg === "--") {
|
|
1808
|
-
index++;
|
|
1809
|
-
break;
|
|
1810
|
-
}
|
|
1811
|
-
if (/^-[rRcMsScn]+$/.test(arg)) continue;
|
|
1812
|
-
if (arg === "--arg" || arg === "--argjson") {
|
|
1813
|
-
index += 2;
|
|
1814
|
-
continue;
|
|
1815
|
-
}
|
|
1816
|
-
if (arg === "--rawfile" || arg === "--slurpfile") {
|
|
1817
|
-
files.push(argv[index + 2] ?? "");
|
|
1818
|
-
index += 2;
|
|
1819
|
-
continue;
|
|
1820
|
-
}
|
|
1821
|
-
if (arg.startsWith("-")) return argv.slice(1);
|
|
1822
|
-
break;
|
|
1823
|
-
}
|
|
1824
|
-
return [...files, ...argv.slice(index + 1)];
|
|
1825
|
-
}
|
|
1826
|
-
|
|
1827
|
-
/** File roles only, never evaluation or repository/cwd attestation. Unknown syntax fails closed. */
|
|
1828
|
-
function gitDiffFileArguments(args: string[]): string[] | undefined {
|
|
1829
|
-
const flags = [
|
|
1830
|
-
"patch",
|
|
1831
|
-
"no-patch",
|
|
1832
|
-
"raw",
|
|
1833
|
-
"patch-with-raw",
|
|
1834
|
-
"patch-with-stat",
|
|
1835
|
-
"numstat",
|
|
1836
|
-
"shortstat",
|
|
1837
|
-
"summary",
|
|
1838
|
-
"name-only",
|
|
1839
|
-
"name-status",
|
|
1840
|
-
"cached",
|
|
1841
|
-
"staged",
|
|
1842
|
-
"merge-base",
|
|
1843
|
-
"check",
|
|
1844
|
-
"full-index",
|
|
1845
|
-
"binary",
|
|
1846
|
-
"no-abbrev",
|
|
1847
|
-
"no-renames",
|
|
1848
|
-
"find-copies-harder",
|
|
1849
|
-
"text",
|
|
1850
|
-
"reverse",
|
|
1851
|
-
"exit-code",
|
|
1852
|
-
"quiet",
|
|
1853
|
-
"no-ext-diff",
|
|
1854
|
-
"no-textconv",
|
|
1855
|
-
"no-index",
|
|
1856
|
-
"no-color",
|
|
1857
|
-
"no-relative",
|
|
1858
|
-
"no-prefix",
|
|
1859
|
-
"default-prefix",
|
|
1860
|
-
"ignore-all-space",
|
|
1861
|
-
"ignore-space-change",
|
|
1862
|
-
"ignore-space-at-eol",
|
|
1863
|
-
"ignore-cr-at-eol",
|
|
1864
|
-
"ignore-blank-lines",
|
|
1865
|
-
"minimal",
|
|
1866
|
-
"patience",
|
|
1867
|
-
"histogram",
|
|
1868
|
-
"indent-heuristic",
|
|
1869
|
-
"no-indent-heuristic",
|
|
1870
|
-
"cc",
|
|
1871
|
-
"function-context",
|
|
1872
|
-
"pickaxe-all",
|
|
1873
|
-
"pickaxe-regex",
|
|
1874
|
-
];
|
|
1875
|
-
const data = [
|
|
1876
|
-
"src-prefix",
|
|
1877
|
-
"dst-prefix",
|
|
1878
|
-
"line-prefix",
|
|
1879
|
-
"word-diff-regex",
|
|
1880
|
-
"anchored",
|
|
1881
|
-
"diff-algorithm",
|
|
1882
|
-
"inter-hunk-context",
|
|
1883
|
-
"find-object",
|
|
1884
|
-
"skip-to",
|
|
1885
|
-
"rotate-to",
|
|
1886
|
-
"ignore-matching-lines",
|
|
1887
|
-
"stat-width",
|
|
1888
|
-
"stat-name-width",
|
|
1889
|
-
"stat-graph-width",
|
|
1890
|
-
"stat-count",
|
|
1891
|
-
"diff-filter",
|
|
1892
|
-
"output-indicator-new",
|
|
1893
|
-
"output-indicator-old",
|
|
1894
|
-
"output-indicator-context",
|
|
1895
|
-
"ws-error-highlight",
|
|
1896
|
-
"color-moved-ws",
|
|
1897
|
-
];
|
|
1898
|
-
const optional = [
|
|
1899
|
-
"stat",
|
|
1900
|
-
"unified",
|
|
1901
|
-
"color",
|
|
1902
|
-
"color-moved",
|
|
1903
|
-
"relative",
|
|
1904
|
-
"abbrev",
|
|
1905
|
-
"word-diff",
|
|
1906
|
-
"submodule",
|
|
1907
|
-
"dirstat",
|
|
1908
|
-
"dirstat-by-file",
|
|
1909
|
-
"find-renames",
|
|
1910
|
-
"find-copies",
|
|
1911
|
-
"break-rewrites",
|
|
1912
|
-
"ignore-submodules",
|
|
1913
|
-
];
|
|
1914
|
-
const names = [...flags, ...data, ...optional, "output"];
|
|
1915
|
-
const files: string[] = [];
|
|
1916
|
-
for (let index = 0; index < args.length; index++) {
|
|
1917
|
-
const arg = args[index] ?? "";
|
|
1918
|
-
if (arg === "--") return [...files, ...args.slice(index + 1)];
|
|
1919
|
-
if (!arg.startsWith("-") || arg === "-") {
|
|
1920
|
-
files.push(arg);
|
|
1921
|
-
continue;
|
|
1922
|
-
}
|
|
1923
|
-
if (arg.startsWith("--")) {
|
|
1924
|
-
const equals = arg.indexOf("=");
|
|
1925
|
-
const spelling = arg.slice(2, equals === -1 ? undefined : equals);
|
|
1926
|
-
const candidates = names.includes(spelling)
|
|
1927
|
-
? [spelling]
|
|
1928
|
-
: names.filter((name) => name.startsWith(spelling));
|
|
1929
|
-
if (!spelling || candidates.length !== 1) return undefined;
|
|
1930
|
-
const name = candidates[0] ?? "";
|
|
1931
|
-
if (flags.includes(name)) {
|
|
1932
|
-
if (equals !== -1) return undefined;
|
|
1933
|
-
continue;
|
|
1934
|
-
}
|
|
1935
|
-
if (optional.includes(name)) continue; // Optional values attach with =, never consume the next path.
|
|
1936
|
-
const value = equals === -1 ? args[++index] : arg.slice(equals + 1);
|
|
1937
|
-
if (value === undefined || (equals === -1 && value === "--")) return undefined;
|
|
1938
|
-
if (name === "output") {
|
|
1939
|
-
if (!value) return undefined;
|
|
1940
|
-
files.push(value);
|
|
1941
|
-
}
|
|
1942
|
-
continue;
|
|
1943
|
-
}
|
|
1944
|
-
for (let cursor = 1; cursor < arg.length; cursor++) {
|
|
1945
|
-
const option = arg[cursor] ?? "";
|
|
1946
|
-
if ("puszawbcRDW".includes(option)) continue;
|
|
1947
|
-
if ("UMCB".includes(option)) break; // Optional attached data occupies the rest of this word.
|
|
1948
|
-
if (!"OSGIl".includes(option)) return undefined;
|
|
1949
|
-
const attached = arg.slice(cursor + 1);
|
|
1950
|
-
const value = attached || args[++index];
|
|
1951
|
-
if (value === undefined || (!attached && value === "--")) return undefined;
|
|
1952
|
-
if (option === "O") {
|
|
1953
|
-
if (!value) return undefined;
|
|
1954
|
-
files.push(value);
|
|
1955
|
-
}
|
|
1956
|
-
break; // Required data consumes its attached/separate value, including option-looking patterns.
|
|
1957
|
-
}
|
|
1958
|
-
}
|
|
1959
|
-
return files;
|
|
1960
|
-
}
|
|
1961
|
-
|
|
1962
|
-
function hasUnsupportedGitDiffArguments(command: string): boolean {
|
|
1963
|
-
const parsed = parseStaticShellCommands(command);
|
|
1964
|
-
if (!parsed || typeof parsed === "string") return false;
|
|
1965
|
-
return parsed.pipelines.flat().some((words) => {
|
|
1966
|
-
const argv = unwrapStaticCommand(words);
|
|
1967
|
-
if (argv[0]?.split("/").at(-1) !== "git") return false;
|
|
1968
|
-
let index = 1;
|
|
1969
|
-
while (argv[index] === "-C" && argv[index + 1]) index += 2;
|
|
1970
|
-
return argv[index] === "diff" && gitDiffFileArguments(argv.slice(index + 1)) === undefined;
|
|
1971
|
-
});
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
/** Index in `args` where the command wrapped by `env` starts, or null when `env` only lists. */
|
|
1975
|
-
function environmentCommandStart(args: string[]): number | null {
|
|
1976
|
-
for (let i = 0; i < args.length; i++) {
|
|
1977
|
-
const arg = args[i] ?? "";
|
|
1978
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) continue;
|
|
1979
|
-
if (arg === "--") return i + 1 === args.length ? null : i + 1;
|
|
1980
|
-
if (arg === "-u" || arg === "--unset" || arg === "-C" || arg === "--chdir") {
|
|
1981
|
-
i++;
|
|
1982
|
-
continue;
|
|
1983
|
-
}
|
|
1984
|
-
if (arg === "-i" || arg === "--ignore-environment" || arg.startsWith("--chdir=")) continue;
|
|
1985
|
-
if (arg.startsWith("--unset=")) continue;
|
|
1986
|
-
if (arg.startsWith("-")) return null;
|
|
1987
|
-
return i;
|
|
1988
|
-
}
|
|
1989
|
-
return null;
|
|
1990
|
-
}
|
|
1991
|
-
|
|
1992
|
-
function isEnvironmentListing(argv: string[]): boolean {
|
|
1993
|
-
return environmentCommandStart(argv.slice(1)) === null;
|
|
1994
|
-
}
|
|
1995
|
-
|
|
1996
|
-
function unwrapEnvironmentCommand(argv: string[]): string[] | null {
|
|
1997
|
-
if (argv[0]?.split("/").at(-1) !== "env") return null;
|
|
1998
|
-
const args = argv.slice(1);
|
|
1999
|
-
const start = environmentCommandStart(args);
|
|
2000
|
-
return start === null ? null : args.slice(start);
|
|
2001
|
-
}
|
|
2002
|
-
|
|
2003
|
-
function isSafeLiteralInspection(argv: string[]): boolean {
|
|
2004
|
-
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
2005
|
-
return (
|
|
2006
|
-
["head", "tail"].includes(name) &&
|
|
2007
|
-
!argv.slice(1).some((arg) => /\.env(?:\.|$)|\.(?:pem|key)\b|secret|credential/i.test(arg))
|
|
2008
|
-
);
|
|
2009
|
-
}
|
|
2010
|
-
|
|
2011
|
-
function isSafeLiteralConsumer(argv: string[]): boolean {
|
|
2012
|
-
return (
|
|
2013
|
-
isSafeLiteralInspection(argv) ||
|
|
2014
|
-
(argv[0]?.split("/").at(-1) === "bun" &&
|
|
2015
|
-
argv[1] === "run" &&
|
|
2016
|
-
argv[2] === "gh-tool" &&
|
|
2017
|
-
argv.includes("--body-file"))
|
|
2018
|
-
);
|
|
2019
|
-
}
|
|
2020
|
-
|
|
2021
|
-
function unwrapStaticCommand(words: string[]): string[] {
|
|
2022
|
-
let argv = [...words];
|
|
2023
|
-
while (argv.length) {
|
|
2024
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[0] ?? "")) {
|
|
2025
|
-
argv.shift();
|
|
2026
|
-
continue;
|
|
2027
|
-
}
|
|
2028
|
-
const executable = argv[0]?.split("/").at(-1);
|
|
2029
|
-
if (executable === "env") {
|
|
2030
|
-
const wrapped = unwrapEnvironmentCommand(argv);
|
|
2031
|
-
if (!wrapped) break;
|
|
2032
|
-
argv = wrapped;
|
|
2033
|
-
} else if (executable === "rtk" || executable === "command") {
|
|
2034
|
-
argv.shift();
|
|
2035
|
-
if (executable === "rtk") {
|
|
2036
|
-
if (argv[0] === "proxy") argv.shift();
|
|
2037
|
-
else if (argv[0] === "read") argv[0] = "cat";
|
|
2038
|
-
}
|
|
2039
|
-
if (executable === "command" && argv[0] === "--") argv.shift();
|
|
2040
|
-
} else break;
|
|
2041
|
-
}
|
|
2042
|
-
return argv;
|
|
2043
|
-
}
|
|
2044
|
-
|
|
2045
|
-
function isAllowedEnvironmentRead(argv: string[], allowedNames: Set<string>): boolean {
|
|
2046
|
-
if (isAllowedPythonEnvironmentRead(argv, allowedNames)) return true;
|
|
2047
|
-
if (argv[0]?.split("/").at(-1) !== "printenv") return false;
|
|
2048
|
-
const args = argv.slice(1);
|
|
2049
|
-
if (args[0] === "--") args.shift();
|
|
2050
|
-
return args.length > 0 && args.every((name) => allowedNames.has(name));
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
/** Prove the complete, print-only Python program before treating its reads as approved. */
|
|
2054
|
-
function isAllowedPythonEnvironmentRead(argv: string[], allowedNames: Set<string>): boolean {
|
|
2055
|
-
const index = inlineProgramIndex(argv);
|
|
2056
|
-
if (inlineRuntimeKind(argv[0]) !== "python" || index === undefined || argv.length !== index + 1)
|
|
2057
|
-
return false;
|
|
2058
|
-
|
|
2059
|
-
const program = argv[index] ?? "";
|
|
2060
|
-
if (program.length > 65_536) return false;
|
|
2061
|
-
let position = 0;
|
|
2062
|
-
let foundRead = false;
|
|
2063
|
-
let importedJson = false;
|
|
2064
|
-
const skipInlineWhitespace = () => {
|
|
2065
|
-
while (/[\t ]/.test(program[position] ?? "")) position++;
|
|
2066
|
-
};
|
|
2067
|
-
const take = (value: string): boolean => {
|
|
2068
|
-
if (!program.startsWith(value, position)) return false;
|
|
2069
|
-
position += value.length;
|
|
2070
|
-
return true;
|
|
2071
|
-
};
|
|
2072
|
-
const takeIdentifier = (value: string): boolean => {
|
|
2073
|
-
if (
|
|
2074
|
-
!program.startsWith(value, position) ||
|
|
2075
|
-
/[A-Za-z0-9_]/.test(program[position + value.length] ?? "")
|
|
2076
|
-
)
|
|
2077
|
-
return false;
|
|
2078
|
-
position += value.length;
|
|
2079
|
-
return true;
|
|
2080
|
-
};
|
|
2081
|
-
const takeString = (): string | undefined => {
|
|
2082
|
-
skipInlineWhitespace();
|
|
2083
|
-
const quote = program[position];
|
|
2084
|
-
if ((quote !== "'" && quote !== '"') || program.startsWith(quote.repeat(3), position))
|
|
2085
|
-
return undefined;
|
|
2086
|
-
position++;
|
|
2087
|
-
const start = position;
|
|
2088
|
-
while (position < program.length && program[position] !== quote) {
|
|
2089
|
-
if (/[\\\r\n]/.test(program[position] ?? "")) return undefined;
|
|
2090
|
-
position++;
|
|
2091
|
-
}
|
|
2092
|
-
if (program[position] !== quote) return undefined;
|
|
2093
|
-
const value = program.slice(start, position);
|
|
2094
|
-
position++;
|
|
2095
|
-
return value;
|
|
2096
|
-
};
|
|
2097
|
-
const takeEnvironmentRead = (variable?: string): boolean => {
|
|
2098
|
-
skipInlineWhitespace();
|
|
2099
|
-
const takeName = (): boolean => {
|
|
2100
|
-
skipInlineWhitespace();
|
|
2101
|
-
if (variable !== undefined) return takeIdentifier(variable);
|
|
2102
|
-
const name = takeString();
|
|
2103
|
-
return name !== undefined && allowedNames.has(name);
|
|
2104
|
-
};
|
|
2105
|
-
const start = position;
|
|
2106
|
-
if (!takeIdentifier("os")) return false;
|
|
2107
|
-
if (!take(".")) {
|
|
2108
|
-
position = start;
|
|
2109
|
-
return false;
|
|
2110
|
-
}
|
|
2111
|
-
if (takeIdentifier("getenv")) {
|
|
2112
|
-
if (!take("(")) return false;
|
|
2113
|
-
if (!takeName()) return false;
|
|
2114
|
-
skipInlineWhitespace();
|
|
2115
|
-
if (take(",")) {
|
|
2116
|
-
if (takeString() === undefined) return false;
|
|
2117
|
-
skipInlineWhitespace();
|
|
2118
|
-
}
|
|
2119
|
-
if (!take(")")) return false;
|
|
2120
|
-
foundRead = true;
|
|
2121
|
-
return true;
|
|
2122
|
-
}
|
|
2123
|
-
if (!takeIdentifier("environ")) return false;
|
|
2124
|
-
if (take(".")) {
|
|
2125
|
-
if (!takeIdentifier("get") || !take("(")) return false;
|
|
2126
|
-
if (!takeName()) return false;
|
|
2127
|
-
skipInlineWhitespace();
|
|
2128
|
-
if (take(",")) {
|
|
2129
|
-
if (takeString() === undefined) return false;
|
|
2130
|
-
skipInlineWhitespace();
|
|
2131
|
-
}
|
|
2132
|
-
if (!take(")")) return false;
|
|
2133
|
-
foundRead = true;
|
|
2134
|
-
return true;
|
|
2135
|
-
}
|
|
2136
|
-
if (!take("[")) return false;
|
|
2137
|
-
if (!takeName()) return false;
|
|
2138
|
-
skipInlineWhitespace();
|
|
2139
|
-
if (!take("]")) return false;
|
|
2140
|
-
foundRead = true;
|
|
2141
|
-
return true;
|
|
2142
|
-
};
|
|
2143
|
-
const takeApprovedNames = (): boolean => {
|
|
2144
|
-
if (!take("[")) return false;
|
|
2145
|
-
for (let count = 0; count < 16; count++) {
|
|
2146
|
-
const name = takeString();
|
|
2147
|
-
if (name === undefined || !allowedNames.has(name)) return false;
|
|
2148
|
-
skipInlineWhitespace();
|
|
2149
|
-
if (take("]")) return true;
|
|
2150
|
-
if (!take(",")) return false;
|
|
2151
|
-
skipInlineWhitespace();
|
|
2152
|
-
if (take("]")) return true;
|
|
2153
|
-
}
|
|
2154
|
-
return false;
|
|
2155
|
-
};
|
|
2156
|
-
const takeComprehension = (): boolean => {
|
|
2157
|
-
const dictionary = take("{");
|
|
2158
|
-
if (!dictionary && !take("[")) return false;
|
|
2159
|
-
skipInlineWhitespace();
|
|
2160
|
-
const variable = dictionary
|
|
2161
|
-
? /^[A-Za-z_][A-Za-z0-9_]*/.exec(program.slice(position))?.[0]
|
|
2162
|
-
: /^os\.(?:getenv\(|environ(?:\.get\(|\[))[\t ]*([A-Za-z_][A-Za-z0-9_]*)/.exec(
|
|
2163
|
-
program.slice(position),
|
|
2164
|
-
)?.[1];
|
|
2165
|
-
if (!variable || ["os", "json", "print"].includes(variable)) return false;
|
|
2166
|
-
if (dictionary) {
|
|
2167
|
-
if (!takeIdentifier(variable)) return false;
|
|
2168
|
-
skipInlineWhitespace();
|
|
2169
|
-
if (!take(":")) return false;
|
|
2170
|
-
}
|
|
2171
|
-
if (!takeEnvironmentRead(variable)) return false;
|
|
2172
|
-
const loop = new RegExp(`[\\t ]+for[\\t ]+${variable}[\\t ]+in[\\t ]+`, "y");
|
|
2173
|
-
loop.lastIndex = position;
|
|
2174
|
-
const match = loop.exec(program);
|
|
2175
|
-
if (!match) return false;
|
|
2176
|
-
position = loop.lastIndex;
|
|
2177
|
-
if (!takeApprovedNames()) return false;
|
|
2178
|
-
skipInlineWhitespace();
|
|
2179
|
-
return take(dictionary ? "}" : "]");
|
|
2180
|
-
};
|
|
2181
|
-
const takeProjection = (): boolean => {
|
|
2182
|
-
const start = position;
|
|
2183
|
-
const previousRead = foundRead;
|
|
2184
|
-
if (takeComprehension()) return true;
|
|
2185
|
-
position = start;
|
|
2186
|
-
foundRead = previousRead;
|
|
2187
|
-
const dictionary = take("{");
|
|
2188
|
-
if (!dictionary && !take("[")) return false;
|
|
2189
|
-
const close = dictionary ? "}" : "]";
|
|
2190
|
-
for (let count = 0; count < 16; count++) {
|
|
2191
|
-
if (dictionary) {
|
|
2192
|
-
const key = takeString();
|
|
2193
|
-
if (key === undefined || !allowedNames.has(key)) return false;
|
|
2194
|
-
skipInlineWhitespace();
|
|
2195
|
-
if (!take(":")) return false;
|
|
2196
|
-
}
|
|
2197
|
-
if (!takeEnvironmentRead()) return false;
|
|
2198
|
-
skipInlineWhitespace();
|
|
2199
|
-
if (take(close)) return true;
|
|
2200
|
-
if (!take(",")) return false;
|
|
2201
|
-
skipInlineWhitespace();
|
|
2202
|
-
if (take(close)) return true;
|
|
2203
|
-
}
|
|
2204
|
-
return false;
|
|
2205
|
-
};
|
|
2206
|
-
const takeJsonDisplay = (): boolean => {
|
|
2207
|
-
if (!importedJson || !takeIdentifier("json") || !take(".dumps")) return false;
|
|
2208
|
-
skipInlineWhitespace();
|
|
2209
|
-
if (!take("(")) return false;
|
|
2210
|
-
skipInlineWhitespace();
|
|
2211
|
-
if (!takeProjection()) return false;
|
|
2212
|
-
skipInlineWhitespace();
|
|
2213
|
-
return take(")");
|
|
2214
|
-
};
|
|
2215
|
-
const takeExpression = (): boolean => {
|
|
2216
|
-
const takeTerm = (): boolean => {
|
|
2217
|
-
skipInlineWhitespace();
|
|
2218
|
-
return program.startsWith("json", position)
|
|
2219
|
-
? takeJsonDisplay()
|
|
2220
|
-
: takeString() !== undefined || takeEnvironmentRead();
|
|
2221
|
-
};
|
|
2222
|
-
if (!takeTerm()) return false;
|
|
2223
|
-
skipInlineWhitespace();
|
|
2224
|
-
while (take("+")) {
|
|
2225
|
-
if (!takeTerm()) return false;
|
|
2226
|
-
skipInlineWhitespace();
|
|
2227
|
-
}
|
|
2228
|
-
return true;
|
|
2229
|
-
};
|
|
2230
|
-
const takePrint = (): boolean => {
|
|
2231
|
-
skipInlineWhitespace();
|
|
2232
|
-
if (!takeIdentifier("print")) return false;
|
|
2233
|
-
skipInlineWhitespace();
|
|
2234
|
-
if (!take("(")) return false;
|
|
2235
|
-
skipInlineWhitespace();
|
|
2236
|
-
if (take(")")) return true;
|
|
2237
|
-
while (true) {
|
|
2238
|
-
if (!takeExpression()) return false;
|
|
2239
|
-
skipInlineWhitespace();
|
|
2240
|
-
if (take(")")) return true;
|
|
2241
|
-
if (!take(",")) return false;
|
|
2242
|
-
skipInlineWhitespace();
|
|
2243
|
-
if (take(")")) return true;
|
|
2244
|
-
}
|
|
2245
|
-
};
|
|
2246
|
-
const skipSeparators = (): void => {
|
|
2247
|
-
while (true) {
|
|
2248
|
-
skipInlineWhitespace();
|
|
2249
|
-
if (take(";") || take("\n") || take("\r\n")) continue;
|
|
2250
|
-
break;
|
|
2251
|
-
}
|
|
2252
|
-
};
|
|
2253
|
-
|
|
2254
|
-
skipInlineWhitespace();
|
|
2255
|
-
if (!takeIdentifier("import")) return false;
|
|
2256
|
-
skipInlineWhitespace();
|
|
2257
|
-
const jsonFirst = takeIdentifier("json");
|
|
2258
|
-
if (!jsonFirst && !takeIdentifier("os")) return false;
|
|
2259
|
-
importedJson = jsonFirst;
|
|
2260
|
-
skipInlineWhitespace();
|
|
2261
|
-
if (take(",")) {
|
|
2262
|
-
skipInlineWhitespace();
|
|
2263
|
-
if (!takeIdentifier(jsonFirst ? "os" : "json")) return false;
|
|
2264
|
-
importedJson = true;
|
|
2265
|
-
} else if (jsonFirst) return false;
|
|
2266
|
-
skipInlineWhitespace();
|
|
2267
|
-
if (!(take(";") || take("\n") || take("\r\n"))) return false;
|
|
2268
|
-
skipSeparators();
|
|
2269
|
-
if (!importedJson && takeIdentifier("import")) {
|
|
2270
|
-
skipInlineWhitespace();
|
|
2271
|
-
if (!takeIdentifier("json")) return false;
|
|
2272
|
-
importedJson = true;
|
|
2273
|
-
skipInlineWhitespace();
|
|
2274
|
-
if (!(take(";") || take("\n") || take("\r\n"))) return false;
|
|
2275
|
-
skipSeparators();
|
|
2276
|
-
}
|
|
2277
|
-
while (position < program.length) {
|
|
2278
|
-
if (!takePrint()) return false;
|
|
2279
|
-
skipInlineWhitespace();
|
|
2280
|
-
if (position === program.length) break;
|
|
2281
|
-
if (!(take(";") || take("\n") || take("\r\n"))) return false;
|
|
2282
|
-
skipSeparators();
|
|
2283
|
-
}
|
|
2284
|
-
return foundRead;
|
|
2285
|
-
}
|
|
2286
|
-
|
|
2287
|
-
function hasExecutionOption(args: string[], longOptions: string[], shortOption?: string): boolean {
|
|
2288
|
-
return args.some((arg) => {
|
|
2289
|
-
const flag = arg.split("=", 1)[0] ?? "";
|
|
2290
|
-
if (flag.startsWith("--") && flag.length > 2) {
|
|
2291
|
-
// Git supports long-option abbreviations. Refuse ambiguous prefixes too.
|
|
2292
|
-
return longOptions.some((option) => option.startsWith(flag.slice(2)));
|
|
2293
|
-
}
|
|
2294
|
-
return shortOption !== undefined && /^-[^-]/.test(flag) && flag.includes(shortOption);
|
|
2295
|
-
});
|
|
2296
|
-
}
|
|
2297
|
-
|
|
2298
|
-
function passivePrintfFormatIndex(argv: string[]): number | undefined {
|
|
2299
|
-
const index = argv[1] === "--" ? 2 : 1;
|
|
2300
|
-
const format = argv[index];
|
|
2301
|
-
if (format === undefined || (index === 1 && format.startsWith("-"))) return undefined;
|
|
2302
|
-
// Ignore escaped percent signs; %n writes a shell variable named by a data argument.
|
|
2303
|
-
return /%[-+ #0]*(?:\d+|\*)?(?:\.(?:\d+|\*))?[hljztL]*n/.test(format.replaceAll("%%", ""))
|
|
2304
|
-
? undefined
|
|
2305
|
-
: index;
|
|
2306
|
-
}
|
|
2307
|
-
|
|
2308
|
-
/** Unknown values are permitted only in operands that navigate without displaying the value. */
|
|
2309
|
-
function isNavigationOperand(argv: string[], marker: string): boolean {
|
|
2310
|
-
const name = argv[0]?.split("/").at(-1);
|
|
2311
|
-
if (name === "cd") {
|
|
2312
|
-
const index = argv[1] === "--" ? 2 : 1;
|
|
2313
|
-
return argv.length === index + 1 && !argv[0]?.includes(marker);
|
|
2314
|
-
}
|
|
2315
|
-
if (name !== "git") return false;
|
|
2316
|
-
let index = 1;
|
|
2317
|
-
while (argv[index] === "-C" && argv[index + 1]) index += 2;
|
|
2318
|
-
return (
|
|
2319
|
-
["status", "rev-parse", "rev-list"].includes(argv[index] ?? "") &&
|
|
2320
|
-
!argv.slice(index).some((arg) => arg.includes(marker)) &&
|
|
2321
|
-
!argv[0]?.includes(marker)
|
|
2322
|
-
);
|
|
2323
|
-
}
|
|
2324
|
-
|
|
2325
|
-
/** Literal directory operands only; navigation is never a pipeline consumer. */
|
|
2326
|
-
function isStaticNavigationCommand(argv: string[]): boolean {
|
|
2327
|
-
const path = (value: string | undefined) => /^[A-Za-z0-9_./][A-Za-z0-9_./ -]*$/.test(value ?? "");
|
|
2328
|
-
if (argv[0] === "cd")
|
|
2329
|
-
return isNavigationOperand(argv, "\0") && path(argv[argv[1] === "--" ? 2 : 1]);
|
|
2330
|
-
if (argv[0] !== "git") return false;
|
|
2331
|
-
let index = 1;
|
|
2332
|
-
while (argv[index] === "-C") {
|
|
2333
|
-
if (!path(argv[index + 1])) return false;
|
|
2334
|
-
index += 2;
|
|
2335
|
-
}
|
|
2336
|
-
return (
|
|
2337
|
-
isNavigationOperand(argv, "\0") ||
|
|
2338
|
-
(index > 1 && isPassiveTextCommand(["git", ...argv.slice(index)]))
|
|
2339
|
-
);
|
|
2340
|
-
}
|
|
2341
|
-
|
|
2342
|
-
/** Literal wrappers and exact directory-operation roles; unknown consumers keep refusal. */
|
|
2343
|
-
function isProvedDirectoryCommand(words: string[]): boolean {
|
|
2344
|
-
const argv = unwrapStaticCommand(words);
|
|
2345
|
-
if (
|
|
2346
|
-
!words
|
|
2347
|
-
.slice(0, words.length - argv.length)
|
|
2348
|
-
.every((word) => ["rtk", "proxy", "command", "--"].includes(word))
|
|
2349
|
-
)
|
|
2350
|
-
return false;
|
|
2351
|
-
if (argv[0] === "cd") return isStaticNavigationCommand(argv);
|
|
2352
|
-
if (argv[0] !== "git") return false;
|
|
2353
|
-
let index = 1;
|
|
2354
|
-
while (argv[index] === "-C") {
|
|
2355
|
-
if (!isStaticNavigationCommand(["cd", argv[index + 1] ?? ""])) return false;
|
|
2356
|
-
index += 2;
|
|
2357
|
-
}
|
|
2358
|
-
const args = argv.slice(index);
|
|
2359
|
-
if (args[0] === "status")
|
|
2360
|
-
return args.length === 1 || (args.length === 2 && args[1] === "--short");
|
|
2361
|
-
if (args[0] === "fetch")
|
|
2362
|
-
return (
|
|
2363
|
-
args.length >= 2 &&
|
|
2364
|
-
args.length <= 3 &&
|
|
2365
|
-
args.slice(1).every((arg) => /^[A-Za-z0-9_][A-Za-z0-9_./-]*$/.test(arg))
|
|
2366
|
-
);
|
|
2367
|
-
return (
|
|
2368
|
-
(args.length === 2 && args[0] === "rev-parse" && args[1] === "HEAD") ||
|
|
2369
|
-
(args.length === 3 && args[0] === "rev-list" && args[1] === "--count" && args[2] === "HEAD")
|
|
2370
|
-
);
|
|
2371
|
-
}
|
|
2372
|
-
|
|
2373
|
-
/** Prove a single cd whose uses are gated on success, without simulating shell state. */
|
|
2374
|
-
function hasProvedNavigationSequence(command: string): boolean {
|
|
2375
|
-
const literal = literalBindingText(command, "", "");
|
|
2376
|
-
const parsed = parseStaticShellCommands(command);
|
|
2377
|
-
if (!literal || !parsed || typeof parsed === "string") return false;
|
|
2378
|
-
const commands = parsed.pipelines.flat().map((words) => unwrapProofCommand(words, "\0"));
|
|
2379
|
-
const changesDirectory = commands.filter((argv) => argv?.[0] === "cd");
|
|
2380
|
-
if (
|
|
2381
|
-
!commands.some((argv) => argv && (argv[0] === "cd" || (argv[0] === "git" && argv[1] === "-C")))
|
|
2382
|
-
)
|
|
2383
|
-
return true;
|
|
2384
|
-
if (
|
|
2385
|
-
/\|\||(^|[^&])&(?!&)/.test(literal.syntax.replace(/[<>]&[0-9-]+/g, "")) ||
|
|
2386
|
-
parsed.redirects.some(({ target }) => !target.startsWith("/")) ||
|
|
2387
|
-
changesDirectory.length > 1
|
|
2388
|
-
)
|
|
2389
|
-
return false;
|
|
2390
|
-
if (!changesDirectory.length) return true;
|
|
2391
|
-
// Only the final AND list can use this directory. A failing cd never reaches its readers.
|
|
2392
|
-
// Trim real trailing whitespace, not the spaces masking final quoted argv words.
|
|
2393
|
-
const syntax = literal.syntax.slice(0, command.replace(/[ \t\n]+$/g, "").length);
|
|
2394
|
-
const boundary = Math.max(syntax.lastIndexOf(";"), syntax.lastIndexOf("\n"));
|
|
2395
|
-
const tail = parseStaticShellCommands(command.slice(boundary + 1, syntax.length));
|
|
2396
|
-
if (!tail || typeof tail === "string" || tail.pipelines.length < 2) return false;
|
|
2397
|
-
const first = tail.pipelines[0];
|
|
2398
|
-
const argv = unwrapProofCommand(first?.[0] ?? [], "\0");
|
|
2399
|
-
return (
|
|
2400
|
-
first?.length === 1 &&
|
|
2401
|
-
argv?.[0] === "cd" &&
|
|
2402
|
-
isStaticNavigationCommand(argv) &&
|
|
2403
|
-
syntax.slice(boundary + 1).match(/&&|[;&|\n]/)?.[0] === "&&"
|
|
2404
|
-
);
|
|
2405
|
-
}
|
|
2406
|
-
|
|
2407
|
-
function isPassiveTextCommand(argv: string[]): boolean {
|
|
2408
|
-
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
2409
|
-
const args = argv.slice(1);
|
|
2410
|
-
// These commands consume literal text. Execution options are not exceptions.
|
|
2411
|
-
if (name === "printf") return passivePrintfFormatIndex(argv) !== undefined;
|
|
2412
|
-
// Native cut only selects input data; its file operands still receive shared path checks.
|
|
2413
|
-
if (["echo", "grep", "head", "tail", "cut"].includes(name)) {
|
|
2414
|
-
return true;
|
|
2415
|
-
}
|
|
2416
|
-
if (name === "rg") return !hasExecutionOption(args, ["pre", "hostname-bin"]);
|
|
2417
|
-
if (name === "awk") return !isAwkExecution(argv);
|
|
2418
|
-
if (name === "sed") return isReadonlySedPrint(argv);
|
|
2419
|
-
if (name === "jq") return isPassiveJqCommand(argv);
|
|
2420
|
-
return (
|
|
2421
|
-
(name === "git" && args[0] === "status") ||
|
|
2422
|
-
(name === "git" &&
|
|
2423
|
-
args[0] === "grep" &&
|
|
2424
|
-
!hasExecutionOption(args, ["open-files-in-pager", "textconv"], "O"))
|
|
2425
|
-
);
|
|
2426
|
-
}
|
|
2427
|
-
|
|
2428
|
-
/** Native data consumers only; ordinary path, expansion and redirect checks still apply. */
|
|
2429
|
-
function isReadonlyJqConsumer(argv: string[]): boolean {
|
|
2430
|
-
if (isPassiveTextCommand(argv)) return true;
|
|
2431
|
-
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
2432
|
-
if (name === "sort")
|
|
2433
|
-
return argv
|
|
2434
|
-
.slice(1)
|
|
2435
|
-
.every(
|
|
2436
|
-
(arg) =>
|
|
2437
|
-
!arg.startsWith("-") || arg === "-" || arg === "--" || /^-[bdfgMhnRruVszcC]+$/.test(arg),
|
|
2438
|
-
);
|
|
2439
|
-
if (name === "uniq") return isReadonlyUniqConsumer(argv);
|
|
2440
|
-
if (["cat", "wc"].includes(name)) return isProvedCwdSubprocessRole(argv);
|
|
2441
|
-
return name === "tr";
|
|
2442
|
-
}
|
|
2443
|
-
|
|
2444
|
-
/** uniq INPUT is a read; uniq INPUT OUTPUT is a write and cannot acquire this proof. */
|
|
2445
|
-
function isReadonlyUniqConsumer(argv: string[]): boolean {
|
|
2446
|
-
let operands = 0;
|
|
2447
|
-
let options = true;
|
|
2448
|
-
for (let index = 1; index < argv.length; index++) {
|
|
2449
|
-
const arg = argv[index] ?? "";
|
|
2450
|
-
if (options && arg === "--") {
|
|
2451
|
-
options = false;
|
|
2452
|
-
continue;
|
|
2453
|
-
}
|
|
2454
|
-
if (options) {
|
|
2455
|
-
if (/^-[cduiz]+$|^--(?:count|repeated|unique|ignore-case|zero-terminated)$/.test(arg))
|
|
2456
|
-
continue;
|
|
2457
|
-
const short = /^-[fsw]([0-9]*)$/.exec(arg);
|
|
2458
|
-
const long = /^--(?:skip-fields|skip-chars|check-chars)(?:=(.*))?$/.exec(arg);
|
|
2459
|
-
if (short || long) {
|
|
2460
|
-
const attached = short ? short[1] || undefined : long?.[1];
|
|
2461
|
-
const value = attached === undefined ? argv[++index] : attached;
|
|
2462
|
-
if (!/^[0-9]+$/.test(value ?? "")) return false;
|
|
2463
|
-
continue;
|
|
2464
|
-
}
|
|
2465
|
-
if (arg.startsWith("-") && arg !== "-") return false;
|
|
2466
|
-
}
|
|
2467
|
-
options = false;
|
|
2468
|
-
if (++operands > 1) return false;
|
|
2469
|
-
}
|
|
2470
|
-
return true;
|
|
2471
|
-
}
|
|
2472
|
-
|
|
2473
|
-
/** One print program with optional numeric or escaped-regex addresses; no callbacks. */
|
|
2474
|
-
function isReadonlySedPrint(argv: string[]): boolean {
|
|
2475
|
-
if (argv[0]?.split("/").at(-1) !== "sed") return false;
|
|
2476
|
-
let index = 1;
|
|
2477
|
-
while (/^-[nE]+$/.test(argv[index] ?? "")) index++;
|
|
2478
|
-
if (argv[index] === "--") index++;
|
|
2479
|
-
const address = String.raw`(?:[0-9]+|\$|/(?:\\[^\r\n]|[^/\\\r\n])*/)`;
|
|
2480
|
-
return (
|
|
2481
|
-
new RegExp(`^(?:${address}(?:,${address})?)?p$`).test(argv[index] ?? "") &&
|
|
2482
|
-
argv.slice(index + 1).every((arg) => !arg.startsWith("-"))
|
|
2483
|
-
);
|
|
2484
|
-
}
|
|
2485
|
-
|
|
2486
|
-
function gitCommitExecutionArguments(argv: string[]): string[] {
|
|
2487
|
-
if (argv[0]?.split("/").at(-1) !== "git" || argv[1] !== "commit") return argv;
|
|
2488
|
-
const executionArguments = [...argv];
|
|
2489
|
-
for (let index = 2; index < argv.length && argv[index] !== "--"; index++) {
|
|
2490
|
-
const argument = argv[index] ?? "";
|
|
2491
|
-
if (argument === "-m" || argument === "--message") {
|
|
2492
|
-
if (index + 1 < argv.length) executionArguments[++index] = "";
|
|
2493
|
-
} else if (argument.startsWith("--message=")) {
|
|
2494
|
-
executionArguments[index] = "--message=";
|
|
2495
|
-
} else if (argument.startsWith("-m") && argument.length > 2) {
|
|
2496
|
-
executionArguments[index] = "-m";
|
|
2497
|
-
}
|
|
2498
|
-
}
|
|
2499
|
-
return executionArguments;
|
|
2500
|
-
}
|
|
2501
|
-
|
|
2502
|
-
function hasStaticEnvironmentRead(
|
|
2503
|
-
argv: string[],
|
|
2504
|
-
allowedNames: Set<string>,
|
|
2505
|
-
allowInventory = false,
|
|
2506
|
-
): boolean {
|
|
2507
|
-
const name = argv[0]?.split("/").at(-1);
|
|
2508
|
-
if (isLiteralHerdrPrompt(argv)) return false;
|
|
2509
|
-
if (
|
|
2510
|
-
["sh", "bash", "zsh"].includes(name ?? "") &&
|
|
2511
|
-
hasExecutionOption(argv.slice(1), ["command"], "c") &&
|
|
2512
|
-
argv.slice(1).some((arg) => /\$\{?[A-Za-z_]/.test(arg))
|
|
2513
|
-
) {
|
|
2514
|
-
return true;
|
|
2515
|
-
}
|
|
2516
|
-
if (
|
|
2517
|
-
name === "docker" &&
|
|
2518
|
-
argv.some((arg) => ["sh", "bash", "zsh"].includes(arg)) &&
|
|
2519
|
-
argv.some((arg) => /[$`]/.test(arg))
|
|
2520
|
-
)
|
|
2521
|
-
return true;
|
|
2522
|
-
if (name === "awk" && argv.slice(1).some((arg) => /\bENVIRON\b/.test(arg))) return true;
|
|
2523
|
-
if (name === "printenv") return !isAllowedEnvironmentRead(argv, allowedNames);
|
|
2524
|
-
if (name === "env") {
|
|
2525
|
-
const wrapped = unwrapEnvironmentCommand(argv);
|
|
2526
|
-
return wrapped === null
|
|
2527
|
-
? isEnvironmentListing(argv)
|
|
2528
|
-
: hasStaticEnvironmentRead(unwrapStaticCommand(wrapped), allowedNames, allowInventory);
|
|
2529
|
-
}
|
|
2530
|
-
if (name === "jq") return !isPassiveJqCommand(argv);
|
|
2531
|
-
if (isUnsupportedInlineRuntime(argv)) return true;
|
|
2532
|
-
const programIndex = inlineProgramIndex(argv);
|
|
2533
|
-
if (programIndex !== undefined)
|
|
2534
|
-
return hasInlineProgramExecution(
|
|
2535
|
-
argv[programIndex] ?? "",
|
|
2536
|
-
inlineRuntimeKind(argv[0]) === "python",
|
|
2537
|
-
allowInventory && inlineRuntimeKind(argv[0]) === "node",
|
|
2538
|
-
);
|
|
2539
|
-
if (isPassiveTextCommand(argv)) return false;
|
|
2540
|
-
// Any command may re-parse an argument as shell code (trap, find -exec, awk system()).
|
|
2541
|
-
return (
|
|
2542
|
-
hasCommandArgumentBraceExpansion(argv) ||
|
|
2543
|
-
([
|
|
2544
|
-
"sh",
|
|
2545
|
-
"bash",
|
|
2546
|
-
"zsh",
|
|
2547
|
-
"eval",
|
|
2548
|
-
"source",
|
|
2549
|
-
"find",
|
|
2550
|
-
"xargs",
|
|
2551
|
-
"trap",
|
|
2552
|
-
"awk",
|
|
2553
|
-
"git",
|
|
2554
|
-
"sort",
|
|
2555
|
-
"jq",
|
|
2556
|
-
"docker",
|
|
2557
|
-
].includes(name ?? "") &&
|
|
2558
|
-
mentionsEnvironmentRead(gitCommitExecutionArguments(argv).join(" ")))
|
|
2559
|
-
);
|
|
2560
|
-
}
|
|
2561
|
-
|
|
2562
|
-
function commandChainSources(command: string): string[] | undefined {
|
|
2563
|
-
const parts: string[] = [];
|
|
2564
|
-
let start = 0;
|
|
2565
|
-
let quote: "'" | '"' | "`" | undefined;
|
|
2566
|
-
let depth = 0;
|
|
2567
|
-
for (let index = 0; index < command.length; index++) {
|
|
2568
|
-
const char = command[index];
|
|
2569
|
-
if (char === "\\" && quote !== "'") {
|
|
2570
|
-
index++;
|
|
2571
|
-
} else if (quote) {
|
|
2572
|
-
if (char === quote) quote = undefined;
|
|
2573
|
-
} else if (char === "'" || char === '"' || char === "`") {
|
|
2574
|
-
quote = char;
|
|
2575
|
-
} else if (char === "#") {
|
|
2576
|
-
return undefined;
|
|
2577
|
-
} else if (char === "(") {
|
|
2578
|
-
depth++;
|
|
2579
|
-
} else if (char === ")") {
|
|
2580
|
-
if (--depth < 0) return undefined;
|
|
2581
|
-
} else if (!depth && (char === ";" || command.startsWith("&&", index))) {
|
|
2582
|
-
const part = command.slice(start, index).trim();
|
|
2583
|
-
if (!part || parts.length >= 31) return undefined;
|
|
2584
|
-
parts.push(part);
|
|
2585
|
-
if (char === "&") index++;
|
|
2586
|
-
start = index + 1;
|
|
2587
|
-
}
|
|
2588
|
-
}
|
|
2589
|
-
const last = command.slice(start).trim();
|
|
2590
|
-
return !quote && !depth && last && parts.length ? [...parts, last] : undefined;
|
|
2591
|
-
}
|
|
2592
|
-
|
|
2593
|
-
type LiteralChainPart = { source: string; argv: string[]; redirects: boolean };
|
|
2594
|
-
|
|
2595
|
-
function literalCommandChain(command: string): LiteralChainPart[] | undefined {
|
|
2596
|
-
const literal = literalBindingText(command, "", "");
|
|
2597
|
-
if (!literal || /[|&\r\n]/.test(literal.syntax.replaceAll("&&", ""))) return undefined;
|
|
2598
|
-
const boundaries = [...literal.syntax.matchAll(/&&|;/g)];
|
|
2599
|
-
if (!boundaries.length || boundaries.length > 31) return undefined;
|
|
2600
|
-
const parts: LiteralChainPart[] = [];
|
|
2601
|
-
let start = 0;
|
|
2602
|
-
for (const end of [...boundaries.map((match) => match.index), command.length]) {
|
|
2603
|
-
const source = command.slice(start, end).trim();
|
|
2604
|
-
const parsed = parseStaticShellCommands(source);
|
|
2605
|
-
if (
|
|
2606
|
-
!parsed ||
|
|
2607
|
-
typeof parsed === "string" ||
|
|
2608
|
-
parsed.pipelines.length !== 1 ||
|
|
2609
|
-
parsed.pipelines[0]?.length !== 1
|
|
2610
|
-
)
|
|
2611
|
-
return undefined;
|
|
2612
|
-
const argv = unwrapProofCommand(parsed.pipelines[0][0] ?? [], "\0");
|
|
2613
|
-
if (!argv) return undefined;
|
|
2614
|
-
parts.push({ source, argv, redirects: parsed.redirects.length > 0 });
|
|
2615
|
-
start = end + (literal.syntax.startsWith("&&", end) ? 2 : 1);
|
|
2616
|
-
}
|
|
2617
|
-
return parts;
|
|
2618
|
-
}
|
|
2619
|
-
|
|
2620
|
-
function isIndependentHerdrOperation(argv: string[]): boolean {
|
|
2621
|
-
if (isLiteralHerdrPrompt(argv)) return argv.length === 5;
|
|
2622
|
-
if (argv[0] !== "herdr") return false;
|
|
2623
|
-
if (argv[1] === "agent" && argv[2] === "start") {
|
|
2624
|
-
const separator = argv.indexOf("--", 3);
|
|
2625
|
-
const options = argv.slice(3, separator < 0 ? undefined : separator);
|
|
2626
|
-
const kinds = options.filter((arg) => arg === "--kind" || arg.startsWith("--kind="));
|
|
2627
|
-
return (
|
|
2628
|
-
kinds.length === 1 &&
|
|
2629
|
-
kinds[0] === "--kind" &&
|
|
2630
|
-
options[options.indexOf("--kind") + 1] === "codex"
|
|
2631
|
-
);
|
|
2632
|
-
}
|
|
2633
|
-
if (argv[1] !== "pane") return false;
|
|
2634
|
-
if (argv[2] === "run")
|
|
2635
|
-
return argv.length === 5 && argv[4] === "exec bash ~/.codex/codex-clean.sh bash --norc -i";
|
|
2636
|
-
if (argv[2] !== "wait-output" || argv.length < 6) return false;
|
|
2637
|
-
const seen = new Set<string>();
|
|
2638
|
-
for (let index = 4; index < argv.length; index += 2) {
|
|
2639
|
-
const option = argv[index] ?? "";
|
|
2640
|
-
if (seen.has(option)) return false;
|
|
2641
|
-
seen.add(option);
|
|
2642
|
-
if (!["--match", "--regex", "--timeout"].includes(option) || !argv[index + 1]) return false;
|
|
2643
|
-
if (argv[index] === "--timeout" && !/^\d+$/.test(argv[index + 1] ?? "")) return false;
|
|
2644
|
-
}
|
|
2645
|
-
return true;
|
|
2646
|
-
}
|
|
2647
|
-
|
|
2648
|
-
function isIndependentDataCopy(argv: string[], destinations: string[]): boolean {
|
|
2649
|
-
if (!destinations.length || argv[0] !== "cp" || argv.length !== 3) return false;
|
|
2650
|
-
const source = argv[1] ?? "";
|
|
2651
|
-
const target = argv[2] ?? "";
|
|
2652
|
-
const literalPath = (path: string) =>
|
|
2653
|
-
/^\/[A-Za-z0-9_./ -]+$/.test(path) &&
|
|
2654
|
-
!path.split("/").some((part) => part === "." || part === "..");
|
|
2655
|
-
if (!/^(?:~\/)?[A-Za-z0-9_./-]+$/.test(source) || source.startsWith("-") || !literalPath(target))
|
|
2656
|
-
return false;
|
|
2657
|
-
try {
|
|
2658
|
-
const targetPath = target.replace(/\/+$/, "");
|
|
2659
|
-
const targetStat = lstatSync(targetPath, { throwIfNoEntry: false });
|
|
2660
|
-
if (target.endsWith("/") && !targetStat?.isDirectory()) return false;
|
|
2661
|
-
const destination = targetStat?.isDirectory()
|
|
2662
|
-
? posix.join(targetPath, posix.basename(source))
|
|
2663
|
-
: targetPath;
|
|
2664
|
-
for (const directory of destinations) {
|
|
2665
|
-
if (!literalPath(directory)) continue;
|
|
2666
|
-
const root = directory.replace(/\/+$/, "");
|
|
2667
|
-
if (!destination.startsWith(root + "/")) continue;
|
|
2668
|
-
if (!lstatSync(root).isDirectory() || realpathSync(root) !== root) return false;
|
|
2669
|
-
const parts = destination.slice(root.length + 1).split("/");
|
|
2670
|
-
let current = root;
|
|
2671
|
-
for (const [index, part] of parts.entries()) {
|
|
2672
|
-
current += "/" + part;
|
|
2673
|
-
const stat = lstatSync(current, { throwIfNoEntry: false });
|
|
2674
|
-
const last = index === parts.length - 1;
|
|
2675
|
-
if (!stat) return last;
|
|
2676
|
-
if (stat.isSymbolicLink() || (!last && !stat.isDirectory())) return false;
|
|
2677
|
-
if (realpathSync(current) !== current) return false;
|
|
2678
|
-
}
|
|
2679
|
-
return true;
|
|
2680
|
-
}
|
|
2681
|
-
} catch {
|
|
2682
|
-
return false;
|
|
2683
|
-
}
|
|
2684
|
-
return false;
|
|
2685
|
-
}
|
|
2686
|
-
|
|
2687
|
-
function isLiteralHerdrPrompt(argv: string[]): boolean {
|
|
2688
|
-
return argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt" && argv.length >= 5;
|
|
2689
|
-
}
|
|
2690
|
-
|
|
2691
|
-
function isStaticHerdrPrompt(command: string): boolean {
|
|
2692
|
-
// Preserve shell word boundaries and executable identity before proving composition.
|
|
2693
|
-
const literal = literalBindingText(command, "", "");
|
|
2694
|
-
if (!literal) return false;
|
|
2695
|
-
const parsed = parseStaticShellCommands(command);
|
|
2696
|
-
if (!parsed || typeof parsed === "string") return false;
|
|
2697
|
-
const commands = parsed.pipelines.flat().map((words) => unwrapProofCommand(words, "\0"));
|
|
2698
|
-
const prompts = commands.filter((argv) => argv && isLiteralHerdrPrompt(argv));
|
|
2699
|
-
if (!hasProvedNavigationSequence(command) || hasEnvironmentMutationPrefix(command)) return false;
|
|
2700
|
-
return (
|
|
2701
|
-
prompts.length > 0 &&
|
|
2702
|
-
parsed.pipelines.every((pipeline) => {
|
|
2703
|
-
return pipeline.every((words, index) => {
|
|
2704
|
-
const argv = unwrapProofCommand(words, "\0");
|
|
2705
|
-
if (!argv) return false;
|
|
2706
|
-
if (isLiteralHerdrPrompt(argv)) return index === 0;
|
|
2707
|
-
if (argv[0] === "herdr" && argv[1] === "pane" && argv[2] === "close")
|
|
2708
|
-
return (
|
|
2709
|
-
index === 0 &&
|
|
2710
|
-
pipeline.length === 1 &&
|
|
2711
|
-
argv.length === 4 &&
|
|
2712
|
-
/^[A-Za-z0-9_.:-]+$/.test(argv[3] ?? "")
|
|
2713
|
-
);
|
|
2714
|
-
// cat is a reader at the start of an independent pipeline, never a consumer.
|
|
2715
|
-
if (argv[0] === "cat")
|
|
2716
|
-
return (
|
|
2717
|
-
index === 0 &&
|
|
2718
|
-
argv.length > 1 &&
|
|
2719
|
-
argv.slice(1).every((arg) => arg === "--" || !arg.startsWith("-"))
|
|
2720
|
-
);
|
|
2721
|
-
if (isPassiveTextCommand(argv)) return true;
|
|
2722
|
-
if (isLiteralHerdrRead(argv)) return index === 0;
|
|
2723
|
-
if (argv[0] === "sleep")
|
|
2724
|
-
return (
|
|
2725
|
-
index === 0 &&
|
|
2726
|
-
pipeline.length === 1 &&
|
|
2727
|
-
argv.length === 2 &&
|
|
2728
|
-
/^\d+(?:\.\d+)?$/.test(argv[1] ?? "")
|
|
2729
|
-
);
|
|
2730
|
-
if (isStaticNavigationCommand(argv))
|
|
2731
|
-
return index === 0 && (argv[0] !== "cd" || pipeline.length === 1);
|
|
2732
|
-
// Literal child scripts keep the existing execution boundary; their source is not attested.
|
|
2733
|
-
if (
|
|
2734
|
-
argv[0] === "bun" &&
|
|
2735
|
-
inlineProgramIndex(argv) === undefined &&
|
|
2736
|
-
!isUnsupportedInlineRuntime(argv)
|
|
2737
|
-
)
|
|
2738
|
-
return index === 0 && /^(?:~\/)?[A-Za-z0-9_./-]+\.(?:[cm]?[jt]s)$/.test(argv[1] ?? "");
|
|
2739
|
-
return false;
|
|
2740
|
-
});
|
|
2741
|
-
})
|
|
2742
|
-
);
|
|
2743
|
-
}
|
|
2744
|
-
|
|
2745
|
-
function isHerdrPrompt(command: string): boolean {
|
|
2746
|
-
if (/^herdr\s+agent\s+prompt\b/.test(command.trim())) return true;
|
|
2747
|
-
const parsed = parseStaticShellCommands(command, true);
|
|
2748
|
-
if (!parsed || typeof parsed === "string") return false;
|
|
2749
|
-
return parsed.pipelines.flat().some((words) => {
|
|
2750
|
-
const argv = unwrapStaticCommand(words);
|
|
2751
|
-
return argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt";
|
|
2752
|
-
});
|
|
2753
|
-
}
|
|
2754
|
-
|
|
2755
|
-
function hasEnvironmentRead(
|
|
2756
|
-
command: string,
|
|
2757
|
-
allowedNames: Set<string>,
|
|
2758
|
-
allowInventory = hasProvedPackageInventoryInvocation(command),
|
|
2759
|
-
replayedShellBodies: string[] = [],
|
|
2760
|
-
): boolean {
|
|
2761
|
-
if (isHerdrPrompt(command) && !isStaticHerdrPrompt(command)) return true;
|
|
2762
|
-
if (isLiteralTextWrite(command)) return false;
|
|
2763
|
-
// ponytail: complex shell syntax stays conservative; use a shell AST if more exceptions are needed.
|
|
2764
|
-
const parsed = parseStaticShellCommands(command);
|
|
2765
|
-
if (parsed === "brace-expansion") return true;
|
|
2766
|
-
if (
|
|
2767
|
-
parsed &&
|
|
2768
|
-
typeof parsed !== "string" &&
|
|
2769
|
-
hasEnvironmentMutationPrefix(command) &&
|
|
2770
|
-
parsed.pipelines
|
|
2771
|
-
.flat()
|
|
2772
|
-
.some((words) => apiRequestDataIndexes(unwrapStaticCommand(words)) !== undefined)
|
|
2773
|
-
)
|
|
2774
|
-
return true;
|
|
2775
|
-
if (!parsed) {
|
|
2776
|
-
const operands = parseStaticShellCommands(command, true);
|
|
2777
|
-
if (
|
|
2778
|
-
operands &&
|
|
2779
|
-
typeof operands !== "string" &&
|
|
2780
|
-
operands.pipelines.flat().some((words) => {
|
|
2781
|
-
const argv = unwrapStaticCommand(words);
|
|
2782
|
-
return (
|
|
2783
|
-
argv[0]?.split("/").at(-1) === "bun" &&
|
|
2784
|
-
argv[1] === "run" &&
|
|
2785
|
-
argv[2] === "db-tool" &&
|
|
2786
|
-
["sql", "query"].includes(argv[3] ?? "") &&
|
|
2787
|
-
argv.some(
|
|
2788
|
-
(arg, index) =>
|
|
2789
|
-
(arg === "--sql" && /\$\(|`/.test(argv[index + 1] ?? "")) ||
|
|
2790
|
-
(arg.startsWith("--sql=") && /\$\(|`/.test(arg)),
|
|
2791
|
-
)
|
|
2792
|
-
);
|
|
2793
|
-
})
|
|
2794
|
-
)
|
|
2795
|
-
return true;
|
|
2796
|
-
const unquoted = command.replace(/'(?:\\.|[^'])*'/g, " ").replace(/"(?:\\.|[^"$`])*"/g, " ");
|
|
2797
|
-
const hasExecutor =
|
|
2798
|
-
/\|\s*(?:sh|bash|zsh|xargs)|\b(?:sh|bash|zsh)\b|\$\(|`|\b(?:eval|source)\b|\bfind\b.*\b-exec\b/i.test(
|
|
2799
|
-
command,
|
|
2800
|
-
);
|
|
2801
|
-
return (
|
|
2802
|
-
mentionsEnvironmentRead(unquoted) ||
|
|
2803
|
-
(mentionsEnvironmentRead(command) && hasExecutor) ||
|
|
2804
|
-
hasShellBraceExpansion(withoutQuotedQuantifiers(command))
|
|
2805
|
-
);
|
|
2806
|
-
}
|
|
2807
|
-
const pipelines = parsed.pipelines.map((commands) => commands.map(unwrapStaticCommand));
|
|
2808
|
-
const unwrapped = pipelines.flat();
|
|
2809
|
-
if (
|
|
2810
|
-
pipelines.some((pipeline) =>
|
|
2811
|
-
pipeline.some(
|
|
2812
|
-
(argv, index) =>
|
|
2813
|
-
(literalSqlArgumentIndex(argv) !== undefined &&
|
|
2814
|
-
pipeline.slice(index + 1).some((consumer) => !isPassiveTextCommand(consumer))) ||
|
|
2815
|
-
(isPassiveJqCommand(argv) &&
|
|
2816
|
-
pipeline.slice(index + 1).some((consumer) => !isReadonlyJqConsumer(consumer))),
|
|
2817
|
-
),
|
|
2818
|
-
)
|
|
2819
|
-
)
|
|
2820
|
-
return true;
|
|
2821
|
-
if (
|
|
2822
|
-
parsed.redirects.length > 0 &&
|
|
2823
|
-
unwrapped.some((argv) => isAllowedEnvironmentRead(argv, allowedNames))
|
|
2824
|
-
) {
|
|
2825
|
-
return true;
|
|
2826
|
-
}
|
|
2827
|
-
if (
|
|
2828
|
-
parsed.redirects.length > 0 &&
|
|
2829
|
-
unwrapped.some(
|
|
2830
|
-
(producer, index) =>
|
|
2831
|
-
producer[0]?.split("/").at(-1) === "git" &&
|
|
2832
|
-
producer[1] === "commit" &&
|
|
2833
|
-
mentionsEnvironmentRead(producer.join(" ")) &&
|
|
2834
|
-
unwrapped.slice(index + 1).some((consumer) => !isPassiveTextCommand(consumer)),
|
|
2835
|
-
)
|
|
2836
|
-
) {
|
|
2837
|
-
return true;
|
|
2838
|
-
}
|
|
2839
|
-
if (
|
|
2840
|
-
parsed.redirects.length > 0 &&
|
|
2841
|
-
pipelines.some((pipeline) =>
|
|
2842
|
-
pipeline.some(
|
|
2843
|
-
(argv) => isLiteralTextProducer(argv) && mentionsEnvironmentRead(argv.join(" ")),
|
|
2844
|
-
),
|
|
2845
|
-
) &&
|
|
2846
|
-
unwrapped.some((argv) => ["sh", "bash", "zsh", "xargs", "eval"].includes(argv[0] ?? ""))
|
|
2847
|
-
) {
|
|
2848
|
-
return true;
|
|
2849
|
-
}
|
|
2850
|
-
if (
|
|
2851
|
-
unwrapped.some(
|
|
2852
|
-
(argv) =>
|
|
2853
|
-
!(SHELL_IDENTITIES.has(argv[0] ?? "") && replayedShellBodies.includes(argv.at(-1) ?? "")) &&
|
|
2854
|
-
hasStaticEnvironmentRead(argv, allowedNames, allowInventory),
|
|
2855
|
-
)
|
|
2856
|
-
) {
|
|
2857
|
-
return true;
|
|
2858
|
-
}
|
|
2859
|
-
|
|
2860
|
-
// A literal producer can feed executable text to a shell, xargs, or an unknown consumer.
|
|
2861
|
-
const result = pipelines.some((pipeline) =>
|
|
2862
|
-
pipeline.some(
|
|
2863
|
-
(argv, index) =>
|
|
2864
|
-
(isAllowedEnvironmentRead(argv, allowedNames) ||
|
|
2865
|
-
(isLiteralTextProducer(argv) &&
|
|
2866
|
-
(mentionsEnvironmentRead(argv.join(" ")) || hasCommandArgumentBraceExpansion(argv)))) &&
|
|
2867
|
-
pipeline
|
|
2868
|
-
.slice(index + 1)
|
|
2869
|
-
.some(
|
|
2870
|
-
(consumer) =>
|
|
2871
|
-
!isPassiveTextCommand(consumer) && !isAllowedEnvironmentRead(consumer, allowedNames),
|
|
2872
|
-
),
|
|
2873
|
-
),
|
|
2874
|
-
);
|
|
2875
|
-
return result;
|
|
2876
|
-
}
|
|
2877
|
-
|
|
2878
|
-
/** Find an actual heredoc operator outside shell quotes/comments, then parse one bounded body. */
|
|
2879
|
-
function boundedHeredoc(command: string) {
|
|
2880
|
-
let quote: "'" | '"' | undefined;
|
|
2881
|
-
let wordStarted = false;
|
|
2882
|
-
for (let i = 0; i < command.length; i++) {
|
|
2883
|
-
const char = command[i];
|
|
2884
|
-
if (quote === "'") {
|
|
2885
|
-
if (char === quote) quote = undefined;
|
|
2886
|
-
} else if (char === "\\") {
|
|
2887
|
-
i++;
|
|
2888
|
-
wordStarted = true;
|
|
2889
|
-
} else if (quote === '"') {
|
|
2890
|
-
if (char === quote) quote = undefined;
|
|
2891
|
-
} else if (char === "'" || char === '"') {
|
|
2892
|
-
quote = char;
|
|
2893
|
-
wordStarted = true;
|
|
2894
|
-
} else if (char === "#" && !wordStarted) {
|
|
2895
|
-
while (i + 1 < command.length && command[i + 1] !== "\n") i++;
|
|
2896
|
-
} else if (command.startsWith("<<", i)) {
|
|
2897
|
-
const match =
|
|
2898
|
-
/^<<(-?)[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2[ \t]*(?:#[^\r\n]*)?(?:\r?\n|$)/.exec(
|
|
2899
|
-
command.slice(i),
|
|
2900
|
-
);
|
|
2901
|
-
const lines = match
|
|
2902
|
-
? command
|
|
2903
|
-
.slice(i + match[0].length)
|
|
2904
|
-
.trimEnd()
|
|
2905
|
-
.split(/\r?\n/)
|
|
2906
|
-
: [];
|
|
2907
|
-
const end = lines.findIndex(
|
|
2908
|
-
(line) => (match?.[1] ? line.replace(/^\t+/, "") : line) === match?.[3],
|
|
2909
|
-
);
|
|
2910
|
-
return {
|
|
2911
|
-
header: command.slice(0, i),
|
|
2912
|
-
quoted: Boolean(match?.[2]),
|
|
2913
|
-
closed: Boolean(match) && end >= 0,
|
|
2914
|
-
body: lines.slice(0, end >= 0 ? end : undefined).join("\n"),
|
|
2915
|
-
following: end >= 0 ? lines.slice(end + 1).join("\n") : "",
|
|
2916
|
-
};
|
|
2917
|
-
} else {
|
|
2918
|
-
wordStarted = !/[\s;&|()<>]/.test(char ?? "");
|
|
2919
|
-
}
|
|
2920
|
-
}
|
|
2921
|
-
return undefined;
|
|
2922
|
-
}
|
|
2923
|
-
|
|
2924
|
-
/** A single literal writer cannot execute its body or feed another command. */
|
|
2925
|
-
function isLiteralTextWrite(command: string): boolean {
|
|
2926
|
-
const heredoc = boundedHeredoc(command);
|
|
2927
|
-
let header = command;
|
|
2928
|
-
let writers = ["echo", "printf"];
|
|
2929
|
-
if (heredoc) {
|
|
2930
|
-
// Only a complete body with no following commands is a literal-write exception.
|
|
2931
|
-
if (!heredoc.closed || heredoc.following) return false;
|
|
2932
|
-
if (!heredoc.quoted && /[$`\\]/.test(heredoc.body)) return false;
|
|
2933
|
-
header = heredoc.header;
|
|
2934
|
-
writers = ["cat", "tee"];
|
|
2935
|
-
} else if (!command.includes(">")) {
|
|
2936
|
-
return false;
|
|
2937
|
-
}
|
|
2938
|
-
|
|
2939
|
-
// Output redirection does not execute text. Other unsupported syntax still fails parsing.
|
|
2940
|
-
const parsed = parseStaticShellCommands(header.replace(/2>&1/g, " ").replace(/>>?/g, " "));
|
|
2941
|
-
if (!parsed || parsed === "brace-expansion") return false;
|
|
2942
|
-
const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
|
|
2943
|
-
const writerIndexes = commands
|
|
2944
|
-
.map((argv, index) => (writers.includes(argv[0]?.split("/").at(-1) ?? "") ? index : -1))
|
|
2945
|
-
.filter((index) => index >= 0);
|
|
2946
|
-
return (
|
|
2947
|
-
writerIndexes.length === 1 &&
|
|
2948
|
-
commands.every((argv, index) => {
|
|
2949
|
-
if (index === writerIndexes[0]) return true;
|
|
2950
|
-
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
2951
|
-
return name === "cd" || (name === "pwd" && argv.length === 1) || isSafeLiteralConsumer(argv);
|
|
2952
|
-
})
|
|
2953
|
-
);
|
|
2954
|
-
}
|
|
2955
|
-
|
|
2956
|
-
function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) => boolean): boolean {
|
|
2957
|
-
const sensitivePath =
|
|
2958
|
-
/(?:^|[/\s'"])(?:[.]env(?\b)|\S*[.](?:pem|key)\b|\S*[.](?:ssh|aws)[/]|(?:\S*secret(?:-|[/\s.]|$))|(?:(?:secrets?|credentials?)(?:[/\s.]|$)|[A-Za-z0-9_.-]*(?:secrets?|credentials?)(?:[/\s.]|$)|[A-Za-z0-9_.-]*-(?:secrets?|credentials?)(?:[/\s.-]|$)))/i;
|
|
2959
|
-
const sensitiveCamelCase =
|
|
2960
|
-
/(?:^|[/\s'"])[A-Za-z0-9_.-]*(?:(?:secret|credential)[A-Z]|(?:Secret|Credential)[A-Z])[^/\s'"]*/;
|
|
2961
|
-
const sensitiveName = /secret|credential/i;
|
|
2962
|
-
const isSensitivePath = (text: string) =>
|
|
2963
|
-
sensitivePath.test(text) ||
|
|
2964
|
-
sensitiveCamelCase.test(text) ||
|
|
2965
|
-
(sensitiveName.test(text) &&
|
|
2966
|
-
!/(?:^|\/)credential-guard(?:(?:-[^/]+)?\.(?:ts|md|js)|\*|\/(?:index\.(?:ts|js)))?$/.test(
|
|
2967
|
-
text,
|
|
2968
|
-
));
|
|
2969
|
-
const heredoc = boundedHeredoc(command);
|
|
2970
|
-
if (
|
|
2971
|
-
heredoc &&
|
|
2972
|
-
(isLiteralTextWrite(command) ||
|
|
2973
|
-
(isLiteralTextWrite(command.slice(0, command.length - heredoc.following.length).trimEnd()) &&
|
|
2974
|
-
!mentionsInlineRuntime(heredoc.following) &&
|
|
2975
|
-
!/\b(?:sh|bash|zsh|eval|source|xargs)\b/.test(heredoc.following)))
|
|
2976
|
-
) {
|
|
2977
|
-
return hasSensitiveFileRead(heredoc.header + "\n" + heredoc.following, isPathBlocked);
|
|
2978
|
-
}
|
|
2979
|
-
if (/\bcat\b[^;&|]*<<-?\s*\S+[\s\S]*\b(?:secret|credential)\b/i.test(command)) return true;
|
|
2980
|
-
const parsed = parseStaticShellCommands(command);
|
|
2981
|
-
const readers =
|
|
2982
|
-
/\b(?:cat|tee|cp|grep|rg|sed|awk|jq|head|tail|less|more|source|ls|sort|uniq|cut|wc|mv|find|shasum|sha1sum|sha256sum|sha512sum|md5sum|cksum)\b/i;
|
|
2983
|
-
if (parsed && parsed !== "brace-expansion") {
|
|
2984
|
-
let directory = "";
|
|
2985
|
-
const trackDirectory = hasProvedNavigationSequence(command);
|
|
2986
|
-
const safeDirectorySequence = parsed.pipelines.flat().every(isProvedDirectoryCommand);
|
|
2987
|
-
return parsed.pipelines.flat().some((words) => {
|
|
2988
|
-
const argv = unwrapStaticCommand(words);
|
|
2989
|
-
const inspectedArgv =
|
|
2990
|
-
argv[0]?.split("/").at(-1) === "env" ? unwrapEnvironmentCommand(argv) : argv;
|
|
2991
|
-
if (inspectedArgv === null) return false;
|
|
2992
|
-
let inspected = unwrapStaticCommand(inspectedArgv);
|
|
2993
|
-
let name = inspected[0]?.split("/").at(-1) ?? "";
|
|
2994
|
-
let commandDirectory = directory;
|
|
2995
|
-
const inDirectory = (path: string) =>
|
|
2996
|
-
path.startsWith("/") ? posix.normalize(path) : posix.join(commandDirectory, path);
|
|
2997
|
-
const blockedOperand = (path: string) =>
|
|
2998
|
-
[path, inDirectory(path)].some((value) => isPathBlocked(value) || isSensitivePath(value));
|
|
2999
|
-
// Directory slots use path policies, not the broad credential-file name heuristic.
|
|
3000
|
-
const blockedDirectory = (path: string) =>
|
|
3001
|
-
[path, inDirectory(path)].some(
|
|
3002
|
-
(value) => isPathBlocked(value) || isPathBlocked(value + "/"),
|
|
3003
|
-
);
|
|
3004
|
-
const program = inlineProgramIndex(inspected);
|
|
3005
|
-
// Runtime file options are operands; known text/prompt/program data is not an option.
|
|
3006
|
-
if (
|
|
3007
|
-
!isPassiveTextCommand(inspected) &&
|
|
3008
|
-
!(name === "herdr" && inspected[1] === "agent" && inspected[2] === "prompt") &&
|
|
3009
|
-
inspected.some(
|
|
3010
|
-
(arg, index) =>
|
|
3011
|
-
index !== program &&
|
|
3012
|
-
((arg === "--env-file" && blockedOperand(inspected[index + 1] ?? "")) ||
|
|
3013
|
-
(arg.startsWith("--env-file=") && blockedOperand(arg.slice("--env-file=".length)))),
|
|
3014
|
-
)
|
|
3015
|
-
)
|
|
3016
|
-
return true;
|
|
3017
|
-
if (name === "cd") {
|
|
3018
|
-
const path = inspected[inspected[1] === "--" ? 2 : 1];
|
|
3019
|
-
if (path === undefined) return false;
|
|
3020
|
-
if (blockedDirectory(path)) return true;
|
|
3021
|
-
// A successful cd alone proves neither its later readers nor pipeline consumers safe.
|
|
3022
|
-
if (
|
|
3023
|
-
(!trackDirectory || !safeDirectorySequence) &&
|
|
3024
|
-
(blockedOperand(path) || blockedOperand(path + "/"))
|
|
3025
|
-
)
|
|
3026
|
-
return true;
|
|
3027
|
-
if (trackDirectory && isStaticNavigationCommand(inspected)) directory = inDirectory(path);
|
|
3028
|
-
return false;
|
|
3029
|
-
}
|
|
3030
|
-
if (name === "git") {
|
|
3031
|
-
let index = 1;
|
|
3032
|
-
while (inspected[index] === "-C" && inspected[index + 1]) {
|
|
3033
|
-
const path = inspected[index + 1] ?? "";
|
|
3034
|
-
if (blockedDirectory(path)) return true;
|
|
3035
|
-
// Git -C affects this command only; following consumers keep the shell directory.
|
|
3036
|
-
if (
|
|
3037
|
-
!isProvedDirectoryCommand(words) &&
|
|
3038
|
-
(blockedOperand(path) || blockedOperand(path + "/"))
|
|
3039
|
-
)
|
|
3040
|
-
return true;
|
|
3041
|
-
commandDirectory = inDirectory(path);
|
|
3042
|
-
index += 2;
|
|
3043
|
-
}
|
|
3044
|
-
// Git grep has the same pattern-versus-file roles as grep below.
|
|
3045
|
-
if (inspected[index] === "grep") {
|
|
3046
|
-
inspected = ["grep", ...inspected.slice(index + 1)];
|
|
3047
|
-
name = "grep";
|
|
3048
|
-
} else if (inspected[index] === "diff") {
|
|
3049
|
-
const files = gitDiffFileArguments(inspected.slice(index + 1));
|
|
3050
|
-
return files === undefined || files.some(blockedOperand);
|
|
3051
|
-
}
|
|
3052
|
-
}
|
|
3053
|
-
if (name === "bun" && inspected[1] === "run" && inspected[2] === "gh-tool") {
|
|
3054
|
-
return inspected.some((arg, index) => {
|
|
3055
|
-
const path =
|
|
3056
|
-
arg === "--body-file"
|
|
3057
|
-
? inspected[index + 1]
|
|
3058
|
-
: arg.startsWith("--body-file=")
|
|
3059
|
-
? arg.slice("--body-file=".length)
|
|
3060
|
-
: undefined;
|
|
3061
|
-
return path !== undefined && (isPathBlocked(path) || isSensitivePath(path));
|
|
3062
|
-
});
|
|
3063
|
-
}
|
|
3064
|
-
const programIndex = inlineProgramIndex(inspected);
|
|
3065
|
-
const compileFiles = pythonCompileFiles(inspected);
|
|
3066
|
-
if (isPythonCompileModule(inspected)) {
|
|
3067
|
-
return (compileFiles ?? inspected.slice(1)).some(
|
|
3068
|
-
(file) =>
|
|
3069
|
-
blockedOperand(file) ||
|
|
3070
|
-
blockedDirectory(posix.join(posix.dirname(file), "__pycache__")),
|
|
3071
|
-
);
|
|
3072
|
-
}
|
|
3073
|
-
if (programIndex !== undefined) {
|
|
3074
|
-
const parts = programParts(
|
|
3075
|
-
inspected[programIndex] ?? "",
|
|
3076
|
-
inlineRuntimeKind(inspected[0]) === "python",
|
|
3077
|
-
);
|
|
3078
|
-
if (!parts) return true;
|
|
3079
|
-
const inventory = staticPackageInventory(inspected[programIndex] ?? "");
|
|
3080
|
-
if (inventory?.some((path) => isPathBlocked(path) || isSensitivePath(path))) return true;
|
|
3081
|
-
if (parts.code.split(/[\s'"`()<>;&|]+/).some(isPathBlocked)) return true;
|
|
3082
|
-
const accessesFiles =
|
|
3083
|
-
/\b(?:open|Path|read_text|read_bytes|readFile|readFileSync|file|write_text|writeFile|writeFileSync)\b/.test(
|
|
3084
|
-
parts.code,
|
|
3085
|
-
) || /\bBun\s*\.\s*write\s*\(/.test(parts.code);
|
|
3086
|
-
if (
|
|
3087
|
-
accessesFiles &&
|
|
3088
|
-
/\b(?:sys|process)\s*\.\s*argv\b/.test(parts.code) &&
|
|
3089
|
-
inspected
|
|
3090
|
-
.slice(programIndex + 1)
|
|
3091
|
-
.some((arg) => isPathBlocked(arg) || isSensitivePath(arg))
|
|
3092
|
-
)
|
|
3093
|
-
return true;
|
|
3094
|
-
// Interpolation expressions remain in code; inspect their nested literal operands too.
|
|
3095
|
-
const expressionParts = /['"]/.test(parts.code)
|
|
3096
|
-
? programParts(parts.code, inlineRuntimeKind(inspected[0]) === "python")
|
|
3097
|
-
: { pathLiterals: [] };
|
|
3098
|
-
if (!expressionParts) return true;
|
|
3099
|
-
return [...parts.pathLiterals, ...expressionParts.pathLiterals].some(
|
|
3100
|
-
(value) =>
|
|
3101
|
-
(accessesFiles && isPathBlocked(value)) ||
|
|
3102
|
-
(!/\s/.test(value) && /[/.]/.test(value) && isSensitivePath(value)),
|
|
3103
|
-
);
|
|
3104
|
-
}
|
|
3105
|
-
if (!readers.test(name)) return false;
|
|
3106
|
-
if (name === "rg" || name === "grep") {
|
|
3107
|
-
const args = inspected.slice(1);
|
|
3108
|
-
const paths: string[] = [];
|
|
3109
|
-
let hasPattern = false;
|
|
3110
|
-
let options = true;
|
|
3111
|
-
const enumeratesFiles = name === "rg" && args.includes("--files");
|
|
3112
|
-
let filesOnly = enumeratesFiles;
|
|
3113
|
-
for (let i = 0; i < args.length; i++) {
|
|
3114
|
-
const arg = args[i] ?? "";
|
|
3115
|
-
if (options && arg === "--") {
|
|
3116
|
-
options = false;
|
|
3117
|
-
continue;
|
|
3118
|
-
}
|
|
3119
|
-
if (
|
|
3120
|
-
options &&
|
|
3121
|
-
(arg === "--pre" ||
|
|
3122
|
-
arg.startsWith("--pre=") ||
|
|
3123
|
-
arg === "--hostname-bin" ||
|
|
3124
|
-
arg.startsWith("--hostname-bin="))
|
|
3125
|
-
)
|
|
3126
|
-
return true;
|
|
3127
|
-
if (options && (arg.startsWith("--file=") || /^-f.+/.test(arg))) {
|
|
3128
|
-
paths.push(arg.startsWith("--file=") ? arg.slice(7) : arg.slice(2));
|
|
3129
|
-
hasPattern = true;
|
|
3130
|
-
continue;
|
|
3131
|
-
}
|
|
3132
|
-
if (options && (arg.startsWith("--regexp=") || /^-e.+/.test(arg))) {
|
|
3133
|
-
hasPattern = true;
|
|
3134
|
-
continue;
|
|
3135
|
-
}
|
|
3136
|
-
if (
|
|
3137
|
-
options &&
|
|
3138
|
-
(arg.startsWith("--glob=") ||
|
|
3139
|
-
arg.startsWith("--iglob=") ||
|
|
3140
|
-
arg.startsWith("--include=") ||
|
|
3141
|
-
/^-g.+/.test(arg))
|
|
3142
|
-
) {
|
|
3143
|
-
const glob = arg.startsWith("--iglob=")
|
|
3144
|
-
? arg.slice(8)
|
|
3145
|
-
: arg.startsWith("--include=")
|
|
3146
|
-
? arg.slice(10)
|
|
3147
|
-
: arg.startsWith("--glob=")
|
|
3148
|
-
? arg.slice(7)
|
|
3149
|
-
: arg.slice(2);
|
|
3150
|
-
if (
|
|
3151
|
-
!enumeratesFiles &&
|
|
3152
|
-
(isSensitivePath(glob) || isPathBlocked(glob.replaceAll("*", "")))
|
|
3153
|
-
)
|
|
3154
|
-
paths.push(glob);
|
|
3155
|
-
continue;
|
|
3156
|
-
}
|
|
3157
|
-
if (
|
|
3158
|
-
options &&
|
|
3159
|
-
["-e", "--regexp", "-f", "--file", "-g", "--glob", "--iglob", "--include"].includes(arg)
|
|
3160
|
-
) {
|
|
3161
|
-
const value = args[++i];
|
|
3162
|
-
if (arg === "-f" || arg === "--file") paths.push(value ?? "");
|
|
3163
|
-
if (
|
|
3164
|
-
(arg === "-g" || arg === "--glob" || arg === "--iglob" || arg === "--include") &&
|
|
3165
|
-
!enumeratesFiles &&
|
|
3166
|
-
value &&
|
|
3167
|
-
(isSensitivePath(value) || isPathBlocked(value.replaceAll("*", "")))
|
|
3168
|
-
)
|
|
3169
|
-
paths.push(value);
|
|
3170
|
-
if (arg === "-e" || arg === "--regexp" || arg === "-f" || arg === "--file")
|
|
3171
|
-
hasPattern = true;
|
|
3172
|
-
continue;
|
|
3173
|
-
}
|
|
3174
|
-
if (options && (arg === "--files" || (arg === "-l" && name === "grep"))) {
|
|
3175
|
-
filesOnly = arg === "--files";
|
|
3176
|
-
if (filesOnly) continue;
|
|
3177
|
-
}
|
|
3178
|
-
if (options && arg.startsWith("-")) {
|
|
3179
|
-
if (/^-[A-Za-z]+[fg]$/.test(arg)) {
|
|
3180
|
-
const value = args[++i];
|
|
3181
|
-
if (value && isSensitivePath(value)) paths.push(value);
|
|
3182
|
-
if (arg.endsWith("f")) hasPattern = true;
|
|
3183
|
-
continue;
|
|
3184
|
-
}
|
|
3185
|
-
const combinedPatternFile = arg.match(/^-[A-Za-z]*f(.+)$/)?.[1];
|
|
3186
|
-
if (combinedPatternFile) {
|
|
3187
|
-
if (isSensitivePath(combinedPatternFile)) paths.push(combinedPatternFile);
|
|
3188
|
-
hasPattern = true;
|
|
3189
|
-
}
|
|
3190
|
-
const optionValue = arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : null;
|
|
3191
|
-
if (optionValue && isSensitivePath(optionValue)) paths.push(optionValue);
|
|
3192
|
-
continue;
|
|
3193
|
-
}
|
|
3194
|
-
if (!hasPattern && !filesOnly) {
|
|
3195
|
-
hasPattern = true;
|
|
3196
|
-
continue;
|
|
3197
|
-
}
|
|
3198
|
-
paths.push(arg);
|
|
3199
|
-
}
|
|
3200
|
-
return paths.some(blockedOperand);
|
|
3201
|
-
}
|
|
3202
|
-
const paths = name === "jq" ? jqFileArguments(inspected) : inspected.slice(1);
|
|
3203
|
-
return paths.some(blockedOperand);
|
|
3204
|
-
});
|
|
3205
|
-
}
|
|
3206
|
-
// Unsupported shell syntax retains conservative option evidence.
|
|
3207
|
-
if (/(?:^|[\s;&|])--env-file(?:=|\s)[^;&|]*\.env\b/i.test(command)) return true;
|
|
3208
|
-
const operands = parseStaticShellCommands(command, true);
|
|
3209
|
-
if (
|
|
3210
|
-
operands &&
|
|
3211
|
-
typeof operands !== "string" &&
|
|
3212
|
-
operands.pipelines.flat().some((words) => {
|
|
3213
|
-
const argv = unwrapStaticCommand(words);
|
|
3214
|
-
return readers.test(argv[0] ?? "") && argv.slice(1).some(isPathBlocked);
|
|
3215
|
-
})
|
|
3216
|
-
)
|
|
3217
|
-
return true;
|
|
3218
|
-
if (readers.test(command) && command.split(/[\s'"`()<>;&|]+/).some(isPathBlocked)) return true;
|
|
3219
|
-
const unquoted = command.replace(/'(?:\\.|[^'])*'/g, " ").replace(/"(?:\\.|[^"$`])*"/g, " ");
|
|
3220
|
-
if ((mentionsInlineRuntime(command) || /\bruby\b/i.test(command)) && isSensitivePath(command)) {
|
|
3221
|
-
return true;
|
|
3222
|
-
}
|
|
3223
|
-
return (
|
|
3224
|
-
new RegExp(readers.source + "[^;&|]*" + sensitivePath.source, "i").test(unquoted) ||
|
|
3225
|
-
new RegExp(readers.source + "[^;&|]*" + sensitiveCamelCase.source).test(unquoted) ||
|
|
3226
|
-
new RegExp(readers.source + "[^;&|]*" + sensitiveName.source, "i").test(unquoted)
|
|
3227
|
-
);
|
|
3228
|
-
}
|
|
3229
|
-
|
|
3230
|
-
function hasEnvironmentVariableExpansionRead(
|
|
3231
|
-
command: string,
|
|
3232
|
-
allowedNames: Set<string>,
|
|
3233
|
-
isPathBlocked: (path: string) => boolean,
|
|
3234
|
-
onProvedStatus?: (command: string) => void,
|
|
3235
|
-
onProvedPredicate?: () => void,
|
|
3236
|
-
): boolean {
|
|
3237
|
-
const originalCommand = command;
|
|
3238
|
-
let heredoc = boundedHeredoc(command);
|
|
3239
|
-
while (heredoc) {
|
|
3240
|
-
// In an unquoted heredoc, # and quote characters are data, not shell syntax.
|
|
3241
|
-
if (!heredoc.closed || (!heredoc.quoted && /[$`\\]/.test(heredoc.body))) return true;
|
|
3242
|
-
command = [heredoc.header, heredoc.following].join("\n");
|
|
3243
|
-
heredoc = boundedHeredoc(command);
|
|
3244
|
-
}
|
|
3245
|
-
if (!command.includes("$") && !command.includes("`")) return false;
|
|
3246
|
-
let predicateSyntaxIntact = command === originalCommand;
|
|
3247
|
-
let invalidExpansion = false;
|
|
3248
|
-
const assignments = getLeadingLiteralAssignments(command);
|
|
3249
|
-
let localIndex = 0;
|
|
3250
|
-
const directCaptures = new Map<string, string>();
|
|
3251
|
-
const statusReferences = new Map<string, { name: string; fallback: string }>();
|
|
3252
|
-
const marker = (prefix: string): string => {
|
|
3253
|
-
let value: string;
|
|
3254
|
-
do value = `${prefix}${localIndex++}END`;
|
|
3255
|
-
while (command.includes(value));
|
|
3256
|
-
return value;
|
|
3257
|
-
};
|
|
3258
|
-
let expansions = 0;
|
|
3259
|
-
let normalized = "";
|
|
3260
|
-
let quote: "'" | '"' | undefined;
|
|
3261
|
-
let wordStarted = false;
|
|
3262
|
-
for (let i = 0; i < command.length; i++) {
|
|
3263
|
-
const char = command[i] ?? "";
|
|
3264
|
-
if (!quote && char === "#" && !wordStarted) {
|
|
3265
|
-
predicateSyntaxIntact = false;
|
|
3266
|
-
while (i + 1 < command.length && command[i + 1] !== "\n") i++;
|
|
3267
|
-
continue;
|
|
3268
|
-
}
|
|
3269
|
-
if (!quote && /[\s;&|()<>]/.test(char)) wordStarted = false;
|
|
3270
|
-
else wordStarted = true;
|
|
3271
|
-
if (char === "\\" && quote !== "'") {
|
|
3272
|
-
normalized += char + (command[++i] ?? "");
|
|
3273
|
-
continue;
|
|
3274
|
-
}
|
|
3275
|
-
if (quote === "'") {
|
|
3276
|
-
normalized += char;
|
|
3277
|
-
if (char === "'") quote = undefined;
|
|
3278
|
-
continue;
|
|
3279
|
-
}
|
|
3280
|
-
if (char === "'" && quote !== '"') {
|
|
3281
|
-
normalized += char;
|
|
3282
|
-
quote = "'";
|
|
3283
|
-
continue;
|
|
3284
|
-
}
|
|
3285
|
-
if (char === '"') {
|
|
3286
|
-
normalized += char;
|
|
3287
|
-
quote = quote === '"' ? undefined : '"';
|
|
3288
|
-
continue;
|
|
3289
|
-
}
|
|
3290
|
-
if (char === "`") return true;
|
|
3291
|
-
if (char !== "$") {
|
|
3292
|
-
normalized += char;
|
|
3293
|
-
continue;
|
|
3294
|
-
}
|
|
3295
|
-
if (command[i + 1] === "?") {
|
|
3296
|
-
expansions++;
|
|
3297
|
-
const value = marker("NUMERICSTATUSVALUE");
|
|
3298
|
-
// Only an exact standalone assignment token captures in the parent shell.
|
|
3299
|
-
// Quoting/escaping the name can instead produce an executable command word.
|
|
3300
|
-
const capture = /(?:^|[;\n])[ \t]*([A-Za-z_][A-Za-z0-9_]*)=("?)$/.exec(command.slice(0, i));
|
|
3301
|
-
const ending = capture?.[2] === '"' ? '"' : "";
|
|
3302
|
-
if (
|
|
3303
|
-
capture &&
|
|
3304
|
-
command.slice(i + 2).startsWith(ending) &&
|
|
3305
|
-
/^[ \t]*(?:[;\n]|$)/.test(command.slice(i + 2 + ending.length))
|
|
3306
|
-
)
|
|
3307
|
-
directCaptures.set(value, capture[1] ?? "");
|
|
3308
|
-
normalized += value;
|
|
3309
|
-
i++;
|
|
3310
|
-
continue;
|
|
3311
|
-
}
|
|
3312
|
-
const variable = /^\$\{([A-Za-z_][A-Za-z0-9_]*)(:-)?\}|^\$([A-Za-z_][A-Za-z0-9_]*)/.exec(
|
|
3313
|
-
command.slice(i),
|
|
3314
|
-
);
|
|
3315
|
-
if (!variable) {
|
|
3316
|
-
if (command[i + 1] === "{" || /[$][0-9@*#?!]/.test(command.slice(i, i + 2))) {
|
|
3317
|
-
invalidExpansion = true;
|
|
3318
|
-
}
|
|
3319
|
-
normalized += char;
|
|
3320
|
-
continue;
|
|
3321
|
-
}
|
|
3322
|
-
expansions++;
|
|
3323
|
-
const name = variable[1] ?? variable[3] ?? "";
|
|
3324
|
-
if (variable[2]) {
|
|
3325
|
-
if (quote !== '"' || !allowedNames.has(name)) invalidExpansion = true;
|
|
3326
|
-
normalized += "ENVVALUE";
|
|
3327
|
-
i += variable[0].length - 1;
|
|
3328
|
-
continue;
|
|
3329
|
-
}
|
|
3330
|
-
const assignment = assignments.get(name);
|
|
3331
|
-
if (assignment && assignment.end >= i) invalidExpansion = true;
|
|
3332
|
-
const reference = marker("STATUSREFERENCE");
|
|
3333
|
-
const fallback =
|
|
3334
|
-
assignment && assignment.end < i
|
|
3335
|
-
? assignment.value
|
|
3336
|
-
: allowedNames.has(name)
|
|
3337
|
-
? quote
|
|
3338
|
-
? "ENVVALUE"
|
|
3339
|
-
: "ENVVALUEUNQUOTED"
|
|
3340
|
-
: quote
|
|
3341
|
-
? "UNKNOWNVALUE"
|
|
3342
|
-
: "UNKNOWNVALUEUNQUOTED";
|
|
3343
|
-
statusReferences.set(reference, { name, fallback });
|
|
3344
|
-
normalized += reference;
|
|
3345
|
-
i += variable[0].length - 1;
|
|
3346
|
-
}
|
|
3347
|
-
if (!expansions) return invalidExpansion;
|
|
3348
|
-
if (invalidExpansion) return true;
|
|
3349
|
-
|
|
3350
|
-
// Resolve every reference at its command position; a later capture cannot prove
|
|
3351
|
-
// an earlier read, and only proved passive intermediates preserve the binding.
|
|
3352
|
-
const initial = parseStaticShellCommands(normalized);
|
|
3353
|
-
if (!initial || typeof initial === "string") return true;
|
|
3354
|
-
const captures = new Map<string, string>();
|
|
3355
|
-
for (const words of initial.pipelines.flat()) {
|
|
3356
|
-
const capture = /^([A-Za-z_][A-Za-z0-9_]*)=(.+)$/.exec(words[0] ?? "");
|
|
3357
|
-
if (capture && directCaptures.get(capture[2] ?? "") === capture[1])
|
|
3358
|
-
captures.set(words[0] ?? "", capture[1] ?? "");
|
|
3359
|
-
}
|
|
3360
|
-
const statusCaptures = new Set<string>();
|
|
3361
|
-
const localStatuses = new Set<string>();
|
|
3362
|
-
const resolutions = new Map<string, string>();
|
|
3363
|
-
const capturedNames = new Set(captures.values());
|
|
3364
|
-
const bound = new Set<string>();
|
|
3365
|
-
if (captures.size) {
|
|
3366
|
-
const syntax = literalBindingText(normalized, "", "")?.syntax;
|
|
3367
|
-
if (syntax === undefined || /&&|\|\||&/.test(syntax.replace(/[<>]&[0-9-]+/g, ""))) return true;
|
|
3368
|
-
}
|
|
3369
|
-
for (const [index, pipeline] of initial.pipelines.entries()) {
|
|
3370
|
-
for (const words of pipeline) {
|
|
3371
|
-
for (const arg of words) {
|
|
3372
|
-
for (const [reference, { name, fallback }] of statusReferences) {
|
|
3373
|
-
if (!arg.includes(reference)) continue;
|
|
3374
|
-
if (bound.has(name)) {
|
|
3375
|
-
const value = marker("NUMERICSTATUSVALUE");
|
|
3376
|
-
localStatuses.add(value);
|
|
3377
|
-
resolutions.set(reference, value);
|
|
3378
|
-
} else {
|
|
3379
|
-
if (capturedNames.has(name)) return true;
|
|
3380
|
-
resolutions.set(reference, fallback);
|
|
3381
|
-
}
|
|
3382
|
-
}
|
|
3383
|
-
}
|
|
3384
|
-
const capture = captures.get(words[0] ?? "");
|
|
3385
|
-
if (capture) {
|
|
3386
|
-
if (
|
|
3387
|
-
pipeline.length !== 1 ||
|
|
3388
|
-
words.length !== 1 ||
|
|
3389
|
-
initial.redirects.some((redirect) => redirect.pipeline === index) ||
|
|
3390
|
-
// Inherited export attributes can make a numeric value select code or paths.
|
|
3391
|
-
/^(?:LD_|DYLD_|_RLD_|LDR_|GIT_CONFIG_(?:KEY|VALUE)_)/.test(capture) ||
|
|
3392
|
-
/^(?:PATH|IFS|CDPATH|BASH_ENV|ENV|FPATH|ZDOTDIR|SHELLOPTS|BASHOPTS|PROMPT_COMMAND|PS[0-4]|GLOBIGNORE|RANDOM|SECONDS|PIPESTATUS|UID|EUID|BASH_XTRACEFD|HOME|XDG_CONFIG_HOME|LIBPATH|SHLIB_PATH|NODE_OPTIONS|NODE_PATH|BUN_OPTIONS|BUN_PRELOAD|PYTHONPATH|PYTHONSTARTUP|PYTHONHOME|RUBYOPT|RUBYLIB|GEM_HOME|GEM_PATH|PERL5OPT|PERL5LIB|PERLLIB|JAVA_TOOL_OPTIONS|JDK_JAVA_OPTIONS|CLASSPATH|_JAVA_OPTIONS|JAVA_HOME|GIT_SSH_COMMAND|GIT_SSH|GIT_PROXY_COMMAND|GIT_EXEC_PATH|GIT_CONFIG|GIT_CONFIG_GLOBAL|GIT_CONFIG_SYSTEM|GIT_CONFIG_PARAMETERS|GIT_CONFIG_COUNT|GIT_ASKPASS|SSH_ASKPASS|GIT_EDITOR|GIT_SEQUENCE_EDITOR|GIT_PAGER|PAGER|EDITOR|VISUAL)$/.test(
|
|
3393
|
-
capture,
|
|
3394
|
-
)
|
|
3395
|
-
)
|
|
3396
|
-
return true;
|
|
3397
|
-
bound.add(capture);
|
|
3398
|
-
statusCaptures.add(words[0] ?? "");
|
|
3399
|
-
continue;
|
|
3400
|
-
}
|
|
3401
|
-
const argv = unwrapStaticCommand(words);
|
|
3402
|
-
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
3403
|
-
if (
|
|
3404
|
-
captures.size &&
|
|
3405
|
-
/^(?:alias|unalias|readonly|declare|typeset|local|export|eval|source|\.|trap|set|read|unset|let)$/.test(
|
|
3406
|
-
name,
|
|
3407
|
-
)
|
|
3408
|
-
)
|
|
3409
|
-
return true;
|
|
3410
|
-
if (!isPassiveTextCommand(argv) && name !== "cat") bound.clear();
|
|
3411
|
-
}
|
|
3412
|
-
for (const redirect of initial.redirects.filter((value) => value.pipeline === index)) {
|
|
3413
|
-
if (
|
|
3414
|
-
[...statusReferences].some(
|
|
3415
|
-
([reference, { name }]) => capturedNames.has(name) && redirect.target.includes(reference),
|
|
3416
|
-
)
|
|
3417
|
-
)
|
|
3418
|
-
return true;
|
|
3419
|
-
}
|
|
3420
|
-
}
|
|
3421
|
-
for (const [reference, { fallback }] of statusReferences) {
|
|
3422
|
-
normalized = normalized.replaceAll(reference, () => resolutions.get(reference) ?? fallback);
|
|
3423
|
-
}
|
|
3424
|
-
const localMaterialized = normalized;
|
|
3425
|
-
// Options must be checked after local literals become actual argv, never as placeholders.
|
|
3426
|
-
if (!hasSafeLocalAssignmentCommands(localMaterialized, assignments, statusCaptures)) return true;
|
|
3427
|
-
const parsed = parseStaticShellCommands(localMaterialized);
|
|
3428
|
-
if (!parsed || typeof parsed === "string") return true;
|
|
3429
|
-
for (const [index, pipeline] of parsed.pipelines.entries()) {
|
|
3430
|
-
if (!pipeline.some((words) => words.some((arg) => arg.includes("NUMERICSTATUSVALUE"))))
|
|
3431
|
-
continue;
|
|
3432
|
-
if (parsed.redirects.some((redirect) => redirect.pipeline === index)) return true;
|
|
3433
|
-
if (
|
|
3434
|
-
pipeline.some((words) => {
|
|
3435
|
-
if (words.length === 1 && statusCaptures.has(words[0] ?? "")) return false;
|
|
3436
|
-
const argv = unwrapStaticCommand(words);
|
|
3437
|
-
if (!words.some((arg) => arg.includes("NUMERICSTATUSVALUE")))
|
|
3438
|
-
return !isPassiveTextCommand(argv);
|
|
3439
|
-
const name = argv[0]?.split("/").at(-1);
|
|
3440
|
-
const format = name === "printf" ? passivePrintfFormatIndex(argv) : undefined;
|
|
3441
|
-
return (
|
|
3442
|
-
!["echo", "printf"].includes(name ?? "") ||
|
|
3443
|
-
!isPassiveTextCommand(argv) ||
|
|
3444
|
-
argv.some(
|
|
3445
|
-
(arg) => arg.startsWith("-") && [...localStatuses].some((value) => arg.includes(value)),
|
|
3446
|
-
) ||
|
|
3447
|
-
words
|
|
3448
|
-
.slice(0, words.length - argv.length)
|
|
3449
|
-
.some((arg) => arg.includes("NUMERICSTATUSVALUE")) ||
|
|
3450
|
-
argv[0]?.includes("NUMERICSTATUSVALUE") === true ||
|
|
3451
|
-
(name === "printf" &&
|
|
3452
|
-
(format === undefined ||
|
|
3453
|
-
argv.slice(0, format + 1).some((arg) => arg.includes("NUMERICSTATUSVALUE"))))
|
|
3454
|
-
);
|
|
3455
|
-
})
|
|
3456
|
-
)
|
|
3457
|
-
return true;
|
|
3458
|
-
}
|
|
3459
|
-
for (const words of parsed.pipelines.flat()) {
|
|
3460
|
-
if (!words.some((arg) => arg.includes("UNKNOWNVALUE"))) continue;
|
|
3461
|
-
if (words.some((arg) => arg.includes("UNKNOWNVALUEUNQUOTED"))) return true;
|
|
3462
|
-
const argv = unwrapStaticCommand(words);
|
|
3463
|
-
if (
|
|
3464
|
-
words.slice(0, words.length - argv.length).some((arg) => arg.includes("UNKNOWNVALUE")) ||
|
|
3465
|
-
!isNavigationOperand(argv, "UNKNOWNVALUE")
|
|
3466
|
-
)
|
|
3467
|
-
return true;
|
|
3468
|
-
}
|
|
3469
|
-
if (!localMaterialized.includes("ENVVALUE")) {
|
|
3470
|
-
const blocked =
|
|
3471
|
-
hasSensitiveFileRead(localMaterialized, isPathBlocked) ||
|
|
3472
|
-
hasSensitivePathRedirect(localMaterialized, isPathBlocked) ||
|
|
3473
|
-
hasEnvironmentRead(localMaterialized, allowedNames);
|
|
3474
|
-
if (!blocked && statusCaptures.size) onProvedStatus?.(localMaterialized);
|
|
3475
|
-
return blocked;
|
|
3476
|
-
}
|
|
3477
|
-
|
|
3478
|
-
for (const [index, pipeline] of parsed.pipelines.entries()) {
|
|
3479
|
-
const hasEnvironmentValue = pipeline.some((argv) =>
|
|
3480
|
-
argv.some((arg) => arg.includes("ENVVALUE")),
|
|
3481
|
-
);
|
|
3482
|
-
if (!hasEnvironmentValue) continue;
|
|
3483
|
-
if (parsed.redirects.some((redirect) => redirect.pipeline === index)) return true;
|
|
3484
|
-
if (
|
|
3485
|
-
pipeline.some((argv) => {
|
|
3486
|
-
const unwrapped = unwrapStaticCommand(argv);
|
|
3487
|
-
const name = unwrapped[0]?.split("/").at(-1) ?? "";
|
|
3488
|
-
if (argv.some((arg) => arg.includes("ENVVALUE"))) {
|
|
3489
|
-
if (argv.slice(0, argv.length - unwrapped.length).some((arg) => arg.includes("ENVVALUE")))
|
|
3490
|
-
return true;
|
|
3491
|
-
if (isApprovedEnvironmentPredicate(unwrapped)) return false;
|
|
3492
|
-
if (isNavigationOperand(unwrapped, "ENVVALUE")) {
|
|
3493
|
-
return unwrapped.some((arg) => arg.includes("ENVVALUEUNQUOTED"));
|
|
3494
|
-
}
|
|
3495
|
-
const formatIndex = name === "printf" ? passivePrintfFormatIndex(unwrapped) : undefined;
|
|
3496
|
-
return (
|
|
3497
|
-
!["echo", "printf"].includes(name) ||
|
|
3498
|
-
!isPassiveTextCommand(unwrapped) ||
|
|
3499
|
-
unwrapped[0]?.includes("ENVVALUE") === true ||
|
|
3500
|
-
(name === "printf" &&
|
|
3501
|
-
(formatIndex === undefined ||
|
|
3502
|
-
unwrapped.slice(0, formatIndex + 1).some((arg) => arg.includes("ENVVALUE"))))
|
|
3503
|
-
);
|
|
3504
|
-
}
|
|
3505
|
-
return !isPassiveTextCommand(unwrapped);
|
|
3506
|
-
})
|
|
3507
|
-
) {
|
|
3508
|
-
return true;
|
|
3509
|
-
}
|
|
3510
|
-
}
|
|
3511
|
-
const materialized = normalized.replaceAll("ENVVALUE", "SAFEENVVALUE");
|
|
3512
|
-
const blocked = hasSensitiveFileRead(materialized, isPathBlocked);
|
|
3513
|
-
if (!blocked && statusCaptures.size) onProvedStatus?.(materialized);
|
|
3514
|
-
// This is a complete command proof, not merely a recursively safe shell body.
|
|
3515
|
-
if (
|
|
3516
|
-
!blocked &&
|
|
3517
|
-
parsed.pipelines.length === 1 &&
|
|
3518
|
-
parsed.pipelines[0]?.length === 1 &&
|
|
3519
|
-
!parsed.redirects.length
|
|
3520
|
-
) {
|
|
3521
|
-
const argv = unwrapProofCommand(parsed.pipelines[0]?.[0] ?? [], "ENVVALUE");
|
|
3522
|
-
if (argv && isApprovedEnvironmentPredicate(argv) && predicateSyntaxIntact) {
|
|
3523
|
-
// Roles alone cannot prove original shell boundaries or discarded controls.
|
|
3524
|
-
// Brackets are enabled only for the completely proved bracket builtin.
|
|
3525
|
-
const binding = literalBindingText(normalized, "", "", argv[0] === "[");
|
|
3526
|
-
if (binding && !/[;&|<>\r\n]/.test(binding.syntax.trim())) onProvedPredicate?.();
|
|
3527
|
-
}
|
|
3528
|
-
}
|
|
3529
|
-
return blocked;
|
|
3530
|
-
}
|
|
3531
|
-
|
|
3532
|
-
/** Quoted approved metadata can be compared or checked for blankness, never evaluated. */
|
|
3533
|
-
function isApprovedEnvironmentPredicate(argv: string[]): boolean {
|
|
3534
|
-
let args = argv.slice(1);
|
|
3535
|
-
if (argv[0] === "[") {
|
|
3536
|
-
if (args.at(-1) !== "]") return false;
|
|
3537
|
-
args = args.slice(0, -1);
|
|
3538
|
-
} else if (argv[0] !== "test") return false;
|
|
3539
|
-
const value = (arg: string | undefined) => arg === "ENVVALUE";
|
|
3540
|
-
const literal = (arg: string | undefined) =>
|
|
3541
|
-
/^[A-Za-z0-9_.:-]*$/.test(arg ?? "") && arg !== undefined;
|
|
3542
|
-
return (
|
|
3543
|
-
(args.length === 2 && ["-n", "-z"].includes(args[0] ?? "") && value(args[1])) ||
|
|
3544
|
-
(args.length === 3 &&
|
|
3545
|
-
args[1] === "=" &&
|
|
3546
|
-
((value(args[0]) && literal(args[2])) || (literal(args[0]) && value(args[2]))))
|
|
3547
|
-
);
|
|
3548
|
-
}
|
|
3549
|
-
|
|
3550
|
-
function mentionsEnvironmentSource(command: string): boolean {
|
|
3551
|
-
return /\b(?:os\s*\.\s*(?:environ|getenv)|process\s*\.\s*env|Environment\s*\.\s*GetEnvironmentVariable)\b/.test(
|
|
3552
|
-
command,
|
|
3553
|
-
);
|
|
3554
|
-
}
|
|
3555
|
-
|
|
3556
|
-
/** Closed raw-source grammar: literal display/write only, without projection or evaluation. */
|
|
3557
|
-
function isLiteralInlineWriter(argv: string[]): boolean {
|
|
3558
|
-
const index = inlineProgramIndex(argv);
|
|
3559
|
-
if (index === undefined || argv.length !== index + 1) return false;
|
|
3560
|
-
const kind = inlineRuntimeKind(argv[0]);
|
|
3561
|
-
const python = kind === "python";
|
|
3562
|
-
const program = (argv[index] ?? "").trim();
|
|
3563
|
-
let position = 0;
|
|
3564
|
-
const take = (pattern: RegExp): boolean => {
|
|
3565
|
-
const match = pattern.exec(program.slice(position));
|
|
3566
|
-
if (!match) return false;
|
|
3567
|
-
position += match[0].length;
|
|
3568
|
-
return true;
|
|
3569
|
-
};
|
|
3570
|
-
const literal = (): string | undefined => {
|
|
3571
|
-
take(/^\s*/);
|
|
3572
|
-
const start = position;
|
|
3573
|
-
const quote = program[position];
|
|
3574
|
-
if (quote !== "'" && quote !== '"') return undefined;
|
|
3575
|
-
const delimiter =
|
|
3576
|
-
python && program.startsWith(quote.repeat(3), position) ? quote.repeat(3) : quote;
|
|
3577
|
-
position += delimiter.length;
|
|
3578
|
-
while (position < program.length) {
|
|
3579
|
-
if (program.startsWith(delimiter, position)) {
|
|
3580
|
-
position += delimiter.length;
|
|
3581
|
-
return program.slice(start, position);
|
|
3582
|
-
}
|
|
3583
|
-
if (program[position] === "\\") position += 2;
|
|
3584
|
-
else if (delimiter.length === 1 && /[\r\n\u2028\u2029]/.test(program[position] ?? ""))
|
|
3585
|
-
return undefined;
|
|
3586
|
-
else position++;
|
|
3587
|
-
}
|
|
3588
|
-
return undefined;
|
|
3589
|
-
};
|
|
3590
|
-
let prefix: RegExp;
|
|
3591
|
-
let middle: RegExp | undefined;
|
|
3592
|
-
let mode = false;
|
|
3593
|
-
if (python && /^print\s*\(/.test(program)) prefix = /^print\s*\(\s*/;
|
|
3594
|
-
else if (!python && /^console\s*\.\s*log\s*\(/.test(program))
|
|
3595
|
-
prefix = /^console\s*\.\s*log\s*\(\s*/;
|
|
3596
|
-
else if (python && /^open\s*\(/.test(program)) {
|
|
3597
|
-
prefix = /^open\s*\(\s*/;
|
|
3598
|
-
mode = true;
|
|
3599
|
-
middle = /^\s*\)\s*\.\s*write\s*\(\s*/;
|
|
3600
|
-
} else if (kind === "bun" && /^Bun\s*\.\s*write\s*\(/.test(program)) {
|
|
3601
|
-
prefix = /^Bun\s*\.\s*write\s*\(\s*/;
|
|
3602
|
-
middle = /^\s*,\s*/;
|
|
3603
|
-
} else if (python) {
|
|
3604
|
-
prefix = /^from[ \t]+pathlib[ \t]+import[ \t]+Path[ \t]*(?:;|\r?\n)\s*Path\s*\(\s*/;
|
|
3605
|
-
middle = /^\s*\)\s*\.\s*write_text\s*\(\s*/;
|
|
3606
|
-
} else return false;
|
|
3607
|
-
if (!take(prefix) || literal() === undefined) return false;
|
|
3608
|
-
if (mode) {
|
|
3609
|
-
if (!take(/^\s*,\s*/)) return false;
|
|
3610
|
-
const value = literal();
|
|
3611
|
-
if (value !== "'w'" && value !== '"w"') return false;
|
|
3612
|
-
}
|
|
3613
|
-
if (middle && (!take(middle) || literal() === undefined)) return false;
|
|
3614
|
-
return take(/^\s*\)\s*;?\s*$/) && position === program.length;
|
|
3615
|
-
}
|
|
3616
|
-
|
|
3617
|
-
/** Literal inline output can accompany a proved environment display; file writers cannot. */
|
|
3618
|
-
function isLiteralInlineDisplay(argv: string[]): boolean {
|
|
3619
|
-
const index = inlineProgramIndex(argv);
|
|
3620
|
-
if (index === undefined || !isLiteralInlineWriter(argv)) return false;
|
|
3621
|
-
const kind = inlineRuntimeKind(argv[0]);
|
|
3622
|
-
const program = argv[index] ?? "";
|
|
3623
|
-
return (
|
|
3624
|
-
(kind === "python" && /^\s*print\s*\(/.test(program)) ||
|
|
3625
|
-
(kind === "node" && /^\s*console\s*\.\s*log\s*\(/.test(program))
|
|
3626
|
-
);
|
|
3627
|
-
}
|
|
3628
|
-
|
|
3629
|
-
/** Parse approved Python readers from the original shell shape, including quoted stdin heredocs. */
|
|
3630
|
-
function hasApprovedPythonEnvironmentRead(command: string, allowedNames: Set<string>): boolean {
|
|
3631
|
-
const normalized = normalizeProgramHeredocs(command);
|
|
3632
|
-
const parsed = parseStaticShellCommands(normalized);
|
|
3633
|
-
if (!parsed || typeof parsed === "string") return false;
|
|
3634
|
-
return parsed.pipelines
|
|
3635
|
-
.flat()
|
|
3636
|
-
.some((words) => isAllowedPythonEnvironmentRead(unwrapStaticCommand(words), allowedNames));
|
|
3637
|
-
}
|
|
3638
|
-
|
|
3639
|
-
/** Inert command identity is unproved after an explicit environment mutation. */
|
|
3640
|
-
function hasEnvironmentMutationPrefix(command: string): boolean {
|
|
3641
|
-
const heredoc = boundedHeredoc(command);
|
|
3642
|
-
if (heredoc) {
|
|
3643
|
-
if (!heredoc.closed) return true;
|
|
3644
|
-
return hasEnvironmentMutationPrefix(heredoc.header + "\n" + heredoc.following);
|
|
3645
|
-
}
|
|
3646
|
-
const parsed = parseStaticShellCommands(command);
|
|
3647
|
-
if (!parsed || typeof parsed === "string") return true;
|
|
3648
|
-
return parsed.pipelines.flat().some((words) => {
|
|
3649
|
-
let index = 0;
|
|
3650
|
-
while (index < words.length) {
|
|
3651
|
-
const word = words[index] ?? "";
|
|
3652
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) return true;
|
|
3653
|
-
const name = word.split("/").at(-1);
|
|
3654
|
-
if (name === "env") {
|
|
3655
|
-
index++;
|
|
3656
|
-
if (words[index] === "--") index++;
|
|
3657
|
-
if (words[index]?.startsWith("-")) return true;
|
|
3658
|
-
} else if (name === "rtk" || name === "command") {
|
|
3659
|
-
index++;
|
|
3660
|
-
if ((name === "rtk" && words[index] === "proxy") || words[index] === "--") index++;
|
|
3661
|
-
} else return false;
|
|
3662
|
-
}
|
|
3663
|
-
return false;
|
|
3664
|
-
});
|
|
3665
|
-
}
|
|
3666
|
-
|
|
3667
|
-
function hasEnvironmentSourceAccess(
|
|
3668
|
-
command: string,
|
|
3669
|
-
rawTrigger: boolean,
|
|
3670
|
-
allowedNames: Set<string>,
|
|
3671
|
-
): boolean {
|
|
3672
|
-
if (rawTrigger && hasEnvironmentMutationPrefix(command)) return true;
|
|
3673
|
-
const parsed = parseStaticShellCommands(command);
|
|
3674
|
-
if (parsed && typeof parsed !== "string") {
|
|
3675
|
-
const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
|
|
3676
|
-
if (commands.some((argv) => isAllowedPythonEnvironmentRead(argv, allowedNames))) {
|
|
3677
|
-
if (hasEnvironmentMutationPrefix(command)) return true;
|
|
3678
|
-
return !commands.every(
|
|
3679
|
-
(argv) =>
|
|
3680
|
-
argv[0]?.split("/").at(-1) === "cat" ||
|
|
3681
|
-
isPassiveTextCommand(argv) ||
|
|
3682
|
-
isAllowedPythonEnvironmentRead(argv, allowedNames) ||
|
|
3683
|
-
isLiteralInlineDisplay(argv),
|
|
3684
|
-
);
|
|
3685
|
-
}
|
|
3686
|
-
}
|
|
3687
|
-
if (isLiteralTextWrite(command)) return false;
|
|
3688
|
-
if (!parsed || typeof parsed === "string") return rawTrigger;
|
|
3689
|
-
const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
|
|
3690
|
-
const promptContext = isStaticHerdrPrompt(command);
|
|
3691
|
-
if (rawTrigger) {
|
|
3692
|
-
// Original invocation-wide evidence survives split writers and inline quote projection.
|
|
3693
|
-
return !commands.every(
|
|
3694
|
-
(argv) =>
|
|
3695
|
-
argv[0]?.split("/").at(-1) === "cat" ||
|
|
3696
|
-
isPassiveTextCommand(argv) ||
|
|
3697
|
-
isLiteralInlineWriter(argv) ||
|
|
3698
|
-
(promptContext && isLiteralHerdrPrompt(argv)),
|
|
3699
|
-
);
|
|
3700
|
-
}
|
|
3701
|
-
return commands.some((argv) => {
|
|
3702
|
-
if (isAllowedPythonEnvironmentRead(argv, allowedNames)) return false;
|
|
3703
|
-
const index = inlineProgramIndex(argv);
|
|
3704
|
-
if (index === undefined) return false;
|
|
3705
|
-
const python = inlineRuntimeKind(argv[0]) === "python";
|
|
3706
|
-
const parts = programParts(argv[index] ?? "", python);
|
|
3707
|
-
if (!parts) return true;
|
|
3708
|
-
return (
|
|
3709
|
-
mentionsEnvironmentSource(parts.code) ||
|
|
3710
|
-
(python && /\b(?:environ|getenv)\b/.test(parts.code)) ||
|
|
3711
|
-
(/\b(?:os|process)\s*\[/.test(parts.code) &&
|
|
3712
|
-
parts.literals.some((value) => /^(?:env|environ|getenv)$/.test(value)))
|
|
3713
|
-
);
|
|
3714
|
-
});
|
|
3715
|
-
}
|
|
3716
|
-
|
|
3717
|
-
/** Explicit lookup/loader names cannot establish interpreter startup context. Literal text is data. */
|
|
3718
|
-
function hasInterpreterStartupMutation(command: string): boolean {
|
|
3719
|
-
const parsed = parseStaticShellCommands(command) ?? parseStaticShellCommands(command, true);
|
|
3720
|
-
if (!parsed || typeof parsed === "string") return false;
|
|
3721
|
-
const commands = parsed.pipelines.flat();
|
|
3722
|
-
if (
|
|
3723
|
-
!commands.some((words) => {
|
|
3724
|
-
const name = unwrapStaticCommand(words)[0];
|
|
3725
|
-
return inlineRuntimeKind(name) !== undefined || isShellWord(name ?? "");
|
|
3726
|
-
})
|
|
3727
|
-
)
|
|
3728
|
-
return false;
|
|
3729
|
-
const startupName =
|
|
3730
|
-
/^(?:PATH|HOME|XDG_CONFIG_HOME|BUN_OPTIONS|BUN_PRELOAD|NODE_OPTIONS|NODE_PATH|PYTHON[A-Z_]*|BASH_ENV|ENV|ZDOTDIR|FPATH|SHELLOPTS|BASHOPTS|LD_[A-Z_]+|DYLD_[A-Z_]+)$/;
|
|
3731
|
-
return commands.some((words) => {
|
|
3732
|
-
const argv = unwrapStaticCommand(words);
|
|
3733
|
-
const prefix = words.slice(0, words.length - argv.length);
|
|
3734
|
-
if (prefix.some((word) => startupName.test(word.split("=", 1)[0] ?? ""))) return true;
|
|
3735
|
-
const mutator = argv[0] === "builtin" ? argv.slice(1) : argv;
|
|
3736
|
-
return (
|
|
3737
|
-
["export", "declare", "typeset", "local", "readonly"].includes(mutator[0] ?? "") &&
|
|
3738
|
-
mutator.slice(1).some((word) => startupName.test(word.split("=", 1)[0] ?? ""))
|
|
3739
|
-
);
|
|
3740
|
-
});
|
|
3741
|
-
}
|
|
3742
|
-
|
|
3743
|
-
/** Extract file path from hook arguments. */
|
|
3744
|
-
export function extractFilePath(args: Record<string, unknown>): string {
|
|
3745
|
-
return (args.filePath as string) || (args.file_path as string) || (args.path as string) || "";
|
|
3746
|
-
}
|
|
3747
|
-
|
|
3748
|
-
/** Extract content from hook arguments. */
|
|
3749
|
-
export function extractContent(args: Record<string, unknown>): string {
|
|
3750
|
-
return (args.content as string) || (args.newString as string) || "";
|
|
3751
|
-
}
|
|
3752
|
-
|
|
3753
|
-
/** Extract command from hook arguments. */
|
|
3754
|
-
export function extractCommand(args: Record<string, unknown>): string {
|
|
3755
|
-
return (args.command as string) || "";
|
|
3756
|
-
}
|
|
3757
|
-
|
|
3758
|
-
// ============================================================================
|
|
3759
|
-
// FACTORY
|
|
3760
|
-
// ============================================================================
|
|
3761
|
-
|
|
3762
|
-
/**
|
|
3763
|
-
* Create a credential guard with optional extra patterns merged into defaults.
|
|
3764
|
-
*
|
|
3765
|
-
* @param config - Optional overrides. Arrays are concatenated with defaults (not replaced).
|
|
3766
|
-
* @returns Object with all guard functions bound to the merged pattern sets.
|
|
3767
|
-
*/
|
|
3768
|
-
export function createCredentialGuard(config?: CredentialGuardConfig): CredentialGuard {
|
|
3769
|
-
const names = config?.allowedEnvironmentVariables;
|
|
3770
|
-
if (
|
|
3771
|
-
names !== undefined &&
|
|
3772
|
-
(!Array.isArray(names) || names.some((name) => typeof name !== "string"))
|
|
3773
|
-
) {
|
|
3774
|
-
throw new Error("allowedEnvironmentVariables must be an array of strings");
|
|
3775
|
-
}
|
|
3776
|
-
const allowedEnvironmentVariables = new Set(names ?? []);
|
|
3777
|
-
for (const name of allowedEnvironmentVariables) {
|
|
3778
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
3779
|
-
throw new Error(`Invalid allowed environment variable name: ${JSON.stringify(name)}`);
|
|
3780
|
-
}
|
|
3781
|
-
}
|
|
3782
|
-
const blockedPathPatterns = [
|
|
3783
|
-
...DEFAULT_BLOCKED_PATH_PATTERNS,
|
|
3784
|
-
...(config?.additionalBlockedPaths ?? []).map((p) => new RegExp(p)),
|
|
3785
|
-
];
|
|
3786
|
-
|
|
3787
|
-
const allowedPathPatterns = [
|
|
3788
|
-
...DEFAULT_ALLOWED_PATH_PATTERNS,
|
|
3789
|
-
...(config?.additionalAllowedPaths ?? []).map((p) => new RegExp(p)),
|
|
3790
|
-
];
|
|
3791
|
-
|
|
3792
|
-
const dangerousBashPatterns = [
|
|
3793
|
-
...(config?.additionalDangerousBashPatterns?.map((p) => new RegExp(p)) ?? []),
|
|
3794
|
-
];
|
|
3795
|
-
|
|
3796
|
-
const blockedCliTools: BlockedCliTool[] = [
|
|
3797
|
-
...DEFAULT_BLOCKED_CLI_TOOLS,
|
|
3798
|
-
...(config?.additionalBlockedCliTools ?? []).map(
|
|
3799
|
-
(override: CliToolOverride): BlockedCliTool => ({
|
|
3800
|
-
pattern: new RegExp(`(?:^|[;&|]\\s*)${escapeRegex(override.tool)}\\s`),
|
|
3801
|
-
name: override.tool,
|
|
3802
|
-
wrapper: override.suggestion,
|
|
3803
|
-
}),
|
|
3804
|
-
),
|
|
3805
|
-
];
|
|
3806
|
-
|
|
3807
|
-
function isPathAllowed(filePath: string): boolean {
|
|
3808
|
-
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
3809
|
-
return (
|
|
3810
|
-
!PROCESS_ENVIRONMENT_PATH.test(posix.normalize(normalizedPath)) &&
|
|
3811
|
-
allowedPathPatterns.some((pattern) => pattern.test(normalizedPath))
|
|
3812
|
-
);
|
|
3813
|
-
}
|
|
3814
|
-
|
|
3815
|
-
function isPathBlocked(filePath: string): boolean {
|
|
3816
|
-
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
3817
|
-
|
|
3818
|
-
if (PROCESS_ENVIRONMENT_PATH.test(posix.normalize(normalizedPath))) return true;
|
|
3819
|
-
|
|
3820
|
-
for (const pattern of allowedPathPatterns) {
|
|
3821
|
-
if (pattern.test(normalizedPath)) {
|
|
3822
|
-
return false;
|
|
3823
|
-
}
|
|
3824
|
-
}
|
|
3825
|
-
|
|
3826
|
-
for (const pattern of blockedPathPatterns) {
|
|
3827
|
-
if (pattern.test(normalizedPath)) {
|
|
3828
|
-
return true;
|
|
3829
|
-
}
|
|
3830
|
-
}
|
|
3831
|
-
|
|
3832
|
-
return false;
|
|
3833
|
-
}
|
|
3834
|
-
|
|
3835
|
-
function detectSecrets(content: string): { name: string; match: string } | null {
|
|
3836
|
-
for (const { name, pattern } of SECRET_PATTERNS) {
|
|
3837
|
-
const match = content.match(pattern);
|
|
3838
|
-
if (match) {
|
|
3839
|
-
const redacted = match[0].substring(0, 8) + "..." + match[0].substring(match[0].length - 4);
|
|
3840
|
-
return { name, match: redacted };
|
|
3841
|
-
}
|
|
3842
|
-
}
|
|
3843
|
-
return null;
|
|
3844
|
-
}
|
|
3845
|
-
|
|
3846
|
-
function isDangerousBashCommand(command: string): boolean {
|
|
3847
|
-
return getDangerousBashReason(command) !== null;
|
|
3848
|
-
}
|
|
3849
|
-
|
|
3850
|
-
function getDangerousBashReason(command: string, shellDepth = 0): string | null {
|
|
3851
|
-
const originalCommand = command;
|
|
3852
|
-
if (hasUnboundedHeredoc(command)) return "cannot prove a bounded complete heredoc invocation";
|
|
3853
|
-
let unterminatedQuote = false;
|
|
3854
|
-
parseStaticShellCommands(command, false, () => {
|
|
3855
|
-
unterminatedQuote = true;
|
|
3856
|
-
});
|
|
3857
|
-
if (unterminatedQuote)
|
|
3858
|
-
return "cannot prove shell syntax: an unterminated quoted argument was found";
|
|
3859
|
-
const chain = literalCommandChain(command);
|
|
3860
|
-
if (chain?.some(({ argv }) => isLiteralHerdrPrompt(argv)) && !isStaticHerdrPrompt(command)) {
|
|
3861
|
-
let copied = false;
|
|
3862
|
-
for (const [index, part] of chain.entries()) {
|
|
3863
|
-
const reason = getDangerousBashReason(part.source, shellDepth);
|
|
3864
|
-
if (reason) return `cannot prove chain part ${index + 1} (${part.source}): ${reason}`;
|
|
3865
|
-
if (
|
|
3866
|
-
!part.redirects &&
|
|
3867
|
-
isIndependentDataCopy(part.argv, config?.chainCopyDestinations ?? []) &&
|
|
3868
|
-
!copied
|
|
3869
|
-
) {
|
|
3870
|
-
copied = true;
|
|
3871
|
-
continue;
|
|
3872
|
-
}
|
|
3873
|
-
if (
|
|
3874
|
-
part.redirects ||
|
|
3875
|
-
!isIndependentHerdrOperation(part.argv) ||
|
|
3876
|
-
(copied && !(isLiteralHerdrPrompt(part.argv) && part.argv.length === 5))
|
|
3877
|
-
)
|
|
3878
|
-
return `cannot prove independent chain part ${index + 1} (${part.source}) under the environment/execution policy`;
|
|
3879
|
-
}
|
|
3880
|
-
if (dangerousBashPatterns.some((pattern) => pattern.test(originalCommand)))
|
|
3881
|
-
return "matches a configured dangerous-command pattern";
|
|
3882
|
-
return null;
|
|
3883
|
-
}
|
|
3884
|
-
const allowPackageInventory = hasProvedPackageInventoryInvocation(originalCommand);
|
|
3885
|
-
const promptWords = isStaticHerdrPrompt(command)
|
|
3886
|
-
? parseStaticShellCommands(command)
|
|
3887
|
-
: undefined;
|
|
3888
|
-
const sourceText =
|
|
3889
|
-
promptWords && typeof promptWords !== "string"
|
|
3890
|
-
? promptWords.pipelines
|
|
3891
|
-
.flat()
|
|
3892
|
-
.map((words) => {
|
|
3893
|
-
const argv = unwrapStaticCommand(words);
|
|
3894
|
-
return (isLiteralHerdrPrompt(argv) ? argv.slice(0, 4) : words).join(" ");
|
|
3895
|
-
})
|
|
3896
|
-
.join("; ")
|
|
3897
|
-
: command;
|
|
3898
|
-
const rawEnvironmentSource =
|
|
3899
|
-
mentionsEnvironmentSource(sourceText) && mentionsInlineRuntime(sourceText);
|
|
3900
|
-
if (
|
|
3901
|
-
hasApprovedPythonEnvironmentRead(originalCommand, allowedEnvironmentVariables) &&
|
|
3902
|
-
hasEnvironmentMutationPrefix(normalizeProgramHeredocs(originalCommand))
|
|
3903
|
-
) {
|
|
3904
|
-
return "blocked by the environment/execution policy after an unproved environment mutation";
|
|
3905
|
-
}
|
|
3906
|
-
if (rawEnvironmentSource && hasEnvironmentMutationPrefix(originalCommand))
|
|
3907
|
-
return "blocked by the environment/execution policy after an unproved environment mutation";
|
|
3908
|
-
command = normalizeProgramHeredocs(command);
|
|
3909
|
-
const shellBodies = literalShellBodies(command);
|
|
3910
|
-
if (!shellBodies || (shellBodies.length && shellDepth >= 8))
|
|
3911
|
-
return "cannot prove a bounded literal shell invocation";
|
|
3912
|
-
const provedPredicateShellBodies: string[] = [];
|
|
3913
|
-
for (const body of shellBodies) {
|
|
3914
|
-
const reason = getDangerousBashReason(body, shellDepth + 1);
|
|
3915
|
-
if (reason || getBlockedCliTool(body, true, shellDepth + 1))
|
|
3916
|
-
return reason ?? "invokes a blocked CLI from a shell body";
|
|
3917
|
-
if (
|
|
3918
|
-
!isProvedLiteralShellBody(body, allowedEnvironmentVariables, isPathBlocked, () => {
|
|
3919
|
-
provedPredicateShellBodies.push(body);
|
|
3920
|
-
})
|
|
3921
|
-
)
|
|
3922
|
-
return "cannot prove a closed literal shell body";
|
|
3923
|
-
}
|
|
3924
|
-
if (provedPredicateShellBodies.length) {
|
|
3925
|
-
const context = parseStaticShellCommands(command);
|
|
3926
|
-
const binding = literalBindingText(command, "", "");
|
|
3927
|
-
const argv =
|
|
3928
|
-
context && typeof context !== "string"
|
|
3929
|
-
? unwrapProofCommand(context.pipelines[0]?.[0] ?? [], "\0")
|
|
3930
|
-
: undefined;
|
|
3931
|
-
if (
|
|
3932
|
-
!context ||
|
|
3933
|
-
typeof context === "string" ||
|
|
3934
|
-
context.pipelines.length !== 1 ||
|
|
3935
|
-
context.pipelines[0]?.length !== 1 ||
|
|
3936
|
-
context.redirects.length !== 0 ||
|
|
3937
|
-
!binding ||
|
|
3938
|
-
/[;&|<>\r\n]/.test(binding.syntax.trim()) ||
|
|
3939
|
-
!argv ||
|
|
3940
|
-
!SHELL_IDENTITIES.has(argv[0] ?? "")
|
|
3941
|
-
)
|
|
3942
|
-
return "cannot prove a single literal shell predicate invocation";
|
|
3943
|
-
}
|
|
3944
|
-
const proof = materializeLiteralShell(command);
|
|
3945
|
-
if (!proof && /^\s*for\b/.test(command)) return "cannot prove a bounded literal shell loop";
|
|
3946
|
-
if (proof) {
|
|
3947
|
-
if (proof.paths.some((path) => hasSensitiveFileRead("cat " + path, isPathBlocked)))
|
|
3948
|
-
return "accesses a sensitive file path";
|
|
3949
|
-
command = proof.command;
|
|
3950
|
-
if (getBlockedCliTool(command, true, shellDepth))
|
|
3951
|
-
return "invokes a blocked CLI from a literal expansion";
|
|
3952
|
-
}
|
|
3953
|
-
for (const nested of [
|
|
3954
|
-
...inlineSubprocessCommands(command),
|
|
3955
|
-
...inlinePackageCommands(command, allowPackageInventory),
|
|
3956
|
-
]) {
|
|
3957
|
-
const reason = getDangerousBashReason(nested, shellDepth);
|
|
3958
|
-
if (reason || getBlockedCliTool(nested, true, shellDepth))
|
|
3959
|
-
return reason ?? "invokes a blocked CLI from a script";
|
|
3960
|
-
}
|
|
3961
|
-
if (hasUnsupportedGitDiffArguments(command))
|
|
3962
|
-
return "cannot prove supported Git diff option and file argument roles";
|
|
3963
|
-
if (
|
|
3964
|
-
hasSensitiveFileRead(command, isPathBlocked) ||
|
|
3965
|
-
hasSensitivePathRedirect(command, isPathBlocked)
|
|
3966
|
-
)
|
|
3967
|
-
return "blocked by the file-access policy; a sensitive path matched or this command form could not be verified";
|
|
3968
|
-
if (hasInterpreterStartupMutation(command))
|
|
3969
|
-
return "cannot prove interpreter startup after an explicit lookup or loader mutation";
|
|
3970
|
-
const staticCommands = parseStaticShellCommands(command);
|
|
3971
|
-
const diagnosticCommands = staticCommands ?? parseStaticShellCommands(command, true);
|
|
3972
|
-
const staticArgv =
|
|
3973
|
-
diagnosticCommands && typeof diagnosticCommands !== "string"
|
|
3974
|
-
? diagnosticCommands.pipelines.flat().map(unwrapStaticCommand)
|
|
3975
|
-
: [];
|
|
3976
|
-
const hasJqInvocation = staticArgv.some(
|
|
3977
|
-
(argv) =>
|
|
3978
|
-
unwrapStaticCommand(argv[0] === "builtin" ? argv.slice(1) : argv)[0]
|
|
3979
|
-
?.split("/")
|
|
3980
|
-
.at(-1) === "jq",
|
|
3981
|
-
);
|
|
3982
|
-
if (staticArgv.some(isPythonCompileModule) && !hasProvedPythonCompileInvocation(command))
|
|
3983
|
-
return "cannot prove isolated Python py_compile startup and literal source/cache paths";
|
|
3984
|
-
if (staticArgv.some(isPythonCompileModule) && config?.additionalBlockedPaths?.length)
|
|
3985
|
-
return "cannot prove implicit py_compile cache writes against configured path rules";
|
|
3986
|
-
if (
|
|
3987
|
-
staticArgv.some(
|
|
3988
|
-
(argv) =>
|
|
3989
|
-
argv[0]?.split("/").at(-1) === "jq" &&
|
|
3990
|
-
(!staticCommands ||
|
|
3991
|
-
staticCommands === "brace-expansion" ||
|
|
3992
|
-
staticCommands.pathnameExpansion ||
|
|
3993
|
-
!isPassiveJqCommand(argv)),
|
|
3994
|
-
)
|
|
3995
|
-
)
|
|
3996
|
-
return "cannot prove a supported passive jq filter and literal options";
|
|
3997
|
-
if (
|
|
3998
|
-
staticCommands &&
|
|
3999
|
-
typeof staticCommands !== "string" &&
|
|
4000
|
-
hasJqInvocation &&
|
|
4001
|
-
(!hasProvedNavigationSequence(command) ||
|
|
4002
|
-
staticCommands.pipelines.flat().some((words) => !hasProvedJqContext(words)))
|
|
4003
|
-
)
|
|
4004
|
-
return "cannot prove a supported passive jq startup prefix";
|
|
4005
|
-
let environmentCommand = command;
|
|
4006
|
-
if (
|
|
4007
|
-
!isLiteralTextWrite(command) &&
|
|
4008
|
-
hasEnvironmentVariableExpansionRead(
|
|
4009
|
-
command,
|
|
4010
|
-
allowedEnvironmentVariables,
|
|
4011
|
-
isPathBlocked,
|
|
4012
|
-
(proved) => {
|
|
4013
|
-
environmentCommand = proved;
|
|
4014
|
-
},
|
|
4015
|
-
)
|
|
4016
|
-
) {
|
|
4017
|
-
if (getLeadingLiteralAssignments(command).size || /\b[A-Za-z_]\w*=(?:\$\(|~)/.test(command))
|
|
4018
|
-
return "cannot prove a local binding expansion in a supported data operand";
|
|
4019
|
-
return "expands an unapproved or executable environment variable value";
|
|
4020
|
-
}
|
|
4021
|
-
if (
|
|
4022
|
-
hasEnvironmentSourceAccess(command, rawEnvironmentSource, allowedEnvironmentVariables) ||
|
|
4023
|
-
hasEnvironmentRead(
|
|
4024
|
-
environmentCommand,
|
|
4025
|
-
allowedEnvironmentVariables,
|
|
4026
|
-
allowPackageInventory,
|
|
4027
|
-
shellBodies.filter((body) => {
|
|
4028
|
-
if (provedPredicateShellBodies.includes(body)) return true;
|
|
4029
|
-
const parsed = parseStaticShellCommands(body);
|
|
4030
|
-
return (
|
|
4031
|
-
parsed &&
|
|
4032
|
-
typeof parsed !== "string" &&
|
|
4033
|
-
parsed.pipelines
|
|
4034
|
-
.flat()
|
|
4035
|
-
.every((words) => isPassiveTextCommand(unwrapStaticCommand(words)))
|
|
4036
|
-
);
|
|
4037
|
-
}),
|
|
4038
|
-
)
|
|
4039
|
-
) {
|
|
4040
|
-
if (staticArgv.some(isUnsupportedInlineRuntime))
|
|
4041
|
-
return "cannot prove supported inline runtime or module startup; external script contents were not inspected";
|
|
4042
|
-
return "blocked by the environment/execution policy; permitted access or non-execution could not be established for this command form";
|
|
4043
|
-
}
|
|
4044
|
-
if (
|
|
4045
|
-
dangerousBashPatterns.some(
|
|
4046
|
-
(pattern) => pattern.test(originalCommand) || pattern.test(command),
|
|
4047
|
-
)
|
|
4048
|
-
) {
|
|
4049
|
-
return "matches a configured dangerous-command pattern";
|
|
4050
|
-
}
|
|
4051
|
-
return null;
|
|
4052
|
-
}
|
|
4053
|
-
|
|
4054
|
-
function isGhCommandAllowed(command: string): boolean {
|
|
4055
|
-
const parsed = parseStaticShellCommands(command);
|
|
4056
|
-
if (!parsed || typeof parsed === "string") return false;
|
|
4057
|
-
const ghCommands = parsed.pipelines
|
|
4058
|
-
.flat()
|
|
4059
|
-
.map(unwrapStaticCommand)
|
|
4060
|
-
.filter((argv) => argv[0]?.split("/").at(-1) === "gh");
|
|
4061
|
-
return ghCommands.length > 0 && ghCommands.every(isAllowedGhArgv);
|
|
4062
|
-
}
|
|
4063
|
-
|
|
4064
|
-
function allGhCommandsAllowed(command: string): boolean {
|
|
4065
|
-
return isGhCommandAllowed(command);
|
|
4066
|
-
}
|
|
4067
|
-
|
|
4068
|
-
function detectSleepPolling(command: string): string | null {
|
|
4069
|
-
command = normalizeProgramHeredocs(command);
|
|
4070
|
-
if (!/\bsleep\s+\d+/.test(command)) return null;
|
|
4071
|
-
|
|
4072
|
-
for (const { pattern, suggestion } of DEFAULT_POLLING_DETECTION_RULES) {
|
|
4073
|
-
if (pattern.test(command)) {
|
|
4074
|
-
return suggestion;
|
|
4075
|
-
}
|
|
4076
|
-
}
|
|
4077
|
-
return null;
|
|
4078
|
-
}
|
|
4079
|
-
|
|
4080
|
-
function getBlockedCliTool(
|
|
4081
|
-
command: string,
|
|
4082
|
-
expand = true,
|
|
4083
|
-
shellDepth = 0,
|
|
4084
|
-
): { name: string; wrapper: string } | null {
|
|
4085
|
-
command = normalizeProgramHeredocs(command);
|
|
4086
|
-
const shellBodies = literalShellBodies(command);
|
|
4087
|
-
if (shellBodies && shellDepth < 8) {
|
|
4088
|
-
for (const body of shellBodies) {
|
|
4089
|
-
const blocked = getBlockedCliTool(body, true, shellDepth + 1);
|
|
4090
|
-
if (blocked) return blocked;
|
|
4091
|
-
}
|
|
4092
|
-
}
|
|
4093
|
-
const proof = expand ? materializeLiteralShell(command) : undefined;
|
|
4094
|
-
if (proof) {
|
|
4095
|
-
const originalBlocked = getBlockedCliTool(command, false, shellDepth);
|
|
4096
|
-
if (originalBlocked) return originalBlocked;
|
|
4097
|
-
command = proof.command;
|
|
4098
|
-
}
|
|
4099
|
-
for (const nested of inlineSubprocessCommands(command)) {
|
|
4100
|
-
const blocked = getBlockedCliTool(nested, true, shellDepth);
|
|
4101
|
-
if (blocked) return blocked;
|
|
4102
|
-
}
|
|
4103
|
-
const staticCommands = parseStaticShellCommands(command);
|
|
4104
|
-
const parsed = staticCommands ?? parseStaticShellCommands(command, true);
|
|
4105
|
-
if (parsed && typeof parsed !== "string") {
|
|
4106
|
-
const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
|
|
4107
|
-
for (const argv of commands) {
|
|
4108
|
-
const executable = argv[0]?.split("/").at(-1) ?? "";
|
|
4109
|
-
const match = blockedCliTools.find(({ name }) =>
|
|
4110
|
-
name === "curl (Azure DevOps)"
|
|
4111
|
-
? executable === "curl" && argv.slice(1).join(" ").includes("dev.azure.com")
|
|
4112
|
-
: name === "az" || name === "az (Azure DevOps)"
|
|
4113
|
-
? executable === "az" &&
|
|
4114
|
-
(name !== "az (Azure DevOps)" ||
|
|
4115
|
-
/^(?:devops|pipelines|repos|boards|artifacts)$/.test(argv[1] ?? ""))
|
|
4116
|
-
: executable === name,
|
|
4117
|
-
);
|
|
4118
|
-
if (!match) continue;
|
|
4119
|
-
if (match.name === "gh" && allGhCommandsAllowed(command)) continue;
|
|
4120
|
-
return { name: match.name, wrapper: match.wrapper };
|
|
4121
|
-
}
|
|
4122
|
-
if (staticCommands) return null;
|
|
4123
|
-
}
|
|
4124
|
-
for (const { pattern, name, wrapper } of blockedCliTools) {
|
|
4125
|
-
if (pattern.test(command)) {
|
|
4126
|
-
if (name === "gh" && allGhCommandsAllowed(command)) {
|
|
4127
|
-
return null;
|
|
4128
|
-
}
|
|
4129
|
-
return { name, wrapper };
|
|
4130
|
-
}
|
|
4131
|
-
}
|
|
4132
|
-
return null;
|
|
4133
|
-
}
|
|
4134
|
-
|
|
4135
|
-
function handleToolExecuteBefore(input: HookInput, output: HookOutput): void {
|
|
4136
|
-
try {
|
|
4137
|
-
enforceToolExecuteBefore(input, output);
|
|
4138
|
-
} catch (error) {
|
|
4139
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
4140
|
-
const sanitized = redactSensitiveText(message);
|
|
4141
|
-
if (error instanceof Error) {
|
|
4142
|
-
if (sanitized !== message) {
|
|
4143
|
-
error.message = sanitized;
|
|
4144
|
-
if (error.stack) error.stack = error.stack.replace(message, () => sanitized);
|
|
4145
|
-
}
|
|
4146
|
-
throw error;
|
|
4147
|
-
}
|
|
4148
|
-
// Do not preserve an unsanitized non-Error value as the new error's cause.
|
|
4149
|
-
// eslint-disable-next-line eslint/preserve-caught-error
|
|
4150
|
-
throw new Error(sanitized);
|
|
4151
|
-
}
|
|
4152
|
-
}
|
|
4153
|
-
|
|
4154
|
-
function blockedCliRecovery(blockedTool: { name: string; wrapper: string }): string {
|
|
4155
|
-
// Custom suggestions can be prose or commands. Preserve them without inventing a binary.
|
|
4156
|
-
const hasKnownHelp = DEFAULT_BLOCKED_CLI_TOOLS.some(
|
|
4157
|
-
({ wrapper }) => wrapper === blockedTool.wrapper,
|
|
4158
|
-
);
|
|
4159
|
-
return (
|
|
4160
|
-
`Configured recovery: ${blockedTool.wrapper}\n` +
|
|
4161
|
-
(hasKnownHelp ? `Discover supported operations: ${blockedTool.wrapper} --help\n` : "") +
|
|
4162
|
-
`Check the project README and the tool's documented help for the bounded operation.`
|
|
4163
|
-
);
|
|
4164
|
-
}
|
|
4165
|
-
|
|
4166
|
-
function dangerousCommandRecovery(command: string, reason: string): string {
|
|
4167
|
-
if (reason.includes("Git diff"))
|
|
4168
|
-
return (
|
|
4169
|
-
`Use supported literal Git diff options with complete argument values and nonsensitive file operands. ` +
|
|
4170
|
-
`Output/order-file arguments and revision-shaped positional operands receive file policy too. ` +
|
|
4171
|
-
`Unknown, ambiguous, missing or executable option roles remain unproved; use a simple git diff HEAD -- path/to/source form. ` +
|
|
4172
|
-
`The guard does not inspect repository state, native configuration or source contents.`
|
|
4173
|
-
);
|
|
4174
|
-
if (reason.includes("shell syntax"))
|
|
4175
|
-
return (
|
|
4176
|
-
`Check shell quote balance and encode the complete literal argument safely. ` +
|
|
4177
|
-
`An apostrophe inside single quotes needs shell encoding such as 'don'"'"'t'. ` +
|
|
4178
|
-
`Use a documented file-input option only if that command supports one; no such option is inferred here. ` +
|
|
4179
|
-
`Path, environment and execution policies still apply to the corrected command.`
|
|
4180
|
-
);
|
|
4181
|
-
if (reason.startsWith("invokes a blocked CLI")) {
|
|
4182
|
-
const blockedTool = getBlockedCliTool(command);
|
|
4183
|
-
if (blockedTool) return blockedCliRecovery(blockedTool);
|
|
4184
|
-
}
|
|
4185
|
-
const externalExecutionRecovery =
|
|
4186
|
-
`The guard sees the command, not external script contents. Removing shell flags does not certify a script. ` +
|
|
4187
|
-
`No generic approved external-script executor is provided by this guard. Review or implement a repository-owned bounded operation ` +
|
|
4188
|
-
`with secret injection and filtered output; discover existing operations in the project README and tool help.`;
|
|
4189
|
-
if (reason.includes("py_compile") && config?.additionalBlockedPaths?.length)
|
|
4190
|
-
return (
|
|
4191
|
-
`Configured path rules cannot be proved for py_compile's runtime-specific cache and temporary filenames. ` +
|
|
4192
|
-
`Use a cache-free syntax parse with literal nonsensitive paths: ` +
|
|
4193
|
-
`python3 -I -S -B -c 'import ast; ast.parse(open("path/to/source.py", "rb").read(), filename="path/to/source.py")'. ` +
|
|
4194
|
-
`Source paths and all other command policies still apply; target source is parsed without execution. ` +
|
|
4195
|
-
`The guard has not inspected source contents or resolved symlinks.`
|
|
4196
|
-
);
|
|
4197
|
-
if (reason.includes("py_compile"))
|
|
4198
|
-
return (
|
|
4199
|
-
`Use python3 -I -S -m py_compile path/to/source.py with literal nonsensitive .py paths and passive consumers. ` +
|
|
4200
|
-
`Both -I and -S must precede -m; unknown flags, other modules and stdin file lists are unsupported. ` +
|
|
4201
|
-
`This compiles syntax without executing target source, but writes __pycache__ even with -B and may quote source in errors. ` +
|
|
4202
|
-
`The guard has not inspected source contents or resolved symlinks.`
|
|
4203
|
-
);
|
|
4204
|
-
if (reason.includes("jq"))
|
|
4205
|
-
return (
|
|
4206
|
-
`Use a literal inline jq filter within the supported passive grammar and known formatting/input flags. ` +
|
|
4207
|
-
`Field traversal, // fallback, nested objects/arrays, map/select and length are supported. ` +
|
|
4208
|
-
`Environment reads, imports, program files, unknown calls and incomplete syntax cannot establish this proof. ` +
|
|
4209
|
-
`Use literal nonsensitive input paths and passive output consumers; field omission does not certify input contents. ` +
|
|
4210
|
-
`Native jq normally loads a home .jq file; trusted host configuration is assumed, not inspected. ` +
|
|
4211
|
-
`The exact child-process prefix env HOME=/dev/null jq avoids that user startup lookup; -L does not.`
|
|
4212
|
-
);
|
|
4213
|
-
if (reason.includes("local binding"))
|
|
4214
|
-
return (
|
|
4215
|
-
`Use separate commands with literal nonsensitive file paths, or a supported bounded literal file binding/loop. ` +
|
|
4216
|
-
`Command substitution and a binding used as an executable or script operand cannot establish this proof. ` +
|
|
4217
|
-
`The guard has not inspected external script contents; this refusal does not establish ambient environment access.`
|
|
4218
|
-
);
|
|
4219
|
-
if (reason.includes("runtime or module"))
|
|
4220
|
-
return (
|
|
4221
|
-
`Use a supported literal inline program with known runtime flags, or a repository-owned bounded operation. ` +
|
|
4222
|
-
`Unknown startup options and executable module forms remain unsupported. ` +
|
|
4223
|
-
`The guard has not inspected external script contents.`
|
|
4224
|
-
);
|
|
4225
|
-
if (reason.startsWith("cannot prove") && reason.includes("shell")) {
|
|
4226
|
-
return (
|
|
4227
|
-
`Interactive and login shells load startup files, which can execute code or load credentials. ` +
|
|
4228
|
-
`Only supported noninteractive literal forms, such as sh -c or zsh -f -c, with a completely proved closed body can pass. ` +
|
|
4229
|
-
externalExecutionRecovery
|
|
4230
|
-
);
|
|
4231
|
-
}
|
|
4232
|
-
if (reason.includes("file")) {
|
|
4233
|
-
return (
|
|
4234
|
-
`Use a nonsensitive example/template or a bounded wrapper operation that returns only the needed fields. ` +
|
|
4235
|
-
`A matched sensitive path or an unverifiable file-access form remains blocked; do not copy or dump credential files.`
|
|
4236
|
-
);
|
|
4237
|
-
}
|
|
4238
|
-
if (reason.includes("environment")) {
|
|
4239
|
-
return (
|
|
4240
|
-
`Do not dump or forward the environment. For a known nonsensitive value, use an exact approved name ` +
|
|
4241
|
-
`with printenv NAME; approval is configured in credentialGuard.allowedEnvironmentVariables. ` +
|
|
4242
|
-
`This does not approve execution or secret values. ` +
|
|
4243
|
-
externalExecutionRecovery
|
|
4244
|
-
);
|
|
4245
|
-
}
|
|
4246
|
-
return (
|
|
4247
|
-
`The guard cannot verify this command form as permitted. Check the project README and tool help ` +
|
|
4248
|
-
`for an existing bounded operation, or review the guard proof and add a regression test before changing policy. ` +
|
|
4249
|
-
externalExecutionRecovery
|
|
4250
|
-
);
|
|
4251
|
-
}
|
|
4252
|
-
|
|
4253
|
-
function enforceToolExecuteBefore(input: HookInput, output: HookOutput): void {
|
|
4254
|
-
// Normalize tool name across platforms:
|
|
4255
|
-
// - AI coding agents pass capitalized: "Bash", "Read", "Write", "Edit"
|
|
4256
|
-
// - OpenCode MCP tools pass prefixed: "mcp_bash", "mcp_read", "mcp_write", "mcp_edit"
|
|
4257
|
-
const tool = input.tool.toLowerCase().replace(/^mcp_/, "");
|
|
4258
|
-
const args = output.args;
|
|
4259
|
-
|
|
4260
|
-
const filePath = extractFilePath(args);
|
|
4261
|
-
|
|
4262
|
-
if ((tool === "read" || tool === "write" || tool === "edit") && filePath) {
|
|
4263
|
-
if (isPathBlocked(filePath)) {
|
|
4264
|
-
throw new Error(
|
|
4265
|
-
`\u{1F6AB} Access blocked: "${filePath}" is a sensitive file.\n\n` +
|
|
4266
|
-
`This file may contain credentials or secrets.\n` +
|
|
4267
|
-
`If you need this file's content, ask the user to provide relevant parts.\n\n` +
|
|
4268
|
-
`Think this should be allowed? Review the project repository, extend the guard, and submit a PR.`,
|
|
4269
|
-
);
|
|
4270
|
-
}
|
|
4271
|
-
}
|
|
4272
|
-
|
|
4273
|
-
if (tool === "write" || tool === "edit") {
|
|
4274
|
-
if (!isPathAllowed(filePath)) {
|
|
4275
|
-
const content = extractContent(args);
|
|
4276
|
-
|
|
4277
|
-
if (content) {
|
|
4278
|
-
const detected = detectSecrets(content);
|
|
4279
|
-
if (detected) {
|
|
4280
|
-
throw new Error(
|
|
4281
|
-
`\u{1F6AB} Secret detected: Potential ${detected.name} found in content.\n\n` +
|
|
4282
|
-
`Matched: [REDACTED]\n\n` +
|
|
4283
|
-
`Never commit secrets to code. Use environment variables or secret managers.\n\n` +
|
|
4284
|
-
`Think this is a false positive? Review the project repository, fix the pattern, and submit a PR.`,
|
|
4285
|
-
);
|
|
4286
|
-
}
|
|
4287
|
-
}
|
|
4288
|
-
}
|
|
4289
|
-
}
|
|
4290
|
-
|
|
4291
|
-
if (tool === "bash") {
|
|
4292
|
-
const command = extractCommand(args);
|
|
4293
|
-
|
|
4294
|
-
let dangerousReason = getDangerousBashReason(command);
|
|
4295
|
-
if (dangerousReason && !dangerousReason.includes("chain part")) {
|
|
4296
|
-
let unproved: string | undefined;
|
|
4297
|
-
for (const [index, source] of (commandChainSources(command) ?? []).entries()) {
|
|
4298
|
-
const reason = getDangerousBashReason(source);
|
|
4299
|
-
const parsed = parseStaticShellCommands(source);
|
|
4300
|
-
const literal = literalBindingText(source, "", "");
|
|
4301
|
-
const words = parsed && typeof parsed !== "string" ? parsed.pipelines[0]?.[0] : undefined;
|
|
4302
|
-
const argv = words ? unwrapProofCommand(words, "\0") : undefined;
|
|
4303
|
-
if (reason) {
|
|
4304
|
-
dangerousReason += `; cannot prove chain part ${index + 1} (${source}): ${reason}`;
|
|
4305
|
-
break;
|
|
4306
|
-
}
|
|
4307
|
-
if (!unproved && (!literal || /[<>]/.test(literal.syntax) || !argv))
|
|
4308
|
-
unproved = `cannot prove chain part ${index + 1} (${source}): independent composition is unproved`;
|
|
4309
|
-
}
|
|
4310
|
-
if (!dangerousReason.includes("chain part") && unproved) dangerousReason += `; ${unproved}`;
|
|
4311
|
-
}
|
|
4312
|
-
if (dangerousReason) {
|
|
4313
|
-
throw new Error(
|
|
4314
|
-
`\u{1F6AB} Command blocked: ${dangerousReason}.\n\n` +
|
|
4315
|
-
`Command: ${command}\n\n` +
|
|
4316
|
-
`${dangerousCommandRecovery(command, dangerousReason)}\n\n` +
|
|
4317
|
-
`Tool inventory and usage: https://github.com/blogic-cz/agent-tools#tools`,
|
|
4318
|
-
);
|
|
4319
|
-
}
|
|
4320
|
-
|
|
4321
|
-
const sleepSuggestion = detectSleepPolling(command);
|
|
4322
|
-
if (sleepSuggestion) {
|
|
4323
|
-
throw new Error(
|
|
4324
|
-
`\u{26A0}\u{FE0F} Sleep-polling detected.\n\n` +
|
|
4325
|
-
`Instead of polling with sleep, use the built-in watch command:\n\n` +
|
|
4326
|
-
`Use instead: ${sleepSuggestion}\n\n` +
|
|
4327
|
-
`Watch commands block until completion — no polling needed.`,
|
|
4328
|
-
);
|
|
4329
|
-
}
|
|
4330
|
-
|
|
4331
|
-
const blockedTool = getBlockedCliTool(command);
|
|
4332
|
-
if (blockedTool) {
|
|
4333
|
-
throw new Error(
|
|
4334
|
-
`\u{1F6AB} Direct ${blockedTool.name} usage blocked.\n\n` +
|
|
4335
|
-
`AI agents must use wrapper tools for security and audit.\n\n` +
|
|
4336
|
-
blockedCliRecovery(blockedTool),
|
|
4337
|
-
);
|
|
4338
|
-
}
|
|
4339
|
-
}
|
|
4340
|
-
}
|
|
4341
|
-
|
|
4342
|
-
return {
|
|
4343
|
-
handleToolExecuteBefore,
|
|
4344
|
-
detectSecrets,
|
|
4345
|
-
isPathAllowed,
|
|
4346
|
-
isPathBlocked,
|
|
4347
|
-
isDangerousBashCommand,
|
|
4348
|
-
getBlockedCliTool,
|
|
4349
|
-
isGhCommandAllowed,
|
|
4350
|
-
detectSleepPolling,
|
|
4351
|
-
};
|
|
4352
|
-
}
|
|
4353
|
-
|
|
4354
|
-
// ============================================================================
|
|
4355
|
-
// TOP-LEVEL EXPORTS (default guard, no config)
|
|
4356
|
-
// ============================================================================
|
|
4357
|
-
|
|
4358
|
-
const defaultGuard = createCredentialGuard();
|
|
366
|
+
let defaultGuard: CredentialGuard | undefined;
|
|
367
|
+
const getDefaultGuard = (): CredentialGuard => (defaultGuard ??= createCredentialGuard());
|
|
4359
368
|
|
|
4360
369
|
/** Handle tool execution with default guard (no extra config). */
|
|
4361
|
-
export const handleToolExecuteBefore =
|
|
370
|
+
export const handleToolExecuteBefore: CredentialGuard["handleToolExecuteBefore"] = (...args) =>
|
|
371
|
+
getDefaultGuard().handleToolExecuteBefore(...args);
|
|
4362
372
|
|
|
4363
373
|
/** Detect secrets in content with default guard. */
|
|
4364
|
-
export const detectSecrets =
|
|
374
|
+
export const detectSecrets: CredentialGuard["detectSecrets"] = (...args) =>
|
|
375
|
+
getDefaultGuard().detectSecrets(...args);
|
|
4365
376
|
|
|
4366
377
|
/** Check if a path is in the allowed exceptions list (default guard). */
|
|
4367
|
-
export const isPathAllowed =
|
|
378
|
+
export const isPathAllowed: CredentialGuard["isPathAllowed"] = (...args) =>
|
|
379
|
+
getDefaultGuard().isPathAllowed(...args);
|
|
4368
380
|
|
|
4369
381
|
/** Check if a path should be blocked (default guard). */
|
|
4370
|
-
export const isPathBlocked =
|
|
382
|
+
export const isPathBlocked: CredentialGuard["isPathBlocked"] = (...args) =>
|
|
383
|
+
getDefaultGuard().isPathBlocked(...args);
|
|
4371
384
|
|
|
4372
385
|
/** Check if a bash command might expose secrets (default guard). */
|
|
4373
|
-
export const isDangerousBashCommand =
|
|
386
|
+
export const isDangerousBashCommand: CredentialGuard["isDangerousBashCommand"] = (...args) =>
|
|
387
|
+
getDefaultGuard().isDangerousBashCommand(...args);
|
|
4374
388
|
|
|
4375
389
|
/** Get blocked CLI tool info (default guard). */
|
|
4376
|
-
export const getBlockedCliTool =
|
|
390
|
+
export const getBlockedCliTool: CredentialGuard["getBlockedCliTool"] = (...args) =>
|
|
391
|
+
getDefaultGuard().getBlockedCliTool(...args);
|
|
4377
392
|
|
|
4378
393
|
/** Check if a gh command is allowed (default guard). */
|
|
4379
|
-
export const isGhCommandAllowed =
|
|
394
|
+
export const isGhCommandAllowed: CredentialGuard["isGhCommandAllowed"] = (...args) =>
|
|
395
|
+
getDefaultGuard().isGhCommandAllowed(...args);
|
|
4380
396
|
|
|
4381
397
|
/** Detect sleep-polling with agent-tools wrapper commands (default guard). */
|
|
4382
|
-
export const detectSleepPolling =
|
|
398
|
+
export const detectSleepPolling: CredentialGuard["detectSleepPolling"] = (...args) =>
|
|
399
|
+
getDefaultGuard().detectSleepPolling(...args);
|