@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
272 lines • 13.9 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 { sessionAccess } from "../../context/session_context.js";
import { scopesPaths } from "../../policy/match/reads.js";
import { PathSpec, wordText } from "../../types.js";
import { hasGlob, literalWord, markGlobs, unmarkGlobs } from "../../utils/glob_walk.js";
import { INTERPRETER_NAMES } from "../lookup/constants.js";
import { TEST_NAMES, fileOperands } from "../executor/builtins/condition/operands.js";
import { commandTree } from "../executor/command/routing.js";
import { envSnapshot } from "../session/state.js";
import { Consumer, WordPolicy, endOptionsAfterProgram, lookup, runsAs, runtimeRefused, wordPolicy, } from "../lookup/index.js";
import { classifyParts } from "./classify/index.js";
import { globNeedsShell, globOptions, resolveGlobs } from "./globs.js";
import { expandWords } from "./parts.js";
import {} from "../../commands/spec/types.js";
import { specForCommand, specWordBases, specWordKinds } from "./spec_hints.js";
/**
* One command's expanded argument vector.
*
* `expandArgv` is the only place allowed to know that word zero of an
* expanded command is its name; every consumer reads named views
* instead of slicing word lists.
*
* `args` and `operands` are two views of the same final word list and
* always have equal length; they differ only in element type. Glob
* words are resolved by whoever consumes them, exactly once: shell
* consumers get shell-resolved words in both views, mount commands
* keep pattern PathSpecs for backend pushdown.
*/
export class Argv {
/** Expanded command name. */
name;
/** Text view (what builtins consume). */
args;
/** Classified view (what mount dispatch, test, and ln consume). */
operands;
/** Original words forming the matched name. */
prefix;
constructor(name, args, operands, prefix = [name]) {
this.name = name;
this.args = args;
this.operands = operands;
this.prefix = prefix;
Object.freeze(this);
}
/** Native argv, preserving word boundaries within a matched name. */
get tokens() {
const [head = this.name, ...tail] = this.prefix;
return [head, ...tail, ...this.args];
}
/** Full classified word list, name included. */
get words() {
if (this.name === '' && this.operands.length === 0)
return [];
return [this.name, ...this.operands];
}
/** Copy with the classified view replaced (e.g. after symlink rewriting). */
withOperands(operands) {
return new Argv(this.name, this.args, [...operands], this.prefix);
}
}
/**
* Expand, classify, and glob-resolve a command's word nodes.
*
* Uses the cwd mount's CommandSpec (when it has one for the command) to
* decide which words are TEXT (skip classification) and which are PATH
* (classify even bare filenames). A program's line (a native capture,
* or an interpreter run in-process) is globbed whatever the slots, as
* bash globs it, and its words are then classified for the slots the
* expanded words fill.
*/
export async function expandArgv(parts, context, executeFn, callStack, registry, links = null, view) {
const session = context.session;
let expanded = await expandWords(parts, context, executeFn, callStack, view);
if (expanded.length === 0)
return new Argv('', [], []);
// `set -f` turns pathname expansion off, which is the same word for
// word as every glob character having been quoted.
if (session.shellOptions.noglob === true)
expanded = expanded.map((w) => markGlobs(w));
// A command name may span several leading words (git-style, e.g.
// `gws docs documents get`); the registry says how many.
const consumed = registry.matchCommandPrefix(expanded);
const name = unmarkGlobs(expanded.slice(0, consumed).join(' '));
// Before anything reads the line: an option carrying a program hands
// the words after it to that program, and POSIX's own `--` is how that
// handoff is spelled. Only when the interpreter is what runs, though:
// a shell function of the same name takes the line instead (bash's own
// rule), and it must receive the words as typed rather than a marker
// meant for a parser it does not have. `command python3` masks the
// function for its inner run, which is exactly when the rewrite
// applies again. A CLI cannot reach here at all, since registerCli
// refuses a shell builtin's name.
const consumer = lookup(name, session, registry, context.frame.routing);
const refused = runtimeRefused(name, session, registry, context.frame.routing);
const shadowed = Object.hasOwn(session.functions, name) || consumer === Consumer.EXTERNAL;
const line = expanded.slice(consumed);
const tail = shadowed ? line : endOptionsAfterProgram(name, line);
const lineWords = [...expanded.slice(0, consumed), ...tail];
const policy = wordPolicy(consumer);
// A native program gets its words the way bash hands them over, with
// every unquoted glob already expanded, whatever slot the word fills.
const native = consumer === Consumer.EXTERNAL && !refused;
// So does an interpreter run in-process. The words after its program
// are that program's argv, handed over as typed, and only its script is
// a file it opens: the spec's script slot makes that one word a path, so
// a rule protecting `secret.py` reads `python3 secret.py` however the
// script is spelled, while `python3 s.py data/in.csv` hands the script
// `data/in.csv` and a `/tmp/q.txt` beside a script on /workspace names
// no second mount.
const inProcess = consumer === Consumer.SESSION && INTERPRETER_NAMES.has(name);
// And so does a command tree a mount serves, as the same tree installed
// as a CLI does: no backend takes its patterns.
const tree = consumer === Consumer.MOUNT &&
commandTree(registry, name, session.cwd, lineWords.slice(consumed), sessionAccess(session)) !==
null;
const program = native || inProcess || tree;
let spec = null;
let wordKinds = null;
let wordBases = null;
// Native captures and interpreters still need the spec's path roles
// for admission.
if (policy === WordPolicy.MOUNT || consumer === Consumer.EXTERNAL || inProcess) {
// A wrapper's words are the program's it runs.
const runs = runsAs(name, registry);
spec = specForCommand(runs, registry, session.cwd, lineWords.slice(consumed), sessionAccess(session));
if (spec !== null) {
const extra = new Array(consumed - 1).fill('str');
wordKinds = [
...extra,
...specWordKinds(spec, lineWords.slice(consumed), runs, envSnapshot(session)),
];
const bases = specWordBases(spec, lineWords.slice(consumed), session.cwd, runs, envSnapshot(session));
if (bases !== null) {
wordBases = [...new Array(consumed - 1).fill(null), ...bases];
}
}
}
else if (TEST_NAMES.has(name)) {
// test reads its own words: the operand of a file operator is a file
// however it is spelled, as the gate reads it.
const leading = new Array(consumed - 1).fill('str');
wordKinds = [...leading, ...fileOperands(name, lineWords.slice(consumed))];
}
const globOpts = globOptions(session);
if (program) {
// bash globs every unquoted word before the program reads any of
// them, whatever slot it fills and whatever it looks like:
// `python3 s.py *.txt` gets the matches, `.*.txt` the dotfiles and
// `x=*` a file named `x=1`, and a glob that matches nothing stays the
// word as typed. So a word carrying a live glob character is a
// pattern here, spec or no spec, rather than a shell word the shape
// rules read. A quoted one carries marks rather than glob characters,
// so it stays text.
const tail = lineWords.slice(consumed);
const own = wordKinds !== null ? wordKinds.slice(consumed - 1) : tail.map(() => null);
wordKinds = [
...new Array(consumed - 1).fill('str'),
...tail.map((word, i) => hasGlob(word, globOpts.access) ? 'path' : (own[i] ?? null)),
];
// The shell searches the working directory: an option that bases the
// program's operands (`-C dir`) bases what matched, which programWords
// reads under it, never where the glob looks.
if (wordBases !== null) {
const words = lineWords.slice(1);
wordBases = wordBases.map((base, i) => hasGlob(words[i] ?? '', globOpts.access) ? null : base);
}
}
const classified = await classifyParts(lineWords, registry, session.cwd, wordKinds, wordBases, globOpts.access);
// A glob word is resolved by whoever consumes it, exactly once:
// WordPolicy.SHELL words get matches here; mount commands keep
// patterns for backend pushdown; unknown names fail without
// touching backends.
// So does a command a path-scoped rule names: the admission gate reads
// the words before the backend would resolve them, and a pattern that
// only later matches under the rule's path would pass a gate its
// matches fail. And so does a pattern that walks a `.` or `..`, which no
// backend key holds.
let words = !refused &&
(policy === WordPolicy.SHELL ||
tree ||
globNeedsShell(globOpts) ||
scopesPaths(session.commands, name) ||
classified.some((w) => w instanceof PathSpec && w.pattern !== null && w.dotted !== null))
? await resolveGlobs(classified, registry, false, links, globOpts)
: // A pattern still owes its backend a resolution, so it travels
// marked and the marks come off there; every other word is done
// with its quoting and reads literally from here on.
classified.map((item) => item instanceof PathSpec && item.pattern !== null ? item : literalWord(item));
if (program && spec !== null) {
words = await programWords(words, spec, name, consumed, registry, session.cwd, envSnapshot(session));
}
// The text view renders words as typed (rawPath): bash hands
// programs their words unchanged, so `echo sub/file.txt` prints the
// relative form, not the resolved absolute path. Quote removal is part
// of "as typed": a word never reaches a command marked.
const textView = words.map((w) => unmarkGlobs(wordText(w)));
return new Argv(name, textView.slice(consumed), words.slice(consumed), expanded.slice(0, consumed).map(unmarkGlobs));
}
/**
* Classify a program's words for the argv it receives.
*
* bash expands every glob before the program parses its argv, so a
* match can fill a slot of another kind than the word it came from:
* `grep *.txt` hands grep its pattern and its files out of one word, and
* a glob's extra matches push every later word into a later slot. The
* spec therefore reads the expanded words, which are literal from here
* on, and each word is classified for the slot it now fills. Admission
* then judges the paths the program opens, and a match in a text slot is
* text, exactly like the same word typed by hand. A glob that matched
* nothing keeps the pattern spec the resolver left in a path slot, and is
* its typed text in a text slot.
*/
async function programWords(words, spec, name, consumed, registry, cwd, env) {
const literal = words.map((w) => markGlobs(wordText(w)));
const kinds = [
...new Array(consumed - 1).fill('str'),
...specWordKinds(spec, literal.slice(consumed), name, env),
];
const bases = specWordBases(spec, literal.slice(consumed), cwd, name, env);
const reread = await classifyParts(literal, registry, cwd, kinds, bases === null ? null : [...new Array(consumed - 1).fill(null), ...bases]);
const out = [words[0] ?? ''];
for (let i = 1; i < words.length; i++) {
const word = words[i] ?? '';
const kind = kinds[i - 1] ?? null;
if (!(word instanceof PathSpec && word.pattern !== null)) {
out.push(literalWord(reread[i] ?? ''));
}
else if (kind === null || kind === 'path') {
out.push(based(word, reread[i] ?? ''));
}
else {
out.push(literalWord(wordText(word)));
}
}
return out;
}
/**
* A glob word that matched nothing, under the program's own base.
*
* The shell searched the working directory and left the word as typed, so
* the program reads it where it reads a match: under `-C dir` when the spec
* bases its operands. It stays a pattern, which a push-down reads as no
* entity name.
*/
function based(word, fresh) {
const spec = literalWord(fresh);
if (!(spec instanceof PathSpec) || spec.virtual === word.virtual)
return word;
return new PathSpec({
virtual: spec.virtual,
directory: spec.directory,
vfsPath: spec.vfsPath,
rawPath: spec.rawPath,
dotted: spec.dotted,
walkError: spec.walkError,
pattern: word.pattern,
resolved: word.resolved,
});
}
//# sourceMappingURL=argv.js.map