mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -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 }] : [];
|
||||
}
|
||||
Reference in New Issue
Block a user