UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

498 lines • 21.5 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 { runWithTimeout } from "../commands/builtin/utils/limit.js"; import { CommandTimeoutError } from "../errors/types.js"; import { WorkspaceBinding } from "../runtime/binding.js"; import { SCRIPT_EVAL_TIMEOUT } from "../runtime/constants.js"; import { MontyRuntime } from "../runtime/python/monty/runtime.js"; import { scriptEngine } from "../runtime/script.js"; import { SecretsError } from "../secrets/errors.js"; import { resolveConfigSecrets } from "../secrets/sources.js"; import { FileStat, PathSpec } from "../types.js"; import { compareCodePoints } from "../utils/sort.js"; import { CTX_ENV, DEFAULT_ASK_REASON, DEFAULT_DENY_REASON, METHODS, PROGRAM_FLAGS, PROGRAM_MEMORY, PROGRAM_RUNTIMES, } from "./constants.js"; import { SESSION_SCOPED } from "./mixin.js"; import { Answer, VALIDITY, } from "./types.js"; const DECODER = new TextDecoder(); const ENCODER = new TextEncoder(); // The live handles every context carries, which a program reads as data // instead: the session as its cwd, env and profile, the files as the mounts // they show and through the program's own open(). const LIVE = new Set(['session', 'vfs']); /** * A context's caller as a program reads it: the facts a policy reads of * it, its session's id and working directory. Mirrors the Python * `_caller_data`. */ function callerData(caller) { if (caller === undefined) return null; const session = caller.session; return { session: session === null ? null : { session_id: session.sessionId, cwd: session.cwd }, account: caller.account, }; } /** * A value as a program reads it: JSON-shaped data, bytes as text, a path * as its virtual spelling, a record as its fields under the Python * names (the live handles left out). A stat's `extra` is the backend's own * metadata: under `data` a plain object keeps its keys, as a Python dict * does, and a record in it still names its own fields. Mirrors the Python * `_plain`. */ function plain(value, data = false) { if (value === null || value === undefined) return null; if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') { return value; } if (typeof value === 'bigint') return Number(value); if (value instanceof Uint8Array) return DECODER.decode(value); if (value instanceof PathSpec) return value.virtual; if (Array.isArray(value) || value instanceof Set) { return [...value].map((item) => plain(item, data)); } if (value instanceof Map) { return Object.fromEntries([...value].map(([key, item]) => [String(key), plain(item, data)])); } if (typeof value === 'object') { const own = value; if (typeof own.toDict === 'function') return plain(own.toDict(), data); if (typeof own.toJSON === 'function') return plain(own.toJSON(), data); const proto = Object.getPrototypeOf(value); const named = !data || (proto !== Object.prototype && proto !== null); return Object.fromEntries(Object.entries(value) .filter(([key, item]) => !(named && LIVE.has(key)) && item !== undefined && typeof item !== 'function' && typeof item !== 'symbol') // Under the field names the document and the Python host spell. .map(([key, item]) => [ named ? key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) : key, named && key === 'caller' ? callerData(item) : plain(item, data || (value instanceof FileStat && key === 'extra')), ])); } return null; } /** * A policy context as a program reads it: every field a coded policy * reads, under the Python name, as plain data. The live handles become * data: the session its `cwd`, `env` and `profile`, the namespace the * `mounts` the session sees. The files are the program's own `open()`, * which reads as the session. Each of a line's `commands` also holds the * `env` it runs with, its own `NAME=value` prefix included. Mirrors the * Python `script_context`. */ export function scriptContext(ctx) { // A config is data as its writer spelled it: no field it holds is a // live handle or a camelCase name. if ('config' in ctx) return { config: plain(ctx.config, true), caller: callerData(ctx.caller) }; const data = plain(ctx); if ('commands' in ctx) { const commands = data.commands; data.commands = ctx.commands.map((command, at) => ({ ...commands[at], env: { ...(command.session?.snapshot() ?? {}) }, })); } const session = ctx.session; if (session !== undefined) { data.cwd = session.cwd(); data.env = { ...session.snapshot() }; data.profile = session.profile() ?? ''; } else { data.cwd ??= '/'; data.env = {}; data.profile = ''; } const vfs = ctx.vfs; data.mounts = vfs === undefined ? [] : vfs.submounts('/').filter((root) => root !== '/' && vfs.isMount(root)); return data; } /** * The answer a policy program printed at one method. * * Nothing (or `null`) is no opinion. Anything else is one JSON answer * (`Answer`): `{"kind": "deny", "reason": ...}`, `{"kind": "ask", * "reason": ...}`, or at `preExecute` `{"kind": "route", "runtime": ...}`. * A deny or an ask without a reason carries the document's default one. * Throws a plain Error whose message is a clause about "script", for the * caller to prefix with whose policy it is. Mirrors the Python * `script_action`. */ export function scriptAction(output, method) { const text = DECODER.decode(output).trim(); if (text === '' || text === 'null') return null; let parsed; try { parsed = JSON.parse(text); } catch { parsed = undefined; } const answer = Answer.safeParse(parsed); const name = method.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); if (!answer.success) { throw new Error(`script must print a JSON answer at ${name}, ` + '{"kind": "deny" | "ask" | "route", ...}'); } const { kind, reason, runtime } = answer.data; if (!VALIDITY[method].has(kind)) throw new Error(`script cannot answer ${kind} at ${name}`); const said = reason ?? ''; if (kind === 'deny') return { kind, reason: said === '' ? DEFAULT_DENY_REASON : said }; if (kind === 'ask') return { kind, reason: said === '' ? DEFAULT_ASK_REASON : said }; return said === '' ? { kind, runtime: runtime ?? '' } : { kind, runtime: runtime ?? '', reason: said }; } /** * Each profile's policy programs, run at the `pre` methods. * * The scripted twin of `PermissionsPolicy`, registered right after it: * where that policy evaluates the document's declarative rules, this one * runs the profile's programs. A profile names, per method, a list of * programs and the runtime each runs on; they are asked in order, up to * the first deny, and their answers reach the chain as one array, each * read as if its own policy had said it: an Ask from one program and a * Route from another both stand, and two routes that disagree refuse the * line. A method with no program is silence and costs nothing. It reads the * session's programs through the narrow `SessionScriptsQuery` by the * session id the entry point put in the context. * * A program runs as any program does on its runtime: never edited, in the * session's cwd, with the context as JSON (`scriptContext`) in `CTX_ENV` * and on stdin, and its answer printed on stdout (`scriptAction`). * Beside `CTX_ENV` it is passed only its entry's own `env`, a pointer read * from the workspace's sources on the entry's first run and kept: the * session's variables are in the context, so none the agent sets changes * how the program starts, * and on pyodide it runs with `-P` (`PROGRAM_FLAGS`), so no file the agent * writes stands in for a module it imports. Every failure fails closed: a * program that exits non-zero, runs past the timeout, prints something * else, or names a runtime that cannot be built refuses the action with a * reason naming the profile, and is logged. The workspace's `routePolicy` * program runs here too (`run`), outside any profile. * * The runtime is the policy's own, built from the name and settings the * profile gives, shared across sessions and never the agent's, and capped * at `PROGRAM_MEMORY` unless the settings name a `maxMemory`. It is * wired to the workspace's files the way an agent's runtime is * (`ScriptWiring`), so a program reads what an operand holds as the * session reads it. A read from a program clears the dispatcher like any * other, except this policy's own `preVfs`: the policy is the one * asking, and judging its own read would re-enter the run waiting on it. * It knows its own read by `issuer`, a token only its dispatch stamps and * that rides each op as an argument: a mark kept in ambient context would * be whatever op another task dispatched while a read was in flight on a * runtime with no task isolation. Runs on one runtime are serialized; runs * on two runtimes go at once. * * A program on monty may also call the host's `externalLookup` functions * by name, sync or async, with plain data in and out (a dict arrives as * an object, as the Python host's arrives as a dict); one that throws * raises in the program. Its runs go at once: monty runs each on a worker * of its own, and a function may be slow or reach the workspace again. No * other runtime sees them. `secretSources` is the workspace's declared * sources an `env` pointer is read from; absent reads each from its * source's ambient defaults. Mirrors the Python `ScriptPolicy`. */ export class ScriptPolicy { [SESSION_SCOPED] = true; sessions; wiring; engines = new Map(); // The mark on every op this policy's own runtimes dispatch, and nothing // else's: unexported, so only the dispatch built for it can stamp it. issuer = Symbol('policy read'); // Each runtime's last run, which its next waits on. queues = new Map(); // The monty runtimes handed the external functions, whose runs go at once. unqueued = new Set(); externalLookup; secretSources; envs = new Map(); constructor(sessions, wiring = null, options = {}) { this.sessions = sessions; this.wiring = wiring; const plain = (value) => value instanceof Map ? Object.fromEntries([...value].map(([key, item]) => [String(key), plain(item)])) : Array.isArray(value) ? value.map(plain) : value; this.externalLookup = Object.fromEntries(Object.entries(options.externalLookup ?? {}).map(([name, fn]) => [ name, (...args) => fn(...args.map(plain)), ])); this.secretSources = options.secretSources; } async preExecute(ctx) { return this.judge('preExecute', ctx); } async preCommand(ctx) { return this.judge('preCommand', ctx); } async preVfs(ctx) { if (ctx.issuer === this.issuer) return null; return this.judge('preVfs', ctx); } async preSession(ctx) { return this.judge('preSession', ctx); } async preWorkspace(ctx) { return this.judge('preWorkspace', ctx); } async preNetwork(ctx) { return this.judge('preNetwork', ctx); } async postNetwork(ctx) { return this.judge('postNetwork', ctx); } async postExecute(ctx) { return this.judge('postExecute', ctx); } async postCommand(ctx) { return this.judge('postCommand', ctx); } async postVfs(ctx) { if (ctx.issuer === this.issuer) return null; return this.judge('postVfs', ctx); } async postSession(ctx) { return this.judge('postSession', ctx); } /** Whether this session's profile names a program at `method`. */ wantsFor(method, sessionId) { const programs = Object.hasOwn(METHODS, method) ? this.sessions.scriptsOf(sessionId)[method] : []; return Promise.resolve((programs?.length ?? 0) > 0); } /** * One program's answer at one method, outside any profile: the * workspace's `routePolicy`. * * @throws Error - the program failed: it exited non-zero, ran past the * timeout, printed something else, or names a runtime that cannot be * built. The message says what, as a clause for the caller to prefix * with whose program it is. */ async run(entry, method, ctx) { const payload = JSON.stringify(scriptContext(ctx)); try { return await this.answer(entry, method, payload, await this.cwd(ctx)); } catch (err) { throw new Error(clause(err)); } } /** Close every runtime a program ran on. */ async close() { const engines = [...this.engines.values()]; this.engines.clear(); this.queues.clear(); this.unqueued.clear(); for (const engine of engines) await engine.close(); } /** * Every program the session's profile names at one method, each answer * in order, up to the first deny. */ async judge(method, ctx) { const programs = this.sessions.scriptsOf('config' in ctx ? '' : (ctx.sessionId ?? ''))[method] ?? []; if (programs.length === 0) return null; const payload = JSON.stringify(scriptContext(ctx)); const cwd = await this.cwd(ctx); const answers = []; for (const entry of programs) { let answer; try { answer = await this.answer(entry, method, payload, cwd); } catch (err) { answer = failed(entry, clause(err)); } if (answer !== null) answers.push(answer); if (answer?.kind === 'deny') break; } return answers.length > 0 ? answers : null; } /** * The directory a program starts in: the session's, or `/` once the * session's has gone, since a runtime that enters it first cannot start * in a directory that is not there. */ async cwd(ctx) { if ('config' in ctx) return '/'; const cwd = ctx.session?.cwd() ?? '/'; if (cwd !== '/' && ctx.vfs !== undefined && !(await ctx.vfs.exists(cwd))) return '/'; return cwd; } /** One program at one method; throws a clause about "script" when it fails. */ async answer(entry, method, payload, cwd) { let result; try { const engine = this.engine(entry); const env = await this.env(entry); const run = async () => { // The runtime stops itself at the deadline; the signal stops it // when the wait gives up first, before the next run takes the // runtime. const cancel = new AbortController(); try { return await runWithTimeout(engine.execute([], [], { flags: { c: entry.script.source, ...PROGRAM_FLAGS.get(entry.runtime) }, cwd: PathSpec.fromStrPath(cwd), env: { ...env, [CTX_ENV]: payload }, stdin: ENCODER.encode(payload), signal: cancel.signal, timeoutSeconds: SCRIPT_EVAL_TIMEOUT.seconds, }), SCRIPT_EVAL_TIMEOUT.seconds, 'policy program'); } catch (err) { cancel.abort(); throw err; } }; result = this.unqueued.has(engine) ? await run() : await this.serialized(engine, run); } catch (err) { if (err instanceof CommandTimeoutError) { throw new Error(`script timed out after ${String(SCRIPT_EVAL_TIMEOUT.seconds)}s`); } throw err; } if (result.exitCode !== 0) { const stderr = DECODER.decode(result.stderr ?? new Uint8Array()); console.error(`policy program on ${entry.runtime} exited ${String(result.exitCode)}\n${stderr.slice(-2000)}`); throw new Error(`script exited ${String(result.exitCode)}`); } return scriptAction(result.stdout, method); } /** * The policy's own runtime for one program, built on first use and * shared by every program naming the same runtime and settings. Throws * a clause about "script" when the runtime is not one a policy program * runs on, or cannot be built here. */ engine(entry) { if (!PROGRAM_RUNTIMES.has(entry.runtime)) { throw new Error(`script names runtime '${entry.runtime}'; a policy program runs on ` + [...PROGRAM_RUNTIMES].sort(compareCodePoints).join(' or ')); } const config = { maxMemory: PROGRAM_MEMORY.get(entry.runtime), ...entry.config }; const settings = Object.entries(config).sort(([a], [b]) => compareCodePoints(a, b)); const key = JSON.stringify([entry.runtime, settings]); let engine = this.engines.get(key); if (engine === undefined) { engine = scriptEngine(entry.script, entry.runtime, config); // Keep policy-origin attribution on the binding's dispatch. if (this.wiring !== null) { engine.bind(new WorkspaceBinding(this.wiring.dispatch(this.issuer), this.wiring.resolver)); } // A program judges reads that runs on the shared pool make, so it must // not queue behind them for a worker. if (engine instanceof MontyRuntime) engine.shared = false; if (Object.keys(this.externalLookup).length > 0 && engine instanceof MontyRuntime) { engine.externalLookup = this.externalLookup; this.unqueued.add(engine); } this.engines.set(key, engine); } return engine; } /** * One program's `env` with every pointer read: one read per program, * shared by the runs waiting on it and kept once it answers, given the * program's timeout and retried by the next run after it fails or runs * out of time. Throws a clause about "script" when a source cannot * answer. */ env(entry) { const key = JSON.stringify([ entry.profile, entry.runtime, entry.script.source, Object.entries(entry.env).sort(([a], [b]) => compareCodePoints(a, b)), ]); let reading = this.envs.get(key); if (reading === undefined) { reading = runWithTimeout(this.readEnv(entry), SCRIPT_EVAL_TIMEOUT.seconds, 'policy program'); this.envs.set(key, reading); reading.catch(() => { if (this.envs.get(key) === reading) this.envs.delete(key); }); } return reading; } /** One read of a program's `env` pointers; throws a clause about "script" when a source cannot answer. */ async readEnv(entry) { const sources = this.secretSources === undefined ? undefined : await this.secretSources(); let read; try { read = await resolveConfigSecrets({ ...entry.env }, sources, 'env'); } catch (err) { if (err instanceof SecretsError) throw new Error(`script ${err.message}`); throw err; } return Object.fromEntries(Object.entries(read).map(([name, value]) => [name, String(value)])); } /** `work` after every run on `engine` before it: two runs on one runtime would interleave. */ serialized(engine, work) { const run = (this.queues.get(engine) ?? Promise.resolve()).then(work); this.queues.set(engine, run.catch(() => undefined)); return run; } } /** The fail-closed refusal: one wording however the program broke, logged. */ function failed(entry, detail) { const reason = `profile '${entry.profile}' policy ${detail}`; console.error(reason); return { kind: 'deny', reason }; } /** * An error's message as the clause after "policy": the runtime builder * and the answer reader both speak of "script", which is the program's * generic name, and the profile's word for its program is policy. */ function clause(err) { const message = err instanceof Error ? err.message : String(err); return message.replace(/^script /, ''); } //# sourceMappingURL=script.js.map