UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

824 lines • 37.2 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 { FlagView, parseCommand, SPECS } from "../commands/spec/index.js"; import { parseToKwargs } from "../commands/spec/parser.js"; import { operandExitCode } from "../commands/spec/usage.js"; import { callerSignal, currentCaller, explaining, lineRunning, noteRefusal, runExplaining, runLanding, } from "../context/session_context.js"; import { eacces, erofs } from "../errors/fs.js"; import { posixPhrase } from "../errors/posix.js"; import { fsErrorLine } from "../errors/render.js"; import { commandRecords } from "../observe/context.js"; import { CANNOT_EXECUTE_STATUS } from "../shell/constants.js"; import { FileType, Limit } from "../types.js"; import { abortable, hasAborted, makeAbortError } from "../utils/abort.js"; import { asyncContextIsolatesTasks } from "../utils/async_context.js"; import { rstripSlash } from "../utils/slash.js"; import { MountModePolicy } from "./builtin/mount_mode.js"; import { PermissionsPolicy } from "./builtin/permissions.js"; import { composeSettled, Composition } from "./composition.js"; import { Decisions, opQuestion } from "./decisions.js"; import { Explained, PolicyDenied } from "./errors.js"; import { sourceOf } from "./match/decide.js"; import { operandPaths } from "./match/rule.js"; import { isSessionScoped } from "./mixin.js"; import { DryRun, Outcome, policyCaller, VALIDITY, } from "./types.js"; /** * The command plane's rendering of a refusal: stderr and exit code. The * one place the outcome table for that plane is written down, so a * document rule and a coded policy print alike, and the policy's reason * is never mixed into what the terminal says: a Deny, or an unanswered * ask, is bash's own `<subject>: Permission denied` at 126, so stderr * never tells an agent whether a retry might pass; a Deny about a path * is the command's own GNU line for that path and EACCES; each leaves * the reason (and an ask's id) on the result's `refusal` record. A Deny * carrying an error keeps `<subject>: <reason>`, because there the * reason is the diagnostic a built-in worded in POSIX's terms. A path or * an error exits at the command's operand-refusal code (1, tar 2). */ export function renderDeny(subject, deny) { if (deny.kind === 'deny' && deny.path !== undefined) { const line = fsErrorLine(subject, deny.path, eacces(deny.path)); return [new TextEncoder().encode(line), operandExitCode(subject)]; } if (deny.kind === 'deny' && deny.error !== undefined) { return [new TextEncoder().encode(`${subject}: ${deny.reason}\n`), operandExitCode(subject)]; } return [new TextEncoder().encode(`${subject}: Permission denied\n`), CANNOT_EXECUTE_STATUS]; } /** * Opt in by assigning a debug sink, as Python's module logger is off until * configured. */ export const logger = {}; /** * `renderDeny` for a refused command whose context is at hand. A Deny about * a cp or mv operand says what GNU says when the step that operand's role * takes is refused, which the bare path line does not: the context says * which operand is a source and which the target, and its file view whether * each is a directory. Every other refusal renders as `renderDeny` renders * it. */ export async function renderCommandDeny(subject, deny, ctx) { if (deny.kind === 'deny' && deny.path !== undefined) { const lines = await transferLines(subject, deny.path, ctx); if (lines !== null) return [new TextEncoder().encode(lines), operandExitCode(subject)]; } return renderDeny(subject, deny); } /** * GNU's lines for the cp or mv operand a path deny names; null for another * command, or a path that is none of its operands. The deny lands before the * command touches a file, so each line is the one GNU prints when that * operand's step is refused: cp opens a source file for reading, reads a * source directory and creates each copy, and mv moves each source onto its * target, or swaps the two under `--exchange`. A target given by `-t`, after * more than one source or that is a directory takes each source by its base * name, unless `-T`. A source named `.` or `..`, or a link cp copies as a * link, keeps `renderDeny`'s line: GNU's there depends on what the directory * or the link holds. A stat still waiting when the line stops ends the * wording with the line's abort, so a cancel neither waits on it nor starts * another stat. */ async function transferLines(subject, path, ctx) { if (subject !== 'cp' && subject !== 'mv') return null; const spec = SPECS[subject]; if (spec === undefined) return null; const parsed = parseCommand(spec, [...ctx.argv], ctx.cwd, subject); const flags = new FlagView(parseToKwargs(parsed), spec); const named = ctx.moveTarget ?? null; const operands = operandPaths(ctx); const directory = named === null ? undefined : ctx.paths.find((p) => named === p.rawPath || named === p.virtual); const lastOperand = operands[operands.length - 1]; let sources; let target; if (directory !== undefined) { sources = operands; target = directory; } else if (operands.length > 1 && lastOperand !== undefined) { sources = operands.slice(0, -1); target = lastOperand; } else { return null; } const namedSource = sources.find((s) => path === s.rawPath || path === s.virtual); let refused; if (namedSource !== undefined) refused = [namedSource]; else if (path === target.rawPath || path === target.virtual) refused = sources; else return null; const typedTarget = parsed.rawPathFlags.target_directory; const goal = directory !== undefined && typeof typedTarget === 'string' ? typedTarget : target.rawPath || target.virtual; const into = directory !== undefined || (!flags.asBool('no_target_directory') && (sources.length > 1 || (await fileType(ctx, target)) === FileType.DIRECTORY)); const landing = (name) => { if (!into) return goal; const base = rstripSlash(name); return `${rstripSlash(goal)}/${base.slice(base.lastIndexOf('/') + 1)}`; }; const recursive = subject === 'cp' && (flags.asBool('r') || flags.asBool('recursive') || flags.asBool('archive')); const typed = subject === 'cp' ? flags.typedOrder('dereference', 'no_dereference', 'H', 'd', 'archive') : []; const last = typed[typed.length - 1]; const follows = last !== undefined ? last === 'dereference' || last === 'H' : !recursive; const exchange = subject === 'mv' && flags.asBool('exchange'); const lines = []; for (const src of refused) { const shown = src.rawPath || src.virtual; const trimmed = rstripSlash(shown); const base = trimmed.slice(trimmed.lastIndexOf('/') + 1); if (base === '.' || base === '..') return null; let line; if (exchange) { line = `cannot exchange '${shown}' and '${landing(shown)}'`; } else if (subject === 'mv') { line = `cannot move '${shown}' to '${landing(shown)}'`; } else if (!follows && (await fileType(ctx, src, true)) === FileType.SYMLINK) { return null; } else if ((await fileType(ctx, src)) !== FileType.DIRECTORY) { line = namedSource !== undefined ? `cannot open '${shown}' for reading` : `cannot create regular file '${landing(shown)}'`; } else if (!recursive) { lines.push(`cp: -r not specified; omitting directory '${shown}'\n`); continue; } else { line = namedSource !== undefined ? `cannot access '${shown}'` : `cannot create directory '${landing(shown)}'`; } lines.push(`${subject}: ${line}: Permission denied\n`); } return lines.join(''); } async function fileType(ctx, operand, nofollow = false) { if (ctx.vfs === undefined) return null; const signal = callerSignal(); try { return (await abortable(ctx.vfs.stat(operand, undefined, { nofollow }), signal)).type; } catch (err) { if (hasAborted(signal)) throw err; logger.debug?.(`no stat for refused operand ${operand.virtual}: ${String(err)}`); return null; } } /** * The record a refused result carries beside its bash-voiced stderr. null * for a Deny carrying an error: that is the filesystem's own failure (a * busy mount root, a name that exists), whose stderr line already says * everything, so no policy refusal is recorded. */ export function refusalOf(action) { if (action.kind === 'pending') { return { kind: 'pending', reason: action.reason, policy: '', askId: action.id }; } if (action.error !== undefined) return null; return { kind: action.failed === true ? 'failed' : 'deny', reason: action.reason, policy: action.policy ?? '', askId: null, }; } /** * One line saying why, for a surface that hands the agent text rather * than a record; the agent adapters append it after stderr. */ export function describeRefusal(refusal) { if (refusal.kind === 'pending') { return `requires approval: ${refusal.reason} (ask ${refusal.askId ?? ''})`; } if (refusal.kind === 'failed') return `policy ${refusal.policy} failed`; return `policy denied: ${refusal.reason}`; } /** * The placement the policies agree on: their one runtime, null when none * placed the line, and a Deny when two disagree. Mirrors the Python * `agreed`. */ export function agreed(routes) { return composeSettled(routes, 'agreed'); } export function settled(answers) { return composeSettled(answers, 'settled'); } /** * The error an entry point throws for a policy's Deny, or for a question the host * has not answered, its record noted for the line running it. The message * says what the terminal would (`Permission denied` unless the entry point words * its own); the reason (or the ask id the agent quotes) rides the record, * on the error for a caller that catches it and on the line's result for * one that only reads what a command printed. `caller` is who was refused, * whose line notes the record; without one, the async context's line. */ export function policyDenied(action, filename, message = posixPhrase('EACCES'), caller) { const refusal = refusalOf(action); if (refusal !== null) { if (caller === undefined) noteRefusal(refusal); else caller.refusals?.(refusal); } return new PolicyDenied(message, filename, refusal); } /** * What the gate would answer one VFS call, as `preVfsGate` decides it and * without its consequences: every policy's answer, the one that wins, * and the error the entry point would throw. A question reads the ledger's * settled records and records nothing. Mirrors the Python `_explained_op`. */ async function explainedOp(policies, ctx, decisions, line) { const answers = (await policies.answers('preVfs', ctx)).filter((a) => a.kind !== 'route'); const winner = settled(answers); const base = { call: ctx.call.name, paths: [ctx.path.virtual], outcome: Outcome.ALLOW, reason: '', source: '', answers, refusal: null, error: '', }; if (winner === null) return base; let action = winner.kind === 'deny' ? winner : null; if (winner.kind === 'ask') { action = decisions === null || line ? { kind: 'deny', reason: winner.reason, ...(winner.policy ? { policy: winner.policy } : {}), } : await decisions.heldOp(ctx, winner); } const decided = { ...base, outcome: winner.kind === 'ask' ? Outcome.ASK : Outcome.DENY, reason: winner.reason, source: winner.rule === undefined ? '' : sourceOf(winner.rule), }; if (action === null) return decided; const error = action.kind === 'deny' ? action.error : undefined; const code = error?.code; return { ...decided, refusal: error !== undefined ? null : refusalOf(action), error: typeof code === 'string' && code !== '' ? code : 'EACCES', }; } /** * Fire preVfs at the dispatcher; a Deny becomes a PolicyDenied (EACCES), * or the built-in's own error (EROFS for a mode). * The one seam helper the dispatcher calls, so a refusal is identical * however the mount is reached: shell internals, programmatic access, * FUSE, and the warm cache all pass through it. Commands and runtimes * pass the same file gates. A policy method's own read meets the hides, * path rules and mount mode alone; its write is refused. `access` carries the * owning mount's mode (unset at an entry point that judges it itself), whether * the op creates the path or mutates below it, and the approval ledger. An Ask is a question only where no line is running and * the entry point holds the ledger (a file tool, the host's facade): the ledger * answers it from a standing grant or records it, and an unanswered one * refuses with the ask id on the record. Inside a line an Ask refuses * like a deny, since the line was admitted without it. `final` is false * only for a rename's source, whose destination is gated next. In a dry * run (`explaining`) the gate notes its answer and throws `Explained` * once the op would refuse or has no gate left, so the entry point stops before * any backend or cache is touched; a write a policy makes while it * decides that op throws EROFS, since the dry run may change nothing, and * an ask its read meets reads the ledger and records nothing. `caller` is * who makes the call; without one, the gate reads the async context's. */ /** * A VFS call's policy answer, the policy's own reads recorded apart. They * still reach the ledger, but they are no records of the call being * judged: counted as its, they would pass for the call's own record (a RAM * unlink records none, so the facade records it) and be charged to the * command the call runs in. Without task isolation the binding would reach * other tasks' ops, and there the facade records its op itself anyway. * Mirrors Python's `_apart`. */ function apart(answer) { return asyncContextIsolatesTasks ? commandRecords(answer, true) : answer(); } export async function preVfsGate(policies, call, path, write, prefix, sessionId = '', issuer, access = {}) { const { decisions = null, final = true, caller, ...context } = access; const ctx = { call, path, write, prefix, sessionId, ...(issuer !== undefined ? { issuer } : {}), ...context, caller: policyCaller((caller ?? currentCaller()).session, policies.account), }; const trace = caller === undefined ? explaining() : caller.explaining; const line = caller === undefined ? lineRunning() : caller.refusals !== null; if (Array.isArray(trace)) { const decide = () => apart(() => explainedOp(policies, ctx, decisions, line)); // Without task isolation the binding would reach other tasks' ops; there // the mark rides a deciding method's own files and program instead. const noted = await (asyncContextIsolatesTasks ? runExplaining(DryRun.DECIDING, decide) : decide()); trace.push(noted); if (final || noted.error !== '') throw new Explained(); return; } const deciding = trace === DryRun.DECIDING; if (deciding && write) throw erofs(path); if (!(policies.wants('preVfs') || (write && context.mode !== undefined))) return; const answer = await apart(() => policies.preVfs(ctx, deciding)); await refuseOp(ctx, answer, line ? null : decisions, caller); } async function refuseOp(ctx, answer, decisions, caller) { let refused; if (answer?.kind === 'ask') { if (decisions === null) { throw policyDenied({ kind: 'deny', reason: answer.reason }, ctx.path.virtual, undefined, caller); } refused = await decisions.resolveOp(ctx, answer, caller); } else { refused = answer; } if (refused?.kind === 'deny' && refused.error !== undefined) throw refused.error; if (refused !== null) throw policyDenied(refused, ctx.path.virtual, undefined, caller); } /** * Fire postVfs at the dispatcher; a Deny, or an Ask the host has not * approved, drops the result with EACCES. Nothing cuts what a call returns: * a call that asks for a range, as a kernel or protocol mount does, must get * it whole. Commands and runtimes pass the same gate; a policy method's own read is exempt. */ export async function postVfsGate(policies, call, path, write, prefix, result, sessionId = '', issuer, access = {}) { const { decisions = null, caller, ...context } = access; const line = caller === undefined ? lineRunning() : caller.refusals !== null; const trace = caller === undefined ? explaining() : caller.explaining; if (trace === DryRun.DECIDING || !(await policies.wantsFor('postVfs', sessionId))) { return; } const ctx = { call, path, write, prefix, sessionId, ...(issuer !== undefined ? { issuer } : {}), ...context, caller: policyCaller((caller ?? currentCaller()).session, policies.account), result, }; await refuseOp(ctx, await apart(() => policies.postVfs(ctx)), line ? null : decisions, caller); } /** * Fire postExecute at the workspace boundary. Returns what drops the * line's output (a Deny, or the ledger's answer to an Ask), else the * merged Limit bound for the boundary to enforce. Nothing the line did is * undone. A run killed while the host decides throws the abort. */ export async function postExecuteGate(policies, ctx, claimant = null, signal) { if (!(await policies.wantsFor('postExecute', ctx.sessionId))) return [null, null]; const [answer, bound] = await policies.postExecute(ctx); if (answer?.kind !== 'ask') return [answer, bound]; const [asked, keyed] = opQuestion(ctx, answer); const said = await policies.decisions.resolve(asked, keyed, signal, claimant); if (said?.kind === 'abandoned') throw makeAbortError(); return [said, said === null ? bound : null]; } /** * Fire postCommand once a command or CLI ran and its output drained. * Returns what refuses it after the fact (a Deny, or the ledger's answer * to an Ask), which the caller turns into exit 126 and a refusal record. * Nothing the command did is undone. A run killed while the host decides * throws the abort. Mirrors the Python `post_command_gate`. */ export async function postCommandGate(policies, ctx, claimant = null, signal) { if (!(await policies.wantsFor('postCommand', ctx.sessionId ?? ''))) return null; const answer = await policies.postCommand(ctx); if (answer?.kind !== 'ask') return answer; const said = await policies.decisions.resolve(ctx, answer, signal, claimant); if (said?.kind === 'abandoned') throw makeAbortError(); return said; } /** * Fire postSession once a session write landed; a Deny, or an Ask the * host has not approved, becomes a PolicyDenied, for the writer to put the * old value back before it reports. Mirrors the Python * `post_session_gate`. */ export async function postSessionGate(policies, ctx, old) { if (policies === null) return; if (!(await policies.wantsFor('postSession', ctx.sessionId))) return; const landed = { ...ctx, old }; await sessionRefused(policies, landed, await policies.postSession(landed)); } async function sessionRefused(policies, ctx, answer) { const refused = answer?.kind === 'ask' ? await policies.decisions.resolveOp(ctx, answer) : answer; if (refused !== null) { throw policyDenied(refused, ctx.key, `${ctx.key}: permission denied`); } } /** * Fire preSession on the session plane; a Deny, or an Ask the host has * not approved, becomes a PolicyDenied. The one seam helper the session * plane's writers call, so a refusal is identical however the state is * reached: shell builtin, command view, or a later tier. Null policies (a * view constructed outside a workspace) gate nothing. An Ask goes to the * workspace's ledger (`Policies.decisions`), inside a line too. A write a * method makes while it decides (`ctx.session`) throws EACCES. */ /** * Fire preNetwork as a connection is about to open; a Deny, or an Ask, * refuses it with EACCES, the host as the name refused. A connection opens * under a command that was admitted without the question, so an Ask refuses * like a deny. A dry run's policy deciding opens nothing to judge. Only the * policies that let the connection through hear how it ends, so a refusal, * or an abort while they decide, ends it as refused for those already * passed. Returns them, for `postNetworkGate` (null when none was asked, so * every policy hears), and the tightest Limit they answered. Mirrors * Python's `pre_network_gate`. */ export async function preNetworkGate(policies, ctx) { if (explaining() === DryRun.DECIDING || !(await policies.wantsFor('preNetwork', ctx.sessionId ?? ''))) { return [null, null]; } const asked = { ...ctx, caller: policyCaller(currentCaller().session, policies.account) }; const passed = []; let answer; let limit; try { ; [answer, limit] = await apart(() => policies.preNetwork(asked, passed)); } catch (err) { await runLanding(() => postNetworkGate(policies, { ...asked, bytesSent: 0, bytesReceived: 0, seconds: 0, ended: 'error' }, passed)); throw err; } if (answer !== null) { await postNetworkGate(policies, { ...asked, bytesSent: 0, bytesReceived: 0, seconds: 0, ended: 'refused' }, passed); const refused = answer.kind === 'deny' ? answer : { kind: 'deny', reason: answer.reason }; throw policyDenied(refused, ctx.host === '' ? ctx.address : ctx.host); } return [passed, limit]; } /** * Fire postNetwork once a connection is over, for every policy among * `passed` (null for every policy), each asked past another's answer. * Mirrors Python's `post_network_gate`. */ export async function postNetworkGate(policies, ctx, passed = null) { if (explaining() === DryRun.DECIDING || !(await policies.wantsFor('postNetwork', ctx.sessionId ?? ''))) { return; } const ended = { ...ctx, caller: policyCaller(currentCaller().session, policies.account) }; await apart(() => policies.postNetwork(ended, passed)); } export async function preSessionGate(policies, ctx) { if (explaining() === DryRun.DECIDING) throw eacces(ctx.key); if (!policies?.wants('preSession')) return; await sessionRefused(policies, ctx, await policies.preSession(ctx)); } /** * Ordered policies; at every method the first Deny wins, and every Ask is * put to the host. * * Built-ins are seeded first (MountRegistry registers * MountRootPolicy), then the document's deny rules compiled by the * workspace, then user policies in registration order * (`Workspace({policies})`, then anything added later through * `add`). There is no allow arm, so adding a policy can only tighten * the workspace, never loosen it; order decides which refusal message * is shown, never whether a refusal holds. * * A policy that throws fails closed: the command is refused with a * whole-command Deny naming the policy. A policy that returns something the method may * not return (VALIDITY) throws PolicyError: that is a programming * error, not a refusal. * * The chain holds the decision ledger an Ask is put to (`decisions`), * `views`, which the workspace sets to fill a method's read-only `session` * and `vfs`, and `account`, which a server sets to the account it holds the * workspace for, for every method's `caller`. */ export class Policies { policies; mode = new MountModePolicy(); placement = null; wanted = new Set(); decisions = new Decisions(); views = null; account = null; constructor(policies) { this.policies = [...(policies ?? [])]; this.rescan(); } /** * Install the built-in placement, the workspace's `routePolicy` * compiled as a policy, which answers `preExecute` ahead of every * registered one. It sits outside the fail-closed fold: a misconfigured * route policy throws `RouteError` to the caller rather than refusing * the line, since the mistake is the deployment's to fix. */ place(placement) { this.placement = placement; this.rescan(); } /** * True when any policy defines `method`. O(1); the op seam gates on it * so a workspace with no op policies pays nothing per VFS op. */ wants(method) { return this.wanted.has(method); } /** * True when some policy will speak at `method` for this session. The * per-session refinement of `wants`: a policy that defines the method * counts, unless it speaks per session (`SessionScopedMixin`) and says this * is not one of its. For a seam that pays ahead for a method rather than * gating on it: the secret fill drops its masks under a session-write * gate, and a profile's policy at that entry point is one profile's, not * every session's. `codedOnly` leaves the profile's rules out, for a * caller whose line's gate already answers for them. */ async wantsFor(method, sessionId, codedOnly = false) { const policies = this.placement === null ? this.policies : [...this.policies, this.placement]; for (const policy of policies.filter((p) => !(codedOnly && p instanceof PermissionsPolicy))) { if (policy[method] === undefined) continue; if (!isSessionScoped(policy)) return true; if (await policy.wantsFor(method, sessionId)) return true; } return false; } rescan() { const wanted = new Set(); const policies = this.placement === null ? this.policies : [...this.policies, this.placement]; for (const method of Object.keys(VALIDITY)) { if (policies.some((p) => p[method] !== undefined)) wanted.add(method); } this.wanted = wanted; } /** * Register a policy after the existing ones. Code only: a * declarative rule belongs in the permissions document * (`commands.deny`), which the workspace compiles. */ add(entry) { this.policies.push(entry); this.rescan(); } /** Remove one registration by identity, preserving the other policies' order. Host-side only. */ remove(entry) { const index = this.policies.indexOf(entry); if (index === -1) return false; this.policies.splice(index, 1); this.rescan(); return true; } async evaluate(method, ctx, every, placed = false, rulesOnly = false, projection = 'fire', among = null, passed = null) { const snapshot = [...this.policies]; const placement = this.placement; const policies = [...snapshot, ...(placement === null ? [] : [placement]), this.mode]; if (policies.every((policy) => policy[method] === undefined)) { return { answers: [], action: null, route: null, limit: null }; } const composition = new Composition(); try { const hook = method.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); const descriptors = policies.map((policy, slot) => { const role = slot < snapshot.length ? policy instanceof PermissionsPolicy ? 'permissions' : 'registered' : policy === this.mode ? 'mount_mode' : 'placement'; composition.names.set(slot, policy.constructor.name || 'policy'); if (passed !== null && policy[method] === undefined) passed.push(policy); return { slot, role, hooks: among === null || among.includes(policy) ? [hook] : [] }; }); let seen = ctx; const signal = callerSignal(); composition.feed({ op: 'evaluate', evaluation: composition.evaluation, hook, projection, every, placed, rules_only: rulesOnly, policies: descriptors, }); composition.stream.finish(); let pending = []; let cursor = 0; for (let effect = composition.request(); effect !== null; effect = composition.request()) { if (composition.respondEffect(effect, 'config' in seen ? undefined : seen)) continue; if (effect.op !== 'run_policy' && effect.op !== 'read_action') throw new Error('invalid evaluation effect'); const policy = policies[effect.slot]; if (policy === undefined) throw new Error('invalid policy slot'); const name = policy.constructor.name || 'policy'; if (effect.op === 'run_policy') { const fn = policy[method]; if (fn === undefined) { composition.respond(effect, null, name, false); continue; } const handles = !rulesOnly && this.views !== null && !('config' in seen) && seen.session === undefined ? this.views(seen.sessionId ?? '', undefined) : null; if (handles !== null) seen = { ...seen, ...handles }; const asked = seen; let answered; try { const ask = async () => fn.call(policy, asked); answered = await abortable(asyncContextIsolatesTasks ? runExplaining(DryRun.DECIDING, ask) : ask(), signal); } catch (error) { if (hasAborted(signal)) throw makeAbortError(signal); if (policy === placement) throw error; const detail = error instanceof Error ? error.message : String(error); console.error(`${method} policy ${name} raised: ${detail}`); answered = { kind: 'deny', reason: `${name} failed`, policy: name, failed: true }; } const items = Array.isArray(answered) ? answered : [answered]; if (items.every((item) => item === null || item instanceof Limit)) passed?.push(policy); pending = Array.isArray(answered) ? answered : [answered]; cursor = 0; } if (hasAborted(signal)) throw makeAbortError(signal); const item = cursor < pending.length ? pending[cursor] : null; cursor += 1; composition.respond(effect, item, name, cursor < pending.length); } return composition.result(method); } finally { composition.close(); } } async fire(method, ctx, placed = false, rulesOnly = false) { const result = await this.evaluate(method, ctx, false, placed, rulesOnly); return [result.action, result.limit, result.route]; } /** * The policies' answers at one stage, in the order the stage asks them, * each naming its policy: what `explain` shows, from the same loop the * entry point runs. With `every` no answer stops the loop, so a Deny does not * hide the answers after it; without it the answers end at the first * Deny, as the entry point's do. The built-in hides never answer here, since a * hide never surfaces; the built-in placement answers first at * `preExecute` and the built-in mount mode last at `preVfs`, as they do * at the entry point. `rulesOnly` asks the profile's rules alone, as * `chain` takes it. Mirrors the Python `answers`. */ async answers(method, ctx, every = true, rulesOnly = false) { const result = await this.evaluate(method, ctx, every, false, rulesOnly, 'answers'); return result.answers; } /** Fire preCommand across the policies; the first Deny wins, else the first Ask. */ async preCommand(ctx) { const [action] = await this.fire('preCommand', ctx); return action; } /** Fire postCommand across the policies; the first Deny wins, else the first Ask. */ async postCommand(ctx) { const [action] = await this.fire('postCommand', ctx); return action; } /** * Fire preExecute: a Deny wins, else the first Ask, which holds the * line until the host approves it; and the Route every placing policy * agrees on, two placing the line on different runtimes refusing it. * `placed` says the caller placed the line (the runtime argument), so * the built-in placement is not asked and a Route is moot; a Deny or an * Ask still holds the line. */ async preExecute(ctx, placed = false) { const [action, , route] = await this.fire('preExecute', ctx, placed); return [action, route]; } /** * Fire preVfs across the policies; the first Deny wins, else the first * Ask, which the entry point decides how to put. A hidden path never * gets here: the dispatcher's walk answers it as absent first (`sees`), * so a refusal never tells a session a hidden name exists. The built-in * mount mode answers after the policies, whether or not any policy * overrides the method; `rulesOnly` asks the profile's path rules alone, * for an op a running command makes. */ async preVfs(ctx, rulesOnly = false) { const [action] = await this.fire('preVfs', ctx, false, rulesOnly); return action; } /** Fire postVfs; a Deny or an Ask holds the result back. */ async postVfs(ctx) { const [action] = await this.fire('postVfs', ctx); return action; } /** * Fire preNetwork across the policies; the first Deny wins, else the first * Ask, and Limits merge to the tightest. `passed` fills, as they answer, * with the policies that let the connection through, and those with no * preNetwork. */ async preNetwork(ctx, passed) { const result = await this.evaluate('preNetwork', ctx, false, false, false, 'fire', null, passed); return [result.action, result.limit]; } /** * Fire postNetwork across the policies among `among` (null for every one), * every one asked; none answers, since the connection is already over. */ async postNetwork(ctx, among = null) { await this.evaluate('postNetwork', ctx, true, false, false, 'fire', among); } /** Fire postExecute; a Deny or an Ask holds the output back, Limits merge to the boundary bound. */ async postExecute(ctx) { const [action, limit] = await this.fire('postExecute', ctx); return [action, limit]; } /** Fire preSession across the policies; the first Deny wins, else the first Ask. */ async preSession(ctx) { const [action] = await this.fire('preSession', ctx); return action; } /** Fire preWorkspace across the policies; the first Deny wins. */ async preWorkspace(ctx) { const [action] = await this.fire('preWorkspace', ctx); return action?.kind === 'deny' ? action : null; } /** Fire postSession across the policies; the first Deny wins, else the first Ask. */ async postSession(ctx) { const [action] = await this.fire('postSession', ctx); return action; } } //# sourceMappingURL=policies.js.map