@datename/fastcode-termux 0.1.6-beta → 0.1.8-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +4 -0
- package/README.md +50 -17
- package/dist/agent/approval-json.d.ts +43 -0
- package/dist/agent/approval-json.js +69 -0
- package/dist/agent/approval-native.d.ts +3 -0
- package/dist/agent/approval-native.js +165 -0
- package/dist/agent/approval-store.d.ts +16 -0
- package/dist/agent/approval-store.js +21 -0
- package/dist/agent/approval.d.ts +33 -0
- package/dist/agent/approval.js +40 -5
- package/dist/agent/compaction.d.ts +2 -0
- package/dist/agent/compaction.js +60 -17
- package/dist/agent/guards.d.ts +16 -16
- package/dist/agent/guards.js +978 -24
- package/dist/agent/policy/defaults.d.ts +16 -0
- package/dist/agent/policy/defaults.js +17 -0
- package/dist/agent/policy/egress.d.ts +32 -0
- package/dist/agent/policy/egress.js +280 -0
- package/dist/agent/process-tree.d.ts +20 -0
- package/dist/agent/process-tree.js +81 -10
- package/dist/agent/protocol.d.ts +20 -1
- package/dist/agent/protocol.js +165 -38
- package/dist/agent/registry.d.ts +3 -1
- package/dist/agent/registry.js +168 -11
- package/dist/agent/runner.d.ts +21 -0
- package/dist/agent/runner.js +44 -5
- package/dist/agent/shell-ast.d.ts +11 -0
- package/dist/agent/shell-ast.js +204 -0
- package/dist/agent/shell-session.d.ts +14 -0
- package/dist/agent/shell-session.js +251 -40
- package/dist/agent/tool-schemas.js +26 -7
- package/dist/agent/tools/append_file.js +3 -1
- package/dist/agent/tools/apply_patch.js +44 -4
- package/dist/agent/tools/copy_file.js +26 -3
- package/dist/agent/tools/delete_file.js +22 -3
- package/dist/agent/tools/grep.js +35 -4
- package/dist/agent/tools/make_dir.js +3 -1
- package/dist/agent/tools/move_path.d.ts +7 -0
- package/dist/agent/tools/move_path.js +31 -5
- package/dist/agent/tools/read_file.js +63 -3
- package/dist/agent/tools/read_many.js +21 -3
- package/dist/agent/tools/readers.d.ts +53 -1
- package/dist/agent/tools/readers.js +236 -26
- package/dist/agent/tools/replace_in_file.js +38 -5
- package/dist/agent/tools/shell.js +2 -2
- package/dist/agent/tools/types.d.ts +26 -0
- package/dist/agent/tools/validate.d.ts +15 -2
- package/dist/agent/tools/validate.js +66 -3
- package/dist/agent/tools/write_file.js +3 -1
- package/dist/app.d.ts +2 -1
- package/dist/app.js +526 -85
- package/dist/cli-main.js +363 -0
- package/dist/cli.js +397 -337
- package/dist/commands/about.js +1 -1
- package/dist/commands/clear.d.ts +1 -0
- package/dist/commands/clear.js +1 -1
- package/dist/commands/compact.d.ts +12 -0
- package/dist/commands/compact.js +14 -2
- package/dist/commands/context.js +5 -4
- package/dist/commands/index.js +2 -0
- package/dist/commands/jobs.js +3 -2
- package/dist/commands/kill.js +3 -1
- package/dist/commands/login.d.ts +2 -2
- package/dist/commands/login.js +15 -15
- package/dist/commands/model.d.ts +1 -13
- package/dist/commands/model.js +6 -38
- package/dist/commands/provider.d.ts +1 -15
- package/dist/commands/provider.js +4 -22
- package/dist/commands/skills.js +34 -4
- package/dist/commands/threads.d.ts +9 -0
- package/dist/commands/threads.js +26 -0
- package/dist/config/store.d.ts +21 -0
- package/dist/config/store.js +47 -9
- package/dist/config/trust.d.ts +9 -0
- package/dist/config/trust.js +41 -2
- package/dist/headless.d.ts +7 -0
- package/dist/headless.js +213 -0
- package/dist/index.js +19 -3
- package/dist/lib/debug.d.ts +2 -0
- package/dist/lib/debug.js +22 -0
- package/dist/lib/diagnostics.d.ts +19 -0
- package/dist/lib/diagnostics.js +32 -6
- package/dist/lib/highlight-subset.d.ts +21 -0
- package/dist/lib/highlight-subset.js +88 -0
- package/dist/lib/lifecycle.d.ts +5 -0
- package/dist/lib/lifecycle.js +62 -0
- package/dist/lib/markdown.js +22 -4
- package/dist/providers/deepseek.js +1 -1
- package/dist/providers/openaiCompatible.d.ts +24 -2
- package/dist/providers/openaiCompatible.js +20 -6
- package/dist/providers/spec.d.ts +12 -0
- package/dist/providers/spec.js +29 -0
- package/dist/session/log.d.ts +14 -2
- package/dist/session/log.js +56 -27
- package/dist/session/projection.d.ts +34 -1
- package/dist/session/projection.js +315 -46
- package/dist/skills/manager.d.ts +15 -0
- package/dist/skills/manager.js +135 -18
- package/dist/types.d.ts +15 -0
- package/dist/ui/StatusBar.js +1 -1
- package/package.json +3 -2
- package/src/agent/approval-json.ts +103 -0
- package/src/agent/approval-native.ts +168 -0
- package/src/agent/approval-store.ts +38 -0
- package/src/agent/approval.ts +58 -11
- package/src/agent/compaction.ts +62 -17
- package/src/agent/guards.ts +1049 -25
- package/src/agent/policy/defaults.ts +34 -0
- package/src/agent/policy/egress.ts +335 -0
- package/src/agent/process-tree.ts +105 -23
- package/src/agent/protocol.ts +187 -39
- package/src/agent/registry.ts +186 -14
- package/src/agent/runner.ts +50 -7
- package/src/agent/shell-ast.ts +227 -0
- package/src/agent/shell-session.ts +265 -41
- package/src/agent/tool-schemas.ts +26 -7
- package/src/agent/tools/append_file.ts +3 -1
- package/src/agent/tools/apply_patch.ts +45 -4
- package/src/agent/tools/copy_file.ts +27 -3
- package/src/agent/tools/delete_file.ts +22 -3
- package/src/agent/tools/grep.ts +36 -4
- package/src/agent/tools/make_dir.ts +3 -1
- package/src/agent/tools/move_path.ts +38 -5
- package/src/agent/tools/read_file.ts +64 -3
- package/src/agent/tools/read_many.ts +21 -3
- package/src/agent/tools/readers.ts +289 -26
- package/src/agent/tools/replace_in_file.ts +39 -5
- package/src/agent/tools/shell.ts +2 -2
- package/src/agent/tools/types.ts +43 -1
- package/src/agent/tools/validate.ts +73 -4
- package/src/agent/tools/write_file.ts +3 -1
- package/src/app.ts +533 -82
- package/src/{cli.ts → cli-main.ts} +34 -6
- package/src/commands/about.ts +1 -1
- package/src/commands/clear.ts +1 -1
- package/src/commands/compact.ts +14 -2
- package/src/commands/context.ts +5 -4
- package/src/commands/index.ts +2 -0
- package/src/commands/jobs.ts +3 -2
- package/src/commands/kill.ts +3 -1
- package/src/commands/login.ts +15 -15
- package/src/commands/model.ts +6 -38
- package/src/commands/provider.ts +4 -22
- package/src/commands/skills.ts +34 -4
- package/src/commands/threads.ts +28 -0
- package/src/config/store.ts +47 -9
- package/src/config/trust.ts +45 -2
- package/src/headless.ts +225 -0
- package/src/index.ts +19 -3
- package/src/lib/debug.ts +24 -0
- package/src/lib/diagnostics.ts +32 -6
- package/src/lib/highlight-subset.ts +93 -0
- package/src/lib/lifecycle.ts +69 -0
- package/src/lib/markdown.ts +22 -4
- package/src/providers/deepseek.ts +1 -1
- package/src/providers/openaiCompatible.ts +47 -10
- package/src/providers/spec.ts +42 -0
- package/src/session/log.ts +70 -27
- package/src/session/projection.ts +360 -47
- package/src/skills/manager.ts +140 -18
- package/src/types/highlight-shims.d.ts +14 -0
- package/src/types.ts +16 -1
- package/src/ui/StatusBar.ts +1 -1
- package/tsconfig.json +1 -0
- /package/dist/{cli.d.ts → cli-main.d.ts} +0 -0
package/dist/agent/guards.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
1
2
|
import { normalize, resolve } from "node:path";
|
|
3
|
+
import { analyzeShellAst } from "./shell-ast.js";
|
|
2
4
|
/**
|
|
3
5
|
* Dangerous-command guard.
|
|
4
6
|
*
|
|
@@ -45,33 +47,54 @@ function matches(lower, pattern) {
|
|
|
45
47
|
*/
|
|
46
48
|
export function evaluateShellCommand(command) {
|
|
47
49
|
const cmd = String(command ?? "").trim();
|
|
48
|
-
const
|
|
49
|
-
if (
|
|
50
|
-
return {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
const destructive = classifyDestructiveCommand(cmd);
|
|
51
|
+
if (destructive?.kind === "hard") {
|
|
52
|
+
return {
|
|
53
|
+
allow: false,
|
|
54
|
+
action: "hard_block",
|
|
55
|
+
code: destructive.code,
|
|
56
|
+
target: destructive.target,
|
|
57
|
+
reason: destructive.reason,
|
|
58
|
+
alternatives: destructive.alternatives,
|
|
59
|
+
output: formatHardBlock(destructive),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const ast = astHardUpgrade(cmd);
|
|
63
|
+
if (ast) {
|
|
64
|
+
return {
|
|
65
|
+
allow: false,
|
|
66
|
+
action: "hard_block",
|
|
67
|
+
code: ast.code,
|
|
68
|
+
target: ast.target,
|
|
69
|
+
reason: ast.reason,
|
|
70
|
+
alternatives: ast.alternatives,
|
|
71
|
+
output: formatHardBlock(ast),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const soft = destructive?.kind === "soft" ? destructive : legacySoftMatch(cmd);
|
|
75
|
+
if (soft) {
|
|
76
|
+
if (enabled("FASTCODE_ALLOW_DANGEROUS")) {
|
|
54
77
|
return {
|
|
55
|
-
allow:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
78
|
+
allow: true,
|
|
79
|
+
action: "allow",
|
|
80
|
+
code: "soft_gate_bypassed",
|
|
81
|
+
target: soft.target,
|
|
82
|
+
output: "Soft gate bypassed by FASTCODE_ALLOW_DANGEROUS=1.",
|
|
83
|
+
bypassed: true,
|
|
59
84
|
};
|
|
60
85
|
}
|
|
86
|
+
return {
|
|
87
|
+
allow: false,
|
|
88
|
+
action: "soft_gate",
|
|
89
|
+
code: soft.code,
|
|
90
|
+
target: soft.target,
|
|
91
|
+
reason: soft.reason,
|
|
92
|
+
alternatives: soft.alternatives,
|
|
93
|
+
output: formatSoftGate(soft),
|
|
94
|
+
...(soft.code === "legacy_dangerous_pattern" ? { pattern: soft.target } : {}),
|
|
95
|
+
};
|
|
61
96
|
}
|
|
62
|
-
|
|
63
|
-
if (lower.includes(marker))
|
|
64
|
-
return { allow: false, output: "Error: blocked by the safety guard (fork bomb pattern)." };
|
|
65
|
-
}
|
|
66
|
-
const allowlist = splitList(process.env["FASTCODE_SHELL_ALLOWLIST"]);
|
|
67
|
-
if (allowlist.length > 0 && !allowlist.some((pattern) => matches(lower, pattern)))
|
|
68
|
-
return { allow: false, output: "Error: blocked by the safety guard (not in FASTCODE_SHELL_ALLOWLIST)." };
|
|
69
|
-
if (enabled("FASTCODE_RESTRICT_TO_WORKSPACE")) {
|
|
70
|
-
const backslash = String.fromCharCode(92);
|
|
71
|
-
if (cmd.includes("../") || cmd.includes(".." + backslash))
|
|
72
|
-
return { allow: false, output: "Error: blocked by the safety guard (path traversal)." };
|
|
73
|
-
}
|
|
74
|
-
return { allow: true, output: undefined };
|
|
97
|
+
return { allow: true, action: "allow", code: "allowed", output: undefined };
|
|
75
98
|
}
|
|
76
99
|
export const READ_ONLY_TOOLS = new Set([
|
|
77
100
|
"pwd", "read_file", "read_many", "list_files", "grep", "stat_path",
|
|
@@ -79,10 +102,12 @@ export const READ_ONLY_TOOLS = new Set([
|
|
|
79
102
|
const READ_ONLY_COMMANDS = new Set([
|
|
80
103
|
"pwd", "ls", "dir", "cat", "head", "tail", "wc", "stat", "file", "du", "df",
|
|
81
104
|
"grep", "rg", "egrep", "fgrep", "which", "whereis", "where", "whoami", "id",
|
|
82
|
-
"groups", "uname", "date", "uptime", "
|
|
105
|
+
"groups", "uname", "date", "uptime", "echo", "printf",
|
|
83
106
|
"true", "false", "basename", "dirname", "realpath", "readlink", "tree",
|
|
84
107
|
"more", "less", "jq", "cut", "tr",
|
|
85
108
|
]);
|
|
109
|
+
const COMMAND_WRAPPERS = new Set(["sudo", "doas", "command", "exec", "nohup", "nice", "stdbuf", "time", "timeout", "xargs"]);
|
|
110
|
+
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish"]);
|
|
86
111
|
const READ_ONLY_PREFIXES = new Set([
|
|
87
112
|
"git status", "git diff", "git log", "git show", "git rev-parse",
|
|
88
113
|
"git ls-files", "git blame", "git grep", "git describe", "git merge-base",
|
|
@@ -193,6 +218,935 @@ export function splitCommandSegments(command) {
|
|
|
193
218
|
parts.push(current);
|
|
194
219
|
return parts.map((part) => part.trim()).filter(Boolean);
|
|
195
220
|
}
|
|
221
|
+
function baseCommandName(token) {
|
|
222
|
+
const cleaned = token.replace(/^[()'"`]+|[()'"`]+$/g, "");
|
|
223
|
+
const parts = cleaned.split(/[\\/]/);
|
|
224
|
+
return (parts[parts.length - 1] ?? cleaned).toLowerCase();
|
|
225
|
+
}
|
|
226
|
+
function rawCommandName(token) {
|
|
227
|
+
const cleaned = stripShellToken(token).replace(/^\\+/, "").replace(/['"]/g, "");
|
|
228
|
+
return baseCommandName(cleaned);
|
|
229
|
+
}
|
|
230
|
+
const SENSITIVE_VAR_RE = /(?:key|token|secret|passw|auth|credential|private)/i;
|
|
231
|
+
const EXFIL_SINK_RE = /\b(?:curl|wget|nc|ncat|netcat|socat|ssh|scp|rsync|tee|logger|mail|sendmail)\b/i;
|
|
232
|
+
const CREDENTIAL_DUMP_RE = /(?:^|[;&|(]\s*)(?:sudo\s+|xargs(?:\s+-\S+)*\s+)?(printenv|env)\b/g;
|
|
233
|
+
/**
|
|
234
|
+
* OQ-06: bare `printenv` / `env` is a Soft Gate (output can be reviewed).
|
|
235
|
+
* Targeted sensitive names, grep for sensitive words, and piping/redirecting
|
|
236
|
+
* the dump to an external sink are Hard Block.
|
|
237
|
+
*/
|
|
238
|
+
function credentialDumpMatch(command) {
|
|
239
|
+
for (const match of command.matchAll(CREDENTIAL_DUMP_RE)) {
|
|
240
|
+
const name = (match[1] ?? "").toLowerCase();
|
|
241
|
+
const start = (match.index ?? 0) + match[0].length;
|
|
242
|
+
let rest = command.slice(start);
|
|
243
|
+
const stop = rest.search(/;|&&|\|\||\n/);
|
|
244
|
+
if (stop >= 0)
|
|
245
|
+
rest = rest.slice(0, stop);
|
|
246
|
+
rest = rest.replace(/[)\]}]+$/, "").trim();
|
|
247
|
+
const pipe = rest.indexOf("|");
|
|
248
|
+
const beforePipe = pipe >= 0 ? rest.slice(0, pipe) : rest;
|
|
249
|
+
const afterPipe = pipe >= 0 ? rest.slice(pipe + 1).trim() : "";
|
|
250
|
+
const args = beforePipe.trim().split(/\s+/).filter(Boolean);
|
|
251
|
+
if (afterPipe) {
|
|
252
|
+
if (/\bgrep\b/i.test(afterPipe) && SENSITIVE_VAR_RE.test(afterPipe)) {
|
|
253
|
+
return hardMatch("credential_dump_targeted", `${name} | grep`, "targeted-secret-search", [
|
|
254
|
+
"Do not grep environment output for secret-like names.",
|
|
255
|
+
]);
|
|
256
|
+
}
|
|
257
|
+
if (EXFIL_SINK_RE.test(afterPipe)) {
|
|
258
|
+
return hardMatch("credential_exfil", `${name} | ${afterPipe.split(/\s+/)[0] ?? ""}`, "environment-output-to-external-sink", [
|
|
259
|
+
"Do not pipe environment output into a network/file/log sink.",
|
|
260
|
+
]);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (/(?:^|\s)>{1,2}\s*(?!\/dev\/null)\S+/.test(beforePipe)) {
|
|
264
|
+
return hardMatch("credential_exfil", `${name} > file`, "environment-output-to-file", [
|
|
265
|
+
"Do not write environment output to a file; review named non-secret variables instead.",
|
|
266
|
+
]);
|
|
267
|
+
}
|
|
268
|
+
if (name === "printenv") {
|
|
269
|
+
const variables = args.filter((arg) => !arg.startsWith("-"));
|
|
270
|
+
if (variables.some((value) => SENSITIVE_VAR_RE.test(value))) {
|
|
271
|
+
return hardMatch("credential_dump_targeted", variables.join(" "), "targeted-secret-variable", [
|
|
272
|
+
"Do not print secret-like environment variables.",
|
|
273
|
+
]);
|
|
274
|
+
}
|
|
275
|
+
if (variables.length > 0)
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
// `env VAR=value command` is a wrapper, not a dump. The inner
|
|
280
|
+
// command still goes through the normal classifier/AST path.
|
|
281
|
+
const commandIndex = args.findIndex((arg) => !arg.startsWith("-") && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg));
|
|
282
|
+
if (commandIndex >= 0)
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
return softMatch("credential_dump", name, "environment-dump");
|
|
286
|
+
}
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
const REMOTE_SHELL_PIPE_RE = /(?:\bcurl\b|\bwget\b)[\s\S]*?\|\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|fish|ksh)\b/i;
|
|
290
|
+
const BASE64_SHELL_PIPE_RE = /\bbase64\b[\s\S]*?(?:-d|--decode)[\s\S]*?\|\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|fish|ksh)\b/i;
|
|
291
|
+
const PROCESS_SUBSTITUTION_SHELL_RE = /(?:sh|bash|zsh|dash|fish|ksh)\s+<\(\s*(?:curl|wget)\b/i;
|
|
292
|
+
const EVAL_FETCH_RE = /(?:eval|source|\.)\s+\$\(\s*(?:curl|wget)\b/i;
|
|
293
|
+
const COMMAND_SUBSTITUTION_FETCH_RE = /\$\(\s*(?:curl|wget)\b/i;
|
|
294
|
+
const BLOCK_DEVICE_RE = /^\/dev\/(?:sd|hd|nvme|mmcblk|disk|rdisk)/i;
|
|
295
|
+
const SOURCE_DIR_NAMES = new Set(["src", "lib", "app", "apps", "packages", "package", "pkg", "server", "client", ".git"]);
|
|
296
|
+
const SYSTEM_PATH_PREFIXES = ["/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/boot", "/var", "/opt", "/root", "/home", "/srv", "/proc", "/sys"];
|
|
297
|
+
const SHELL_GRAMMAR_TOKENS = new Set(["{", "}", "(", ")", "!", "if", "then", "else", "elif", "fi", "for", "do", "done", "while", "until", "case", "esac", "function"]);
|
|
298
|
+
function formatHardBlock(match) {
|
|
299
|
+
const alternatives = match.alternatives.length > 0 ? `\nSafe alternatives: ${match.alternatives.join("; ")}` : "";
|
|
300
|
+
return `[guardrails] HARD_BLOCK action=${match.code} target=${JSON.stringify(match.target)} reason=${match.reason}\n` +
|
|
301
|
+
"This hard block cannot be overridden by FASTCODE_ALLOW_DANGEROUS, trust, session/always approval, or configuration." +
|
|
302
|
+
alternatives;
|
|
303
|
+
}
|
|
304
|
+
function formatSoftGate(match) {
|
|
305
|
+
return `[guardrails] SOFT_GATE action=${match.code} target=${JSON.stringify(match.target)} reason=${match.reason}\n` +
|
|
306
|
+
"Approval is required; no approval resolver is wired in this batch, so the command is denied (fail closed).";
|
|
307
|
+
}
|
|
308
|
+
function redirectTargets(command) {
|
|
309
|
+
const targets = [];
|
|
310
|
+
let quote = "";
|
|
311
|
+
let escaped = false;
|
|
312
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
313
|
+
const char = command[i];
|
|
314
|
+
if (escaped) {
|
|
315
|
+
escaped = false;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (char === "\\") {
|
|
319
|
+
escaped = true;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (quote) {
|
|
323
|
+
if (char === quote)
|
|
324
|
+
quote = "";
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (char === '"' || char === "'") {
|
|
328
|
+
quote = char;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (char !== ">")
|
|
332
|
+
continue;
|
|
333
|
+
if (command[i + 1] === ">" || command[i + 1] === "|")
|
|
334
|
+
i += 1;
|
|
335
|
+
let cursor = i + 1;
|
|
336
|
+
while (/\s/.test(command[cursor] ?? ""))
|
|
337
|
+
cursor += 1;
|
|
338
|
+
if (!command[cursor])
|
|
339
|
+
continue;
|
|
340
|
+
let target = "";
|
|
341
|
+
const targetQuote = command[cursor];
|
|
342
|
+
if (targetQuote === '"' || targetQuote === "'") {
|
|
343
|
+
cursor += 1;
|
|
344
|
+
while (cursor < command.length && command[cursor] !== targetQuote) {
|
|
345
|
+
if (command[cursor] === "\\" && targetQuote === '"' && cursor + 1 < command.length) {
|
|
346
|
+
target += command[cursor + 1];
|
|
347
|
+
cursor += 2;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
target += command[cursor];
|
|
351
|
+
cursor += 1;
|
|
352
|
+
}
|
|
353
|
+
i = cursor;
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
while (cursor < command.length && !/[\s;&|]/.test(command[cursor])) {
|
|
357
|
+
target += command[cursor];
|
|
358
|
+
cursor += 1;
|
|
359
|
+
}
|
|
360
|
+
i = cursor - 1;
|
|
361
|
+
}
|
|
362
|
+
if (target)
|
|
363
|
+
targets.push(target);
|
|
364
|
+
}
|
|
365
|
+
return targets;
|
|
366
|
+
}
|
|
367
|
+
function unquotedForLegacy(command) {
|
|
368
|
+
let out = "";
|
|
369
|
+
let quote = "";
|
|
370
|
+
let escaped = false;
|
|
371
|
+
for (const char of command) {
|
|
372
|
+
if (escaped) {
|
|
373
|
+
escaped = false;
|
|
374
|
+
out += quote ? " " : char;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (char === "\\") {
|
|
378
|
+
escaped = true;
|
|
379
|
+
out += quote ? " " : char;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (quote) {
|
|
383
|
+
if (char === quote)
|
|
384
|
+
quote = "";
|
|
385
|
+
out += " ";
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (char === '"' || char === "'") {
|
|
389
|
+
quote = char;
|
|
390
|
+
out += " ";
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
out += char;
|
|
394
|
+
}
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Fold the historical "ordinary deny" patterns into Soft Gate. They are
|
|
399
|
+
* heuristics, not non-overridable Hard Blocks; keeping a fourth state made the
|
|
400
|
+
* FASTCODE_ALLOW_DANGEROUS semantics ambiguous.
|
|
401
|
+
*/
|
|
402
|
+
function legacySoftMatch(command) {
|
|
403
|
+
const scan = unquotedForLegacy(command).toLowerCase();
|
|
404
|
+
const deny = DEFAULT_DENY_PATTERNS.concat(splitList(process.env["FASTCODE_SHELL_DENY_EXTRA"]));
|
|
405
|
+
for (const pattern of deny) {
|
|
406
|
+
if (matches(scan, pattern))
|
|
407
|
+
return { kind: "soft", code: "legacy_dangerous_pattern", target: pattern, reason: "legacy-deny", alternatives: [] };
|
|
408
|
+
}
|
|
409
|
+
const allowlist = splitList(process.env["FASTCODE_SHELL_ALLOWLIST"]);
|
|
410
|
+
if (allowlist.length > 0 && !allowlist.some((pattern) => matches(scan, pattern)))
|
|
411
|
+
return { kind: "soft", code: "allowlist", target: "FASTCODE_SHELL_ALLOWLIST", reason: "not-allowlisted", alternatives: [] };
|
|
412
|
+
if (enabled("FASTCODE_RESTRICT_TO_WORKSPACE")) {
|
|
413
|
+
const backslash = String.fromCharCode(92);
|
|
414
|
+
if (command.includes("../") || command.includes(".." + backslash))
|
|
415
|
+
return { kind: "soft", code: "path_traversal", target: "..", reason: "workspace-escape", alternatives: [] };
|
|
416
|
+
}
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
function hardMatch(code, target, reason, alternatives) {
|
|
420
|
+
return { kind: "hard", code, target, reason, alternatives };
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* AST is a second opinion, never a replacement. It may only return a Hard
|
|
424
|
+
* Block upgrade; parse failures or soft findings are ignored so the shape
|
|
425
|
+
* matcher's result is never downgraded.
|
|
426
|
+
*/
|
|
427
|
+
function astHardUpgrade(command, depth = 0, visited = new Set()) {
|
|
428
|
+
if (depth > 4 || visited.has(command))
|
|
429
|
+
return undefined;
|
|
430
|
+
visited.add(command);
|
|
431
|
+
const analysis = analyzeShellAst(command);
|
|
432
|
+
if (analysis.hard)
|
|
433
|
+
return hardMatch(analysis.hard.code, analysis.hard.target, analysis.hard.reason, analysis.hard.alternatives);
|
|
434
|
+
for (const derived of analysis.derived) {
|
|
435
|
+
if (derived === command)
|
|
436
|
+
continue;
|
|
437
|
+
const shape = classifyDestructiveCommand(derived);
|
|
438
|
+
if (shape?.kind === "hard")
|
|
439
|
+
return shape;
|
|
440
|
+
const nested = astHardUpgrade(derived, depth + 1, visited);
|
|
441
|
+
if (nested)
|
|
442
|
+
return nested;
|
|
443
|
+
}
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
function softMatch(code, target, reason) {
|
|
447
|
+
return { kind: "soft", code, target, reason, alternatives: [] };
|
|
448
|
+
}
|
|
449
|
+
function stripShellToken(value) {
|
|
450
|
+
let token = String(value ?? "").trim();
|
|
451
|
+
for (const quote of ['"', "'", "`"]) {
|
|
452
|
+
if (token.length >= 2 && token.startsWith(quote) && token.endsWith(quote)) {
|
|
453
|
+
token = token.slice(1, -1);
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return token.replace(/^[()]+|[()]+$/g, "");
|
|
458
|
+
}
|
|
459
|
+
function commandIndex(tokens) {
|
|
460
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
461
|
+
const token = stripShellToken(tokens[i]);
|
|
462
|
+
if (!token)
|
|
463
|
+
continue;
|
|
464
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token))
|
|
465
|
+
continue;
|
|
466
|
+
const name = rawCommandName(token);
|
|
467
|
+
if (SHELL_GRAMMAR_TOKENS.has(name))
|
|
468
|
+
continue;
|
|
469
|
+
if (name === "sudo" || name === "doas") {
|
|
470
|
+
i += 1;
|
|
471
|
+
while (i < tokens.length && tokens[i].startsWith("-")) {
|
|
472
|
+
const option = stripShellToken(tokens[i]);
|
|
473
|
+
i += 1;
|
|
474
|
+
if (/^-(u|g|p|C|U|h)$/.test(option) && i < tokens.length)
|
|
475
|
+
i += 1;
|
|
476
|
+
}
|
|
477
|
+
i -= 1;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (name === "timeout") {
|
|
481
|
+
i += 1;
|
|
482
|
+
while (i < tokens.length && (tokens[i].startsWith("-") || /^\d+(?:\.\d+)?[smhd]?$/.test(tokens[i])))
|
|
483
|
+
i += 1;
|
|
484
|
+
i -= 1;
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (COMMAND_WRAPPERS.has(name) || token.startsWith("-") || /^\d+(?:\.\d+)?[smhd]?$/.test(token))
|
|
488
|
+
continue;
|
|
489
|
+
return i;
|
|
490
|
+
}
|
|
491
|
+
return -1;
|
|
492
|
+
}
|
|
493
|
+
function parseRmArgs(args) {
|
|
494
|
+
let recursive = false;
|
|
495
|
+
let literal = false;
|
|
496
|
+
const targets = [];
|
|
497
|
+
for (const raw of args) {
|
|
498
|
+
const arg = stripShellToken(raw);
|
|
499
|
+
if (!literal && arg === "--") {
|
|
500
|
+
literal = true;
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (!literal && arg.startsWith("--")) {
|
|
504
|
+
if (arg === "--recursive")
|
|
505
|
+
recursive = true;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
if (!literal && /^-[A-Za-z]+$/.test(arg)) {
|
|
509
|
+
if (/[rR]/.test(arg.slice(1)))
|
|
510
|
+
recursive = true;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (arg)
|
|
514
|
+
targets.push(arg);
|
|
515
|
+
}
|
|
516
|
+
return { recursive, targets };
|
|
517
|
+
}
|
|
518
|
+
const SAFE_SENSITIVE_NAME_RE = /^(?:\.env\.example|\.envrc)$/i;
|
|
519
|
+
const SENSITIVE_NAME_RE = /^(?:\.env(?:\.[^/]+)?|auth\.json|trust\.json|credentials(?:\.json)?|\.netrc|\.git-credentials|id_(?:rsa|dsa|ecdsa|ed25519)|authorized_keys|known_hosts|sessions|.*\.(?:pem|key|p12|pfx)|.*\.session(?:\.jsonl)?)$/i;
|
|
520
|
+
const SENSITIVE_DIR_RE = /(?:^|\/)(?:\.ssh|\.aws|\.gnupg|\.git\/hooks|\.fastcode|sessions)(?:\/|$)/i;
|
|
521
|
+
const CRITICAL_READ_PATH_RE = /^(?:\/etc\/(?:shadow|gshadow|sudoers)|\/private\/etc\/(?:shadow|sudoers))$/i;
|
|
522
|
+
const CRITICAL_READ_BASENAME_RE = /^id_(?:rsa|dsa|ecdsa|ed25519)$/i;
|
|
523
|
+
const CRITICAL_READ_COMMANDS = new Set([
|
|
524
|
+
"cat", "tac", "head", "tail", "less", "more", "most", "strings", "base64",
|
|
525
|
+
"xxd", "od", "hexdump", "grep", "egrep", "fgrep", "awk", "sed", "perl",
|
|
526
|
+
"ruby", "python", "python3", "node", "openssl", "cp", "scp", "rsync",
|
|
527
|
+
"sftp", "ftp", "curl", "wget", "nc", "ncat", "netcat", "socat", "ssh",
|
|
528
|
+
"tar", "zip", "gzip", "bzip2", "xz", "7z", "find",
|
|
529
|
+
]);
|
|
530
|
+
const NETWORK_SINK_COMMANDS = new Set([
|
|
531
|
+
"curl", "wget", "nc", "ncat", "netcat", "socat", "ssh", "scp", "rsync",
|
|
532
|
+
"sftp", "ftp", "telnet",
|
|
533
|
+
]);
|
|
534
|
+
const SHARED_STORAGE_RE = /(?:^|[\\/])(?:sdcard|mnt[\\/]sdcard|storage[\\/](?:shared|emulated))(?=[\\/]|$)/i;
|
|
535
|
+
function expandHome(target) {
|
|
536
|
+
const home = process.env["HOME"] || process.env["USERPROFILE"] || "";
|
|
537
|
+
if (!home)
|
|
538
|
+
return target;
|
|
539
|
+
if (target === "~")
|
|
540
|
+
return home;
|
|
541
|
+
if (target.startsWith("~/") || target.startsWith("~\\"))
|
|
542
|
+
return home + target.slice(1);
|
|
543
|
+
return target.replace(/^\$(?:HOME|\{HOME\})/i, home);
|
|
544
|
+
}
|
|
545
|
+
function normalizeTargetPath(target) {
|
|
546
|
+
const expanded = expandHome(stripShellToken(target).replace(/\\/g, "/"));
|
|
547
|
+
const absolute = resolve(process.cwd(), expanded);
|
|
548
|
+
let real = absolute;
|
|
549
|
+
try {
|
|
550
|
+
real = realpathSync.native(absolute);
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
// The target may not exist yet; lexical normalization still blocks ./ and a/../ forms.
|
|
554
|
+
}
|
|
555
|
+
return normalize(real).replace(/\\/g, "/");
|
|
556
|
+
}
|
|
557
|
+
function pathBasename(target) {
|
|
558
|
+
return (normalizeTargetPath(target).split("/").pop() ?? "").toLowerCase();
|
|
559
|
+
}
|
|
560
|
+
export function isSharedStoragePath(target) {
|
|
561
|
+
return SHARED_STORAGE_RE.test(normalizeTargetPath(target));
|
|
562
|
+
}
|
|
563
|
+
function matchesCriticalReadPath(target) {
|
|
564
|
+
const raw = stripShellToken(target).replace(/\\/g, "/");
|
|
565
|
+
return CRITICAL_READ_PATH_RE.test(raw) || CRITICAL_READ_PATH_RE.test(normalizeTargetPath(target));
|
|
566
|
+
}
|
|
567
|
+
function cleanSensitiveToken(token) {
|
|
568
|
+
let value = stripShellToken(String(token ?? "").trim());
|
|
569
|
+
value = value.replace(/^@+/, "");
|
|
570
|
+
const equals = value.lastIndexOf("=");
|
|
571
|
+
if (equals >= 0)
|
|
572
|
+
value = value.slice(equals + 1);
|
|
573
|
+
value = value.replace(/^@+/, "");
|
|
574
|
+
if (!/^[A-Za-z]:[\\/]/.test(value)) {
|
|
575
|
+
const colon = value.lastIndexOf(":");
|
|
576
|
+
if (colon > 0)
|
|
577
|
+
value = value.slice(colon + 1);
|
|
578
|
+
}
|
|
579
|
+
return value.replace(/[),;]+$/, "");
|
|
580
|
+
}
|
|
581
|
+
function sensitiveFileHit(token) {
|
|
582
|
+
const candidate = cleanSensitiveToken(token);
|
|
583
|
+
if (!candidate || candidate === "/dev/null" || candidate.toLowerCase() === "nul")
|
|
584
|
+
return undefined;
|
|
585
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(candidate))
|
|
586
|
+
return undefined;
|
|
587
|
+
const base = pathBasename(candidate);
|
|
588
|
+
if (!base || SAFE_SENSITIVE_NAME_RE.test(base))
|
|
589
|
+
return undefined;
|
|
590
|
+
if (SENSITIVE_NAME_RE.test(base))
|
|
591
|
+
return { target: candidate, name: base };
|
|
592
|
+
const normalized = normalizeTargetPath(candidate);
|
|
593
|
+
if (SENSITIVE_DIR_RE.test(normalized) || matchesCriticalReadPath(candidate))
|
|
594
|
+
return { target: candidate, name: base };
|
|
595
|
+
return undefined;
|
|
596
|
+
}
|
|
597
|
+
function criticalReadHit(token) {
|
|
598
|
+
const candidate = cleanSensitiveToken(token);
|
|
599
|
+
if (!candidate)
|
|
600
|
+
return undefined;
|
|
601
|
+
const normalized = normalizeTargetPath(candidate);
|
|
602
|
+
if (matchesCriticalReadPath(candidate))
|
|
603
|
+
return candidate;
|
|
604
|
+
const base = (normalized.split("/").pop() ?? "").toLowerCase();
|
|
605
|
+
if (!CRITICAL_READ_BASENAME_RE.test(base))
|
|
606
|
+
return undefined;
|
|
607
|
+
if (/[\\/]/.test(candidate) || /(?:^|\/)\.ssh(?:\/|$)/.test(normalized))
|
|
608
|
+
return candidate;
|
|
609
|
+
return undefined;
|
|
610
|
+
}
|
|
611
|
+
export function isCriticalReadTarget(target) {
|
|
612
|
+
return Boolean(criticalReadHit(target));
|
|
613
|
+
}
|
|
614
|
+
function commandTokens(command) {
|
|
615
|
+
const tokens = [];
|
|
616
|
+
let current = "";
|
|
617
|
+
let quote = "";
|
|
618
|
+
let escaped = false;
|
|
619
|
+
const push = () => {
|
|
620
|
+
if (current) {
|
|
621
|
+
tokens.push(current);
|
|
622
|
+
current = "";
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
626
|
+
const char = command[i];
|
|
627
|
+
if (escaped) {
|
|
628
|
+
current += char;
|
|
629
|
+
escaped = false;
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if (char === "\\") {
|
|
633
|
+
escaped = true;
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (quote) {
|
|
637
|
+
if (char === quote)
|
|
638
|
+
quote = "";
|
|
639
|
+
else
|
|
640
|
+
current += char;
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
if (char === '"' || char === "'") {
|
|
644
|
+
quote = char;
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
if (/[\s|;&()<>]/.test(char)) {
|
|
648
|
+
push();
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
current += char;
|
|
652
|
+
}
|
|
653
|
+
push();
|
|
654
|
+
return tokens;
|
|
655
|
+
}
|
|
656
|
+
function segmentCommandName(segment) {
|
|
657
|
+
const tokens = segment.split(/\s+/).filter(Boolean);
|
|
658
|
+
const index = commandIndex(tokens);
|
|
659
|
+
if (index < 0)
|
|
660
|
+
return "";
|
|
661
|
+
let name = rawCommandName(tokens[index]);
|
|
662
|
+
let cursor = index + 1;
|
|
663
|
+
while (name === "env") {
|
|
664
|
+
while (cursor < tokens.length && (tokens[cursor].startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(stripShellToken(tokens[cursor]))))
|
|
665
|
+
cursor += 1;
|
|
666
|
+
if (cursor >= tokens.length)
|
|
667
|
+
break;
|
|
668
|
+
name = rawCommandName(tokens[cursor]);
|
|
669
|
+
cursor += 1;
|
|
670
|
+
}
|
|
671
|
+
return name;
|
|
672
|
+
}
|
|
673
|
+
function credentialReadMatch(command) {
|
|
674
|
+
for (const segment of splitCommandSegments(command)) {
|
|
675
|
+
if (!CRITICAL_READ_COMMANDS.has(segmentCommandName(segment)))
|
|
676
|
+
continue;
|
|
677
|
+
for (const token of commandTokens(segment)) {
|
|
678
|
+
const hit = criticalReadHit(token);
|
|
679
|
+
if (hit) {
|
|
680
|
+
return hardMatch("credential_read", hit, "critical-credential-read", [
|
|
681
|
+
"Do not read private keys or system credential databases.",
|
|
682
|
+
"Ask the user to rotate the credential if exposure is suspected.",
|
|
683
|
+
]);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return undefined;
|
|
688
|
+
}
|
|
689
|
+
function sensitiveFileEgressMatch(command) {
|
|
690
|
+
const hits = commandTokens(command).map(sensitiveFileHit).filter(Boolean);
|
|
691
|
+
if (hits.length === 0)
|
|
692
|
+
return undefined;
|
|
693
|
+
const hasSink = splitCommandSegments(command).some((segment) => NETWORK_SINK_COMMANDS.has(segmentCommandName(segment)));
|
|
694
|
+
if (!hasSink)
|
|
695
|
+
return undefined;
|
|
696
|
+
const hit = hits[0];
|
|
697
|
+
return hardMatch("sensitive_file_egress", hit.target, `sensitive-file-upload: ${hit.name}`, [
|
|
698
|
+
"Do not send credential/session files to network sinks or upload targets.",
|
|
699
|
+
"Use a redacted copy that contains no secrets.",
|
|
700
|
+
]);
|
|
701
|
+
}
|
|
702
|
+
function sensitiveWriteReason(name, args) {
|
|
703
|
+
const positional = args.map(stripShellToken).filter((arg) => arg && arg !== "--" && !arg.startsWith("-") && !/^\d+$/.test(arg));
|
|
704
|
+
if (positional.length === 0)
|
|
705
|
+
return undefined;
|
|
706
|
+
const writeMatch = (target) => {
|
|
707
|
+
const hit = sensitiveFileHit(target);
|
|
708
|
+
if (!hit)
|
|
709
|
+
return undefined;
|
|
710
|
+
return hardMatch("truncate_sensitive", hit.target, `sensitive-write: ${hit.name}`, [
|
|
711
|
+
"Move the credential/session file to a secure backup instead of rewriting it.",
|
|
712
|
+
]);
|
|
713
|
+
};
|
|
714
|
+
if (name === "tee" || name === "sponge") {
|
|
715
|
+
for (const target of positional) {
|
|
716
|
+
const match = writeMatch(target);
|
|
717
|
+
if (match)
|
|
718
|
+
return match;
|
|
719
|
+
}
|
|
720
|
+
return undefined;
|
|
721
|
+
}
|
|
722
|
+
if (name === "sed" || name === "perl" || name === "ruby") {
|
|
723
|
+
const inPlace = args.some((arg) => /^-[a-z]*i/i.test(stripShellToken(arg)) ||
|
|
724
|
+
/^--in-place/i.test(stripShellToken(arg)));
|
|
725
|
+
return inPlace ? writeMatch(positional[positional.length - 1]) : undefined;
|
|
726
|
+
}
|
|
727
|
+
if (name === "cp" || name === "install" || name === "rsync" || name === "ln") {
|
|
728
|
+
const destination = positional[positional.length - 1];
|
|
729
|
+
const destinationMatch = writeMatch(destination);
|
|
730
|
+
if (destinationMatch)
|
|
731
|
+
return destinationMatch;
|
|
732
|
+
const sourceHit = positional.length > 1 ? sensitiveFileHit(positional[0]) : undefined;
|
|
733
|
+
if (sourceHit && isSharedStoragePath(destination)) {
|
|
734
|
+
return hardMatch("sensitive_file_egress", sourceHit.target, `shared-storage-egress: ${sourceHit.name}`, [
|
|
735
|
+
"Do not copy credential/session files into shared storage.",
|
|
736
|
+
]);
|
|
737
|
+
}
|
|
738
|
+
return undefined;
|
|
739
|
+
}
|
|
740
|
+
if (name === "mv" || name === "move") {
|
|
741
|
+
const destination = positional[positional.length - 1];
|
|
742
|
+
const destinationMatch = writeMatch(destination);
|
|
743
|
+
if (destinationMatch)
|
|
744
|
+
return destinationMatch;
|
|
745
|
+
if (positional.length > 1) {
|
|
746
|
+
const sourceHit = sensitiveFileHit(positional[0]);
|
|
747
|
+
if (sourceHit) {
|
|
748
|
+
return hardMatch("move_sensitive", sourceHit.target, `sensitive-file-move: ${sourceHit.name}`, [
|
|
749
|
+
"Do not rename or relocate credential/session files.",
|
|
750
|
+
]);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return undefined;
|
|
754
|
+
}
|
|
755
|
+
return undefined;
|
|
756
|
+
}
|
|
757
|
+
function isDotEnvTarget(target) {
|
|
758
|
+
const base = pathBasename(target);
|
|
759
|
+
if (SAFE_SENSITIVE_NAME_RE.test(base))
|
|
760
|
+
return false;
|
|
761
|
+
return base === ".env" || base.startsWith(".env.");
|
|
762
|
+
}
|
|
763
|
+
export function isSensitiveTarget(target) {
|
|
764
|
+
return Boolean(sensitiveFileHit(target));
|
|
765
|
+
}
|
|
766
|
+
function isSystemPath(target) {
|
|
767
|
+
const clean = stripShellToken(target).replace(/\\/g, "/").replace(/\/+$/, "");
|
|
768
|
+
return SYSTEM_PATH_PREFIXES.some((prefix) => clean === prefix || clean.startsWith(prefix + "/"));
|
|
769
|
+
}
|
|
770
|
+
function hasWildcardTarget(target) {
|
|
771
|
+
return /[*?\[]/.test(stripShellToken(target));
|
|
772
|
+
}
|
|
773
|
+
function dangerousDeleteReason(target) {
|
|
774
|
+
const cleaned = stripShellToken(target).replace(/\\/g, "/");
|
|
775
|
+
const noTrail = cleaned.length > 1 ? cleaned.replace(/\/+$/, "") : cleaned;
|
|
776
|
+
const lower = noTrail.toLowerCase();
|
|
777
|
+
if (/^\/+$/.test(cleaned))
|
|
778
|
+
return "root";
|
|
779
|
+
if (lower === ".")
|
|
780
|
+
return "current-directory";
|
|
781
|
+
if (lower === "..")
|
|
782
|
+
return "parent-directory";
|
|
783
|
+
if (lower === "~" || lower === "$home" || lower === "${home}" || lower === "%userprofile%")
|
|
784
|
+
return "home";
|
|
785
|
+
if (lower === "/*" || lower === "/." || lower === "/..")
|
|
786
|
+
return "root";
|
|
787
|
+
if (/^\$[a-z_{(]/i.test(cleaned) || /^\$\(/.test(cleaned) || /^%[a-z_]+%$/i.test(cleaned) || /^\$env:/i.test(cleaned))
|
|
788
|
+
return "variable-target";
|
|
789
|
+
if (noTrail === "{}" || noTrail === "$@" || noTrail === "$*")
|
|
790
|
+
return "variable-target";
|
|
791
|
+
if (/^[a-z]:$/i.test(noTrail))
|
|
792
|
+
return "drive-root";
|
|
793
|
+
const home = (process.env["HOME"] || process.env["USERPROFILE"] || "").replace(/\\/g, "/").replace(/\/+$/, "");
|
|
794
|
+
if (home && noTrail.toLowerCase() === home.toLowerCase())
|
|
795
|
+
return "home";
|
|
796
|
+
if (isSystemPath(noTrail))
|
|
797
|
+
return "system-path";
|
|
798
|
+
return undefined;
|
|
799
|
+
}
|
|
800
|
+
function isSourceDirectory(target) {
|
|
801
|
+
const base = stripShellToken(target).replace(/\\/g, "/").replace(/\/+$/, "").split("/").pop() ?? "";
|
|
802
|
+
const lower = base.toLowerCase();
|
|
803
|
+
if (SOURCE_DIR_NAMES.has(lower))
|
|
804
|
+
return true;
|
|
805
|
+
return /(?:^|[._-])(?:src|lib|app|apps|packages?|pkg|server|client)(?=$|[._-]|\d)/.test(lower);
|
|
806
|
+
}
|
|
807
|
+
function classifySegment(name, args, segment) {
|
|
808
|
+
if (name === "rm") {
|
|
809
|
+
const { recursive, targets } = parseRmArgs(args);
|
|
810
|
+
if (targets.length === 0)
|
|
811
|
+
return undefined;
|
|
812
|
+
// .env delete is not recoverable once secrets are lost, and all
|
|
813
|
+
// credential/session files are protected on write and delete.
|
|
814
|
+
const classified = targets.filter((target) => !isDotEnvTarget(target));
|
|
815
|
+
if (classified.length === 0) {
|
|
816
|
+
const sensitive = targets.find(isSensitiveTarget);
|
|
817
|
+
if (sensitive)
|
|
818
|
+
return hardMatch("delete_sensitive", stripShellToken(sensitive), "sensitive-file-delete", [
|
|
819
|
+
"Move the credential/session file to a secure backup without printing its contents.",
|
|
820
|
+
]);
|
|
821
|
+
return undefined;
|
|
822
|
+
}
|
|
823
|
+
for (const target of classified) {
|
|
824
|
+
if (isSensitiveTarget(target)) {
|
|
825
|
+
return hardMatch("delete_sensitive", stripShellToken(target), "protected-credential-or-session-file", [
|
|
826
|
+
"Move the credential/session file to a secure backup without printing its contents.",
|
|
827
|
+
"Rotate the credential if it may already be exposed.",
|
|
828
|
+
]);
|
|
829
|
+
}
|
|
830
|
+
if (recursive && hasWildcardTarget(target)) {
|
|
831
|
+
return hardMatch("delete_wildcard", stripShellToken(target), "wildcard-recursive-delete", [
|
|
832
|
+
"List the matches and delete reviewed explicit paths instead of a glob.",
|
|
833
|
+
]);
|
|
834
|
+
}
|
|
835
|
+
if (recursive) {
|
|
836
|
+
const reason = dangerousDeleteReason(target);
|
|
837
|
+
if (reason) {
|
|
838
|
+
return hardMatch("delete_all", stripShellToken(target), reason, [
|
|
839
|
+
"Use a narrower explicit path.",
|
|
840
|
+
"Move the target to a reviewed backup instead of deleting it recursively.",
|
|
841
|
+
]);
|
|
842
|
+
}
|
|
843
|
+
if (isSourceDirectory(target)) {
|
|
844
|
+
return hardMatch("delete_source_tree", stripShellToken(target), "source-directory-recursive", [
|
|
845
|
+
"Delete individual generated files instead of a source directory.",
|
|
846
|
+
"Use a reviewed backup or version control to recover the directory.",
|
|
847
|
+
]);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return softMatch(recursive ? "delete_recursive" : "delete_file", classified[0], recursive ? "recursive-delete" : "file-delete");
|
|
852
|
+
}
|
|
853
|
+
if (name === "find" && args.some((arg) => stripShellToken(arg) === "-delete")) {
|
|
854
|
+
const target = args.map(stripShellToken).find((arg) => arg && !arg.startsWith("-")) ?? ".";
|
|
855
|
+
return hardMatch("find_delete", target, "find-delete", [
|
|
856
|
+
"List the matches first, then delete only reviewed explicit paths.",
|
|
857
|
+
]);
|
|
858
|
+
}
|
|
859
|
+
if (name === "git") {
|
|
860
|
+
const sub = stripShellToken(args[0] ?? "").toLowerCase();
|
|
861
|
+
if (sub === "reset" && args.some((arg) => stripShellToken(arg) === "--hard")) {
|
|
862
|
+
const hardIndex = args.findIndex((arg) => stripShellToken(arg) === "--hard");
|
|
863
|
+
const target = args.slice(hardIndex + 1).map(stripShellToken).find((arg) => arg && !arg.startsWith("-")) ?? "";
|
|
864
|
+
return softMatch("git_reset_hard", target || "HEAD", "working-tree-reset");
|
|
865
|
+
}
|
|
866
|
+
if (sub === "push") {
|
|
867
|
+
const force = args.some((arg) => {
|
|
868
|
+
const value = stripShellToken(arg);
|
|
869
|
+
return value === "-f" || value === "--force" || value.startsWith("--force-with-lease");
|
|
870
|
+
});
|
|
871
|
+
if (!force)
|
|
872
|
+
return undefined;
|
|
873
|
+
const positional = args.map(stripShellToken).filter((arg) => arg && !arg.startsWith("-"));
|
|
874
|
+
const protectedBranch = positional.find((arg) => /\b(?:main|master|prod)\b/i.test(arg));
|
|
875
|
+
if (protectedBranch)
|
|
876
|
+
return hardMatch("git_push_force_protected", protectedBranch, "protected-branch-force-push", [
|
|
877
|
+
"Push a feature branch and open a reviewed merge instead.",
|
|
878
|
+
"Use a normal, non-force push after reconciling the remote branch.",
|
|
879
|
+
]);
|
|
880
|
+
if (positional.length >= 2)
|
|
881
|
+
return softMatch("git_push_force", positional.join(" "), "force-push");
|
|
882
|
+
return hardMatch("git_push_force_unknown", positional[0] ?? "current-branch", "force-push-without-explicit-branch", [
|
|
883
|
+
"Name the remote and target branch explicitly.",
|
|
884
|
+
"Push a feature branch instead of the current/protected branch.",
|
|
885
|
+
]);
|
|
886
|
+
}
|
|
887
|
+
if (sub === "clean") {
|
|
888
|
+
const flags = args.map(stripShellToken).filter((arg) => arg.startsWith("-")).join(" ");
|
|
889
|
+
if (/(^|\s)-[a-z]*x[a-z]*\b/i.test(flags)) {
|
|
890
|
+
return hardMatch("git_clean_dirs", flags || "-fdx", "clean-untracked-and-ignored", [
|
|
891
|
+
"Run `git clean -n` and review the list first.",
|
|
892
|
+
"Delete only explicit generated paths.",
|
|
893
|
+
]);
|
|
894
|
+
}
|
|
895
|
+
if (/(^|\s)-[a-z]*[df][a-z]*\b/i.test(flags) || /--directories|--force/i.test(flags))
|
|
896
|
+
return softMatch("git_clean_files", flags || "-f", "clean-untracked-files");
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (name === "dd") {
|
|
900
|
+
const of = args.map(stripShellToken).find((arg) => /^of=/i.test(arg));
|
|
901
|
+
if (!of)
|
|
902
|
+
return undefined;
|
|
903
|
+
const target = of.slice(3);
|
|
904
|
+
if (/^\/(?:dev|proc|sys)\//i.test(target)) {
|
|
905
|
+
return hardMatch("dd_block_device", target, "block-device-write", [
|
|
906
|
+
"Never write raw images to a block device from the agent.",
|
|
907
|
+
"Verify the device path and ask the user to run the write manually.",
|
|
908
|
+
]);
|
|
909
|
+
}
|
|
910
|
+
return softMatch("dd_write", target, "raw-write");
|
|
911
|
+
}
|
|
912
|
+
if (name.startsWith("mkfs") || name.startsWith("mke2fs") || name === "fdisk" || name === "diskpart" || name === "format") {
|
|
913
|
+
return hardMatch("disk_format", name, "disk-format-or-partition", [
|
|
914
|
+
"Ask the user to partition/format the device manually.",
|
|
915
|
+
]);
|
|
916
|
+
}
|
|
917
|
+
if (name === "nc" || name === "ncat" || name === "netcat" || name === "socat") {
|
|
918
|
+
const values = args.map(stripShellToken);
|
|
919
|
+
if (values.some((arg) => arg === "-e" || arg === "-c" || arg === "--exec" || arg === "--command" ||
|
|
920
|
+
/^exec:/i.test(arg))) {
|
|
921
|
+
return hardMatch("reverse_shell", name, "reverse-shell-entry", [
|
|
922
|
+
"Do not start network shells from the agent.",
|
|
923
|
+
]);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
if (name === "shutdown" || name === "reboot" || name === "poweroff" || name === "halt") {
|
|
927
|
+
return hardMatch("system_power", name, "system-shutdown", [
|
|
928
|
+
"Ask the user to power off or reboot explicitly.",
|
|
929
|
+
]);
|
|
930
|
+
}
|
|
931
|
+
if (name === "systemctl") {
|
|
932
|
+
const action = stripShellToken(args[0] ?? "").toLowerCase();
|
|
933
|
+
if (action === "stop" || action === "disable" || action === "mask") {
|
|
934
|
+
return hardMatch("system_service_stop", args.map(stripShellToken).join(" "), "system-service-stop", [
|
|
935
|
+
"Ask the user to stop/disable the service manually.",
|
|
936
|
+
]);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
if (name === "docker") {
|
|
940
|
+
const sub = stripShellToken(args[0] ?? "").toLowerCase();
|
|
941
|
+
const values = args.map(stripShellToken);
|
|
942
|
+
if (sub === "container" && values.includes("prune") &&
|
|
943
|
+
values.some((arg) => arg === "-f" || arg === "--force")) {
|
|
944
|
+
return hardMatch("docker_prune_all", "container prune", "delete-stopped-containers", [
|
|
945
|
+
"List containers and remove only reviewed ids explicitly.",
|
|
946
|
+
]);
|
|
947
|
+
}
|
|
948
|
+
if (sub === "system" && values.includes("prune") &&
|
|
949
|
+
values.some((arg) => arg === "--all" || (arg.startsWith("-") && !arg.startsWith("--") && arg.includes("a"))) &&
|
|
950
|
+
values.includes("--volumes")) {
|
|
951
|
+
return hardMatch("docker_prune_all", "system prune -a --volumes", "delete-all-containers-and-volumes", [
|
|
952
|
+
"Prune one reviewed resource type at a time.",
|
|
953
|
+
]);
|
|
954
|
+
}
|
|
955
|
+
if (sub === "rm" && values.includes("-f") && (segment.includes("$(") || values.includes("--all"))) {
|
|
956
|
+
return hardMatch("docker_rm_all", "docker rm -f", "delete-all-containers", [
|
|
957
|
+
"List containers and remove only the reviewed ids explicitly.",
|
|
958
|
+
]);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
if (name === "kubectl") {
|
|
962
|
+
const sub = stripShellToken(args[0] ?? "").toLowerCase();
|
|
963
|
+
const target = stripShellToken(args[1] ?? "").toLowerCase();
|
|
964
|
+
if (sub === "delete" && (target === "ns" || target === "namespace")) {
|
|
965
|
+
return softMatch("kubectl_delete_namespace", args.map(stripShellToken).join(" "), "delete-namespace");
|
|
966
|
+
}
|
|
967
|
+
if (sub === "scale" && args.some((arg) => /--replicas=0\b/i.test(stripShellToken(arg)))) {
|
|
968
|
+
return softMatch("kubectl_scale_zero", args.map(stripShellToken).join(" "), "scale-deployment-to-zero");
|
|
969
|
+
}
|
|
970
|
+
if (sub === "delete" && args.some((arg) => stripShellToken(arg) === "--all") &&
|
|
971
|
+
args.some((arg) => /^(?:ns|namespace|namespaces)$/i.test(stripShellToken(arg)))) {
|
|
972
|
+
return hardMatch("kubectl_delete_namespace", args.map(stripShellToken).join(" "), "delete-all-namespaces", [
|
|
973
|
+
"Delete individual reviewed resources instead of using --all.",
|
|
974
|
+
]);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if (name === "chmod" || name === "chown" || name === "chgrp") {
|
|
978
|
+
const values = args.map(stripShellToken);
|
|
979
|
+
const recursive = values.some((arg) => arg === "-R" || arg === "--recursive");
|
|
980
|
+
const mode = values.find((arg) => /^[0-7]{3,4}$/.test(arg));
|
|
981
|
+
const target = values.find((arg) => !arg.startsWith("-") && (arg === "/" || arg.startsWith("/*") || isSystemPath(arg)));
|
|
982
|
+
if (recursive && target && (name !== "chmod" || mode === "777" || argIsRootLike(target))) {
|
|
983
|
+
return hardMatch(name === "chmod" ? "chmod_root_recursive" : "chown_system_recursive", target, "system-permission-change", [
|
|
984
|
+
"Change permissions/ownership only on reviewed explicit paths.",
|
|
985
|
+
]);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
if (name === "truncate") {
|
|
989
|
+
const values = args.map(stripShellToken);
|
|
990
|
+
const zero = values.some((arg, index) => (arg === "-s" || arg === "--size") && values[index + 1] === "0") ||
|
|
991
|
+
values.some((arg) => /^--size=0$/i.test(arg));
|
|
992
|
+
const targets = values.filter((arg) => arg && !arg.startsWith("-") && arg !== "0");
|
|
993
|
+
const sensitive = targets.find((target) => isSensitiveTarget(target) || isDotEnvTarget(target));
|
|
994
|
+
if (zero && sensitive) {
|
|
995
|
+
return hardMatch("truncate_sensitive", sensitive, isDotEnvTarget(sensitive) ? "truncate-dotenv" : "empty-protected-file", [
|
|
996
|
+
"Move the credential/session file to a secure backup instead of emptying it.",
|
|
997
|
+
]);
|
|
998
|
+
}
|
|
999
|
+
if (targets.length > 0)
|
|
1000
|
+
return softMatch("truncate_file", targets[0], "empty-file");
|
|
1001
|
+
}
|
|
1002
|
+
if (name === "unlink" || name === "shred") {
|
|
1003
|
+
const targets = args.map(stripShellToken).filter((arg) => arg && !arg.startsWith("-"));
|
|
1004
|
+
const dotenv = targets.find(isDotEnvTarget);
|
|
1005
|
+
if (name === "shred" && dotenv) {
|
|
1006
|
+
return hardMatch("truncate_sensitive", dotenv, "shred-dotenv", [
|
|
1007
|
+
"Never shred .env; move it to a secure backup instead.",
|
|
1008
|
+
]);
|
|
1009
|
+
}
|
|
1010
|
+
const sensitive = targets.find(isSensitiveTarget);
|
|
1011
|
+
if (sensitive) {
|
|
1012
|
+
return hardMatch("delete_sensitive", sensitive, "protected-credential-or-session-file", [
|
|
1013
|
+
"Move the credential/session file to a secure backup without printing its contents.",
|
|
1014
|
+
]);
|
|
1015
|
+
}
|
|
1016
|
+
if (targets.length > 0)
|
|
1017
|
+
return softMatch("delete_file", targets[0], "file-delete");
|
|
1018
|
+
}
|
|
1019
|
+
if (name === "del" || name === "erase" || name === "rd" || name === "rmdir") {
|
|
1020
|
+
const values = args.map(stripShellToken);
|
|
1021
|
+
const targets = values.filter((arg) => arg && !arg.startsWith("/") && !arg.startsWith("-"));
|
|
1022
|
+
const sensitive = targets.find(isSensitiveTarget);
|
|
1023
|
+
if (sensitive) {
|
|
1024
|
+
return hardMatch("delete_sensitive", sensitive, "protected-credential-or-session-file", [
|
|
1025
|
+
"Move the credential/session file to a secure backup without printing its contents.",
|
|
1026
|
+
]);
|
|
1027
|
+
}
|
|
1028
|
+
const classified = targets.filter((target) => !isDotEnvTarget(target));
|
|
1029
|
+
const recursive = values.some((arg) => /^\/[a-z]*s/i.test(arg) || /^--recursive$/i.test(arg));
|
|
1030
|
+
if (recursive && classified.length > 0) {
|
|
1031
|
+
return hardMatch("delete_all", classified[0], "windows-recursive-delete", [
|
|
1032
|
+
"Delete reviewed explicit files/directories instead of recursive Windows delete.",
|
|
1033
|
+
]);
|
|
1034
|
+
}
|
|
1035
|
+
if (classified.length > 0)
|
|
1036
|
+
return softMatch("delete_file", classified[0], "file-delete");
|
|
1037
|
+
}
|
|
1038
|
+
if (name === "mv" || name === "move") {
|
|
1039
|
+
const values = args.map(stripShellToken).filter((arg) => arg && !arg.startsWith("-"));
|
|
1040
|
+
const sourceHit = values.length > 0 ? sensitiveFileHit(values[0]) : undefined;
|
|
1041
|
+
if (sourceHit) {
|
|
1042
|
+
return hardMatch("move_sensitive", sourceHit.target, `sensitive-file-move: ${sourceHit.name}`, [
|
|
1043
|
+
"Do not rename or relocate credential/session files.",
|
|
1044
|
+
]);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
if ((name === "cp" || name === "install" || name === "mv") && args.some((arg) => stripShellToken(arg) === "/dev/null")) {
|
|
1048
|
+
const targets = args.map(stripShellToken).filter((arg) => arg && !arg.startsWith("-") && arg !== "/dev/null");
|
|
1049
|
+
const sensitive = targets.find((target) => isSensitiveTarget(target) || isDotEnvTarget(target));
|
|
1050
|
+
if (sensitive) {
|
|
1051
|
+
return hardMatch("truncate_sensitive", sensitive, isDotEnvTarget(sensitive) ? "truncate-dotenv" : "truncate-with-dev-null", [
|
|
1052
|
+
"Move the credential/session file to a secure backup instead of truncating it.",
|
|
1053
|
+
]);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
const write = sensitiveWriteReason(name, args);
|
|
1057
|
+
if (write)
|
|
1058
|
+
return write;
|
|
1059
|
+
return undefined;
|
|
1060
|
+
}
|
|
1061
|
+
function argIsRootLike(target) {
|
|
1062
|
+
const clean = stripShellToken(target).replace(/\\/g, "/");
|
|
1063
|
+
return /^\/+$/.test(clean) || clean === "/" || isSystemPath(clean);
|
|
1064
|
+
}
|
|
1065
|
+
function classifyDestructiveCommand(command, depth = 0) {
|
|
1066
|
+
const raw = String(command ?? "");
|
|
1067
|
+
const scan = unquotedForLegacy(raw);
|
|
1068
|
+
const credential = credentialDumpMatch(raw);
|
|
1069
|
+
if (credential?.kind === "hard")
|
|
1070
|
+
return credential;
|
|
1071
|
+
const credentialRead = credentialReadMatch(raw);
|
|
1072
|
+
if (credentialRead?.kind === "hard")
|
|
1073
|
+
return credentialRead;
|
|
1074
|
+
const egress = sensitiveFileEgressMatch(raw);
|
|
1075
|
+
if (egress?.kind === "hard")
|
|
1076
|
+
return egress;
|
|
1077
|
+
if (REMOTE_SHELL_PIPE_RE.test(scan) || BASE64_SHELL_PIPE_RE.test(scan) ||
|
|
1078
|
+
PROCESS_SUBSTITUTION_SHELL_RE.test(scan) || EVAL_FETCH_RE.test(raw) ||
|
|
1079
|
+
COMMAND_SUBSTITUTION_FETCH_RE.test(raw)) {
|
|
1080
|
+
return hardMatch("remote_code_pipe", "piped-to-shell", "download-or-decoded-code-execution", [
|
|
1081
|
+
"Download the script, review it, then run it explicitly.",
|
|
1082
|
+
]);
|
|
1083
|
+
}
|
|
1084
|
+
if (FORK_BOMB_MARKERS.some((marker) => raw.includes(marker))) {
|
|
1085
|
+
return hardMatch("fork_bomb", "fork-bomb", "resource-exhaustion", [
|
|
1086
|
+
"Do not run self-replicating shell functions.",
|
|
1087
|
+
]);
|
|
1088
|
+
}
|
|
1089
|
+
if (/\/dev\/(?:tcp|udp)\//i.test(scan)) {
|
|
1090
|
+
return hardMatch("reverse_shell", "/dev/tcp", "network-socket-redirect", [
|
|
1091
|
+
"Do not open network sockets from the shell.",
|
|
1092
|
+
]);
|
|
1093
|
+
}
|
|
1094
|
+
for (const rawTarget of redirectTargets(raw)) {
|
|
1095
|
+
const target = stripShellToken(rawTarget);
|
|
1096
|
+
if (!target)
|
|
1097
|
+
continue;
|
|
1098
|
+
if (isSharedStoragePath(target)) {
|
|
1099
|
+
return hardMatch("sensitive_file_egress", target, "shared-storage-egress", [
|
|
1100
|
+
"Do not redirect output into shared storage; write to a reviewed local path instead.",
|
|
1101
|
+
]);
|
|
1102
|
+
}
|
|
1103
|
+
if (BLOCK_DEVICE_RE.test(target)) {
|
|
1104
|
+
return hardMatch("write_block_device", target, "redirect-to-block-device", [
|
|
1105
|
+
"Never redirect output to a block device from the agent.",
|
|
1106
|
+
]);
|
|
1107
|
+
}
|
|
1108
|
+
if (isSensitiveTarget(target) || isDotEnvTarget(target) || isSystemPath(target)) {
|
|
1109
|
+
return hardMatch("truncate_sensitive", target, isDotEnvTarget(target) ? "overwrite-dotenv" : "redirect-over-protected-file", [
|
|
1110
|
+
"Move the credential/session file to a secure backup instead of overwriting it.",
|
|
1111
|
+
]);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
let soft = credential?.kind === "soft" ? credential : undefined;
|
|
1115
|
+
for (const segment of splitCommandSegments(raw)) {
|
|
1116
|
+
const tokens = segment.split(/\s+/).filter(Boolean);
|
|
1117
|
+
if (tokens.length === 0)
|
|
1118
|
+
continue;
|
|
1119
|
+
const index = commandIndex(tokens);
|
|
1120
|
+
if (index < 0)
|
|
1121
|
+
continue;
|
|
1122
|
+
const name = rawCommandName(tokens[index]);
|
|
1123
|
+
const args = tokens.slice(index + 1);
|
|
1124
|
+
if (SHELL_COMMANDS.has(name) || name === "eval") {
|
|
1125
|
+
let inner = "";
|
|
1126
|
+
if (name === "eval")
|
|
1127
|
+
inner = args.join(" ");
|
|
1128
|
+
else {
|
|
1129
|
+
const cIndex = args.findIndex((arg) => /^-[a-z]*c$/i.test(stripShellToken(arg)));
|
|
1130
|
+
if (cIndex >= 0)
|
|
1131
|
+
inner = args.slice(cIndex + 1).join(" ");
|
|
1132
|
+
}
|
|
1133
|
+
if (inner && depth < 4) {
|
|
1134
|
+
const nested = classifyDestructiveCommand(stripShellToken(inner), depth + 1);
|
|
1135
|
+
if (nested?.kind === "hard")
|
|
1136
|
+
return nested;
|
|
1137
|
+
if (nested?.kind === "soft" && !soft)
|
|
1138
|
+
soft = nested;
|
|
1139
|
+
}
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1142
|
+
const match = classifySegment(name, args, segment);
|
|
1143
|
+
if (match?.kind === "hard")
|
|
1144
|
+
return match;
|
|
1145
|
+
if (match?.kind === "soft" && !soft)
|
|
1146
|
+
soft = match;
|
|
1147
|
+
}
|
|
1148
|
+
return soft;
|
|
1149
|
+
}
|
|
196
1150
|
function isReadOnlySegment(segment) {
|
|
197
1151
|
const tokens = segment.split(/\s+/).filter(Boolean);
|
|
198
1152
|
if (tokens.length === 0)
|