UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

162 lines • 6.93 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 { WordKind } from "../../commands/spec/types.js"; import { units } from "../../rs/document.js"; import { admissionMatcher } from "../../rs/policy.js"; import { METADATA_OPS, SUBTREE_OPS } from "../constants.js"; import { sameRule } from "../decisions.js"; import { lineTokens } from "./allow.js"; /** * The paths a command's operands name, in order: what `rm -r` removes or * `mv`, `tar` and `cp` take as sources, never an option's value. Mirrors * Python's `operand_paths`. */ export function operandPaths(ctx) { return (ctx.words ?? []).flatMap((word) => word.kind === WordKind.OPERAND && word.path !== null ? [word.path] : []); } function pathFact(path) { return { virtual_path: units(path.virtual), display_path: units(path.rawPath || path.virtual) }; } /** * What a line's rules are read against, in the stream's wire shape: its * tokens and paths, and the host's word on its command: whether it moves or * removes whole subtrees (`CommandContext.subtree`) and whether it is a move * whose last operand is the destination. Mirrors Python's command_facts. */ export function commandFacts(ctx) { const moves = ctx.command === 'mv'; const target = moves && ctx.paths.length > 0 ? (ctx.moveTarget ?? null) : null; return { tokens: lineTokens(ctx).map(units), tool: ctx.tool ?? true, cwd: units(ctx.cwd), paths: ctx.paths.map(pathFact), operands: operandPaths(ctx).map(pathFact), walks: ctx.walks ?? false, subtree: ctx.subtree ?? false, moves, move_target: target === null ? null : units(target), }; } /** * Whether a rule holds at a dispatcher too: a pure path rule, which names * no command and has paths to match, the one kind an op can meet. The * sort of a profile's rules: a POSIX-level rule holds at every entry point, a * command-level one (it names a command, or no path) only where a line * is judged. */ export function posixLevel(rule) { return (admissionMatcher({ allow: null, deny: [rule], ask: [] }).ask('posix_level', { rule: 0, }) === true); } /** * The command-level parts of a profile's rules, which an entry point that sees * only ops (a kernel mount, SFTP, codex-exec's file calls) cannot apply, * each described for the operator's warning: the allow list and every * rule that is not POSIX level. */ export function skippedAtDispatch(rules) { if (rules === null) return []; const skipped = rules.allow === null ? [] : ['commands.allow']; for (const [verb, listed] of [ ['deny', rules.deny], ['ask', rules.ask], ]) for (const rule of listed) if (!posixLevel(rule)) skipped.push(`commands.${verb}: ${rule.reason}`); return skipped; } /** * Whether a rule in force could reach an entry at or under this path for * the running line: the rule names the line and its paths, or a mount * rule's mount, could cover something in the subtree. What a native walk asks before it trusts the * raw tree, per operand the way a hide is: a rule on `/repo/.env` leaves * `find /s3` its native op. */ export function ioReach(rules, tokens, virtual) { return (rules !== null && admissionMatcher(rules).ask('io_reach', { tokens: tokens.map(units), path: units(virtual), }) === true); } function grantIndices(rules, granted) { if (granted.length === 0) return []; return [...rules.deny, ...rules.ask].flatMap((rule, id) => granted.some((nod) => sameRule(nod, rule)) ? [id] : []); } function ruleAt(rules, id) { const rule = [...rules.deny, ...rules.ask][id]; if (rule === undefined) throw new Error('Invalid policy rule reply'); return rule; } /** * The reason a command may not touch an entry it reached on its own, * null when it may. * * The same law the admission gate applies to a line, and literally the * same loop (`ruleAt`): anchor depth first, deny before ask * at equal depth. Reading every deny before any ask instead would let a * broad deny on `/repo/*` overrule an approved ask on `/repo/sealed/*` * that the gate had just admitted the line under, so the carve-out * would survive admission and then refuse every entry it was written * for. * * The winning rule then answers: a deny refuses, an ask refuses unless * the line holds a grant under it (the nod the gate took for `rm -r /x` * covers the entries under `/x`; a walk that wanders into an asked * scope from outside gets no nod mid-command, so it is refused and the * agent names the path to be asked). */ export function ioRefusal(rules, tokens, virtual, granted) { if (rules === null) return null; const id = admissionMatcher(rules).ask('io_refusal', { tokens: tokens.map(units), path: units(virtual), granted: grantIndices(rules, granted), }); return id === null ? null : ruleAt(rules, id).reason; } /** * The rule an op meets and whether it asks, null when the op may run. * * The dispatcher twin of `ioRefusal`, and the same law: anchor depth first, * deny before ask at equal depth, and an ask satisfied by a grant the * line already holds. Reading every deny before any ask instead let a * broad deny on `/repo/*` overrule an approved ask on `/repo/outbox/*`, * so the carve-out command admission had just admitted the line under * could not authorize the redirect it was written for: the write reached * the dispatcher and was refused there. * * What an ask means is the entry point's to say: inside a running line it * refuses like a deny, since the line was admitted without it, and * outside one (a file tool, the host's facade) it goes to the approval * ledger, keyed by rule and path. Mirrors Python's `op_ruling`. */ export function opRuling(rules, ctx, granted) { if (rules === null || METADATA_OPS.has(ctx.call.name)) return null; const answer = admissionMatcher(rules).ask('op_ruling', { subtree: SUBTREE_OPS.has(ctx.call.name), path: units(ctx.path.virtual), granted: grantIndices(rules, granted), }); return answer === null ? null : [ruleAt(rules, answer[0]), answer[1]]; } //# sourceMappingURL=rule.js.map