UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

572 lines • 29.1 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { walk } from "../../commands/cli/walk.js"; import { callerSignal, sessionAccess } from "../../context/session_context.js"; import { YieldBudget } from "../../io/yield_budget.js"; import { askRule, Outcome, PermissionsPolicy, policyDenied, refusalOf, renderCommandDeny, Scope, } from "../../policy/index.js"; import { lineTokens } from "../../policy/match/allow.js"; import { patternMatches, splitPattern } from "../../policy/match/pattern.js"; import { ioReach, ioRefusal } from "../../policy/match/rule.js"; import { settled } from "../../policy/policies.js"; import { CommandWord } from "../../policy/types.js"; import { getRedirects, literalWord } from "../../shell/helpers.js"; import { NodeType, RedirectKind } from "../../shell/types.js"; import { Follow, PathSpec } from "../../types.js"; import { makeAbortError } from "../../utils/abort.js"; import { getExtension } from "../../utils/filetype.js"; import { pathVisible } from "../../utils/hidden.js"; import { resolvePath } from "../../utils/path.js"; import { rstripSlash } from "../../utils/slash.js"; import { CondRefused } from "../executor/builtins/condition/errors.js"; import { fileOperands, TEST_NAMES } from "../executor/builtins/condition/operands.js"; import { followPaths } from "../executor/builtins/links/links.js"; import { toScope } from "../executor/builtins/scope.js"; import { reachesService } from "../executor/command/cli.js"; import { commandWords, CWD_DEFAULT_RAW, defaultCwdOperand, mergeScopes, pathFlagScopes, programTokens, routableScopes, routedOperands, treePathScopes, } from "../executor/command/routing.js"; import { classifyParts } from "../expand/classify/parts.js"; import { classifyBarePath } from "../expand/classify/path.js"; import { specForCommand, specWordBases, specWordKinds } from "../expand/spec_hints.js"; import { INTERPRETER_NAMES, WRITING_PROGRAMS } from "../lookup/constants.js"; import { Consumer, isTool, lineFacts, lookup, runsAs, SLASH_KEEPS_LAST, WordPolicy, wordPolicy, } from "../lookup/index.js"; import { envSnapshot } from "../session/state.js"; import { wordValue } from "./inner_lines.js"; function norm(virtual) { return rstripSlash(virtual) || '/'; } /** * The nodes a redirected statement may wrap whose last command is the * one the redirect binds to. A `!` wraps one command, so it is its own * last one: `! cat < f` parses as redirected(negated(cat), < f). */ const REDIRECT_CHAIN = new Set([ NodeType.LIST, NodeType.PIPELINE, NodeType.NEGATED_COMMAND, ]); /** * A command the gate let through, and what its own I/O may touch. * * The gate judged the paths the line names; a walk below them reaches * entries no rule has seen, so the dispatcher binds this to the session * context for the command's run and the commands tier asks it before each * read, write or listing (`EntryGate`). The paths the gate already judged * pass, since the line was admitted on them; every other entry is judged * by `ioRefusal` under the same precedence the gate applied to the line, * and a refusal is the dispatcher's `PolicyDenied` (EACCES, the path, the * reason on its record), which every command renders as GNU's * `Permission denied`. * `granted` holds the ask rules the line runs under a grant for: the one * the entry point answered for this line, and the session's standing ones. * `ctx` is what `preCommand` judged the command on, which `postCommand` * extends with what came back. */ export class Admitted { rules; ctx; judged; granted; constructor(init) { this.rules = init.rules; this.ctx = init.ctx; this.judged = init.judged; this.granted = init.granted; } // Throw `PolicyDenied` when a rule in force refuses this entry for the // running command. check(virtual) { const reason = this.refusal(virtual); if (reason !== null) { throw policyDenied({ kind: 'deny', reason, policy: PermissionsPolicy.name }, virtual); } } // Whether anything at or under this path could be refused for the // running command: a rule in force at any path its scope could cover. // The dispatcher checks coded file policies separately. scopes(virtual) { return ioReach(this.rules, this.ctx.tokens ?? [], virtual); } // The reason a rule in force refuses this entry, null when the line was // admitted on it or nothing refuses it. refusal(virtual) { if (this.judged.has(norm(virtual))) return null; return ioRefusal(this.rules, this.ctx.tokens ?? [], virtual, this.granted); } } /** * The paths a path-pattern guard reads for a line: the operands as * typed and the values of path-valued flags, a command tree's group and * leaf options among them (`treeFlags`, from `treePathScopes`), then, for * a command that * follows links, the targets they resolve to. `cat /data/link` reads * `/data/secret`, so a rule protecting the target has to see it, and a * command-scoped rule never runs at the dispatcher where the resolved path * would otherwise be checked. The links are followed as the command's own * session sees them (`links`, `Namespace.sessionLinks`), as the router * follows them: on the browser fallback the ambient view folds every live * session's hides, and a peer hiding the link must not keep its target from * the rule; null where no link stands. The follow policy is the command's own * (`follows`, whether `LineFacts.follows` is not NONE: rm, mv, ln, stat, * tar ... act on the link itself, `-L` turns following back on), the same * one the router applies to the operands before the handler runs, so a rule * sees exactly the path the command will touch. A loop is left to that later * step to report; here the typed paths stand. Then the operand a bare * `ls`/`find`/`du`/`tree`/`grep -r` implies, the working directory, * which the executor injects after the gate and which a rule on that * directory has to see. Last come the statement's redirect targets: * `cat < /data/secret` reads the file and `echo x > /data/secret` * truncates it, on the shell's own fds outside the admitted command's * gate window, so the admission is the one place a rule can see them. A * redirect always dereferences (the shell opens the target), so its * link targets ride along whatever the command's own follow policy * says. */ export function policyScopes(name, args, operands, follows, links, cwd, implied = null, redirects = [], treeFlags = []) { const scopes = []; for (const p of operands) { if (p instanceof PathSpec) scopes.push(p); } for (const item of pathFlagScopes(name, [...args], cwd)) scopes.push(item); const typed = new Set(scopes.map((p) => p.virtual)); for (const item of treeFlags.filter((p) => !typed.has(p.virtual))) scopes.push(item); if (name.includes('/')) { // A slash-carrying head word is a file the line executes, and it // lives in argv[0], not the operands, so a path-pattern guard would // never see it without this row. scopes.unshift(toScope(resolvePath(name, cwd))); } if (links !== null && operands.length > 0) { const followed = followPaths(links, [...operands], follows, !SLASH_KEEPS_LAST.has(name)); const seen = new Set(scopes.map((p) => p.virtual)); for (const item of followed) { if (item instanceof PathSpec && !seen.has(item.virtual)) { seen.add(item.virtual); scopes.push(item); } } } if (implied !== null && !scopes.some((p) => p.virtual === implied.virtual)) { scopes.push(implied); } if (redirects.length > 0) { const targets = [...redirects]; if (links !== null) { const followed = followPaths(links, [...redirects], true, true); for (const item of followed.filter((p) => p instanceof PathSpec)) targets.push(item); } const seen = new Set(scopes.map((p) => p.virtual)); for (const item of targets) { if (item instanceof PathSpec && !seen.has(item.virtual)) { seen.add(item.virtual); scopes.push(item); } } } return scopes; } /** * The paths of a line the session can see. A hidden path is nonexistent * for the session, so no policy may learn of it either: a rule scoped * to it must not fire (the reason would say the path is there), an ask * must not be raised for it (a request would name it to the host), and * the line runs on to the dispatcher, which answers ENOENT like any other * absent path. A path the reader could not read (`unread`, as `gate` * takes it) goes the same way, since the line may never name it. */ function seen(session, specs, unread = new Set()) { return specs.filter((p) => !unread.has(p.virtual) && pathVisible(session.visibility, p.virtual)); } /** * The words a policy is shown, a path `seen` drops taken off the word that * names it, so a policy reads the word and never the path. Mirrors Python's * `_seen_words`. */ function seenWords(session, words, unread) { const shown = new Set(seen(session, words.flatMap((w) => (w.path !== null ? [w.path] : [])), unread).map((p) => p.virtual)); return words.map((w) => w.path === null || shown.has(w.path.virtual) ? w : new CommandWord(w.text, w.kind, w.option, null)); } /** * The command plane's admission of one command: the policy chain, then * the approval ledger. The one gate every command class passes through, * in the tree (`runArgv`, once the words are expanded) and for a line a * runtime takes whole (`admitLine`, per parsed command). A word the * session's allow list leaves out is the profile's Deny, like any other * rule's; a path the session cannot see is * dropped before any method, so a rule never names it and the dispatcher * answers ENOENT; a Deny renders in the outcome table's voice; an Ask * is answered by the entry point from the session's grants or the host. * `stdin` decides whether a bare `rg` reads the working directory. */ /** * One command's literal words, classified the way the runtime would * classify them, so the gate and the run name the same paths. */ export async function classifiedWords(name, args, session, registry) { const line = [name, ...args]; const [wordKinds, wordBases] = wordHints(line, session, registry); return await classifyParts(line, registry, session.cwd, wordKinds, wordBases); } /** * The paths a statement's redirect targets name. * * Shared by admission and by the dry run, because a rule reads a * redirect the same way in both: a target only the runtime can expand * names no path here, and one that is not path-shaped is not a file. */ export function redirectPaths(words, registry, cwd) { return words .filter((w) => w.text !== null) .map((w) => classifyBarePath(wordValue(w), registry, cwd)) .filter((p) => p instanceof PathSpec); } /** * Everything the gate decides about one command before anything is * spent on it: the classified context, and the policy chain's answer (the * first Deny, else the first Ask) with the answers it came from. * * Split out of `admit` so a dry run can have the answer without the * consequences. Nothing here records a request, consumes a grant or * reaches the host, which is what makes it safe for `explain`; * `admit` adds exactly those and renders. * * `intrinsic` keeps shell-provided operations subject to tool allow lists * even when a function shadows their policy name. `unread` holds the * virtual paths a reader of the line's text cannot vouch for: what a word * only the runtime expands names, or a relative word after a `cd` it * could not follow. No policy is shown them; the per-command gate reads * the real ones and passes none. `every` asks every policy past a Deny, * for `explain`; the gate's own answers end at the first Deny. * `rulesOnly` asks the profile's rules alone, for a pass that judges a line * before it runs; a policy's own `preCommand` is asked once, by the gate * that runs the command. `line` is the line the command was parsed from, * empty outside a line. */ /** * Whether the allow list refuses a CLI that reaches a service. Such a CLI * (an account or script CLI) reaches its service by id, past every hide, so * under a profile that hides paths it runs only where a pattern starting * with its own name matches the line. `*` and a pattern for another of its * verbs do not count. Mirrors the Python `unlisted_service`. */ export function unlistedService(ctx, registry, session) { const install = registry.clis.get(ctx.command); if (ctx.tool === false || install === null || !reachesService(install.cli) || session.visibility.paths === null) { return false; } const tokens = lineTokens(ctx); return !(session.commands?.allow ?? []).some((p) => splitPattern(p)[0] === ctx.command && patternMatches(p, tokens)); } /** * Whether the handler a command resolves to declares that it may change * state. A command tree's leaf (an installed CLI's, or a tree a mount * serves) as its walk names it, under the session environment its run walks * with, which fills a group option declaring `Argument.env`; any other mount * command as every handler a run of it may reach: on the mount that would run * it (the mount of the first path the line names, else of the working * directory), on each path's own mount, which a run across mounts hands that * path, and on every mount below a path, which a walk reaches. Its handler is * picked by an operand's extension, so each path's counts, and a run follows * links before it picks either, so a link's target counts beside its name. * Mirage's own handler counts only when the line's options let it write * (`LineFacts.writes`: `sort -o` writes, `sort` does not). A namespace or * routed program that changes what it names (`WRITING_PROGRAMS`: `ln`, * `chmod` ...) writes by name. A walk the tree refuses runs nothing, and a * shell builtin, a function, an external program or an unknown name declares * nothing. Mirrors Python's `_writes`. */ function writes(name, args, operands, facts, session, registry, namespace, implied) { const consumer = lookup(name, session, registry); if (WRITING_PROGRAMS.has(name) && (consumer === Consumer.NAMESPACE || consumer === Consumer.MOUNT)) return true; if (consumer !== Consumer.CLI && consumer !== Consumer.MOUNT) return false; const tree = registry.clis.get(name)?.cli ?? null; if (tree !== null) { const result = walk(name, tree.spec, [...args], session.cwd, envSnapshot(session)); return result.leaf !== null && tree.handlers[result.path.join(' ')]?.write === true; } const candidates = registry.invocationCandidates(name, [], session.cwd, args, sessionAccess(session)); if ((candidates[0]?.cliFor(name) ?? null) !== null) { return candidates.some((mount) => { const cli = mount.cliFor(name); if (cli === null) return mount.mayWrite(name); const result = walk(name, cli.spec, args, session.cwd, envSnapshot(session)); return result.leaf !== null && cli.handlers[result.path.join(' ')]?.write === true; }); } const runs = runsAs(name, registry); // The words that route the run, picked as execution picks them: a stdin `-` // and an assignment route nowhere, and a path-valued option, attached or // not, routes like an operand. const named = mergeScopes(routableScopes(runs, routedOperands(runs, [...args], session.cwd, operands, operands.filter((p) => p instanceof PathSpec))), pathFlagScopes(runs, [...args], session.cwd)).filter((s) => s.walkError !== 'ENOENT'); if (namespace !== null && namespace.nodes.size > 0 && named.length > 0) { const typed = new Set(named.map((p) => p.virtual)); const followed = followPaths(namespace.sessionLinks(session), [...named], facts.follows !== Follow.NONE, !SLASH_KEEPS_LAST.has(name)); for (const p of followed) { if (p instanceof PathSpec && !typed.has(p.virtual)) named.push(p); } } const scopes = named.length > 0 ? named : implied !== null ? [implied] : []; const reached = registry.invocationCandidates(runs, scopes.slice(0, 1), session.cwd, args, sessionAccess(session)); for (const scope of scopes) { const owner = registry.tryMountFor(scope.virtual); const below = `${rstripSlash(scope.virtual)}/`; for (const item of registry .allMounts() .filter((m) => m === owner || m.prefix.startsWith(below))) reached.push(item); } const extensions = named.length > 0 ? new Set(named.map((p) => getExtension(p.virtual))) : [null]; return reached.some((mount) => { const cli = mount.cliFor(runs); if (cli !== null) { const result = walk(runs, cli.spec, args, session.cwd, envSnapshot(session)); return result.leaf !== null && cli.handlers[result.path.join(' ')]?.write === true; } return [...extensions].some((extension) => mount.mayWrite(runs, extension, facts.writes)); }); } export async function gate(name, args, operands, session, registry, namespace, stdin = null, redirects = [], intrinsic = false, unread = new Set(), every = false, rulesOnly = false, line = '') { const tool = intrinsic || isTool(name, session); const env = envSnapshot(session); const [tokens, program] = programTokens(registry, name, [...args], session.cwd, env, sessionAccess(session)); const implied = name in CWD_DEFAULT_RAW ? defaultCwdOperand([name, ...operands], name, registry, session.cwd, stdin) : null; // Each read of the words is a pass over all of them, so a command of a // million words yields between them: a cancel or the line's deadline // lands before the policies are asked. const budget = new YieldBudget(callerSignal()); await budget.run(); const facts = lineFacts(name, [name, ...operands], session.cwd); const follows = facts.follows !== Follow.NONE; const paths = seen(session, policyScopes(name, args, operands, follows, namespace !== null && namespace.nodes.size > 0 ? namespace.sessionLinks(session) : null, session.cwd, implied, redirects, treePathScopes(registry, name, args, session.cwd, env, sessionAccess(session))), unread); await budget.run(); const words = seenWords(session, commandWords(registry, name, args, operands, redirects, session.cwd, env, sessionAccess(session)), unread); await budget.run(); const write = writes(name, args, operands, facts, session, registry, namespace, implied); await budget.run(); const ctx = { line, command: name, paths, words, argv: [...args], cwd: session.cwd, sessionId: session.sessionId, tokens, program, tool, walks: facts.walks, write, subtree: facts.subtree, moveTarget: facts.moveTarget, // The methods read the session this command is judged in, which for a // line explain walks is the walked one, not the stored one. ...registry.policies.views?.(session.sessionId, session), }; const answers = (await registry.policies.answers('preCommand', ctx, every, rulesOnly)).filter((a) => a.kind !== 'route'); if (unlistedService(ctx, registry, session)) { answers.unshift({ kind: 'deny', reason: `${program.join(' ')} is not allowed`, policy: PermissionsPolicy.name, }); } return [ctx, settled(answers), answers]; } /** * The chain's answer once the ledger has had its say. An Ask is the chain's * answer only after every Deny had its say; the ledger answers it from the * session's records or the host, so an answer never re-opens a deny. A wait * the run's kill cut short is the kill landing late, not a ruling, so it * throws as every other abandoned wait does rather than being rendered as a * refusal the document never made. Mirrors the Python `resolved`. */ export async function resolved(ctx, asked, registry, signal, claimant) { if (asked?.kind !== 'ask') return asked; const action = await registry.decisions.resolve(ctx, asked, signal, claimant); if (action !== null && action.kind === 'abandoned') throw makeAbortError(); return action; } export async function admit(name, args, operands, session, registry, namespace, stdin = null, redirects = [], // The run's abort signal, carried only so a question put to a host // cannot outlive the run that raised it. Nothing else here waits on // anything outside mirage. signal, // The command and its line, null outside a line. On a line, every // grant behind the command is claimed on the line's hand-off for that // occurrence, whether a pass that judges the line before it runs // (`prejudgeLine`, `admitLine`) or the gate that runs it is reading, // and spent when the line ends, so one question covers one run rather // than one reader; a reader outside a line spends what it matched. A // refusal needs no such care: the record refuses the agent's retry // from the ledger either way. claimant = null, // Shell-provided operations keep their tool policy even if a function shadows the name. intrinsic = false, // Paths no policy is shown, as `gate` takes them. unread = new Set(), // Ask the profile's rules alone, as `gate` takes it. rulesOnly = false) { const gated = await gate(name, args, operands, session, registry, namespace, stdin, redirects, intrinsic, unread, false, rulesOnly, claimant?.occurrence.source ?? ''); const [ctx, asked] = gated; const action = await resolved(ctx, asked, registry, signal, claimant); if (action === null) { const granted = session.decisions .filter((r) => r.scope === Scope.SESSION && r.outcome === Outcome.ALLOW) .map((r) => r.rule); if (asked !== null && asked.kind === 'ask') granted.unshift(...(asked.rules ?? [askRule(ctx, asked)])); return new Admitted({ rules: session.commands, ctx, judged: new Set(ctx.paths.map((p) => norm(p.virtual))), granted, }); } const [stderr, exitCode] = await renderCommandDeny(name, action, ctx); return { stderr, exitCode, refusal: refusalOf(action) }; } /** * Admit a `[ ]` test, or one `[[ ]]` leaf, as the command it is: the shell * evaluates it itself, so no command gate saw its words, and a rule, an allow * list or a policy answers it by its own name as it answers `test` on the * same words. `name` is `[` or `[[`; `words` what it tests, expanded, * operators included; `signal` the run's abort signal; `claimant` the test * and its line, null outside a line. Throws `CondRefused` where the gate * refused it. Mirrors Python's admit_test. */ export async function admitTest(name, words, session, registry, namespace, signal, claimant = null) { const classified = await classifiedWords(name, words, session, registry); const verdict = await admit(name, words, classified.slice(1), session, registry, namespace, null, [], signal, claimant, true); if (!(verdict instanceof Admitted)) { throw new CondRefused(verdict.stderr, verdict.exitCode, verdict.refusal); } return verdict; } /** * The spec's per-position classification hints for a literal line, the * way `expandArgv` computes them for an expanded one. Without them a * bare filename operand stays text (`cat secret` from `/data` yields no * `/data/secret` scope) and a chdir option (tar's `-C`) resolves later * words against the wrong base, so a rule and the run would disagree * about the paths the line names. */ function wordHints(line, session, registry) { const consumed = registry.matchCommandPrefix([...line]); const joined = line.slice(0, consumed).join(' '); if (TEST_NAMES.has(joined)) return [fileOperands(joined, line.slice(1)), null]; const consumer = lookup(joined, session, registry); // A mount command's spec is read, and so is a native capture's and an // interpreter's: `python3 steal.py` runs on the runtime's own disk or // a host process, where no dispatcher follows the read, so the script // slot the spec declares is the one place a path rule can see the // file. The tree's gate reads a native capture the same way // (`expandArgv`), and an interpreter it runs itself for the script slot // alone, since its other words become the program's argv there; here // the hints reach no runtime word, since the line runs as typed, so // there is nothing to lose by reading them all. if (Object.hasOwn(session.functions, joined) || !(wordPolicy(consumer) === WordPolicy.MOUNT || consumer === Consumer.EXTERNAL || INTERPRETER_NAMES.has(joined))) { return [null, null]; } // A wrapper's words are the program's it runs. const runs = runsAs(joined, registry); const spec = specForCommand(runs, registry, session.cwd, line.slice(consumed), sessionAccess(session)); if (spec === null) return [null, null]; const extra = new Array(consumed - 1).fill('str'); const wordKinds = [ ...extra, ...specWordKinds(spec, [...line.slice(consumed)], runs, envSnapshot(session)), ]; const bases = specWordBases(spec, [...line.slice(consumed)], session.cwd, runs, envSnapshot(session)); const wordBases = bases === null ? null : [...new Array(consumed - 1).fill(null), ...bases]; return [wordKinds, wordBases]; } /** * The redirect targets of the statement holding a command, as the gate * reads its words: the raw text and the literal it names, null when * only the runtime can expand it (refused wherever a rule reads the * command's arguments, like any other word). Heredoc and herestring * bodies are content, not paths, and a numeric target is an fd * duplication; neither names a file. * * A redirect binds to one command, and which one is a question about * the tree rather than the statement: `a && b > f` and `a | b > f` both * parse as a redirected_statement wrapping the whole list, so reading * only its first child answered `a` and left `b`, the command bash * actually opens the file for, with no target at all. The walk climbs * the last-command chain instead, which is bash's own rule for a list, * a pipeline and a `!`. A compound (`{ }`, a loop, a subshell) redirects * every command inside it, which is not a chain, so none is claimed * here and the dispatcher judges the write. */ function statementRedirectsRaw(node) { let owner = node; let parent = owner.parent; while (parent !== undefined && parent !== null && REDIRECT_CHAIN.has(parent.type)) { const last = parent.namedChildren[parent.namedChildren.length - 1]; if (last === undefined || last.startIndex !== owner.startIndex) return []; owner = parent; parent = owner.parent; } if (parent === undefined || parent === null) return []; if (parent.type !== NodeType.REDIRECTED_STATEMENT) return []; const body = parent.namedChildren[0]; if (body === undefined || body.startIndex !== owner.startIndex) return []; const [, redirects] = getRedirects(parent); return redirects; } /** Mark redirected input without opening or expanding its source. */ export function statementStdin(node) { return statementRedirectsRaw(node).some((r) => r.fd === 0) ? new Uint8Array(0) : null; } export function statementRedirects(node, home) { const words = []; for (const r of statementRedirectsRaw(node)) { if (r.kind === RedirectKind.HEREDOC || r.kind === RedirectKind.HERESTRING || r.kind === RedirectKind.AMBIGUOUS) continue; if (typeof r.target === 'number' || r.targetNode === null) continue; const target = r.targetNode; words.push({ raw: String(r.target), text: literalWord(target, home) }); } return words; } //# sourceMappingURL=admission.js.map