UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

496 lines • 22.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 { networkLayer, networkRoutes, NetworkRule } from "../io/network.js"; import { parseProcessPermissions } from "../process/config.js"; import { validateDocument } from "../rs/document.js"; import { ScriptSource } from "../runtime/types.js"; import { EnvVar, SecretRef } from "../secrets/config.js"; import { parseMountMode, weakerMode } from "../types.js"; import { normalizeFields, snakeToCamel } from "../utils/normalize.js"; import { norm } from "../utils/path.js"; import { isPlainObject } from "../utils/records.js"; import { compareCodePoints } from "../utils/sort.js"; import { checkCommandLimits, commandLimitsToJSON, parseCommandLimits, } from "./builtin/output_cap.js"; import { CTX_ENV, DEFAULT_ASK_REASON, DEFAULT_DENY_REASON, METHODS, PROGRAM_RUNTIME, } from "./constants.js"; const VARS_FIELDS = ['hide']; const COMMANDS_FIELDS = ['allow', 'ask', 'deny']; const MOUNT_COMMANDS_FIELDS = ['ask', 'deny']; const PROFILE_MOUNT_FIELDS = ['mode', 'commands', 'paths']; const PROFILE_FIELDS = [ 'cwd', 'env', 'mounts', 'paths', 'vars', 'commands', 'policy', 'command_limits', 'processes', 'network', ]; const PROGRAM_FIELDS = ['script', 'runtime', 'env']; function rejectUnknownKeys(block, allowed, where) { for (const key of Object.keys(block)) { if (!allowed.includes(key)) { throw new Error(`${where}: unknown field \`${key}\` (allowed: ${allowed.join(', ')})`); } } } function asObject(raw, where) { if (!isPlainObject(raw)) throw new Error(`${where} must be a mapping`); return raw; } // An entry that names something must hold a token: a blank command // pattern is a prefix of every line, so a stray "" would allow, ask about // or deny every command, and a blank path entry is the root, so it would // hide or deny the whole tree. `names` says what an entry names ("a // command", "a path"); omitted, blank entries pass. function stringList(raw, where, names) { return validateDocument('strings', raw, where, names === undefined ? {} : { names }); } /** * Refuse a relative path entry. Every path in the document is absolute: * an entry is either an absolute path or a name pattern (`*.pem`, no * slash, matching a path component anywhere). A plain `xxx` or an * anchored `secrets/*` would otherwise be read from the root (`/xxx`, * `/secrets/*`), which is never what a relative spelling meant. There is * no relative spelling anywhere: a mount section spells its paths in * full and they are checked against the mount root (`underMount`), * which is what a rebase used to do silently and wrongly (`/repo/secret` * under `/repo` became `/repo/repo/secret`). */ function absolutePaths(entries, where) { validateDocument('absolute', entries, where); } /** * Refuse a path entry in a mount's section that leaves the mount. A * mount's rules are about that mount, so a path under `mounts./repo` * names something inside `/repo`. A name pattern carries no anchor and * is left alone; it means the same thing here as anywhere else. * * The root mount contains everything, and has to be spelled out: * `root + '/'` is `'//'` there, which no path starts with, so a * workspace mounted at `/` could write a section for its one mount and * then name nothing inside it. */ function underMount(entries, root, where) { validateDocument('under', entries, where, { root }); } /** * Coerce a `deny` or `ask` list to its rules. A bare string is one * command pattern over the whole line, with the arm's default reason. A * mapping carries `reason` (defaulting) and exactly one of: `commands` * as a list, a whole-line rule on each pattern; `commands` as a mapping, * each command pattern on its own paths (one command to many paths, one * rule per command); `paths` alone, a path rule on every command. A list * of commands beside a list of paths is refused, because it does not say * which command the paths belong to, and a rule naming neither is * refused rather than read as "every command". */ function rules(raw, where, arm) { const reason = arm === 'ask' ? DEFAULT_ASK_REASON : DEFAULT_DENY_REASON; return validateDocument('rules', raw, `${where}.${arm}`, { reason }); } function parseAllow(raw, where) { if (raw === undefined || raw === null) return null; return stringList(raw, `${where}.allow`, 'a command'); } /** Validate a `paths:` block. Every entry is absolute or a name pattern. */ export function PathsBlock(raw, where = 'paths') { return validateDocument('paths', raw, where); } export function VarsBlock(raw, where = 'vars') { const obj = asObject(raw, where); rejectUnknownKeys(obj, VARS_FIELDS, where); return { hide: stringList(obj.hide, `${where}.hide`) }; } export function CommandsBlock(raw, where = 'commands') { const obj = asObject(raw, where); rejectUnknownKeys(obj, COMMANDS_FIELDS, where); const ask = rules(obj.ask, where, 'ask'); const deny = rules(obj.deny, where, 'deny'); // This block is the profile's own, never a mount section's, so a rule's // paths are virtual paths: absolute, or name patterns. for (const rule of ask) absolutePaths(rule.paths ?? [], `${where}.ask rule paths`); for (const rule of deny) absolutePaths(rule.paths ?? [], `${where}.deny rule paths`); return { allow: parseAllow(obj.allow, where), ask, deny }; } /** Validate a mount section's `commands:` block (`ask` and `deny` only). */ export function MountCommandsBlock(raw, where = 'commands') { const obj = asObject(raw, where); rejectUnknownKeys(obj, MOUNT_COMMANDS_FIELDS, where); return { ask: rules(obj.ask, where, 'ask'), deny: rules(obj.deny, where, 'deny') }; } /** Validate one `mounts.<prefix>` section of a profile. */ export function ProfileMount(raw, root, where) { // A bare mode string is sugar for the section that carries only a mode. const obj = typeof raw === 'string' ? { mode: raw } : asObject(raw, where); rejectUnknownKeys(obj, PROFILE_MOUNT_FIELDS, where); const out = {}; if (obj.mode !== undefined && obj.mode !== null) { if (typeof obj.mode !== 'string') throw new Error(`${where}.mode must be a mode name or alias`); out.mode = parseMountMode(obj.mode); } if (obj.paths !== undefined && obj.paths !== null) { const paths = PathsBlock(obj.paths, `${where}.paths`); underMount(paths.hide, root, `${where}.paths.hide`); underMount((paths.show ?? []).map((e) => e.path), root, `${where}.paths.show`); out.paths = paths; } if (obj.commands !== undefined && obj.commands !== null) { const commands = MountCommandsBlock(obj.commands, `${where}.commands`); for (const rule of commands.ask ?? []) underMount(rule.paths ?? [], root, `${where}.commands.ask`); for (const rule of commands.deny ?? []) underMount(rule.paths ?? [], root, `${where}.commands.deny`); out.commands = commands; } return out; } /** * Normalize a profile's `mounts` mapping: prefix to its settings, with a * bare mode string as sugar for a section carrying only a mode. A bare * list used to mean "only these mounts" and now means nothing at all, * so it fails loudly rather than quietly dropping the confinement it * used to carry. */ export function parseProfileMounts(raw, where = 'mounts') { if (raw === undefined || raw === null) return null; let entries; if (raw instanceof Map) entries = [...raw.entries()]; else if (isPlainObject(raw)) entries = Object.entries(raw); else throw new Error(`${where} must be a mapping of prefix to its settings`); const sections = new Map(); for (const [prefix, entry] of entries) { if (typeof prefix !== 'string') throw new Error(`${where} keys must be strings`); const root = norm(prefix); sections.set(root, ProfileMount(entry, root, `${where}[${root}]`)); } return sections; } /** * Validate a profile's `policy` block: per method, a list of programs, * each naming its program and the runtime that runs it, monty when it * names none. * A config file spells the methods and a runtime's settings as the * document does (`pre_command`, `auto_load_from_imports`); code, with * `code` set, as the interface does (`preCommand`, `autoLoadFromImports`). */ export function parseProfilePolicy(raw, where, code = false) { const keys = code ? Object.keys(METHODS) : Object.values(METHODS); if (typeof raw === 'string' || raw instanceof ScriptSource) { throw new Error(`${where} must be a mapping of method to programs`); } const obj = asObject(raw, where); if ('script' in obj || 'runtime' in obj) { throw new Error(`${where} names its programs per method, policy: ` + `{pre_command: [{script: <file>, runtime: <runtime>}]}`); } const unknown = Object.keys(obj).filter((key) => !keys.includes(key)); if (unknown.length > 0) { throw new Error(`${where} names ${unknown.sort(compareCodePoints).join(', ')}; a program answers at ` + keys.join(', ')); } const out = {}; for (const key of keys) { const programs = obj[key]; if (programs === undefined) continue; if (!Array.isArray(programs)) throw new Error(`${where}.${key} must be a list of programs`); out[snakeToCamel(key)] = programs.map((raw, i) => parseProgram(raw, `${where}.${key}[${String(i)}]`, code)); } return out; } /** * Validate one policy program, `{script, runtime}`: a profile's, or the * workspace's `route_policy`. `code` reads a runtime's settings as the * interface spells them, as `parseProfilePolicy` does. */ export function parseProgram(raw, at, code = false) { const program = asObject(raw, at); rejectUnknownKeys(program, PROGRAM_FIELDS, at); const given = program.script; if (given === undefined || given === null) { throw new Error(`${at}.script names the policy's program, and is required`); } // A path, a ScriptSource code built, or the {source, language, module} a // JSON profile spells one as, as Python's model reads it. const fields = typeof given === 'object' && !(given instanceof ScriptSource) && !Array.isArray(given) ? given : null; if (!(typeof given === 'string' || given instanceof ScriptSource) && (fields === null || Object.keys(fields).some((key) => !['source', 'language', 'module'].includes(key)) || typeof fields.source !== 'string' || (fields.language !== undefined && fields.language !== 'python' && fields.language !== 'js') || (fields.module !== undefined && typeof fields.module !== 'boolean'))) { throw new Error(`${at}.script must be a script path or source`); } const script = fields === null ? given : new ScriptSource(fields.source, (fields.language ?? 'python'), fields.module === true); let env = {}; if (program.env !== undefined) { const entries = asObject(program.env, `${at}.env`); if (CTX_ENV in entries) { throw new Error(`${at}.env: a policy program's env cannot set ${CTX_ENV}, which carries its context`); } const parsed = Object.entries(entries).map(([name, value]) => { const pointer = typeof value === 'string' ? null : SecretRef.safeParse(value); if (pointer !== null && !pointer.success) { throw new Error(`${at}.env.${name} must be a string or a {from, ref, key} pointer`); } return [name, pointer === null ? value : pointer.data]; }); env = { env: Object.fromEntries(parsed) }; } const runtime = program.runtime === undefined ? PROGRAM_RUNTIME : program.runtime; if (typeof runtime === 'string') return { script, runtime, ...env }; const named = asObject(runtime, `${at}.runtime`); const settings = named.config; if (Object.keys(named).some((field) => field !== 'name' && field !== 'config') || typeof named.name !== 'string' || (settings !== undefined && (typeof settings !== 'object' || settings === null || Array.isArray(settings)))) { throw new Error(`${at}.runtime: a policy runtime is a name or {name, config}, got ` + JSON.stringify(runtime)); } if (settings === undefined) return { script, runtime: { name: named.name }, ...env }; const config = settings; return { script, runtime: { name: named.name, config: code ? config : normalizeFields(config) }, ...env, }; } /** * A policy program as it runs: its source, its runtime's name and that * runtime's settings. `profile` is the profile it speaks for, empty for * the workspace's `routePolicy`. Mirrors the Python `compile_program`. * * @throws Error - the script is still a path, which only the config * loader loads. */ export function compileProgram(program, profile) { if (typeof program.script === 'string') { throw new Error(`names a policy by path ('${program.script}'); only the config loader loads one, ` + `pass ScriptSource in code`); } const named = program.runtime ?? PROGRAM_RUNTIME; const runtime = typeof named === 'string' ? { name: named, config: {} } : named; return { profile, script: program.script, runtime: runtime.name, config: { ...runtime.config }, env: { ...program.env }, }; } /** Validate one profile (a `profiles.<name>` block, or an inline document). */ export function SessionProfile(raw, where = 'profile') { const obj = asObject(raw, where); // The keys this was first shipped under, a `script` named for what // the file is rather than what it does and a `runtime` beside it // that read as the profile's own; refused with the new spelling // rather than as two more unknown keys. if (obj.script !== undefined || obj.runtime !== undefined) { throw new Error(`${where}: script and runtime are now a policy block, ` + `policy: {pre_command: [{script: <file>, runtime: <runtime>}]}`); } rejectUnknownKeys(obj, PROFILE_FIELDS, where); const out = {}; if (obj.network != null) { const layer = networkLayer(obj.network); new NetworkRule(layer === null ? [] : [layer], false, networkRoutes(obj.network)); out.network = obj.network; } if (obj.command_limits != null) { out.commandLimits = parseCommandLimits(obj.command_limits); checkCommandLimits(out.commandLimits, 'profile', true); } if (obj.processes != null) out.processes = parseProcessPermissions(obj.processes); if (obj.policy !== undefined && obj.policy !== null) { out.policy = parseProfilePolicy(obj.policy, `${where}.policy`); } if (obj.cwd !== undefined && obj.cwd !== null) { if (typeof obj.cwd !== 'string') throw new Error(`${where}.cwd must be a string`); out.cwd = obj.cwd; } if (obj.env !== undefined && obj.env !== null) { const env = asObject(obj.env, `${where}.env`); for (const [k, v] of Object.entries(env)) { if (typeof v === 'string') continue; if (!isPlainObject(v)) throw new Error(`${where}.env.${k} must be a string or an env entry`); const parsed = EnvVar.safeParse(v); if (!parsed.success) { throw new Error(`${where}.env.${k}: ${parsed.error.issues.map((i) => i.message).join('; ')}`); } } out.env = env; } if (obj.mounts !== undefined && obj.mounts !== null) { out.mounts = parseProfileMounts(obj.mounts, `${where}.mounts`); } if (obj.paths !== undefined && obj.paths !== null) out.paths = PathsBlock(obj.paths, `${where}.paths`); if (obj.vars !== undefined && obj.vars !== null) out.vars = VarsBlock(obj.vars, `${where}.vars`); if (obj.commands !== undefined && obj.commands !== null) out.commands = CommandsBlock(obj.commands, `${where}.commands`); return out; } /** * A profile as the document that parses back to it: the shape a * `profiles:` entry has in YAML, which either host reads. Every block is * spelled as the document spells it, a program's runtime settings * included, and a script as its source, language and module flag. * Mirrors Python `profile_to_dict`. */ export function profileToDict(profile) { const doc = {}; if (profile.cwd != null) doc.cwd = profile.cwd; if (profile.env != null) doc.env = { ...profile.env }; if (profile.vars != null) doc.vars = { hide: [...profile.vars.hide] }; if (profile.policy != null) { doc.policy = Object.fromEntries(Object.entries(profile.policy).map(([method, programs]) => [ METHODS[method], programs.map(({ script, runtime = PROGRAM_RUNTIME, env }) => ({ script: typeof script === 'string' ? script : { source: script.source, language: script.language, module: script.module }, runtime: typeof runtime === 'string' ? runtime : { name: runtime.name, ...(runtime.config !== undefined ? { config: Object.fromEntries(Object.entries(runtime.config).map(([key, value]) => [ key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`), value, ])), } : {}), }, ...(env !== undefined && Object.keys(env).length > 0 ? { env: { ...env } } : {}), })), ])); } if (profile.commandLimits != null) doc.command_limits = commandLimitsToJSON(profile.commandLimits); if (profile.processes != null) doc.processes = parseProcessPermissions(profile.processes); if (profile.network != null) doc.network = profile.network; if (profile.mounts != null) { doc.mounts = Object.fromEntries([...profile.mounts].map(([prefix, entry]) => [prefix, mountDoc(entry)])); } if (profile.paths != null) doc.paths = pathsDoc(profile.paths); if (profile.commands != null) doc.commands = commandsDoc(profile.commands); return doc; } /** * The profile a document states, as `profileToDict` writes it: a script * written as its source comes back as the ScriptSource code passes. * Mirrors Python `profile_from_dict`. */ export function profileFromDict(doc) { if (!isPlainObject(doc.policy)) return SessionProfile(doc); const policy = Object.fromEntries(Object.entries(doc.policy).map(([method, programs]) => [ method, Array.isArray(programs) ? programs.map((program) => isPlainObject(program) && isPlainObject(program.script) && typeof program.script.source === 'string' ? { ...program, script: new ScriptSource(program.script.source, program.script.language, program.script.module === true), } : program) : programs, ])); return SessionProfile({ ...doc, policy }); } function mountDoc(entry) { const doc = {}; if (entry.mode != null) doc.mode = entry.mode; if (entry.paths != null) doc.paths = pathsDoc(entry.paths); if (entry.commands != null) doc.commands = commandsDoc(entry.commands); return doc; } function pathsDoc(block) { // A path shown twice answers with the weaker of its modes, and an // entry without one states visibility only (it sets no mode), so the // mapping keeps that answer for each path. const show = new Map(); for (const e of block.show ?? []) { const held = show.get(e.path) ?? null; show.set(e.path, held === null ? e.mode : e.mode === null ? held : weakerMode(held, e.mode)); } return { hide: [...block.hide], show: Object.fromEntries(show), reasons: (block.reasons ?? []).map((g) => ({ patterns: [...g.patterns], reason: g.reason })), }; } function commandsDoc(block) { const doc = { ask: (block.ask ?? []).map(ruleDoc), deny: (block.deny ?? []).map(ruleDoc), }; if ('allow' in block && block.allow != null) doc.allow = [...block.allow]; return doc; } function ruleDoc(rule) { const commands = rule.commands ?? []; const paths = rule.paths ?? []; // A rule naming commands and paths is the mapping form, each command // on the rule's paths; it reads back as one rule per command, which // admits exactly what the one rule did. if (commands.length > 0 && paths.length > 0) { return { reason: rule.reason, commands: Object.fromEntries(commands.map((c) => [c, [...paths]])), }; } if (paths.length > 0) return { reason: rule.reason, paths: [...paths] }; return { reason: rule.reason, commands: [...commands] }; } //# sourceMappingURL=profile.js.map