@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
315 lines • 13.8 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 { operandPaths } from "../match/rule.js";
import { isCreateMode } from "../../commands/builtin/generic/tar/mode.js";
import { expandTableLong } from "../../commands/spec/compile.js";
import { GNU_LONG_OPTIONS } from "../../commands/spec/long_options.js";
import { ebusy, eexist, einval, eisdir } from "../../errors/fs.js";
/**
* Spot ln's -s/--symbolic by raw token scan. Same reason as
* hasParentsFlag: the policy fires before flag parsing, and GNU words
* the refusal by link kind ("failed to create symbolic link" vs
* "failed to create link").
*/
const LN_VALUED_SHORTS = 'tS';
const LN_VALUED_LONGS = new Set(['--target-directory', '--suffix']);
// Whether ln's raw argv carries one short flag or its long spelling. The
// scan is option-aware so an operand cannot pose as a flag: it stops at
// `--`, skips the value of a valued option (`-t DIR`, `-S SUF`, their
// long forms), and inside a cluster stops at the first valued letter,
// whose remainder is its attached value (`-SfooT` carries no -T).
export function lnFlagPresent(argv, letter, long) {
let skip = false;
for (const tok of argv) {
if (skip) {
skip = false;
continue;
}
if (tok === '--')
return false;
if (tok === long)
return true;
if (tok.startsWith('--')) {
skip = LN_VALUED_LONGS.has(tok);
continue;
}
if (!tok.startsWith('-') || tok.length < 2)
continue;
for (let pos = 1; pos < tok.length; pos++) {
const ch = tok[pos];
if (ch === letter)
return true;
if (ch !== undefined && LN_VALUED_SHORTS.includes(ch)) {
skip = pos === tok.length - 1;
break;
}
}
}
return false;
}
function hasNoTargetFlag(argv) {
return lnFlagPresent(argv, 'T', '--no-target-directory');
}
function hasSymlinkFlag(argv) {
return lnFlagPresent(argv, 's', '--symbolic');
}
/**
* Spot mkdir's -p/--parents by raw token scan. The policy fires before
* flag parsing (its refusals must win over parse errors and stay
* consistent across the single-mount and cross-mount paths), so the
* shorthand cluster (-pv) is detected on the raw argv rather than
* through the spec parser.
*/
export function hasParentsFlag(argv) {
for (const tok of argv) {
if (tok === '-p' || tok === '--parents')
return true;
if (tok.startsWith('-') && !tok.startsWith('--') && tok.includes('p'))
return true;
}
return false;
}
/**
* What an rm line asks of a directory operand, by raw token scan: recurse
* (-r, -R), remove an empty one (-d), and keep `/` out of a recursive
* removal, which is the default; the last of --preserve-root and
* --no-preserve-root wins. A long word is read against rm's whole table, so
* an abbreviation counts too. Mirrors Python's rm_options.
*/
export function rmOptions(argv) {
let recursive = false;
let emptyDir = false;
let preserve = true;
for (const tok of argv) {
if (tok === '-')
continue;
if (tok === '--')
break;
if (tok.startsWith('--')) {
const name = expandTableLong(GNU_LONG_OPTIONS.rm ?? [], tok.split('=', 1)[0] ?? tok);
const only = name.length === 1 ? name[0] : undefined;
if (only === '--recursive')
recursive = true;
else if (only === '--dir')
emptyDir = true;
else if (only === '--preserve-root')
preserve = true;
else if (only === '--no-preserve-root')
preserve = false;
continue;
}
if (tok.startsWith('-')) {
recursive ||= tok.includes('r') || tok.includes('R');
emptyDir ||= tok.includes('d');
}
}
return [recursive, emptyDir, preserve];
}
// An operand as fts hands it back: two or more trailing slashes trimmed to
// one, so `///` reads `/` while `//` stays.
export function ftsName(raw) {
let end = raw.length;
if (end > 2 && raw.endsWith('/')) {
while (end > 1 && raw[end - 2] === '/')
end -= 1;
}
return raw.slice(0, end);
}
/**
* rm's refusal of a mount root, in GNU rm's order and words: a recursive `.`
* or `..` is skipped before anything is looked at, a directory without -r or
* -d is not removed at all, and a recursive `/` meets the root failsafe; only
* what is left reaches the mountpoint and is busy. Mirrors Python's
* rm_root_refusal.
*/
export function rmRootRefusal(path, recursive, emptyDir, preserve) {
const raw = path.rawPath;
const last = raw.replace(/\/+$/, '').split('/').at(-1) ?? '';
if (recursive && (last === '.' || last === '..')) {
return deny(`refusing to remove '.' or '..' directory: skipping '${raw}'`, einval(path));
}
if (!recursive && !emptyDir)
return deny(`cannot remove '${raw}': Is a directory`, eisdir(path));
if (recursive && preserve && path.virtual.replaceAll('/', '') === '') {
const shown = ftsName(raw);
const named = shown === '/' ? "'/'" : `'${shown}' (same as '/')`;
return deny(`it is dangerous to operate recursively on ${named}\n` +
'rm: use --no-preserve-root to override this failsafe', einval(path));
}
return deny(`cannot remove '${raw}': Device or resource busy`, ebusy(path));
}
// Every mount-root refusal is the filesystem's own error in the command's
// own voice: the command plane prefixes the command name and picks the
// exit code from the operand table (1, tar 2), and no policy refusal is
// recorded.
function deny(reason, error) {
return { kind: 'deny', reason, error };
}
// Whether an operand names a mount root. An operand the kernel walk refused
// (`walkError`) names nothing, whatever its `virtual` reads as: the empty
// name simplifies to the working directory, which can be a mount root, and
// the command reports it ENOENT rather than busy. Mirrors Python's
// names_root.
function namesRoot(query, path) {
return path.walkError === null && query.isMountRoot(path.virtual);
}
// The first of these paths that is a mount root, if any.
function firstRoot(query, paths) {
for (const path of paths) {
if (namesRoot(query, path))
return path;
}
return null;
}
// Where mv's refusal says a source was going, as typed. Several sources, or
// `-t`, land inside the destination under their own names, as GNU names
// them. Two operands name the destination itself: GNU names DST/NAME when DST
// is a directory, which the policy cannot tell before anything is stat'd. A
// `-t` value is read back from argv already resolved, so it is named
// absolute. A lone operand has no destination and names `?`. Mirrors Python's
// mv_destination.
function mvDestination(operands, source, target) {
if (target === null && operands.length < 3) {
return operands.length === 2 && operands[1] !== undefined ? operands[1].rawPath : '?';
}
const parent = target ?? operands[operands.length - 1]?.rawPath ?? '';
const name = source.rawPath.replace(/\/+$/, '').split('/').at(-1) ?? '';
return `${parent.replace(/\/+$/, '')}/${name}`;
}
/**
* The built-in rule: a mount root is not an ordinary directory.
*
* Two rules, one boundary. The first mirrors the kernel's refusal to unlink
* or replace a mountpoint (EBUSY on Linux), with each command's own GNU
* message: rm, rmdir, mv, mkdir, touch and ln.
*
* The second is mirage's own, and is a deliberate divergence: an archiver or
* a recursive copy pointed at a mount root would read an entire backend into
* one object. Real tar and cp allow it because a mountpoint there is just
* another directory; here the mount table is the deployment's configuration,
* and consuming a whole mount is neither what the operand looks like it costs
* nor something an agent should be able to do to data it was merely given a
* view of. The refusal names the boundary in each tool's own voice rather
* than inventing a mirage error, so a caller sees a filesystem answer.
*
* Only positional operands are tested. tar's `-C` and unzip's `-d` are
* destinations to extract INTO, which is ordinary use of a mount, so reading
* them here would refuse the safe direction as well.
*
* Fires before mount resolution and cross-mount routing so the refusal is the
* same however the operands span mounts, and before runtime placement so a
* routed command is refused identically. MountRegistry seeds it as the first
* policy (mount-root semantics belong to the mount layer), so its exact
* messages win over user policies by order, not by privilege. `registry` is
* the mount table it asks.
*/
export class MountRootPolicy {
registry;
constructor(registry) {
this.registry = registry;
}
preCommand(ctx) {
if (ctx.paths.length === 0)
return null;
const cmd = ctx.command;
const operands = operandPaths(ctx);
if (cmd === 'rm') {
const root = firstRoot(this.registry, ctx.paths);
if (root === null)
return null;
return rmRootRefusal(root, ...rmOptions(ctx.argv));
}
if (cmd === 'rmdir') {
for (const p of ctx.paths) {
if (namesRoot(this.registry, p)) {
return deny(`failed to remove '${p.virtual}': Device or resource busy`, ebusy(p));
}
}
return null;
}
if (cmd === 'mv') {
// Sources are read off the positionals: `mv f /mnt` and
// `mv -t /mnt f` move INTO a mount root, which is ordinary.
// The refusal stops the whole line, as rm's does; GNU would
// move the other sources.
const target = ctx.moveTarget ?? null;
const sources = target !== null || operands.length < 2 ? operands : operands.slice(0, -1);
const root = firstRoot(this.registry, sources);
if (root === null)
return null;
return deny(`cannot move '${root.rawPath}' to '${mvDestination(operands, root, target)}': Device or resource busy`, ebusy(root));
}
if (cmd === 'mkdir') {
// GNU mkdir -p makes "already exists" a no-op.
if (hasParentsFlag(ctx.argv))
return null;
for (const p of ctx.paths) {
if (namesRoot(this.registry, p)) {
return deny(`cannot create directory '${p.virtual}': File exists`, eexist(p));
}
}
return null;
}
if (cmd === 'touch') {
// Positionals only: `-r REF` is read, never touched.
for (const p of operands) {
if (namesRoot(this.registry, p)) {
return deny(`cannot touch '${p.virtual}': Is a directory`, eisdir(p));
}
}
return null;
}
if (cmd === 'ln') {
// A mount root is refused only as the link NAME. Without -T a
// directory operand is the directory to link into, GNU's rule, and
// creating inside a mount is ordinary.
const last = ctx.paths[ctx.paths.length - 1];
if (last !== undefined && hasNoTargetFlag(ctx.argv) && namesRoot(this.registry, last)) {
const kind = hasSymlinkFlag(ctx.argv) ? 'symbolic link' : 'link';
return deny(`failed to create ${kind} '${last.virtual}': File exists`, eexist(last));
}
return null;
}
if (cmd === 'tar') {
// Only -c reads the filesystem; -t and -x match their operands
// against names inside the archive.
const root = isCreateMode(ctx.argv) ? firstRoot(this.registry, operands) : null;
if (root !== null) {
return deny(`${root.rawPath}: Cannot open: Device or resource busy\n` +
`tar: Error is not recoverable: exiting now`, ebusy(root));
}
return null;
}
if (cmd === 'zip') {
// The first operand is the archive being written, not a source;
// only what follows it is read.
const root = firstRoot(this.registry, operands.slice(1));
if (root !== null) {
return deny(`cannot read '${root.rawPath}': Device or resource busy`, ebusy(root));
}
return null;
}
if (cmd === 'cp') {
// The last operand is the destination, and copying INTO a mount is
// ordinary; only the sources are refused.
const root = firstRoot(this.registry, operands.slice(0, -1));
if (root !== null) {
return deny(`cannot copy '${root.rawPath}': Device or resource busy`, ebusy(root));
}
return null;
}
return null;
}
}
//# sourceMappingURL=mount_root.js.map