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%
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