@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
572 lines • 29.1 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 { walk } from "../../commands/cli/walk.js";
import { callerSignal, sessionAccess } from "../../context/session_context.js";
import { YieldBudget } from "../../io/yield_budget.js";
import { askRule, Outcome, PermissionsPolicy, policyDenied, refusalOf, renderCommandDeny, Scope, } from "../../policy/index.js";
import { lineTokens } from "../../policy/match/allow.js";
import { patternMatches, splitPattern } from "../../policy/match/pattern.js";
import { ioReach, ioRefusal } from "../../policy/match/rule.js";
import { settled } from "../../policy/policies.js";
import { CommandWord } from "../../policy/types.js";
import { getRedirects, literalWord } from "../../shell/helpers.js";
import { NodeType, RedirectKind } from "../../shell/types.js";
import { Follow, PathSpec } from "../../types.js";
import { makeAbortError } from "../../utils/abort.js";
import { getExtension } from "../../utils/filetype.js";
import { pathVisible } from "../../utils/hidden.js";
import { resolvePath } from "../../utils/path.js";
import { rstripSlash } from "../../utils/slash.js";
import { CondRefused } from "../executor/builtins/condition/errors.js";
import { fileOperands, TEST_NAMES } from "../executor/builtins/condition/operands.js";
import { followPaths } from "../executor/builtins/links/links.js";
import { toScope } from "../executor/builtins/scope.js";
import { reachesService } from "../executor/command/cli.js";
import { commandWords, CWD_DEFAULT_RAW, defaultCwdOperand, mergeScopes, pathFlagScopes, programTokens, routableScopes, routedOperands, treePathScopes, } from "../executor/command/routing.js";
import { classifyParts } from "../expand/classify/parts.js";
import { classifyBarePath } from "../expand/classify/path.js";
import { specForCommand, specWordBases, specWordKinds } from "../expand/spec_hints.js";
import { INTERPRETER_NAMES, WRITING_PROGRAMS } from "../lookup/constants.js";
import { Consumer, isTool, lineFacts, lookup, runsAs, SLASH_KEEPS_LAST, WordPolicy, wordPolicy, } from "../lookup/index.js";
import { envSnapshot } from "../session/state.js";
import { wordValue } from "./inner_lines.js";
function norm(virtual) {
return rstripSlash(virtual) || '/';
}
/**
* The nodes a redirected statement may wrap whose last command is the
* one the redirect binds to. A `!` wraps one command, so it is its own
* last one: `! cat < f` parses as redirected(negated(cat), < f).
*/
const REDIRECT_CHAIN = new Set([
NodeType.LIST,
NodeType.PIPELINE,
NodeType.NEGATED_COMMAND,
]);
/**
* A command the gate let through, and what its own I/O may touch.
*
* The gate judged the paths the line names; a walk below them reaches
* entries no rule has seen, so the dispatcher binds this to the session
* context for the command's run and the commands tier asks it before each
* read, write or listing (`EntryGate`). The paths the gate already judged
* pass, since the line was admitted on them; every other entry is judged
* by `ioRefusal` under the same precedence the gate applied to the line,
* and a refusal is the dispatcher's `PolicyDenied` (EACCES, the path, the
* reason on its record), which every command renders as GNU's
* `Permission denied`.
* `granted` holds the ask rules the line runs under a grant for: the one
* the entry point answered for this line, and the session's standing ones.
* `ctx` is what `preCommand` judged the command on, which `postCommand`
* extends with what came back.
*/
export class Admitted {
rules;
ctx;
judged;
granted;
constructor(init) {
this.rules = init.rules;
this.ctx = init.ctx;
this.judged = init.judged;
this.granted = init.granted;
}
// Throw `PolicyDenied` when a rule in force refuses this entry for the
// running command.
check(virtual) {
const reason = this.refusal(virtual);
if (reason !== null) {
throw policyDenied({ kind: 'deny', reason, policy: PermissionsPolicy.name }, virtual);
}
}
// Whether anything at or under this path could be refused for the
// running command: a rule in force at any path its scope could cover.
// The dispatcher checks coded file policies separately.
scopes(virtual) {
return ioReach(this.rules, this.ctx.tokens ?? [], virtual);
}
// The reason a rule in force refuses this entry, null when the line was
// admitted on it or nothing refuses it.
refusal(virtual) {
if (this.judged.has(norm(virtual)))
return null;
return ioRefusal(this.rules, this.ctx.tokens ?? [], virtual, this.granted);
}
}
/**
* The paths a path-pattern guard reads for a line: the operands as
* typed and the values of path-valued flags, a command tree's group and
* leaf options among them (`treeFlags`, from `treePathScopes`), then, for
* a command that
* follows links, the targets they resolve to. `cat /data/link` reads
* `/data/secret`, so a rule protecting the target has to see it, and a
* command-scoped rule never runs at the dispatcher where the resolved path
* would otherwise be checked. The links are followed as the command's own
* session sees them (`links`, `Namespace.sessionLinks`), as the router
* follows them: on the browser fallback the ambient view folds every live
* session's hides, and a peer hiding the link must not keep its target from
* the rule; null where no link stands. The follow policy is the command's own
* (`follows`, whether `LineFacts.follows` is not NONE: rm, mv, ln, stat,
* tar ... act on the link itself, `-L` turns following back on), the same
* one the router applies to the operands before the handler runs, so a rule
* sees exactly the path the command will touch. A loop is left to that later
* step to report; here the typed paths stand. Then the operand a bare
* `ls`/`find`/`du`/`tree`/`grep -r` implies, the working directory,
* which the executor injects after the gate and which a rule on that
* directory has to see. Last come the statement's redirect targets:
* `cat < /data/secret` reads the file and `echo x > /data/secret`
* truncates it, on the shell's own fds outside the admitted command's
* gate window, so the admission is the one place a rule can see them. A
* redirect always dereferences (the shell opens the target), so its
* link targets ride along whatever the command's own follow policy
* says.
*/
export function policyScopes(name, args, operands, follows, links, cwd, implied = null, redirects = [], treeFlags = []) {
const scopes = [];
for (const p of operands) {
if (p instanceof PathSpec)
scopes.push(p);
}
for (const item of pathFlagScopes(name, [...args], cwd))
scopes.push(item);
const typed = new Set(scopes.map((p) => p.virtual));
for (const item of treeFlags.filter((p) => !typed.has(p.virtual)))
scopes.push(item);
if (name.includes('/')) {
// A slash-carrying head word is a file the line executes, and it
// lives in argv[0], not the operands, so a path-pattern guard would
// never see it without this row.
scopes.unshift(toScope(resolvePath(name, cwd)));
}
if (links !== null && operands.length > 0) {
const followed = followPaths(links, [...operands], follows, !SLASH_KEEPS_LAST.has(name));
const seen = new Set(scopes.map((p) => p.virtual));
for (const item of followed) {
if (item instanceof PathSpec && !seen.has(item.virtual)) {
seen.add(item.virtual);
scopes.push(item);
}
}
}
if (implied !== null && !scopes.some((p) => p.virtual === implied.virtual)) {
scopes.push(implied);
}
if (redirects.length > 0) {
const targets = [...redirects];
if (links !== null) {
const followed = followPaths(links, [...redirects], true, true);
for (const item of followed.filter((p) => p instanceof PathSpec))
targets.push(item);
}
const seen = new Set(scopes.map((p) => p.virtual));
for (const item of targets) {
if (item instanceof PathSpec && !seen.has(item.virtual)) {
seen.add(item.virtual);
scopes.push(item);
}
}
}
return scopes;
}
/**
* The paths of a line the session can see. A hidden path is nonexistent
* for the session, so no policy may learn of it either: a rule scoped
* to it must not fire (the reason would say the path is there), an ask
* must not be raised for it (a request would name it to the host), and
* the line runs on to the dispatcher, which answers ENOENT like any other
* absent path. A path the reader could not read (`unread`, as `gate`
* takes it) goes the same way, since the line may never name it.
*/
function seen(session, specs, unread = new Set()) {
return specs.filter((p) => !unread.has(p.virtual) && pathVisible(session.visibility, p.virtual));
}
/**
* The words a policy is shown, a path `seen` drops taken off the word that
* names it, so a policy reads the word and never the path. Mirrors Python's
* `_seen_words`.
*/
function seenWords(session, words, unread) {
const shown = new Set(seen(session, words.flatMap((w) => (w.path !== null ? [w.path] : [])), unread).map((p) => p.virtual));
return words.map((w) => w.path === null || shown.has(w.path.virtual)
? w
: new CommandWord(w.text, w.kind, w.option, null));
}
/**
* The command plane's admission of one command: the policy chain, then
* the approval ledger. The one gate every command class passes through,
* in the tree (`runArgv`, once the words are expanded) and for a line a
* runtime takes whole (`admitLine`, per parsed command). A word the
* session's allow list leaves out is the profile's Deny, like any other
* rule's; a path the session cannot see is
* dropped before any method, so a rule never names it and the dispatcher
* answers ENOENT; a Deny renders in the outcome table's voice; an Ask
* is answered by the entry point from the session's grants or the host.
* `stdin` decides whether a bare `rg` reads the working directory.
*/
/**
* One command's literal words, classified the way the runtime would
* classify them, so the gate and the run name the same paths.
*/
export async function classifiedWords(name, args, session, registry) {
const line = [name, ...args];
const [wordKinds, wordBases] = wordHints(line, session, registry);
return await classifyParts(line, registry, session.cwd, wordKinds, wordBases);
}
/**
* The paths a statement's redirect targets name.
*
* Shared by admission and by the dry run, because a rule reads a
* redirect the same way in both: a target only the runtime can expand
* names no path here, and one that is not path-shaped is not a file.
*/
export function redirectPaths(words, registry, cwd) {
return words
.filter((w) => w.text !== null)
.map((w) => classifyBarePath(wordValue(w), registry, cwd))
.filter((p) => p instanceof PathSpec);
}
/**
* Everything the gate decides about one command before anything is
* spent on it: the classified context, and the policy chain's answer (the
* first Deny, else the first Ask) with the answers it came from.
*
* Split out of `admit` so a dry run can have the answer without the
* consequences. Nothing here records a request, consumes a grant or
* reaches the host, which is what makes it safe for `explain`;
* `admit` adds exactly those and renders.
*
* `intrinsic` keeps shell-provided operations subject to tool allow lists
* even when a function shadows their policy name. `unread` holds the
* virtual paths a reader of the line's text cannot vouch for: what a word
* only the runtime expands names, or a relative word after a `cd` it
* could not follow. No policy is shown them; the per-command gate reads
* the real ones and passes none. `every` asks every policy past a Deny,
* for `explain`; the gate's own answers end at the first Deny.
* `rulesOnly` asks the profile's rules alone, for a pass that judges a line
* before it runs; a policy's own `preCommand` is asked once, by the gate
* that runs the command. `line` is the line the command was parsed from,
* empty outside a line.
*/
/**
* Whether the allow list refuses a CLI that reaches a service. Such a CLI
* (an account or script CLI) reaches its service by id, past every hide, so
* under a profile that hides paths it runs only where a pattern starting
* with its own name matches the line. `*` and a pattern for another of its
* verbs do not count. Mirrors the Python `unlisted_service`.
*/
export function unlistedService(ctx, registry, session) {
const install = registry.clis.get(ctx.command);
if (ctx.tool === false ||
install === null ||
!reachesService(install.cli) ||
session.visibility.paths === null) {
return false;
}
const tokens = lineTokens(ctx);
return !(session.commands?.allow ?? []).some((p) => splitPattern(p)[0] === ctx.command && patternMatches(p, tokens));
}
/**
* Whether the handler a command resolves to declares that it may change
* state. A command tree's leaf (an installed CLI's, or a tree a mount
* serves) as its walk names it, under the session environment its run walks
* with, which fills a group option declaring `Argument.env`; any other mount
* command as every handler a run of it may reach: on the mount that would run
* it (the mount of the first path the line names, else of the working
* directory), on each path's own mount, which a run across mounts hands that
* path, and on every mount below a path, which a walk reaches. Its handler is
* picked by an operand's extension, so each path's counts, and a run follows
* links before it picks either, so a link's target counts beside its name.
* Mirage's own handler counts only when the line's options let it write
* (`LineFacts.writes`: `sort -o` writes, `sort` does not). A namespace or
* routed program that changes what it names (`WRITING_PROGRAMS`: `ln`,
* `chmod` ...) writes by name. A walk the tree refuses runs nothing, and a
* shell builtin, a function, an external program or an unknown name declares
* nothing. Mirrors Python's `_writes`.
*/
function writes(name, args, operands, facts, session, registry, namespace, implied) {
const consumer = lookup(name, session, registry);
if (WRITING_PROGRAMS.has(name) &&
(consumer === Consumer.NAMESPACE || consumer === Consumer.MOUNT))
return true;
if (consumer !== Consumer.CLI && consumer !== Consumer.MOUNT)
return false;
const tree = registry.clis.get(name)?.cli ?? null;
if (tree !== null) {
const result = walk(name, tree.spec, [...args], session.cwd, envSnapshot(session));
return result.leaf !== null && tree.handlers[result.path.join(' ')]?.write === true;
}
const candidates = registry.invocationCandidates(name, [], session.cwd, args, sessionAccess(session));
if ((candidates[0]?.cliFor(name) ?? null) !== null) {
return candidates.some((mount) => {
const cli = mount.cliFor(name);
if (cli === null)
return mount.mayWrite(name);
const result = walk(name, cli.spec, args, session.cwd, envSnapshot(session));
return result.leaf !== null && cli.handlers[result.path.join(' ')]?.write === true;
});
}
const runs = runsAs(name, registry);
// The words that route the run, picked as execution picks them: a stdin `-`
// and an assignment route nowhere, and a path-valued option, attached or
// not, routes like an operand.
const named = mergeScopes(routableScopes(runs, routedOperands(runs, [...args], session.cwd, operands, operands.filter((p) => p instanceof PathSpec))), pathFlagScopes(runs, [...args], session.cwd)).filter((s) => s.walkError !== 'ENOENT');
if (namespace !== null && namespace.nodes.size > 0 && named.length > 0) {
const typed = new Set(named.map((p) => p.virtual));
const followed = followPaths(namespace.sessionLinks(session), [...named], facts.follows !== Follow.NONE, !SLASH_KEEPS_LAST.has(name));
for (const p of followed) {
if (p instanceof PathSpec && !typed.has(p.virtual))
named.push(p);
}
}
const scopes = named.length > 0 ? named : implied !== null ? [implied] : [];
const reached = registry.invocationCandidates(runs, scopes.slice(0, 1), session.cwd, args, sessionAccess(session));
for (const scope of scopes) {
const owner = registry.tryMountFor(scope.virtual);
const below = `${rstripSlash(scope.virtual)}/`;
for (const item of registry
.allMounts()
.filter((m) => m === owner || m.prefix.startsWith(below)))
reached.push(item);
}
const extensions = named.length > 0 ? new Set(named.map((p) => getExtension(p.virtual))) : [null];
return reached.some((mount) => {
const cli = mount.cliFor(runs);
if (cli !== null) {
const result = walk(runs, cli.spec, args, session.cwd, envSnapshot(session));
return result.leaf !== null && cli.handlers[result.path.join(' ')]?.write === true;
}
return [...extensions].some((extension) => mount.mayWrite(runs, extension, facts.writes));
});
}
export async function gate(name, args, operands, session, registry, namespace, stdin = null, redirects = [], intrinsic = false, unread = new Set(), every = false, rulesOnly = false, line = '') {
const tool = intrinsic || isTool(name, session);
const env = envSnapshot(session);
const [tokens, program] = programTokens(registry, name, [...args], session.cwd, env, sessionAccess(session));
const implied = name in CWD_DEFAULT_RAW
? defaultCwdOperand([name, ...operands], name, registry, session.cwd, stdin)
: null;
// Each read of the words is a pass over all of them, so a command of a
// million words yields between them: a cancel or the line's deadline
// lands before the policies are asked.
const budget = new YieldBudget(callerSignal());
await budget.run();
const facts = lineFacts(name, [name, ...operands], session.cwd);
const follows = facts.follows !== Follow.NONE;
const paths = seen(session, policyScopes(name, args, operands, follows, namespace !== null && namespace.nodes.size > 0 ? namespace.sessionLinks(session) : null, session.cwd, implied, redirects, treePathScopes(registry, name, args, session.cwd, env, sessionAccess(session))), unread);
await budget.run();
const words = seenWords(session, commandWords(registry, name, args, operands, redirects, session.cwd, env, sessionAccess(session)), unread);
await budget.run();
const write = writes(name, args, operands, facts, session, registry, namespace, implied);
await budget.run();
const ctx = {
line,
command: name,
paths,
words,
argv: [...args],
cwd: session.cwd,
sessionId: session.sessionId,
tokens,
program,
tool,
walks: facts.walks,
write,
subtree: facts.subtree,
moveTarget: facts.moveTarget,
// The methods read the session this command is judged in, which for a
// line explain walks is the walked one, not the stored one.
...registry.policies.views?.(session.sessionId, session),
};
const answers = (await registry.policies.answers('preCommand', ctx, every, rulesOnly)).filter((a) => a.kind !== 'route');
if (unlistedService(ctx, registry, session)) {
answers.unshift({
kind: 'deny',
reason: `${program.join(' ')} is not allowed`,
policy: PermissionsPolicy.name,
});
}
return [ctx, settled(answers), answers];
}
/**
* The chain's answer once the ledger has had its say. An Ask is the chain's
* answer only after every Deny had its say; the ledger answers it from the
* session's records or the host, so an answer never re-opens a deny. A wait
* the run's kill cut short is the kill landing late, not a ruling, so it
* throws as every other abandoned wait does rather than being rendered as a
* refusal the document never made. Mirrors the Python `resolved`.
*/
export async function resolved(ctx, asked, registry, signal, claimant) {
if (asked?.kind !== 'ask')
return asked;
const action = await registry.decisions.resolve(ctx, asked, signal, claimant);
if (action !== null && action.kind === 'abandoned')
throw makeAbortError();
return action;
}
export async function admit(name, args, operands, session, registry, namespace, stdin = null, redirects = [],
// The run's abort signal, carried only so a question put to a host
// cannot outlive the run that raised it. Nothing else here waits on
// anything outside mirage.
signal,
// The command and its line, null outside a line. On a line, every
// grant behind the command is claimed on the line's hand-off for that
// occurrence, whether a pass that judges the line before it runs
// (`prejudgeLine`, `admitLine`) or the gate that runs it is reading,
// and spent when the line ends, so one question covers one run rather
// than one reader; a reader outside a line spends what it matched. A
// refusal needs no such care: the record refuses the agent's retry
// from the ledger either way.
claimant = null,
// Shell-provided operations keep their tool policy even if a function shadows the name.
intrinsic = false,
// Paths no policy is shown, as `gate` takes them.
unread = new Set(),
// Ask the profile's rules alone, as `gate` takes it.
rulesOnly = false) {
const gated = await gate(name, args, operands, session, registry, namespace, stdin, redirects, intrinsic, unread, false, rulesOnly, claimant?.occurrence.source ?? '');
const [ctx, asked] = gated;
const action = await resolved(ctx, asked, registry, signal, claimant);
if (action === null) {
const granted = session.decisions
.filter((r) => r.scope === Scope.SESSION && r.outcome === Outcome.ALLOW)
.map((r) => r.rule);
if (asked !== null && asked.kind === 'ask')
granted.unshift(...(asked.rules ?? [askRule(ctx, asked)]));
return new Admitted({
rules: session.commands,
ctx,
judged: new Set(ctx.paths.map((p) => norm(p.virtual))),
granted,
});
}
const [stderr, exitCode] = await renderCommandDeny(name, action, ctx);
return { stderr, exitCode, refusal: refusalOf(action) };
}
/**
* Admit a `[ ]` test, or one `[[ ]]` leaf, as the command it is: the shell
* evaluates it itself, so no command gate saw its words, and a rule, an allow
* list or a policy answers it by its own name as it answers `test` on the
* same words. `name` is `[` or `[[`; `words` what it tests, expanded,
* operators included; `signal` the run's abort signal; `claimant` the test
* and its line, null outside a line. Throws `CondRefused` where the gate
* refused it. Mirrors Python's admit_test.
*/
export async function admitTest(name, words, session, registry, namespace, signal, claimant = null) {
const classified = await classifiedWords(name, words, session, registry);
const verdict = await admit(name, words, classified.slice(1), session, registry, namespace, null, [], signal, claimant, true);
if (!(verdict instanceof Admitted)) {
throw new CondRefused(verdict.stderr, verdict.exitCode, verdict.refusal);
}
return verdict;
}
/**
* The spec's per-position classification hints for a literal line, the
* way `expandArgv` computes them for an expanded one. Without them a
* bare filename operand stays text (`cat secret` from `/data` yields no
* `/data/secret` scope) and a chdir option (tar's `-C`) resolves later
* words against the wrong base, so a rule and the run would disagree
* about the paths the line names.
*/
function wordHints(line, session, registry) {
const consumed = registry.matchCommandPrefix([...line]);
const joined = line.slice(0, consumed).join(' ');
if (TEST_NAMES.has(joined))
return [fileOperands(joined, line.slice(1)), null];
const consumer = lookup(joined, session, registry);
// A mount command's spec is read, and so is a native capture's and an
// interpreter's: `python3 steal.py` runs on the runtime's own disk or
// a host process, where no dispatcher follows the read, so the script
// slot the spec declares is the one place a path rule can see the
// file. The tree's gate reads a native capture the same way
// (`expandArgv`), and an interpreter it runs itself for the script slot
// alone, since its other words become the program's argv there; here
// the hints reach no runtime word, since the line runs as typed, so
// there is nothing to lose by reading them all.
if (Object.hasOwn(session.functions, joined) ||
!(wordPolicy(consumer) === WordPolicy.MOUNT ||
consumer === Consumer.EXTERNAL ||
INTERPRETER_NAMES.has(joined))) {
return [null, null];
}
// A wrapper's words are the program's it runs.
const runs = runsAs(joined, registry);
const spec = specForCommand(runs, registry, session.cwd, line.slice(consumed), sessionAccess(session));
if (spec === null)
return [null, null];
const extra = new Array(consumed - 1).fill('str');
const wordKinds = [
...extra,
...specWordKinds(spec, [...line.slice(consumed)], runs, envSnapshot(session)),
];
const bases = specWordBases(spec, [...line.slice(consumed)], session.cwd, runs, envSnapshot(session));
const wordBases = bases === null ? null : [...new Array(consumed - 1).fill(null), ...bases];
return [wordKinds, wordBases];
}
/**
* The redirect targets of the statement holding a command, as the gate
* reads its words: the raw text and the literal it names, null when
* only the runtime can expand it (refused wherever a rule reads the
* command's arguments, like any other word). Heredoc and herestring
* bodies are content, not paths, and a numeric target is an fd
* duplication; neither names a file.
*
* A redirect binds to one command, and which one is a question about
* the tree rather than the statement: `a && b > f` and `a | b > f` both
* parse as a redirected_statement wrapping the whole list, so reading
* only its first child answered `a` and left `b`, the command bash
* actually opens the file for, with no target at all. The walk climbs
* the last-command chain instead, which is bash's own rule for a list,
* a pipeline and a `!`. A compound (`{ }`, a loop, a subshell) redirects
* every command inside it, which is not a chain, so none is claimed
* here and the dispatcher judges the write.
*/
function statementRedirectsRaw(node) {
let owner = node;
let parent = owner.parent;
while (parent !== undefined && parent !== null && REDIRECT_CHAIN.has(parent.type)) {
const last = parent.namedChildren[parent.namedChildren.length - 1];
if (last === undefined || last.startIndex !== owner.startIndex)
return [];
owner = parent;
parent = owner.parent;
}
if (parent === undefined || parent === null)
return [];
if (parent.type !== NodeType.REDIRECTED_STATEMENT)
return [];
const body = parent.namedChildren[0];
if (body === undefined || body.startIndex !== owner.startIndex)
return [];
const [, redirects] = getRedirects(parent);
return redirects;
}
/** Mark redirected input without opening or expanding its source. */
export function statementStdin(node) {
return statementRedirectsRaw(node).some((r) => r.fd === 0) ? new Uint8Array(0) : null;
}
export function statementRedirects(node, home) {
const words = [];
for (const r of statementRedirectsRaw(node)) {
if (r.kind === RedirectKind.HEREDOC ||
r.kind === RedirectKind.HERESTRING ||
r.kind === RedirectKind.AMBIGUOUS)
continue;
if (typeof r.target === 'number' || r.targetNode === null)
continue;
const target = r.targetNode;
words.push({ raw: String(r.target), text: literalWord(target, home) });
}
return words;
}
//# sourceMappingURL=admission.js.map