feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -0,0 +1,263 @@
---
issue: 569
issue_title: "Move the presentation family onto the tool-kind product"
---
# Move the presentation family onto the tool-kind product
## Release Recommendation
**Release:** ship now — batch "tool-kind-dispatch" tail (this issue completes the batch)
This is Phase 10 Step 2 of the pi-permission-system roadmap, the **tail** of the two-step batch "tool-kind-dispatch" whose head (Step 1, [#568]) has already landed.
Landing Step 2 completes the batch, so there is no reason to hold it — ship (land on `main`) now.
It is a `refactor:` change (a hidden changelog type), so it does **not** cut a release on its own; it auto-batches into the next `feat:`/`fix:`/unhidden-`docs:` release regardless.
The rationale here does not claim it will cut a release — the "ship now — batch tail" marker means land the coordinated pair's tail, not that a release is emitted.
## Problem Statement
The extraction family migrated onto the `access-intent/tool-kind.ts` classification in Step 1 ([#568]).
The **presentation** family still re-decides the tool kind per formatter by silent string comparison ([#561], Category C repeated-discriminator / OCP):
- `tool-preview-formatter.ts::getToolInputPreviewForLog``result.toolName === "bash" || result.toolName === "mcp" || result.source === "mcp"`.
- `permission-prompts.ts``formatUnknownToolReason`'s `toolName === "mcp"` MCP hint, `formatAskPrompt`'s `result.toolName === "bash"` bash branch, and its `(result.source === "mcp" || result.toolName === "mcp") && result.target` MCP branch.
- `denial-messages.ts` — two `check.toolName === "bash" && check.command` bash guards plus a private `isMcpCheck()` helper (`(check.source === "mcp" || check.toolName === "mcp") && !!check.target`) called at three sites.
- `handlers/gates/helpers.ts::deriveDecisionValue``toolName === "bash"` / `toolName === "mcp"`.
The private `isMcpCheck()` encapsulates the `(source === "mcp" || toolName === "mcp")` MCP-ness derivation, but two sibling call sites — `permission-prompts.ts::formatAskPrompt` and `tool-preview-formatter.ts::getToolInputPreviewForLog` — re-derive the same pattern inline instead of sharing it.
Adding a tool kind, or changing what "MCP-ness" means, requires finding and editing every site, and a missed site diverges silently.
With Step 1 landing the classification at the boundary, the presentation formatters should ask the tool-kind product for their display projection rather than re-checking `toolName`/`source` strings.
## Goals
- Migrate the four presentation consumers named in the roadmap onto `access-intent/tool-kind.ts`: `tool-preview-formatter.ts`, `permission-prompts.ts`, `denial-messages.ts`, `handlers/gates/helpers.ts`.
- Consolidate the `(source === "mcp" || toolName === "mcp")` MCP-ness derivation into a single shared `isMcpCheck` in `tool-kind.ts`; delete the private copy in `denial-messages.ts` so all three denial sites plus the two sibling formatters share one predicate.
- Drive every remaining `toolName`/`source` bash/mcp discriminator through `classifyToolKind` (already exported) or the new shared `isMcpCheck`, so the recompute grep drops the total family from 12 to ≤ 4, all inside `access-intent/tool-kind.ts`.
- Preserve behavior exactly — this is a pure internal refactor with no observable change to prompts, denial messages, log previews, decision values, or config.
Not a breaking change.
## Non-Goals
- No change to `classifyToolKind` itself, nor to the extraction consumers migrated in Step 1 (`input-normalizer.ts`, `tool-input-path.ts`, `handlers/gates/tool.ts`, `handlers/gates/tool-call-gate-pipeline.ts`, `permission-manager.ts`) — they are already on the product.
- No change to the message text, prompt wording, or log-preview format any formatter emits — the characterization tests must stay green unchanged.
- The `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 Step 1 product (`src/access-intent/tool-kind.ts`) exports:
```typescript
export type ToolKind = "bash" | "mcp" | "skill" | "path" | "extension";
export function classifyToolKind(toolName: string): ToolKind;
```
`classifyToolKind` keys purely on the tool **name**.
The presentation family needs one thing it cannot express: MCP-ness of a *resolved check*, which considers `source === "mcp"` in addition to `toolName === "mcp"`.
`PermissionCheckResult.source` is derived independently of `toolName` (`permission-manager.ts::deriveSource`), and the characterization suite already pins the source-only case:
- `denial-messages.test.ts` "MCP source with target on non-mcp toolName" — `toolCheck("anything", { source: "mcp", target: "server:tool" })``"…run MCP target 'server:tool'."`.
- `tool-preview-formatter.test.ts` "returns undefined for mcp source" — `makeResult("some-server:some-tool", { source: "mcp" })``undefined`.
So the shared MCP predicate must keep the `source === "mcp"` disjunct; reducing it to `classifyToolKind(toolName) === "mcp"` would regress these two tests.
Constraint from AGENTS.md and Step 1: `permission-manager.ts` carries an ESLint `no-restricted-imports` rule forbidding any import of `access-intent/access-path`, and `tool-kind.ts` must stay `AccessPath`-free so it remains a legal import there.
The new `isMcpCheck` imports nothing new (it reuses `classifyToolKind` and compares `source` to a string literal), so the boundary holds.
The recompute grep (`toolName === "(bash|mcp)"|source === "mcp"`) currently returns 12: one docstring inside `tool-kind.ts` plus the 11 presentation sites above.
## Design Overview
### One shared MCP-ness predicate, target-presence separated
Add a single predicate to `tool-kind.ts` that answers "does this resolved check concern an MCP call?"
— nothing more.
Whether a target string is available to *display* is a separate concern that stays at the call sites that need it (SRP):
```typescript
// src/access-intent/tool-kind.ts (added)
/** 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)`.
*/
export function isMcpCheck(check: McpKindFields): boolean {
return check.source === "mcp" || classifyToolKind(check.toolName) === "mcp";
}
```
The param is a narrow structural type (ISP): `PermissionCheckResult` satisfies it structurally (`source` is a union assignable to `string`), so `tool-kind.ts` imports no domain types and stays `AccessPath`-free.
The private `denial-messages.ts::isMcpCheck` currently bakes in `&& !!check.target`.
Separating that out means every call site that needs the target hoists `&& check.target` explicitly — which also gives TypeScript the truthy-narrowing the old `!!check.target` implied.
### Consumer call sites (behavior preserved exactly)
`denial-messages.ts` — three sites, each gaining an explicit `&& check.target` (was folded into the old private helper), plus the two bash guards:
```typescript
// buildToolDenyBody
if (isMcpCheck(check) && check.target) {
parts.push(`is not permitted to run MCP target '${check.target}'`);
} else {
parts.push(`is not permitted to run '${check.toolName}'`);
}
// buildUnavailableBody / buildUserDeniedBody — MCP arms
if (isMcpCheck(check) && check.target) { /* MCP-target text */ }
// bash guards (was check.toolName === "bash")
if (classifyToolKind(check.toolName) === "bash" && check.command) { /* … */ }
```
`permission-prompts.ts`:
```typescript
// formatUnknownToolReason — MCP hint (was toolName === "mcp")
const mcpHint = classifyToolKind(toolName) === "mcp" ? "" : " If this was intended…";
// formatAskPrompt — bash branch (was result.toolName === "bash")
if (classifyToolKind(result.toolName) === "bash") { /* bash prompt */ }
// formatAskPrompt — MCP branch (was (source === "mcp" || toolName === "mcp") && target)
if (isMcpCheck(result) && result.target) { /* MCP prompt */ }
```
`tool-preview-formatter.ts::getToolInputPreviewForLog` — the "skip preview because content is surfaced elsewhere" guard is bash **or** MCP-of-a-result (no target requirement, matching the original):
```typescript
if (classifyToolKind(result.toolName) === "bash" || isMcpCheck(result)) {
return undefined;
}
```
`handlers/gates/helpers.ts::deriveDecisionValue` — an exhaustive `switch` (a future `ToolKind` variant becomes a compile error, the OCP win):
```typescript
switch (classifyToolKind(toolName)) {
case "bash": return check.command ?? toolName;
case "mcp": return check.target ?? toolName;
case "path":
case "skill":
case "extension":
// Preserve the original `if (path) return path; return toolName` — an empty
// string falls through to toolName, so keep the truthy ternary (not `??`).
return path ? path : toolName;
}
```
### Why this is decide-once, not procedure-splitting
The refactor removes a genuine repeated discriminator: the `(source === "mcp" || toolName === "mcp")` derivation was re-decided at three-plus sites, and `isMcpCheck` becomes its single home alongside `classifyToolKind`.
Each consumer dispatches on the shared classification instead of re-checking strings; adding a tool kind means editing `tool-kind.ts` plus the exhaustive `switch` the compiler flags.
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.
### Metric note
The recompute grep keys literally on `toolName === "(bash|mcp)"` / `source === "mcp"`.
Migrated code dispatches on `classifyToolKind(...)` (whose `=== "bash"` / `=== "mcp"` follows a `)`, not the bare `toolName`, so it does not match) or `isMcpCheck(...)`, so the presentation sites drop to 0.
After migration the only matches are inside `access-intent/tool-kind.ts`: the module docstring's `toolName === "bash"` reference and the `source === "mcp"` disjunct in `isMcpCheck` — 2 lines, within the Phase 10 end-state target of ≤ 4.
## Module-Level Changes
- **`src/access-intent/tool-kind.ts`** — add `isMcpCheck(check: { toolName: string; source: string })`; extend the module docstring to note the presentation consumers now dispatch on it too.
Still imports only `PATH_BEARING_TOOLS`.
- **`src/denial-messages.ts`** — delete the private `isMcpCheck`; import `classifyToolKind` + `isMcpCheck` from `./access-intent/tool-kind`; the three MCP arms become `isMcpCheck(check) && check.target`; the two bash guards become `classifyToolKind(check.toolName) === "bash"`.
- **`src/permission-prompts.ts`** — import `classifyToolKind` + `isMcpCheck`; migrate the MCP hint (`formatUnknownToolReason`), the bash branch, and the MCP branch (`isMcpCheck(result) && result.target`) of `formatAskPrompt`.
- **`src/tool-preview-formatter.ts`** — import `classifyToolKind` + `isMcpCheck`; migrate the `getToolInputPreviewForLog` skip guard.
- **`src/handlers/gates/helpers.ts`** — import `classifyToolKind`; migrate `deriveDecisionValue` to an exhaustive `switch`.
- **`test/access-intent/tool-kind.test.ts`** — add an `isMcpCheck` describe block (toolName-mcp → true, source-mcp on a non-mcp toolName → true, bash → false, plain tool → false).
- **`docs/architecture/architecture.md`** — mark Phase 10 Step 2 `✅` (step heading + `S2` Mermaid node); add a **Landed:** bullet; update the "Tool-kind discriminator sites" metric row to note the target is met (≤ 4, all in `tool-kind.ts`); extend the `tool-kind.ts` module-tree entry (line ~749) to say the presentation consumers (`tool-preview-formatter`, `permission-prompts`, `denial-messages`, `deriveDecisionValue`) dispatch on it via `classifyToolKind`/`isMcpCheck`.
No public `exports`, event channel, or `Symbol.for()` surface changes.
`isMcpCheck` is not a cross-extension API; no user-facing doc, README, or `package-*` SKILL references `isMcpCheck` or the presentation formatters by name (verified by grep — the only `isMcpCheck` mentions are in `src/`, the historical `0568` plan/retro, and the Step 2 roadmap entry being updated here).
## Test Impact Analysis
1. **New tests enabled.**
`isMcpCheck` becomes independently testable for the first time (it was a private helper inlining the derivation).
`test/access-intent/tool-kind.test.ts` pins it directly, including the source-only disjunct (`{ toolName: "read", source: "mcp" }``true`) that distinguishes it from `classifyToolKind`.
2. **Redundant tests.**
None become fully redundant.
The per-consumer characterization tests (`denial-messages.test.ts`, `permission-prompts.test.ts`, `tool-preview-formatter.test.ts`, `helpers.test.ts`) assert each formatter's *output* (message text, prompt string, log preview, decision value), which the refactor keeps invariant, so they remain the safety net.
The `isMcpCheck` unit test overlaps with them only at the classification layer, not the projection layer.
3. **Tests that must stay as-is.**
All four presentation characterization suites — in particular `denial-messages.test.ts` "MCP source with target on non-mcp toolName" and `tool-preview-formatter.test.ts` "returns undefined for mcp source", which pin the `source === "mcp"` disjunct the shared predicate must keep.
Before each migration, confirm the touched branch has characterization coverage (verified above — MCP-with-target, MCP-via-source, bash, and generic-tool arms are all covered across the four suites); add a red characterization test only if a gap surfaces.
## Invariants at risk
The refactor touches surfaces earlier phases refactored; each invariant is pinned by an existing test:
- **MCP-ness considers `source`, not just `toolName`** — Step 1's `deriveSource` can set `source: "mcp"` on a result whose `toolName` is a server-qualified string.
Pinned by `denial-messages.test.ts` "MCP source with target on non-mcp toolName" and `tool-preview-formatter.test.ts` "returns undefined for mcp source".
- **Formatter output text unchanged** — every prompt, denial, unavailable, user-denied, log-preview, and decision-value projection.
Pinned by the four presentation characterization suites (kept green, unchanged).
- **`deriveDecisionValue` empty-path fallback** — an empty `path` string falls through to `toolName` (the original `if (path)` truthiness).
Pinned by `helpers.test.ts` "falls back to toolName for path-bearing tools when path is missing"; preserved by the truthy ternary (`path ? path : toolName`), not `??`.
- **ADR-0002 string boundary** — `tool-kind.ts` stays `AccessPath`-free so `isMcpCheck`/`classifyToolKind` remain legal imports package-wide.
Pinned by the `no-restricted-imports` ESLint rule on `permission-manager.ts` (which does not import the presentation modules, so it is unaffected either way).
## TDD Order
Each migration is behavior-preserving under the existing green suite; only the new `isMcpCheck` unit tests carry a true red.
Run `pnpm run check` after each step and the full package suite before committing.
1. **Add `isMcpCheck` + migrate `denial-messages.ts`.**
Red: `test/access-intent/tool-kind.test.ts` `isMcpCheck` block (function absent).
Green: implement `isMcpCheck` in `tool-kind.ts`, then delete the private `denial-messages.ts::isMcpCheck` and migrate its three MCP arms (`isMcpCheck(check) && check.target`) and two bash guards (`classifyToolKind(check.toolName) === "bash"`) in the **same** commit so the export lands with a consumer (avoids a `fallow dead-code` failure on an unwired export).
`denial-messages.test.ts` stays green.
Commit: `refactor(pi-permission-system): share MCP-check via tool-kind product in denial messages`.
2. **Migrate `permission-prompts.ts`.**
`formatUnknownToolReason` MCP hint, `formatAskPrompt` bash branch, and MCP branch (`isMcpCheck(result) && result.target`) dispatch on the product.
`permission-prompts.test.ts` green.
Commit: `refactor(pi-permission-system): classify prompt tool kind via tool-kind product`.
3. **Migrate `tool-preview-formatter.ts`.**
`getToolInputPreviewForLog` skip guard → `classifyToolKind(result.toolName) === "bash" || isMcpCheck(result)`.
`tool-preview-formatter.test.ts` green.
Commit: `refactor(pi-permission-system): classify preview tool kind via tool-kind product`.
4. **Migrate `handlers/gates/helpers.ts::deriveDecisionValue`.**
Exhaustive `switch (classifyToolKind(toolName))`, preserving the empty-path truthy fallback.
`helpers.test.ts` green.
Commit: `refactor(pi-permission-system): derive decision value via tool-kind classification`.
5. **Record the roadmap step.**
Update `docs/architecture/architecture.md`: mark Phase 10 Step 2 `✅` (heading + `S2` Mermaid node), add the **Landed:** bullet, update the "Tool-kind discriminator sites" metric row (target met), extend the `tool-kind.ts` module-tree entry to name the presentation consumers.
Verify the recompute returns ≤ 4 (expected 2, both inside `tool-kind.ts`).
Commit: `docs(pi-permission-system): record Phase 10 Step 2 presentation tool-kind migration`.
## Risks and Mitigations
- **Dropping the `source === "mcp"` disjunct.**
A naive `classifyToolKind(toolName) === "mcp"` replacement would regress the source-only characterization tests.
Mitigation: `isMcpCheck` keeps the `source === "mcp"` disjunct; the two pinning tests stay green.
- **`&& target` hoist changes a branch.**
Moving the target guard from the private helper to the call sites must be applied at all three denial sites and the prompt MCP branch.
Mitigation: enumerate them (three in `denial-messages.ts`, one in `permission-prompts.ts`); `tool-preview-formatter.ts` deliberately omits it (its original had no target check).
The characterization suites (MCP-with-target unavailable/user-denied/deny arms, MCP-via-source) catch any slip.
- **`deriveDecisionValue` empty-path fallback.**
`path ?? toolName` would return `""` for an empty path; the original returns `toolName`.
Mitigation: keep the truthy ternary; `helpers.test.ts` pins it.
- **Unwired export → `fallow dead-code`.**
Mitigation: fold the first consumer (`denial-messages.ts`) into the `isMcpCheck` introduction commit (Step 1).
- **Exhaustiveness drift.**
Mitigation: `deriveDecisionValue` uses an exhaustive `switch` with no `default`, so a future `ToolKind` variant is a compile error.
## Open Questions
- None blocking.
The one design choice (a single no-target `isMcpCheck` with target-presence hoisted to call sites, vs. two predicates) is resolved by SRP and the existing characterization coverage; it is behavior-preserving either way.
[#309]: https://github.com/gotgenes/pi-packages/issues/309
[#490]: https://github.com/gotgenes/pi-packages/issues/490
[#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
[#568]: https://github.com/gotgenes/pi-packages/issues/568