UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

1,295 lines • 62.6 kB
import { homedir } from "node:os";
import { shellWordsOf } from "../shell/shell-words.js";
import { parsePermissionRule } from "./permission-rule-syntax.js";
import { compileReadDeny } from "../tools/fs/read-deny.js";
import { carriesShellRedirection, parseLeadingCommandName, splitShellCompoundSegments } from "../tools/fs/bash-readonly-classifier.js";
import { INLINE_TRUNCATION_MARK, inlineUntrusted } from "./untrusted-text.js";
import { isShellFaceToolName, pathTargetOf, protectivePathTargetOf } from "./tool-registry.js";
import { heldValues } from "./tool-name-readings.js";
import { carriesDotSegment, isAbsoluteForFamily, isIdentityDir, isPathIdentity, isShellRootedSpellingUnmapped, isUncIdentity, pathFamilyOf, pathIdentityOf } from "../tools/fs/safety.js";
import { bindPathRules, judgeRuleRows, rowsWhereTheyLead } from "./path-rule-binding.js";
import { readShellCommand } from "./shell-lexer.js";
import { lineDescriptorsOfText, readOnlyLineObjection, readOnlyShellVerdict } from "./read-only-shell.js";
import { ARTIFACT_ACTIONS } from "./artifact-host.js";
import { ARTIFACT_TOOL_NAME } from "./tool-catalog-entries.js";
export const RULE_BEHAVIORS = Object.freeze(["deny", "ask", "allow"]);
const RULE_BEHAVIOR_SET = new Set(RULE_BEHAVIORS);
export function isRuleBehavior(v) {
    return RULE_BEHAVIOR_SET.has(v);
}
export const RULE_BEHAVIOR_PRECEDENCE = {
    deny: 0,
    ask: 1,
    allow: 2,
};
export const RULE_BEHAVIORS_BY_PRECEDENCE = [...RULE_BEHAVIORS].sort((a, b) => RULE_BEHAVIOR_PRECEDENCE[a] - RULE_BEHAVIOR_PRECEDENCE[b]);
export function readRowBehavior(v) {
    if (v === undefined)
        return "allow";
    return isRuleBehavior(v) ? v : undefined;
}
export const COMMAND_RULE_TOOL = "Bash";
if (!isShellFaceToolName(COMMAND_RULE_TOOL))
    throw new Error(`permission rules: the command rule word ${JSON.stringify(COMMAND_RULE_TOOL)} names no definition on the catalog's shell face`);
export const READ_RULE_TOOL = "Read";
export const PATH_RULE_FACES = Object.freeze({
    [READ_RULE_TOOL]: Object.freeze({ deny: Object.freeze(["read", "write"]), ask: Object.freeze(["read"]), allow: Object.freeze(["read"]) }),
    Edit: Object.freeze({ deny: Object.freeze(["write"]), ask: Object.freeze(["write"]), allow: Object.freeze(["write", "read"]) }),
});
export function callPathFaceOf(call) {
    const target = protectivePathTargetOf(call);
    return target === undefined ? undefined : target.access === "read" ? "read" : "write";
}
export function faceRowsSpeakFor(face) {
    return face !== undefined && Object.values(PATH_RULE_FACES).some((columns) => columns.deny?.includes(face) === true || columns.ask?.includes(face) === true);
}
export const NAME_RULE_FACES = Object.freeze({
    Skill: Object.freeze({ param: "skill", params: Object.freeze(["skill", "args"]) }),
    [ARTIFACT_TOOL_NAME]: Object.freeze({ param: "action", params: Object.freeze(["action"]), words: ARTIFACT_ACTIONS }),
});
function keyedValueParamOf(tool) {
    const face = Object.hasOwn(NAME_RULE_FACES, tool) ? NAME_RULE_FACES[tool] : undefined;
    return face?.words !== undefined ? face.param : undefined;
}
export function keyedValueAbsentReason(tool) {
    const key = keyedValueParamOf(tool);
    return key === undefined ? undefined : `the ${tool} call names no ${key} as the tool reads it, so the ${key} it runs is not one its arguments show — a rule by ${key} cannot be read against it`;
}
export function nameGrammarReads(tool, content) {
    if (ruleToolGrammarOf(tool) !== "name")
        return false;
    const key = keyedValueParamOf(tool);
    if (key === undefined)
        return true;
    const colon = content.indexOf(":");
    return colon > 0 && content.slice(0, colon).trim() === key;
}
export const DOMAIN_RULE_FACES = Object.freeze({
    WebFetch: Object.freeze({ param: "url" }),
});
export function ruleToolGrammarOf(toolName) {
    if (toolName === COMMAND_RULE_TOOL)
        return "command";
    if (Object.hasOwn(NAME_RULE_FACES, toolName))
        return "name";
    if (Object.hasOwn(DOMAIN_RULE_FACES, toolName))
        return "domain";
    return pathTargetOf(toolName) !== undefined ? "path" : undefined;
}
export function domainRuleSubjectOf(toolName, args) {
    const face = Object.hasOwn(DOMAIN_RULE_FACES, toolName) ? DOMAIN_RULE_FACES[toolName] : undefined;
    if (face === undefined || typeof args !== "object" || args === null || Array.isArray(args) || !Object.hasOwn(args, face.param))
        return undefined;
    const value = args[face.param];
    if (typeof value !== "string")
        return undefined;
    let url;
    try {
        url = new URL(value);
    }
    catch {
        return undefined;
    }
    if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username !== "" || url.password !== "")
        return undefined;
    const host = url.hostname.replace(/\.+$/, "");
    return host === "" ? undefined : host;
}
export function nameRuleSubjectOf(toolName, args) {
    const face = Object.hasOwn(NAME_RULE_FACES, toolName) ? NAME_RULE_FACES[toolName] : undefined;
    if (face === undefined || typeof args !== "object" || args === null || Array.isArray(args) || !Object.hasOwn(args, face.param))
        return undefined;
    const value = args[face.param];
    return typeof value === "string" ? value : undefined;
}
export function laneToolNamesOf(req) {
    const words = heldValues("policy.persistedGrammar", { get: ruleWordOf }, req);
    return words.length <= 1 ? words : [...new Set(words)];
}
function ruleWordOf(name) {
    if (isShellFaceToolName(name))
        return COMMAND_RULE_TOOL;
    return ruleToolGrammarOf(name) !== undefined ? name : undefined;
}
function isCommandMatch(match) {
    return match === "exact" || match === "prefix" || match === "wildcard";
}
export const MAX_RULE_TEXT_CHARS = 512;
export const NOTICE_RULE_TEXT_MAX = 2 * MAX_RULE_TEXT_CHARS;
export function noticeRuleText(text) {
    if (text.length <= NOTICE_RULE_TEXT_MAX)
        return text;
    let kept = 0;
    let end = 0;
    for (const ch of text) {
        if (kept === NOTICE_RULE_TEXT_MAX)
            return `${text.slice(0, end)}${INLINE_TRUNCATION_MARK}`;
        end += ch.length;
        kept += 1;
    }
    return text;
}
export const BARE_INTERPRETER_NAMES = new Set([
    "node", "deno", "bun", "python", "python2", "python3", "perl", "ruby", "php", "osascript",
    "java", "dotnet", "mono", "lua", "luajit", "julia", "Rscript", "tclsh", "groovy", "scala",
    "awk", "gawk", "mawk", "nawk", "sed",
    "bash", "sh", "zsh", "ksh", "csh", "tcsh", "dash", "fish", "cmd", "powershell", "pwsh", "busybox",
    "source", ".", "trap", "eval", "exec", "command", "builtin",
    "enable", "compgen", "complete", "bind", "mapfile", "readarray",
    "env", "noglob", "nocorrect", "xargs",
    "sudo", "doas", "pkexec", "su", "runuser", "ssh", "chroot", "setpriv",
    "nohup", "nice", "ionice", "chrt", "taskset", "stdbuf", "timeout", "time", "watch",
    "setsid", "flock", "unshare", "nsenter", "script", "numactl", "prlimit", "systemd-run", "parallel",
    "strace", "ltrace", "valgrind", "gdb", "lldb", "perf", "firejail", "bwrap",
]);
export const SHELL_RESERVED_WORDS = shellWordsOf("rule_reserved");
export const SUGGESTION_LEXICON = [
    "git stash list", "git stash show", "git stash push", "git stash pop", "git stash drop",
    "git remote show", "git worktree list", "git submodule update", "git submodule status",
    "yarn workspaces list",
    "go mod tidy", "go mod download", "go mod verify", "go mod graph",
    "docker compose up", "docker compose down", "docker compose ps", "docker compose logs",
    "docker compose build", "docker compose pull",
    "kubectl rollout status", "kubectl rollout restart", "kubectl rollout history",
    "gh pr view", "gh pr list", "gh pr diff", "gh pr checks", "gh pr status", "gh pr create",
    "gh pr merge", "gh issue view", "gh issue list", "gh issue create", "gh repo view",
    "gh repo clone", "gh release list", "gh release view", "gh run list", "gh run view",
    "gh run watch", "gh workflow list", "gh workflow view", "gh auth status",
    "gh search repos", "gh search issues", "gh search prs", "gh search code",
];
const LEXICON_BODIES = SUGGESTION_LEXICON.map((b) => b.split(" "));
const GENERIC_SUBCOMMAND_WORD = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
const SCREENED_HEAD_NAMES = new Set([...BARE_INTERPRETER_NAMES].map((n) => n.toLowerCase()));
const TRAILING_DOTS = /\.+$/;
const EXECUTABLE_SUFFIX = /\.(?:exe|com|bat|cmd)$/;
const PROPOSABLE_HEAD_NAME = /^[A-Za-z0-9._+-]+$/;
function screenedHeadName(head) {
    let name = commandBasename(head).toLowerCase();
    for (;;) {
        const peeled = name.replace(TRAILING_DOTS, "").replace(EXECUTABLE_SUFFIX, "");
        if (peeled === name || peeled === "")
            return name;
        name = peeled;
    }
}
function genericPrefixBody(tokens) {
    const head = tokens[0];
    const second = tokens[1];
    if (head === undefined || second === undefined)
        return undefined;
    if (!PROPOSABLE_HEAD_NAME.test(commandBasename(head)))
        return undefined;
    if (SCREENED_HEAD_NAMES.has(screenedHeadName(head)))
        return undefined;
    return GENERIC_SUBCOMMAND_WORD.test(second) ? `${head} ${second}` : undefined;
}
function longestReviewedBody(tokens) {
    let best;
    for (const words of LEXICON_BODIES) {
        if (words.length > tokens.length)
            continue;
        if (best !== undefined && words.length <= best.length)
            continue;
        let hit = true;
        for (let i = 0; i < words.length; i++) {
            if (words[i] !== tokens[i]) {
                hit = false;
                break;
            }
        }
        if (hit)
            best = words;
    }
    return best?.join(" ");
}
function foldSpacing(s) {
    let out = "";
    let quote;
    let pendingGap = false;
    for (const ch of s) {
        if (quote !== undefined) {
            if (ch === quote)
                quote = undefined;
            out += ch;
            continue;
        }
        if (ch === "'" || ch === '"') {
            if (pendingGap) {
                if (out !== "")
                    out += " ";
                pendingGap = false;
            }
            quote = ch;
            out += ch;
            continue;
        }
        if (ch === " " || ch === "\t") {
            pendingGap = true;
            continue;
        }
        if (pendingGap) {
            if (out !== "")
                out += " ";
            pendingGap = false;
        }
        out += ch;
    }
    return quote === undefined ? out : undefined;
}
function reject(code, message) {
    return { reject: { code, message } };
}
const RULE_LANE_FLOOR = {
    pathPrefixedNameIsText: true,
    quotedOperatorsAreText: true,
};
const MATCH_READING = { terminator: "keep", redirection: "reject" };
const OFFER_READING = { terminator: "keep", redirection: "tolerate" };
function commandBasename(name) {
    return name.slice(name.lastIndexOf("/") + 1);
}
function ruleLaneShapeOf(command, reading) {
    const split = splitShellCompoundSegments(command, { trailingTerminator: reading.terminator, redirection: reading.redirection });
    if ("reject" in split)
        return { reject: split.reject };
    const names = [];
    for (const segment of split.segments) {
        if (reading.redirection === "tolerate" && carriesShellRedirection(segment)) {
            names.push(undefined);
            continue;
        }
        const floor = parseLeadingCommandName(segment, RULE_LANE_FLOOR);
        if ("reject" in floor) {
            return {
                reject: split.segments.length > 1
                    ? `the segment "${escapeForDisclosure(segment.trim())}" is not a single simple command (${floor.reject})`
                    : floor.reject,
            };
        }
        if (SHELL_RESERVED_WORDS.has(floor.name)) {
            return {
                reject: `"${escapeForDisclosure(floor.name)}" is a shell keyword, not a command name — a control structure's real program is not named by the text this lane compares`,
            };
        }
        names.push(floor.name);
    }
    return { segments: split.segments, names };
}
const CONTROL_CHARS_RE = /[\u0000-\u0008\u000A-\u001F\u007F-\u009F\p{Cf}\u2028\u2029]/u;
const CONTROL_CHARS_GLOBAL_RE = new RegExp(CONTROL_CHARS_RE.source, "gu");
const RULE_SPELLING_REJECT_BYTES = "<>";
const RULE_SPELLING_REJECT_RE = new RegExp(`[${RULE_SPELLING_REJECT_BYTES}]`);
function valueRuleFormsSentence() {
    return Object.entries(NAME_RULE_FACES)
        .map(([tool, face]) => (face.words !== undefined ? `${tool}(${face.param}:<${face.param}>) rules` : `${tool}(<${face.param}>) rules`))
        .join(", ");
}
const DISCLOSED_RULE_TEXT_MAX_CHARS = 120;
export function escapeForDisclosure(value) {
    let text;
    try {
        text = typeof value === "string" ? value : String(value);
    }
    catch {
        return "<unprintable>";
    }
    const escaped = text.replace(CONTROL_CHARS_GLOBAL_RE, (ch) => {
        const cp = ch.codePointAt(0) ?? 0;
        const hex = cp.toString(16).toUpperCase();
        return cp > 0xffff ? `\\u{${hex}}` : `\\u${hex.padStart(4, "0")}`;
    });
    return escaped.length <= DISCLOSED_RULE_TEXT_MAX_CHARS ? escaped : `${escaped.slice(0, DISCLOSED_RULE_TEXT_MAX_CHARS)}…`;
}
const DISPLAY_STRIP_FORMAT_RE = /\p{Cf}/gu;
export function stripFormatCharacters(text) {
    return text.replace(DISPLAY_STRIP_FORMAT_RE, "");
}
export function renderUntrustedCommandText(text, maxLen = DISCLOSED_RULE_TEXT_MAX_CHARS) {
    let raw;
    try {
        raw = typeof text === "string" ? text : String(text);
    }
    catch {
        return "<unprintable>";
    }
    return inlineUntrusted(stripFormatCharacters(raw), maxLen);
}
export function hasUnrenderableCharacters(text) {
    return CONTROL_CHARS_RE.test(text);
}
export function parseRuleText(text, behavior) {
    if (!isRuleBehavior(behavior)) {
        return reject("invalid.grammar", `rule "${text}" was parsed under behavior ${escapeForDisclosure(behavior)}, which is not one of ${RULE_BEHAVIORS.join("/")}`);
    }
    const tighten = behavior !== "allow";
    if (text.length > MAX_RULE_TEXT_CHARS) {
        return reject("invalid.too_long", `rule text exceeds ${MAX_RULE_TEXT_CHARS} characters`);
    }
    if (CONTROL_CHARS_RE.test(text)) {
        return reject("invalid.control_chars", "rule text contains control characters (C0/C1, tab excepted), which cannot be part of a readable command");
    }
    const parsed = parsePermissionRule(text);
    if (parsed.ruleContent === undefined) {
        return reject("invalid.grammar", `"${text}" is not a Tool(content) rule — a bare tool name claims the whole tool and is not a command rule`);
    }
    if (parsed.toolName !== COMMAND_RULE_TOOL) {
        const grammar = ruleToolGrammarOf(parsed.toolName);
        if (grammar === undefined) {
            return reject("unsupported.tool", `only Bash command rules, rules on path-targeting tools (Read, Edit, Write, …), ${valueRuleFormsSentence()} and WebFetch domain rules have a rule form in this version (got "${parsed.toolName}")`);
        }
        if (!tighten && RULE_SPELLING_REJECT_RE.test(parsed.ruleContent)) {
            return reject("invalid.grammar", `"${text}" carries a shell redirection byte (${[...RULE_SPELLING_REJECT_BYTES].map((b) => `\`${b}\``).join(" or ")}) — no path, name or host allow spells one, and the grant would not read as what it looks like`);
        }
        if (grammar === "name")
            return parseNameRuleContent(text, parsed.toolName, parsed.ruleContent, behavior);
        if (grammar === "domain")
            return parseDomainRuleContent(text, parsed.toolName, parsed.ruleContent, behavior);
        return parsePathRuleContent(text, parsed.toolName, parsed.ruleContent, behavior);
    }
    const content = parsed.ruleContent;
    const prefixBody = /^(.+):\*$/.exec(content)?.[1];
    let match;
    let body;
    if (prefixBody !== undefined) {
        if (prefixBody.includes("*")) {
            return reject("unsupported.wildcard", `a \`*\` inside a prefix body matches anything at that position, which has no rule form in this version ("${text}")`);
        }
        match = "prefix";
        body = prefixBody;
    }
    else {
        const wildcardBody = /^(.+) \*$/.exec(content)?.[1];
        if (wildcardBody !== undefined && !hasUnescapedStar(wildcardBody)) {
            match = "wildcard";
            body = wildcardBody;
        }
        else if (hasUnescapedStar(content)) {
            return reject("unsupported.wildcard", `a \`*\` before the end of the pattern matches anything at that position, which has no rule form in this version — only the trailing \`<command> *\` form does ("${text}")`);
        }
        else {
            match = "exact";
            body = content;
        }
    }
    const shape = ruleLaneShapeOf(body, MATCH_READING);
    if ("reject" in shape) {
        if (body.trim() === "" || shape.reject === "empty command")
            return reject("invalid.empty_command", `rule "${text}" names no command`);
        if (foldSpacing(body) === undefined)
            return reject("invalid.unbalanced_quotes", `rule "${text}" has unbalanced quoting, so the command it names is not determinable`);
        return reject("invalid.not_simple_command", `rule "${text}" is not a command this lane can name (${shape.reject})`);
    }
    if (match === "wildcard" && shape.segments.length > 1) {
        return reject("unsupported.wildcard", `a \`<chain> *\` pattern names more than one command, and the trailing-star form has no chain reading — the compound-prefix form spells one with the \`:*\` marker ("${text}")`);
    }
    const head = commandBasename(shape.names[shape.names.length - 1] ?? "");
    if ((match === "prefix" || match === "wildcard") && BARE_INTERPRETER_NAMES.has(head) && !tighten) {
        return reject("invalid.bare_interpreter_prefix", shape.segments.length > 1
            ? `prefix rule "${text}" leaves its final segment open-ended under the interpreter "${head}" — such a rule authorizes running arbitrary programs, which one approval click cannot be read as having granted (an exact rule naming the whole command line is accepted)`
            : `prefix rule "${text}" is headed by the interpreter "${head}" — such a rule authorizes running arbitrary programs, which one approval click cannot be read as having granted (an exact rule naming the whole command line is accepted)`);
    }
    const command = foldSpacing(body);
    if (command === undefined) {
        return reject("invalid.unbalanced_quotes", `rule "${text}" has unbalanced quoting, so the command it names is not determinable`);
    }
    return { rule: { behavior, rule: formatRuleText(command, match, COMMAND_RULE_TOOL), tool: COMMAND_RULE_TOOL, match, command } };
}
export function formatRuleText(command, match, tool) {
    if (match === "subpath")
        return `${tool}(//${command.startsWith("/") ? command.slice(1) : command}/**)`;
    if (match === "path")
        return `${tool}(${command})`;
    if (match === "name")
        return `${tool}(${keyedValueParamOf(tool) !== undefined ? `${keyedValueParamOf(tool)}:` : ""}${command})`;
    if (match === "host" || match === "subdomain")
        return `${tool}(domain:${match === "subdomain" ? "*." : ""}${command})`;
    return `${tool}(${command}${match === "prefix" || match === "name_prefix" ? ":*" : match === "wildcard" ? " *" : ""})`;
}
function parseNameRuleContent(text, tool, content, behavior, verifyCanonical = true) {
    const face = NAME_RULE_FACES[tool];
    if (face?.words !== undefined)
        return parseKeyedValueRuleContent(text, tool, content, behavior, { param: face.param, words: face.words });
    const bare = content.startsWith("/") ? content.slice(1) : content;
    const colon = bare.indexOf(":");
    if (face !== undefined && colon > 0 && face.params.includes(bare.slice(0, colon).trim())) {
        return reject("invalid.grammar", `"${text}" is the parameter spelling, which a ${tool} rule no longer has — a ${tool} rule names the ${face.param} itself: ${tool}(<name>) for one, ${tool}(<name>:*) for every ${face.param} whose name begins with <name>`);
    }
    const open = bare.endsWith(":*") || bare.endsWith(" *");
    const body = open ? bare.slice(0, -2) : bare;
    const match = open ? "name_prefix" : "name";
    if (body === "" || body.trim() !== body) {
        return reject("invalid.grammar", `the name in "${text}" is empty or carries surrounding whitespace — a ${tool} rule names the ${face?.param ?? "name"} exactly as it is spelled, so the spelling must be the one meant`);
    }
    if (body.includes("*")) {
        return reject("unsupported.wildcard", `a \`*\` inside the name of "${text}" matches anything at that position, which has no rule form — only the trailing \`<name>:*\` prefix form does`);
    }
    const canonical = formatRuleText(body, match, tool);
    const back = parsePermissionRule(canonical);
    if (back.toolName !== tool || back.ruleContent !== (open ? `${body}:*` : body)) {
        return reject("invalid.grammar", `the name in "${text}" has no spelling the rule syntax reads back (a backslash or an unbalanced parenthesis) — no ${tool} rule can name it`);
    }
    const rule = { behavior, rule: canonical, tool, match, command: body };
    if (verifyCanonical) {
        const again = parseNameRuleContent(canonical, tool, back.ruleContent, behavior, false);
        if (!("rule" in again) || again.rule.rule !== canonical || again.rule.match !== match || again.rule.command !== body) {
            return reject("invalid.grammar", `"${text}" has no canonical spelling that reads back as itself ("${canonical}" does not) — no ${tool} rule can name it exactly`);
        }
    }
    return { rule };
}
function parseKeyedValueRuleContent(text, tool, content, behavior, face) {
    const vocabulary = face.words.join(", ");
    const colon = content.indexOf(":");
    if (colon <= 0 || content.slice(0, colon).trim() !== face.param) {
        return reject("invalid.grammar", `${tool} rules name the ${face.param}: ${tool}(${face.param}:<${face.param}>) with one of ${vocabulary} ("${text}") — a bare value, or a rule on another argument, has no rule form here`);
    }
    const word = content.slice(colon + 1).trim();
    if (word === "") {
        return reject("invalid.grammar", `the ${face.param} in "${text}" is empty — ${tool} rules name the ${face.param} exactly as the call carries it`);
    }
    if (word.includes("*")) {
        return reject("unsupported.wildcard", `a \`*\` in the ${face.param} of "${text}" has no rule form — ${tool} rules name one ${face.param} (${vocabulary}); the whole tool is the bare ${tool} entry`);
    }
    if (!face.words.includes(word)) {
        return reject("invalid.grammar", `"${word}" is not one of the ${face.param} values ${tool} declares (${vocabulary}) — the rule would never reach a call`);
    }
    return { rule: { behavior, rule: formatRuleText(word, "name", tool), tool, match: "name", command: word } };
}
export function nameRuleAdmits(rule, name) {
    if (rule.match === "name")
        return name === rule.command;
    if (rule.match === "name_prefix")
        return name.startsWith(rule.command);
    return false;
}
function parseDomainRuleContent(text, tool, content, behavior) {
    if (behavior !== "allow") {
        return reject("unsupported.tool", `a ${tool} rule by host has an allow form only in this version ("${text}") — a ${behavior} by host is not read; write the ${behavior} on the whole ${tool} tool`);
    }
    if (!content.startsWith("domain:")) {
        return reject("invalid.grammar", `a ${tool} rule names a host: ${tool}(domain:<host>) for one, ${tool}(domain:*.<domain>) for every host below a domain ("${text}")`);
    }
    const spelled = content.slice("domain:".length);
    if (spelled === "*")
        return reject("unsupported.wildcard", `"${text}" names every host — the whole tool is the bare ${tool} entry`);
    const below = spelled.startsWith("*.");
    const body = below ? spelled.slice(2) : spelled;
    if (body.includes("*")) {
        return reject("unsupported.wildcard", `a \`*\` in "${text}" other than a leading \`*.\` has no rule form — a ${tool} rule names one host, or every host below one domain with \`*.<domain>\``);
    }
    const host = hostOfSpelling(body);
    if (host === undefined || hostOfSpelling(host) !== host) {
        return reject("invalid.grammar", `"${text}" does not name a host alone — write the host as a URL carries it, with no scheme, path, port or credentials`);
    }
    if (below && isAddressHost(host))
        return reject("invalid.grammar", `"${text}" names hosts below an address, which has none — write the address itself`);
    const match = below ? "subdomain" : "host";
    return { rule: { behavior, rule: formatRuleText(host, match, tool), tool, match, command: host } };
}
function hostOfSpelling(spelled) {
    if (spelled === "" || /[\s/\\@?#%]/.test(spelled) || (spelled.includes(":") && !/^\[[^\]]+\]$/.test(spelled)))
        return undefined;
    let url;
    try {
        url = new URL(`https://${spelled}/`);
    }
    catch {
        return undefined;
    }
    if (url.port !== "" || url.username !== "" || url.password !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "")
        return undefined;
    const host = url.hostname.replace(/\.+$/, "");
    return host === "" ? undefined : host;
}
function isAddressHost(host) {
    return host.startsWith("[") || /^\d+\.\d+\.\d+\.\d+$/.test(host);
}
export function domainRuleAdmits(rule, host) {
    if (rule.match === "host")
        return host === rule.command;
    if (rule.match !== "subdomain" || isAddressHost(host) || !host.endsWith(`.${rule.command}`))
        return false;
    const labels = host.slice(0, -(rule.command.length + 1));
    return labels !== "" && !labels.split(".").includes("");
}
function parsePathRuleContent(text, tool, content, behavior) {
    if (behavior === "allow" && PATH_RULE_FACES[tool]?.allow === undefined) {
        return reject("unsupported.tool", `an allow by path is read on Read and Edit rules only — an Edit rule speaks for every tool that edits a file, a Read rule for every tool that reads one ("${text}"); write Edit(<path>) or Read(<path>)`);
    }
    const dirForm = /^\/\/(.+)\/\*\*$/.exec(content);
    if (dirForm !== null) {
        const bodyR = dirForm[1];
        if (bodyR.includes("*")) {
            return reject("unsupported.wildcard", `a \`*\` inside the directory body matches anything at that position, which has no rule form ("${text}")`);
        }
        const segments = bodyR.split("/");
        if (segments.some((sg) => sg === "" || sg.trim() === "")) {
            return reject("invalid.grammar", `the directory body of "${text}" is not in the canonical spelling (an empty or whitespace-only path segment) — the lexical normal form is the only accepted spelling`);
        }
        if (segments.some((sg) => sg === "." || sg === "..")) {
            return reject("invalid.path_traversal", `the directory body of "${text}" carries a \`.\` or \`..\` segment — the lexical normal form is the only accepted spelling, and a traversal segment is not normalized away`);
        }
        const dir = "/" + bodyR;
        return { rule: { behavior, rule: formatRuleText(dir, "subpath", tool), tool, match: "subpath", command: dir } };
    }
    if (content.trim() !== content || content === "") {
        return reject("invalid.grammar", `the path pattern of "${text}" is empty or carries surrounding whitespace — the pattern is stored as spelled, so the spelling must be the one meant`);
    }
    if (content.startsWith("!"))
        return reject("invalid.grammar", `a negated path pattern ("${text}") is not a rule — a rule says what it reaches, never what it excepts`);
    if (/[?[\]{}]/.test(content)) {
        return reject("unsupported.wildcard", `the path pattern of "${text}" carries a glob class (\`?\`, \`[]\`, \`{}\`) this lane does not read — only \`*\` inside a segment and \`**\` as a whole segment have a rule form`);
    }
    const body = content.startsWith("//") ? content.slice(2) : content.startsWith("~/") ? content.slice(2) : content.startsWith("/") ? content.slice(1) : content.startsWith("./") ? content.slice(2) : content;
    const segments = body.split("/");
    if (segments.some((sg) => sg === "" || sg.trim() === "")) {
        return reject("invalid.grammar", `the path pattern of "${text}" carries an empty path segment — the lexical normal form is the only accepted spelling`);
    }
    if (segments.some((sg) => sg === "." || sg === "..")) {
        return reject("invalid.path_traversal", `the path pattern of "${text}" carries a \`.\` or \`..\` segment — the lexical normal form is the only accepted spelling, and a traversal segment is not normalized away`);
    }
    if (segments.some((sg) => sg.includes("**") && sg !== "**")) {
        return reject("unsupported.wildcard", `\`**\` in the path pattern of "${text}" must stand as a whole segment (\`a/**/b\`), never inside one`);
    }
    return { rule: { behavior, rule: formatRuleText(content, "path", tool), tool, match: "path", command: content } };
}
export const PATH_RULE_BASES = Object.freeze(["cwd", "root", "home"]);
export const PATH_RULE_BASE_LABEL = {
    cwd: "the working directory",
    root: "the project root",
    home: "the home directory of the execution environment",
};
export function ruleBasesNeeded(rule) {
    if (rule.match !== "path")
        return [];
    const pattern = rule.command;
    if (pattern.startsWith("//"))
        return [];
    if (pattern.startsWith("~/"))
        return ["home"];
    if (pattern.startsWith("/"))
        return ["root"];
    return ["cwd"];
}
function baseValueOf(base, bases) {
    switch (base) {
        case "home":
            return bases.home;
        case "root":
            return bases.root;
        case "cwd":
            return bases.cwd ?? bases.root;
        default: {
            const _exhaustive = base;
            return _exhaustive;
        }
    }
}
function resolvePathPattern(pattern, bases, behavior, treeFamily = pathFamilyOf(bases)) {
    const family = treeFamily;
    const tree = bases.root ?? bases.cwd;
    if (pattern.startsWith("//")) {
        const abs = pathIdentityOf("/" + pattern.slice(2), family, tree);
        return abs.ok ? { resolved: abs.identity } : { missingBase: "root" };
    }
    const needed = ruleBasesNeeded({ match: "path", command: pattern })[0];
    const baseValue = baseValueOf(needed, bases);
    const base = baseValue !== undefined && isAbsoluteForFamily(family ?? "own", baseValue) ? pathIdentityOf(baseValue, family, tree) : undefined;
    if (base === undefined || !base.ok)
        return { missingBase: needed };
    const normal = base.identity;
    const rest = pattern.startsWith("~/") ? pattern.slice(2) : pattern.startsWith("/") ? pattern.slice(1) : pattern.startsWith("./") ? pattern.slice(2) : pattern;
    const peeled = rest.endsWith("/**") ? rest.slice(0, -3) : rest;
    const bare = needed === "cwd" && peeled !== "" && !peeled.includes("/");
    const anyDepth = bare && (peeled === rest || behavior !== "allow");
    const body = anyDepth ? "**/" + peeled : rest;
    return { resolved: (normal === "/" ? "" : normal) + "/" + body };
}
export function isUsablePathBase(base, family) {
    return base !== undefined && isAbsoluteForFamily(family ?? "own", base) && pathIdentityOf(base, family, base).ok;
}
function segmentGlobMatches(glob, segment) {
    if (!glob.includes("*"))
        return glob === segment;
    const parts = glob.split("*");
    let at = 0;
    for (let i = 0; i < parts.length; i++) {
        const part = parts[i];
        if (i === 0) {
            if (!segment.startsWith(part))
                return false;
            at = part.length;
            continue;
        }
        if (i === parts.length - 1)
            return part === "" || (segment.endsWith(part) && segment.length - part.length >= at);
        const k = segment.indexOf(part, at);
        if (k === -1)
            return false;
        at = k + part.length;
    }
    return true;
}
function globSegmentsReach(globs, target) {
    const seen = new Set();
    const width = target.length + 1;
    const walk = (g, t) => {
        if (g === globs.length)
            return true;
        const key = g * width + t;
        if (seen.has(key))
            return false;
        seen.add(key);
        if (globs[g] === "**")
            return walk(g + 1, t) || (t < target.length && walk(g, t + 1));
        if (t >= target.length)
            return false;
        return segmentGlobMatches(globs[g], target[t]) && walk(g + 1, t + 1);
    };
    return walk(0, 0);
}
export function pathRuleReachOf(rule, target, bases, treeFamily = pathFamilyOf(bases)) {
    if (rule.match === "subpath")
        return directoryRuleAdmits(rule, target, bases, treeFamily) ? REACHED : NOT_REACHED;
    if (rule.match !== "path")
        return NOT_REACHED;
    const family = treeFamily;
    if (!isPathIdentity(target, family))
        return NOT_REACHED;
    const r = resolvePathPattern(rule.command, bases, rule.behavior, family);
    if ("missingBase" in r) {
        return { reach: "unreadable", reason: `the rule is relative to ${PATH_RULE_BASE_LABEL[r.missingBase]} and this call supplies no absolute \`${r.missingBase}\` base of this tree's family to resolve it against` };
    }
    if (isUncIdentity(r.resolved) !== isUncIdentity(target))
        return NOT_REACHED;
    const globs = r.resolved.split("/").filter((sg) => sg !== "");
    const segments = target.split("/").filter((sg) => sg !== "");
    return globSegmentsReach(globs, segments) ? REACHED : NOT_REACHED;
}
export function pathRuleHeadOf(rule, bases) {
    const family = pathFamilyOf(bases);
    const tree = bases.root ?? bases.cwd;
    let head;
    if (rule.match === "subpath") {
        if (carriesDotSegment(rule.command, family))
            return undefined;
        const dir = pathIdentityOf(rule.command, family, tree);
        return dir.ok && isIdentityDir(dir.identity, family) ? dir.identity : undefined;
    }
    else if (rule.match === "path") {
        const r = resolvePathPattern(rule.command, bases, rule.behavior);
        if ("missingBase" in r)
            return undefined;
        const parts = r.resolved.split("/");
        const wild = parts.findIndex((part) => part.includes("*"));
        head = (wild === -1 ? parts : parts.slice(0, wild)).join("/");
    }
    else
        return undefined;
    const id = pathIdentityOf(head === "" ? "/" : head, family, tree);
    return id.ok ? id.identity : undefined;
}
function hasUnescapedStar(s) {
    for (let i = 0; i < s.length; i++) {
        if (s[i] !== "*")
            continue;
        let backslashes = 0;
        for (let j = i - 1; j >= 0 && s[j] === "\\"; j--)
            backslashes++;
        if (backslashes % 2 === 0)
            return true;
    }
    return false;
}
export function ruleAdmitsCommand(rule, command) {
    return admitsUnder(rule, command, MATCH_READING);
}
export const PROGRAM_RUN_REACHES = Object.freeze(["reached", "not_reached", "unreadable"]);
const REACHED = { reach: "reached" };
const NOT_REACHED = { reach: "not_reached" };
export function programRunReachOf(rule, command) {
    if (!isCommandMatch(rule.match))
        return NOT_REACHED;
    const body = readRuleBody(rule.command);
    if (body === undefined)
        return NOT_REACHED;
    const shape = readCommandMemo(command);
    const exact = rule.match === "exact";
    if (body.segments.length > 1) {
        const broken = shape.segments.find((s) => s.unreadable !== undefined);
        if (broken !== undefined)
            return { reach: "unreadable", reason: broken.unreadable };
        const hidden = shape.segments.find((s) => s.peelUnreadable !== undefined);
        const shaped = !shape.grouped && shape.segments.length === body.segments.length && shape.connectors.every((c, i) => c === body.connectors[i]);
        if (!shaped)
            return hidden === undefined ? NOT_REACHED : { reach: "unreadable", reason: hidden.peelUnreadable };
        let outcome = REACHED;
        for (let i = 0; i < body.segments.length; i++) {
            const r = segmentReachOf(body.segments[i].argv, shape.segments[i], exact || i < body.segments.length - 1);
            if (r.reach === "not_reached")
                return hidden === undefined ? NOT_REACHED : { reach: "unreadable", reason: hidden.peelUnreadable };
            if (r.reach === "unreadable")
                outcome = r;
        }
        return outcome;
    }
    const bodyArgv = body.segments[0].argv;
    let unreadable;
    for (const segment of shape.segments) {
        const r = segmentReachOf(bodyArgv, segment, exact);
        if (r.reach === "reached")
            return REACHED;
        if (r.reach === "unreadable")
            unreadable ??= r;
    }
    return unreadable ?? NOT_REACHED;
}
export function ruleReachesProgramRun(rule, command) {
    return programRunReachOf(rule, command).reach === "reached";
}
function segmentReachOf(bodyArgv, segment, exact) {
    if (segment.unreadable !== undefined)
        return { reach: "unreadable", reason: segment.unreadable };
    let unreadable;
    for (const argv of [segment.argv, ...segment.peeled]) {
        const r = argvReachOf(bodyArgv, argv, exact);
        if (r.reach === "reached")
            return REACHED;
        if (r.reach === "unreadable")
            unreadable ??= r;
    }
    if (segment.peelUnreadable !== undefined)
        unreadable ??= { reach: "unreadable", reason: segment.peelUnreadable };
    return unreadable ?? NOT_REACHED;
}
function argvReachOf(bodyArgv, argv, exact) {
    for (let i = 0; i < bodyArgv.length; i++) {
        const word = argv[i];
        const wanted = bodyArgv[i];
        if (word === undefined)
            return NOT_REACHED;
        if (word.expands !== false) {
            if (word.raw === wanted.raw)
                continue;
            return { reach: "unreadable", reason: i === 0 ? "the program name is an expansion" : `an expansion (${word.raw}) stands where the rule reads a word` };
        }
        if (word.text !== wanted.text)
            return NOT_REACHED;
    }
    if (!exact || argv.length === bodyArgv.length)
        return REACHED;
    const loose = argv.slice(bodyArgv.length).find((w) => w.expands === "many");
    return loose === undefined ? NOT_REACHED : { reach: "unreadable", reason: `an unquoted expansion (${loose.raw}) may expand to nothing` };
}
function readRuleBody(body) {
    const shape = readShellCommand(body);
    if (shape.grouped || shape.segments.length === 0)
        return undefined;
    return shape.segments.every((s) => s.unreadable === undefined && s.argv.length > 0) ? shape : undefined;
}
let lastRead;
function readCommandMemo(command) {
    if (lastRead?.command !== command)
        lastRead = { command, shape: readShellCommand(command) };
    return lastRead.shape;
}
function admitsUnder(rule, command, reading) {
    if (!isCommandMatch(rule.match))
        return false;
    const shape = ruleLaneShapeOf(command, reading);
    if ("reject" in shape)
        return false;
    const folded = foldSpacing(command);
    if (folded === undefined)
        return false;
    if (rule.match === "exact")
        return folded === rule.command;
    const bodyShape = ruleLaneShapeOf(rule.command, reading);
    if ("reject" in bodyShape || foldSpacing(rule.command) === undefined)
        return false;
    if (shape.segments.length !== bodyShape.segments.length)
        return false;
    return folded === rule.command || folded.startsWith(rule.command + " ");
}
export function ruleBreadthWarningsOf(rule) {
    if (rule.match !== "prefix" && rule.match !== "wildcard")
        return [];
    const bodyShape = ruleLaneShapeOf(rule.command, MATCH_READING);
    if ("reject" in bodyShape)
        return [];
    if (bodyShape.segments.length > 1) {
        return [
            {
                code: "compound_prefix",
                message: `this rule's body is a command chain — it admits every run of the whole chain "${rule.command}", with any text extending its final segment`,
            },
        ];
    }
    if (rule.command.split(/\s+/).filter((t) => t !== "").length === 1) {
        return [
            {
                code: "broad_prefix",
                message: `this rule admits every argument form of "${rule.command}" — any \`${rule.command} …\` command, not just the one on this card`,
            },
        ];
    }
    return [];
}
export function pathWithinRoot(path, root) {
    if (path === root)
        return true;
    const base = root.endsWith("/") ? root : root + "/";
    return path.startsWith(base);
}
export function lexicalNormalAbsolutePathOf(path, family, anchor) {
    const r = pathIdentityOf(path, family, anchor);
    return r.ok ? r.identity : undefined;
}
function isLexicalNormalAbsoluteDir(path) {
    if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//"))
        return false;
    const segments = path.split("/").slice(1);
    return segments.length > 0 && segments.every((s) => s !== "" && s !== "." && s !== "..");
}
export function directoryRuleAdmits(rule, path, bases, treeFamily = pathFamilyOf(bases)) {
    if (rule.match !== "subpath")
        return false;
    const family = treeFamily;
    if (carriesDotSegment(rule.command, family))
        return false;
    const dir = pathIdentityOf(rule.command, family, bases.root ?? bases.cwd);
    if (!dir.ok || !isIdentityDir(dir.identity, family))
        return false;
    if (!isPathIdentity(path, family) || !isIdentityDir(path, family))
        return false;
    return pathWithinRoot(path, dir.identity);
}
export function directoryIdentityOf(spelling, bases) {
    const r = pathIdentityOf(spelling, pathFamilyOf(bases), bases.root ?? bases.cwd);
    return r.ok ? r.identity : undefined;
}
const CD_TARGET_REJECT_CHARS = new RegExp(String.raw `[*?[\]{}$` + "`" + String.raw `\\'"\n\r` + RULE_SPELLING_REJECT_BYTES + "]");
function cdSegmentDirectoryOf(segment, opts) {
    if (/[^\S \t]/.test(segment))
        return undefined;
    const tokens = segment.split(/[ \t]+/).filter((t) => t !== "");
    if (tokens.length !== 2 || tokens[0] !== "cd")
        return undefined;
    const target = tokens[1];
    if (CD_TARGET_REJECT_CHARS.test(target))
        return undefined;
    if (target.startsWith("-") || target.startsWith("~"))
        return undefined;
    let resolved;
    if (target.startsWith("//"))
        return undefined;
    if (target.startsWith("/")) {
        resolved = target;
    }
    else if (target.startsWith("./")) {
        if (opts.priorCd || opts.cwd === undefined)
            return undefined;
        if (!opts.cwd.startsWith("/") || opts.cwd.startsWith("//"))
            return undefined;
        resolved = opts.cwd.replace(/\/+$/, "") + target.slice(1);
    }
    else {
        return undefined;
    }
    if (!isLexicalNormalAbsoluteDir(resolved))
        return undefined;
    if (HOME_TOP_SHAPE.test(resolved))
        return undefined;
    if (HOME_ANCESTOR_SHAPE.test(resolved))
        return undefined;
    const processHome = userHomeTopLevel();
    if (processHome !== undefined && (resolved === processHome || isStrictAncestorDir(resolved, processHome))) {
        return undefined;
    }
    return resolved;
}
function isStrictAncestorDir(ancestor, path) {
    return ancestor !== "/" && path.startsWith(`${ancestor}/`);
}
const HOME_TOP_SHAPE = /^(?:\/home\/[^/]+|\/Users\/[^/]+|\/root|\/var\/root)$/;
const HOME_ANCESTOR_SHAPE = /^(?:\/home|\/Users|\/var)$/;
let HOME_TOP_LEVEL = null;
function userHomeTopLevel() {
    if (HOME_TOP_LEVEL === null) {
        try {
            const h = homedir();
            HOME_TOP_LEVEL = typeof h === "string" && h.startsWith("/") && !h.startsWith("//") ? h.replace(/\/+$/, "") : undefined;
        }
        catch {
            HOME_TOP_LEVEL = undefined;
        }
    }
    return HOME_TOP_LEVEL === null ? undefined : HOME_TOP_LEVEL;
}
const CURSOR_MOVING_HEADS = shellWordsOf("base_mover");
const OPAQUE_CURSOR_MOVERS = new Set(["eval", "source", ".", "trap", "enable", "builtin", "command", "time"]);
function pushdPopdLeavesCursor(head, args) {
    for (const a of args) {
        if (a === "--")
            return false;
        if (/^-[a-zA-Z]*n[a-zA-Z]*$/.test(a))
            return true;
        if (head === "popd" && /^\+0*[1-9]\d*$/.test(a))
            return true;
    }
    return false;
}
function movesShellCursor(segment) {
    if (carriesShellRedirection(segment))
        return true;
    const words = segment
        .split(/[ \t]+/)
        .map((w) => w.replace(/['"]/g, ""))
        .filter((t) => t !== "");
    const head = words[0];
    if (head === undefined)
        return false;
    if (OPAQUE_CURSOR_MOVERS.has(head))
        return true;
    if (!CURSOR_MOVING_HEADS.has(head))
        return false;
    return !(shellWordsOf("stack_mover").has(head) && pushdPopdLeavesCursor(head, words.slice(1)));
}
function resolveCdSegmentDirectories(segments, cwd) {
    let priorCd = false;
    return segments.map((segment) => {
        const dir = cdSegmentDirectoryOf(segment, { cwd, priorCd });
        if (!priorCd && movesShellCursor(segment))
            priorCd = true;
        const bases = basesOfCwd(cwd);
        return dir === undefined || isShellRootedSpellingUnmapped(pathFamilyOf(bases), dir) ? undefined : directoryIdentityOf(dir, bases);
    });
}
export function scopeCoversCwd(scope, cwd, sessionId, identity) {
    if (scope.kind === "global")
        return true;
    if (scope.kind === "session")
        return sessionId !== undefined && sessionId === scope.sessionId;
    if (cwd === undefined)
        return false;
    if (pathWithinRoot(cwd, scope.root))
        return true;
    return identity !== undefined && pathWithinRoot(identity.canonicalRoot, identity.canonicalOf(scope.root) ?? scope.root);
}
export function isRuleLive(rule) {
    return rule.adds.length > 0;
}
export function eligibleContext(rule, call) {
    return rule.tool === call.tool && scopeCoversCwd(rule.scope, call.cwd, call.sessionId, call.scopeIdentity);
}
export function eligiblePersisted(rule, call, behavior) {
    return rule.behavior === behavior && isRuleLive(rule) && eligibleContext(rule, behavior === "allow" ? spelledScope(call) : call);
}
export function spelledScope(call) {
    const { scopeIdentity: _identity, ...spelled } = call;
    return spelled;
}
function firstEligibleAdmittingRule(rules, commandText, call) {
    for (const rule of rules) {
        if (!eligiblePersisted(rule, call, "allow"))
            continue;
        if (ruleAdmitsCommand(rule, commandText))
            return rule;
    }
    return undefined;
}
export function findAdmittingRule(rules, call) {
    const coverage = commandCoverageOf(rules, call);
    return "covering" in coverage ? coverage.covering : undefined;
}
export function commandCoverageOf(rules, call) {
    const shape = ruleLaneShapeOf(call.command, MATCH_READING);
    if ("reject" in shape)
        return { uncovered: undefined };
    const whole = firstEligibleAdmittingRule(rules, call.command, call);
    if (whole !== undefined)
        return { covering: [whole] };
    const cdDirs = resolveCdSegmentDirectories(shape.segments, call.execCwd ?? call.cwd);
    if (shape.segments.length < 2) {
        const dir = cdDirs[0];
        const lone = dir !== undefined ? firstEligibleDirectoryRule(rules, dir, call) : undefined;
        return lone !== undefined ? { covering: [lone] } : { uncovered: shape.segments[0] ?? call.command };
    }
    const covering = [];
    const lineObjection = readOnlyLineObjection(call.command, call.execCwd ?? call.cwd);
    for (const [i, segment] of shape.segments.entries()) {
        const dir = cdDirs[i];
        const first = firstEligibleAdmittingRule(rules, segment, call) ?? (dir !== undefined ? firstEligibleDirectoryRule(rules, dir, call) : undefined);
        if (first === undefined) {
            if (call.posture !== "narrowing" && readsOnlyAsSegment(segment, lineObjection, call.command))
                continue;
            return { uncovered: segment };
        }
        if (!covering.includes(first))
            covering.push(first);
    }
    return covering.length > 0 ? { covering } : { uncovered: undefined };
}
function readsOnlyAsSegment(segment, lineObjection, whole) {
    return lineObjection === undefined && !movesShellCursor(segment) && readOnlyShellVerdict(segment, undefined, undefined, () => lineDescriptorsOfText(whole)).readOnly;
}
function firstEligibleDirectoryRule(rules, directory, call) {
    for (const rule of rules) {
        if (!eligiblePersisted(rule, { tool: "Read", cwd: call.cwd, sessionId: call.sessionId }, "allow"))
            continue;
        if (directoryRuleAdmits(rule, directory, basesOfCwd(call.execCwd ?? call.cwd)))
            return rule;
    }
    return undefined;
}
function basesOfCwd(cwd) {
    return cwd === undefined ? {} : { root: cwd };
}
export function adjudicatePersistedRules(rules, call) {
    for (const behavior of RULE_BEHAVIORS_BY_PRECEDENCE) {
        if (behavior === "allow") {
            const covering = findAdmittingRule(rules, call);
            if (covering !== undefined)
                return { behavior, rules: covering };
            continue;
        }
        let unreadable;
        for (const rule of rules) {
            if (!eligiblePersisted(rule, call, behavior))
                continue;
            const outcome = programRunReachOf(rule, call.command);
            if (outcome.reach === "reached")
                return { behavior, rules: [rule] };
            if (outcome.reach === "unreadable")
                unreadable ??= { behavior: "ask", rules: [rule], unreadable: outcome.reason };
        }
        if (unreadable !== undefined)
            return unreadable;
    }
    return undefined;
}
export function adjudicatePersistedPathRules(rules, call, views, anchorsOf, leads) {
    const speaks = (rule, behavior) => eligiblePersisted(rule, call, behavior) ||
        (call.face !== undefined && PATH_RULE_FACES[rule.tool]?.[behavior]?.includes(call.face) === true && eligiblePersisted(rule, { ...call, tool: rule.tool }, behavior));
    const bind = (rows) => bindPathRules(rows.map((rule) => ({ parsed: rule, ruleText: rule.rule, rule })), (s) => anchorsOf(s.rule));
    for (const behavior of RULE_BEHAVIORS_BY_PRECEDENCE) {
        if (behavior === "allow") {
            const face = call.face;
            if (views.allow === undefined || face === undefined)
                continue;
            const allowing = rules.filter((rule) => (rule.match === "path" || rule.match === "subpath") && PATH_RULE_FACES[rule.tool]?.allow?.includes(face) === true && eligiblePersisted(rule, { ...call, tool: rule.tool }, behavior));
            const hit = judgeRuleRows(bind(allowing), views.allow, "allow");
            if (hit !== undefined)
                return { behavior, rules: [hit.row.spec.rule] };
            continue;
        }
        if (views.tighten === undefined)
            continue;
        const rows = rules.filter((rule) => (rule.match === "path" || rule.match === "subpath") && speaks(rule, behavior));
        if (rows.length === 0)
            continue;
        const hit = judgeRuleRows(rowsWhereTheyLead(bind(rows), leads), views.tighten, "tighten");
        if (hit === undefined)
            continue;
        const rule = hit.row.spec.rule;
        if (hit.unreadable !== undefined)
            return { behavior: "ask", rules: [rule], unreadable: hit.unreadable };
        return { behavior, rules: [rule], ...(behavior === "deny" && hit.view !== undefined && call.face !== undefined ? { denial: { rule: rule.rule, view: hit.view, face: call.face } } : {}) };
    }
    return undefined;
}
export function adjudicatePersistedNameRules(rules, call) {
    if (call.name === undefined) {
        if (keyedValueAbsentReason(call.tool) === undefined)
            return undefined;
        for (const behavior of ["deny", "ask"]) {
            const row = rules.find((rule) => rule.match === "name" && eligiblePersisted(rule, call, behavior));
            if (row !== undefined)
                return { behavior: "deny", rules: [row] };
        }
        return undefined;
    }
    for (const behavior of RULE_BEHAVIORS_BY_PRECEDENCE) {
        for (const rule of rules) {
            if (eligiblePersisted(rule, call, behavior) && nameRuleAdmits(rule, call.name))
                return { behavior, rules: [rule] };
        }
    }
    return undefined;
}
export function adjudicatePersistedDomainRules(rules, call) {
    if (call.host === undefined)
        return undefined;
    for (const rule of rules) {
        if (eligiblePersisted(rule, call, "allow") && domainRuleAdmits(rule, call.host))
            return { behavior: "allow", rules: [rule] };
    }
    return undefined;
}
export function suggestRuleForName(tool, name) {
    if (ruleToolGrammarOf(tool) !== "name")
        return undefined;
    const parsed = parseRuleText(formatRuleText(name, "name", tool), "allow");
    if (!("rule" in parsed) || parsed.rule.match !== "name" || parsed.rule.command !== name)
        return undefined;
    return { kind: "single", rule: parsed.rule.rule, match: "name", command: name };
}
export function segmentCoverageOf(command, rules, call) {
    const folded = foldSpacing(command);
    if (folded === undefined)
        return undefined;
    const shape = ruleLaneShapeOf(folded, OFFER_READING);
    if ("reject" in shape)
        return undefined;
    const proposed = rules.proposed ?? [];
    const cdDirs = resolveCdSegmentDirectories(shape.segments, call.execCwd ?? call.cwd);
    const lineObjection = readOnlyLineObjection(folded, call.execCwd ?? call.cwd);
    return shape.segments.map((segment, i) => {
        const dir = cdDirs[i];
        return {
            segment: segment.trim(),
            covered: readsOnlyAsSegment(segment, lineObjection, folded) ||
                firstEligibleAdmittingRule(rules.persisted, segment, call) !== undefined ||
                (dir !== undefined && firstEligibleDirectoryRule(rules.persisted, dir, call) !== undefined) ||
                proposed.some((p) => p.rule.tool === "Read"
                    ? dir !== undefined &&
                        eligibleContext({ tool: p.rule.tool, scope: p.scope }, { ...spelledScope(call), tool: "Read" }) &&
                        directoryRuleAdmits(p.rule, dir, basesOfCwd(call.execCwd ?? call.cwd))
                    : eligibleContext({ tool: p.rule.tool, scope: p.scope }, spelledScope(call)) && ruleAdmitsCommand(p.rule, segment)),
        };
    });
}
export const UNCOVERED_SEGMENT_REASON_BASELINE = {
    redirection: "This part carries a redirection, so it is approved per use — no rule can cover this spelling. If you trust its redirection-free form, a rule can be written for that form (the card's edit row, or settings).",
    no_rule_form: "No rule form exists for this part; it can be approved per use.",
    cap_overflow: "This batch does not cover this part; you will be asked again when it comes up.",
};
export const RULE_OFFERS_ABSENCE_BASELINE = {
    mandated: "This approval is required by the deployment's policy each time; a rule cannot waive it.",
    allow_outranked: "An allow rule already matches this call, and this question outranks it — another allow rule would be outranked the same way; it is approved per use.",
    ask_rule_matched: "An ask-each-time rule matches this call and keeps asking — a new allow rule changes nothing while it stands; to stop being asked, review the existing ask rules.",
    lane_cannot_speak: "This card has no rule to offer for this exact command; it can be approved per use. A coverable form of what you trust can still get a rule — through a card drawn for that form, or settings.",
};
export function suggestRulesForCommand(command, ctx) {
    const shape = ruleLaneShapeOf(command, OFFER_READING);
    if ("reject" in shape)
        return [];
    const folded = foldSpacing(command);
    if (folded === undefined)
        return [];
    const offers = [];
    const exact = parseRuleText(formatRuleText(folded, "exact", COMMAND_RULE_TOOL), "allow");
    if ("rule" in exact && exact.rule.match === "exact" && ruleAdmitsCommand(exact.rule, command)) {
        offers.push({ kind: "single", rule: exact.rule.rule, match: "exact", command: exact.rule.command });
    }
    const tokensOf = (s) => s.split(/\s+/).filter((t) => t !== "");
    const bodyOf = (tokens) => longestReviewedBody(tokens) ?? genericPrefixBody(tokens);
    const directoryGateOpen = (ctx?.scope?.kind === "project" || ctx?.scope?.kind === "session") && scopeCoversCwd(ctx.scope, ctx?.cwd, ctx?.sessionId);
    const deniesDirectory = ctx?.deniesDirectoryRead ?? defaultDeniesDirectoryRead;
    const mintDirectoryMember = (directory, segment) => {
        if (!directoryGateOpen || directory === undefined)
            return undefined;
        let denied;
        try {
            denied = deniesDirectory(directory) === true;
        }
        catch {
            denied = true;
        }
        if (denied)
            return undefined;
        const parsed = parseRuleText(formatRuleText(directory, "subpath", READ_RULE_TOOL), "allow");
        if (!("rule" in parsed) || parsed.rule.match !== "subpath")
            return undefined;
        if (!directoryRuleAdmits(parsed.rule, directory, basesOfCwd(ctx?.execCwd ?? ctx?.cwd)))
            return undefined;
        return { kind: "directoryRead", rule: parsed.rule.rule, directory: parsed.rule.command, segment };
    };
    if (shape.segments.length === 1) {
        if (offers.length === 1) {
            const body = bodyOf(tokensOf(folded));
            if (body !== undefined) {
                const parsed = parseRuleText(formatRuleText(body, "wildcard", COMMAND_RULE_TOOL), "allow");
                if ("rule" in parsed && parsed.rule.match === "wildcard" && ruleAdmitsCommand(parsed.rule, command)) {
                    offers.push({ kind: "single", rule: parsed.rule.rule, match: "wildcard", command: parsed.rule.command });
                }
            }
        }
        const lone = folded.trim();
        const loneCovered = ctx?.coverage !== undefined && ctx.coverage.length === 1 && ctx.coverage[0]?.segment === lone && ctx.coverage[0]?.covered === true;
        if (!loneCovered) {
            const member = mintDirectoryMember(resolveCdSegmentDirectories([lone], ctx?.execCwd ?? ctx?.cwd)[0], lone);
            if (member !== undefined)
                offers.push({ kind: "batch", rules: [member], uncoveredSegments: 0, uncoveredDetail: [] });
        }
        return offers;
    }
    const foldedShape = ruleLaneShapeOf(folded, OFFER_READING);
    if ("reject" in foldedShape || foldedShape.segments.length !== shape.segments.length)
        return offers;
    const segments = foldedShape.segments.map((s) => s.trim());
    const table = ctx?.coverage;
    const alignedTable = table !== undefined && table.length === segments.length && table.every((row, i) => row.segment === segments[i]) ? table : undefined;
    const coveredAt = (i) => alignedTable?.[i]?.covered === true;
    const mintSegmentRule = (segment) => {
        if (carriesShellRedirection(segment))
            return undefined;
        const body = bodyOf(tokensOf(segment));
        if (body !== undefined) {
            const parsed = parseRuleText(formatRuleText(body, "wildcard", COMMAND_RULE_TOOL), "allow");
            if ("rule" in parsed && parsed.rule.match === "wildcard" && ruleAdmitsCommand(parsed.rule, segment)) {
                return { rule: parsed.rule.rule, match: "wildcard", command: parsed.rule.command };
            }
        }
        const segExact = parseRuleText(formatRuleText(segment, "exact", COMMAND_RULE_TOOL), "allow");
        if ("rule" in segExact && segExact.rule.match === "exact" && ruleAdmitsCommand(segExact.rule, segment)) {
            return { rule: segExact.rule.rule, match: "exact", command: segExact.rule.command };
        }
        return undefined;
    };
    const cdDirs = resolveCdSegmentDirectories(segments, ctx?.execCwd ?? ctx?.cwd);
    const minted = [];
    for (let i = 0; i < segments.length; i++) {
        if (coveredAt(i))
            continue;
        const segment = segments[i];
        const dirMember = mintDirectoryMember(cdDirs[i], segment);
        const member = dirMember ?? (() => {
            const rule = mintSegmentRule(segment);
            return rule !== undefined ? { kind: "command", ...rule, segment } : undefined;
        })();
        if (member !== undefined && !minted.some((m) => m.rule === member.rule))
            minted.push(member);
    }
    const batchRules = minted.slice(0, 5);
    if (batchRules.length > 0) {
        const parsedBatch = batchRules.flatMap((r) => {
            const p = parseRuleText(r.rule, "allow");
            return "rule" in p ? [p.rule] : [];
        });
        const uncoveredDetail = [];
        for (let i = 0; i < segments.length; i++) {
            const segment = segments[i];
            if (coveredAt(i))
                continue;
            if (parsedBatch.some((p) => p.tool !== "Read" && ruleAdmitsCommand(p, segment)))
                continue;
            const dir = cdDirs[i];
            if (dir !== undefined && parsedBatch.some((p) => directoryRuleAdmits(p, dir, basesOfCwd(ctx?.execCwd ?? ctx?.cwd))))
                continue;
            const reason = carriesShellRedirection(segment) ? "redirection" : mintSegmentRule(segment) === undefined ? "no_rule_form" : "cap_overflow";
            uncoveredDetail.push({ segment, reason });
        }
        offers.push({ kind: "batch", rules: batchRules, uncoveredSegments: uncoveredDetail.length, uncoveredDetail });
    }
    return offers;
}
let DEFAULT_DIRECTORY_DENY;
function defaultDeniesDirectoryRead(directory) {
    if (DEFAULT_DIRECTORY_DENY === undefined) {
        const matcher = compileReadDeny([], "directoryRead mint floor");
        DEFAULT_DIRECTORY_DENY = (d) => matcher.matchPath(d) !== null;
    }
    return DEFAULT_DIRECTORY_DENY(directory);
}