UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

1,257 lines • 59.1 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 { YieldBudget } from "../../io/yield_budget.js";
import { PermissionsPolicy, refusalOf, renderCommandDeny, renderDeny } from "../../policy/index.js";
import { isPending } from "../../policy/decisions.js";
import { sourceOf } from "../../policy/match/decide.js";
import { hasRules, readsArgs, scopesPaths } from "../../policy/match/reads.js";
import { settled } from "../../policy/policies.js";
import { Outcome, } from "../../policy/types.js";
import { inputSubstitutionRedirect, getParts, getText, literalWord, splitEnvPrefix, } from "../../shell/helpers.js";
import { opaqueReads, referencedNames } from "../../shell/parse/index.js";
import { NodeType } from "../../shell/types.js";
import { VarAttr } from "../../shell/variable.js";
import { PathSpec } from "../../types.js";
import { isGlob } from "../../utils/hidden.js";
import { resolvePath } from "../../utils/path.js";
import { commandWords } from "../executor/command/routing.js";
import { envChild, envVars } from "../executor/builtins/env/env.js";
import { classifyBarePath } from "../expand/classify/path.js";
import { lineFacts } from "../lookup/index.js";
import { homeDir } from "../session/shell_dirs.js";
import { envIsReadonly, envSnapshot, seedVar, setAttr } from "../session/state.js";
import { sessionAccess } from "../../context/session_context.js";
import { classifiedWords, gate, redirectPaths, resolved, statementRedirects, statementStdin, } from "./admission.js";
import { innerLines, innerReadable, readWord, readsOfTest, wordValue, } from "./inner_lines.js";
import { bodyFrame, argvFrame, definitionFrame, gateFrame, lineFrame, occurrenceIn, rootFrame, segmentFrames, wholeOccurrence, } from "./occurrence.js";
import { decodeText, encodeText } from "../../shell/bytes.js";
// Why the gate refuses, under a rule, a command that runs lines it cannot
// see into: a sourced file, a script, a bash option mirage does not read.
export const UNREADABLE_LINES = 'runs lines the gate cannot read';
/**
 * Nodes that run their commands in a child shell: a `cd` inside one
 * applies to the rest of that child and is gone when it exits. A
 * pipeline is not here because it forks per segment, not once.
 */
const FORK_SCOPES = new Set([
    NodeType.SUBSHELL,
    NodeType.COMMAND_SUBSTITUTION,
    NodeType.PROCESS_SUBSTITUTION,
]);
/**
 * Nodes whose body may run again from where it ended, or later than where it
 * stands: a `cd` in one moves the commands before it too.
 */
const REPEATED_SCOPES = new Set([
    NodeType.FOR_STATEMENT,
    NodeType.C_STYLE_FOR_STATEMENT,
    NodeType.WHILE_STATEMENT,
    NodeType.FUNCTION_DEFINITION,
]);
/**
 * Commands that move the shell's cwd where the walk cannot follow: a line
 * run in this shell, and the directory stack.
 */
const CWD_MOVERS = new Set(['eval', '.', 'source', 'pushd', 'popd']);
/**
 * How many other places a line may stand in before the walk calls its cwd
 * lost: each relative `cd` doubles them.
 */
const PLACES_LIMIT = 8;
/**
 * How the explanation tree names a parse node that shapes a line; a node
 * not here (a redirected or negated statement) is read through. Mirrors
 * the Python SHAPES.
 */
const SHAPES = new Map([
    [NodeType.LIST, 'list'],
    [NodeType.PIPELINE, 'pipeline'],
    [NodeType.SUBSHELL, 'subshell'],
    [NodeType.COMPOUND_STATEMENT, 'group'],
    [NodeType.IF_STATEMENT, 'group'],
    [NodeType.FOR_STATEMENT, 'group'],
    [NodeType.WHILE_STATEMENT, 'group'],
    [NodeType.CASE_STATEMENT, 'group'],
    [NodeType.FUNCTION_DEFINITION, 'group'],
]);
/** The nodes whose body a nested line evaluates on its own. Mirrors the Python SUBSTITUTIONS. */
const SUBSTITUTIONS = new Set([
    NodeType.COMMAND_SUBSTITUTION,
    NodeType.PROCESS_SUBSTITUTION,
]);
/**
 * The paths a command's words may name that the pass cannot vouch for:
 * what a word only the runtime expands names, and, once a `cd` lost the
 * cwd, the cwd and what every relative word names. Judged as typed in
 * the cwd the pass last knew, a glob in a rule matched them and refused
 * lines that touch only allowed files. A path some word names outright as
 * a path stays read: `rm -rf /data/old` names `/data/old` even when that
 * is the cwd a `cd` lost, while the pattern in `grep -r -e /data/old`
 * names no path at all. `kinds` holds the same words classified, a path
 * as a PathSpec.
 */
function unreadPaths(words, kinds, cwd, lost) {
    const unread = new Set(lost ? [cwd] : []);
    const read = new Set();
    words.forEach((w, i) => {
        const value = wordValue(w);
        const kind = kinds[i];
        if (w.text === null || (lost && !value.startsWith('/')))
            unread.add(resolvePath(value, cwd));
        else if (kind instanceof PathSpec)
            read.add(kind.virtual);
    });
    for (const path of read)
        unread.delete(path);
    return unread;
}
/** The explanation of a command the gate refuses outright. */
function denied(command, reason) {
    const deny = { kind: 'deny', reason };
    const [stderr, exitCode] = renderDeny(command, deny);
    return {
        command,
        argv: [],
        outcome: Outcome.DENY,
        rule: null,
        reason,
        source: '',
        matchedPath: null,
        paths: [],
        exitCode,
        stderr: decodeText(stderr),
        refusal: refusalOf(deny),
        answers: [],
        words: [],
    };
}
/** Why the gate refuses a word only the runtime can expand. */
export function unreadable(raw) {
    return `cannot read ${raw} before the runtime expands it`;
}
/**
 * One command's explanation, read off the answers the gate's chain gave
 * and rendered from the same table the gate renders a refusal with: the
 * profile's rules (`PermissionsPolicy`) say the outcome and the rule that
 * spoke, the chain's answer what the line would do.
 *
 * An Ask reads the session's standing grants and stops there
 * (`Decisions.held`): a dry run must not spend one, record a question
 * or reach the host. An answer that already covers the ask leaves the
 * outcome ASK, because that is what the document says, with exit 0,
 * because that is what the line would do.
 */
async function explained(ctx, registry, asked, answers) {
    const document = settled(answers.filter((a) => a.policy === PermissionsPolicy.name));
    const rule = document?.rule ?? null;
    const base = {
        command: ctx.command,
        argv: ctx.argv,
        outcome: document === null ? Outcome.ALLOW : document.kind === 'deny' ? Outcome.DENY : Outcome.ASK,
        rule,
        reason: rule?.reason ?? '',
        source: rule !== null ? sourceOf(rule) : document !== null ? 'commands.allow' : '',
        matchedPath: document?.kind === 'deny' ? (document.path ?? null) : null,
        paths: ctx.paths.map((p) => p.virtual),
        exitCode: 0,
        stderr: '',
        refusal: null,
        answers,
        words: [],
    };
    const action = asked !== null && asked.kind === 'ask' ? await registry.decisions.held(ctx, asked) : asked;
    if (action === null)
        return base;
    const [stderr, exitCode] = await renderCommandDeny(ctx.command, action, ctx);
    return {
        ...base,
        reason: base.reason === '' ? action.reason : base.reason,
        exitCode,
        stderr: decodeText(stderr),
        refusal: refusalOf(action),
    };
}
/**
 * Why a runtime that takes the line whole may not run a command the chain
 * did not refuse, null when it may. Such a runtime expands, walks, globs and
 * changes directory on its own (a remote sandbox's own disk, a host
 * process), where no entry gate follows an I/O below the judged words, so a
 * command a rule reads must not reach it with any of them in hand. A command
 * a path or mount rule reads is refused when it walks (`find`, `grep -r`,
 * `tar -c`) or names a glob; one a rule reads the arguments of when a word
 * only the runtime can expand stands among them, when the runtime appends
 * operands (`xargs`, `find -exec`) or when it runs after a `cd` the walk
 * could not follow; and any command, under any rule, that runs lines the
 * gate cannot read (a sourced file, a script, `eval "$p"`). With no rule in
 * force nothing is refused on this account: the words are admitted as
 * typed, which is all a coded policy ever saw. `words` are the words after
 * the name and the statement's redirect targets, as the gate read them;
 * `operands` the words after the name, classified; `cwd` the working
 * directory the line runs in. Mirrors the Python `_line_refusal`.
 */
function lineRefusal(name, args, words, operands, redirects, inners, rules, open, lost, cwd) {
    if (scopesPaths(rules, name)) {
        if (lineFacts(name, [name, ...args], cwd).readsSubtree) {
            return 'walks a tree the gate cannot follow';
        }
        const globby = [...operands, ...redirects].some((p) => p instanceof PathSpec && isGlob(p.rawPath || p.virtual));
        if (globby)
            return 'expands a pattern only the runtime can read';
    }
    if (readsArgs(rules, name)) {
        const unread = words.find((w) => w.text === null)?.raw;
        if (unread !== undefined)
            return unreadable(unread);
        if (open)
            return 'runs on operands the gate cannot read';
        if (lost)
            return 'runs after a cd the gate cannot follow';
    }
    if (hasRules(rules) && inners.some((inner) => !innerReadable(inner)))
        return UNREADABLE_LINES;
    return null;
}
/**
 * Judge one command and whatever lines it runs in turn, each with its
 * occurrence, one at a time, so a reader that stops at a refusal never asks
 * the chain about what comes after it. The redirect targets are read as
 * words of the command: the shell opens them on its own fds, outside the
 * window the command's own gate covers, so a rule about `/protected` sees
 * `echo x > /protected` only if they are passed here; they are empty for a
 * command with none and for the inner lines a command runs. A line the
 * command runs (`eval`, `sh -c`) is parsed on its own and read under the
 * command's occurrence, exactly as the nested evaluation will stand when it
 * runs. For a runtime that takes the line whole this is the gate itself
 * (`admitLine`), so what `explain` says about such a line is what its
 * admission does: a deny first, then what the runtime would do past the gate
 * (`lineRefusal`), then an ask. `stated` is whether the words reach here as
 * the gate will read them; false under a command the runtime completes,
 * since a line built from its words (`eval`) or run on its operands
 * (`xargs`) is completed with them. `wholeLine` is whether a runtime takes
 * the line whole, where this is the only gate; the executor judges each
 * command again once its words are expanded. `lost` is whether a `cd` the
 * walk could not follow ran before the command, as `Walked` carries it; a
 * whole line still shows the chain what its words name where the walk last
 * knew the shell, since no gate judges them once it runs.
 * `every` asks every policy past a Deny, for `explain`; `rulesOnly` asks the
 * profile's rules alone, for a pass that judges a line before it runs, since
 * a policy's own `preCommand` is asked once, by the gate that runs the
 * command. `open` says the runtime appends operands the gate cannot read
 * (`xargs`, `find -exec`, `mapfile -C`).
 */
async function* judgeWords(words, occurrence, session, registry, namespace, reparse, redirectWords = [], stated = true, intrinsic = false, wholeLine = false, lost = false, every = false, stdin = null, rulesOnly = true, open = false) {
    const head = words[0];
    if (head === undefined)
        return;
    const rules = session.commands;
    // The runtime runs the commands itself: no command's result comes back
    // for a postCommand policy to judge.
    if (wholeLine && (await registry.policies.wantsFor('postCommand', session.sessionId))) {
        const reason = 'a post_command policy cannot judge a line a runtime runs whole';
        yield { judgment: denied(head.raw, reason), occurrence, stated: false };
        return;
    }
    if (head.text === null && !wholeLine)
        return;
    if (head.text === null && hasRules(rules)) {
        yield { judgment: denied(head.raw, unreadable(head.raw)), occurrence, stated: false };
        return;
    }
    const literal = stated && [...words, ...redirectWords].every((w) => w.text !== null);
    const name = wordValue(head);
    const args = words.slice(1).map(wordValue);
    const classified = await classifiedWords(name, args, session, registry);
    const readWords = [...words.slice(1), ...redirectWords];
    const kinds = [
        ...classified.slice(1),
        ...redirectWords.map((w) => classifyBarePath(wordValue(w), registry, session.cwd)),
    ];
    const unread = unreadPaths(readWords, kinds, session.cwd, lost && !wholeLine);
    const redirects = redirectPaths(redirectWords, registry, session.cwd);
    const wordsRead = commandWords(registry, name, args, classified.slice(1), redirects, session.cwd, envSnapshot(session), sessionAccess(session));
    const [ctx, asked, answers] = await gate(name, args, classified.slice(1), session, registry, namespace, stdin, redirects, intrinsic, unread, every, rulesOnly, occurrence.source);
    const inners = innerLines(name, words.slice(1));
    const refused = wholeLine && asked?.kind !== 'deny'
        ? lineRefusal(name, args, readWords, classified.slice(1), redirects, inners, rules, open, lost, session.cwd)
        : null;
    if (refused !== null) {
        yield {
            judgment: { ...denied(name, refused), words: wordsRead },
            occurrence,
            stated: literal,
            intrinsic,
            unread,
        };
        return;
    }
    yield {
        judgment: { ...(await explained(ctx, registry, asked, answers)), words: wordsRead },
        occurrence,
        stated: literal,
        intrinsic,
        unread,
        ctx,
        session,
    };
    // env's command runs in the environment env builds for it, and a nested
    // shell's line in a new shell started from this one.
    const child = name === 'env' && inners.length > 0 ? envChild(args, session) : null;
    let innerSession = session;
    if (child !== null && typeof child !== 'string') {
        innerSession = session.fork();
        innerSession.vars = envVars(session, child[1]);
    }
    else if ((name === 'sh' || name === 'bash') && inners.length > 0) {
        innerSession = session.newShell();
    }
    for (const inner of inners) {
        if (!innerReadable(inner))
            continue;
        if (inner.line !== null) {
            yield* judgeLine(reparse(inner.line), innerSession, registry, namespace, reparse, lineFrame(inner.line, occurrence), literal && !inner.open, wholeLine, lost, every, rulesOnly, inner.open);
        }
        else {
            const within = wholeOccurrence(argvFrame(inner.argv.map(wordValue), occurrence));
            yield* judgeWords(inner.argv, within, innerSession, registry, namespace, reparse, [], literal && !inner.open, false, wholeLine, lost, every, null, rulesOnly, inner.open);
        }
    }
}
/** One command node's words, name first, the env prefix dropped. */
function wordsOf(node, home) {
    const [, parts] = splitEnvPrefix(getParts(node));
    return parts.map((part) => readWord(part, home));
}
/**
 * The session a command runs in: `session` with the command's own
 * `NAME=value` prefix exported, as the executor seeds it before the
 * command's gate, so `preExecute` reads the environment `preCommand`
 * will. A value only the run expands is read as typed, as the command's
 * words are, and a readonly name is left as it is.
 */
function seeded(node, session, home) {
    const [assigned] = splitEnvPrefix(getParts(node));
    if (assigned.length === 0)
        return session;
    const forked = session.fork();
    for (const part of assigned) {
        const text = getText(part);
        const eq = text.indexOf('=');
        if (eq < 0)
            continue;
        const key = text.slice(0, eq);
        if (envIsReadonly(forked, key, false))
            continue;
        const value = part.namedChildren.find((c) => c.type !== NodeType.VARIABLE_NAME);
        seedVar(forked, key, value !== undefined ? wordValue(readWord(value, home)) : text.slice(eq + 1));
        setAttr(forked, key, VarAttr.Export);
    }
    return forked;
}
/**
 * Every command under one node, in source order, each with the session
 * it is judged in; returns the session the node leaves behind.
 *
 * A `cd` reaches the commands after it, and how far is the whole
 * question. Pinned against bash: `( )`, `$( )` and `<( )` run their
 * contents in a child shell, so a `cd` inside one applies to the rest of
 * that child and is gone when it exits; a pipeline forks once per
 * segment, so a `cd` in one segment reaches neither the next segment nor
 * the line; `&` backgrounds into a fork; and a brace group or an `if`
 * body does not fork at all, so its `cd` does escape. Reading a subshell
 * as "no `cd` applies" rather than "no `cd` escapes" judged
 * `(cd d && tar -c ..)` at the wrong directory, which made `..` read as
 * a mount root.
 *
 * The session is returned rather than carried down because that is what
 * "escapes" means, and because `&` is not a wrapper node: it is a token
 * following its command, visible only to whoever holds the sibling list.
 */
function* walkSubstitution(tree, session, home, frame, reparse, lost, elsewhere) {
    const redirect = inputSubstitutionRedirect(tree);
    if (redirect === null) {
        return yield* walkNode(tree, session, home, frame, reparse, lost, elsewhere);
    }
    const target = redirect.targetNode;
    yield {
        words: [{ raw: 'cat', text: 'cat' }],
        redirects: [{ raw: getText(target), text: literalWord(target, home) }],
        session,
        occurrence: occurrenceIn(tree, frame),
        intrinsic: true,
        lost,
        elsewhere,
    };
    yield* walkNode(target, session, home, frame, reparse, lost, elsewhere);
    return [session, lost, elsewhere];
}
function* walkNode(node, session, home, frame, reparse, lost = false, elsewhere = []) {
    if (node.type === NodeType.FUNCTION_DEFINITION)
        frame = definitionFrame(node, frame);
    if (node.type === NodeType.TEST_COMMAND) {
        // The shell's own test, judged as a command of its own name, a test at
        // a time as the gate admits it.
        for (const test of readsOfTest(node, home)) {
            yield {
                words: test.words,
                redirects: statementRedirects(node, home),
                stdin: statementStdin(node),
                session,
                occurrence: occurrenceIn(node, frame),
                intrinsic: true,
                lost,
                stated: test.stated,
                elsewhere,
            };
        }
        for (const child of node.children) {
            yield* walkNode(child, session, home, frame, reparse, lost, elsewhere);
        }
        return [session, lost, elsewhere];
    }
    if (node.type === NodeType.COMMAND) {
        let walked = [session, lost, elsewhere];
        const words = wordsOf(node, home);
        if (words.length > 0) {
            yield {
                words,
                redirects: statementRedirects(node, home),
                stdin: statementStdin(node),
                session: seeded(node, session, home),
                occurrence: occurrenceIn(node, frame),
                lost,
                elsewhere: elsewhere.map((e) => seeded(node, e, home)),
            };
            walked = afterCd(words, session, lost, elsewhere);
        }
        // A substitution among the words runs in its own shell.
        for (const child of node.children) {
            yield* walkNode(child, session, home, frame, reparse, lost, elsewhere);
        }
        return walked;
    }
    if (FORK_SCOPES.has(node.type)) {
        // A substitution's body is walked in a frame of its own: the nested
        // line that evaluates it parses the body alone, under the
        // substitution's node, and the commands in it have to be placed
        // here exactly where that line will place them.
        const segments = segmentFrames(node, frame);
        if (segments.length > 0) {
            // A backtick region is read as the evaluator runs it: its body
            // parsed again once its backslashes are gone, because the node's
            // subtree is not what runs.
            for (const inner of segments) {
                yield* walkSubstitution(reparse(inner.text), session, home, inner, reparse, lost, elsewhere);
            }
            return [session, lost, elsewhere];
        }
        const inner = bodyFrame(node, frame);
        if (node.type === NodeType.COMMAND_SUBSTITUTION && inner !== null) {
            const body = { ...inner, base: 0 };
            yield* walkSubstitution(reparse(inner.text), session, home, body, reparse, lost, elsewhere);
            return [session, lost, elsewhere];
        }
        yield* walkChildren(node, session, home, inner ?? frame, reparse, lost, elsewhere);
        return [session, lost, elsewhere];
    }
    if (node.type === NodeType.PIPELINE) {
        for (const child of node.children) {
            yield* walkNode(child, session, home, frame, reparse, lost, elsewhere);
        }
        return [session, lost, elsewhere];
    }
    if (REPEATED_SCOPES.has(node.type) && moves(node, session, home))
        lost = true;
    return yield* walkChildren(node, session, home, frame, reparse, lost, elsewhere);
}
/**
 * Whether a command in a scope may move the shell's cwd, outside the child
 * shells it forks (a subshell, a substitution, a pipeline's stages, a job sent
 * to the background), as `walkNode` reads them: what a loop or a function
 * body carries into its next run. Read off the tree, so a nested scope costs
 * one more scan, not another walk.
 */
function moves(node, session, home) {
    const stack = [node];
    for (let current = stack.pop(); current !== undefined; current = stack.pop()) {
        if (FORK_SCOPES.has(current.type) || current.type === NodeType.PIPELINE)
            continue;
        if (current.type === NodeType.COMMAND) {
            const words = wordsOf(current, home);
            if (words.length > 0) {
                const [moved, lost] = afterCd(words, session, false, []);
                if (lost || moved !== session)
                    return true;
            }
        }
        const children = current.children;
        for (const item of children.filter((_, index) => children[index + 1]?.type !== '&'))
            stack.push(item);
    }
    return false;
}
/**
 * One scope's children in order, threading the cwd between them; returns
 * where the scope ends.
 */
function* walkChildren(node, session, home, frame, reparse, lost, elsewhere) {
    let walked = [session, lost, elsewhere];
    const children = node.children;
    for (let index = 0; index < children.length; index += 1) {
        const child = children[index];
        if (child === undefined)
            continue;
        const ended = yield* walkNode(child, walked[0], home, frame, reparse, walked[1], walked[2]);
        if (children[index + 1]?.type === '&')
            continue;
        walked = ended;
    }
    return walked;
}
/**
 * Where the next command of a line stands, which differs from this one only
 * when this command moved the shell.
 *
 * `cd /repo && git commit` is judged before the line runs, so without this
 * the rule about `/repo` reads the cwd the session happened to be in and
 * answers about the wrong directory. A `cd` may also be skipped
 * (`false && cd d`) or fail, so the shell may still stand anywhere it stood
 * before: those places ride along, and a runtime that takes the line whole
 * admits a command only where every one of them does. A `cd` the walk
 * cannot follow (`cd "$d"`, `cd -`, any once the cwd is lost, since it too
 * may fail), a line run in this shell (`eval`, `source`) or the directory
 * stack loses the cwd, and what the commands after it name relative to it
 * is the per-command gate's to judge, in the real one: judged in the cwd the
 * walk last knew, `cd "$d" && rm x` refused a line that removes an allowed
 * file.
 */
function afterCd(words, session, lost, elsewhere) {
    const head = words[0];
    if (head === undefined)
        return [session, lost, elsewhere];
    const name = wordValue(head);
    if ((name === 'command' || name === 'builtin') && words.length > 1) {
        return afterCd(words.slice(1), session, lost, elsewhere);
    }
    if (CWD_MOVERS.has(name))
        return [session, true, elsewhere];
    if (name !== 'cd')
        return [session, lost, elsewhere];
    const target = words.length === 2 ? (words[1]?.text ?? null) : null;
    if (lost || target === null || target.startsWith('-'))
        return [session, true, elsewhere];
    const predicted = session.fork({ cwd: resolvePath(target, session.cwd) });
    const places = new Map();
    for (const place of [
        session,
        ...elsewhere,
        ...elsewhere.map((e) => e.fork({ cwd: resolvePath(target, e.cwd) })),
    ]) {
        if (place.cwd !== predicted.cwd && !places.has(place.cwd))
            places.set(place.cwd, place);
    }
    if (places.size > PLACES_LIMIT)
        return [session, true, elsewhere];
    return [predicted, false, [...places.values()]];
}
/**
 * Every command of a line with the session it is judged in.
 *
 * The cwd is the one fact that moves as a line runs, and both readers
 * of a line need the same answer about it: a host asking what a line
 * would do and the pass deciding whether to let it run cannot differ,
 * or `explain` would report an allow the run then refuses. The redirects
 * ride along for the same reason: they are read here so both readers
 * judge the file the shell opens, not just the operands.
 */
function* walkedLine(root, session, reparse, frame = null, lost = false) {
    yield* walkNode(root, session, homeDir(session), frame ?? rootFrame(root, null), reparse, lost);
}
/**
 * Whether an explanation refuses the line's intent, rather than just
 * failing one command.
 *
 * Explicit deny rules and command-scoped policy refusals hold the line.
 * Operand-scoped filesystem refusals wait for the per-command gate,
 * where earlier commands have established the live cwd and namespace.
 * Rule-less DENY results also wait: an unavailable head word, an
 * uncovered command, or words only the runtime can expand fail where
 * they occur rather than against the whole line.
 */
/**
 * Whether the compound-line pass puts a command through the gate.
 *
 * A verdict is, so the line is refused whole. So is a command that would
 * run, because "would run" may mean a standing grant answers its ask,
 * and only the gate can claim that grant for this line: read but not
 * claimed, one nod answered every spelling of the command on the line,
 * and a grant given to a line that was then refused stood for the next
 * one. What stays out is the rule-less DENY, which `isVerdict` explains
 * is answered where it happens.
 */
function isJudged(expl) {
    return expl.exitCode === 0 || isVerdict(expl);
}
/**
 * Whether the allow list refuses a command on its own words, which nothing
 * that runs before it on the line can change. Mirrors the Python
 * `_uncovered`.
 */
function uncovered(expl) {
    return expl.outcome === Outcome.DENY && expl.source === 'commands.allow' && refuses(expl);
}
/**
 * Whether an explanation refuses the command outright, rather than
 * reporting a line that would run or a question the host has not
 * answered.
 */
function refuses(expl) {
    return expl.exitCode !== 0 && !isPending(expl.refusal);
}
/**
 * Whether the pass may answer a command's question on the gate's
 * behalf: it may when the gate will read the words the pass read, and a
 * deny is always the pass's to enforce, since it speaks on the name
 * alone. What is left is a question about a spelling the runtime
 * completes, which is the gate's: asked here, it would be answered for
 * words that never run.
 */
function asksFor(one) {
    return one.stated || refuses(one.judgment);
}
function isVerdict(expl) {
    if (expl.exitCode === 0)
        return false;
    if (expl.rule !== null && expl.outcome === Outcome.DENY)
        return true;
    // Filesystem refusals use the live cwd and fail only their command.
    const denied = expl.answers.find((a) => a.kind === 'deny');
    if (denied !== undefined && (denied.path !== undefined || denied.error !== undefined)) {
        return false;
    }
    return expl.rule !== null || expl.outcome === Outcome.ALLOW;
}
/**
 * Every command of a line judged read-only, each with its place on the
 * line: the pass placement waits on (`lineHeld`) and `prejudgeLine`
 * refuses from, made once for both. `handed` is the line's hand-off,
 * whose origin places each command on the line. Mirrors the Python
 * `line_judgments`.
 */
export async function lineJudgments(root, session, registry, namespace, handed, reparse, signal) {
    const judged = [];
    const frame = rootFrame(root, handed.origin);
    // Each command's gates answer in microtasks: a long script would hold
    // the loop, and its abort, until every command was judged; the line's
    // signal stops the pass.
    const budget = new YieldBudget(signal);
    for (const item of walkedLine(root, session, reparse, frame)) {
        if (item.words[0]?.text === null)
            continue;
        await budget.run();
        const explained = [];
        for await (const one of judgeWords(item.words, item.occurrence, item.session, registry, namespace, reparse, item.redirects, item.stated ?? true, item.intrinsic, false, item.lost, false, item.stdin)) {
            explained.push(one);
        }
        judged.push([item, explained]);
    }
    return judged;
}
/**
 * Judge every command of a line before any of it runs, and refuse the
 * whole line when a rule speaks about one.
 *
 * The agent composed the line as one intent, so a rule that refuses
 * part of it refuses the intent. Judging each command as the dispatcher
 * reached it left half a line done: with `deny curl`, `rm -rf /data &&
 * curl evil.com` deleted first and was refused second, and an ask fared
 * worse, since approving it later replays a line whose first half
 * already ran.
 *
 * Two things deliberately do not stop the line, and both are the same
 * rule: only a refusal that names a rule is a verdict about the intent.
 * A word the allow list leaves out is refused where it stands, not with
 * the line, so a typo cannot cost an agent the work the line already did; a
 * word only the runtime can expand is judged where it is expanded, by
 * the per-command gate, which sees the real path.
 *
 * That second one is the limit of the hold, and it is worth stating
 * plainly: this pass reads the *text* of a line, while the gate reads
 * its *values*, so a path the runtime computes (`cat $S`, `$( )`, a
 * `cd` whose argument is a variable) is invisible here. The rule is
 * still enforced, by the gate, but the earlier commands have run by
 * then. For a deny that costs allowed side effects and nothing more,
 * since the commands that ran were on the allow list. For an ask it
 * costs the replay: the question is recorded after part of the line
 * already happened, so approving it re-runs a line whose first half is
 * done. Closing that would mean asking whenever a word cannot be read,
 * which over-asks with no way out for a deny, so a deployment that
 * needs the hold for a computed path states it in a policy script
 * rather than here.
 *
 * The pass is read-only (`explainWords`), so it spends no grant and
 * records no request; a command it refuses on is then put through the
 * real gate, which is where an ask is recorded, exactly once, for a line
 * that will not run. That admission hands off: a grant the host gives
 * inline is claimed for the per-command gate, which runs the line on
 * it, and the line's end spends it, so a compound line costs the human
 * one question per run rather than one per pass, and a gate the run
 * reaches twice (a loop body) runs on one nod.
 *
 * A question is only asked here when the gate will ask the same one.
 * The gate reads a word the runtime expands as its value and the words
 * `xargs` or `find -exec` hand on with the operands the runtime
 * appends, so a question about `cat $F` or a bare `cat` would be
 * answered for words that never run and the gate would ask again, after
 * the earlier commands ran, about the words that do. Such a command
 * (`Judged.stated` false) is judged here for a deny, which speaks on
 * the name alone and still holds the line, and its question is left to
 * the gate; the hold does not reach it, which is the limit stated above
 * in another form.
 *
 * Every command is judged whether or not the session carries a document,
 * by the profile's rules alone: a policy registered in code or a profile's
 * script answers `preCommand` once, at the gate that runs the command, and
 * a line it should refuse whole is `preExecute`'s to refuse. Operand-scoped
 * refusals, including MountRootPolicy's, remain the per-command gate's
 * responsibility.
 *
 * A line with one command to judge is left to the per-command gate,
 * which is not an optimization but the more faithful answer: there is no
 * earlier command whose side effects a hold could save, and the gate
 * refuses from inside the shell, so the line's own redirections still
 * apply. This pass answers above them, so refusing `rm -rf /mnt 2>&1`
 * here wrote the refusal to stderr where bash puts it on stdout.
 */
export async function prejudgeLine(root, session, registry, namespace, 
// The line's hand-off, on which every grant claimed here rides to the
// executor's sweep.
handed, reparse, 
// This pass puts real questions to a host, so it carries the run's
// kill channel exactly as the per-command gate does. Without it a
// compound line asked here waited on an answer that its own timeout
// could no longer cut short.
signal, 
// The line's read-only judgments (`lineJudgments`) when placement
// already made them, so the policies are asked once.
made = null) {
    const judged = made ?? (await lineJudgments(root, session, registry, namespace, handed, reparse));
    if (judged.reduce((n, [, explained]) => n + explained.length, 0) < 2)
        return null;
    for (const [, explained] of judged) {
        for (const one of explained) {
            if (!isJudged(one.judgment) || !asksFor(one))
                continue;
            // This pass judges on the gate's behalf and runs nothing itself, so a
            // grant the host gives here is claimed for the per-command gate that
            // runs the line, and spent when the line ends: one question per run,
            // not per pass.
            const refused = await admitted(one, registry, handed, signal);
            if (refused !== null)
                return refused;
            // The host answered this one inline. The rest of the line has not
            // been judged yet, so the scan goes on: stopping here let a later
            // command's deny run behind an approval.
        }
    }
    return null;
}
/**
 * One judged command put through the ledger, as the gate puts it. A command
 * refused before any policy was asked (`Judged.ctx` absent) stands as
 * judged. Otherwise the chain's answer the judgment recorded is the gate's,
 * and its Ask is answered from the session's grants or the host and claimed
 * on the line's hand-off (`handed`, null outside a line) for this
 * occurrence, so the policies are asked once per command, by the pass that
 * judged it. Mirrors the Python `_admitted`.
 */
async function admitted(one, registry, handed, signal) {
    const judgment = one.judgment;
    if (one.ctx === undefined) {
        return {
            stderr: encodeText(judgment.stderr),
            exitCode: judgment.exitCode,
            refusal: judgment.refusal,
        };
    }
    const action = await resolved(one.ctx, settled(judgment.answers), registry, signal, handed === null ? null : { line: handed, occurrence: one.occurrence });
    if (action === null)
        return null;
    const [stderr, exitCode] = await renderCommandDeny(judgment.command, action, one.ctx);
    return { stderr, exitCode, refusal: refusalOf(action) };
}
/**
 * Admit every command of a line a runtime takes whole. A whole line is a
 * command like any other, but the runtime does the expanding, so the gate
 * reads the line as typed, walked as `explain` walks it: each command is
 * judged on its literal words (quotes removed, escapes resolved, a
 * path-shaped word a path, an installed CLI's verb path walked) where it
 * stands, a `cd` before it on the line included, and the first refusal is
 * the line's. A statement's redirect targets are read as words of its
 * command, so `cat < /data/secret` is judged on the file it opens; the lines
 * a command runs (`eval`, `sh -c`, `xargs`, `env` ... see `innerLines`) are
 * judged in turn; and what the runtime would do past the gate is refused
 * under a rule that would have read it (`lineRefusal`). `reparse` parses the
 * text a word runs the way the line reader parsed the line.
 *
 * No gate follows this pass: the runtime runs the line whole, so every
 * grant it matches is claimed on the line's hand-off exactly as any reader
 * on a line claims, and the executor's sweep spends them when the line
 * ends. A line held on a question still waiting keeps its earlier answers
 * standing for the retry, exactly as the compound-line pass does, where
 * spending them here asked the human again for each on every retry.
 * `handed` is null outside a line (a bare admission with no run behind it).
 * Mirrors the Python `admit_line`.
 */
export async function admitLine(root, session, registry, namespace, reparse, signal, handed = null) {
    for await (const one of judgeLine(root, session, registry, namespace, reparse, rootFrame(root, handed?.origin ?? null), true, true, false, false, false)) {
        const refused = await admitted(one, registry, handed, signal);
        if (refused !== null)
            return refused;
    }
    return null;
}
/**
 * Whether a verdict's answer refuses the command, putting an
 * unanswered ask's question to the host.
 *
 * The rules' own answer the pass recorded is read, the first deny else
 * the first ask, the way the gate picks it. A deny refuses outright. An
 * ask's settled record is read without being spent (`Decisions.held`), so
 * the gate that then runs the line consumes the same answer, in its own
 * voice and behind the line's redirections; an unanswered rule is raised
 * through the same ledger the gate reads, so the answer lands exactly once
 * and the gate does not ask again.
 */
async function verdictRefuses(judged, registry, 
// The line's hand-off, on which an answer given here is claimed for
// the gate.
handed, signal) {
    const ctx = judged.ctx;
    if (ctx === undefined)
        return true;
    const asked = settled(judged.judgment.answers);
    if (asked?.kind !== 'ask')
        return asked !== null;
    const claimant = { line: handed, occurrence: judged.occurrence };
    const standing = await registry.decisions.held(ctx, asked, claimant);
    if (standing === null)
        return false;
    if (standing.kind === 'deny')
        return true;
    // handOff: this pass exists to decide whether a secret is fetched, and the
    // gate behind it still has to admit the line. An answer given here is
    // claimed for that gate, which runs on it, so the host is asked once.
    return (await resolved(ctx, asked, registry, signal, claimant)) !== null;
}
/**
 * Whether the node defines a function anywhere in its tree.
 *
 * A definition's body is walked by `walkedLine` like any other scope,
 * but it runs at invocation, not here, so a command inside one must
 * not be read as the node's own: judging it would refuse a line that
 * only stores text, and the read walks already charge nothing for it.
 */
function definesFunction(node) {
    const stack = [node];
    for (let current = stack.pop(); current !== undefined; current = stack.pop()) {
        if (current.type === 'function_definition')
            return true;
        for (const child of current.namedChildren)
            stack.push(child);
    }
    return false;
}
/**
 * The node's one fully-literal command, when nothing else in the node
 * can read a name.
 *
 * A walked node's reads can be discounted only when the whole node is
 * one command, every word and redirect of it is literal, it defines
 * nothing, and its tree reads no name any other way: such a node reads
 * only what that one command's own grammar reads, so a refusal of the
 * command is a refusal of every read the node contributes. Anything
 * less provable -- a second command, a word only the runtime can
 * expand, a `$NAME` anywhere -- returns null, and the caller keeps the
 * node, because some part of it may still run and read.
 */
function soleLiteralCommand(node, session, frame, reparse) {
    const items = [...walkedLine(node, session, reparse, frame)];
    const item = items[0];
    if (items.length !== 1 || item === undefined)
        return null;
    if ([...item.words, ...item.redirects].some((word) => word.text === null))
        return null;
    if (definesFunction(node))
        return null;
    if (referencedNames(node).size > 0 || opaqueReads(node))
        return null;
    return item;
}
/**
 * Whether one walked command is refused on its text, resolving an
 * unanswered ask through the ledger the gate reads. `line` says the
 * command is the whole line, which runs nothing at all once the allow list
 * refuses it.
 */
async function commandRefused(item, registry, namespace, handed, reparse, signal, line = false) {
    const explained = [];
    for await (const one of judgeWords(item.words, item.occurrence, item.session, registry, namespace, reparse, item.redirects, item.stated ?? true, false, false, item.lost, false, item.stdin)) {
        explained.push(one);
    }
    // The allow list refusing the line's one command on words no glob can
    // change means nothing runs. A line it hands on, or a line handed on to
    // it, is not enough: what runs it may not ask the gate.
    const globbed = [...item.words, ...item.redirects].some((word) => word.glob === true);
    const [own] = explained;
    if (line && own !== undefined && !globbed && uncovered(own.judgment))
        return true;
    for (const judged of explained) {
        // A question about a spelling the runtime completes is the gate's
        // (`asksFor`); the node is kept, and over-keeping only ever
        // over-fetches.
        if (!isVerdict(judged.judgment) || !asksFor(judged))
            continue;
        if (await verdictRefuses(judged, registry, handed, signal))
            return true;
    }
    return false;
}
/**
 * The walked nodes whose reads an env-plane fetch still serves.
 *
 * The fill derives its fetch set from this same list (`lineNodes`: the
 * line's own tree first, then every stored body and alias expansion
 * its words can invoke), and a fetch serves a command that is going to
 * run, so refusals are judged over the same nodes reads are. One rule
 * for every node: when it is one fully-literal command with no other
 * read in it (`soleLiteralCommand`), the gate is asked here on exactly
 * the words it will read at run time, and a refusal discounts every
 * read the node contributes. The line's own refusal drops the whole
 * list, because nothing runs at all; a refused body or alias drops
 * just itself, because the invocation still runs and is refused in
 * place. A node this pass cannot prove silent is kept, and over-keeping
 * only ever over-fetches.
 *
 * An ASK is resolved rather than skipped, because the fetch is itself
 * an effect: contacting a secret store for a line the host then
 * refuses would do a piece of exactly what was refused. A settled
 * answer is read without being spent; an unanswered rule is put to the
 * host now, through the same ledger the gate reads, so the answer
 * lands exactly once -- an approval keeps the node and the gate
 * consumes the grant, while a denial or a question left waiting drops
 * it, and the line still runs into the gate, which refuses in place
 * with its wording and its redirections.
 */
export async function unrefusedNodes(nodes, session, registry, namespace, 
// The line's hand-off, on which an approval given here is claimed
// for the gate.
handed, reparse, signal) {
    const out = [];
    for (const [position, node] of nodes.entries()) {
        // Each node is read in the frame of its own tree: the line's, or
        // the one a stored body was parsed from, which is the frame its gate
        // will read it in. An alias expansion is parsed here with a rest
        // word no reader can spell (`lineNodes`), so it is never one literal
        // command and its frame goes unread; its gate reads it under the
        // word that invoked it.
        const item = soleLiteralCommand(node, session, gateFrame(node, nodes[0] ?? node, session, handed), reparse);
        if (item === null) {
            out.push(node);
            continue;
        }
        if (await commandRefused(item, registry, namespace, handed, reparse, signal, position === 0)) {
            if (position === 0)
                return [];
            continue;
        }
        out.push(node);
    }
    return out;
}
/**
 * Whether the line's admission holds it back, which is what placement
 * waits on: admission comes first, so a line the rules refuse, or that
 * waits on the host, is never shown to a placing policy. A verdict
 * (`isVerdict`) that refuses holds the line. A question is put to the
 * host now, on the line's hand-off, so the gate that later runs the
 * command finds the answer claimed for it and does not ask again; one
 * still waiting holds the line, and a question the gate will ask about
 * other words (`Judged.stated` false) is left to it. A word the allow
 * list leaves out fails where it stands while the rest of its line runs,
 * so it does not hold the line. Mirrors the Python `line_held`.
 */
export async function lineHeld(judged, registry, handed, signal) {
    for (const [, explained] of judged) {
        for (const one of explained) {
            const expl = one.judgment;
            if (!isVerdict(expl) || expl.exitCode === 0)
                continue;
            if (refuses(expl))
                return true;
            const asked = settled(expl.answers);
            if (one.ctx === undefined || asked?.kind !== 'ask' || !one.stated)
                continue;
            const claimant = { line: handed, occurrence: one.occurrence };
            if ((await resolved(one.ctx, asked, registry, signal, claimant)) !== null)
                return true;
        }
    }
    return false;
}
/**
 * Whether some command's judgment is a verdict that refuses it or waits
 * on the host (`isVerdict`), the line-level answer placement waits on.
 * Mirrors the Python `holds`.
 */
export function holds(judgments) {
    return judgments.some((one) => isVerdict(one) && one.exitCode !== 0);
}
/**
 * What every command of a line would do, in the order the gate reads
 * them, each where it stands, without running any of it (`explainedLine`
 * turns them into the public tree).
 *
 * The dry run of the gate: the same visibility check, the same context,
 * the same policy chain and the same outcome table, so a host reading
 * this and an agent typing the line cannot be told different things.
 * What it deliberately does not do is the half of admission that costs
 * something, since a line nobody typed must not consume a grant or put
 * a question to a host.
 *
 * The words are read literally, as `admitLine` reads them, so nothing is
 * expanded and no `$( )` runs. `wholeLine` is whether a runtime takes the
 * line whole, which reads it as typed; the executor's gate reads each
 * command once expanded. `every` asks every policy, as each command's
 * explanation shows them; false asks the profile's rules alone, as the
 * run does before any of the line runs, which is what holds it.
 */
export async function explainLine(root, session, registry, namespace, reparse, wholeLine = false, every = true) {
    const judged = [];
    for await (const one of judgeLine(root, session, registry, namespace, reparse, rootFrame(root, null), true, wholeLine, false, every, !every)) {
        judged.push(one);
    }
    return judged;
}
/**
 * A line's public explanation: its verdict and its parse tree, with every
 * command's explanation where the command stands. The verdict is the
 * first command, in the order the gate reads them, whose refusal by the
 * profile's rules (`verdicts`, the line judged with `every` false) holds
 * the whole line (`holds`), as the run reports it; a line nothing holds
 * runs, carrying the first ask an approval lets through. A command
 * refused only where it stands, a policy's own `preCommand` refusal
 * included, keeps its refusal to its own node, since the rest of the
 * line still runs. A runtime that takes the line whole (`wholeLine`) runs
 * none of it once any command is refused (`admitLine`), so there every
 * refusal is the line's, and `verdicts` is `judged` itself, whose gate asks
 * every policy. Every scope a command reads its words in (the typed line, a
 * `$( )` body, a `bash -c` string) is parsed on its own with `reparse`, as the nested
 * line will be; a judgment is placed on the command at its span in that
 * scope, and a nested scope under the command holding it. Mirrors the
 * Python `explained_line`.
 */
export function explainedLine(line, judged, verdicts, runtimeOf, reparse, wholeLine = false) {
    const scopes = [];
    for (const one of judged) {
        const at = one.occurrence;
        let scope = scopes.find((s) => s.source === at.source && sameAt(s.parent, at.parent));
        if (scope === undefined) {
            scope = { parent: at.parent, source: at.source, judged: [] };
            scopes.push(scope);
        }
        scope.judged.push(one);
    }
    const root = scopes.find((s) => s.parent === null) ?? { parent: null, source: line, judged: [] };
    const node = scopeNode('line', root, scopes, runtimeOf, reparse);
    // The run holds a line only on a verdict the pass may give on the gate's
    // behalf (`prejudgeLine`): a question about words the runtime completes
    // (`find -exec`, `xargs`) is the gate's, asked where it runs, so it holds
    // nothing here either.
    const held = verdicts.find((one) => one.judgment.exitCode !== 0 && (wholeLine || (isVerdict(one.judgment) && asksFor(one))));
    if (held !== undefined) {
        const judgment = held.judgment;
        const [outcome, reason, source] = verdictOf(judgment);
        return {
            line,
            node,
            outcome,
            reason,
            source,
            answers: [],
            refusal: judgment.refusal,
            exitCode: judgment.exitCode,
            stderr: judgment.stderr,
        };
    }
    const asked = verdicts.find((one) => one.judgment.exitCode === 0 && verdictOf(one.judgment)[0] === Outcome.ASK);
    const [outcome, reason, source] = asked === undefined ? [Outcome.ALLOW, '', ''] : verdictOf(asked.judgment);
    return {
        line,
        node,
        outcome,
        reason,
        source,
        answers: [],
        refusal: null,
        exitCode: 0,
        stderr: '',
    };
}
/** Whether two occurrences name the same place, as Python's dataclass equality reads them. */
function sameAt(a, b) {
    if (a === null || b === null)
        return a === b;
    return (a.source === b.source && a.start === b.start && a.end === b.end && sameAt(a.parent, b.parent));
}
/**
 * A command's outcome, reason and source as its explanation states them:
 * the refusal it meets (`ASK` for a question waiting on the host, `DENY`
 * for any other, a policy that failed included), else the ask an approval
 * lets through, else `DENY` for a word the allow list refuses. The reason
 * and source are the deciding answer's, which a coded policy may give over
 * the document's own. Mirrors the Python `_verdict_of`.
 */
function verdictOf(judgment) {
    const decider = settled(judgment.answers);
    const source = decider?.rule !== undefined
        ? sourceOf(decider.rule)
        : judgment.source === 'commands.allow'
            ? judgment.source
            : '';
    const refusal = judgment.refusal;
    if (refusal !== null) {
        return [isPending(refusal) ? Outcome.ASK : Outcome.DENY, refusal.reason, source];
    }
    if (decider?.kind === 'ask')
        return [Outcome.ASK, decider.reason, source];
    if (judgment.exitCode !== 0)
        return [Outcome.DENY, '', source];
    return [Outcome.ALLOW, '', source];
}
/**
 * One scope of a line as a node: its text parsed, the commands judged in
 * it placed by span, and the scopes evaluated from it placed under the
 * command holding them. Mirrors the Python `_scope_node`.
 */
function scopeNode(kind, scope, scopes, runtimeOf, reparse) {
    const text = scope.source;
    const mine = new Map();
    for (const one of scope.judged) {
        const span = `${String(one.occurrence.start)}:${String(one.occurrence.end)}`;
        mine.set(span, [...(mine.get(span) ?? []), one]);
    }
    const nested = scopes
        .filter((s) => s.parent !== null && s.parent.source === text && sameAt(s.parent.parent, scope.parent))
        .sort((a, b) => a.parent.start - b.parent.start || a.parent.end - b.parent.end);
    const take = (start, end) => {
        const inside = nested.filter((s) => start <= s.parent.start && s.parent.end <= end);
        for (const s of inside)
            nested.splice(nested.indexOf(s), 1);
        return inside.map((s) => scopeNode(s.parent.start === start && s.parent.end === end ? 'line' : 'substitution', s, scopes, runtimeOf, reparse));
    };
    const convert = (node) => {
        const start = node.startIndex ?? 0;
        const end = node.endIndex ?? 0;
        if (node.type === NodeType.COMMAND || node.type === NodeType.TEST_COMMAND) {
            const kids = take(start, end);
            const span = `${String(start)}:${String(end)}`;
            const ones = mine.get(span) ?? [];
            mine.delete(span);
            if (ones.length === 0)
                return [{ type: 'command', text: getText(node), children: kids }];
            return ones.map((one, i) => commandOf(one, getText(node), i === 0 ? kids : [], runtimeOf));
        }
        if (SUBSTITUTIONS.has(node.type))
            return take(start, end);
        const inner = node.namedChildren.flatMap(convert);
        const shape = SHAPES.get(node.type);
        return shape === undefined ? inner : [{ type: shape, text: getText(node), children: inner }];
    };
    const children = convert(reparse(text));
    for (const [span, ones] of mine) {
        const [start, end] = span.split(':').map(Number);
        const spoken = text.slice(start, end);
        for (const item of ones.map((one) => commandOf(one, spoken, [], runtimeOf)))
            children.push(item);
    }
    for (const item of take(0, Number.MAX_SAFE_INTEGER))
        children.push(item);
    return { type: kind, text, children };
}
/** One command's public explanation. Mirrors the Python `_command_of`. */
function commandOf(one, text, children, runtimeOf) {
    const judgment = one.judgment;
    const [outcome, reason, source] = verdictOf(judgment);
    return {
        type: 'command',
        command: judgment.command,
        words: judgment.words,
        exitCode: judgment.exitCode,
        stderr: judgment.stderr,
        outcome,
        reason,
        source,
        answers: judgment.answers,
        refusal: judgment.refusal,
        runtime: runtimeOf(judgment.command),
        text,
        children,
    };
}
/**
 * Every command of a line judged, in the order the gate reads them, each
 * with its place on the line, one at a time as `judgeWords` yields them.
 * `frame` is the scope the line is read in; `stated` is whether the line's
 * text reaches here as the gate will read it, and `wholeLine` whether a
 * runtime takes it whole, as `judgeWords` takes them; `lost` is whether the
 * line begins with its cwd lost, as a line a command runs after such a `cd`
 * does; `every` asks every policy past a Deny, for `explain`, and
 * `rulesOnly` the profile's rules alone; `open` says the runtime appends
 * operands the gate cannot read to the line (`mapfile -C`'s callback, which
 * runs with the index and the record after it), as `judgeWords` takes it
 * for each of its commands.
 */
async function* judgeLine(root, session, registry, namespace, reparse, frame, stated = true, wholeLine = false, lost = false, every = false, rulesOnly = true, open = false) {
    const judged = (item, place) => judgeWords(item.words, item.occurrence, place, registry, namespace, reparse, item.redirects, stated && (item.stated ?? true), item.intrinsic, wholeLine, item.lost, every, item.stdin, rulesOnly, open);
    for (const item of walkedLine(root, session, reparse, frame, lost)) {
        const elsewhere = item.elsewhere ?? [];
        if (!wholeLine || elsewhere.length === 0) {
            yield* judged(item, item.session);
            continue;
        }
        const places = [];
        for (const place of [item.session, ...elsewhere]) {
            const one = [];
            for await (const judgment of judged(item, place))
                one.push(judgment);
            places.push(one);
        }
        yield* strictest(places);
    }
}
/**
 * What a command is judged to do where it may stand, the strictest first: a
 * place where it is refused, then one where it asks, then where the walk
 * predicts it stands.
 */
function strictest(places) {
    const first = places.map((judged) => judged.find((one) => one.judgment.exitCode !== 0));
    for (const refused of [true, false]) {
        const index = first.findIndex((one) => one !== undefined && refuses(one.judgment) === refused);
        if (index >= 0)
            return places[index] ?? [];
    }
    return places[0] ?? [];
}
//# sourceMappingURL=explain.js.map