import { homedir } from "node:os"; /* FNXC:BashContainment 2026-07-26-12:40: Security incident follow-up: an agent's real escalation chain was `bash("cat ~/.fusion/settings.json")` (agents run as the same OS user, so the 0600 file holding `daemonToken` is readable) → find its own pending approval request → `curl` the approvals decision API to approve itself. Fixing the decision route alone is insufficient while bash can read the token. This module is a PRIVILEGE-ESCALATION FLOOR, not a permission preference: it holds UNCONDITIONALLY at every permission preset including the default `unrestricted`. Ordinary bash permission gating stays policy-driven (see agent-action-gate.ts); this floor only denies the narrow set of commands that let an agent escalate its OWN privileges: 1. reads of the Fusion global config / daemon token (~/.fusion, any .fusion/settings.json spelling, FUSION_DAEMON_TOKEN / daemonToken refs), 2. reads of common same-user credential stores (~/.ssh, ~/.aws, ~/.netrc, ~/.npmrc, ~/.config/gh, ~/.docker/config.json, ~/.gnupg, ~/.kube), 3. self-directed calls to the approvals API (/api/approvals, fn_token=). HONESTY / LIMITATIONS (do not oversell this): matching happens on a normalized copy of the command STRING (quotes/backslashes stripped, $HOME and the literal home directory folded to ~, lowercased). That defeats the trivial bypasses (`cat ~/.fus''ion/settings.json`, `"$HOME"/.fusion`, absolute home paths) but NOT determined indirection: base64-built paths, variable concatenation (a=fus; b=ion; cat ~/.$a$b/...), glob spelling (~/.f*sion), symlinks created first, python/node one-liners, or reading via a spawned interpreter. String inspection of shell cannot be sound. This floor raises the cost of the KNOWN escalation chain and creates an audit-visible denial; real containment requires OS-level isolation (separate user / sandbox), which is out of scope here. Deny messages are deliberately explicit so a probing agent's attempts are visible in session logs. */ export interface BashContainmentVerdict { allowed: boolean; /** Stable rule id for logs/tests when denied. */ rule?: string; /** Human-readable denial reason (safe to surface to the agent). */ reason?: string; } interface ContainmentRule { id: string; pattern: RegExp; reason: string; } /* FNXC:BashContainment 2026-07-26-12:40: Rules match the NORMALIZED command (see normalizeCommand). Home-anchored patterns use `~/.` because normalization folds $HOME/${HOME}/absolute home spellings to `~`. `/users//` and `/home//` cover OTHER users' homes which normalization cannot fold. */ const RULES: readonly ContainmentRule[] = [ { id: "fusion-global-dir", pattern: /(?:~|\/users\/[^/\s]+|\/home\/[^/\s]+)\/\.fusion\b/, reason: "access to the global Fusion directory (daemon token / global settings) is not permitted from agent sessions", }, { id: "fusion-settings-file", pattern: /\.fusion\/settings\.json/, reason: "access to Fusion settings.json is not permitted from agent sessions", }, { id: "fusion-daemon-token", pattern: /fusion_daemon_token|fusion_dashboard_token|daemontoken/, reason: "referencing the Fusion daemon token is not permitted from agent sessions", }, { id: "credential-store", pattern: /(?:~|\/users\/[^/\s]+|\/home\/[^/\s]+)\/(?:\.ssh|\.aws|\.netrc|\.npmrc|\.gnupg|\.kube|\.config\/gh|\.docker\/config\.json)\b/, reason: "access to user credential stores is not permitted from agent sessions", }, { id: "approvals-api", pattern: /\/api\/approvals|fn_token=/, reason: "calling the Fusion approvals API from a shell is not permitted from agent sessions (approvals are decided by the operator)", }, ]; function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } const HOME_DIR = homedir(); const HOME_PATTERN = new RegExp(escapeRegExp(HOME_DIR), "gi"); /** * FNXC:BashContainment 2026-07-26-12:40: * Normalization defeats quote-splitting and $HOME spellings only. Keep this * pure and dependency-free so it is trivially unit-testable. */ export function normalizeBashCommandForContainment(command: string): string { let normalized = command.replace(/["'\\]/g, ""); normalized = normalized.replace(/\$\{home\}/gi, "~").replace(/\$home\b/gi, "~"); if (HOME_DIR && HOME_DIR !== "/") { normalized = normalized.replace(HOME_PATTERN, "~"); } return normalized.toLowerCase(); } /** Evaluate the unconditional containment floor for one bash command string. */ export function evaluateBashContainment(command: string): BashContainmentVerdict { if (typeof command !== "string" || command.trim() === "") { return { allowed: true }; } const normalized = normalizeBashCommandForContainment(command); for (const rule of RULES) { if (rule.pattern.test(normalized)) { return { allowed: false, rule: rule.id, reason: rule.reason }; } } return { allowed: true }; } /** Stable message shown to the agent on denial. */ export function buildBashContainmentDenialMessage(verdict: BashContainmentVerdict): string { return ( `Command blocked by Fusion privilege-escalation containment (${verdict.rule ?? "containment"}): ` + `${verdict.reason ?? "not permitted"}. This boundary applies at every permission preset; ` + `do not attempt to work around it — ask the operator instead.` ); }