UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

1,016 lines (1,015 loc) • 46 kB
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); }