@sema-agent/core
Version:
Stateless, task-oriented AI agent core
1,016 lines (1,015 loc) • 46 kB
JavaScript
import { anchorSpelling, isAbsoluteForFamily, isBlockedDevicePath, isShellRootedSpellingUnmapped, nativeUncSpellingOf, normalizeAbsPathLexically, win32NamespaceScreen, pathFamilyOf } from "./safety.js";
import { PARSE_FAILURE_SENTENCE, UNRESOLVED_SENTENCE, argvOf, lexShell, literalTildeSpelling, } from "../../shell/lex.js";
import { shellWordsOf } from "../../shell/shell-words.js";
import { textBindsHome } from "./shell-read-bindings.js";
import { GENERATOR_DEVICES, newLineDescriptors, bindDescriptors, isNamedDescriptorWord, standardDeviceUnrebound, descriptorsBoundBy } from "./shell-read-device.js";
import { NOT_AUTO_ALLOWED, argvReentryUnjudged, isBareControlWord, programPositionRefusalSentence, readProgramPosition, } from "./bash-program-position.js";
import { BLOCK_WORD_NAMES, attachedStdinFile, byteBoundIsSmall, declaresOption, firstOperandSlot, opensNoOperand, programDelivery, programRow, programTextReadings, readArgs, readProgramTextLanguage, runsShellText, selectedOptions, slotValueCandidates, suppliesFirstOperandSlot, takesSeparatedDatum, valuesAreLiteral, writeReadings, writesPath, } from "./program-read-model.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 (expandsBeyondText(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 expandsBeyondText(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 (k === "tilde" && p.expansion?.body !== "~")
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, line) {
if (r.op === ">" && r.fd === 2 && r.target !== undefined && standardDeviceUnrebound(r.target.raw, line, descriptorsBoundBy(r)))
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 = [];
const line = newLineDescriptors();
for (const s of lexed.segments)
for (const t of s.tokens)
if (t.kind === "redirect")
bindDescriptors(line, t);
else if (t.kind === "word" && isNamedDescriptorWord(t.raw))
line.named = true;
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, line))
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 && !textBindsHome(source) ? opts.homeDir : undefined;
for (const w of words) {
if (expandsBeyondText(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 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 || !isAbsoluteForFamily("own", 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 ?? "own", raw))
return spellingsOf(nativeUncSpellingOf(family, raw));
if (base === undefined || family === undefined || !isAbsoluteForFamily(family, base))
return undefined;
if (isShellRootedSpellingUnmapped(family, raw))
return undefined;
return spellingsOf(anchorSpelling(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)] };
}
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 (k === "glob" && /[$`]/.test(p.expansion?.body ?? ""))
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;
}
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";
}
function scriptReadings(row, args) {
return programTextReadings(row, args).map((r) => (r === "from-file" ? "unseen" : r === "unparsed" ? "unparsed" : r === "none" ? "clean" : readSedScript(r.text)));
}
export function programTextSlotFiles(row, args) {
if (readProgramTextLanguage(row) !== "sed")
return undefined;
const reads = new Set();
const writes = new Set();
const commands = new Set();
let executesPatternSpace = false;
const kinds = [];
for (const reading of scriptReadings(row, args)) {
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 } : {}) };
}
function scriptSlotHidesNames(row, args) {
if (readProgramTextLanguage(row) !== "sed")
return false;
const readings = scriptReadings(row, args).map(sedReadingKind);
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";
function unjudgedProgramTextRefusal(written, clause) {
return `command "${written}" ${clause}, which this walk does not judge ${NOT_AUTO_ALLOWED}`;
}
function argumentAsProgramRefusal(written, name, args) {
const row = programRow(name);
if (row !== undefined && scriptSlotHidesNames(row, args)) {
return unjudgedProgramTextRefusal(written, "names files or runs commands inside its script text");
}
const delivery = programDelivery(row, args);
return delivery === undefined ? undefined : unjudgedProgramTextRefusal(written, delivery === "text" ? INLINE_PROGRAM_TEXT : INLINE_PROGRAM_STDIN);
}
function makesMoreThanARead(spec) {
return spec.effect === "writes" || spec.effect === "executes" || spec.delivers !== undefined || spec.selects === "edit-in-place" || writesPath(spec.value);
}
function writesWordsItIsHanded(row, args) {
if (row === undefined)
return false;
const operands = args.some((w) => !w.startsWith("-") || w === "-");
return writeReadings(row, args).some((r) => r.written.length > 0 || (r.unexplained && r.operandRole !== undefined && operands));
}
function inReadOnlyForm(row, flag) {
const plain = (spec) => spec !== undefined && spec.arity === 0 && spec.effect === "none" && spec.fillsSlot !== true;
const merged = (table, key) => {
const specs = row.grammars.map((g) => g[table][key]).filter((x) => x !== undefined);
return specs.length > 0 && specs.every(plain) ? specs[0] : undefined;
};
if (flag.startsWith("--"))
return !flag.includes("=") && merged("long", flag.slice(2)) !== undefined;
return /^-[A-Za-z]+$/.test(flag) && [...flag.slice(1)].every((ch) => merged("short", ch) !== undefined);
}
function selectsQuiet(row, flag) {
const quiet = (spec) => spec?.mode === "quiet";
if (flag.startsWith("--"))
return row.grammars.some((g) => quiet(g.long[flag.slice(2)]));
return /^-[A-Za-z]+$/.test(flag) && [...flag.slice(1)].some((ch) => row.grammars.some((g) => quiet(g.short[ch])));
}
function readOnlyFormReason(name, row, rest, args) {
for (const w of args) {
if (globInWord(w) || expansionInWord(w)) {
return `\`${name}\` 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 ${name} runs ${NOT_AUTO_ALLOWED}`;
}
}
const flags = [];
const operands = [];
let endOfOptions = false;
for (const t of rest) {
if (!endOfOptions && t === "--") {
endOfOptions = true;
continue;
}
if (!endOfOptions && t.startsWith("-")) {
flags.push(t);
continue;
}
operands.push(t);
}
for (const f of flags)
if (!inReadOnlyForm(row, f))
return `\`${name} ${f}\` is outside the read-only ${name} form (-n plus print-only script) ${NOT_AUTO_ALLOWED}`;
if (!flags.some((f) => selectsQuiet(row, f)))
return `\`${name}\` 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 `\`${name}\` with no script ${NOT_AUTO_ALLOWED}`;
for (const piece of script.split(";")) {
if (!SED_READONLY_SCRIPT_PIECE.test(piece.trim()))
return `\`${name}\` script "${script}" is not a pure print command (N[,M]p) ${NOT_AUTO_ALLOWED}`;
}
return undefined;
}
function guardedVerbFormReason(name, toks, args) {
const row = programRow(name);
const rest = toks.slice(1);
if (row !== undefined && readProgramTextLanguage(row) !== undefined)
return readOnlyFormReason(name, row, rest, args);
if (row !== undefined && declaresOption(row, makesMoreThanARead)) {
for (const t of rest) {
if (selectedOptions(row, [t]).some(makesMoreThanARead))
return `\`${name} ${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 `\`${name}\` is given the unquoted pattern "${w.raw}", which the shell may expand into other arguments before ${name} runs ${NOT_AUTO_ALLOWED}`;
}
}
return undefined;
}
const repeats = rest.find((t) => selectedOptions(row, [t]).some((s) => s.mode === "repeats"));
if (repeats !== undefined)
return `\`${name} ${repeats}\` repeats until killed — it never terminates on its own ${NOT_AUTO_ALLOWED}`;
if (name === "sleep") {
return pollLoopSleepReason(toks);
}
if (name === "seq") {
for (const t of rest) {
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;
}
return undefined;
}
function openableWordsOf(row, args) {
const out = [];
const globs = valuesAreLiteral(row, args) ? "literal" : "expanded";
const offer = (value, role) => {
const named = slotValueCandidates(value, role, globs);
out.push(...(named ?? [value]));
};
for (const e of readArgs(row, args.map((folded) => ({ folded })), { firstOperandSlot: true })) {
const t = args[e.at];
if (e.kind === "dash-word-operand")
out.push(t);
else if (e.kind === "value")
offer(t, e.role);
else if (e.kind === "option") {
if (e.reading === "speculative")
out.push(...attachedOptionPayloads(t));
else if (e.reading !== "no-value")
offer(literalTildeSpelling(e.reading.attached), e.reading.role);
}
else if (e.kind === "operand") {
const role = e.slot ?? e.role;
if (role !== undefined)
offer(t, role);
else
out.push(t);
}
}
return out;
}
const CLUSTER_OPTION_CHAR = /[A-Za-z0-9]/;
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) || isAbsoluteForFamily("own", 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;
}
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 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 && textBindsHome(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 (shellWordsOf("background").has(written))
return { reason: "`coproc` backgrounds a command — a backgrounded process outlives the command" };
if (isBareControlWord(words, from, BLOCK_WORD_NAMES)) {
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 (runsShellText(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);
if (program !== undefined && !listed && runsShellText(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, opensNoOperand)) {
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 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) ?? (writesWordsItIsHanded(programRow(name), toks.slice(1)) ? `\`${name}\` writes a file it is handed — not a read ${NOT_AUTO_ALLOWED}` : undefined);
if (guardReason !== undefined) {
shapeRefusal ??= guardReason;
continue;
}
const row = programRow(name);
const restArgs = toks.slice(1);
if (!(pipeFed[si] ?? false)) {
const floor = row?.operands?.stdinBelow;
if (floor === "always") {
shapeRefusal ??= `"${name}" with no file argument reads stdin and would block until the tool timeout ${NOT_AUTO_ALLOWED}`;
continue;
}
if (floor !== undefined) {
const floorFirstOperandSlot = firstOperandSlot(row);
const floorSlotSuppliedByFlag = floorFirstOperandSlot !== undefined && restArgs.some((t) => suppliesFirstOperandSlot(floorFirstOperandSlot, t));
let sawNonFlagOperand = false;
let hasStdinDash = false;
let operandCount = 0;
let endOfOptions = false;
for (let k = 0; k < restArgs.length; k++) {
const t = restArgs[k];
if (!endOfOptions) {
if (t === "--") {
endOfOptions = true;
continue;
}
if (takesSeparatedDatum(row, t)) {
k++;
continue;
}
if (attachedStdinFile(row, 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 (hasStdinDash) {
shapeRefusal ??= `"${name}" reads stdin via an explicit "-" argument and would block until the tool timeout ${NOT_AUTO_ALLOWED}`;
continue;
}
if (operandCount < floor) {
shapeRefusal ??= `"${name}" with no file argument reads stdin and would block until the tool timeout ${NOT_AUTO_ALLOWED}`;
continue;
}
}
}
if (selectedOptions(row, restArgs).some((o) => o.mode === "follows")) {
shapeRefusal ??= `\`${name}\` in follow mode never terminates ${NOT_AUTO_ALLOWED}`;
continue;
}
const deviceCandidates = openableWordsOf(row, restArgs);
const deviceArgs = deviceCandidates.map((t) => resolveOperandLexically(undefined, t, boundary?.homeDir) ?? normalizeAbsPathLexically(t)).filter(isBlockedDevicePath);
const rescuedByHead = byteBoundIsSmall(row, restArgs) && 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 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 && textBindsHome(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);
}