import { stripBashCommentLines } from "#src/bash-arity"; import type { PathNormalizer } from "#src/path-normalizer"; import { getNonEmptyString, toRecord } from "#src/value-guards"; import type { AccessIntent, ResolvedAccessIntent } from "./access-intent"; import { createMcpPermissionTargets } from "./mcp-targets"; import { PATH_SURFACES } from "./path-surfaces"; import { classifyToolKind } from "./tool-kind"; /** * Build the {@link AccessIntent} an external policy query (the `Symbol.for()` * service and the event-bus RPC) feeds to the resolver from a `(surface, value)` * pair. * * For a path-shaped surface (`path`, `external_directory`, or a path-bearing * tool) carrying a non-empty value, it builds an `AccessPath` and emits an * `access-path` intent, so the resolver matches the lexical aliases ∪ canonical * (symlink-resolved) set — at parity with the gates (#486, #502). Every other * surface, and any value-less surface-level query, keeps the `tool` intent so * the manager's `normalizeInput` `["*"]` fallback is preserved. */ export function buildAccessIntentForSurface( surface: string, value: string | undefined, normalizer: PathNormalizer, agentName: string | undefined, ): AccessIntent { const pathValue = getNonEmptyString(value); if (pathValue !== null && PATH_SURFACES.has(surface)) { return { kind: "access-path", surface, path: normalizer.forPath(pathValue), agentName, }; } return { kind: "tool", surface, input: buildInputForSurface(surface, value), agentName, }; } /** * Build a {@link ResolvedAccessIntent} directly from a forwarded request's * child-fixed match values (ADR 0008 §2), for the forwarded-serving wire * (#597). * * Unlike {@link buildAccessIntentForSurface}, this never touches a * `PathNormalizer` and never rebuilds an `AccessPath` — a path-shaped surface * gets a `path-values` intent carrying `matchValues` as-is (the values the * child already fixed), and every other surface gets a `tool` intent built * from its single portable value. `agentName` is always the requester's * `principal.agentName` (ADR 0008 §3, agent-scoped serving). */ export function buildResolvedIntentFromMatchValues( surface: string, matchValues: readonly string[], agentName: string, ): ResolvedAccessIntent { if (PATH_SURFACES.has(surface)) { return { kind: "path-values", surface, values: [...matchValues], agentName, }; } return { kind: "tool", surface, input: buildInputForSurface(surface, matchValues[0]), agentName, }; } /** * Construct a surface-appropriate input object from a raw value string for the * `tool`-intent branch of {@link buildAccessIntentForSurface} (the non-path * surfaces and value-less path queries). * * This is the inverse of `normalizeInput()` — it builds the minimal input * object that the manager expects for a given surface, from a single string * value. * * Note: MCP inputs are complex (server name + tool name derivation). Callers * providing an MCP surface receive a best-effort policy evaluation using the * value as a pre-qualified target string. Pass the fully-qualified target * (e.g. "exa:search" or "exa") directly. */ function buildInputForSurface( surface: string, value: string | undefined, ): unknown { const v = value ?? ""; if (surface === "bash") return { command: v }; if (surface === "skill") return { name: v }; if (surface === "external_directory") return { path: v }; // MCP and tool surfaces: normalizeInput handles them from the surface alone. return {}; } /** * Surface-normalized representation of a tool invocation used by * `checkPermission()` to feed a single `evaluateFirst()` call. */ export interface NormalizedInput { /** The permission surface for `evaluate()` (e.g. "bash", "mcp", "skill"). */ surface: string; /** * Candidate lookup values in priority order (most-specific first). * Most surfaces produce a single-element array; MCP produces a * multi-candidate list derived from the invocation input. */ values: string[]; /** * Surface-specific fields forwarded verbatim into `PermissionCheckResult` * (e.g. `{ command }` for bash, `{ target }` for mcp). */ resultExtras: Record; } /** * Map a raw tool invocation to the surface/values/extras triple needed by * `checkPermission()`. * * Handles bash, skill, mcp, and extension surfaces. Path-bearing tool surfaces * (`path`, `external_directory`, `read`, `write`, `edit`, `grep`, `find`, * `ls`) now route through the access-path gate (#502) and service/RPC builder * (#503) before reaching the manager, so they never arrive here with a real * path value — all fall through to the extension catch-all `["*"]`. * * @param toolName - Normalized (trimmed) tool name from the tool-call event. * @param input - Raw input payload from the tool-call event. * @param configuredMcpServerNames - Ordered list of MCP server names from the * global MCP config, used to derive server-qualified MCP targets. */ export function normalizeInput( toolName: string, input: unknown, configuredMcpServerNames: readonly string[], ): NormalizedInput { switch (classifyToolKind(toolName)) { // --- Skill --- case "skill": { const record = toRecord(input); const skillName = record.name; const lookupValue = typeof skillName === "string" ? skillName : "*"; return { surface: "skill", values: [lookupValue], resultExtras: {}, }; } // --- Bash --- case "bash": { const record = toRecord(input); const command = typeof record.command === "string" ? record.command : ""; // Strip leading shell comment lines so pattern matching operates on the // actual command, not a `# description` prefix agents often prepend. // Fall back to the raw command when stripping leaves nothing, so an // all-comment command still evaluates against its literal text. const matchValue = stripBashCommentLines(command) || command; return { surface: "bash", values: [matchValue], resultExtras: { command }, }; } // --- MCP --- case "mcp": { const mcpTargets = [ ...createMcpPermissionTargets(input, configuredMcpServerNames), "mcp", ]; const fallbackTarget = mcpTargets[0] ?? "mcp"; return { surface: "mcp", values: mcpTargets, resultExtras: { target: fallbackTarget }, }; } // --- All other surfaces (path-bearing tools and extension tools) --- // Path-bearing tools with a present path never reach here — the gate emits // an access-path intent (#502). Missing-path and extension-tool cases both // collapse to the surface catch-all. case "path": case "extension": return { surface: toolName, values: ["*"], resultExtras: {}, }; } }