/** * KHAELOR * File: src/permissions/bash-analysis.ts * Description: V1 conservative shell-word analysis — lexer, simple/compound/obfuscated classification, arity suggestions, hardline floor (PERMISSION_MODEL.md §3, §4.2). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { collapseWhitespace, expandHomeWord, isUnderRoot, resolveSubjectPath } from "./paths.js"; import type { PathContext } from "./paths.js"; // ───────────────────────────── tokenization (§3.1) ───────────────────────────── /** Substitution markers flagged anywhere in the raw string (§3.1). */ const SUBSTITUTION_RE = /\$\(|`|<\(|>\(|\$\{/; /** Redirect operators — the command stays one part, but is no longer "simple" (§4.5 note). */ const REDIRECT_OPERATORS: ReadonlySet = new Set([">", ">>", ">&", "<", "<<"]); export interface ShellLexSuccess { ok: true; /** One word list per simple command in the pipeline/chain. */ parts: string[][]; /** Every operator token seen, in order. */ operators: string[]; /** Words that are `>` / `>>` targets (excluded from `parts`). */ redirectTargets: string[]; hasSubstitution: boolean; } export interface ShellLexFailure { ok: false; reason: string; hasSubstitution: boolean; } export type ShellLexResult = ShellLexSuccess | ShellLexFailure; /** * Small, dependency-free shell-word lexer (§3.1): splits on whitespace, * respects single quotes, double quotes, and backslash escapes; recognizes * operator tokens; flags substitution markers. Lexer failure (unterminated * quote) is reported so callers can fail closed (§7 invariant 4). */ export function lexCommand(raw: string): ShellLexResult { const hasSubstitution = SUBSTITUTION_RE.test(raw); const parts: string[][] = []; const operators: string[] = []; const redirectTargets: string[] = []; let current: string[] = []; let word = ""; let hasWord = false; /** The next completed word is a `>`/`>>` target. */ let pendingWriteTarget = false; /** The next completed word is a `<`/`<<`/`>&` operand — consumed, not a command word. */ let pendingOtherTarget = false; const endWord = (): void => { if (!hasWord) return; if (pendingWriteTarget) { redirectTargets.push(word); pendingWriteTarget = false; } else if (pendingOtherTarget) { pendingOtherTarget = false; } else { current.push(word); } word = ""; hasWord = false; }; const endPart = (): void => { endWord(); if (current.length > 0) { parts.push(current); current = []; } }; const n = raw.length; let i = 0; while (i < n) { const ch = raw[i] as string; if (ch === "'") { const close = raw.indexOf("'", i + 1); if (close < 0) return { ok: false, reason: "unterminated single quote", hasSubstitution }; word += raw.slice(i + 1, close); hasWord = true; i = close + 1; continue; } if (ch === '"') { i += 1; let closed = false; while (i < n) { const c = raw[i] as string; if (c === "\\" && i + 1 < n) { word += raw[i + 1] as string; i += 2; continue; } if (c === '"') { closed = true; i += 1; break; } word += c; i += 1; } if (!closed) return { ok: false, reason: "unterminated double quote", hasSubstitution }; hasWord = true; continue; } if (ch === "\\") { if (i + 1 < n) { word += raw[i + 1] as string; hasWord = true; i += 2; } else { i += 1; } continue; } if (ch === " " || ch === "\t" || ch === "\r") { endWord(); i += 1; continue; } if (ch === "\n") { operators.push("\n"); endPart(); i += 1; continue; } if (ch === "&") { if (raw[i + 1] === "&") { operators.push("&&"); i += 2; } else { operators.push("&"); i += 1; } endPart(); continue; } if (ch === "|") { if (raw[i + 1] === "|") { operators.push("||"); i += 2; } else { operators.push("|"); i += 1; } endPart(); continue; } if (ch === ";") { operators.push(";"); endPart(); i += 1; continue; } if (ch === ">") { // An attached bare fd digit (`2>`, `1>`) is part of the redirect, not an argument. if (hasWord && /^[0-9]+$/.test(word)) { word = ""; hasWord = false; } endWord(); if (raw[i + 1] === ">") { operators.push(">>"); pendingWriteTarget = true; i += 2; } else if (raw[i + 1] === "&") { operators.push(">&"); pendingOtherTarget = true; i += 2; } else { operators.push(">"); pendingWriteTarget = true; i += 1; } continue; } if (ch === "<") { endWord(); if (raw[i + 1] === "<") { operators.push("<<"); pendingOtherTarget = true; i += 2; } else { operators.push("<"); pendingOtherTarget = true; i += 1; } continue; } word += ch; hasWord = true; i += 1; } endPart(); return { ok: true, parts, operators, redirectTargets, hasSubstitution }; } // ───────────────────────────── detection tables (§3.4–3.5) ───────────────────────────── const NETWORK_COMMANDS: ReadonlySet = new Set([ "curl", "wget", "nc", "ncat", "netcat", "ssh", "scp", "sftp", "ftp", "telnet", "ping", "dig", "nslookup", ]); /** Conservative net-tool hint for obfuscated commands (§3.2). */ const NETWORK_HINT_RE = /\b(curl|wget|nc|ncat|netcat|ssh|scp|sftp|ftp|telnet|ping|dig|nslookup|rsync)\b/; const GIT_MODIFY_SUBCOMMANDS: ReadonlySet = new Set([ "commit", "push", "reset", "rebase", "merge", "revert", "cherry-pick", "checkout", "switch", "restore", "clean", "stash", "tag", "am", "apply", "filter-branch", "gc", "config", "rm", "mv", ]); const GIT_REMOTE_MODIFY: ReadonlySet = new Set(["add", "remove", "rm", "rename", "set-url"]); const GIT_REFLOG_MODIFY: ReadonlySet = new Set(["delete", "expire"]); const FS_WRITE_VERBS: ReadonlySet = new Set([ "rm", "cp", "mv", "mkdir", "rmdir", "touch", "chmod", "chown", "ln", "dd", "tee", "truncate", "install", ]); const SHELL_WRAPPERS: ReadonlySet = new Set(["sh", "bash", "zsh", "dash", "ksh"]); export const OBFUSCATION_RISK_NOTE = "Command uses substitution — KHAELOR cannot verify what it will run."; export const COMPOUND_RISK_NOTE = 'Compound command — each part is checked individually; it cannot be saved as an "always allow" rule.'; /** Last path segment: `/usr/bin/curl` → `curl`. */ function basenameOf(word: string): string { const idx = word.lastIndexOf("/"); return idx >= 0 ? word.slice(idx + 1) : word; } function isRemoteRsyncArg(arg: string): boolean { // host:path or user@host:path — a colon before any slash. return !arg.startsWith("-") && /^(?:[^\s/@]+@)?[^\s/:]+:/.test(arg); } function isGitModify(words: string[]): boolean { if (basenameOf(words[0] ?? "") !== "git") return false; const sub = words[1]; if (sub === undefined) return false; if (GIT_MODIFY_SUBCOMMANDS.has(sub)) return true; if (sub === "branch") { return words.slice(2).some((a) => /^-(d|D|m|M)$/.test(a) || a === "--delete" || a === "--move"); } if (sub === "remote") return GIT_REMOTE_MODIFY.has(words[2] ?? ""); if (sub === "reflog") return GIT_REFLOG_MODIFY.has(words[2] ?? ""); return false; } function detectOutsideWrites(words: string[], ctx: PathContext): string[] { const outside: string[] = []; for (const arg of words.slice(1)) { if (arg.startsWith("-")) continue; let candidate = arg; if (arg.includes("=")) { if (arg.startsWith("of=")) candidate = arg.slice(3); else continue; // key=value that is not a dd output target — skip (best-effort, §3.5) } const resolved = resolveSubjectPath(candidate, ctx); if (!isUnderRoot(resolved, ctx.projectRoot)) outside.push(resolved); } return [...new Set(outside)]; } /** Quoted operator smuggling: sh -c / bash -c / eval / xargs with an argument that itself lexes into operators (§3.2). */ function hasOperatorSmuggling(words: string[]): boolean { const w0 = basenameOf(words[0] ?? ""); let argsToCheck: string[] = []; if (SHELL_WRAPPERS.has(w0)) { const cIdx = words.findIndex((a, idx) => idx > 0 && /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a)); if (cIdx < 0) return false; argsToCheck = words.slice(cIdx + 1); } else if (w0 === "eval" || w0 === "xargs") { argsToCheck = words.slice(1); } else { return false; } return argsToCheck.some((arg) => { const inner = lexCommand(arg); if (!inner.ok) return true; return inner.hasSubstitution || inner.operators.length > 0 || inner.parts.length > 1; }); } // ───────────────────────── arity suggestions (§3.3) ───────────────────────── /** Prefix word-counts per tool (PERMISSION_MODEL.md §3.3, verbatim). */ export const ARITY: Readonly>> = { git: { "*": 2, config: 3, remote: 3, stash: 3, submodule: 3 }, npm: { "*": 2, run: 3, exec: 3 }, pnpm: { "*": 2, run: 3 }, yarn: { "*": 2, run: 3 }, npx: 2, node: 2, python: 2, python3: 2, pip: 2, pip3: 2, cargo: 2, go: 2, make: 2, docker: { "*": 2, compose: 3 }, kubectl: 2, gh: 3, brew: 2, ls: 1, cat: 1, mkdir: 1, touch: 1, }; /** * Generate candidate "always allow" patterns for a SIMPLE command, most * specific first (§3.3). Callers enforce the refusal rule: never call this * for compound/obfuscated commands. */ export function suggestAlwaysPatterns(words: string[], ctx: PathContext): string[] { const w0 = words[0]; if (w0 === undefined) return []; const prefixPattern = (n: number): string => { const take = Math.min(n, words.length); const prefix = words.slice(0, take).join(" "); return words.length > take ? `${prefix} *` : prefix; }; const patterns: string[] = []; const entry = ARITY[w0]; if (typeof entry === "number") { patterns.push(prefixPattern(entry)); } else if (entry !== undefined) { const sub = words[1] !== undefined ? entry[words[1]] : undefined; const base = entry["*"]; if (sub !== undefined) patterns.push(prefixPattern(sub)); if (base !== undefined) patterns.push(prefixPattern(base)); } else if (w0.includes("/")) { // Unknown path-like command: generalize only when it resolves inside the project. const resolved = resolveSubjectPath(w0, ctx); if (isUnderRoot(resolved, ctx.projectRoot)) patterns.push(prefixPattern(1)); else patterns.push(collapseWhitespace(words.join(" "))); } else { // Unknown bare command: exact command only (never over-generalize, §3). patterns.push(collapseWhitespace(words.join(" "))); } return [...new Set(patterns)]; } // ───────────────────────── whole-command analysis (§3.2) ───────────────────────── export type BashClassification = "simple" | "compound" | "obfuscated"; export interface BashPartAnalysis { /** Collapsed text of this simple command part — rule-matching subject. */ text: string; words: string[]; network: boolean; gitModify: boolean; /** Resolved outside-project paths written by filesystem verbs (§3.5). */ outsideWrites: string[]; } export interface BashAnalysis { classification: BashClassification; /** Whitespace-collapsed full command text — the primary subject. */ collapsed: string; /** Per-part analysis. Empty for obfuscated commands (analysis unreliable, §3.2). */ parts: BashPartAnalysis[]; operators: string[]; hasRedirect: boolean; /** Resolved outside-project `>`/`>>` targets. */ outsideRedirectWrites: string[]; hasSubstitution: boolean; /** Obfuscated only: a net-tool name appears somewhere in the raw text. */ rawNetworkHint: boolean; /** Empty for compound/obfuscated — the refusal rule (§3.3). */ alwaysPatterns: string[]; riskNotes: string[]; } export function analyzeBashCommand(command: string, ctx: PathContext): BashAnalysis { const collapsed = collapseWhitespace(command); const lex = lexCommand(command); const obfuscated = (note: string, operators: string[]): BashAnalysis => ({ classification: "obfuscated", collapsed, parts: [], operators, hasRedirect: false, outsideRedirectWrites: [], hasSubstitution: lex.hasSubstitution, rawNetworkHint: NETWORK_HINT_RE.test(command), alwaysPatterns: [], riskNotes: [note], }); if (!lex.ok) { return obfuscated( `Command could not be parsed (${lex.reason}) — KHAELOR cannot verify what it will run.`, [], ); } if (lex.hasSubstitution) return obfuscated(OBFUSCATION_RISK_NOTE, lex.operators); if (lex.parts.length === 0) { return obfuscated("Empty command — nothing to analyze.", lex.operators); } if (lex.parts.some((p) => hasOperatorSmuggling(p))) { return obfuscated( "Command passes shell operators through a string argument — KHAELOR cannot verify what it will run.", lex.operators, ); } const parts: BashPartAnalysis[] = lex.parts.map((words) => { const w0 = basenameOf(words[0] ?? ""); const network = NETWORK_COMMANDS.has(w0) || (w0 === "rsync" && words.slice(1).some(isRemoteRsyncArg)); return { text: collapseWhitespace(words.join(" ")), words, network, gitModify: isGitModify(words), outsideWrites: FS_WRITE_VERBS.has(w0) ? detectOutsideWrites(words, ctx) : [], }; }); const hasRedirect = lex.operators.some((op) => REDIRECT_OPERATORS.has(op)); const outsideRedirectWrites = [ ...new Set( lex.redirectTargets .map((t) => resolveSubjectPath(t, ctx)) .filter((p) => !isUnderRoot(p, ctx.projectRoot)), ), ]; const isCompound = lex.operators.length > 0 || lex.parts.length > 1; const riskNotes: string[] = []; if (isCompound) riskNotes.push(COMPOUND_RISK_NOTE); for (const part of parts) { for (const p of part.outsideWrites) riskNotes.push(`Writes outside the project: ${p}`); } for (const p of outsideRedirectWrites) riskNotes.push(`Redirects output outside the project: ${p}`); return { classification: isCompound ? "compound" : "simple", collapsed, parts, operators: lex.operators, hasRedirect, outsideRedirectWrites, hasSubstitution: false, rawNetworkHint: false, alwaysPatterns: isCompound ? [] : suggestAlwaysPatterns(parts[0]?.words ?? [], ctx), riskNotes, }; } // ───────────────────────── hardline deny floor (§4.2) ───────────────────────── export const HARDLINE_FLOOR_NOTE = "Blocked by KHAELOR's built-in safety floor — this cannot be allowed by configuration."; /** De-obfuscated rendering: quotes stripped, whitespace collapsed, `~`/`$HOME` expanded (§4.2). */ export interface DeobfuscatedCommand { parts: string[][]; redirectTargets: string[]; /** All whitespace removed — fork-bomb shape check. */ squashed: string; } export function deobfuscateCommand(command: string, home: string): DeobfuscatedCommand { const expand = (w: string): string => expandHomeWord(w, home); const lex = lexCommand(command); if (lex.ok) { return { parts: lex.parts.map((p) => p.map(expand)), redirectTargets: lex.redirectTargets.map(expand), squashed: command.replace(/["']/g, "").replace(/\s+/g, ""), }; } // Lexer failure: naive quote-stripped fallback — still checked, fail closed. const stripped = command.replace(/["']/g, ""); const redirectTargets: string[] = []; for (const m of stripped.matchAll(/>>?\s*(\S+)/g)) redirectTargets.push(expand(m[1] as string)); const parts = stripped .split(/&&|\|\||;|\||&|\n/) .map((segment) => segment .replace(/>>?\s*\S+/g, " ") .trim() .split(/\s+/) .filter((w) => w.length > 0) .map(expand), ) .filter((p) => p.length > 0); return { parts, redirectTargets, squashed: stripped.replace(/\s+/g, "") }; } const PRIVILEGE_WRAPPERS: ReadonlySet = new Set(["sudo", "doas", "env", "nohup", "nice"]); function stripPrivilegeWrappers(words: string[]): string[] { let rest = words; while (rest.length > 0 && PRIVILEGE_WRAPPERS.has(basenameOf(rest[0] as string))) { rest = rest.slice(1); while (rest.length > 0) { const w = rest[0] as string; if (w.startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) rest = rest.slice(1); else break; } } return rest; } function checkHardlinePart(rawWords: string[], home: string): string | null { const words = stripPrivilegeWrappers(rawWords); const first = words[0]; if (first === undefined) return null; const w0 = basenameOf(first); if (w0.startsWith("mkfs")) return "mkfs*"; if (w0 === "shutdown" || w0 === "reboot" || w0 === "halt") return `${w0}*`; if (w0 === "rm") { const shortFlags = words .slice(1) .filter((a) => a.startsWith("-") && !a.startsWith("--")) .map((a) => a.slice(1)) .join(""); const recursive = /[rR]/.test(shortFlags) || words.includes("--recursive"); const force = shortFlags.includes("f") || words.includes("--force"); if (recursive && force) { for (const target of words.slice(1).filter((a) => !a.startsWith("-"))) { if (target === "/" || target === "/*") return "rm -rf /"; if (target === "~" || target === "$HOME") return "rm -rf ~"; if (home.length > 0 && (target === home || target === `${home}/` || target === `${home}/*`)) { return "rm -rf ~"; } } } return null; } if (w0 === "dd" && words.some((a) => /^of=\/dev\//.test(a))) return "dd * of=/dev/*"; if (w0 === "chmod") { const recursive = words.slice(1).some((a) => /^-[a-zA-Z]*R/.test(a) || a === "--recursive"); if (recursive && words.includes("777") && words.includes("/")) return "chmod -R 777 /"; return null; } if (w0 === "chown") { const recursive = words.slice(1).some((a) => /^-[a-zA-Z]*R/.test(a) || a === "--recursive"); const targets = words.slice(1).filter((a) => !a.startsWith("-")); if (recursive && targets.includes("/")) return "chown -R * /"; return null; } if (w0 === "git" && words[1] === "push") { const force = words.includes("--force") || words.includes("-f"); const toMain = words.some((a) => a === "main" || a === "master" || /:(main|master)$/.test(a)); if (force && toMain) return "git push * --force * (main/master)"; return null; } return null; } /** * The built-in, non-configurable deny floor. Returns the matched hardline * shape, or null. Checked BEFORE any rule; no layer can override it (§4.2). */ export function hardlineMatch(subjectCommand: string, home?: string): string | null { const h = home ?? process.env["HOME"] ?? ""; const d = deobfuscateCommand(subjectCommand, h); if (d.squashed.includes(":(){:|:&};:")) return "fork bomb"; for (const target of d.redirectTargets) { if (target.startsWith("/dev/sd")) return "> /dev/sd*"; } for (const part of d.parts) { const hit = checkHardlinePart(part, h); if (hit !== null) return hit; } return null; }