UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

343 lines (342 loc) • 15.3 kB
import { posix } from "node:path"; import { readShellCommand } from "./shell-lexer.js"; import { FIND_ACTION_PRIMARIES, FIND_NEWER_PRIMARY, FIND_VALUE_PRIMARIES, FLAG_VALUE_ACCEPTS, READ_ONLY_BARE_ONLY, READ_ONLY_BARE_PROGRAMS, READ_ONLY_COMMAND_TABLE, READ_ONLY_ENV_NAMES, READ_ONLY_EXACT_FORMS, READ_ONLY_GLOB_PROGRAMS, XARGS_READ_ONLY_TARGETS, dockerRetargetDanger, } from "./read-only-shell-table.js"; const NOT = (reason) => ({ readOnly: false, reason }); export const CD_GIT_COMPOUND_SENTENCE = "This command changes directory before running a version-control command, which can pick up untrusted hooks or repository configuration from the target directory. Approve only if you trust it."; export function changesDirectoryBeforeGit(shape, cwd) { const programs = shape.segments.map((s) => peelBuiltinPrefix(s.argv.slice(leadingAssignmentCount(s.argv)).map((w) => w.text))[0] ?? ""); if (shape.segments.length < 2 || !programs.includes("cd") || !programs.includes("git")) return false; const cds = shape.segments.filter((_, i) => programs[i] === "cd"); const [cd] = cds; if (cwd === undefined || cds.length !== 1 || cd === undefined || cd.redirections.length > 0 || cd.argv.length !== 2 || cd.argv[0].raw !== "cd") return true; const target = cd.argv[1]; if (target.expands !== false || !/^(?:\.\.?(?:\/|$)|\/)/.test(target.text) || /[$`*?[\\]/.test(target.raw)) return true; const trim = (p) => p.replace(/\/+$/, "") || "/"; return trim(posix.resolve(cwd, target.text)) !== trim(cwd); } const READ_ONLY = { readOnly: true }; const BARE_PROGRAMS = new Set(READ_ONLY_BARE_PROGRAMS.filter((p) => !p.includes(" "))); const BARE_MULTIWORD = READ_ONLY_BARE_PROGRAMS.filter((p) => p.includes(" ")).map((p) => p.split(" ")); const GLOB_PROGRAMS = new Set(READ_ONLY_GLOB_PROGRAMS); const BARE_ONLY = new Set(READ_ONLY_BARE_ONLY); const ENV_NAMES = new Set(READ_ONLY_ENV_NAMES); const FIND_ACTIONS = new Set(FIND_ACTION_PRIMARIES); const FIND_VALUES = new Set(FIND_VALUE_PRIMARIES); const XARGS_TARGETS = XARGS_READ_ONLY_TARGETS; const TABLE_ROWS = Object.entries(READ_ONLY_COMMAND_TABLE) .map(([key, row]) => ({ words: key.split(" "), row })) .sort((a, b) => b.words.length - a.words.length); const READING_REDIRECTS = new Set(["<", "<<", "<<-", "<&", "<<<"]); const LITERAL_ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)\+?=(?:"[^"$`\\]*"|'[^']*'|[A-Za-z0-9_./:+-]*)$/; const ASSIGNMENT_NAME = /^([A-Za-z_][A-Za-z0-9_]*)(?:\[[^\]]*\])?\+?=/; const FLAG_WORD = /^-[a-zA-Z0-9_-]/; const NUMBER_LITERAL = /^[-+]?(0[xX][0-9a-fA-F]+|[0-9]+#[0-9a-zA-Z]+|[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?)$/; const PRINTF_LENGTH = "[lLhqjzZt]*"; const PRINTF_ESCAPE = new RegExp(`%[^%a-zA-Z]*${PRINTF_LENGTH}\\\\[0-7xX]`); const PRINTF_NUMERIC = new RegExp(`%[-+ 0#']*[0-9.*]*${PRINTF_LENGTH}[diouxXeEfFgGaAn]`); const JQ_DANGER_FLAG = /^['"]?-[a-zA-Z]*[fL]/; const JQ_DANGER_TEXT = /--from-file|--rawfile|--slurpfile|--run-tests|--library-path|\benv\b|\$ENV\b|\binclude\b|\bimport\b/; export function readOnlyShellVerdict(command, shape = readShellCommand(command), cwd) { const line = lineStructureObjection(shape); if (line !== undefined) return NOT(line); for (const seg of shape.segments) { if (seg.unreadable !== undefined) return NOT(seg.unreadable); if (seg.peelUnreadable !== undefined) return NOT(seg.peelUnreadable); for (const w of [...seg.argv, ...seg.redirections.map((r) => r.target)]) { if (expansionKind(w) === "variable") return NOT("a variable, arithmetic or command substitution — its value is not known at approval time"); if (w.expands !== false && /[()]/.test(w.raw)) return NOT("a pattern with a parenthesised part (an extended pattern or a shell-specific glob qualifier)"); if (/^=[A-Za-z_]/.test(w.raw) || w.raw.includes("~[")) return NOT("a shell-specific expansion spelling (`=cmd`, `~[…]`)"); } } if (changesDirectoryBeforeGit(shape, cwd)) return NOT(CD_GIT_REASON); for (const seg of shape.segments) { const verdict = segmentVerdict(seg); if (!verdict.readOnly) return verdict; } return READ_ONLY; } const CD_GIT_REASON = "a compound that changes directory and runs git"; function lineStructureObjection(shape) { if (shape.grouped) return "a subshell, group or control structure — not a flat list of simple commands"; if (shape.segments.length === 0) return "no command"; if (shape.backgrounded) return "a background `&` defers execution past the approval-time reading"; if (shape.strayRedirection) return "a redirection on a piece that runs no program — the shell still opens its target"; return undefined; } export function readOnlyLineObjection(command, cwd) { const shape = readShellCommand(command); return lineStructureObjection(shape) ?? (changesDirectoryBeforeGit(shape, cwd) ? CD_GIT_REASON : undefined); } function expansionKind(w) { if (w.expands === false) return "none"; if (/[$`]|[<>]\(/.test(w.raw)) return "variable"; if (w.expands === "many") return "glob"; return "none"; } function leadingAssignmentCount(argv) { let k = 0; while (k < argv.length && ASSIGNMENT_NAME.test(argv[k].raw)) k++; return k; } function peelBuiltinPrefix(words) { let t = words; for (;;) { if (t[0] === "command") { let r = 1; while (t[r] !== undefined && /^-p+$/.test(t[r])) r++; if (t[r] === "--") r++; if (r >= t.length || t[r].startsWith("-")) return t; t = t.slice(r); } else if (t[0] === "builtin") { const r = t[1] === "--" ? 2 : 1; if (r >= t.length) return t; t = t.slice(r); } else if (t[0] === "noglob") { if (t.length <= 1) return t; t = t.slice(1); } else return t; } } function segmentVerdict(seg) { for (const r of seg.redirections) { const target = r.target.text; if (!READING_REDIRECTS.has(r.op) && target !== "/dev/null" && !(r.op === ">&" && /^\d+$/.test(target))) return NOT(`the redirection \`${r.op} ${target}\` writes`); if (/^\/dev\/(tcp|udp)\//.test(target)) return NOT("a redirection to a network pseudo-device"); } const k = leadingAssignmentCount(seg.argv); for (const w of seg.argv.slice(0, k)) { const m = LITERAL_ASSIGNMENT.exec(w.raw); if (m === null) return NOT(`the assignment \`${w.raw}\` is not a literal value`); if (!ENV_NAMES.has(m[1])) return NOT(`the environment variable \`${m[1]}\` can alter what a program does`); } const run = seg.argv.slice(k); if (run.length === 0) return NOT("a bare assignment runs no program"); const words = peelBuiltinPrefix(run.map((w) => w.text)); const program = words[0]; if (run.some((w) => expansionKind(w) === "glob")) return GLOB_PROGRAMS.has(program) ? READ_ONLY : NOT(`\`${program}\` with a glob argument — the glob may name anything`); const structural = structuralReading(words); if (structural !== null) return structural ? READ_ONLY : NOT(`\`${program}\` with these arguments is not a read-only form`); return tableReading(words, run); } function structuralReading(words) { const program = words[0]; if (BARE_ONLY.has(program)) return words.length === 1; for (const form of READ_ONLY_EXACT_FORMS) if (words.length === form.length && words.every((w, i) => w === form[i])) return true; if (BARE_PROGRAMS.has(program)) return true; for (const form of BARE_MULTIWORD) if (words.length >= form.length && form.every((w, i) => words[i] === w)) return form[0] !== "docker" || !dockerRetargetDanger(words); if (program === "echo") return true; if (program === "printf") return printfReading(words); if (program === "ls") return true; if (program === "cd") return words.length <= 2; if (program === "find") { for (let i = 1; i < words.length; i++) { const w = words[i]; if (FIND_ACTIONS.has(w)) return false; if (FIND_VALUES.has(w) || FIND_NEWER_PRIMARY.test(w)) { i++; continue; } } return true; } if (program === "history") return words.length === 1 || (words.length === 2 && /^\d+$/.test(words[1])); if (program === "arch") return words.length === 1 || (words.length === 2 && (words[1] === "-h" || words[1] === "--help")); if (program === "ifconfig") return words.length === 1 || (words.length === 2 && /^[a-zA-Z]/.test(words[1])); return null; } function printfReading(words) { if (words[1]?.startsWith("-") === true && words[1] !== "--") return false; const at = words[1] === "--" ? 2 : 1; const format = words[at] ?? ""; if (format.includes("$")) return false; const stripped = format.replace(/%%/g, ""); if (PRINTF_ESCAPE.test(stripped) || /\\[uU]/.test(stripped)) return false; if (PRINTF_NUMERIC.test(stripped) || /%[^%a-zA-Z]*\*/.test(stripped)) { for (let i = at + 1; i < words.length; i++) { const operand = words[i]; if (operand.includes("[") || operand.includes("`") || operand.includes("$(") || !NUMBER_LITERAL.test(operand)) return false; } } return true; } function tableReading(words, run) { const program = words[0]; const hit = TABLE_ROWS.find((r) => words.length >= r.words.length && r.words.every((w, i) => words[i] === w)); if (hit !== undefined) { const args = words.slice(hit.words.length); for (const a of args) { if (a.includes("$")) return NOT(`\`${program}\`: an argument spells \`$\``); if (a.includes("{") && (a.includes(",") || a.includes(".."))) return NOT(`\`${program}\`: a brace pattern argument`); } if (!flagWalk(args, hit.row, program)) return NOT(`\`${program}\`: an option outside its read-only set (or a malformed option value)`); if (hit.row.wordsShape !== undefined ? !hit.row.wordsShape(args) : run.some((w) => w.raw.includes("`"))) return NOT(`\`${program}\`: arguments outside its read-only form`); if (hit.row.wordsShape === undefined && (program === "rg" || program === "grep" || program === "egrep" || program === "fgrep") && run.some((w) => /[\n\r]/.test(w.raw))) return NOT(`\`${program}\`: a line break inside an argument`); if (hit.row.dangerous?.(args) === true) return NOT(`\`${program}\`: an argument form upstream marks as writing, executing or resolving`); return READ_ONLY; } if (program === "uniq" && words.slice(1).every((w, i, all) => /^-[a-zA-Z]+$/.test(w) || /^--[a-zA-Z-]+(=\S+)?$/.test(w) || (i > 0 && /^-[fsw]$/.test(all[i - 1]) && /^\d+$/.test(w)))) return gitOptionGuard(words); if (program === "jq" && jqReading(run.slice(run.length - words.length + 1))) return gitOptionGuard(words); return NOT(`\`${program}\` is not a read-only program (or not in a read-only form)`); } function gitOptionGuard(words) { if (words.includes("git") && words.some((w) => /^-c(=|$)|^--exec-path(=|$)|^--config-env(=|$)/.test(w))) return NOT("a git configuration override on the line"); return READ_ONLY; } function jqReading(args) { if (args.some((w) => JQ_DANGER_FLAG.test(w.raw) || JQ_DANGER_TEXT.test(w.raw))) return false; let i = 0; while (i < args.length && (/^-[a-zA-Z]+$/.test(args[i].raw) || /^--[a-zA-Z-]+(=\S+)?$/.test(args[i].raw))) i++; if (i >= args.length) return false; for (; i < args.length; i++) { const w = args[i]; const quoted = /^'[^'`]*'$|^"[^"`]*"$/.test(w.raw); if (!quoted && (/^[-'"\s]/.test(w.raw) || /['"]/.test(w.raw))) return false; } return true; } function flagWalk(args, row, program) { let i = 0; while (i < args.length) { let word = args[i]; if (word === "") { i++; continue; } if (program === "xargs" && (!word.startsWith("-") || word === "--")) { if (word === "--" && i + 1 < args.length) { i++; word = args[i]; } return XARGS_TARGETS.includes(word); } if (word === "--") { if (row.respectsDoubleDash !== false) break; i++; continue; } if (FLAG_WORD.test(word)) { const eq = word.indexOf("="); const flag = eq === -1 ? word : word.slice(0, eq); const attached = eq === -1 ? undefined : word.slice(eq + 1); const arity = row.safeFlags[flag]; if (arity === undefined) { if (program === "git" && /^-\d+$/.test(flag)) { i++; continue; } if ((program === "grep" || program === "egrep" || program === "fgrep" || program === "rg") && !flag.startsWith("--") && flag.length > 2) { const base = flag.slice(0, 2); const rest = flag.slice(2); const baseArity = row.safeFlags[base]; if (baseArity !== undefined && /^\d+$/.test(rest) && (baseArity === "number" || baseArity === "string")) { if (FLAG_VALUE_ACCEPTS[baseArity](rest)) { i++; continue; } return false; } } if (!flag.startsWith("--") && flag.length > 2) { for (let c = 1; c < flag.length; c++) if (row.safeFlags[`-${flag[c]}`] !== "none") return false; i++; continue; } return false; } if (arity === "none") { if (attached !== undefined) return false; i++; continue; } let value; if (attached !== undefined) { value = attached; i++; } else { const next = args[i + 1]; if (next === undefined || FLAG_WORD.test(next)) return false; value = next; i += 2; } if (arity === "string" && value.startsWith("-") && !(flag === "--sort" && program === "git" && /^-[a-zA-Z]/.test(value))) return false; if (!FLAG_VALUE_ACCEPTS[arity](value)) return false; continue; } i++; } return true; }