mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
117 lines
4.7 KiB
TypeScript
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";
|
|
}
|