Files
my-pi/pi-permission-system/docs/plans/0568-tool-kind-classification-extraction.md

254 lines
19 KiB
Markdown

---
issue: 568
issue_title: "Tool-kind classification decided once at the normalize boundary (extraction family)"
---
# Tool-kind classification decided once at the normalize boundary (extraction family)
## Release Recommendation
**Release:** mid-batch — defer (batch "tool-kind-dispatch"); confirm at ship time
This is Phase 10 Step 1 of the pi-permission-system roadmap, the head of the two-step batch "tool-kind-dispatch" whose tail is Step 2 ([#569]).
The batch ships together, so this step lands on `main` but does not cut a release on its own.
It is a `refactor:` change (a hidden changelog type), so it auto-batches into the next `feat:`/`fix:`/unhidden-`docs:` release regardless — the rationale here does not claim it will cut a release.
## Problem Statement
The extraction question — "what does this invocation access: a bash command, an MCP target, or a path?"
— is re-derived by silent string comparison (`toolName === "bash"`, `toolName === "mcp"`, `source === "mcp"`) at 21 production sites across 8 modules ([#561]).
This is a Category C repeated-discriminator / OCP flaw: the same domain decision is re-decided at every consumer, so adding a tool kind or changing what "MCP-ness" means requires finding and editing every site, and a missed site diverges silently — there is no compiler-enforced exhaustive dispatch, just scattered `===` comparisons.
`fallow dupes` is structurally blind to it (one-line comparisons never form a token-run clone); the repeated-discriminator grep sweep is the only detector.
This step captures the classification once in a new `access-intent/tool-kind.ts` and migrates the **extraction-family** consumers onto it.
The **presentation-family** consumers (prompts, previews, denial messages, decision values) follow in Step 2 ([#569]).
## Goals
- Introduce `src/access-intent/tool-kind.ts` owning a `ToolKind` discriminated classification and its single dispatch point, `classifyToolKind(toolName)`.
- Migrate the five extraction consumers named in the roadmap to ask the classification instead of re-checking the raw string: `input-normalizer.ts`, `tool-input-path.ts`, `handlers/gates/tool.ts`, `handlers/gates/tool-call-gate-pipeline.ts`, `permission-manager.ts`.
- Keep `permission-manager.ts` string-based: `tool-kind.ts` returns plain data (a string union), imports no `AccessPath`, and stays safe to consume there per `docs/decisions/0002-path-values-string-boundary.md`.
- Preserve behavior exactly — this is a pure internal refactor with no observable change to decisions, output, or config.
Not a breaking change.
## Non-Goals
- The presentation family (`tool-preview-formatter.ts`, `permission-prompts.ts`, `denial-messages.ts`, `handlers/gates/helpers.ts::deriveDecisionValue`, and `denial-messages.ts::isMcpCheck`) — deferred to Step 2 ([#569]).
- `input-normalizer.ts::buildInputForSurface` (the `surface === "bash"` / `"skill"` / `"external_directory"` inverse reconstruction) is **not** migrated: it dispatches on path/service **surface** names, not tool names, and `external_directory` has no `ToolKind` variant, so `classifyToolKind` would drop that branch.
It is outside the extraction-discriminator family (it is not a grep-counted site) and stays as-is.
- `PathFlavor` / win32 discriminator work ([#562], Step 3), advisory bash decomposition ([#309], Step 4), indirection wrappers ([#490], Step 5), and the docs recipe ([#521], Step 6) are separate roadmap steps.
## Background
The extraction consumers and their exact discriminator sites (the official recompute grep, `toolName === "(bash|mcp)"|source === "mcp"`, run 2026-07-10):
| Module | Sites | Role |
| ------------------------------------------- | -------- | ----------------------------------------------------------------------------- |
| `input-normalizer.ts` | 122, 138 | `normalizeInput` — builds the `(surface, values, resultExtras)` triple |
| `tool-input-path.ts` | 34, 44 | `getToolInputPath` — extracts the filesystem path for the cross-cutting gates |
| `handlers/gates/tool.ts` | 26, 27 | `deriveSuggestionValue` — the session-approval suggestion value |
| `handlers/gates/tool-call-gate-pipeline.ts` | 80, 162 | bash-parse guard + `resolvePerToolCheck` bash branch |
| `permission-manager.ts` | 448, 455 | `deriveSource` — maps a matched rule + tool to `PermissionCheckResult.source` |
That is 10 extraction sites; the remaining 11 (in `denial-messages.ts`, `tool-preview-formatter.ts`, `permission-prompts.ts`, `handlers/gates/helpers.ts`) are the presentation family Step 2 clears.
The classification set is fixed by the existing surfaces:
- `bash` — its own token-based path gates; extraction product is the command string.
- `mcp` — extraction product is the qualified target string(s).
- `skill` — a distinct surface `normalizeInput` and `deriveSource` treat specially.
- path-bearing built-ins (`read`/`write`/`edit`/`grep`/`find`/`ls`, the `PATH_BEARING_TOOLS` set in `path-surfaces.ts`) — extraction product is `input.path`.
- everything else (extension tools, and the `external_directory`/`path` special surfaces that reach `deriveSource` as normalized names).
Two AGENTS.md constraints apply:
- `permission-manager.ts` carries an ESLint `no-restricted-imports` rule (`eslint.config.js`) forbidding any import of `access-intent/access-path`. `tool-kind.ts` imports only `PATH_BEARING_TOOLS` (from `path-surfaces.ts`, a pure module), so importing `classifyToolKind` there does not breach the boundary. `tool-kind.ts` must stay `AccessPath`-free to keep this true.
- When the roadmap step completes, the implementation doc-update commit marks Phase 10 Step 1 `✅` on both its heading and its Mermaid node in `docs/architecture/architecture.md`, per the package skill — not a deferred ship commit.
## Design Overview
### The classification
`tool-kind.ts` owns a string-union `ToolKind` and one dispatch function.
A plain string union (not a rich product object carrying the extracted command/target/path) is the right granularity:
- A rich product would have to be built with the MCP server-name list (`createMcpPermissionTargets`), the tool-access extractor registry, and the `PathNormalizer`/`AccessPath` — but `deriveSource` and the pipeline's bash guard have none of those in hand, and pulling `AccessPath` into `tool-kind.ts` would breach the ADR-0002 boundary permission-manager depends on.
- The string union keeps the classifier a pure, plain-data function every consumer can call, while each consumer builds its own product by dispatching on the shared kind.
The classification is the single missing dispatch point ([#561]); the per-kind products stay where their dependencies live.
```typescript
// src/access-intent/tool-kind.ts
import { PATH_BEARING_TOOLS } from "#src/path-surfaces";
/** What a tool invocation accesses, decided once from the tool name. */
export type ToolKind = "bash" | "mcp" | "skill" | "path" | "extension";
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";
}
```
`tool-kind.ts` is `AccessPath`-free (its only import is the pure `PATH_BEARING_TOOLS` set), so it is plain data safe for `permission-manager.ts` to consume — the ADR-0002 constraint holds.
### Why this is decide-once, not procedure-splitting
The refactor introduces a genuine new collaborator (`classifyToolKind`, the dispatch point that did not exist) and gives behavior to data (a `ToolKind` union the consumers dispatch on via exhaustive `switch`).
Adding a new tool kind then means editing `classifyToolKind` plus the exhaustive switches the compiler flags — an OCP win over silent `===` comparisons that a new variant sails past.
This is the design-review skill's accepted resolution for a repeated discriminator: capture the decision once at the boundary and hand consumers its product.
### Consumer call sites (Tell-Don't-Ask preserved)
Each consumer replaces its inline `toolName === "bash"` chain with a `switch` (or single comparison) on the shared classification.
Representative sketches:
```typescript
// input-normalizer.ts — normalizeInput
switch (classifyToolKind(toolName)) {
case "skill": return { surface: "skill", values: [skillName ?? "*"], resultExtras: {} };
case "bash": return { surface: "bash", values: [matchValue], resultExtras: { command } };
case "mcp": return { surface: "mcp", values: mcpTargets, resultExtras: { target } };
case "path":
case "extension": return { surface: toolName, values: ["*"], resultExtras: {} };
}
```
```typescript
// tool-input-path.ts — getToolInputPath
switch (classifyToolKind(toolName)) {
case "bash": return null; // own token gates
case "path": return getNonEmptyString(record.path);
case "mcp": return getNonEmptyString(toRecord(record.arguments).path);
case "skill": // no path; default
case "extension": return getNonEmptyString((extractors?.get(toolName) ?? ((r) => r.path))(record));
}
```
```typescript
// handlers/gates/tool.ts — deriveSuggestionValue (accessPath stays here; tool-kind.ts stays AccessPath-free)
switch (classifyToolKind(tcc.toolName)) {
case "bash": return check.command ?? "";
case "mcp": return check.target ?? "mcp";
default: return accessPath ? accessPath.value() : "*";
}
```
`deriveSource` keeps its `SPECIAL_PERMISSION_KEYS` check (the `external_directory`/`path` surfaces classify as `extension`, then the special-key check maps them to `"special"`), and dispatches the remaining arms on `classifyToolKind`.
`getPathBearingToolPath` becomes `classifyToolKind(toolName) === "path"`.
The pipeline's two bash guards become `classifyToolKind(tcc.toolName) === "bash"` (compute the kind once at the top of `evaluate`).
`buildCheckResult`'s `surface === "mcp"` target-shaping becomes `classifyToolKind(surface) === "mcp"` for consistency.
### Observed redundancy: `getToolPermission`
`getToolPermission` (permission-manager) branches on `normalizedToolName === "bash"` / `"mcp"` / `"skill"` (not grep-counted — the variable is `normalizedToolName`, not `toolName`), but every branch — including the `SPECIAL` and default arms — evaluates the identical expression `evaluate(normalizedToolName, "*", composedRules, this.platform).action`.
The branching is provably dead.
Collapsing the whole body to that single return is a behavior-identical simplification folded into the `permission-manager` migration step, covered by the existing `getToolPermission` tests.
It removes a discriminator without needing `classifyToolKind`.
### Metric note
The roadmap's recompute grep keys literally on `toolName ===` / `source ===`.
Migrated consumers dispatch on a `classifyToolKind(...)`/`kind`-named value, and `classifyToolKind` itself compares a `name`-named local, so none of the migrated code matches the grep — extraction-family sites drop to 0 and the total falls from 21 to 11 (presentation only), within the Step 1 target of ≤ 12.
The Phase 10 end-state target (≤ 4, all in `tool-kind.ts`) is reached only after Step 2.
## Module-Level Changes
- **`src/access-intent/tool-kind.ts`** (new) — `ToolKind` union + `classifyToolKind`; imports only `PATH_BEARING_TOOLS`.
- **`src/input-normalizer.ts`** — `normalizeInput` dispatches on `classifyToolKind`; `buildInputForSurface` unchanged (see Non-Goals).
- **`src/tool-input-path.ts`** — `getToolInputPath` and `getPathBearingToolPath` dispatch on `classifyToolKind`.
- **`src/handlers/gates/tool.ts`** — `deriveSuggestionValue` dispatches on `classifyToolKind` (AccessPath handling stays local).
- **`src/handlers/gates/tool-call-gate-pipeline.ts`** — the bash-parse guard (`evaluate`) and `resolvePerToolCheck` bash branch use `classifyToolKind(tcc.toolName) === "bash"`, computed once.
- **`src/permission-manager.ts`** — `deriveSource` and `buildCheckResult` (`surface === "mcp"`) dispatch on `classifyToolKind`; `getToolPermission` collapses its dead branches.
Imports `classifyToolKind` (allowed — it is not `access-path`).
- **`test/access-intent/tool-kind.test.ts`** (new) — unit tests for `classifyToolKind`.
- **`docs/architecture/architecture.md`** — add a `tool-kind.ts` entry under `access-intent/` in the module-layout tree; mark Phase 10 Step 1 `✅` on its heading and Mermaid node; annotate the "Tool-kind discriminator sites" metric row that the extraction family is cleared (presentation remains until Step 2).
No public `exports`, event channel, or `Symbol.for()` surface changes — so no `docs/`-tree or README grep beyond the architecture doc is needed.
`classifyToolKind` is not a cross-extension API; no user-facing doc references it.
## Test Impact Analysis
1. **New tests enabled.**
The classification was inlined at 10 sites and untestable in isolation.
`test/access-intent/tool-kind.test.ts` pins it directly: `bash`/`mcp`/`skill`, each member of `PATH_BEARING_TOOLS``path`, an arbitrary extension tool → `extension`, and whitespace-trim (`" bash "``bash`).
2. **Redundant tests.**
None become fully redundant.
Per-consumer "is this bash/mcp?"
behavior is now also covered indirectly by the classifier test, but the consumer tests assert the consumer's *product* (surface/values/extras, path, suggestion value, source) — the refactor must preserve those, so they stay.
3. **Tests that must stay as-is.** `input-normalizer.test.ts`, `tool-input-path.test.ts`, `handlers/gates/tool.test.ts`, `handlers/gates/tool-call-gate-pipeline.test.ts`, and `permission-manager-unified.test.ts` exercise the extraction products the refactor keeps invariant — they are the characterization safety net.
Before each migration, confirm the touched branch has characterization coverage; add a red characterization test only where a branch is uncovered (candidates: `getToolInputPath("skill", …)` → default path; `deriveSource` for the `external_directory`/`path` special surfaces and for an extension tool at the default layer).
## Invariants at risk
The refactor touches surfaces earlier phases refactored; each invariant is pinned by an existing test:
- **Extraction products unchanged** — `normalizeInput`'s `(surface, values, resultExtras)` ([#478]), `getToolInputPath`'s per-kind path ([#502]), `deriveSource`'s source mapping, and the pipeline's single-parse bash guard.
Pinned by the five test files above.
- **ADR-0002 string boundary** — `permission-manager.ts` must not import `AccessPath`.
Pinned by the `no-restricted-imports` ESLint rule (`eslint.config.js`); `tool-kind.ts` staying `AccessPath`-free keeps `classifyToolKind` a legal import.
- **Special-surface source** — `external_directory`/`path` still resolve to `source: "special"`.
Ensure a characterization test covers it before migrating `deriveSource`.
## TDD Order
Each migration is behavior-preserving under the existing green suite; only the new module carries a true red.
Run `pnpm run check` after each step (shared-type / interface touch) and the full package suite before committing (shared helpers).
1. **Introduce `tool-kind.ts` + migrate the first consumer.**
Red: `test/access-intent/tool-kind.test.ts` (module absent).
Green: implement `classifyToolKind`, then migrate `tool-input-path.ts` (`getToolInputPath`, `getPathBearingToolPath`) so the export has a production consumer immediately (avoids a `fallow dead-code` failure on an unwired export).
`tool-input-path.test.ts` stays green.
Commit: `refactor(pi-permission-system): add tool-kind classification and migrate tool-input-path`.
2. **Migrate `normalizeInput`.**
Refactor `input-normalizer.ts::normalizeInput` to the `classifyToolKind` switch; `input-normalizer.test.ts` green.
Commit: `refactor(pi-permission-system): normalize input via tool-kind classification`.
3. **Migrate `permission-manager.ts`.**
`deriveSource` + `buildCheckResult` (`surface === "mcp"`) dispatch on `classifyToolKind`; collapse `getToolPermission`'s dead branches.
Add the special-surface / extension-default source characterization test first if uncovered.
`permission-manager-unified.test.ts` and `permission-manager-yolo.test.ts` green; run `pnpm run check` (shared-type consumer).
Commit: `refactor(pi-permission-system): derive check-result source via tool-kind`.
4. **Migrate the gate consumers.**
`handlers/gates/tool.ts::deriveSuggestionValue` and `tool-call-gate-pipeline.ts` (compute the kind once, replace both bash guards).
`tool.test.ts` and `tool-call-gate-pipeline.test.ts` green.
Commit: `refactor(pi-permission-system): classify tool kind in the tool-call gate pipeline`.
5. **Record the roadmap step.**
Update `docs/architecture/architecture.md`: add `tool-kind.ts` to the `access-intent/` module tree, mark Phase 10 Step 1 `✅` (heading + Mermaid node), annotate the metric row.
Verify the extraction-family recompute returns 0.
Commit: `docs(pi-permission-system): record Phase 10 Step 1 tool-kind classification`.
## Risks and Mitigations
- **Exhaustiveness drift.**
A future `ToolKind` variant must reach every switch.
Mitigation: use exhaustive `switch` statements (or a `never`-typed default) in `normalizeInput`, `getToolInputPath`, and `deriveSource` so a new variant is a compile error, not a silent fall-through.
- **`getToolInputPath` skill branch.**
`skill` currently falls to the default `record.path` extraction; the migration must fold `skill` into the same arm as `extension`, not into `bash`/`path`/`mcp`.
Mitigation: characterization test for `getToolInputPath("skill", …)``null`.
- **`deriveSource` special surfaces.**
`external_directory`/`path` classify as `extension`; the `SPECIAL_PERMISSION_KEYS` check must run and map them to `"special"`.
Mitigation: keep the special-key check ahead of the default arm; characterization test.
- **Unwired export → `fallow dead-code`.**
CI gates on it.
Mitigation: fold the first consumer into the introduction commit (Step 1) so `classifyToolKind` never lands unused.
- **ADR-0002 breach.**
Mitigation: `tool-kind.ts` imports only `PATH_BEARING_TOOLS`; the existing ESLint rule guards `permission-manager.ts`.
Confirm `pnpm run lint` passes after Step 3.
## Open Questions
- None blocking. `getToolPermission`'s dead-branch collapse is included as a behavior-identical simplification; if review prefers to keep it out of a classification-focused refactor, it can drop to a one-line follow-up without affecting the rest.
[#309]: https://github.com/gotgenes/pi-packages/issues/309
[#478]: https://github.com/gotgenes/pi-packages/issues/478
[#490]: https://github.com/gotgenes/pi-packages/issues/490
[#502]: https://github.com/gotgenes/pi-packages/issues/502
[#521]: https://github.com/gotgenes/pi-packages/issues/521
[#561]: https://github.com/gotgenes/pi-packages/issues/561
[#562]: https://github.com/gotgenes/pi-packages/issues/562
[#569]: https://github.com/gotgenes/pi-packages/issues/569