@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
498 lines • 21.5 kB
JavaScript
// ========= 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