SPB Git

spb/khaelor Public

KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.

TypeScript 82.9% HTML 14.9% CSS 1.1% JavaScript 0.7%
19.5 KB · 627 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/permissions/bash-analysis.ts4 * Description: V1 conservative shell-word analysis — lexer, simple/compound/obfuscated classification, arity suggestions, hardline floor (PERMISSION_MODEL.md §3, §4.2).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { collapseWhitespace, expandHomeWord, isUnderRoot, resolveSubjectPath } from "./paths.js";11import type { PathContext } from "./paths.js";1213// ───────────────────────────── tokenization (§3.1) ─────────────────────────────1415/** Substitution markers flagged anywhere in the raw string (§3.1). */16const SUBSTITUTION_RE = /\$\(|`|<\(|>\(|\$\{/;1718/** Redirect operators — the command stays one part, but is no longer "simple" (§4.5 note). */19const REDIRECT_OPERATORS: ReadonlySet<string> = new Set([">", ">>", ">&", "<", "<<"]);2021export interface ShellLexSuccess {22  ok: true;23  /** One word list per simple command in the pipeline/chain. */24  parts: string[][];25  /** Every operator token seen, in order. */26  operators: string[];27  /** Words that are `>` / `>>` targets (excluded from `parts`). */28  redirectTargets: string[];29  hasSubstitution: boolean;30}3132export interface ShellLexFailure {33  ok: false;34  reason: string;35  hasSubstitution: boolean;36}3738export type ShellLexResult = ShellLexSuccess | ShellLexFailure;3940/**41 * Small, dependency-free shell-word lexer (§3.1): splits on whitespace,42 * respects single quotes, double quotes, and backslash escapes; recognizes43 * operator tokens; flags substitution markers. Lexer failure (unterminated44 * quote) is reported so callers can fail closed (§7 invariant 4).45 */46export function lexCommand(raw: string): ShellLexResult {47  const hasSubstitution = SUBSTITUTION_RE.test(raw);48  const parts: string[][] = [];49  const operators: string[] = [];50  const redirectTargets: string[] = [];51  let current: string[] = [];52  let word = "";53  let hasWord = false;54  /** The next completed word is a `>`/`>>` target. */55  let pendingWriteTarget = false;56  /** The next completed word is a `<`/`<<`/`>&` operand — consumed, not a command word. */57  let pendingOtherTarget = false;5859  const endWord = (): void => {60    if (!hasWord) return;61    if (pendingWriteTarget) {62      redirectTargets.push(word);63      pendingWriteTarget = false;64    } else if (pendingOtherTarget) {65      pendingOtherTarget = false;66    } else {67      current.push(word);68    }69    word = "";70    hasWord = false;71  };72  const endPart = (): void => {73    endWord();74    if (current.length > 0) {75      parts.push(current);76      current = [];77    }78  };7980  const n = raw.length;81  let i = 0;82  while (i < n) {83    const ch = raw[i] as string;84    if (ch === "'") {85      const close = raw.indexOf("'", i + 1);86      if (close < 0) return { ok: false, reason: "unterminated single quote", hasSubstitution };87      word += raw.slice(i + 1, close);88      hasWord = true;89      i = close + 1;90      continue;91    }92    if (ch === '"') {93      i += 1;94      let closed = false;95      while (i < n) {96        const c = raw[i] as string;97        if (c === "\\" && i + 1 < n) {98          word += raw[i + 1] as string;99          i += 2;100          continue;101        }102        if (c === '"') {103          closed = true;104          i += 1;105          break;106        }107        word += c;108        i += 1;109      }110      if (!closed) return { ok: false, reason: "unterminated double quote", hasSubstitution };111      hasWord = true;112      continue;113    }114    if (ch === "\\") {115      if (i + 1 < n) {116        word += raw[i + 1] as string;117        hasWord = true;118        i += 2;119      } else {120        i += 1;121      }122      continue;123    }124    if (ch === " " || ch === "\t" || ch === "\r") {125      endWord();126      i += 1;127      continue;128    }129    if (ch === "\n") {130      operators.push("\n");131      endPart();132      i += 1;133      continue;134    }135    if (ch === "&") {136      if (raw[i + 1] === "&") {137        operators.push("&&");138        i += 2;139      } else {140        operators.push("&");141        i += 1;142      }143      endPart();144      continue;145    }146    if (ch === "|") {147      if (raw[i + 1] === "|") {148        operators.push("||");149        i += 2;150      } else {151        operators.push("|");152        i += 1;153      }154      endPart();155      continue;156    }157    if (ch === ";") {158      operators.push(";");159      endPart();160      i += 1;161      continue;162    }163    if (ch === ">") {164      // An attached bare fd digit (`2>`, `1>`) is part of the redirect, not an argument.165      if (hasWord && /^[0-9]+$/.test(word)) {166        word = "";167        hasWord = false;168      }169      endWord();170      if (raw[i + 1] === ">") {171        operators.push(">>");172        pendingWriteTarget = true;173        i += 2;174      } else if (raw[i + 1] === "&") {175        operators.push(">&");176        pendingOtherTarget = true;177        i += 2;178      } else {179        operators.push(">");180        pendingWriteTarget = true;181        i += 1;182      }183      continue;184    }185    if (ch === "<") {186      endWord();187      if (raw[i + 1] === "<") {188        operators.push("<<");189        pendingOtherTarget = true;190        i += 2;191      } else {192        operators.push("<");193        pendingOtherTarget = true;194        i += 1;195      }196      continue;197    }198    word += ch;199    hasWord = true;200    i += 1;201  }202  endPart();203  return { ok: true, parts, operators, redirectTargets, hasSubstitution };204}205206// ───────────────────────────── detection tables (§3.4–3.5) ─────────────────────────────207208const NETWORK_COMMANDS: ReadonlySet<string> = new Set([209  "curl",210  "wget",211  "nc",212  "ncat",213  "netcat",214  "ssh",215  "scp",216  "sftp",217  "ftp",218  "telnet",219  "ping",220  "dig",221  "nslookup",222]);223224/** Conservative net-tool hint for obfuscated commands (§3.2). */225const NETWORK_HINT_RE =226  /\b(curl|wget|nc|ncat|netcat|ssh|scp|sftp|ftp|telnet|ping|dig|nslookup|rsync)\b/;227228const GIT_MODIFY_SUBCOMMANDS: ReadonlySet<string> = new Set([229  "commit",230  "push",231  "reset",232  "rebase",233  "merge",234  "revert",235  "cherry-pick",236  "checkout",237  "switch",238  "restore",239  "clean",240  "stash",241  "tag",242  "am",243  "apply",244  "filter-branch",245  "gc",246  "config",247  "rm",248  "mv",249]);250251const GIT_REMOTE_MODIFY: ReadonlySet<string> = new Set(["add", "remove", "rm", "rename", "set-url"]);252const GIT_REFLOG_MODIFY: ReadonlySet<string> = new Set(["delete", "expire"]);253254const FS_WRITE_VERBS: ReadonlySet<string> = new Set([255  "rm",256  "cp",257  "mv",258  "mkdir",259  "rmdir",260  "touch",261  "chmod",262  "chown",263  "ln",264  "dd",265  "tee",266  "truncate",267  "install",268]);269270const SHELL_WRAPPERS: ReadonlySet<string> = new Set(["sh", "bash", "zsh", "dash", "ksh"]);271272export const OBFUSCATION_RISK_NOTE =273  "Command uses substitution — KHAELOR cannot verify what it will run.";274275export const COMPOUND_RISK_NOTE =276  'Compound command — each part is checked individually; it cannot be saved as an "always allow" rule.';277278/** Last path segment: `/usr/bin/curl` → `curl`. */279function basenameOf(word: string): string {280  const idx = word.lastIndexOf("/");281  return idx >= 0 ? word.slice(idx + 1) : word;282}283284function isRemoteRsyncArg(arg: string): boolean {285  // host:path or user@host:path — a colon before any slash.286  return !arg.startsWith("-") && /^(?:[^\s/@]+@)?[^\s/:]+:/.test(arg);287}288289function isGitModify(words: string[]): boolean {290  if (basenameOf(words[0] ?? "") !== "git") return false;291  const sub = words[1];292  if (sub === undefined) return false;293  if (GIT_MODIFY_SUBCOMMANDS.has(sub)) return true;294  if (sub === "branch") {295    return words.slice(2).some((a) => /^-(d|D|m|M)$/.test(a) || a === "--delete" || a === "--move");296  }297  if (sub === "remote") return GIT_REMOTE_MODIFY.has(words[2] ?? "");298  if (sub === "reflog") return GIT_REFLOG_MODIFY.has(words[2] ?? "");299  return false;300}301302function detectOutsideWrites(words: string[], ctx: PathContext): string[] {303  const outside: string[] = [];304  for (const arg of words.slice(1)) {305    if (arg.startsWith("-")) continue;306    let candidate = arg;307    if (arg.includes("=")) {308      if (arg.startsWith("of=")) candidate = arg.slice(3);309      else continue; // key=value that is not a dd output target — skip (best-effort, §3.5)310    }311    const resolved = resolveSubjectPath(candidate, ctx);312    if (!isUnderRoot(resolved, ctx.projectRoot)) outside.push(resolved);313  }314  return [...new Set(outside)];315}316317/** Quoted operator smuggling: sh -c / bash -c / eval / xargs with an argument that itself lexes into operators (§3.2). */318function hasOperatorSmuggling(words: string[]): boolean {319  const w0 = basenameOf(words[0] ?? "");320  let argsToCheck: string[] = [];321  if (SHELL_WRAPPERS.has(w0)) {322    const cIdx = words.findIndex((a, idx) => idx > 0 && /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a));323    if (cIdx < 0) return false;324    argsToCheck = words.slice(cIdx + 1);325  } else if (w0 === "eval" || w0 === "xargs") {326    argsToCheck = words.slice(1);327  } else {328    return false;329  }330  return argsToCheck.some((arg) => {331    const inner = lexCommand(arg);332    if (!inner.ok) return true;333    return inner.hasSubstitution || inner.operators.length > 0 || inner.parts.length > 1;334  });335}336337// ───────────────────────── arity suggestions (§3.3) ─────────────────────────338339/** Prefix word-counts per tool (PERMISSION_MODEL.md §3.3, verbatim). */340export const ARITY: Readonly<Record<string, number | Record<string, number>>> = {341  git: { "*": 2, config: 3, remote: 3, stash: 3, submodule: 3 },342  npm: { "*": 2, run: 3, exec: 3 },343  pnpm: { "*": 2, run: 3 },344  yarn: { "*": 2, run: 3 },345  npx: 2,346  node: 2,347  python: 2,348  python3: 2,349  pip: 2,350  pip3: 2,351  cargo: 2,352  go: 2,353  make: 2,354  docker: { "*": 2, compose: 3 },355  kubectl: 2,356  gh: 3,357  brew: 2,358  ls: 1,359  cat: 1,360  mkdir: 1,361  touch: 1,362};363364/**365 * Generate candidate "always allow" patterns for a SIMPLE command, most366 * specific first (§3.3). Callers enforce the refusal rule: never call this367 * for compound/obfuscated commands.368 */369export function suggestAlwaysPatterns(words: string[], ctx: PathContext): string[] {370  const w0 = words[0];371  if (w0 === undefined) return [];372  const prefixPattern = (n: number): string => {373    const take = Math.min(n, words.length);374    const prefix = words.slice(0, take).join(" ");375    return words.length > take ? `${prefix} *` : prefix;376  };377  const patterns: string[] = [];378  const entry = ARITY[w0];379  if (typeof entry === "number") {380    patterns.push(prefixPattern(entry));381  } else if (entry !== undefined) {382    const sub = words[1] !== undefined ? entry[words[1]] : undefined;383    const base = entry["*"];384    if (sub !== undefined) patterns.push(prefixPattern(sub));385    if (base !== undefined) patterns.push(prefixPattern(base));386  } else if (w0.includes("/")) {387    // Unknown path-like command: generalize only when it resolves inside the project.388    const resolved = resolveSubjectPath(w0, ctx);389    if (isUnderRoot(resolved, ctx.projectRoot)) patterns.push(prefixPattern(1));390    else patterns.push(collapseWhitespace(words.join(" ")));391  } else {392    // Unknown bare command: exact command only (never over-generalize, §3).393    patterns.push(collapseWhitespace(words.join(" ")));394  }395  return [...new Set(patterns)];396}397398// ───────────────────────── whole-command analysis (§3.2) ─────────────────────────399400export type BashClassification = "simple" | "compound" | "obfuscated";401402export interface BashPartAnalysis {403  /** Collapsed text of this simple command part — rule-matching subject. */404  text: string;405  words: string[];406  network: boolean;407  gitModify: boolean;408  /** Resolved outside-project paths written by filesystem verbs (§3.5). */409  outsideWrites: string[];410}411412export interface BashAnalysis {413  classification: BashClassification;414  /** Whitespace-collapsed full command text — the primary subject. */415  collapsed: string;416  /** Per-part analysis. Empty for obfuscated commands (analysis unreliable, §3.2). */417  parts: BashPartAnalysis[];418  operators: string[];419  hasRedirect: boolean;420  /** Resolved outside-project `>`/`>>` targets. */421  outsideRedirectWrites: string[];422  hasSubstitution: boolean;423  /** Obfuscated only: a net-tool name appears somewhere in the raw text. */424  rawNetworkHint: boolean;425  /** Empty for compound/obfuscated — the refusal rule (§3.3). */426  alwaysPatterns: string[];427  riskNotes: string[];428}429430export function analyzeBashCommand(command: string, ctx: PathContext): BashAnalysis {431  const collapsed = collapseWhitespace(command);432  const lex = lexCommand(command);433434  const obfuscated = (note: string, operators: string[]): BashAnalysis => ({435    classification: "obfuscated",436    collapsed,437    parts: [],438    operators,439    hasRedirect: false,440    outsideRedirectWrites: [],441    hasSubstitution: lex.hasSubstitution,442    rawNetworkHint: NETWORK_HINT_RE.test(command),443    alwaysPatterns: [],444    riskNotes: [note],445  });446447  if (!lex.ok) {448    return obfuscated(449      `Command could not be parsed (${lex.reason}) — KHAELOR cannot verify what it will run.`,450      [],451    );452  }453  if (lex.hasSubstitution) return obfuscated(OBFUSCATION_RISK_NOTE, lex.operators);454  if (lex.parts.length === 0) {455    return obfuscated("Empty command — nothing to analyze.", lex.operators);456  }457  if (lex.parts.some((p) => hasOperatorSmuggling(p))) {458    return obfuscated(459      "Command passes shell operators through a string argument — KHAELOR cannot verify what it will run.",460      lex.operators,461    );462  }463464  const parts: BashPartAnalysis[] = lex.parts.map((words) => {465    const w0 = basenameOf(words[0] ?? "");466    const network =467      NETWORK_COMMANDS.has(w0) || (w0 === "rsync" && words.slice(1).some(isRemoteRsyncArg));468    return {469      text: collapseWhitespace(words.join(" ")),470      words,471      network,472      gitModify: isGitModify(words),473      outsideWrites: FS_WRITE_VERBS.has(w0) ? detectOutsideWrites(words, ctx) : [],474    };475  });476  const hasRedirect = lex.operators.some((op) => REDIRECT_OPERATORS.has(op));477  const outsideRedirectWrites = [478    ...new Set(479      lex.redirectTargets480        .map((t) => resolveSubjectPath(t, ctx))481        .filter((p) => !isUnderRoot(p, ctx.projectRoot)),482    ),483  ];484  const isCompound = lex.operators.length > 0 || lex.parts.length > 1;485486  const riskNotes: string[] = [];487  if (isCompound) riskNotes.push(COMPOUND_RISK_NOTE);488  for (const part of parts) {489    for (const p of part.outsideWrites) riskNotes.push(`Writes outside the project: ${p}`);490  }491  for (const p of outsideRedirectWrites) riskNotes.push(`Redirects output outside the project: ${p}`);492493  return {494    classification: isCompound ? "compound" : "simple",495    collapsed,496    parts,497    operators: lex.operators,498    hasRedirect,499    outsideRedirectWrites,500    hasSubstitution: false,501    rawNetworkHint: false,502    alwaysPatterns: isCompound ? [] : suggestAlwaysPatterns(parts[0]?.words ?? [], ctx),503    riskNotes,504  };505}506507// ───────────────────────── hardline deny floor (§4.2) ─────────────────────────508509export const HARDLINE_FLOOR_NOTE =510  "Blocked by KHAELOR's built-in safety floor — this cannot be allowed by configuration.";511512/** De-obfuscated rendering: quotes stripped, whitespace collapsed, `~`/`$HOME` expanded (§4.2). */513export interface DeobfuscatedCommand {514  parts: string[][];515  redirectTargets: string[];516  /** All whitespace removed — fork-bomb shape check. */517  squashed: string;518}519520export function deobfuscateCommand(command: string, home: string): DeobfuscatedCommand {521  const expand = (w: string): string => expandHomeWord(w, home);522  const lex = lexCommand(command);523  if (lex.ok) {524    return {525      parts: lex.parts.map((p) => p.map(expand)),526      redirectTargets: lex.redirectTargets.map(expand),527      squashed: command.replace(/["']/g, "").replace(/\s+/g, ""),528    };529  }530  // Lexer failure: naive quote-stripped fallback — still checked, fail closed.531  const stripped = command.replace(/["']/g, "");532  const redirectTargets: string[] = [];533  for (const m of stripped.matchAll(/>>?\s*(\S+)/g)) redirectTargets.push(expand(m[1] as string));534  const parts = stripped535    .split(/&&|\|\||;|\||&|\n/)536    .map((segment) =>537      segment538        .replace(/>>?\s*\S+/g, " ")539        .trim()540        .split(/\s+/)541        .filter((w) => w.length > 0)542        .map(expand),543    )544    .filter((p) => p.length > 0);545  return { parts, redirectTargets, squashed: stripped.replace(/\s+/g, "") };546}547548const PRIVILEGE_WRAPPERS: ReadonlySet<string> = new Set(["sudo", "doas", "env", "nohup", "nice"]);549550function stripPrivilegeWrappers(words: string[]): string[] {551  let rest = words;552  while (rest.length > 0 && PRIVILEGE_WRAPPERS.has(basenameOf(rest[0] as string))) {553    rest = rest.slice(1);554    while (rest.length > 0) {555      const w = rest[0] as string;556      if (w.startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) rest = rest.slice(1);557      else break;558    }559  }560  return rest;561}562563function checkHardlinePart(rawWords: string[], home: string): string | null {564  const words = stripPrivilegeWrappers(rawWords);565  const first = words[0];566  if (first === undefined) return null;567  const w0 = basenameOf(first);568  if (w0.startsWith("mkfs")) return "mkfs*";569  if (w0 === "shutdown" || w0 === "reboot" || w0 === "halt") return `${w0}*`;570  if (w0 === "rm") {571    const shortFlags = words572      .slice(1)573      .filter((a) => a.startsWith("-") && !a.startsWith("--"))574      .map((a) => a.slice(1))575      .join("");576    const recursive = /[rR]/.test(shortFlags) || words.includes("--recursive");577    const force = shortFlags.includes("f") || words.includes("--force");578    if (recursive && force) {579      for (const target of words.slice(1).filter((a) => !a.startsWith("-"))) {580        if (target === "/" || target === "/*") return "rm -rf /";581        if (target === "~" || target === "$HOME") return "rm -rf ~";582        if (home.length > 0 && (target === home || target === `${home}/` || target === `${home}/*`)) {583          return "rm -rf ~";584        }585      }586    }587    return null;588  }589  if (w0 === "dd" && words.some((a) => /^of=\/dev\//.test(a))) return "dd * of=/dev/*";590  if (w0 === "chmod") {591    const recursive = words.slice(1).some((a) => /^-[a-zA-Z]*R/.test(a) || a === "--recursive");592    if (recursive && words.includes("777") && words.includes("/")) return "chmod -R 777 /";593    return null;594  }595  if (w0 === "chown") {596    const recursive = words.slice(1).some((a) => /^-[a-zA-Z]*R/.test(a) || a === "--recursive");597    const targets = words.slice(1).filter((a) => !a.startsWith("-"));598    if (recursive && targets.includes("/")) return "chown -R * /";599    return null;600  }601  if (w0 === "git" && words[1] === "push") {602    const force = words.includes("--force") || words.includes("-f");603    const toMain = words.some((a) => a === "main" || a === "master" || /:(main|master)$/.test(a));604    if (force && toMain) return "git push * --force * (main/master)";605    return null;606  }607  return null;608}609610/**611 * The built-in, non-configurable deny floor. Returns the matched hardline612 * shape, or null. Checked BEFORE any rule; no layer can override it (§4.2).613 */614export function hardlineMatch(subjectCommand: string, home?: string): string | null {615  const h = home ?? process.env["HOME"] ?? "";616  const d = deobfuscateCommand(subjectCommand, h);617  if (d.squashed.includes(":(){:|:&};:")) return "fork bomb";618  for (const target of d.redirectTargets) {619    if (target.startsWith("/dev/sd")) return "> /dev/sd*";620  }621  for (const part of d.parts) {622    const hit = checkHardlinePart(part, h);623    if (hit !== null) return hit;624  }625  return null;626}627