feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -0,0 +1,215 @@
import { EXTENSION_ID } from "#src/extension-config";
import { DEFAULT_RENDER_BUDGET } from "#src/presentation/dialog-renderer";
import {
describeBashCommandContext,
flaggedElementLabel,
flaggedElements,
} from "#src/presentation/fact-vocabulary";
import {
allEvidence,
findEvidence,
type PromptPayload,
} from "#src/presentation/prompt-payload";
/**
* The agent-facing render of a refused permission ask (ADR 0011 §7).
*
* The rule that governs this renderer and no other:
*
* > The agent renderer identifies the call; it does not reproduce it.
*
* The agent authored the tool call, and the harness returns this text as that
* call's own tool result with its arguments still in context, so echoing the
* input back tells it nothing it did not already have. What is new is the
* verdict: which surface gated the call, which rule matched, which of the
* call's operands tripped it, and what the human said.
*
* The command is the one value never rendered — it is the payload that took
* over the viewport in #710 and the context window on every denial. The
* flagged element (a path, an MCP target, a skill) *is* rendered, because
* which operand a rule fired on is below tool-call granularity and the agent
* cannot recover it from its own arguments; being agent input, it is capped
* rather than structurally bounded.
*/
/** Attribution tag on every block reason this extension produces. */
export const EXTENSION_TAG = `[${EXTENSION_ID}]`;
/** How much room the flagged element has, as the operator configured it. */
export interface AgentRenderBudget {
/** Maximum characters of the flagged element's text. */
readonly fieldMaxWidth: number;
}
/** The agent-facing render of a policy deny. */
export function renderPolicyDenial(
payload: PromptPayload,
ruleReason: string | null,
budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
): string {
return tagged(
`Denied by policy: ${identification(payload, budget, "")}${boundaryClause(payload)}${provenanceClause(payload)}.`,
ruleReason,
);
}
/** The agent-facing render of a human's denial at an interactive prompt. */
export function renderUserDenial(
payload: PromptPayload,
denialReason: string | null,
budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
): string {
return tagged(
`The user denied this ${identification(payload, budget, "call")}${boundaryClause(payload)}${provenanceClause(payload)}.`,
denialReason,
);
}
/** The agent-facing render when no live authority could answer the ask. */
export function renderUnavailableDenial(
payload: PromptPayload,
denialReason: string | null,
budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
): string {
return tagged(
`This ${identification(payload, budget, "call")} requires approval, but no interactive UI is available.`,
denialReason,
);
}
// ── Sentence assembly ──────────────────────────────────────────────────────
function tagged(sentence: string, reason: string | null): string {
return `${EXTENSION_TAG} ${sentence}${reasonClause(reason)}`;
}
/**
* What was refused, in the order a reader needs it: the gate surface, the tool
* that reached it, who asked, which of the call's operands was flagged, and the
* rule that fired.
*
* `callWord` is the noun the verdict needs after the surface — a user or
* unavailable verdict refuses a *call*, while a policy deny refuses the
* surface itself.
*/
function identification(
payload: PromptPayload,
budget: AgentRenderBudget,
callWord: string,
): string {
return [
`'${payload.request.surface}'`,
callWord,
invokedAsClause(payload),
toolClause(payload),
agentClause(payload),
flaggedClause(payload, budget),
ruleClause(payload),
]
.filter((clause) => clause !== "")
.join(" ");
}
/** The gated tool, named only when the surface has not already named it. */
function toolClause(payload: PromptPayload): string {
const { toolName, surface } = payload.request;
return toolName === null || toolName === surface
? ""
: `for tool '${toolName}'`;
}
/** The name the agent actually called, when a shell alias re-exposed bash. */
function invokedAsClause(payload: PromptPayload): string {
const { invokedToolName } = payload.request;
return invokedToolName === null ? "" : `(invoked as '${invokedToolName}')`;
}
/** Which agent asked, when the ask carries a name. */
function agentClause(payload: PromptPayload): string {
const { agentName } = payload.request.requester;
return agentName ? `for agent '${agentName}'` : "";
}
/**
* Which of the call's operands the rule fired on.
*
* Omitted for a bash ask, whose flagged element is the command §7 forbids
* echoing; for a generic tool ask, whose value is the tool name an earlier
* clause already stated; and for a payload-less forwarded relay, whose value
* shape is unknown, so it cannot be shown to not be a command.
*/
function flaggedClause(
payload: PromptPayload,
budget: AgentRenderBudget,
): string {
if (payload.kind === "bash" || payload.kind === "forwarded") {
return "";
}
const label = flaggedElementLabel(payload);
const elements = flaggedElements(payload).filter(
(element) => element !== payload.request.toolName,
);
if (elements.length === 0) {
return "";
}
const noun = elements.length === 1 ? label : `${label}s`;
return `for ${noun} ${elements
.map(
(element) =>
`'${cap(element, budget)}'${resolvedAlias(payload, element)}`,
)
.join(", ")}`;
}
/** The canonical target of a flagged path, when it names somewhere else. */
function resolvedAlias(payload: PromptPayload, element: string): string {
const resolved =
findEvidence(payload, "resolves to")?.text ??
allEvidence(payload, "external path").find(
(entry) => entry.text === element,
)?.detail;
return resolved ? ` (resolves to '${resolved}')` : "";
}
/** The rule that fired, with the nested context that makes it intelligible. */
function ruleClause(payload: PromptPayload): string {
const { matchedPattern, commandContext } = payload.request;
const parts: string[] = [];
if (matchedPattern !== null) {
parts.push(`rule '${matchedPattern}'`);
}
const context = describeBashCommandContext(commandContext);
if (context !== undefined) {
parts.push(`inside ${context}`);
}
return parts.length > 0 ? `(${parts.join(", ")})` : "";
}
/** The working directory the flagged paths escaped. */
function boundaryClause(payload: PromptPayload): string {
const cwd = findEvidence(payload, "working directory")?.text;
return cwd ? `: outside working directory '${cwd}'` : "";
}
/** The path a skill read reached its skill through. */
function provenanceClause(payload: PromptPayload): string {
const readPath = findEvidence(payload, "read path")?.text;
return readPath ? `, reached via '${readPath}'` : "";
}
function reasonClause(reason: string | null): string {
return reason ? ` Reason: ${reason}.` : "";
}
/**
* Narrow the flagged element to the budget.
*
* The command is never rendered, so this bounds the only agent-supplied value
* that reaches the agent. A quantity bound applied uniformly, never a content
* filter, with the same bare-ellipsis marker the dialog uses (ADR 0011 §4).
*/
function cap(text: string, budget: AgentRenderBudget): string {
return text.length <= budget.fieldMaxWidth
? text
: `${text.slice(0, budget.fieldMaxWidth)}\u2026`;
}
@@ -0,0 +1,348 @@
import {
describeBashCommandContext,
flaggedElements,
valueLabel,
} from "#src/presentation/fact-vocabulary";
import { fitLinesToWidth } from "#src/presentation/line-fitting";
import type { PromptPayload } from "#src/presentation/prompt-payload";
/**
* Render a {@link PromptPayload} for a human deciding an ask (ADR 0011 §5).
*
* The payload is complete by contract, so this is where elision happens: the
* dialog and the `select`/`input` fallback both render through here under
* their own budget, which is what makes a bounded prompt a property of the
* render rather than of what the gate assembled.
*
* The layout is one fact per line, `label : value`, labels aligned. A fact
* whose text an earlier line already carries is not repeated — a bash ask's
* gate surface is its tool name, and a generic tool ask's value is the tool —
* so every line the render spends states something new.
*/
export function renderPromptDialog(
payload: PromptPayload,
budget: DialogBudget,
paint: HighlightPaint = plainText,
): DialogView {
const core = coreFacts(payload).map((fact) =>
capField(fact, budget.fieldMaxWidth),
);
const evidence = evidenceFacts(payload).map((fact) =>
capField(fact, budget.fieldMaxWidth),
);
const blocks = layout(
[...core, ...evidence],
flaggedElements(payload),
paint,
).map((block) => fitLinesToWidth(block, budget.width));
const fitted = fitToRows(
blocks.slice(0, core.length).flat(),
blocks.slice(core.length),
budget.maxRows,
);
return {
lines: fitted.lines,
elided:
fitted.dropped || [...core, ...evidence].some((fact) => fact.clipped),
};
}
/**
* How much room a render has, as the operator configured it.
*
* Separate from the terminal width, which only the component rendering a frame
* knows — the configured half is read once per ask, the width once per frame.
*/
export interface RenderBudget {
/** Maximum rendered rows. */
readonly maxRows: number;
/** Maximum characters of any one field's text. */
readonly fieldMaxWidth: number;
}
/** A {@link RenderBudget} against the width its rows are counted at. */
export interface DialogBudget extends RenderBudget {
/** Terminal width the lines are wrapped to, so a row count is meaningful. */
readonly width: number;
}
/**
* The budget when the operator configures neither field.
*
* Twenty-four rows plus the decision options and the hint fit a thirty-row
* terminal; four hundred characters is roughly four wrapped rows, which is what
* actually bounds a here-string command.
*/
export const DEFAULT_RENDER_BUDGET: RenderBudget = {
maxRows: 24,
fieldMaxWidth: 400,
};
/** The two prompt-budget knobs, as the extension config carries them. */
export interface PromptBudgetConfig {
readonly promptMaxRows?: number;
readonly promptFieldMaxWidth?: number;
}
/** The configured budget, falling back per field to {@link DEFAULT_RENDER_BUDGET}. */
export function resolveRenderBudget(config: PromptBudgetConfig): RenderBudget {
return {
maxRows: config.promptMaxRows ?? DEFAULT_RENDER_BUDGET.maxRows,
fieldMaxWidth:
config.promptFieldMaxWidth ?? DEFAULT_RENDER_BUDGET.fieldMaxWidth,
};
}
/**
* Paints the flagged element — the command, path, or target the rule fired on.
*
* A render concern, so the fallback and the review log pass nothing: only the
* TUI has a theme to paint with.
*/
export type HighlightPaint = (text: string) => string;
/** What a renderer produced, and whether it had to leave anything out. */
export interface DialogView {
/** Wrapped to the budget's width: each entry is one visual row. */
readonly lines: readonly string[];
/** True when any field was shortened or any entry dropped. */
readonly elided: boolean;
}
/**
* The budget that elides nothing — the complete view an operator must be able
* to reach while the decision is pending (ADR 0011 §4).
*/
export function completeViewBudget(width: number): DialogBudget {
return {
maxRows: Number.POSITIVE_INFINITY,
fieldMaxWidth: Number.POSITIVE_INFINITY,
width,
};
}
const plainText: HighlightPaint = (text) => text;
/** One rendered fact. */
interface Fact {
readonly label: string;
readonly text: string;
}
/** A fact narrowed to the budget, and whether that cost it anything. */
interface CappedFact extends Fact {
readonly clipped: boolean;
}
/**
* Narrow one field's text to the budget.
*
* A quantity bound applied uniformly, never a content filter: it does not read
* the value to decide what to hide, which is what keeps it a cap rather than
* redaction (ADR 0010). The marker is a bare ellipsis — a character or line
* count is a number the operator cannot act on, and ADR 0011 §4 rejects it in
* favour of reaching the complete view.
*/
function capField(fact: Fact, fieldMaxWidth: number): CappedFact {
if (fact.text.length <= fieldMaxWidth) {
return { ...fact, clipped: false };
}
return {
...fact,
text: `${fact.text.slice(0, fieldMaxWidth)}\u2026`,
clipped: true,
};
}
/**
* Fit the rendered blocks into the row budget.
*
* The core is exempt and the evidence is what gives way: §3 outranks §5, so a
* core that alone overruns the budget still renders whole — the field cap is
* what bounds it, and the row budget is what bounds the evidence. A drop costs
* one row for its marker, taken only when there is something to mark.
*/
function fitToRows(
core: readonly string[],
evidence: readonly (readonly string[])[],
maxRows: number,
): { lines: string[]; dropped: boolean } {
const total = evidence.reduce((rows, block) => rows + block.length, 0);
if (core.length + total <= maxRows) {
return { lines: [...core, ...evidence.flat()], dropped: false };
}
const limit = maxRows - ELISION_MARKER_ROWS;
const lines = [...core];
for (const block of evidence) {
// An entry is shown whole or not at all: half a path is worse evidence
// than none, and the reader cannot tell the halves apart.
if (lines.length + block.length > limit) {
break;
}
lines.push(...block);
}
if (lines.length < maxRows) {
lines.push(ELISION_MARKER);
}
return { lines, dropped: true };
}
/**
* What an elision states: that there is more, and nothing else.
*
* Character and line counts were considered and rejected (ADR 0011 §4) — they
* are a number the operator cannot act on, and they spend budget the evidence
* itself should hold.
*/
const ELISION_MARKER = "\u2026";
const ELISION_MARKER_ROWS = 1;
/**
* The invariant core (ADR 0011 §3), in reading order: who is asking, what they
* called, what gated it, the decision-relevant value, and what will actually
* run.
*/
function coreFacts(payload: PromptPayload): Fact[] {
const { request } = payload;
const facts: Fact[] = [];
const requester = requesterFact(payload);
if (requester) {
facts.push(requester);
}
if (request.toolName !== null) {
facts.push({ label: "tool", text: toolText(payload) });
}
// The surface is stated already when it *is* the tool name (a bash ask) or
// when it is the word the value line is labelled with (a path ask reads
// `path : /tmp/x`), so a line for it would repeat rather than add.
const label = valueLabel(payload);
if (request.surface !== request.toolName && request.surface !== label) {
facts.push({ label: "surface", text: request.surface });
}
if (request.matchedPattern !== null) {
facts.push({ label: "rule", text: request.matchedPattern });
}
if (request.value !== "" && request.value !== request.toolName) {
facts.push({ label, text: request.value });
}
if (request.executedUnit !== null) {
facts.push({ label: "runs", text: request.executedUnit });
}
const context = describeBashCommandContext(request.commandContext);
if (context !== undefined) {
facts.push({ label: "context", text: context });
}
return facts;
}
/**
* The decision evidence, in payload order.
*
* An entry's `detail` rides its own line rather than becoming a second entry,
* so an elision can never show a path while dropping what it resolves to.
*/
function evidenceFacts(payload: PromptPayload): Fact[] {
return payload.evidence.map((entry) => ({
label: entry.label,
text:
entry.detail === null ? entry.text : `${entry.text}${entry.detail}`,
}));
}
/**
* Who is asking.
*
* A forwarded ask always names its requester — that the ask came from a
* subagent is itself a core fact — while an unnamed local requester states
* nothing, and a line asserting the default would spend a row saying so.
*/
function requesterFact(payload: PromptPayload): Fact | undefined {
const { agentName, forwarded, sessionId } = payload.request.requester;
if (!forwarded) {
return agentName ? { label: "agent", text: agentName } : undefined;
}
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: a version-skewed request carries "" rather than null
const name = agentName || "unknown";
return {
label: "subagent",
text: sessionId ? `${name} · session ${sessionId}` : name,
};
}
/** The gated tool, and the name the agent actually called when they differ. */
function toolText(payload: PromptPayload): string {
const { toolName, invokedToolName } = payload.request;
return invokedToolName === null
? String(toolName)
: `${String(toolName)} (invoked as ${invokedToolName})`;
}
/**
* Align the labels into a `label : value` column.
*
* A field carrying its own newlines (a here-string, a multi-line preview)
* continues under the column rather than back at the margin, so the eye can
* still tell a continuation from the next fact.
*/
function layout(
facts: readonly Fact[],
flagged: readonly string[],
paint: HighlightPaint,
): string[][] {
const width = Math.max(0, ...facts.map((fact) => fact.label.length));
const indent = " ".repeat(width + 3);
return facts.map((fact) => {
// A fact that *is* the flagged element paints whole; any other line paints
// the whole-token occurrences of it, so `ls` stays plain inside `lsof`.
const highlight = flagged.includes(fact.text)
? paint
: (line: string) => paintTokens(line, flagged, paint);
return fact.text
.split("\n")
.map((line, index) =>
index === 0
? `${fact.label.padEnd(width)} : ${highlight(line)}`
: indent + highlight(line),
);
});
}
/** Characters a path, command, or target may contain, so a match is a whole token. */
const TOKEN_CHARACTER = /[\w/.-]/;
/** Paint every whole-token occurrence of each flagged text within one line. */
function paintTokens(
line: string,
flagged: readonly string[],
paint: HighlightPaint,
): string {
return flagged.reduce(
(painted, needle) => paintOccurrences(painted, needle, paint),
line,
);
}
function paintOccurrences(
line: string,
needle: string,
paint: HighlightPaint,
): string {
if (needle === "" || needle.includes("\n")) {
return line;
}
let result = "";
let cursor = 0;
for (
let at = line.indexOf(needle, cursor);
at !== -1;
at = line.indexOf(needle, cursor)
) {
const end = at + needle.length;
const whole =
!TOKEN_CHARACTER.test(line[at - 1] ?? " ") &&
!TOKEN_CHARACTER.test(line[end] ?? " ");
result += line.slice(cursor, at) + (whole ? paint(needle) : needle);
cursor = end;
}
return result + line.slice(cursor);
}
@@ -0,0 +1,103 @@
import {
allEvidence,
type PromptPayload,
} from "#src/presentation/prompt-payload";
import type { BashCommandContext } from "#src/types";
/**
* The render vocabulary shared by every renderer over a {@link PromptPayload}.
*
* Which element an ask flags, what that element is called, and how a nested
* execution context reads are all answers a render needs and none of them is
* a payload fact — the payload carries `value`, `kind`, and `commandContext`,
* and this module is where they acquire a name. It lives apart from any one
* renderer so the dialog, the agent-facing text, and the review log cannot
* disagree about what a given ask is flagging.
*/
/**
* What the ask is flagging.
*
* The decision-relevant value for every shape but one: a bash ask that escaped
* the working directory flags the paths it referenced, not the command that
* referenced them — the command is the context, and the paths are what the
* operator is ruling on.
*/
export function flaggedElements(payload: PromptPayload): readonly string[] {
if (payload.kind === "bash_external_directory") {
return allEvidence(payload, "external path").map((entry) => entry.text);
}
return payload.request.value === "" ? [] : [payload.request.value];
}
/**
* What {@link flaggedElements} returns is called.
*
* Differs from {@link valueLabel} for exactly one shape: a bash ask that
* escaped the working directory flags paths while its value is the command,
* so the two nouns are for two different things.
*/
export function flaggedElementLabel(payload: PromptPayload): string {
return payload.kind === "bash_external_directory"
? "path"
: valueLabel(payload);
}
/** What the decision-relevant value is called, per ask shape. */
export function valueLabel(payload: PromptPayload): string {
switch (payload.kind) {
case "bash":
case "bash_external_directory":
return "command";
case "mcp":
return "target";
case "tool":
return "tool";
case "path":
case "external_directory":
return "path";
case "skill":
case "skill_read":
return "skill";
case "forwarded":
return forwardedValueLabel(payload.request.surface);
}
}
/**
* Labels the version-skew render only: a payload-bearing forwarded ask carries
* the child's real `kind` and never reaches this arm (#745).
*
* Without a payload all that survives is the child's *display* projection — its
* tool name as the surface — so the label is inferred from it and falls back to
* a neutral one.
*/
function forwardedValueLabel(surface: string): string {
switch (surface) {
case "bash":
return "command";
case "skill":
return "skill";
default:
return "value";
}
}
/**
* Human-readable label for a nested bash execution context, or `undefined` for
* a current-shell (top-level) command.
*/
export function describeBashCommandContext(
context: BashCommandContext | null,
): string | undefined {
switch (context) {
case "command_substitution":
return "command substitution";
case "process_substitution":
return "process substitution";
case "subshell":
return "subshell";
case null:
return undefined;
}
}
@@ -0,0 +1,70 @@
import type { ForwardedPermissionRequest } from "#src/authority/permission-forwarding";
import type {
PromptPayload,
PromptRequester,
} from "#src/presentation/prompt-payload";
/**
* Build the payload for an ask forwarded up from a subagent.
*
* A projection, not a synthesizer: the child ships its own complete payload, so
* the serving node renders the child's facts under the *parent's* budget — which
* is what makes a forwarded ask and a local one consistent in kind, a forwarded
* bash ask reading `command : …` exactly as a local one does (ADR 0011 §2).
*
* A request carrying no payload renders from whatever it does hold: fail-closed
* applies to presentation as it does to policy, so a version-skewed ask still
* reaches the human rather than resolving without one (ADR 0011 §9).
*/
export function buildForwardedAskPayload(
request: ForwardedPermissionRequest,
): PromptPayload {
// The child built its payload with `localRequester` — `forwarded: false`,
// `sessionId: null`. The serving node is the only party that knows the ask
// arrived over the wire, and the request's own provenance is authoritative
// (#292); everything else is the child's fact and passes through untouched.
const requester: PromptRequester = {
agentName: request.requesterAgentName,
forwarded: true,
sessionId: request.requesterSessionId,
};
return request.payload
? {
...request.payload,
request: { ...request.payload.request, requester },
}
: degradedForwardedPayload(request, requester);
}
/**
* The render for an ask that arrived without a payload.
*
* `kind: "forwarded"` narrows to meaning exactly this — not "an ask from a
* subagent", which every branch above is too.
*/
function degradedForwardedPayload(
request: ForwardedPermissionRequest,
requester: PromptRequester,
): PromptPayload {
return {
kind: "forwarded",
request: {
requester,
// The child's display projection: what the ask was about, as the child's
// own gate named it.
surface: request.surface ?? "",
toolName: null,
invokedToolName: null,
value: request.value ?? "",
matchedPattern: null,
commandContext: null,
executedUnit: null,
},
// Nothing to carry: the wire no longer relays a sentence, and inventing
// evidence the child never sent is exactly the fiction the bounded
// renderers would then have to trust.
evidence: [],
annotations: [],
};
}
@@ -0,0 +1,27 @@
import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
/**
* Fit rendered lines to a terminal width, so each returned entry is a single
* visual row no wider than `width`.
*
* Long lines are wrapped rather than clipped so no content is lost; the final
* `truncateToWidth` guards the edge cases `wrapTextWithAnsi` cannot split (a
* lone wide grapheme). A width of zero or less yields no rows.
*
* Shared by the `ctx.ui.custom` dialog — whose contract requires it — and by
* any renderer that must count rows, since a row count is only meaningful
* after wrapping.
*/
export function fitLinesToWidth(
lines: readonly string[],
width: number,
): string[] {
if (width <= 0) {
return [];
}
return lines.flatMap((line) =>
wrapTextWithAnsi(line, width).map((wrapped) =>
truncateToWidth(wrapped, width),
),
);
}
@@ -0,0 +1,135 @@
import type {
PromptEvidence,
PromptPayload,
} from "#src/presentation/prompt-payload";
import { localRequester } from "#src/presentation/prompt-payload";
/** A displayed external path paired with its resolved target, when distinct. */
export interface ExternalPathDisclosure {
/** The path as displayed (typed for tools, lexical-absolute for bash). */
path: string;
/** The canonical symlink-resolved target; present only when it differs. */
resolvedPath?: string;
}
/** The facts a path-shaped gate holds when it raises an ask. */
interface PathAskFacts {
toolName: string;
/** The path as the caller typed it — what the user recognizes. */
pathValue: string;
agentName: string | null;
matchedPattern?: string;
}
/** A tool ask gated by an explicit `path` rule. */
export function buildPathAskPayload(facts: PathAskFacts): PromptPayload {
return pathPayload("path", "path", facts, []);
}
/** The facts the external-directory gate adds: the boundary and the alias. */
interface ExternalDirectoryAskFacts extends PathAskFacts {
/** The canonical location, when it names somewhere other than the typed path. */
resolvedPath?: string;
/** The working directory the path escapes. */
cwd: string;
}
/** A tool ask for a path outside the working directory. */
export function buildExternalDirectoryAskPayload(
facts: ExternalDirectoryAskFacts,
): PromptPayload {
return pathPayload("external_directory", "external_directory", facts, [
...resolvedAliasEvidence(facts.resolvedPath),
workingDirectoryEvidence(facts.cwd),
]);
}
/** The facts the bash external-directory gate holds: one command, many paths. */
interface BashExternalDirectoryAskFacts {
command: string;
/** Every uncovered path the command references, with its canonical alias. */
externalPaths: readonly ExternalPathDisclosure[];
cwd: string;
agentName: string | null;
toolName: string;
matchedPattern?: string;
}
/** A bash ask whose command references paths outside the working directory. */
export function buildBashExternalDirectoryAskPayload(
facts: BashExternalDirectoryAskFacts,
): PromptPayload {
return {
kind: "bash_external_directory",
request: {
requester: localRequester(facts.agentName),
surface: "external_directory",
toolName: facts.toolName,
invokedToolName: null,
value: facts.command,
matchedPattern: facts.matchedPattern ?? null,
commandContext: null,
executedUnit: null,
},
evidence: [
workingDirectoryEvidence(facts.cwd),
...facts.externalPaths.map(externalPathEvidence),
],
annotations: [],
};
}
// ── Shared shape ────────────────────────────────────────────────────────────
/**
* The payload common to the single-path asks: the typed path is the
* decision-relevant value, and the gate surface distinguishes them.
*/
function pathPayload(
kind: "path" | "external_directory",
surface: string,
facts: PathAskFacts,
evidence: PromptEvidence[],
): PromptPayload {
return {
kind,
request: {
requester: localRequester(facts.agentName),
surface,
toolName: facts.toolName,
invokedToolName: null,
value: facts.pathValue,
matchedPattern: facts.matchedPattern ?? null,
commandContext: null,
executedUnit: null,
},
evidence,
annotations: [],
};
}
/**
* The canonical location, as its own entry rather than folded into the value:
* the user decides on the path they typed, and the alias is what that path
* turns out to name.
*/
function resolvedAliasEvidence(resolvedPath?: string): PromptEvidence[] {
return resolvedPath === undefined
? []
: [{ label: "resolves to", text: resolvedPath, detail: null }];
}
function workingDirectoryEvidence(cwd: string): PromptEvidence {
return { label: "working directory", text: cwd, detail: null };
}
/**
* One escaping path. The canonical alias rides as the entry's `detail` so a
* render cannot separate a path from what it resolves to.
*/
function externalPathEvidence({
path,
resolvedPath,
}: ExternalPathDisclosure): PromptEvidence {
return { label: "external path", text: path, detail: resolvedPath ?? null };
}
@@ -0,0 +1,298 @@
import type { BashCommandContext } from "#src/types";
/**
* The complete, structured description of a permission ask (ADR 0011 §2).
*
* A gate emits one of these instead of a sentence. It is complete by contract:
* it never truncates and never decides what a human will see. Every consumer is
* a renderer over it, eliding under its own budget — so elision is a property
* of a render, never of the payload.
*/
export interface PromptPayload {
readonly kind: PromptPayloadKind;
readonly request: PromptRequestFacts;
/** Complete; each renderer elides to fit its own budget. */
readonly evidence: readonly PromptEvidence[];
/** Supplied by registered annotators; always marked as model-generated. */
readonly annotations: readonly PromptAnnotation[];
}
/**
* Which ask this payload describes — the renderers' dispatch discriminant.
*
* Present because the ask shapes are not separable by surface alone: a tool
* external-directory ask and a bash one share the `external_directory` surface,
* and the `path` gate and the per-tool gate differ only in wording. It gives
* every renderer an exhaustive switch rather than a set of string comparisons a
* new variant sails past — which is what let the parallel denial-context union
* ADR 0011 §7 described dissolve into this one (#746).
*/
export type PromptPayloadKind =
| "bash"
| "mcp"
| "tool"
| "path"
| "external_directory"
| "bash_external_directory"
| "skill"
| "skill_read"
| "forwarded";
/**
* The invariant core (ADR 0011 §3): the facts visible in every render, that no
* renderer's budget may elide.
*
* Named for what it holds — the permission request's own facts, matching the
* package's `PermissionRequest` / `ForwardedPermissionRequest` vocabulary —
* rather than for its contract, which this comment states instead.
*/
export interface PromptRequestFacts {
/** Who is asking, and whether the ask arrived from a subagent. */
readonly requester: PromptRequester;
/** The gate surface the rule fired on. */
readonly surface: string;
/** The gated tool name; `null` when the ask is not tool-shaped. */
readonly toolName: string | null;
/**
* The invoked tool name when a shell alias re-exposes bash under another
* name (#574) — "gated as bash, invoked as exec_command" is two facts.
* `null` when it adds nothing.
*/
readonly invokedToolName: string | null;
/** The decision-relevant value: the command, path, MCP target, or skill name. */
readonly value: string;
/** The matched rule, including a sentinel such as `<indirection-bash-wrapper>`. */
readonly matchedPattern: string | null;
/**
* Where the offending bash unit runs, when it came from a substitution or a
* subshell. A fact rather than a rendered clause: it is what makes the
* matched rule intelligible, and how it reads is the renderer's choice.
*/
readonly commandContext: BashCommandContext | null;
/**
* For bash, the unit that will actually run — including inside an unstrippable
* wrapper (#713). `null` when it adds nothing over {@link value}.
*/
readonly executedUnit: string | null;
}
/** Who is asking, one hop below when the ask was forwarded. */
export interface PromptRequester {
readonly agentName: string | null;
readonly forwarded: boolean;
/** The requesting session, for a forwarded ask; `null` for a local one. */
readonly sessionId: string | null;
}
/**
* One piece of decision evidence.
*
* Complete on the payload; each renderer elides entries and orders them under
* its own budget (ADR 0011 §4).
*/
export interface PromptEvidence {
readonly label: string;
readonly text: string;
/**
* A secondary fact bound to this entry that a renderer may show alongside
* {@link text} or elide independently — a path's symlink-resolved alias, for
* instance. Bound to the entry rather than listed as a second one so an
* elision cannot separate the two.
*/
readonly detail: string | null;
}
/**
* A model-generated advisory (ADR 0011 §8).
*
* The slot owns the attribution and the model-generated marking, so marking is
* a property of the payload rather than a discipline each annotator must
* remember. Structurally separate from any verdict: an annotation cannot allow,
* deny, defer, or suppress.
*/
export interface PromptAnnotation {
readonly source: string;
readonly text: string;
}
/** The `requester` facts for an ask raised by this session. */
export function localRequester(agentName: string | null): PromptRequester {
return { agentName, forwarded: false, sessionId: null };
}
/** Every {@link PromptPayloadKind}, for tolerant reads of a serialized payload. */
const PROMPT_PAYLOAD_KINDS = [
"bash",
"mcp",
"tool",
"path",
"external_directory",
"bash_external_directory",
"skill",
"skill_read",
"forwarded",
] as const satisfies readonly PromptPayloadKind[];
const BASH_COMMAND_CONTEXTS = [
"command_substitution",
"process_substitution",
"subshell",
] as const satisfies readonly BashCommandContext[];
/**
* Narrow an unknown value to a {@link PromptPayload}, or `undefined`.
*
* Lives beside its type so a new request fact updates the guard next door
* rather than in a distant reader, following `isPermissionDecisionState`'s
* precedent.
*
* All-or-nothing: any malformed field yields `undefined` rather than a
* half-payload, so a consumer renders its own degraded view instead of
* presenting corrupt facts (ADR 0011 §9).
*/
export function asPromptPayload(value: unknown): PromptPayload | undefined {
const candidate = asObject(value);
if (!candidate) return undefined;
const kind = PROMPT_PAYLOAD_KINDS.find((entry) => entry === candidate.kind);
const request = asPromptRequestFacts(candidate.request);
const evidence = asArrayOf(candidate.evidence, asPromptEvidence);
const annotations = asArrayOf(candidate.annotations, asPromptAnnotation);
if (!kind || !request || !evidence || !annotations) return undefined;
return { kind, request, evidence, annotations };
}
function asPromptRequestFacts(value: unknown): PromptRequestFacts | undefined {
const candidate = asObject(value);
if (!candidate) return undefined;
const requester = asPromptRequester(candidate.requester);
const commandContext = asNullableMember(
candidate.commandContext,
BASH_COMMAND_CONTEXTS,
);
if (
!requester ||
commandContext === undefined ||
typeof candidate.surface !== "string" ||
typeof candidate.value !== "string" ||
!isNullableString(candidate.toolName) ||
!isNullableString(candidate.invokedToolName) ||
!isNullableString(candidate.matchedPattern) ||
!isNullableString(candidate.executedUnit)
) {
return undefined;
}
return {
requester,
surface: candidate.surface,
toolName: candidate.toolName,
invokedToolName: candidate.invokedToolName,
value: candidate.value,
matchedPattern: candidate.matchedPattern,
commandContext: commandContext.value,
executedUnit: candidate.executedUnit,
};
}
function asPromptRequester(value: unknown): PromptRequester | undefined {
const candidate = asObject(value);
if (
!candidate ||
typeof candidate.forwarded !== "boolean" ||
!isNullableString(candidate.agentName) ||
!isNullableString(candidate.sessionId)
) {
return undefined;
}
return {
agentName: candidate.agentName,
forwarded: candidate.forwarded,
sessionId: candidate.sessionId,
};
}
function asPromptEvidence(value: unknown): PromptEvidence | undefined {
const candidate = asObject(value);
if (
!candidate ||
typeof candidate.label !== "string" ||
typeof candidate.text !== "string" ||
!isNullableString(candidate.detail)
) {
return undefined;
}
return {
label: candidate.label,
text: candidate.text,
detail: candidate.detail,
};
}
function asPromptAnnotation(value: unknown): PromptAnnotation | undefined {
const candidate = asObject(value);
if (
!candidate ||
typeof candidate.source !== "string" ||
typeof candidate.text !== "string"
) {
return undefined;
}
return { source: candidate.source, text: candidate.text };
}
function asObject(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: undefined;
}
/** Narrow every entry, or `undefined` when the array or any entry is malformed. */
function asArrayOf<T>(
value: unknown,
narrow: (entry: unknown) => T | undefined,
): T[] | undefined {
if (!Array.isArray(value)) return undefined;
const narrowed: T[] = [];
for (const entry of value) {
const result = narrow(entry);
if (!result) return undefined;
narrowed.push(result);
}
return narrowed;
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === "string";
}
/**
* Narrow to `null` or a member of `members`, boxed so a valid `null` is
* distinguishable from the malformed `undefined`.
*/
function asNullableMember<T extends string>(
value: unknown,
members: readonly T[],
): { value: T | null } | undefined {
if (value === null) return { value: null };
const member = members.find((entry) => entry === value);
return member ? { value: member } : undefined;
}
/** Find the evidence entry a renderer knows by label. */
export function findEvidence(
payload: PromptPayload,
label: string,
): PromptEvidence | undefined {
return payload.evidence.find((entry) => entry.label === label);
}
/** Every evidence entry carrying the given label, in payload order. */
export function allEvidence(
payload: PromptPayload,
label: string,
): readonly PromptEvidence[] {
return payload.evidence.filter((entry) => entry.label === label);
}
@@ -0,0 +1,51 @@
import type { PromptPayload } from "#src/presentation/prompt-payload";
/**
* The payload facts the permission review log persists (ADR 0011 §6).
*
* The log is a renderer over the payload like any other, and this is its
* content decision: the request facts, and only those the log's own structured
* columns do not already carry. `toolName`, `command`, `path`, `target`, and
* `toolInputPreview` are written by the gates; restating them under a second
* name would grow the log rather than sharpen it.
*
* Evidence and annotations are deliberately absent.
* `docs/decisions/0010-permission-log-secret-exposure.md` bounds what the logs
* accumulate, and evidence is exactly the unbounded part — the point of this
* render is that the log's growth is a decision, not a side effect of how a
* prompt happened to be worded.
*
* A fact the ask does not carry is omitted rather than written as `null`, so a
* line states what was true rather than enumerating what was not.
*/
export function renderReviewLogFacts(
payload: PromptPayload,
): Record<string, unknown> {
const { request } = payload;
return {
surface: request.surface,
...present("matchedPattern", request.matchedPattern),
...present("executedUnit", request.executedUnit),
...present("commandContext", request.commandContext),
...present("invokedToolName", request.invokedToolName),
...forwardingFacts(payload),
};
}
/**
* Where the ask came from, when it came from somewhere else.
*
* A local ask is the default and states nothing; a forwarded one names the
* session that raised it, so a decision can be correlated back to the child
* that asked.
*/
function forwardingFacts(payload: PromptPayload): Record<string, unknown> {
const { forwarded, sessionId } = payload.request.requester;
return forwarded
? { forwarded: true, ...present("requesterSessionId", sessionId) }
: {};
}
function present<T>(key: string, value: T | null): Record<string, T> {
return value === null ? {} : { [key]: value };
}
@@ -0,0 +1,50 @@
import type { PromptPayload } from "#src/presentation/prompt-payload";
import { localRequester } from "#src/presentation/prompt-payload";
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
/** A request to load a skill. */
export function buildSkillAskPayload(
skillName: string,
agentName: string | null,
): PromptPayload {
return skillPayload("skill", skillName, agentName, []);
}
/**
* A read that reaches a skill through one of its files.
*
* The skill is the decision-relevant value — it is what the policy names — and
* the path is the evidence for why this read counts as reaching it.
*/
export function buildSkillPathAskPayload(
skill: SkillPromptEntry,
readPath: string,
agentName: string | null,
): PromptPayload {
return skillPayload("skill_read", skill.name, agentName, [
{ label: "read path", text: readPath, detail: null },
]);
}
function skillPayload(
kind: "skill" | "skill_read",
skillName: string,
agentName: string | null,
evidence: PromptPayload["evidence"],
): PromptPayload {
return {
kind,
request: {
requester: localRequester(agentName),
surface: "skill",
toolName: null,
invokedToolName: null,
value: skillName,
matchedPattern: null,
commandContext: null,
executedUnit: null,
},
evidence,
annotations: [],
};
}
@@ -0,0 +1,104 @@
import { classifyToolKind, isMcpCheck } from "#src/access-intent/tool-kind";
import type {
PromptEvidence,
PromptPayload,
} from "#src/presentation/prompt-payload";
import { localRequester } from "#src/presentation/prompt-payload";
import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
import type { PermissionCheckResult } from "#src/types";
import { getNonEmptyString, toRecord } from "#src/value-guards";
/** The facts the per-tool gate holds when it raises an ask. */
export interface ToolAskFacts {
/** The resolved check: the gated tool, the matched rule, the offending unit. */
check: PermissionCheckResult;
agentName: string | null;
/** The gate surface the rule fired on — `bash` for a shell alias (#574). */
surface: string;
/** The tool the agent actually called, when a shell alias re-exposes bash. */
invokedToolName?: string | null;
/** The raw tool input, the source of the input-preview evidence. */
input?: unknown;
/** Renders the per-tool input preview; absent means no preview evidence. */
formatter?: ToolPreviewFormatter;
}
/**
* Build the payload for the per-tool gate: a bash, MCP, or generic-tool ask.
*
* The branch decides only the payload's `kind` and which fact is the
* decision-relevant `value`; how any of it reads is a renderer's decision.
*/
export function buildToolAskPayload(facts: ToolAskFacts): PromptPayload {
const { check } = facts;
const bash = classifyToolKind(check.toolName) === "bash";
const mcp = isMcpCheck(check) && check.target !== undefined;
return {
kind: bash ? "bash" : mcp ? "mcp" : "tool",
request: {
requester: localRequester(facts.agentName),
surface: facts.surface,
toolName: check.toolName,
invokedToolName: distinctInvokedName(facts),
value: askValue(check, bash, mcp),
matchedPattern: check.matchedPattern ?? null,
commandContext: check.commandContext ?? null,
executedUnit: check.executedUnit ?? null,
},
evidence: bash
? fullCommandEvidence(facts)
: inputPreviewEvidence(facts, mcp),
annotations: [],
};
}
/**
* The decision-relevant value: the offending command for bash, the qualified
* target for MCP, the tool name otherwise.
*
* A bash check with no command yields the empty string rather than the tool
* name — the ask is about a command, and naming the surface instead would
* assert a command that was never resolved.
*/
function askValue(
check: PermissionCheckResult,
bash: boolean,
mcp: boolean,
): string {
if (bash) return check.command ?? "";
if (mcp) return check.target ?? "";
return check.toolName;
}
/** The invoked tool name, but only when it is a fact the gated name does not carry. */
function distinctInvokedName(facts: ToolAskFacts): string | null {
const invoked = facts.invokedToolName ?? null;
return invoked === null || invoked === facts.check.toolName ? null : invoked;
}
/** The enclosing command, when the gated unit is only part of what will run. */
function fullCommandEvidence(facts: ToolAskFacts): PromptEvidence[] {
const fullCommand = getNonEmptyString(toRecord(facts.input).command);
if (fullCommand === null || fullCommand === facts.check.command) {
return [];
}
return [{ label: "full command", text: fullCommand, detail: null }];
}
/**
* The per-tool input preview, when a formatter is registered and produces one.
*
* An MCP ask previews under the `mcp` key rather than the qualified target, so
* a registered MCP formatter is consulted for every server.
*/
function inputPreviewEvidence(
facts: ToolAskFacts,
mcp: boolean,
): PromptEvidence[] {
const preview = facts.formatter?.formatToolInputForPrompt(
mcp ? "mcp" : facts.check.toolName,
facts.input,
);
return preview ? [{ label: "input", text: preview, detail: null }] : [];
}