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,133 @@
import type {
BashCommand,
WrapperKind,
} from "#src/access-intent/bash/command-enumeration";
import { pickMostRestrictive } from "#src/handlers/gates/candidate-check";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import type { PermissionCheckResult } from "#src/types";
/**
* Resolve the bash command-pattern decision for a (possibly chained) command.
*
* A bash invocation may be a shell program with several commands joined by
* `&&`, `||`, `;`, `|`, `&`, or newlines. Matching the whole string against the
* bash patterns lets a denied command ride through on an allowed leading one
* (issue #301). Instead, the caller supplies the program's command units (from
* the shared `BashProgram.commands()` parse) — including those nested inside
* substitutions and subshells (#306); each is evaluated on the `bash` surface
* and the most restrictive result wins (`deny > ask > allow`).
*
* The selected result carries the offending sub-command in `command`, its rule
* in `matchedPattern`, and the offending command's execution context in
* `commandContext` (set only for a nested command), so the prompt,
* session-approval suggestion, and decision event scope to that command.
*
* A wrapper unit (flagged with a `wrapperKind` by the enumerator) hides or
* indirects the command that should be gated, so an `allow` is floored up to a
* synthetic `ask` — the `<opaque-bash-wrapper>` pattern for an inline-shell
* payload (`bash -c`/`eval`, #481) or `<indirection-bash-wrapper>` for a
* prefix/exec wrapper (`sudo`/`env`/`xargs`/`find -exec`/…, #490) — to keep it
* from riding a permissive rule; an explicit `deny`/`ask` on the wrapper is left
* untouched (`deny > ask > allow`).
*
* When `commands` is empty there are two cases. A trivially-empty command (an
* empty, whitespace-only, or comment-only line) has genuinely nothing to gate,
* so the whole `command` is resolved as before. A non-empty command that parsed
* to zero command units (a parse anomaly or an opaque program) fails closed to
* a synthetic `ask` so a permissive top-level `*` cannot silently allow an
* unparseable command (e.g. `cd /repo && git push` riding a top-level allow on
* the empty-parse path) — #452. The whole command is still resolved first so an
* explicit `deny` covering it denies outright rather than being masked into an
* approvable prompt (#712).
*
* Pure and synchronous: the (async, tree-sitter) parse happens once in the
* handler, which passes the decomposed `commands` here.
*/
/**
* The synthetic `matchedPattern` recorded when a wrapper unit's `allow` is
* floored to `ask`, keyed by the wrapper kind that caused the floor.
*/
const WRAPPER_SENTINEL: Record<WrapperKind, string> = {
"opaque-payload": "<opaque-bash-wrapper>",
indirection: "<indirection-bash-wrapper>",
};
export function resolveBashCommandCheck(
command: string,
commands: BashCommand[],
agentName: string | undefined,
resolver: ScopedPermissionResolver,
): PermissionCheckResult {
if (commands.length === 0) {
if (isTriviallyEmptyCommand(command)) {
return resolveWholeCommand(command, agentName, resolver);
}
const whole = resolveWholeCommand(command, agentName, resolver);
if (whole.state === "deny") {
return whole;
}
return {
state: "ask",
toolName: "bash",
source: "bash",
origin: "builtin",
command,
matchedPattern: "<unparseable-bash-command>",
};
}
const results = commands.map((cmd) => {
const base = resolver.resolve({
kind: "tool",
surface: "bash",
input: { command: cmd.text },
agentName,
});
const floored =
cmd.wrapperKind && base.state === "allow"
? {
...base,
state: "ask" as const,
matchedPattern: WRAPPER_SENTINEL[cmd.wrapperKind],
}
: base;
const result = cmd.context
? { ...floored, commandContext: cmd.context }
: floored;
return cmd.executedUnit === undefined
? result
: { ...result, executedUnit: cmd.executedUnit };
});
return (
pickMostRestrictive(results) ??
resolveWholeCommand(command, agentName, resolver)
);
}
/**
* True when a command has genuinely nothing to gate: it is empty,
* whitespace-only, or contains only comment lines (every non-blank line starts
* with `#`). Such a command yields zero command units legitimately, so the
* whole-string resolve is safe rather than a parse anomaly.
*/
function isTriviallyEmptyCommand(command: string): boolean {
const lines = command
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
return lines.every((line) => line.startsWith("#"));
}
/** Resolve the whole command string as a single unit on the `bash` surface. */
function resolveWholeCommand(
command: string,
agentName: string | undefined,
resolver: ScopedPermissionResolver,
): PermissionCheckResult {
return resolver.resolve({
kind: "tool",
surface: "bash",
input: { command },
agentName,
});
}
@@ -0,0 +1,126 @@
import type { BashProgram } from "#src/access-intent/bash/program";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import { buildBashExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
import { SessionApproval } from "#src/session-approval";
import { deriveApprovalPattern } from "#src/session-rules";
import type { GateResult } from "./descriptor";
import { selectUncoveredExternalPaths } from "./external-directory-policy";
import { accessFactsFromPath } from "./helpers";
import type { ToolCallContext } from "./types";
/**
* Build a pure descriptor for the bash external-directory permission gate.
*
* Reads the external paths from the injected `BashProgram` and checks whether
* any reference directories outside the working directory. Returns `null` when the gate
* does not apply (not a shell invocation, no command, or no external paths found).
* Returns a `GateBypass` when all paths are allowed (by config or session rule).
* Returns a `GateDescriptor` with multi-pattern sessionApproval for uncovered paths.
*
* The shell command (native `bash` or an aliased shell tool) is read from the
* injected `BashProgram`, which owns the source text it was parsed from, so
* this gate does not re-derive the input field name (#574).
*/
export function describeBashExternalDirectoryGate(
tcc: ToolCallContext,
bashProgram: BashProgram | null,
resolver: ScopedPermissionResolver,
): GateResult {
if (!bashProgram) return null;
const command = bashProgram.commandText();
const externalPaths = bashProgram.externalPaths();
if (externalPaths.length === 0) return null;
// Resolve every external path on the external_directory surface and keep the
// ones not already allowed (config-level allows suppress the prompt just as
// session-level allows do); the shared helper single-sources the #418 alias
// matching and the worst-uncovered selection.
const { uncovered: uncoveredEntries, worstCheck } =
selectUncoveredExternalPaths(
externalPaths,
resolver,
tcc.agentName ?? undefined,
);
const uncoveredPaths = uncoveredEntries.map(({ path }) => path.value());
if (uncoveredPaths.length === 0) {
return {
action: "allow",
// A whole-command bypass covers every external path at once, and each
// may have matched a different session pattern -- so the surface is one
// value and the pattern is not. The entry's `externalPaths` lists what
// was covered.
decidedBy: {
kind: "session_approval",
surface: "external_directory",
pattern: null,
},
log: {
event: "permission_request.session_approved",
details: {
source: "tool_call",
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
agentName: tcc.agentName,
command,
externalPaths: externalPaths.map((p) => p.value()),
resolution: "session_approved",
},
},
};
}
// After the early bypass, at least one path is uncovered, so worstCheck is
// defined; the fallback keeps TypeScript happy across the early return. A
// config-level "deny" is preserved (not downgraded to the catch-all "ask").
const preCheck = worstCheck ?? uncoveredEntries[0].check;
// The AccessPath the decision was made against — its facts ride the wire.
const worstEntry =
uncoveredEntries.find(({ check }) => check === preCheck) ??
uncoveredEntries[0];
const disclosures = uncoveredEntries.map(({ path }) => ({
path: path.value(),
resolvedPath: path.resolvedAlias(),
}));
const payload = buildBashExternalDirectoryAskPayload({
command,
externalPaths: disclosures,
cwd: tcc.cwd,
agentName: tcc.agentName,
toolName: tcc.toolName,
matchedPattern: preCheck.matchedPattern,
});
const patterns = uncoveredPaths.map((p) => deriveApprovalPattern(p));
return {
surface: "external_directory",
input: {},
payload,
sessionApproval: SessionApproval.multiple("external_directory", patterns),
promptDetails: {
source: "tool_call",
agentName: tcc.agentName,
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
command,
accessIntent: accessFactsFromPath("external_directory", worstEntry.path),
},
logContext: {
source: "tool_call",
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
agentName: tcc.agentName,
command,
externalPaths: uncoveredPaths,
},
decision: {
surface: "external_directory",
value: command,
},
preCheck,
};
}
@@ -0,0 +1,23 @@
import { BashProgram } from "#src/access-intent/bash/program";
import type { PathNormalizer } from "#src/path-normalizer";
/**
* Extract paths from a bash command that resolve outside CWD.
*
* Thin facade over {@link BashProgram.externalPaths}; parses the command
* through the injected {@link PathNormalizer} (platform + cwd baked in) and
* returns the cd-aware external paths in their lexical (as-typed) string form.
* See `BashProgram` for the parsing and resolution semantics.
*
* Returns `string[]` (not `AccessPath[]`) so the large projection-correctness
* test suite in `bash-external-directory.test.ts` can assert path values
* without migrating to the `AccessPath` accessors.
*/
export async function extractExternalPathsFromBashCommand(
command: string,
normalizer: PathNormalizer,
): Promise<string[]> {
return (await BashProgram.parse(command, normalizer))
.externalPaths()
.map((p) => p.value());
}
@@ -0,0 +1,158 @@
import type { AccessPath } from "#src/access-intent/access-path";
import type { BashProgram } from "#src/access-intent/bash/program";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import { buildPathAskPayload } from "#src/presentation/path-ask-payload";
import { SessionApproval } from "#src/session-approval";
import { deriveApprovalPattern } from "#src/session-rules";
import type { PermissionCheckResult } from "#src/types";
import { pickMostRestrictive } from "./candidate-check";
import type { GateResult } from "./descriptor";
import { accessFactsFromPath } from "./helpers";
import type { ToolCallContext } from "./types";
/**
* Build a pure descriptor for the cross-cutting path permission gate (bash).
*
* Reads path-rule candidates from the injected `BashProgram` (the broader
* `path`-rule filter, accepting dot-files and relative paths). Each candidate
* pairs the raw token with cd-aware policy values; the gate evaluates those
* values against the `path` permission surface and returns the most
* restrictive result, while prompts, logs, and session approvals use the raw
* token.
*
* Returns `null` when the gate does not apply (not a shell invocation, no
* command, no tokens extracted, or all tokens evaluate to `allow`).
* Returns a `GateBypass` when all tokens are session-covered.
* Returns a `GateDescriptor` for the most restrictive token needing a check.
*
* The shell command (native `bash` or an aliased shell tool) is read from the
* injected `BashProgram`, which owns the source text it was parsed from, so
* this gate does not re-derive the input field name (#574).
*/
export function describeBashPathGate(
tcc: ToolCallContext,
bashProgram: BashProgram | null,
resolver: ScopedPermissionResolver,
): GateResult {
if (!bashProgram) return null;
const command = bashProgram.commandText();
const candidates = bashProgram.pathRuleCandidates();
if (candidates.length === 0) return null;
const tokens = candidates.map(({ token }) => token);
// Tokens whose resolved state needs a check (deny/ask), paired with the raw
// token (prompt/decision display) and its `AccessPath` (whose `value()` is
// the lexical absolute path the approval pattern is derived from).
const uncovered: Array<{
token: string;
path: AccessPath;
check: PermissionCheckResult;
}> = [];
let allSessionCovered = true;
for (const { token, path } of candidates) {
const check = resolver.resolve({
kind: "access-path",
surface: "path",
path,
agentName: tcc.agentName ?? undefined,
});
// No explicit path rule matched — only the universal default fired.
// Treat this token as unrestricted to preserve backward compatibility
// for configs without a "path" key (#58).
if (check.matchedPattern === undefined && check.source !== "session") {
allSessionCovered = false;
continue;
}
if (check.source !== "session") {
allSessionCovered = false;
}
if (check.state === "deny") {
uncovered.push({ token, path, check });
break; // Short-circuit on deny.
}
if (check.state === "ask") {
uncovered.push({ token, path, check });
}
}
// All tokens are session-covered — bypass.
if (allSessionCovered) {
return {
action: "allow",
// Every token was covered, each possibly by a different session pattern
// -- the surface is one value and the pattern is not. The entry's
// `tokens` lists what was covered.
decidedBy: {
kind: "session_approval",
surface: "path",
pattern: null,
},
log: {
event: "permission_request.session_approved",
details: {
source: "tool_call",
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
agentName: tcc.agentName,
command,
tokens,
resolution: "session_approved",
},
},
};
}
// Pick the most restrictive (deny > ask > allow, first-wins) uncovered token.
const worstCheck = pickMostRestrictive(uncovered.map(({ check }) => check));
const worstEntry = worstCheck
? uncovered.find(({ check }) => check === worstCheck)
: undefined;
const worstToken = worstEntry?.token ?? null;
// All tokens evaluate to allow — no restriction.
if (!worstCheck || !worstToken || !worstEntry) return null;
// Derive the pattern from the lexical absolute form (the cd-aware resolved
// path), so it matches the values a later call produces. For an unknown base
// (`forLiteral`) `value()` is the raw token.
const pattern = deriveApprovalPattern(worstEntry.path.value());
const payload = buildPathAskPayload({
toolName: tcc.toolName,
pathValue: worstToken,
agentName: tcc.agentName,
matchedPattern: worstCheck.matchedPattern,
});
return {
surface: "path",
input: { path: worstToken },
payload,
sessionApproval: SessionApproval.single("path", pattern),
promptDetails: {
source: "tool_call",
agentName: tcc.agentName,
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
command,
accessIntent: accessFactsFromPath("path", worstEntry.path),
},
logContext: {
source: "tool_call",
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
agentName: tcc.agentName,
command,
path: worstToken,
},
decision: {
surface: "path",
value: worstToken,
},
preCheck: worstCheck,
};
}
@@ -0,0 +1,32 @@
import type { PermissionCheckResult, PermissionState } from "#src/types";
/** Restrictiveness ordering: deny is the most restrictive, allow the least. */
const RESTRICTIVENESS: Record<PermissionState, number> = {
allow: 0,
ask: 1,
deny: 2,
};
/**
* Select the most restrictive permission result from a list (deny > ask > allow).
*
* The first occurrence wins on ties, so a caller passing results in candidate
* order receives the earliest worst case. Returns `undefined` for an empty list.
*
* Shared by the bash gates (path, external-directory) to combine the per-candidate
* `checkPermission` results their tree-sitter token extraction produces.
*/
export function pickMostRestrictive(
results: readonly PermissionCheckResult[],
): PermissionCheckResult | undefined {
let worst: PermissionCheckResult | undefined;
for (const result of results) {
if (
worst === undefined ||
RESTRICTIVENESS[result.state] > RESTRICTIVENESS[worst.state]
) {
worst = result;
}
}
return worst;
}
@@ -0,0 +1,110 @@
import type { DecisionSource } from "#src/authority/decision-source";
import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
import type { PermissionDecisionEvent } from "#src/permission-events";
import type { PromptPayload } from "#src/presentation/prompt-payload";
import type { SessionApproval } from "#src/session-approval";
import type { PermissionCheckResult, PermissionState } from "#src/types";
// ── Descriptor types ───────────────────────────────────────────────────────
/**
* Pure output of a gate function — describes what to check and how to present it.
*
* The gate runner (`runGateCheck`) uses this descriptor to execute the
* mechanical check→log→emit→approve cycle without the gate needing to know
* about logging, event emission, or session-rule recording.
*/
export interface GateDescriptor {
/** Permission surface to check (e.g. "bash", "external_directory", "skill"). */
surface: string;
/** Input passed to checkPermission. */
input: unknown;
/**
* The complete structured description of this ask (ADR 0011 §2).
*
* The descriptor's one presentation fact: every render over it — the dialog,
* the agent-facing denial text, the review log — reads this and nothing
* else, so a gate states its facts once.
*/
payload: PromptPayload;
/**
* Session-approval suggestion for the "for this session" option.
* Wraps either a single pattern or multiple patterns behind a unified
* interface — the runner never needs to know which case applies.
*/
sessionApproval?: SessionApproval;
/**
* Details passed to the interactive permission prompt.
*
* The runner stamps both `requestId` (which it mints) and `payload` (which
* the descriptor owns), so neither is a gate's to supply twice.
*/
promptDetails: Omit<PromptPermissionDetails, "requestId" | "payload">;
/** Extra context fields written to the review log alongside gate outcomes. */
logContext: Record<string, unknown>;
/** Surface and value for the decision event (may differ from the check surface). */
decision: {
surface: string;
value: string;
};
/**
* When set, the gate has already resolved the permission state
* (e.g. from a skill entry match). The runner uses this directly
* instead of calling checkPermission.
*/
preResolved?: {
state: PermissionState;
};
/**
* When set, the runner uses this pre-computed check result directly
* instead of calling checkPermission. Used when the orchestrator has
* already performed the check (e.g. to build messages from the result).
*/
preCheck?: PermissionCheckResult;
}
/**
* A decision event's facts, before the runner stamps the request id it minted.
*
* A gate knows what was decided but not which request it was deciding — the id
* is minted in `GateRunner.run`. Producing this type rather than the full event
* is what routes every emit through the runner's single stamping site.
*/
export type DecisionEventFacts = Omit<PermissionDecisionEvent, "requestId">;
/**
* Early allow result — gate has determined the action without needing the runner.
*
* Used for cases like Pi infrastructure read bypass where the gate short-circuits
* with a deterministic allow before reaching the permission check.
*/
export interface GateBypass {
action: "allow";
/**
* What decided this short-circuit.
*
* The gate that bypasses *is* the decider, so it states its own provenance
* and the runner relays it onto the log entry rather than inferring one from
* the event name (#726). Required, so a bypass added later cannot omit it.
*/
decidedBy: DecisionSource;
/** Optional review log entry to emit. */
log?: { event: string; details: Record<string, unknown> };
/** Optional decision event to emit. */
decision?: DecisionEventFacts;
}
/** Union of possible gate function return values. */
export type GateResult = GateDescriptor | GateBypass | null;
// ── Type guard helpers ─────────────────────────────────────────────────────
/** Check whether a GateResult is a GateBypass (early allow). */
export function isGateBypass(result: GateResult): result is GateBypass {
return result !== null && "action" in result;
}
/** Check whether a GateResult is a GateDescriptor (needs runner). */
export function isGateDescriptor(result: GateResult): result is GateDescriptor {
return result !== null && !("action" in result);
}
@@ -0,0 +1,66 @@
import type { AccessPath } from "#src/access-intent/access-path";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import type { PermissionCheckResult } from "#src/types";
import { pickMostRestrictive } from "./candidate-check";
/** An external path whose resolved `external_directory` state is not "allow". */
export interface UncoveredExternalPath {
path: AccessPath;
check: PermissionCheckResult;
}
/** The uncovered external paths plus the most restrictive check among them. */
export interface UncoveredExternalPaths {
uncovered: UncoveredExternalPath[];
/** Worst check among uncovered paths; `undefined` only when none are uncovered. */
worstCheck: PermissionCheckResult | undefined;
}
/**
* Resolve one external path's policy on the `external_directory` surface.
*
* Emits an `access-path` {@link AccessIntent}; the resolver unwraps it via
* {@link AccessPath.matchValues} so a config pattern on either the typed or
* symlink-resolved alias applies (#418). This is the single source for the
* external-directory resolve that the two external-directory gates previously
* duplicated.
*/
export function resolveExternalDirectoryPolicy(
path: AccessPath,
resolver: ScopedPermissionResolver,
agentName: string | undefined,
): PermissionCheckResult {
return resolver.resolve({
kind: "access-path",
surface: "external_directory",
path,
agentName,
});
}
/**
* Resolve a set of external paths and select those not already allowed.
*
* Each path is resolved via {@link resolveExternalDirectoryPolicy}; entries
* whose state is not "allow" are collected (filtering on state, not source, so
* config-level allow rules suppress the prompt just as session-level allow
* rules do), and the most restrictive uncovered check is returned so a config
* "deny" is not downgraded to the catch-all "ask".
*/
export function selectUncoveredExternalPaths(
paths: readonly AccessPath[],
resolver: ScopedPermissionResolver,
agentName: string | undefined,
): UncoveredExternalPaths {
const uncovered: UncoveredExternalPath[] = [];
for (const path of paths) {
const check = resolveExternalDirectoryPolicy(path, resolver, agentName);
if (check.state !== "allow") {
uncovered.push({ path, check });
}
}
return {
uncovered,
worstCheck: pickMostRestrictive(uncovered.map(({ check }) => check)),
};
}
@@ -0,0 +1,118 @@
import { getToolInputPath } from "#src/access-intent/tool-input-path";
import type { PathNormalizer } from "#src/path-normalizer";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import { buildExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
import { SessionApproval } from "#src/session-approval";
import { deriveApprovalPattern } from "#src/session-rules";
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
import type { GateResult } from "./descriptor";
import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
import { accessFactsFromPath } from "./helpers";
import type { ToolCallContext } from "./types";
/**
* Build a pure descriptor for the external-directory permission gate.
*
* Returns `null` when the gate does not apply (no CWD, tool is not
* path-bearing, or path is inside the working directory).
* Returns a `GateBypass` for Pi infrastructure reads.
* Returns a `GateDescriptor` for external paths needing a permission check.
*/
export function describeExternalDirectoryGate(
tcc: ToolCallContext,
infraDirs: string[],
resolver: ScopedPermissionResolver,
normalizer: PathNormalizer,
extractors?: ToolAccessExtractorLookup,
): GateResult {
const externalDirectoryPath = getToolInputPath(
tcc.toolName,
tcc.input,
extractors,
);
if (!externalDirectoryPath) return null;
if (!normalizer.isOutsideWorkingDirectory(externalDirectoryPath)) {
return null;
}
// The boundary decision (above) and the infrastructure-read containment
// check (below) use the canonical, symlink-resolved path; pattern matching
// uses the typed and resolved aliases (#418).
const accessPath = normalizer.forPath(externalDirectoryPath);
// ── Pi infrastructure read bypass ──────────────────────────────────────
if (normalizer.isInfrastructureRead(tcc.toolName, accessPath, infraDirs)) {
return {
action: "allow",
// Containment allowed this, not a rule the operator wrote.
decidedBy: { kind: "infrastructure_read" },
log: {
event: "permission_request.infrastructure_auto_allowed",
details: {
source: "tool_call",
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
agentName: tcc.agentName,
path: externalDirectoryPath,
},
},
decision: {
surface: tcc.toolName,
value: externalDirectoryPath,
result: "allow",
resolution: "infrastructure_auto_allowed",
origin: null,
agentName: tcc.agentName ?? null,
matchedPattern: null,
},
};
}
// ── Build descriptor for permission check ───────────────────────────────
const resolvedAlias = accessPath.resolvedAlias();
// The runner consumes this preCheck and skips its own resolve.
const preCheck = resolveExternalDirectoryPolicy(
accessPath,
resolver,
tcc.agentName ?? undefined,
);
const pattern = deriveApprovalPattern(accessPath.value());
const payload = buildExternalDirectoryAskPayload({
toolName: tcc.toolName,
pathValue: externalDirectoryPath,
resolvedPath: resolvedAlias,
cwd: tcc.cwd,
agentName: tcc.agentName,
matchedPattern: preCheck.matchedPattern,
});
return {
surface: "external_directory",
input: {},
preCheck,
payload,
sessionApproval: SessionApproval.single("external_directory", pattern),
promptDetails: {
source: "tool_call",
agentName: tcc.agentName,
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
path: externalDirectoryPath,
accessIntent: accessFactsFromPath("external_directory", accessPath),
},
logContext: {
source: "tool_call",
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
agentName: tcc.agentName,
path: externalDirectoryPath,
},
decision: {
surface: "external_directory",
value: externalDirectoryPath,
},
};
}
@@ -0,0 +1,143 @@
import type { AccessPath } from "#src/access-intent/access-path";
import { classifyToolKind } from "#src/access-intent/tool-kind";
import type { ForwardedAccessFacts } from "#src/authority/permission-forwarding";
import type { PermissionDecisionResolution } from "#src/permission-events";
import type { PermissionCheckResult } from "#src/types";
import type { DecisionEventFacts } from "./descriptor";
/**
* Build the child-fixed access facts for a path-shaped gate from its
* `AccessPath`.
*
* Converts the `AccessPath` to strings at the point of emission (ADR-0002: an
* `AccessPath` never crosses onto the wire), carrying the lexical canonical
* match set. An empty `boundaryValue()` (a literal-only path) becomes `null`,
* so the wire distinguishes "no canonical form" cleanly.
*/
export function accessFactsFromPath(
surface: string,
path: AccessPath,
): ForwardedAccessFacts {
return {
surface,
matchValues: path.matchValues(),
boundaryValue: path.boundaryValue() || null,
};
}
/**
* Build the child-fixed access facts for a non-path gate (bash command, MCP
* target, skill name, plain tool) from its already-portable single value.
*/
export function accessFactsFromValue(
surface: string,
value: string,
): ForwardedAccessFacts {
return { surface, matchValues: [value], boundaryValue: null };
}
/**
* Derive the human-readable value for a decision event from a check result.
* Bash → extracted command; MCP → qualified target;
* path-bearing tools → file path; others → tool name.
*/
export function deriveDecisionValue(
toolName: string,
check: Pick<PermissionCheckResult, "command" | "target">,
path?: string,
): string {
switch (classifyToolKind(toolName)) {
case "bash":
return check.command ?? toolName;
case "mcp":
return check.target ?? toolName;
case "path":
case "skill":
case "extension":
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: an empty path falls through to toolName (the original `if (path)` truthiness)
return path || toolName;
}
}
/**
* Build a decision event's facts from the gate's inputs.
*
* Centralises the `origin / agentName / matchedPattern ?? null` normalization
* that is otherwise duplicated across the session-hit path and the gate-result
* path in `runGateCheck`. The request id is stamped by the runner, which is
* where it was minted.
*/
export function buildDecisionEvent(
decision: { surface: string; value: string },
check: Pick<PermissionCheckResult, "origin" | "matchedPattern">,
agentName: string | null,
result: "allow" | "deny",
resolution: PermissionDecisionResolution,
): DecisionEventFacts {
return {
surface: decision.surface,
value: decision.value,
result,
resolution,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ?? null normalises undefined to null for the log record
origin: check.origin ?? null,
agentName: agentName ?? null,
matchedPattern: check.matchedPattern ?? null,
};
}
/**
* Map the gate outcome back to a PermissionDecisionResolution.
*
* @param state - The permission state passed to the gate.
* @param action - The gate's resulting action ("allow" | "block").
* @param hasSession - True when the gate result carries a sessionApproval
* (indicates the user chose "for this session").
* @param confirmationUnavailable - True when the denial came from the
* DenyingAuthorizer (no live authority was reachable).
*/
export function deriveResolution(
state: "allow" | "deny" | "ask",
action: "allow" | "block",
hasSession: boolean,
confirmationUnavailable: boolean,
autoApproved = false,
): PermissionDecisionResolution {
if (state === "allow") return autoApproved ? "auto_approved" : "policy_allow";
if (state === "deny") return "policy_deny";
// state === "ask"
if (action === "allow") {
if (autoApproved) return "auto_approved";
return hasSession ? "user_approved_for_session" : "user_approved";
}
return confirmationUnavailable ? "confirmation_unavailable" : "user_denied";
}
/**
* The standing yolo grant covering a gate's resolved check, or `null` when
* yolo does not answer it.
*
* yolo is primarily recorded authority: `rewriteAsksToYolo` turns every `ask`
* rule into an `allow` tagged `origin: "yolo"` at composition (#526), and the
* first arm recognizes that grant. The second arm covers an `ask` synthesized
* *after* resolution — the bash wrapper floor (#481, #490) and the fail-closed
* `<unparseable-bash-command>` sentinel (#452) — which the ruleset rewrite
* cannot reach because the floor is a property of a parsed command unit, not of
* a pattern (#712). The synthetic `matchedPattern` is preserved so the review
* log still shows why the ask was raised, while `origin: "yolo"` records why it
* was granted.
*
* A `deny` matches neither arm, so an explicit deny survives yolo.
*/
export function resolveYoloGrant(
check: PermissionCheckResult,
yoloEnabled: boolean,
): PermissionCheckResult | null {
if (check.state === "allow" && check.origin === "yolo") {
return check;
}
if (check.state === "ask" && yoloEnabled) {
return { ...check, state: "allow", origin: "yolo" };
}
return null;
}
@@ -0,0 +1,86 @@
import { getToolInputPath } from "#src/access-intent/tool-input-path";
import type { PathNormalizer } from "#src/path-normalizer";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import { buildPathAskPayload } from "#src/presentation/path-ask-payload";
import { SessionApproval } from "#src/session-approval";
import { deriveApprovalPattern } from "#src/session-rules";
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
import type { GateDescriptor, GateResult } from "./descriptor";
import { accessFactsFromPath } from "./helpers";
import type { ToolCallContext } from "./types";
/**
* Build a pure descriptor for the cross-cutting path permission gate (tools).
*
* Returns `null` when the gate does not apply (tool is not path-bearing,
* no extractable path, the `path` surface evaluates to `allow`, or no
* explicit `path` rule matched — i.e. only the universal default fired).
* Returns a `GateDescriptor` when the path matches a `deny` or `ask` rule.
*/
export function describePathGate(
tcc: ToolCallContext,
resolver: ScopedPermissionResolver,
normalizer: PathNormalizer,
extractors?: ToolAccessExtractorLookup,
): GateResult {
const filePath = getToolInputPath(tcc.toolName, tcc.input, extractors);
if (!filePath) return null;
// Emit an access-path intent so the resolver matches the lexical aliases
// *and* the canonical (symlink-resolved) form, the same set
// `external_directory` matches (#418, #486).
const accessPath = normalizer.forPath(filePath);
const check = resolver.resolve({
kind: "access-path",
surface: "path",
path: accessPath,
agentName: tcc.agentName ?? undefined,
});
if (check.state === "allow") return null;
// No explicit path rule matched — only the universal default fired.
// Skip the gate to preserve backward compatibility: configs without a
// "path" key should not trigger path-level prompts (#58).
if (check.matchedPattern === undefined) return null;
// Derive the approval pattern from the lexical absolute form so it matches
// the policy values a later call produces.
const pattern = deriveApprovalPattern(accessPath.value());
const payload = buildPathAskPayload({
toolName: tcc.toolName,
pathValue: filePath,
agentName: tcc.agentName,
matchedPattern: check.matchedPattern,
});
const descriptor: GateDescriptor = {
surface: "path",
input: { path: filePath },
payload,
sessionApproval: SessionApproval.single("path", pattern),
promptDetails: {
source: "tool_call",
agentName: tcc.agentName,
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
path: filePath,
accessIntent: accessFactsFromPath("path", accessPath),
},
logContext: {
source: "tool_call",
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
agentName: tcc.agentName,
path: filePath,
},
decision: {
surface: "path",
value: filePath,
},
preCheck: check,
};
return descriptor;
}
@@ -0,0 +1,262 @@
import type { AskEscalator } from "#src/authority/authorizer-selection";
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
import type { DecisionReporter } from "#src/decision-reporter";
import { applyPermissionGate } from "#src/permission-gate";
import { createPermissionRequestId } from "#src/permission-request-id";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import {
renderPolicyDenial,
renderUnavailableDenial,
renderUserDenial,
} from "#src/presentation/agent-renderer";
import { renderReviewLogFacts } from "#src/presentation/review-log-renderer";
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
import type { PermissionCheckResult } from "#src/types";
import type {
DecisionEventFacts,
GateDescriptor,
GateResult,
} from "./descriptor";
import { isGateBypass } from "./descriptor";
import {
buildDecisionEvent,
deriveResolution,
resolveYoloGrant,
} from "./helpers";
import type { GateOutcome } from "./types";
// ── GateRunner class ───────────────────────────────────────────────────────
/**
* Executes permission gate checks for a single gate result (null, bypass, or
* descriptor).
*
* Constructed once per handler with its four role collaborators and reused
* for every gate in a tool-call pipeline. The `run` method absorbs the null /
* bypass / descriptor dispatch that previously lived as an anonymous closure
* in `PermissionGateHandler.handleToolCall`.
*/
export class GateRunner {
constructor(
private readonly resolver: ScopedPermissionResolver,
private readonly recorder: SessionApprovalRecorder,
private readonly prompter: AskEscalator,
private readonly reporter: DecisionReporter,
/**
* Live yolo reader, read per gate so a mid-session config change takes
* effect — the same closure `PermissionManager` receives.
*/
private readonly isYoloEnabled: () => boolean,
) {}
/**
* Execute a gate: null → allow; bypass → log/emit side effects then allow;
* descriptor → full check→log→emit→approve cycle.
*
* The request id is minted here, before the branch, so a request that never
* prompts is identified exactly as one that does.
*/
async run(gate: GateResult, agentName: string | null): Promise<GateOutcome> {
if (!gate) {
return { action: "allow" };
}
const requestId = createPermissionRequestId();
if (isGateBypass(gate)) {
if (gate.log) {
this.reporter.writeReviewLog(gate.log.event, {
...gate.log.details,
requestId,
decidedBy: gate.decidedBy,
});
}
if (gate.decision) {
this.emitDecision(requestId, gate.decision);
}
return { action: "allow" };
}
return this.runDescriptor(gate, agentName, requestId);
}
// ── Private helpers ──────────────────────────────────────────────────────
/**
* The one place a decision event acquires its request id, so no emit path
* can be added that forgets it.
*/
private emitDecision(requestId: string, facts: DecisionEventFacts): void {
this.reporter.emitDecision({ requestId, ...facts });
}
private async runDescriptor(
descriptor: GateDescriptor,
agentName: string | null,
requestId: string,
): Promise<GateOutcome> {
// 1. Resolve permission state — pre-check, pre-resolved, or via resolver
let check: PermissionCheckResult;
if (descriptor.preCheck) {
check = descriptor.preCheck;
} else if (descriptor.preResolved) {
check = {
state: descriptor.preResolved.state,
toolName: descriptor.surface,
source: "tool",
origin: "builtin",
};
} else {
check = this.resolver.resolve({
kind: "tool",
surface: descriptor.surface,
input: descriptor.input,
agentName: agentName ?? undefined,
});
}
// The fields every review-log write for this gate shares, whatever the
// resolution — built once so a field added here reaches all of them. The
// payload's request facts are stamped here rather than by each gate, for
// the same reason `requestId` is: a gate cannot forget what it never
// supplies (ADR 0011 §6).
const logContext = {
...descriptor.logContext,
...renderReviewLogFacts(descriptor.payload),
agentName,
requestId,
};
// Each resolution below states its own decider. The provenance is built
// at the branch that decides rather than merged into `logContext`: that
// context holds what every resolution of this gate shares, and who decided
// is by definition not shared (#726).
// 2. Session-hit fast path
if (check.source === "session") {
this.reporter.writeReviewLog("permission_request.session_approved", {
...logContext,
resolution: "session_approved",
sessionApprovalPattern: check.matchedPattern,
decidedBy: {
kind: "session_approval",
surface: descriptor.surface,
pattern: check.matchedPattern ?? null,
},
});
this.emitDecision(
requestId,
buildDecisionEvent(
descriptor.decision,
check,
agentName,
"allow",
"session_approved",
),
);
return { action: "allow" };
}
// 2b. Yolo fast-path — the composition-stage ask→allow rewrite (origin
// "yolo" on the matched rule, #526) or, under yolo, an ask synthesized
// after resolution (#712). Auto-approve without prompting, preserving the
// single auto_approved review entry + decision event so log parity holds.
const yoloGrant = resolveYoloGrant(check, this.isYoloEnabled());
if (yoloGrant) {
this.reporter.writeReviewLog("permission_request.auto_approved", {
...logContext,
resolution: "auto_approved",
// The pattern that raised the ask, sentinel included: "yolo allowed
// it" alone does not say why it was asked in the first place.
decidedBy: { kind: "yolo", pattern: check.matchedPattern ?? null },
});
this.emitDecision(
requestId,
buildDecisionEvent(
descriptor.decision,
yoloGrant,
agentName,
"allow",
deriveResolution(yoloGrant.state, "allow", false, false, true),
),
);
return { action: "allow" };
}
// 3. Apply the deny/ask/allow gate — always escalate on ask; the selected
// Authorizer answers (the DenyingAuthorizer by denying with a marker).
// The agent-facing renders of this ask. The rule reason is the operator's
// deny-with-reason text, which lives on the resolved check rather than the
// payload: no human render wants it, because a deny never prompts.
const { payload } = descriptor;
const messages = {
denyReason: renderPolicyDenial(payload, check.reason ?? null),
unavailableReason: (decision: PermissionPromptDecision) =>
renderUnavailableDenial(payload, decision.denialReason ?? null),
userDeniedReason: (decision: PermissionPromptDecision) =>
renderUserDenial(payload, decision.denialReason ?? null),
};
let autoApproved = false;
let confirmationUnavailable = false;
const gateResult = await applyPermissionGate({
state: check.state,
sessionApproval: descriptor.sessionApproval?.toGateApproval(),
promptForApproval: async () => {
const decision = await this.prompter.escalate({
requestId,
payload,
...descriptor.promptDetails,
...(descriptor.sessionApproval
? { sessionApproval: descriptor.sessionApproval.toForwardedData() }
: {}),
});
autoApproved = decision.autoApproved === true;
confirmationUnavailable = decision.confirmationUnavailable === true;
return decision;
},
writeLog: (event, details) =>
this.reporter.writeReviewLog(event, details),
logContext,
decidedByRule: {
kind: "rule",
surface: descriptor.surface,
pattern: check.matchedPattern ?? null,
origin: check.origin,
},
messages,
});
// 4. Determine whether session approval was granted
const hasSessionApproval =
gateResult.action === "allow" && gateResult.sessionApproval !== undefined;
// 5. Emit decision event
this.emitDecision(
requestId,
buildDecisionEvent(
descriptor.decision,
check,
agentName,
gateResult.action === "allow" ? "allow" : "deny",
deriveResolution(
check.state,
gateResult.action,
hasSessionApproval,
confirmationUnavailable,
autoApproved,
),
),
);
// 6. Record session approval — tell the store; it owns the per-pattern loop
// hasSessionApproval already implies gateResult.action === "allow"
if (hasSessionApproval && descriptor.sessionApproval) {
this.recorder.recordSessionApproval(descriptor.sessionApproval);
}
if (gateResult.action === "block") {
return { action: "block", reason: gateResult.reason };
}
return { action: "allow" };
}
}
@@ -0,0 +1,93 @@
import type { PermissionCheckResult } from "#src/types";
import type { GateRunner } from "./runner";
import { describeSkillInputGate } from "./skill-input";
import type { GateOutcome } from "./types";
// ── Interfaces ────────────────────────────────────────────────────────────────
/**
* Narrow interface the pipeline needs from its session-side dependency.
*
* A raw `checkPermission` (no session rules) — preserves the skill-input
* semantics established in #326 where the skill-input gate intentionally
* bypasses session-rule resolution.
*
* `PermissionSession` satisfies this structurally at the construction call
* site; no `implements` clause is needed and would create a layer-inversion
* import from the domain module into the handler layer.
*/
export interface SkillInputGateInputs {
checkPermission(
surface: string,
input: unknown,
agentName?: string,
): PermissionCheckResult;
}
/**
* Narrow UI seam: warn the user when a skill is denied.
*
* The handler builds this per-event from `ctx`, encapsulating the `hasUI`
* guard so the pipeline never touches `ExtensionContext` directly
* (Tell-Don't-Ask: the pipeline tells the notifier to warn; the notifier
* decides whether a UI is present).
*/
export interface GateNotifier {
warn(message: string): void;
}
// ── Pipeline ─────────────────────────────────────────────────────────────────
/**
* Owns the skill-input gate assembly: raw permission pre-check, deny notify,
* `describeSkillInputGate` descriptor, and `runner.run(...)`.
*
* Constructed once in the composition root and injected into
* `PermissionGateHandler`, mirroring `ToolCallGatePipeline` for the `input`
* path.
*
* `evaluate` is not `async` because it has no `await` of its own — it returns
* `runner.run(...)` directly (`@typescript-eslint/require-await` would reject
* an `async` body with no `await`).
*/
export class SkillInputGatePipeline {
constructor(private readonly inputs: SkillInputGateInputs) {}
evaluate(
skillName: string,
agentName: string | null,
notifier: GateNotifier,
runner: GateRunner,
): Promise<GateOutcome> {
const check = this.inputs.checkPermission(
"skill",
{ name: skillName },
agentName ?? undefined,
);
if (check.state === "deny") {
notifier.warn(formatSkillDenyNotice(skillName, agentName));
}
return runner.run(
describeSkillInputGate(skillName, agentName, check),
agentName,
);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* Format the deny warning shown in the UI when a skill is blocked.
*
* Intentionally untagged (no `[pi-permission-system]` prefix) — this is a
* UI notify distinct from the agent-facing deny reasons the runner routes
* through `renderPolicyDenial`.
*/
export function formatSkillDenyNotice(
skillName: string,
agentName: string | null,
): string {
return agentName
? `Skill '${skillName}' is not permitted for agent '${agentName}'.`
: `Skill '${skillName}' is not permitted by the current skill policy.`;
}
@@ -0,0 +1,40 @@
import { buildSkillAskPayload } from "#src/presentation/skill-ask-payload";
import type { PermissionCheckResult } from "#src/types";
import type { GateDescriptor } from "./descriptor";
import { accessFactsFromValue } from "./helpers";
/**
* Build a pure descriptor for the skill-input permission gate.
*
* Takes the pre-computed check result so the gate can reuse the result the
* caller already obtained (e.g. to conditionally emit a deny warning) without
* re-running the check inside the runner.
*/
export function describeSkillInputGate(
skillName: string,
agentName: string | null,
preCheck: PermissionCheckResult,
): GateDescriptor {
const payload = buildSkillAskPayload(skillName, agentName);
return {
surface: "skill",
input: { name: skillName },
preCheck,
payload,
promptDetails: {
source: "skill_input",
agentName,
skillName,
accessIntent: accessFactsFromValue("skill", skillName),
},
logContext: {
source: "skill_input",
skillName,
agentName,
},
decision: {
surface: "skill",
value: skillName,
},
};
}
@@ -0,0 +1,75 @@
import type { PathNormalizer } from "#src/path-normalizer";
import { buildSkillPathAskPayload } from "#src/presentation/skill-ask-payload";
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
import { findSkillPathMatch } from "#src/skill-prompt-sanitizer";
import { toRecord } from "#src/value-guards";
import type { GateDescriptor } from "./descriptor";
import { accessFactsFromValue } from "./helpers";
import type { ToolCallContext } from "./types";
/**
* Build a pure descriptor for the skill-read permission gate.
*
* Returns `null` when the gate does not apply (tool is not `read`, no active
* skill entries, or the read path does not match any skill).
* Returns a GateDescriptor with preResolved state from the matched skill entry.
*/
export function describeSkillReadGate(
tcc: ToolCallContext,
normalizer: PathNormalizer,
getActiveSkillEntries: () => SkillPromptEntry[],
): GateDescriptor | null {
const activeSkillEntries = getActiveSkillEntries();
if (tcc.toolName !== "read" || activeSkillEntries.length === 0) {
return null;
}
const inputRecord = toRecord(tcc.input);
const path = typeof inputRecord.path === "string" ? inputRecord.path : "";
if (!path) {
return null;
}
const normalizedReadPath = normalizer.comparableValue(path);
const matchedSkill = findSkillPathMatch(
normalizedReadPath,
activeSkillEntries,
normalizer,
);
if (!matchedSkill) {
return null;
}
const payload = buildSkillPathAskPayload(matchedSkill, path, tcc.agentName);
return {
surface: "skill",
input: { name: matchedSkill.name },
payload,
promptDetails: {
source: "skill_read",
agentName: tcc.agentName,
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
skillName: matchedSkill.name,
path,
accessIntent: accessFactsFromValue("skill", matchedSkill.name),
},
logContext: {
source: "skill_read",
toolCallId: tcc.toolCallId,
skillName: matchedSkill.name,
agentName: tcc.agentName,
path,
},
decision: {
surface: "skill",
value: matchedSkill.name,
},
preResolved: {
state: matchedSkill.state,
},
};
}
@@ -0,0 +1,212 @@
import type { AccessPath } from "#src/access-intent/access-path";
import { BashProgram } from "#src/access-intent/bash/program";
import { getPathBearingToolPath } from "#src/access-intent/tool-input-path";
import {
resolveShellInvocation,
type ShellInvocation,
} from "#src/access-intent/tool-kind";
import type { ShellToolsConfig } from "#src/config-schema";
import type { PathNormalizer } from "#src/path-normalizer";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
import type { ToolInputFormatterLookup } from "#src/tool-input-formatter-registry";
import {
ToolPreviewFormatter,
type ToolPreviewFormatterOptions,
} from "#src/tool-preview-formatter";
import type { PermissionCheckResult } from "#src/types";
import { resolveBashCommandCheck } from "./bash-command";
import { describeBashExternalDirectoryGate } from "./bash-external-directory";
import { describeBashPathGate } from "./bash-path";
import type { GateResult } from "./descriptor";
import { describeExternalDirectoryGate } from "./external-directory";
import { describePathGate } from "./path";
import type { GateRunner } from "./runner";
import { describeSkillReadGate } from "./skill-read";
import { describeToolGate } from "./tool";
import type { GateOutcome, ToolCallContext } from "./types";
/**
* Narrow interface the pipeline needs from its session-side dependency.
*
* The three query methods needed to assemble gate inputs.
* The resolver is injected separately as a constructor parameter.
*
* `PermissionSession` satisfies this structurally at the construction call
* site; no `implements` clause is needed and would create a layer-inversion
* import from the domain module into the handler layer.
*/
export interface ToolCallGateInputs {
/** Active skill prompt entries for the skill-read gate. */
getActiveSkillEntries(): SkillPromptEntry[];
/** Combined infrastructure read directories (static + config-derived). */
getInfrastructureReadDirs(): string[];
/** Resolved tool-preview formatter options from the current config. */
getToolPreviewLimits(): ToolPreviewFormatterOptions;
/** The session's path normalizer (platform + cwd baked in). */
getPathNormalizer(): PathNormalizer;
/**
* The configured shell-tool aliases (`shellTools`), or `undefined` when none
* are set. Consulted by {@link resolveShellInvocation} so an aliased shell
* tool is gated through the bash stack at parity with native `bash` (#574).
*/
getShellToolAliases(): ShellToolsConfig | undefined;
}
/**
* Owns the ordered tool-call gate-producer assembly and the run loop.
*
* Constructed once in the composition root and injected into
* `PermissionGateHandler`. `evaluate(tcc, runner)` encapsulates:
* - bash-command extraction and single `BashProgram.parse` (#308)
* - `ToolPreviewFormatter` construction from `getToolPreviewLimits()`
* - infrastructure-dir list from `getInfrastructureReadDirs()`
* - all six gate producers in their prescribed order
* - the run loop that returns the first block outcome, or allow
*/
export class ToolCallGatePipeline {
constructor(
private readonly resolver: ScopedPermissionResolver,
private readonly inputs: ToolCallGateInputs,
private readonly customFormatters?: ToolInputFormatterLookup,
private readonly customExtractors?: ToolAccessExtractorLookup,
) {}
async evaluate(
tcc: ToolCallContext,
runner: GateRunner,
): Promise<GateOutcome> {
// Resolve the shell invocation once: native `bash` and any tool recorded in
// `shellTools` both yield a command (+ optional workdir); every other tool
// yields null (#574). The three bash gates then share the single BashProgram
// parsed from that command instead of each re-parsing (#308).
const shell = resolveShellInvocation(
tcc.toolName,
tcc.input,
this.inputs.getShellToolAliases(),
);
const normalizer = this.inputs.getPathNormalizer();
const bashProgram = shell?.command
? await BashProgram.parse(shell.command, normalizer, {
workdir: shell.workdir,
})
: null;
const formatter = new ToolPreviewFormatter(
this.inputs.getToolPreviewLimits(),
this.customFormatters,
);
const infraDirs = this.inputs.getInfrastructureReadDirs();
const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
() =>
describeSkillReadGate(tcc, normalizer, () =>
this.inputs.getActiveSkillEntries(),
),
() =>
describePathGate(tcc, this.resolver, normalizer, this.customExtractors),
() =>
describeExternalDirectoryGate(
tcc,
infraDirs,
this.resolver,
normalizer,
this.customExtractors,
),
() => describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver),
() => describeBashPathGate(tcc, bashProgram, this.resolver),
() => {
const { toolCheck, accessPath } = this.resolvePerToolCheck(
tcc,
shell,
bashProgram,
normalizer,
);
const toolDescriptor = describeToolGate(
tcc,
toolCheck,
formatter,
accessPath,
shell,
);
toolDescriptor.preCheck = toolCheck;
return toolDescriptor;
},
];
for (const produce of gateProducers) {
const outcome = await runner.run(await produce(), tcc.agentName);
if (outcome.action === "block") {
return outcome;
}
}
return { action: "allow" };
}
/**
* Resolve the per-tool gate's check, choosing the intent by tool shape:
* bash chains its sub-commands; a path-bearing tool with a path emits an
* `access-path` intent (so the per-tool surface matches lexical canonical,
* #502); every other tool (and a path-bearing tool with no path) keeps the
* raw `tool` intent the manager normalizes.
*
* Returns the `AccessPath` alongside the check so `describeToolGate` derives
* the session-approval value from `accessPath.value()`.
*/
private resolvePerToolCheck(
tcc: ToolCallContext,
shell: ShellInvocation | null,
bashProgram: BashProgram | null,
normalizer: PathNormalizer,
): { toolCheck: PermissionCheckResult; accessPath?: AccessPath } {
if (shell) {
if (bashProgram) {
return {
toolCheck: resolveBashCommandCheck(
bashProgram.commandText(),
bashProgram.commands(),
tcc.agentName ?? undefined,
this.resolver,
),
};
}
// A shell invocation whose command did not parse (e.g. empty) still
// resolves on the `bash` surface, so an aliased tool never falls through
// to its own extension-tool surface.
return {
toolCheck: this.resolver.resolve({
kind: "tool",
surface: "bash",
input: { command: shell.command },
agentName: tcc.agentName ?? undefined,
}),
};
}
const filePath = getPathBearingToolPath(tcc.toolName, tcc.input);
if (filePath !== null) {
const accessPath = normalizer.forPath(filePath);
return {
accessPath,
toolCheck: this.resolver.resolve({
kind: "access-path",
surface: tcc.toolName,
path: accessPath,
agentName: tcc.agentName ?? undefined,
}),
};
}
return {
toolCheck: this.resolver.resolve({
kind: "tool",
surface: tcc.toolName,
input: tcc.input,
agentName: tcc.agentName ?? undefined,
}),
};
}
}
@@ -0,0 +1,124 @@
import type { AccessPath } from "#src/access-intent/access-path";
import { PATH_BEARING_TOOLS } from "#src/access-intent/path-surfaces";
import { getPathBearingToolPath } from "#src/access-intent/tool-input-path";
import {
classifyToolKind,
type ShellInvocation,
} from "#src/access-intent/tool-kind";
import { suggestSessionPattern } from "#src/pattern-suggest";
import { buildToolAskPayload } from "#src/presentation/tool-ask-payload";
import { SessionApproval } from "#src/session-approval";
import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
import type { PermissionCheckResult } from "#src/types";
import type { GateDescriptor } from "./descriptor";
import {
accessFactsFromPath,
accessFactsFromValue,
deriveDecisionValue,
} from "./helpers";
import type { ToolCallContext } from "./types";
/**
* Derive the value used for session-approval pattern suggestions.
*
* Bash → command string; MCP → qualified target;
* path-bearing tools → the `AccessPath`'s lexical absolute form (`value()`),
* so the suggested pattern matches the policy values a later call produces;
* others (or a path-bearing tool with no path) → catch-all wildcard.
*/
function deriveSuggestionValue(
toolName: string,
check: PermissionCheckResult,
accessPath?: AccessPath,
): string {
switch (classifyToolKind(toolName)) {
case "bash":
return check.command ?? "";
case "mcp":
return check.target ?? "mcp";
default:
return accessPath ? accessPath.value() : "*";
}
}
/**
* Build a pure descriptor for the normal tool permission gate.
*
* Takes a pre-computed PermissionCheckResult (from checkPermission) and
* returns a GateDescriptor that the runner can execute. No side effects.
*/
export function describeToolGate(
tcc: ToolCallContext,
check: PermissionCheckResult,
formatter: ToolPreviewFormatter,
accessPath?: AccessPath,
shell?: ShellInvocation | null,
): GateDescriptor {
// A shell invocation (native `bash` or an aliased shell tool) is gated on the
// `bash` surface — its session rule, decision value, and suggestion are
// bash-shaped — while the invoked tool name is preserved in the prompt and
// review log so a user sees which tool actually ran (#574).
const gateSurface = shell ? "bash" : tcc.toolName;
const permissionLogContext = formatter.getPermissionLogContext(
check,
tcc.input,
PATH_BEARING_TOOLS,
);
// Compute session approval suggestion for the "for this session" option.
const suggestion = suggestSessionPattern(
gateSurface,
deriveSuggestionValue(gateSurface, check, accessPath),
);
const payload = buildToolAskPayload({
check,
agentName: tcc.agentName,
surface: gateSurface,
invokedToolName: tcc.toolName,
input: tcc.input,
formatter,
});
const decisionValue = deriveDecisionValue(
gateSurface,
check,
getPathBearingToolPath(tcc.toolName, tcc.input) ?? undefined,
);
// A path-bearing tool carries the AccessPath's alias set; every other surface
// (bash command, MCP target, plain tool) carries its already-portable value.
const accessIntent = accessPath
? accessFactsFromPath(gateSurface, accessPath)
: accessFactsFromValue(gateSurface, decisionValue);
return {
surface: gateSurface,
input: tcc.input,
payload,
sessionApproval: SessionApproval.single(
suggestion.surface,
suggestion.pattern,
),
promptDetails: {
source: "tool_call",
agentName: tcc.agentName,
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
sessionLabel: suggestion.label,
accessIntent,
...permissionLogContext,
},
logContext: {
source: "tool_call",
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
...permissionLogContext,
},
decision: {
surface: gateSurface,
value: decisionValue,
},
};
}
@@ -0,0 +1,13 @@
/** Outcome of a single permission gate evaluation. */
export type GateOutcome =
| { action: "allow" }
| { action: "block"; reason: string };
/** Pre-validated context shared across all gates. */
export interface ToolCallContext {
toolName: string;
agentName: string | null;
input: unknown;
toolCallId: string;
cwd: string;
}