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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user