@sema-agent/core
Version:
Stateless, task-oriented AI agent core
2,086 lines • 89.2 kB
JavaScript
import { isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, win32NamespaceScreen, pathFamilyOf } from "./safety.js";
import { PARSE_FAILURE_SENTENCE, UNRESOLVED_SENTENCE, argvOf, lexShell, literalTildeSpelling, } from "../../shell/lex.js";
import { COMMAND_LAUNCHERS, NOT_AUTO_ALLOWED, SHELL_BLOCK_WORDS, argvReentryUnjudged, isBareControlWord, programBasename, programPositionRefusalSentence, readProgramPosition, } from "./bash-program-position.js";
export const BASH_READONLY_DEFAULT_ALLOW = [
"ls", "cat", "head", "tail", "wc", "pwd", "echo", "whoami", "uname",
"grep", "cut", "tr", "basename", "dirname", "stat", "du", "df", "which",
"cal", "uptime", "id", "groups", "nproc", "locale", "free", "realpath", "readlink",
"true", "false", "seq", "expr", "type", "sleep", "diff", "cmp", "comm",
];
export const BASH_CLASSIFY_EXTRA_ALLOW = ["find", "sed", "cd"];
export function bashClassifyDefaultAllow() {
return [...BASH_READONLY_DEFAULT_ALLOW, ...BASH_CLASSIFY_EXTRA_ALLOW];
}
const SHELL_OPERATORS = /[;&|<>$()`\n\r\\]/;
function hasBareShellOperator(s, quotedOperatorsAreText) {
if (!quotedOperatorsAreText)
return SHELL_OPERATORS.test(s);
const lexed = lexShell(s);
if ("parseFailure" in lexed)
return SHELL_OPERATORS.test(s);
if (lexed.segments.length > 1)
return true;
for (const t of lexed.segments[0].tokens) {
if (t.kind !== "word")
return true;
if (expandsDollar(t) || t.assignment?.form === "compound")
return true;
if (t.pieces.some((p) => p.quotedBy === "\\" || (p.quotedBy === undefined && p.literal !== undefined && /[\r\\]/.test(p.literal))))
return true;
}
return false;
}
function expandsDollar(w) {
if (w.quotedSpans.some((q) => q.by === '$"'))
return true;
for (const p of w.pieces) {
const k = p.expansion?.kind;
if (k === "parameter" || k === "command" || k === "process" || k === "arithmetic")
return true;
if (p.literal !== undefined && (p.quotedBy === undefined || p.quotedBy === '"') && /[$`]/.test(p.literal))
return true;
}
return false;
}
export function parseLeadingCommandName(command, options) {
const trimmed = command.trim();
if (!trimmed)
return { reject: "empty command" };
if (hasBareShellOperator(trimmed, options?.quotedOperatorsAreText === true)) {
return {
reject: "shell operators (pipes, redirects, ;, &&, command substitution, subshells, variable expansion) are not allowed — run a single simple command",
};
}
const first = trimmed.split(/[ \t]+/)[0];
if (first.includes("/") && options?.pathPrefixedNameIsText !== true) {
return { reject: "the command must be a bare name resolved via PATH (no path prefix)" };
}
if (first.includes("="))
return { reject: "leading environment-variable assignments are not allowed" };
if (first.startsWith("#"))
return { reject: "empty command" };
if (/["'{}*?[\]~]/.test(first))
return { reject: "the command name must be a bare token (no quotes, braces, globs, or ~)" };
return { name: first };
}
export function coarseReadonlyCheck(command, allow, options) {
const parsed = parseLeadingCommandName(command, options);
if ("reject" in parsed)
return parsed.reject;
if (!allow.has(parsed.name))
return `command "${parsed.name}" is not in the read-only allowlist`;
return undefined;
}
const SHELL_SEGMENT_QUOTE_BLIND_REJECT = /[<>()`\n\r\\]/;
const SHELL_EXPANSION_CHARS = /[$]/;
export function carriesShellRedirection(text) {
return SHELL_REDIRECTION_OPERATORS.test(text);
}
const SHELL_REDIRECTION_OPERATORS = /[<>]/;
function isDataFreeRedirection(r) {
if (r.op === ">" && r.fd === 2 && r.target !== undefined && r.target.raw === "/dev/null")
return true;
return r.op === ">&" && r.dup !== undefined && /^[12]$/.test(r.dup) && (r.fd === undefined || r.fd === 1 || r.fd === 2);
}
const BACKGROUND_REFUSAL = "`&` backgrounding is not allowed — a backgrounded process outlives the command";
const PIPE_BOTH_REFUSAL = "`|&` pipes standard error as well as standard output — not a connector this face reads";
export function strictResidualRefusal(lexed, source, opts = {}) {
const tolerate = opts.tolerateRedirection === true;
const reason = tolerate
? "command/variable substitution, subshells, escapes, and line breaks are not allowed"
: "redirection, command/variable substitution, subshells, escapes, and line breaks are not allowed";
const inClass = (text) => {
for (const ch of text)
if (SHELL_SEGMENT_QUOTE_BLIND_REJECT.test(ch) && !(tolerate && SHELL_REDIRECTION_OPERATORS.test(ch)))
return true;
return false;
};
if ("parseFailure" in lexed)
return inClass(source) || SHELL_EXPANSION_CHARS.test(source) ? reason : PARSE_FAILURE_SENTENCE[lexed.parseFailure.reason];
const words = [];
for (const s of lexed.segments) {
if (s.connector === "\n")
return reason;
for (const t of s.tokens) {
if (t.kind === "word") {
if (inClass(t.raw))
return reason;
words.push(t);
continue;
}
if (t.kind !== "redirect")
return reason;
if (opts.dataFreeRedirections === true && isDataFreeRedirection(t))
continue;
if (!tolerate)
return reason;
if (t.target !== undefined) {
if (inClass(t.target.raw))
return reason;
words.push(t.target);
}
}
}
const home = opts.homeDir !== undefined && !homeMayBeRebound(source) ? opts.homeDir : undefined;
for (const w of words) {
if (expandsDollar(w) && !(home !== undefined && homeSpelledForClassify(w, home) !== undefined))
return reason;
}
for (const s of lexed.segments) {
if (s.connector === "&")
return BACKGROUND_REFUSAL;
if (s.pipesStderr)
return PIPE_BOTH_REFUSAL;
}
for (const w of words)
if (w.unresolved !== undefined)
return UNRESOLVED_SENTENCE[w.unresolved];
return undefined;
}
export function splitShellCompoundSegments(source, options) {
const scanned = options?.trailingTerminator !== "keep" ? source.replace(/;\s*$/, "") : source;
if (/[\n\r]/.test(source.slice(scanned.length)))
return { reject: options?.redirection === "tolerate" ? "command/variable substitution, subshells, escapes, and line breaks are not allowed" : "redirection, command/variable substitution, subshells, escapes, and line breaks are not allowed" };
const lexed = lexShell(scanned);
const refused = strictResidualRefusal(lexed, scanned, {
tolerateRedirection: options?.redirection === "tolerate",
dataFreeRedirections: options?.redirection === "data-free",
...(options?.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
});
if (refused !== undefined)
return { reject: refused };
const command = lexed;
const connectors = command.segments.map((s) => s.connector);
return { segments: command.segments.map((s) => s.text), pipeFed: connectors.map((k) => k === "|"), connectors, lexed: command.segments };
}
export function formatOutOfRootReadApprovalOption(directory) {
const trimmed = directory.replace(/[/\\]+$/, "");
const cut = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
const leaf = cut >= 0 ? trimmed.slice(cut + 1) : trimmed;
return `Yes, allow reading from ${leaf || directory}/ from this project`;
}
export const NO_PATH_OPERAND_COMMANDS = new Set([
"pwd", "echo", "printf", "whoami", "uname", "which", "tr", "basename", "dirname",
"cal", "uptime", "id", "groups", "nproc", "locale", "free", "true", "false", "seq", "expr", "type", "sleep",
"test", "[",
]);
export const NO_PATH_OPERAND_EVALUATORS = new Set(["test", "["]);
export const DECLARATION_BUILTINS = new Set(["export", "declare", "typeset", "local", "readonly"]);
export const INERT_BUILTINS = new Set(["echo", "true", "false", "pwd", ...DECLARATION_BUILTINS]);
const MESSAGE_SLOTS = {
git: {
globalPlain: ["-p", "--paginate", "-P", "--no-pager", "--bare", "--no-replace-objects", "--literal-pathspecs", "--glob-pathspecs", "--noglob-pathspecs", "--icase-pathspecs", "--no-optional-locks"],
globalValue: ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env", "--super-prefix"],
subcommands: {
commit: { plain: "apsnvqeioz", optional: "Su", value: "mFCct", longValue: ["message", "file", "template", "pathspec-from-file", "author", "date", "cleanup", "fixup", "squash", "trailer", "reuse-message", "reedit-message"] },
tag: { plain: "asfdvle", optional: "n", value: "mFu", longValue: ["message", "file", "local-user", "cleanup", "trailer", "sort", "format", "contains", "no-contains", "points-at", "merged", "no-merged"] },
merge: { plain: "nqve", optional: "S", value: "mFsX", longValue: ["message", "file", "strategy", "strategy-option", "into-name", "cleanup"] },
stash: { plain: "pkuaqS", optional: "", value: "m", longValue: ["message", "pathspec-from-file"] },
},
},
};
export function messageSlotWords(program, words, programIndex) {
const out = new Set();
const row = MESSAGE_SLOTS[program];
if (row === undefined)
return out;
let j = programIndex + 1;
for (; j < words.length; j++) {
const w = words[j].folded;
if (!w.startsWith("-"))
break;
if (row.globalValue.includes(w))
j++;
else if (!row.globalPlain.includes(w) && !row.globalValue.some((o) => o.startsWith("--") && w.startsWith(`${o}=`)))
return out;
}
const sub = row.subcommands[words[j]?.folded ?? ""];
if (sub === undefined)
return out;
const settled = (k) => words[k] !== undefined && words[k].expands === false;
let pending;
for (let k = j + 1; k < words.length; k++) {
const w = words[k].folded;
if (pending !== undefined) {
if (pending === "message" && settled(k))
out.add(k);
pending = undefined;
continue;
}
if (w === "--")
break;
if (!settled(k)) {
pending = "other";
continue;
}
if (!w.startsWith("-") || w === "-")
continue;
if (w.startsWith("--")) {
const eq = w.indexOf("=");
const name = w.slice(2, eq < 0 ? undefined : eq);
const takers = sub.longValue.filter((n) => n.startsWith(name));
const message = name.length > 0 && takers.length > 0 && takers.every((n) => n === "message") || name === "message";
if (eq >= 0) {
if (message)
out.add(k);
continue;
}
if (takers.length > 0)
pending = message ? "message" : "other";
continue;
}
const letters = w.slice(1);
for (let i = 0; i < letters.length; i++) {
const c = letters[i];
if (sub.plain.includes(c))
continue;
if (sub.optional.includes(c))
break;
if (sub.value.includes(c)) {
if (i < letters.length - 1) {
if (c === "m")
out.add(k);
}
else
pending = c === "m" ? "message" : "other";
break;
}
pending = "other";
break;
}
}
return out;
}
const XARGS_TARGETS_ACCEPTED_UNJUDGED = NO_PATH_OPERAND_COMMANDS;
export function isPathShapedToken(tok) {
return tok.includes("/") || tok.startsWith("~") || tok === "." || tok === "..";
}
export function resolveOperandLexically(base, operand, homeDir) {
return resolveOperandSpellings(base, operand, homeDir)?.folded;
}
const PARENT_SEGMENT_RE = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
export function resolveOperandSpellings(base, operand, homeDir) {
let raw = operand;
if (raw === "~" || raw.startsWith("~/")) {
if (homeDir === undefined || !isAbsolutePathForm(homeDir))
return undefined;
raw = raw === "~" ? homeDir : `${homeDir.replace(/[/\\]+$/, "")}/${raw.slice(2)}`;
}
else if (raw.startsWith("~")) {
return undefined;
}
const family = base === undefined ? undefined : pathFamilyOf({ root: base });
if (!win32NamespaceScreen(raw, family).ok)
return undefined;
if (isAbsoluteForFamily(family, raw))
return spellingsOf(nativeUncSpellingOf(family, raw));
if (base === undefined || family === undefined || !isAbsoluteForFamily(family, base))
return undefined;
if (isShellRootedSpellingUnmapped(family, raw))
return undefined;
return spellingsOf(joinForFamily(family, base, raw));
}
function spellingsOf(absolute) {
const folded = normalizeAbsPathLexically(absolute);
return PARENT_SEGMENT_RE.test(absolute) ? { folded, spelled: absolute } : { folded };
}
function reheadSegment(toks, head, from = 0) {
return { folded: [head, ...toks.folded.slice(from + 1)], raw: [head, ...toks.raw.slice(from + 1)] };
}
export const SHELL_REENTRY_PROGRAM_RE = /^(?:(?:ba|z|da|k|fi|a|mk|ya|pdk|c|tc)?sh|eval|source|\.|parallel)$/;
function segmentSpellings(words, homeDir, homeRebound) {
const folded = [];
const raw = [];
for (const w of words) {
const spelled = homeDir !== undefined && !homeRebound ? homeSpelledForClassify(w, homeDir) : undefined;
folded.push(spelled ?? w.folded);
raw.push(spelled ?? w.raw);
}
return { folded, raw };
}
export function expansionInWord(w) {
for (const p of w.pieces) {
const k = p.expansion?.kind;
if (k === "parameter" || k === "command" || k === "process" || k === "arithmetic" || k === "brace")
return true;
if (p.literal === undefined)
continue;
if (p.quotedBy === undefined && /[{}$`]/.test(p.literal))
return true;
if (p.quotedBy === '"' && /[$`]/.test(p.literal))
return true;
}
return false;
}
const isGlobPiece = (p) => p.expansion?.kind === "glob";
export function globInWord(w) {
for (const p of w.pieces) {
if (isGlobPiece(p))
return true;
if (p.literal !== undefined && p.quotedBy === undefined && /[*?[]/.test(p.literal))
return true;
}
return false;
}
function longOptionNameOf(tok) {
if (!tok.startsWith("--") || tok.length === 2)
return undefined;
const body = tok.slice(2);
const eq = body.indexOf("=");
const name = eq >= 0 ? body.slice(0, eq) : body;
return name.length > 0 ? name : undefined;
}
function isLongOptionAbbrevOf(name, full) {
return full.startsWith(name);
}
export function suppliesFirstOperandSlot(slot, tok) {
if (tok.startsWith("--")) {
const long = longOptionNameOf(tok);
return long !== undefined && slot.suppliedByLongNames.some((full) => isLongOptionAbbrevOf(long, full));
}
return new RegExp(`^-[A-Za-z0-9]*[${slot.suppliedByLetters}]`).test(tok);
}
function isGrepClusterFileStdin(tok) {
if (tok.startsWith("--") || !/^-[A-Za-z0-9]/.test(tok))
return false;
for (let i = 1; i < tok.length; i++) {
const ch = tok[i];
if (ch === "e")
return false;
if (ch === "f")
return tok.slice(i + 1) === "-";
if (!/[A-Za-z0-9]/.test(ch))
return false;
}
return false;
}
function valueNamesAFile(kind) {
return kind === "path" || kind === "indirect" || kind === "form" || kind === "header" || kind === "url";
}
function widestValueKind(kinds) {
if (kinds.has("indirect"))
return "indirect";
if (kinds.has("path"))
return "path";
if (kinds.has("form"))
return "form";
if (kinds.has("header"))
return "header";
if (kinds.has("url"))
return "url";
return [...kinds][0];
}
const VERB_OPTION_SLOTS = {
head: {
shortValue: { c: "count", n: "count" },
shortPlain: "qvz",
longValue: { bytes: "count", lines: "count" },
longOptionalValue: {},
longPlain: ["help", "quiet", "silent", "verbose", "version", "zero-terminated"],
},
tail: {
shortValue: { b: "count", c: "count", n: "count", s: "count" },
shortPlain: "fFqvz",
longValue: { bytes: "count", lines: "count", "max-unchanged-stats": "count", pid: "count", "sleep-interval": "count" },
longOptionalValue: { follow: "label" },
longPlain: ["help", "quiet", "retry", "silent", "verbose", "version", "zero-terminated"],
},
cut: {
shortValue: { b: "count", c: "count", d: "delimiter", f: "count" },
shortPlain: "nsz",
longValue: { bytes: "count", characters: "count", delimiter: "delimiter", fields: "count", "output-delimiter": "delimiter" },
longOptionalValue: {},
longPlain: ["complement", "help", "only-delimited", "version", "zero-terminated"],
},
grep: {
shortValue: { A: "count", B: "count", C: "count", D: "label", d: "label", e: "pattern", f: "path", m: "count" },
shortPlain: "0123456789abcEFGhHiIlLnoPqrRsUvVwxyzZ",
longValue: {
"after-context": "count",
"before-context": "count",
"binary-files": "label",
devices: "label",
directories: "label",
exclude: "name-pattern",
"exclude-dir": "name-pattern",
"exclude-from": "path",
file: "path",
"group-separator": "label",
include: "name-pattern",
label: "label",
"max-count": "count",
regexp: "pattern",
},
longOptionalValue: { color: "label", colour: "label", context: "count" },
firstOperand: { kind: "pattern", suppliedByLetters: "ef", suppliedByLongNames: ["regexp", "file"] },
longPlain: [
"basic-regexp", "byte-offset", "count", "dereference-recursive", "extended-regexp",
"files-with-matches", "files-without-match", "fixed-strings", "help", "ignore-case", "initial-tab",
"invert-match", "line-buffered", "line-number", "line-regexp", "no-filename", "no-group-separator",
"no-messages", "null", "null-data", "only-matching", "perl-regexp", "quiet", "recursive", "silent",
"text", "version", "with-filename", "word-regexp",
],
},
diff: {
shortValue: { C: "count", D: "label", F: "pattern", I: "pattern", L: "label", S: "path", U: "count", W: "count", X: "path", x: "name-pattern" },
shortPlain: "abBcdeEHilnNpqrstTuvwyZ",
longValue: {
exclude: "name-pattern",
"exclude-from": "path",
"from-file": "path",
"horizon-lines": "count",
ifdef: "label",
"ignore-matching-lines": "pattern",
label: "label",
"show-function-line": "pattern",
"starting-file": "path",
tabsize: "count",
"to-file": "path",
width: "count",
},
longOptionalValue: { color: "label", context: "count", unified: "count" },
longPlain: [
"brief", "ed", "expand-tabs", "help", "ignore-all-space", "ignore-blank-lines", "ignore-case",
"ignore-space-change", "ignore-tab-expansion", "ignore-trailing-space", "initial-tab", "minimal",
"new-file", "normal", "paginate", "recursive", "report-identical-files", "rcs", "show-c-function",
"side-by-side", "speed-large-files", "suppress-blank-empty", "text", "version",
],
},
cmp: {
shortValue: { i: "count", n: "count" },
shortPlain: "blsv",
longValue: { bytes: "count", "ignore-initial": "count" },
longOptionalValue: {},
longPlain: ["help", "print-bytes", "quiet", "silent", "verbose", "version"],
},
sed: {
shortValue: { e: "script", f: "path" },
shortPlain: "nrEszu",
longValue: { expression: "script", file: "path", "line-length": "count" },
longOptionalValue: { "in-place": "label" },
firstOperand: { kind: "script", suppliedByLetters: "ef", suppliedByLongNames: ["expression", "file"] },
longPlain: [
"debug", "follow-symlinks", "help", "null-data", "posix", "quiet", "regexp-extended", "sandbox",
"separate", "silent", "unbuffered", "version", "zero-terminated",
],
},
date: {
shortValue: { d: "count", f: "path", r: "path", s: "count" },
shortPlain: "uR",
longValue: { date: "count", file: "path", reference: "path", "rfc-3339": "count", set: "count" },
longOptionalValue: { "iso-8601": "label" },
longPlain: ["debug", "help", "rfc-822", "rfc-email", "universal", "utc", "version"],
},
tar: {
shortValue: { b: "count", C: "path", f: "path", T: "path", X: "path" },
shortPlain: "acdhjJkmOpPrStuvwWxzZ",
longValue: {
"blocking-factor": "count",
directory: "path",
exclude: "name-pattern",
"exclude-from": "path",
file: "path",
"files-from": "path",
"strip-components": "count",
},
longOptionalValue: {},
longPlain: [
"absolute-names", "append", "create", "dereference", "extract", "get", "help", "interactive",
"keep-old-files", "list", "preserve-permissions", "sparse", "to-stdout", "touch", "update",
"verbose", "verify", "version",
],
},
find: {
shortValue: { D: "label", O: "count", f: "path" },
shortPlain: "HLPdsx",
longValue: {},
longOptionalValue: {},
longPlain: ["help", "version"],
dashWordValue: {
amin: "count", anewer: "path", atime: "count", cmin: "count", cnewer: "path", context: "label",
ctime: "count", fstype: "label", gid: "count", group: "label", ilname: "path", iname: "name-pattern",
inum: "count", ipath: "path", iregex: "name-pattern", iwholename: "path", links: "count",
lname: "path", maxdepth: "count", mindepth: "count", mmin: "count", mtime: "count", name: "name-pattern",
newer: "path", newerat: "path", neweraa: "path", neweram: "path", newerac: "path",
newerct: "path", newerca: "path", newercm: "path", newercc: "path",
newermt: "path", newerma: "path", newermm: "path", newermc: "path",
newerbt: "path", newerba: "path", newerbm: "path", newerbc: "path",
path: "path", perm: "label", printf: "label", regex: "name-pattern", regextype: "label",
samefile: "path", size: "count", type: "label", uid: "count", used: "count", user: "label",
wholename: "path", xtype: "label",
},
dashWordPlain: [
"and", "daystart", "depth", "empty", "executable", "false", "follow", "help", "ignore_readdir_race",
"mount", "noignore_readdir_race", "noleaf", "not", "nogroup", "nouser", "or", "print",
"print0", "prune", "quit", "readable", "true", "version", "writable", "xdev",
],
optionsArePositionIndependent: true,
},
curl: {
shortValue: {
A: "label", b: "indirect", C: "count", c: "path", D: "path", d: "indirect", E: "path", e: "label",
F: "form", H: "header", K: "path", m: "count", o: "path", P: "label", Q: "label", r: "count",
T: "path", t: "label", U: "label", u: "label", w: "indirect", X: "label", x: "label", Y: "count",
y: "count", z: "path",
},
shortPlain: "#0123456aBfgGhiIjJklLMnNOpqRsSvVZ",
longValue: {
cacert: "path",
capath: "path",
cert: "path",
config: "path",
"connect-timeout": "count",
cookie: "indirect",
"cookie-jar": "path",
data: "indirect",
"data-ascii": "indirect",
"data-binary": "indirect",
"data-raw": "label",
"data-urlencode": "form",
"dump-header": "path",
form: "form",
"form-string": "label",
header: "header",
key: "path",
"max-time": "count",
"netrc-file": "path",
output: "path",
proxy: "label",
range: "count",
referer: "label",
request: "label",
resolve: "label",
trace: "path",
"time-cond": "path",
"trace-ascii": "path",
"upload-file": "path",
url: "url",
user: "label",
"user-agent": "label",
"write-out": "indirect",
},
longOptionalValue: {},
longPlain: [
"append", "compressed", "disable", "fail", "get", "globoff", "head", "help", "include", "insecure",
"junk-session-cookies", "list-only", "location", "manual", "netrc", "next", "no-buffer", "parallel",
"proxytunnel", "remote-header-name", "remote-name", "remote-time", "show-error", "silent",
"use-ascii", "verbose", "version",
],
},
};
export function readVerbOptionSlot(name, tok) {
const row = VERB_OPTION_SLOTS[name];
if (row === undefined)
return undefined;
if (!tok.startsWith("-") || tok === "-" || tok === "--")
return { form: "unexplained" };
if (tok.startsWith("--")) {
const long = longOptionNameOf(tok);
if (long === undefined)
return { form: "unexplained" };
const eq = tok.indexOf("=");
const attached = eq >= 0 ? tok.slice(eq + 1) : undefined;
const withValue = (kind) => attached !== undefined ? { form: "attached", value: attached, kind } : { form: "separated", kind };
const exact = row.longValue[long];
if (exact !== undefined)
return withValue(exact);
const exactOptional = row.longOptionalValue[long];
if (exactOptional !== undefined)
return attached !== undefined ? { form: "attached", value: attached, kind: exactOptional } : { form: "no-value" };
if (row.longPlain.includes(long))
return attached === undefined ? { form: "no-value" } : { form: "unexplained" };
const valueNames = [
...Object.keys(row.longValue),
...(attached !== undefined ? Object.keys(row.longOptionalValue) : []),
].filter((full) => isLongOptionAbbrevOf(long, full));
const plainNames = [
...row.longPlain,
...(attached === undefined ? Object.keys(row.longOptionalValue) : []),
].filter((full) => isLongOptionAbbrevOf(long, full));
if (valueNames.length === 0)
return plainNames.length > 0 && attached === undefined ? { form: "no-value" } : { form: "unexplained" };
const kinds = new Set(valueNames.map((full) => (row.longValue[full] ?? row.longOptionalValue[full])));
if (attached !== undefined)
return { form: "attached", value: attached, kind: widestValueKind(kinds) };
if (plainNames.length > 0 || kinds.size !== 1)
return { form: "unexplained" };
return withValue([...kinds][0]);
}
const word = row.dashWordValue !== undefined || row.dashWordPlain !== undefined ? /^-([A-Za-z_][\w-]+)$/.exec(tok)?.[1] : undefined;
if (word !== undefined) {
if (row.shortValue[word[0]] !== undefined)
return { form: "unexplained" };
const kind = row.dashWordValue?.[word];
if (kind !== undefined)
return { form: "separated", kind };
if (row.dashWordPlain?.includes(word) === true)
return { form: "no-value" };
return { form: "unexplained" };
}
for (let i = 1; i < tok.length; i++) {
const ch = tok[i];
const kind = row.shortValue[ch];
if (kind !== undefined) {
const rest = tok.slice(i + 1);
return rest !== "" ? { form: "attached", value: rest, kind } : { form: "separated", kind };
}
if (!row.shortPlain.includes(ch))
return { form: "unexplained" };
}
return { form: "no-value" };
}
export function verbOptionsArePositionIndependent(name) {
return VERB_OPTION_SLOTS[name]?.optionsArePositionIndependent === true;
}
export function verbFirstOperandSlot(name) {
return VERB_OPTION_SLOTS[name]?.firstOperand;
}
function localFileUrlPath(value) {
const m = /^file:(?:\/\/[^/?#]*)?(\/[^?#]*)/i.exec(value.trim());
if (m === null)
return undefined;
const raw = m[1];
try {
return decodeURIComponent(raw);
}
catch {
return raw;
}
}
function formSpecFileNames(value) {
const out = [];
const after = (s) => {
const at = s.search(/[@<]/);
if (at !== -1 && at < s.length - 1)
out.push(s.slice(at + 1));
};
after(value);
for (const part of value.split(/[;,]/))
after(part);
return [...new Set(out)];
}
export function slotValueCandidates(value, kind) {
if (!valueNamesAFile(kind))
return [];
if (kind === "url") {
const local = localFileUrlPath(value);
return local !== undefined ? [local] : [];
}
if (kind === "form")
return formSpecFileNames(value).map(literalTildeSpelling);
const named = value.startsWith("@") && value.length > 1 ? literalTildeSpelling(value.slice(1)) : undefined;
if (kind === "header")
return named !== undefined ? [value, named] : [];
const out = [value];
if (kind === "indirect" && named !== undefined)
out.push(named);
return out;
}
const SEPARATED_VALUE_OPTIONS = {
head: { short: "cn", long: ["bytes", "lines"] },
tail: { short: "cns", long: ["bytes", "lines", "sleep-interval", "pid", "max-unchanged-stats"] },
cut: { short: "bcdf", long: ["bytes", "characters", "delimiter", "fields", "output-delimiter"] },
grep: {
ownsValueButNotSkippable: "ef",
short: "mABCdD",
long: [
"max-count",
"after-context",
"before-context",
"context",
"devices",
"directories",
"label",
"binary-files",
"group-separator",
"include",
"exclude",
"exclude-dir",
],
},
cmp: { short: "ni", long: ["bytes", "ignore-initial"] },
diff: { short: "UCWIFxL", ownsValueButNotSkippable: "SX", long: ["unified", "context", "width", "ignore-matching-lines", "show-function-line", "exclude", "label", "horizon-lines", "tabsize"] },
};
function takesSeparatedValue(name, tok) {
const model = SEPARATED_VALUE_OPTIONS[name];
if (model === undefined)
return false;
if (tok.startsWith("--")) {
if (tok.includes("="))
return false;
const long = longOptionNameOf(tok);
return long !== undefined && model.long.some((full) => isLongOptionAbbrevOf(long, full));
}
if (!/^-[A-Za-z0-9]/.test(tok))
return false;
for (let i = 1; i < tok.length; i++) {
const ch = tok[i];
if ((model.ownsValueButNotSkippable ?? "").includes(ch))
return false;
if (model.short.includes(ch))
return i === tok.length - 1;
if (!/[A-Za-z0-9]/.test(ch))
return false;
}
return false;
}
const GREP_RECURSIVE_READ_FORM = {
discloses: "contents",
shortLetters: "rR",
valueOwners: "efmABCD",
longNames: ["recursive", "dereference-recursive"],
enumOptions: [{ shortLetter: "d", longName: "directories", recursiveValue: "recurse" }],
dashIsStdin: true,
};
export const RECURSIVE_READ_FORMS = {
grep: GREP_RECURSIVE_READ_FORM,
egrep: GREP_RECURSIVE_READ_FORM,
fgrep: GREP_RECURSIVE_READ_FORM,
ls: { discloses: "names", shortLetters: "R", longNames: ["recursive"] },
du: { discloses: "names", always: true },
find: { discloses: "names", always: true },
rg: { discloses: "contents", always: true, dashIsStdin: true },
tree: { discloses: "names", always: true },
ag: { discloses: "contents", always: true, dashIsStdin: true },
ack: { discloses: "contents", always: true, dashIsStdin: true },
tar: { discloses: "contents", shortLetters: "cru", valueOwners: "fCTXbg", longNames: ["create", "append", "update"], bundledModeLetters: "cru", dashIsStdin: true },
diff: { discloses: "contents", shortLetters: "r", valueOwners: "UCWISFXx", longNames: ["recursive"], dashIsStdin: true },
};
const FIND_DANGEROUS_PREDICATES = new Set([
"-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fls", "-fprintf", "-files0-from",
]);
const SED_READONLY_FLAGS = new Set([
"-n", "--quiet", "--silent", "-E", "--regexp-extended", "-r", "-z", "--zero-terminated", "--posix",
]);
const SED_READONLY_CLUSTER_LETTERS = new Set(["n", "E", "r", "z"]);
const SED_READONLY_SCRIPT_PIECE = /^(?:\d+|\d+,\d+)?p$/;
const SED_FILE_OR_EXEC_COMMANDS = new Set(["r", "R", "w", "W", "e"]);
const SED_PLAIN_COMMANDS = new Set(["=", "d", "D", "F", "g", "G", "h", "H", "n", "N", "p", "P", "x", "z"]);
const SED_NUMERIC_COMMANDS = new Set(["q", "Q", "l", "L"]);
const SED_LABEL_COMMANDS = new Set(["b", "t", "T", ":", "v"]);
const SED_TEXT_COMMANDS = new Set(["a", "i", "c"]);
const SED_SUBSTITUTE_FLAGS = /[gpiImM0-9]/;
const SED_SCRIPT_LINE_LIMIT = 1024;
function utf8Length(text) {
let bytes = 0;
for (const ch of text) {
const code = ch.codePointAt(0);
bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
}
return bytes;
}
function sedReadingKind(reading) {
return typeof reading === "string" ? reading : "names";
}
function readSedScript(script) {
if (script.split("\n").some((line) => utf8Length(line) >= SED_SCRIPT_LINE_LIMIT))
return "oversized";
let i = 0;
let depth = 0;
const len = script.length;
const reads = [];
const writes = [];
const commands = [];
let executesPatternSpace = false;
const anyName = () => reads.length + writes.length + commands.length > 0 || executesPatternSpace;
const named = () => ({ names: { reads, writes, commands, executesPatternSpace } });
const failed = () => (anyName() ? named() : "unparsed");
const restOfLine = () => {
skipBlank();
const from = i;
while (i < len && script[i] !== "\n")
i++;
return script.slice(from, i);
};
const isBlank = (ch) => ch === " " || ch === "\t" || ch === "\v" || ch === "\f";
const skipBlank = () => {
while (i < len && isBlank(script[i]))
i++;
};
const digits = () => {
while (i < len && script[i] >= "0" && script[i] <= "9")
i++;
};
const consumeDelimited = (delim, regex) => {
while (i < len) {
const ch = script[i];
if (ch === "\\") {
const escaped = script[i + 1];
if (escaped === undefined || escaped === "\n" || escaped === "\r")
return false;
i += 2;
continue;
}
if (ch === "\n" || ch === "\r")
return false;
if (ch === delim) {
i++;
return true;
}
if (regex && ch === "[") {
i++;
if (script[i] === "^")
i++;
if (script[i] === "]")
i++;
while (i < len && script[i] !== "]" && script[i] !== "\n" && script[i] !== "\r") {
const next = script[i + 1];
if (script[i] === "[" && (next === ":" || next === "." || next === "=")) {
const at = script.indexOf(`${next}]`, i + 2);
if (at === -1 || script.slice(i, at).includes("\n"))
return false;
i = at + 2;
continue;
}
i++;
}
if (i >= len || script[i] !== "]")
return false;
i++;
continue;
}
i++;
}
return false;
};
const readAddress = () => {
const ch = script[i];
if (ch === undefined)
return undefined;
if (ch === "$") {
i++;
return true;
}
if (ch >= "0" && ch <= "9") {
digits();
if (script[i] === "~") {
i++;
digits();
}
return true;
}
if (ch === "/" || ch === "\\") {
const delim = ch === "/" ? "/" : script[i + 1];
i += ch === "/" ? 1 : 2;
if (delim === undefined || !consumeDelimited(delim, true))
return false;
while (i < len && (script[i] === "I" || script[i] === "M"))
i++;
return true;
}
return undefined;
};
while (i < len) {
const ch = script[i];
if (isBlank(ch) || ch === "\n" || ch === "\r" || ch === ";") {
i++;
continue;
}
if (ch === "#") {
while (i < len && script[i] !== "\n")
i++;
continue;
}
if (ch === "}") {
if (depth === 0)
return failed();
depth--;
i++;
continue;
}
const first = readAddress();
if (first === false)
return failed();
if (first === true) {
skipBlank();
if (script[i] === ",") {
i++;
skipBlank();
if (script[i] === "+" || script[i] === "~") {
i++;
if (!(script[i] >= "0" && script[i] <= "9"))
return failed();
digits();
}
else if (readAddress() !== true)
return failed();
}
skipBlank();
}
while (script[i] === "!") {
i++;
skipBlank();
}
const cmd = script[i];
if (cmd === undefined)
return failed();
i++;
if (SED_FILE_OR_EXEC_COMMANDS.has(cmd)) {
const arg = restOfLine();
if (cmd === "e") {
if (arg === "")
executesPatternSpace = true;
else
commands.push(arg);
}
else if (cmd === "r" || cmd === "R")
reads.push(arg);
else
writes.push(arg);
continue;
}
if (cmd === "{") {
depth++;
continue;
}
if (cmd === "s" || cmd === "y") {
const delim = script[i];
if (delim === undefined || delim === "\\" || delim === "\n")
return failed();
i++;
if (!consumeDelimited(delim, cmd === "s"))
return failed();
if (!consumeDelimited(delim, false))
return failed();
if (cmd === "y")
continue;
while (i < len) {
const flag = script[i];
if (flag === "w" || flag === "W") {
i++;
writes.push(restOfLine());
break;
}
if (flag === "e") {
executesPatternSpace = true;
i++;
continue;
}
if (SED_SUBSTITUTE_FLAGS.test(flag) || isBlank(flag)) {
i++;
continue;
}
if (flag === ";" || flag === "\n" || flag === "\r" || flag === "}" || flag === "#")
break;
return failed();
}
continue;
}
if (SED_TEXT_COMMANDS.has(cmd)) {
while (i < len && script[i] !== "\n")
i++;
continue;
}
if (SED_LABEL_COMMANDS.has(cmd)) {
skipBlank();
while (i < len && script[i] !== "\n" && script[i] !== ";" && script[i] !== "}" && !isBlank(script[i]))
i++;
continue;
}
if (SED_NUMERIC_COMMANDS.has(cmd)) {
skipBlank();
digits();
continue;
}
if (!SED_PLAIN_COMMANDS.has(cmd))
return failed();
}
if (depth !== 0)
return failed();
return anyName() ? named() : "clean";
}
const SED_OPTION_DIALECTS = [
{ separated: "efl", suffix: "iI", stopsAtFirstOperand: false },
{ separated: "ef", suffix: "iI", stopsAtFirstOperand: true },
];
function sedSeparatedSuffixTaken(tok) {
return tok !== undefined && (tok === "" || tok.startsWith("."));
}
const SED_LONG_VALUE_OPTIONS = ["expression", "line-length"];
const SED_LONG_PLAIN_OPTIONS = [
"debug", "follow-symlinks", "help", "in-place", "null-data", "posix", "quiet", "regexp-extended",
"sandbox", "separate", "silent", "unbuffered", "version", "zero-terminated",
];
function readSedInvocation(args, dialect) {
return sedReadingKind(readSedInvocationDetailed(args, dialect));
}
function readSedInvocationDetailed(args, dialect) {
const scripts = [];
let scriptSupplied = false;
let endOfOptions = false;
let firstOperand;
for (let k = 0; k < args.length; k++) {
const tok = args[k];
if (!endOfOptions && tok === "--") {
endOfOptions = true;
continue;
}
if (endOfOptions || !tok.startsWith("-") || tok === "-") {
if (firstOperand === undefined) {
firstOperand = tok;
if (dialect.stopsAtFirstOperand)
endOfOptions = true;
}
continue;
}
if (tok.startsWith("--")) {
const long = longOptionNameOf(tok);
if (long === undefined)
return "unparsed";
const eq = tok.indexOf("=");
const attached = eq >= 0 ? tok.slice(eq + 1) : undefined;
if (isLongOptionAbbrevOf(long, "file"))
return "unseen";
if (isLongOptionAbbrevOf(long, "expression")) {
const value = attached ?? args[++k];
if (value === undefined)
return "unparsed";
scripts.push(value);
scriptSupplied = true;
continue;
}
if (SED_LONG_VALUE_OPTIONS.some((full) => isLongOptionAbbrevOf(long, full))) {
if (attached === undefined)
k++;
continue;
}
if (SED_LONG_PLAIN_OPTIONS.some((full) => isLongOptionAbbrevOf(long, full)))
continue;
return "unparsed";
}
for (let c = 1; c < tok.length; c++) {
const letter = tok[c];
if (dialect.suffix.includes(letter)) {
if (c + 1 === tok.length && sedSeparatedSuffixTaken(args[k + 1]))
k++;
break;
}
if (dialect.separated.includes(letter)) {
const rest = tok.slice(c + 1);
const value = rest !== "" ? rest : args[++k];
if (letter === "f")
return "unseen";
if (letter === "e") {
if (value === undefined)
return "unparsed";
scripts.push(value);
scriptSupplied = true;
}
break;
}
if (!SED_SHORT_PLAIN_LETTERS.includes(letter))
return "unparsed";
}
}
const script = scriptSupplied ? scripts.join("\n") : firstOperand;
if (script === undefined)
return "clean";
return readSedScript(script);
}
const SED_SHORT_PLAIN_LETTERS = "nsuzEraHl";
export function sedScriptSlotFiles(args) {
const reads = new Set();
const writes = new Set();
const commands = new Set();
let executesPatternSpace = false;
const kinds = [];
for (const dialect of SED_OPTION_DIALECTS) {
const reading = readSedInvocationDetailed(args, dialect);
kinds.push(sedReadingKind(reading));
if (typeof reading === "string")
continue;
for (const f of reading.names.reads)
reads.add(f);
for (const f of reading.names.writes)
writes.add(f);
for (const c of reading.names.commands)
commands.add(c);
executesPatternSpace ||= reading.names.executesPatternSpace;
}
const unread = kinds.includes("oversized")
? "a script line is longer than an implementation compiles whole, so what runs is not the text written here"
: kinds.every((k) => k === "unparsed")
? "its script could not be read to its end"
: undefined;
return { reads: [...reads], writes: [...writes], commands: [...commands], executesPatternSpace, ...(unread !== undefined ? { unread } : {}) };
}
const SCRIPT_SLOT_VERBS = new Map([
["sed", (args) => {
const readings = SED_OPTION_DIALECTS.map((dialect) => readSedInvocation(args, dialect));
if (readings.includes("names") || readings.includes("unseen") || readings.includes("oversized"))
return true;
return readings.every((reading) => reading === "unparsed");
}],
]);
const INLINE_PROGRAM_TEXT = "runs its argument as an interpreted program";
const INLINE_PROGRAM_STDIN = "reads the program it runs from its standard input";
const INTERPRETER_BANNER_OPTIONS = new Set(["--version", "--help", "--usage", "-version", "-help"]);
const BLANK_INLINE = {
longText: new Set(), longFile: new Set(), longNamesProgram: new Set(), longSupplemental: new Set(),
longValue: new Set(), longPlain: new Set(), fileOptionEndsOptions: false,
textLetters: "", fileLetters: "", separatedValueLetters: "", attachedValueLetters: "",
plainLetters: "", bannerLetters: "", interactiveLetters: "",
programOperand: { at: 0, isText: false },
};
const PERL_INLINE = {
...BLANK_INLINE, textLetters: "eEmM", separatedValueLetters: "I", attachedValueLetters: "iCFxDV",
plainLetters: "nplawcsStTUWX", bannerLetters: "vVh", stdinWhenUnnamed: true,
};
const RUBY_INLINE = {
...BLANK_INLINE, textLetters: "e", separatedValueLetters: "IrECFKT", plainLetters: "nplawcsSU",
bannerLetters: "vh", stdinWhenUnnamed: true,
};
const PYTHON_INLINE = {
...BLANK_INLINE, textLetters: "c", fileLetters: "m", separatedValueLetters: "WXQ",
plainLetters: "uBEIsSqbdvxOtR", bannerLetters: "Vh?", interactiveLetters: "i",
fileOptionEndsOptions: true, stdinWhenUnnamed: true,
};
const NODE_INLINE = {
...BLANK_INLINE, longText: new Set(["--eval", "--print"]), textLetters: "ep",
longValue: new Set(["--import", "--loader", "--experimental-loader", "--require", "--env-file", "--conditions", "--max-old-space-size"]),
longNamesProgram: new Set(["--test"]),
longPlain: new Set(["--inspect", "--inspect-brk", "--no-warnings", "--trace-warnings", "--watch", "--enable-source-maps", "--experimental-modules", "--no-deprecation", "--check"]),
separatedValueLetters: "r", plainLetters: "c", bannerLetters: "vh", interactiveLetters: "i",
stdinWhenUnnamed: true,
};
const PHP_INLINE = {
...BLANK_INLINE, longText: new Set(["--run", "--process-begin", "--process-code", "--process-end"]),
textLetters: "rRBE", fileLetters: "fF", separatedValueLetters: "dcz", plainLetters: "nqswl",
bannerLetters: "vhi", interactiveLetters: "a", stdinWhenUnnamed: true,
};
const LUA_INLINE = {
...BLANK_INLINE, textLetters: "e", separatedValueLetters: "l", bannerLetters: "vh",
interactiveLetters: "i", stdinWhenUnnamed: true,
};
const TCL_INLINE = { ...BLANK_INLINE, stdinWhenUnnamed: true };
const EXPECT_INLINE = {
...BLANK_INLINE, textLetters: "c", separatedValueLetters: "Db", plainLetters: "dN",
bannerLetters: "vh", interactiveLetters: "i", stdinWhenUnnamed: true,
};
const AWK_INLINE = {
...BLANK_INLINE, longText: new Set(["--source"]), textLetters: "e",
longFile: new Set(["--file", "--exec"]), fileLetters: "fE", separatedValueLetters: "vF",
bannerLetters: "V", programOperand: { at: 0, isText: true }, stdinWhenUnnamed: false,
};
const JQ_INLINE = {
...BLANK_INLINE, longFile: new Set(["--from-file"]), fileLetters: "f", separatedValueLetters: "L",
plainLetters: "nrjcsSaCMRe", bannerLetters: "h", programOperand: { at: 0, isText: true },
stdinWhenUnnamed: false,
};
const BC_INLINE = {
...BLANK_INLINE, longText: new Set(["--expression"]), textLetters: "e", plainLetters: "lqsw",
bannerLetters: "vh", interactiveLetters: "i", programOperand: undefined, stdinWhenUnnamed: true,
};
const DC_INLINE = {
...BLANK_INLINE, longText: new Set(["--expression"]), textLetters: "e",
longFile: new Set(["--file"]), fileLetters: "f", bannerLetters: "hV",
programOperand: undefined, stdinWhenUnnamed: true,
};
const SQLITE_INLINE = {
...BLANK_INLINE, longText: new Set(["-cmd"]), longSupplemental: new Set(["-init"]),
programOperand: { at: 1, isText: true }, stdinWhenUnnamed: true,
};
const PSQL_INLINE = {
...BLANK_INLINE, longText: new Set(["--command"]), textLetters: "c",
longFile: new Set(["--file"]), fileLetters: "f", separatedValueLetters: "hpUdoF",
plainLetters: "aeEqtxXwW", bannerLetters: "lV?", programOperand: undefined, stdinWhenUnnamed: true,
};
const OSASCRIPT_INLINE = {
...BLANK_INLINE, textLetters: "e", separatedValueLetters: "ls", plainLetters: "i", bannerLetters: "h",
stdinWhenUnnamed: true,
};
const RSCRIPT_INLINE = {
...BLANK_INLINE, textLetters: "e", bannerLetters: "h", stdinWhenUnnamed: false,
};
const JULIA_INLINE = {
...BLANK_INLINE, longText: new Set(["--eval", "--print"]), textLetters: "eE",
separatedValueLetters: "JLp", plainLetters: "q", bannerLetters: "vh", interactiveLetters: "i",
stdinWhenUnnamed: true,
};
const GHCI_INLINE = {
...BLANK_INLINE, textLetters: "e", separatedValueLetters: "io", programOperand: undefined,
stdinWhenUnnamed: true,
};
const GROOVY_INLINE = {
...BLANK_INLINE, textLetters: "e", separatedValueLetters: "Dcl", plainLetters: "n",
bannerLetters: "vh", interactiveLetters: "i", stdinWhenUnnamed: false,
};
const BUN_INLINE = {
...BLANK_INLINE, longText: new Set(["--eval", "--print"]), textLetters: "ep",
separatedValueLetters: "r", bannerLetters: "vh", stdinWhenUnnamed: false,
};
export const INTERPRETER_INLINE_PROGRAMS = new Map([
["perl", PERL_INLINE],
["ruby", RUBY_INLINE],
["python", PYTHON_INLINE],
["node", NODE_INLINE],
["nodejs", NODE_INLINE],
["php", PHP_INLINE],
["lua", LUA_INLINE],
["luajit", LUA_INLINE],
["tclsh", TCL_INLINE],
["wish", TCL_INLINE],
["expect", EXPECT_INLINE],
["awk", AWK_INLINE],
["gawk", AWK_INLINE],
["mawk", AWK_INLINE],
["nawk", AWK_INLINE],
["jq", JQ_INLINE],
["bc", BC_INLINE],
["dc", DC_INLINE],
["sqlite", SQLITE_INLINE],
["psql", PSQL_INLINE],
["osascript", OSASCRIPT_INLINE],
["Rscript", RSCRIPT_INLINE],
["julia", JULIA_INLINE],
["ghci", GHCI_INLINE],
["groovy", GROOVY_INLINE],
["bun", BUN_INLINE],
]);
export function interpreterKey(name) {
return name.replace(/\d+(?:\.\d+)*$/, "");
}
export function inlineProgramClause(model, args) {
let endOfOptions = false;
let banner = false;
let interactive = false;
let named = false;
let stdin = false;
let explained = true;
let operands = 0;
scan: for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (!endOfOptions && arg === "--") {
endOfOptions = true;
continue;
}
if (arg === "-") {
if (model.programOperand?.isText !== true)
stdin = true;
continue;
}
if (!endOfOptions && arg.startsWith("-")) {
const eq = arg.indexOf("=");
const whole = eq < 0 ? arg : arg.slice(0, eq);
const valueOf = () => (eq < 0 ? args[++i] : arg.slice(eq + 1));
if (model.longText.has(whole))
return INLINE_PROGRAM_TEXT;
if (model.longFile.has(whole)) {
if (valueOf() === "-") {
stdin = true;
continue;
}
named = true;
if (explained && model.fileOptionEndsOptions)
break scan;
continue;
}
if (model.longNamesProgram.has(whole)) {
named = true;
continue;
}
if (model.longSupplemental.has(whole)) {
valueOf();
continue;
}
if (INTERPRETER_BANNER_OPTIONS.has(whole)) {
banner = true;
continue;
}
if (model.longValue.has(whole)) {
valueOf();
continue;
}
if (model.longPlain.has(whole))
continue;
if (arg.startsWith("--")) {
if (eq < 0)
explained = false;
continue;
}
for (let k = 1; k < arg.length; k++) {
const ch = arg[k];
if (ch >= "0" && ch <= "9")
continue;
if (model.textLetters.includes(ch))
return INLINE_PROGRAM_TEXT;
if (model.fileLetters.includes(ch)) {
const value = k + 1 < arg.length ? arg.slice(k + 1) : args[++i];
if (value === "-") {
stdin = true;
break;
}
named = true;
if (explained && model.fileOptionEndsOptions)
break scan;
break;
}
if (model.attachedValueLetters.includes(ch)) {
if (model.bannerLetters.includes(ch))
banner = true;
break;
}
if (model.bannerLetters.includes(ch)) {
banner = true;
continue;
}
if (model.interactiveLetters.includes(ch)) {
interactive = true;
continue;
}
if (model.separatedValueLetters.includes(ch)) {
if (k + 1 >= arg.length)
i++;
break;
}
if (!model.plainLetters.includes(ch))
explained = false;
}
continue;
}
operands++;
if (!named && explained && model.programOperand !== undefined && operands === model.programOperand.at + 1) {
if (model.programOperand.isText)
return INLINE_PROGRAM_TEXT;
named = true;
break;
}
}
if (stdin || interactive)
return INLINE_PROGRAM_STDIN;
if (named || banner)
return undefined;
return model.stdinWhenUnnamed ? INLINE_PROGRAM_STDIN : undefined;
}
function unjudgedProgramTextRefusal(written, clause) {
return `command "${written}" ${clause}, which this walk does not judge ${NOT_AUTO_ALLOWED}`;
}
function argumentAsProgramRefusal(written, name, args) {
const slot = SCRIPT_SLOT_VERBS.get(name);
if (slot !== undefined && slot(args)) {
return unjudgedProgramTextRefusal(written, "names files or runs commands inside its script text");
}
const interpreter = INTERPRETER_INLINE_PROGRAMS.get(interpreterKey(name));
if (interpreter === undefined)
return undefined;
const clause = inlineProgramClause(interpreter, args);
return clause === undefined ? undefined : unjudgedProgramTextRefusal(written, clause);
}
function guardedVerbFormReason(name, toks, args) {
if (name === "find") {
for (const t of toks) {
if (FIND_DANGEROUS_PREDICATES.has(t)) {
return `\`find ${t}\` executes a command or writes a file — not a read ${NOT_AUTO_ALLOWED}`;
}
}
for (const w of args) {
if (globInWord(w) || expansionInWord(w)) {
return `\`find\` is given the unquoted pattern "${w.raw}", which the shell may expand into other arguments before find runs ${NOT_AUTO_ALLOWED}`;
}
}
return undefined;
}
if (name === "sed") {
for (const w of args) {
if (globInWord(w) || expansionInWord(w)) {
return `\`sed\` is given the unquoted pattern "${w.raw}", which the shell may expand into other arguments (a file named like a flag becomes a live option) before sed runs ${NOT_AUTO_ALLOWED}`;
}
}
const flags = [];
const operands = [];
let endOfOptions = false;
for (const t of toks.slice(1)) {
if (!endOfOptions && t === "--") {
endOfOptions = true;
continue;
}
if (!endOfOptions && t.startsWith("-")) {
flags.push(t);
continue;
}
operands.push(t);
}
for (const f of flags) {
if (SED_READONLY_FLAGS.has(f))
continue;
if (/^-[A-Za-z]+$/.test(f) && f.length > 2 && [...f.slice(1)].every((ch) => SED_READONLY_CLUSTER_LETTERS.has(ch)))
continue;
return `\`sed ${f}\` is outside the read-only sed form (-n plus print-only script) ${NOT_AUTO_ALLOWED}`;
}
const quiet = flags.some((f) => f === "-n" || f === "--quiet" || f === "--silent" || (/^-[A-Za-z]+$/.test(f) && f.includes("n")));
if (!quiet)
return `\`sed\` without \`-n\` echoes its whole input — only the -n print-only form is read-classified ${NOT_AUTO_ALLOWED}`;
const script = operands[0];
if (script === undefined)
return `\`sed\` with no script ${NOT_AUTO_ALLOWED}`;
for (const piece of script.split(";")) {
if (!SED_READONLY_SCRIPT_PIECE.test(piece.trim())) {
return `\`sed\` script "${script}" is not a pure print command (N[,M]p) ${NOT_AUTO_ALLOWED}`;
}
}
return undefined;
}
if (name === "free") {
for (const t of toks.slice(1)) {
if (/^--seconds(=.*)?$/.test(t) || /^-[A-Za-z0-9]*s[A-Za-z0-9]*$/.test(t)) {
return `\`free ${t}\` repeats until killed — it never terminates on its own ${NOT_AUTO_ALLOWED}`;
}
}
return undefined;
}
if (name === "sleep") {
return pollLoopSleepReason(toks);
}
if (name === "seq") {
for (const t of toks.slice(1)) {
if (t.startsWith("-") && t !== "-" && !/^-\d/.test(t))
continue;
if (!/^-?\d{1,6}$/.test(t)) {
return `\`seq ${t}\` is not a small literal integer bound — the output cannot be bounded statically ${NOT_AUTO_ALLOWED}`;
}
}
return undefined;
}
if (name === "diff") {
for (const t of toks.slice(1)) {
if (/^--[A-Za-z-]+=-$/.test(t) || /^-[A-Za-z]*[SX]-$/.test(t)) {
return `\`diff ${t}\` names stdin inside a fused option — a read this check cannot see through ${NOT_AUTO_ALLOWED}`;
}
}
}
return undefined;
}
export function segmentSelectsRecursiveRead(name, args) {
const model = RECURSIVE_READ_FORMS[name];
if (model === undefined)
return false;
if (model.discloses !== "contents")
return false;
if (model.always === true)
return true;
if (model.bundledModeLetters !== undefined) {
const first = args.find((t) => t.length > 0);
if (first !== undefined && !first.startsWith("-") && /^[A-Za-z]+$/.test(first) && [...first].some((ch) => model.bundledModeLetters.includes(ch))) {
return true;
}
}
let endOfOptions = false;
for (let k = 0; k < args.length; k++) {
const t = args[k];
if (endOfOptions)
continue;
if (t === "--") {
const prev = k > 0 ? args[k - 1] : undefined;
const prevMayOwnValue = prev !== undefined && prev.startsWith("--") && prev.length > 2 && !prev.includes("=");
if (!prevMayOwnValue)
endOfOptions = true;
continue;
}
if (t.startsWith("--")) {
const long = longOptionNameOf(t);
if (long === undefined)
continue;
if (model.longNames?.some((full) => isLongOptionAbbrevOf(long, full)) === true)
return true;
for (const en of model.enumOptions ?? []) {
if (en.longName === undefined || !isLongOptionAbbrevOf(long, en.longName))
continue;
const eq = t.indexOf("=");
const v = eq >= 0 ? t.slice(eq + 1) : args[k + 1];
if (v !== undefined && v.length > 0 && en.recursiveValue.startsWith(v.toLowerCase()))
return true;
}
continue;
}
if (!t.startsWith("-") || t === "-")
continue;
for (let i = 1; i < t.length; i++) {
const ch = t[i];
if (model.shortLetters?.includes(ch) === true)
return true;
const en = (model.enumOptions ?? []).find((e) => e.shortLetter === ch);
if (en !== undefined) {
const v = i === t.length - 1 ? args[k + 1] : t.slice(i + 1);
if (v !== undefined && v.length > 0 && en.recursiveValue.startsWith(v.toLowerCase()))
return true;
if (i === t.length - 1)
k++;
break;
}
if (model.valueOwners?.includes(ch) === true) {
if (i === t.length - 1)
k++;
break;
}
if (!/[A-Za-z0-9]/.test(ch))
break;
}
}
return false;
}
function isGrepFileStdinLongOption(tok) {
const eq = tok.indexOf("=");
if (eq < 0 || tok.slice(eq + 1) !== "-")
return false;
const name = longOptionNameOf(tok);
return name !== undefined && isLongOptionAbbrevOf(name, "file");
}
const CLUSTER_OPTION_CHAR = /[A-Za-z0-9]/;
export const ASSIGNMENT_VALUE_NOT_PATHS = new Set(["make", "gmake", "bmake", "cmake", "ninja", "meson", "docker", "podman"]);
export function argvProgramOf(words) {
const pos = readProgramPosition(words);
if (pos.program === undefined)
return undefined;
const at = pos.program.index;
return words[at]?.assignment !== undefined ? undefined : { program: pos.program.basename, from: at };
}
export function attachedOptionPayloads(tok) {
const literal = literalTildeSpelling;
const out = [];
if (tok.startsWith("--")) {
const eq = tok.indexOf("=");
if (eq > 0 && eq + 1 < tok.length)
out.push(literal(tok.slice(eq + 1)));
return out;
}
const whole = literal(tok.slice(1));
if (whole.length > 0 && (isPathShapedToken(whole) || isAbsolutePathForm(whole)))
out.push(whole);
for (let k = 2; k <= tok.length; k++) {
if (![...tok.slice(1, k)].every((ch) => CLUSTER_OPTION_CHAR.test(ch)))
break;
const payload = tok.slice(k);
if (payload.length > 0)
out.push(literal(payload));
}
return out;
}
const HOME_EXPANDS_TO_ONE_WORD = /^[A-Za-z0-9_./+@:%=,-]+$/;
export function homeExpansionOf(w, home) {
const [head, ...rest] = w.pieces;
const e = head?.expansion;
if (head === undefined || e === undefined || e.kind !== "parameter" || e.name !== "HOME" || (e.body !== undefined && e.body !== "HOME"))
return undefined;
if (head.quotedBy !== undefined && head.quotedBy !== '"')
return undefined;
if (head.quotedBy === undefined && !HOME_EXPANDS_TO_ONE_WORD.test(home))
return undefined;
if (rest.some((p) => p.expansion !== undefined || p.literal === undefined))
return undefined;
const next = rest[0];
if (next !== undefined) {
if (head.quotedBy === undefined && !(next.literal.startsWith("/") || next.quotedBy === '"'))
return undefined;
if (head.quotedBy === '"' && next.quotedBy === '"' && !next.literal.startsWith("/"))
return undefined;
}
return { spelled: `${home}${rest.map((p) => p.literal).join("")}` };
}
const CLASSIFY_HOME_REREADABLE = /["$`\\\n\r]/;
function homeSpelledForClassify(w, home) {
return CLASSIFY_HOME_REREADABLE.test(home) ? undefined : homeExpansionOf(w, home)?.spelled;
}
const HOME_PLAIN_READ = /\$HOME(?![A-Za-z0-9_])|\$\{HOME\}/g;
const HOME_NAME = /HOME(?![A-Za-z0-9_])/;
export function homeMayBeRebound(text) {
const unquoted = text.replace(/\\\r?\n/g, "").replace(HOME_PLAIN_READ, " ").replace(/\$?["']|\\/g, "");
if (HOME_NAME.test(unquoted))
return true;
if (hasUnquotedExtglobOpener(text))
return true;
if (!unquoted.includes("printf") && !/[*?[{]/.test(unquoted))
return false;
const lexed = lexShell(text);
return !("parseFailure" in lexed) && lexed.segments.some((seg) => {
const words = argvOf(seg);
return printfNamesComputed(words) || words.some((w) => globMayMatchHome(w) || optionWordCarriesGlob(w) || wordHasBraceList(w));
});
}
function hasUnquotedExtglobOpener(text) {
const lexed = lexShell(text);
return "parseFailure" in lexed || extglobOpenerIn(lexed, text);
}
export function extglobOpenerIn(lexed, text) {
for (const seg of lexed.segments) {
for (const [k, tok] of seg.tokens.entries()) {
const next = seg.tokens[k + 1];
if (tok.kind === "heredoc" && /[?*+@!]$/.test(tok.delimiter) && next?.kind === "group" && next.op === "(")
return true;
const t = tok.kind === "word" ? tok : tok.kind === "redirect" ? tok.target : undefined;
if (t === undefined)
continue;
const last = t.pieces[t.pieces.length - 1];
if (last === undefined || last.quotedBy !== undefined)
continue;
if (!/[?*+@!]$/.test(text.slice(last.span[0], last.span[1])))
continue;
let j = last.span[1];
while (text[j] === "\\" && (text[j + 1] === "\n" || (text[j + 1] === "\r" && text[j + 2] === "\n")))
j += text[j + 1] === "\r" ? 3 : 2;
if (text[j] === "(")
return true;
}
}
return false;
}
function wordHasBraceList(w) {
return w.pieces.some((p) => p.expansion?.kind === "brace");
}
function optionWordCarriesGlob(w) {
if (!w.pieces.some(isGlobPiece))
return false;
const atoms = patternAtoms(w.pieces);
if (atoms === undefined)
return UNREADABLE_GLOB_IS_A_REBINDING;
try {
const reversed = atoms.slice().reverse();
return closureReaches(atoms, "-") && (closureReaches(reversed, "EMOH") || closureReaches(reversed, "]"));
}
catch {
return UNREADABLE_GLOB_IS_A_REBINDING;
}
}
const UNREADABLE_GLOB_IS_A_REBINDING = true;
function escapeRegExp(s) {
return s.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
}
function globMayMatchHome(w) {
if (!w.pieces.some(isGlobPiece))
return false;
const atoms = patternAtoms(w.pieces);
if (atoms === undefined)
return UNREADABLE_GLOB_IS_A_REBINDING;
try {
if (new RegExp(`^${atoms.join("")}$`).test("HOME"))
return true;
return closureReaches(atoms, "HOME[") && closureReaches(atoms.slice().reverse(), "]");
}
catch {
return UNREADABLE_GLOB_IS_A_REBINDING;
}
}
function patternAtoms(pieces) {
const atoms = [];
for (const p of pieces) {
if (p.literal !== undefined && p.expansion === undefined) {
for (const ch of p.literal)
atoms.push(caseFoldedChar(ch));
continue;
}
if (isGlobPiece(p)) {
const body = p.expansion.body;
if (body === undefined) {
atoms.push(RUN_ATOM);
continue;
}
const cls = translateBracket(body);
if (cls === undefined)
return undefined;
atoms.push(cls);
continue;
}
atoms.push(RUN_ATOM);
}
return atoms;
}
const RUN_ATOM = ".*";
function caseFoldedChar(ch) {
const lower = ch.toLowerCase();
const upper = ch.toUpperCase();
return lower !== upper ? `[${escapeRegExp(lower)}${escapeRegExp(upper)}]` : escapeRegExp(ch);
}
function closureReaches(atoms, target) {
let re = "";
for (let i = atoms.length - 1; i >= 0; i--)
re = `(?:${atoms[i]}${re})?`;
return new RegExp(`^${re}$`).test(target);
}
function scanBracketBody(body) {
const members = [];
let computed = false;
for (let i = 0; i < body.length; i++) {
const c = body[i];
if (c === "\\") {
if (i + 1 < body.length)
members.push({ ch: body[++i], unquoted: false });
continue;
}
if (c === "$" || c === "`" || c === "{")
computed = true;
members.push({ ch: c, unquoted: true });
}
return { members, computed };
}
function membersOpenClassExpression(members) {
return members.some((m, i) => m.unquoted && m.ch === "[" && members[i + 1]?.unquoted === true && (members[i + 1].ch === ":" || members[i + 1].ch === "=" || members[i + 1].ch === "."));
}
function translateBracket(body) {
if (body.includes('"') || body.includes("'"))
return undefined;
const { members, computed } = scanBracketBody(body);
if (computed || membersOpenClassExpression(members))
return undefined;
let i = 0;
let negated = false;
if (members[0]?.unquoted === true && (members[0].ch === "!" || members[0].ch === "^")) {
negated = true;
i = 1;
}
const first = i;
let cls = "";
for (; i < members.length; i++) {
const { ch, unquoted } = members[i];
if (ch === "-" && unquoted && i > first && i < members.length - 1) {
if (!negated)
return undefined;
cls += "-";
continue;
}
const escaped = ch.replace(/[\\\]\[^-]/g, "\\$&");
cls += !negated && ch.toLowerCase() !== ch.toUpperCase() ? `${escapeRegExp(ch.toLowerCase())}${escapeRegExp(ch.toUpperCase())}` : escaped;
}
return `[${negated ? "^" : ""}${cls || "\\]"}]`;
}
const literalWord = (w) => (w !== undefined && w.pieces.every((p) => p.literal !== undefined && p.expansion === undefined) ? w.pieces.map((p) => p.literal).join("") : undefined);
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
function printfNamesComputed(words) {
for (let i = 0; i < words.length; i++) {
const head = literalWord(words[i]);
if (head === undefined || programBasename(head) !== "printf")
continue;
for (let j = i + 1; j < words.length; j++) {
const lit = literalWord(words[j]);
const opt = lit ?? words[j].pieces[0]?.literal ?? "";
if (lit === "--" || !opt.startsWith("-v"))
break;
if (lit === undefined)
return true;
const name = lit === "-v" ? words[++j] : undefined;
if (lit === "-v" && name === undefined)
break;
if (!IDENTIFIER.test(lit === "-v" ? (literalWord(name) ?? "") : lit.slice(2)))
return true;
}
}
return false;
}
function segmentCompoundForReadonly(command, homeDir) {
const trimmed = command.trim();
if (!trimmed)
return { reject: "empty command" };
const terminatorStripped = trimmed.endsWith(";") ? trimmed.slice(0, -1) : trimmed;
return splitShellCompoundSegments(terminatorStripped, { trailingTerminator: "keep", redirection: "data-free", ...(homeDir !== undefined ? { homeDir } : {}) });
}
export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
const split = segmentCompoundForReadonly(command, boundary?.homeDir);
if ("reject" in split)
return { reason: split.reject };
const { segments, pipeFed } = split;
const homeRebound = boundary?.homeDir !== undefined && homeMayBeRebound(command);
let nameRefusal;
const unlistedSegments = new Set();
const reheaded = new Map();
for (let si = 0; si < segments.length; si++) {
const words = argvOf(split.lexed[si]);
const toks = segmentSpellings(words, boundary?.homeDir, homeRebound);
const folded = toks.folded;
const pos = readProgramPosition(words);
const from = pos.written ?? folded.length;
const written = folded[from];
if (written === undefined)
return { reason: "empty command" };
if (written === "coproc")
return { reason: "`coproc` backgrounds a command — a backgrounded process outlives the command" };
if (isBareControlWord(words, from, SHELL_BLOCK_WORDS)) {
unlistedSegments.add(si);
nameRefusal ??= `command "${written}" is not in the read-only allowlist`;
continue;
}
const program = pos.program;
if (program !== undefined) {
const familyRefusal = argumentAsProgramRefusal(program.basename, program.basename, folded.slice(program.index + 1));
if (familyRefusal !== undefined)
nameRefusal ??= familyRefusal;
}
const parsed = parseLeadingCommandName(toks.raw.slice(from).join(" "), { quotedOperatorsAreText: true, pathPrefixedNameIsText: true });
if ("reject" in parsed)
return { reason: parsed.reject };
const pathPrefixed = parsed.name.includes("/");
const name = pathPrefixed ? parsed.name.slice(parsed.name.lastIndexOf("/") + 1) : parsed.name;
if (pathPrefixed)
nameRefusal ??= "the command must be a bare name resolved via PATH (no path prefix)";
if (pos.refusal !== undefined)
return { reason: programPositionRefusalSentence(pos.refusal) };
if (allow.has(name)) {
if (pathPrefixed || from > 0)
reheaded.set(si, { from, head: name });
if (from > 0)
nameRefusal ??= `command "${folded[0]}" is not in the read-only allowlist`;
continue;
}
nameRefusal ??= `command "${parsed.name}" is not in the read-only allowlist`;
if (SHELL_REENTRY_PROGRAM_RE.test(name))
return { reason: unjudgedProgramTextRefusal(parsed.name, "runs its argument as shell text") };
if (pos.launchers.length > 0) {
const listed = program !== undefined && (allow.has(program.basename) || program.basename === "cd");
if (program !== undefined && !listed && SHELL_REENTRY_PROGRAM_RE.test(program.basename)) {
return { reason: `"${written}" runs "${program.basename}", which runs its argument as shell text this classifier does not read ${NOT_AUTO_ALLOWED}` };
}
if (argvReentryUnjudged(pos, XARGS_TARGETS_ACCEPTED_UNJUDGED)) {
return { reason: `"${written}" takes the operands of the program it runs from its standard input, which this classifier does not read ${NOT_AUTO_ALLOWED}` };
}
if (!listed || program === undefined)
unlistedSegments.add(si);
else
reheaded.set(si, { from: program.index, head: program.basename });
continue;
}
unlistedSegments.add(si);
}
const HEAD_AUTO_ALLOW_MAX = 1_000_000;
const headBoundIsSmall = (name, toks) => {
if (name !== "head")
return false;
let byteBound = false;
for (let k = 1; k < toks.length; k++) {
const t = toks[k];
if (!t.startsWith("-") || t === "-")
continue;
const inline = /^-[A-Za-z]*c(\d+)$/.exec(t) ?? /^--bytes=(\d+)$/.exec(t);
if (inline) {
if (Number(inline[1]) > HEAD_AUTO_ALLOW_MAX)
return false;
byteBound = true;
continue;
}
if (/^-[A-Za-z]*c$/.test(t) || t === "--bytes") {
const v = toks[k + 1];
if (v === undefined || !/^\d+$/.test(v) || Number(v) > HEAD_AUTO_ALLOW_MAX)
return false;
byteBound = true;
k++;
continue;
}
return false;
}
return byteBound;
};
const STDIN_FILE_FLOOR = { cat: 1, grep: 2, head: 1, tail: 1, wc: 1, cut: 1, tr: Infinity, diff: 2, cmp: 2, comm: 2, sed: 2 };
const foldedSegments = [];
let shapeRefusal = nameRefusal;
for (let si = 0; si < segments.length; si++) {
if (unlistedSegments.has(si))
continue;
const rehead = reheaded.get(si);
const words = argvOf(split.lexed[si]);
const spelled = segmentSpellings(words, boundary?.homeDir, homeRebound);
const segmentTokens = rehead === undefined ? spelled : reheadSegment(spelled, rehead.head, rehead.from);
const argWords = words.slice((rehead?.from ?? 0) + 1);
const toks = segmentTokens.folded;
if (toks.length === 0)
continue;
foldedSegments.push(segmentTokens);
const name = toks[0];
const guardReason = guardedVerbFormReason(name, toks, argWords);
if (guardReason !== undefined) {
shapeRefusal ??= guardReason;
continue;
}
if (!(pipeFed[si] ?? false)) {
const floor = STDIN_FILE_FLOOR[name];
const restArgs = toks.slice(1);
const floorFirstOperandSlot = VERB_OPTION_SLOTS[name]?.firstOperand;
const floorSlotSuppliedByFlag = floorFirstOperandSlot !== undefined && restArgs.some((t) => suppliesFirstOperandSlot(floorFirstOperandSlot, t));
let sawNonFlagOperand = false;
let hasStdinDash = false;
let operandCount = 0;
let endOfOptions = false;
if (name !== "tr") {
for (let k = 0; k < restArgs.length; k++) {
const t = restArgs[k];
if (endOfOptions) {
}
else if (t === "--") {
endOfOptions = true;
continue;
}
else if (takesSeparatedValue(name, t)) {
k++;
continue;
}
else {
if (name === "grep" && (isGrepClusterFileStdin(t) || isGrepFileStdinLongOption(t))) {
hasStdinDash = true;
break;
}
if (t.startsWith("-") && t !== "-")
continue;
}
const inFirstOperandSlot = floorFirstOperandSlot !== undefined && !floorSlotSuppliedByFlag && !sawNonFlagOperand;
sawNonFlagOperand = true;
operandCount++;
if (inFirstOperandSlot)
continue;
if (t === "-") {
hasStdinDash = true;
break;
}
}
}
if (floor !== undefined && hasStdinDash) {
shapeRefusal ??= `"${name}" reads stdin via an explicit "-" argument and would block until the tool timeout ${NOT_AUTO_ALLOWED}`;
continue;
}
if (floor !== undefined && operandCount < floor) {
shapeRefusal ??= `"${name}" with no file argument reads stdin and would block until the tool timeout ${NOT_AUTO_ALLOWED}`;
continue;
}
}
if (name === "tail" && toks.slice(1).some((t) => t === "--follow" || t.startsWith("--follow=") || /^[-+][^\s]*[fF]/.test(t))) {
shapeRefusal ??= "`tail` in follow mode never terminates " + NOT_AUTO_ALLOWED;
continue;
}
const GENERATOR_DEVICES = new Set(["/dev/zero", "/dev/random", "/dev/urandom", "/dev/full"]);
const deviceCandidates = [];
for (const t of toks.slice(1)) {
if (t.startsWith("-") && t !== "-") {
deviceCandidates.push(...attachedOptionPayloads(t));
continue;
}
deviceCandidates.push(t);
}
const deviceArgs = deviceCandidates.map((t) => resolveOperandLexically(undefined, t, boundary?.homeDir) ?? normalizeAbsPathLexically(t)).filter(isBlockedDevicePath);
const rescuedByHead = headBoundIsSmall(name, toks) && deviceArgs.every((d) => GENERATOR_DEVICES.has(d));
if (!rescuedByHead && deviceArgs.length > 0) {
shapeRefusal ??= "reads a device/special file that is either unbounded (/dev/zero, /dev/stdin, /proc/<pid>/fd/0, … — blocks the pipeline until the tool timeout) or process-private (/proc/<pid>/environ, /proc/<pid>/mem, …) " + NOT_AUTO_ALLOWED;
}
}
const cdSegments = foldedSegments.filter((st) => st.folded[0] === "cd");
if (cdSegments.length > 1)
shapeRefusal ??= `multiple directory changes in one command require approval for clarity ${NOT_AUTO_ALLOWED}`;
return shapeRefusal !== undefined ? { reason: shapeRefusal } : {};
}
export const UNMODELLED_BASE_MOVERS = new Set(["pushd", "popd"]);
export const COMMAND_LAUNCHERS_FOR_TEST = COMMAND_LAUNCHERS;
export function classifyCompoundReadonly(command, allow, boundary) {
return classifyCompoundReadonlyDetailed(command, allow, boundary).reason;
}
export const POLL_LOOP_MAX_BEATS = 120;
export const POLL_LOOP_MAX_SLEEP_SECONDS = 600;
const POLL_BODY_HARD_REJECT = /[<>$()`|&{}\n\r\\]/;
const NON_ASCII_WHITESPACE = /[^\S \t]/;
const NOT_A_POLL_LOOP = "not a bounded read-only poll loop (`for <v> in <literal bound>; do <read commands>; done` is the only accepted control structure)";
function pollLoopShapeOf(lexed) {
const segments = [...lexed.segments];
const last = segments[segments.length - 1];
if (last !== undefined && last.tokens.length === 0 && last.connector === ";" && segments.length > 1)
segments.pop();
if (segments.length < 3)
return undefined;
const bare = (w, text) => w !== undefined && w.raw === w.folded && w.folded === text;
const onlyWords = (s) => s.tokens.every((t) => t.kind === "word");
const head = segments[0];
if (!onlyWords(head))
return undefined;
const hw = argvOf(head);
if (!bare(hw[0], "for") || !bare(hw[2], "in") || hw.length < 4)
return undefined;
const name = hw[1];
if (name.raw !== name.folded || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name.folded))
return undefined;
const done = segments[segments.length - 1];
const dw = argvOf(done);
if (done.connector !== ";" || !onlyWords(done) || dw.length !== 1 || !bare(dw[0], "done"))
return undefined;
const first = segments[1];
if (first.connector !== ";")
return undefined;
const fw = argvOf(first);
if (!bare(fw[0], "do") || fw.length < 2)
return undefined;
const body = [{ words: fw.slice(1), segment: first }];
for (const s of segments.slice(2, -1))
body.push({ words: argvOf(s), segment: s });
return { varName: name.folded, iterable: hw.slice(3), body };
}
function pollLoopBeatsOf(iterable) {
const one = iterable.length === 1 && iterable[0].pieces.length === 1 ? iterable[0].pieces[0].expansion : undefined;
const seq = one?.kind === "command" ? /^seq[ \t]+(\d{1,7})[ \t]+(\d{1,7})$/.exec(one.body ?? "") : null;
const brace = seq === null && one?.kind === "brace" ? /^(\d{1,7})\.\.(\d{1,7})$/.exec(one.body ?? "") : null;
const range = seq ?? brace;
if (range !== null) {
const lo = Number(range[1]);
const hi = Number(range[2]);
return hi >= lo ? hi - lo + 1 : undefined;
}
return iterable.every((w) => w.raw === w.folded && w.expansions.length === 0 && /^[A-Za-z0-9_.-]{1,64}$/.test(w.raw)) ? iterable.length : undefined;
}
function pollLoopSleepReason(toks) {
if (toks.length !== 2)
return "`sleep` in a poll loop must take exactly one literal numeric argument";
const v = toks[1];
if (!/^\d+(\.\d+)?$/.test(v) || v.length > 8 || Number(v) > POLL_LOOP_MAX_SLEEP_SECONDS) {
return `\`sleep ${v}\` is not a literal duration within the ${POLL_LOOP_MAX_SLEEP_SECONDS}s per-beat cap ${NOT_AUTO_ALLOWED}`;
}
return undefined;
}
export function classifyBoundedReadonlyPollLoop(command, allow, boundary) {
return classifyBoundedReadonlyPollLoopDetailed(command, allow, boundary).reason;
}
export function classifyBoundedReadonlyPollLoopDetailed(command, allow, boundary) {
if (NON_ASCII_WHITESPACE.test(command)) {
return { reason: "the command contains a non-ASCII or control whitespace character (only space and tab are allowed) — its word boundaries cannot be read the way bash would split them, so it is not auto-allowed" };
}
const trimmed = command.trim();
if (!trimmed)
return { reason: "empty command" };
const lexed = lexShell(trimmed);
if ("parseFailure" in lexed) {
const r = lexed.parseFailure.reason;
if (r === "unterminated_single_quote" || r === "unterminated_double_quote" || r === "unterminated_ansi_c") {
return { reason: "unbalanced quote — the command's structure cannot be read reliably, so it is not auto-allowed" };
}
return { reason: NOT_A_POLL_LOOP };
}
const shape = pollLoopShapeOf(lexed);
if (shape === undefined)
return { reason: NOT_A_POLL_LOOP };
if (!/^[a-z]$/.test(shape.varName)) {
return { reason: `the loop variable "${shape.varName}" must be a single lowercase letter — a name like PATH/IFS/LD_PRELOAD would change how the body's commands resolve` };
}
const beats = pollLoopBeatsOf(shape.iterable);
if (beats === undefined) {
return { reason: `the loop bound "${shape.iterable.map((w) => w.raw).join(" ")}" is not a recognized literal — only \`$(seq <int> <int>)\`, \`{<int>..<int>}\` (ascending) or a literal word list is accepted` };
}
if (beats > POLL_LOOP_MAX_BEATS) {
return { reason: `the loop runs ${beats} iterations, above the ${POLL_LOOP_MAX_BEATS}-iteration cap for auto-allow` };
}
const homeRebound = boundary?.homeDir !== undefined && homeMayBeRebound(command);
const bodies = shape.body.map((b) => ({ ...b, spelled: segmentSpellings(b.words, boundary?.homeDir, homeRebound).raw }));
for (const b of bodies) {
if (b.segment.connector !== ";" || b.segment.tokens.some((t) => t.kind !== "word") || b.spelled.some((raw) => POLL_BODY_HARD_REJECT.test(raw))) {
return { reason: "the loop body may not contain redirection, pipes, backgrounding, substitution, subshells, braces, escapes, or line breaks" };
}
}
const readSegments = [];
for (const b of bodies) {
if (b.words.length === 0)
return { reason: "empty command in the loop body" };
const segment = b.spelled.join(" ");
const parsed = parseLeadingCommandName(segment, { quotedOperatorsAreText: true });
if ("reject" in parsed)
return { reason: parsed.reject };
if (parsed.name === "sleep") {
const sleepReason = pollLoopSleepReason(b.spelled);
if (sleepReason !== undefined)
return { reason: sleepReason };
continue;
}
readSegments.push(segment);
}
if (readSegments.length === 0) {
return { reason: "the loop body has no read command — a sleep-only loop observes nothing and is not auto-allowed" };
}
return classifyCompoundReadonlyDetailed(readSegments.join("; "), allow, boundary);
}