Files
my-pi/pi-permission-system/src/access-intent/tool-kind.ts
T

117 lines
4.7 KiB
TypeScript

import type { ShellToolsConfig } from "#src/config-schema";
import { getNonEmptyString, toRecord } from "#src/value-guards";
import { PATH_BEARING_TOOLS } from "./path-surfaces";
/**
* What a tool invocation accesses — decided once from the tool name at the
* point an invocation enters the system.
*
* This is the single dispatch point that replaces the scattered
* `toolName === "bash"`/`"mcp"` re-derivation across the extraction consumers
* (`input-normalizer`, `tool-input-path`, the tool-call gate pipeline, and
* `permission-manager`'s source derivation) and the presentation consumers
* (`tool-preview-formatter`, `permission-prompts`, the payload builders, and
* `deriveDecisionValue`), which dispatch on {@link classifyToolKind} or
* {@link isMcpCheck}. Adding a tool kind means editing {@link classifyToolKind}
* plus the exhaustive switches the compiler flags — an OCP win over silent
* `===` comparisons a new variant sails past (#561).
*
* The value is plain data (a string union): `tool-kind.ts` imports no
* `AccessPath`, so `permission-manager.ts` may consume it without breaching the
* string boundary formalized in ADR-0002
* (`docs/decisions/0002-path-values-string-boundary.md`).
*
* - `bash` — its own token-based path gates; extraction product is the command.
* - `mcp` — extraction product is the qualified target.
* - `skill` — a distinct surface `normalizeInput`/`deriveSource` treat specially.
* - `path` — a path-bearing built-in (`read`/`write`/`edit`/`grep`/`find`/`ls`);
* extraction product is `input.path`.
* - `extension` — every other tool, plus the `path` special surface that
* reaches `deriveSource` as a normalized name.
*/
export type ToolKind = "bash" | "mcp" | "skill" | "path" | "extension";
/** Classify a tool name into its {@link ToolKind}. */
export function classifyToolKind(toolName: string): ToolKind {
const name = toolName.trim();
if (name === "bash") return "bash";
if (name === "mcp") return "mcp";
if (name === "skill") return "skill";
if (PATH_BEARING_TOOLS.has(name)) return "path";
return "extension";
}
/** A shell invocation's effective command and optional working directory. */
export interface ShellInvocation {
/** The shell command string to decompose and gate. */
command: string;
/** The working directory the command runs in, if the tool projects one. */
workdir: string | undefined;
/** Optional minimum review decision imposed by a configured shell alias. */
decisionFloor?: ShellToolsConfig[string]["decisionFloor"];
}
/**
* Decide whether a tool invocation carries shell semantics, and if so extract
* its command and working directory.
*
* Native `bash` and any tool recorded in `shellTools` both yield a
* {@link ShellInvocation}; every other tool yields `null`. This is the single
* dispatch point the bash gate pipeline consults instead of re-deriving
* `toolName === "bash"` and reading `input.command`, so an aliased shell tool
* (e.g. `@howaboua/pi-codex-conversion`'s `exec_command`) is routed through the
* same bash enforcement stack as native `bash` (#574).
*
* The command and workdir are read through {@link getNonEmptyString} (trimmed,
* empty → `""`/`undefined`), matching the pipeline's existing native-bash
* extraction. Kept separate from {@link classifyToolKind} because it needs
* config (the alias map) and returns a richer product than a {@link ToolKind}
* string — `classifyToolKind` stays AccessPath-free and config-free.
*/
export function resolveShellInvocation(
toolName: string,
input: unknown,
aliases: ShellToolsConfig | undefined,
): ShellInvocation | null {
const name = toolName.trim();
const record = toRecord(input);
if (name === "bash") {
return {
command: getNonEmptyString(record.command) ?? "",
workdir: undefined,
decisionFloor: undefined,
};
}
const alias = aliases?.[name];
if (alias) {
return {
command: getNonEmptyString(record[alias.commandArgument]) ?? "",
workdir: alias.workdirArgument
? (getNonEmptyString(record[alias.workdirArgument]) ?? undefined)
: undefined,
decisionFloor: alias.decisionFloor,
};
}
return null;
}
/** The resolved-check fields that decide MCP-ness. */
interface McpKindFields {
toolName: string;
source: string;
}
/**
* True when a resolved check concerns an MCP call — either the invoked tool is
* `mcp`, or the winning rule matched on the `mcp` surface (`source`). The
* `source` disjunct is why this cannot reduce to `classifyToolKind(toolName)`:
* `deriveSource` can set `source` to `mcp` on a result whose `toolName` is a
* server-qualified string.
*/
export function isMcpCheck(check: McpKindFields): boolean {
return check.source === "mcp" || classifyToolKind(check.toolName) === "mcp";
}