@sema-agent/core
Version:
Stateless, task-oriented AI agent core
358 lines (357 loc) • 16.3 kB
JavaScript
import { posix } from "node:path";
import { readShellCommand } from "./shell-lexer.js";
import { descriptorsOfPlan, newLineDescriptors, standardDeviceUnrebound } from "../tools/fs/shell-read-device.js";
import { planShellReads } from "../tools/fs/shell-read-plan.js";
import { writesOnlyStandardDevices } from "../tools/fs/shell-read-write-targets.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, bound) {
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);
const descriptors = newLineDescriptors();
descriptors.unknown = shape.segments.reduce((n, s) => n + s.redirections.length, 0) > 1;
let boundOfText;
const bindings = () => (boundOfText ??= (typeof bound === "function" ? bound() : bound) ?? lineDescriptorsOfText(command));
for (const seg of shape.segments) {
const verdict = segmentVerdict(seg, descriptors, bindings);
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, line, bindings) {
for (const r of seg.redirections) {
const target = r.target.text;
if (!READING_REDIRECTS.has(r.op) && !standardDeviceUnrebound(target, line, []) && !(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`);
if (DEVICE_WRITERS.has(program) && writesOnlyStandardDevices(program, words.slice(1), bindings()))
return READ_ONLY;
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);
}
export function lineDescriptorsOfText(command) {
return descriptorsOfPlan(planShellReads(command, { readers: new Set(), cwd: "/", roots: [] }));
}
const DEVICE_WRITERS = new Set(["tee", "cp"]);
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 === "exec")
return words.length === 1;
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;
}