mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
245 lines
22 KiB
Markdown
245 lines
22 KiB
Markdown
---
|
||
issue: 502
|
||
issue_title: "pi-permission-system: migrate the per-tool path-bearing gate onto AccessPath (Phase 7 Step 1)"
|
||
---
|
||
|
||
# Migrate the per-tool path-bearing gate onto `AccessPath`
|
||
|
||
## Release Recommendation
|
||
|
||
**Release:** mid-batch — defer (batch "symlink-resistant-path-matching"); confirm at ship time
|
||
|
||
This is Phase 7 Step 1 of the [#487] roadmap.
|
||
The architecture's `Release batches` subsection puts Steps 1, 2, 3 in the breaking batch "symlink-resistant-path-matching", with the **tail at Step 3** ([#504]).
|
||
This issue is Step 1, not the tail, so it lands on `main` and auto-batches; the major-bump release cuts when Step 3 lands.
|
||
The breaking `feat!:` commit does not cut a release on its own here because the batch is still mid-flight — confirm the deferral at ship time.
|
||
|
||
## Problem Statement
|
||
|
||
The per-tool path-bearing gate (`read` / `write` / `edit` / `grep` / `find` / `ls`) still matches **lexically only**, while the cross-cutting `path` surface matches the lexical aliases ∪ canonical (symlink-resolved) form after [#486].
|
||
The per-tool gate's sixth gate producer in `tool-call-gate-pipeline.ts` emits a `kind: "tool"` intent, which the manager normalizes via `normalizeInput` → `normalizePathSurfaceValues` → `getPathPolicyValues` — lexical only.
|
||
So a per-tool rule like `read: deny *.env` can be evaded through a symlink alias, whereas the same rule on `path` cannot.
|
||
This asymmetry is the residual gap Phase 7 Step 1 closes: route the per-tool path-bearing gate onto `AccessPath` so its match set becomes lexical ∪ canonical — the same set [#486] already gives `path` and `external_directory`.
|
||
|
||
## Goals
|
||
|
||
- For path-bearing built-in tools (`read` / `write` / `edit` / `grep` / `find` / `ls`), build an `AccessPath` via the session `PathNormalizer` and emit a `kind: "access-path"` intent with `surface: toolName`, so the per-tool gate matches the lexical aliases ∪ canonical form.
|
||
- Keep non-path tools (bash, MCP, extension tools, and a path-bearing tool with no `input.path`) on the existing `kind: "tool"` intent — no behavior change for them.
|
||
- Derive the per-tool session-approval suggestion value from `accessPath.value()` instead of re-deriving it with `normalizePathForComparison`, dropping the `platform` parameter that `describeToolGate` threaded only to feed that derivation.
|
||
- Remove the now-unused `getPlatform()` session accessor (this resolves [#513]): with [#511] already landed, this issue is the second of the two consumers to fold, so after the per-tool gate read goes, `ToolCallGatePipeline.evaluate`'s `getPlatform()` read — and the accessor it backs — has no caller and must be removed (the `pnpm fallow dead-code` CI gate would otherwise flag it).
|
||
|
||
This is a **breaking change**: adding the canonical alias to the per-tool match set alters which rules fire on upgrade with no user edit.
|
||
A symlink whose resolved target matches a per-tool `deny`/`allow` pattern now matches it where it previously did not.
|
||
The behavior step's commit is `feat(pi-permission-system)!:` with a `BREAKING CHANGE:` footer.
|
||
|
||
## Non-Goals
|
||
|
||
- No migration of the service/RPC path queries onto `AccessPath` — that is Phase 7 Step 2 ([#503]).
|
||
- No removal of `input-normalizer`'s `normalizePathSurfaceValues` / `PATH_BEARING_TOOLS` branch — that is Phase 7 Step 3 ([#504]), after both Step 1 and Step 2 strip its callers.
|
||
This plan leaves `normalizeInput` intact; the path-bearing tool with no `input.path` still routes through the `tool` branch (so the missing-path `["*"]` fallback is preserved).
|
||
- No change to MCP or extension per-tool gating: they stay on the `tool` intent (their path is already covered symlink-resistantly by the cross-cutting `path` gate, which emits `access-path` since [#486]).
|
||
- No change to dedup/approval-key identity: keys continue to derive from the **lexical** form (`accessPath.value()`), so existing session approvals stay stable.
|
||
- No principal identity on `AccessIntent`; cross-session path portability stays deferred.
|
||
|
||
## Background
|
||
|
||
Relevant modules (all in `packages/pi-permission-system/`):
|
||
|
||
- `src/handlers/gates/tool-call-gate-pipeline.ts` — `ToolCallGatePipeline.evaluate` assembles six gate producers.
|
||
The **sixth** producer is the per-tool gate: for bash it calls `resolveBashCommandCheck`, otherwise it resolves `{ kind: "tool", surface: tcc.toolName, input: tcc.input }` and feeds the result into `describeToolGate`.
|
||
It currently reads `const platform = this.inputs.getPlatform()` and threads it only into `describeToolGate` (after [#511] removed the skill-read and external-directory `platform` threading).
|
||
- `src/handlers/gates/tool.ts` — `describeToolGate(tcc, check, formatter, platform)` builds the descriptor; `deriveSuggestionValue` returns the session-approval suggestion (`bash` → command, `mcp` → target, path-bearing → `normalizePathForComparison(path, tcc.cwd, platform)`, else `*`).
|
||
- `src/handlers/gates/path.ts` — the cross-cutting `path` gate, already migrated by [#486]: builds `normalizer.forPath(filePath)`, emits `{ kind: "access-path", surface: "path", path }`, derives the approval pattern from `accessPath.value()`.
|
||
This is the template for the per-tool change.
|
||
- `src/path-normalizer.ts` — `PathNormalizer.forPath(pathValue, options?)` builds an `AccessPath` resolved against the baked session `cwd` + `platform`.
|
||
- `src/path-utils.ts` — `getPathBearingToolPath(toolName, input)` returns `input.path` for the six built-in `PATH_BEARING_TOOLS`, else `null`; `PATH_SURFACES` includes the path-bearing tool names, so the manager's `buildCheckResult` already routes a `surface: "read"` path-values intent through `evaluateAnyValue` (last-match-wins across aliases).
|
||
- `src/permission-resolver.ts` — `toResolvedIntent` unwraps an `access-path` intent to `path-values` via `path.matchValues()`; the manager stays string-based and never imports `AccessPath`.
|
||
- `src/access-intent/access-intent.ts` — `AccessPathAccessIntent`; its doc comment names only the `path` and `external_directory` surfaces as emitters.
|
||
- `src/permission-session.ts` — `getPlatform()` returns `this.platform` (still used internally by `getPathNormalizer()`); the accessor is consumed only by the pipeline's `getPlatform()` read.
|
||
|
||
`getPlatform()` consumers (grep-verified): `PermissionSession.getPlatform()` (definition), `ToolCallGateInputs.getPlatform()` (interface member), and the single `const platform = this.inputs.getPlatform()` read in `ToolCallGatePipeline.evaluate`.
|
||
The test fixture `makeGateInputs` (`test/helpers/gate-fixtures.ts`) provides a `getPlatform` stub.
|
||
No other `src/` or `test/` reader exists.
|
||
|
||
Key constraint (AGENTS.md / SKILL): the manager stays string-based and never imports `AccessPath`; the resolver does the `matchValues()` unwrap.
|
||
This change preserves that — the per-tool gate emits `access-path`, the resolver unwraps, the manager is untouched (mechanically parallel to [#486]).
|
||
|
||
[#513] designates "whichever of [#502]/[#511] lands second drops the accessor." [#511] is already merged, so [#502] is second and folds the removal in; [#513] closes when this ships.
|
||
|
||
## Design Overview
|
||
|
||
### The match set is already single-sourced
|
||
|
||
`AccessPath.matchValues()` returns exactly `lexical aliases ∪ canonical`.
|
||
The resolver already unwraps an `access-path` intent through `matchValues()`, and `PATH_SURFACES.has("read")` is `true`, so the manager already evaluates a `surface: "read"` path-values intent with `evaluateAnyValue`.
|
||
So the change is purely: make the per-tool gate emit `access-path` (for path-bearing tools with a path) instead of `tool`.
|
||
No manager or resolver change is needed — the only behavior change is the canonical alias joining the match set.
|
||
|
||
### Per-tool gate producer (`tool-call-gate-pipeline.ts`)
|
||
|
||
The sixth producer gains an `AccessPath` branch for path-bearing tools, keyed off `getPathBearingToolPath` (which is non-`null` only for the six built-ins with a present `input.path`):
|
||
|
||
```typescript
|
||
const path = getPathBearingToolPath(tcc.toolName, tcc.input);
|
||
let accessPath: AccessPath | undefined;
|
||
let toolCheck: PermissionCheckResult;
|
||
if (tcc.toolName === "bash" && bashProgram) {
|
||
toolCheck = resolveBashCommandCheck(command ?? "", bashProgram.commands(), tcc.agentName ?? undefined, this.resolver);
|
||
} else if (path !== null) {
|
||
accessPath = normalizer.forPath(path);
|
||
toolCheck = this.resolver.resolve({
|
||
kind: "access-path",
|
||
surface: tcc.toolName,
|
||
path: accessPath,
|
||
agentName: tcc.agentName ?? undefined,
|
||
});
|
||
} else {
|
||
toolCheck = this.resolver.resolve({
|
||
kind: "tool",
|
||
surface: tcc.toolName,
|
||
input: tcc.input,
|
||
agentName: tcc.agentName ?? undefined,
|
||
});
|
||
}
|
||
const toolDescriptor = describeToolGate(tcc, toolCheck, formatter, accessPath);
|
||
toolDescriptor.preCheck = toolCheck;
|
||
return toolDescriptor;
|
||
```
|
||
|
||
`normalizer` is the `PathNormalizer` already obtained at the top of `evaluate`; the `const platform = this.inputs.getPlatform()` read and its threading into `describeToolGate` are removed. (`prefer-const` does not fire here: `accessPath`/`toolCheck` are each assigned once across the branches, but a `let` with no initializer assigned in mutually exclusive branches is fine; if the linter objects, hoist the branch into a small helper that returns `{ accessPath, toolCheck }`.)
|
||
|
||
The discriminator `path !== null` preserves the missing-path case: a path-bearing tool whose `input.path` is absent routes through the `tool` intent, where `normalizeInput` collapses it to `["*"]` exactly as today.
|
||
|
||
### Per-tool descriptor (`tool.ts`)
|
||
|
||
`describeToolGate` takes an optional `accessPath` in place of `platform`; `deriveSuggestionValue` reads `accessPath.value()` for the path branch:
|
||
|
||
```typescript
|
||
function deriveSuggestionValue(
|
||
tcc: ToolCallContext,
|
||
check: PermissionCheckResult,
|
||
accessPath?: AccessPath,
|
||
): string {
|
||
if (tcc.toolName === "bash") return check.command ?? "";
|
||
if (tcc.toolName === "mcp") return check.target ?? "mcp";
|
||
if (accessPath) return accessPath.value();
|
||
return "*";
|
||
}
|
||
|
||
export function describeToolGate(
|
||
tcc: ToolCallContext,
|
||
check: PermissionCheckResult,
|
||
formatter: ToolPreviewFormatter,
|
||
accessPath?: AccessPath,
|
||
): GateDescriptor { /* ... */ }
|
||
```
|
||
|
||
`accessPath.value()` is behavior-identical to today's `normalizePathForComparison(path, tcc.cwd, platform)`: `value()` returns `normalizePathForComparison(pathValue, resolveBase = cwd, platform)`, and the pipeline's normalizer is built from the same session `cwd` + `platform` as `tcc.cwd`.
|
||
The `normalizePathForComparison` import is removed from `tool.ts`.
|
||
`decision.value` is unchanged — it keeps using `getPathBearingToolPath(tcc.toolName, tcc.input)` (the **raw** referenced path) via `deriveDecisionValue`, so `getPathBearingToolPath` and `PATH_BEARING_TOOLS` imports stay.
|
||
|
||
This honors Tell-Don't-Ask (the descriptor asks the `AccessPath` for `value()`) and removes a parameter relay: `platform` was threaded session → pipeline → `describeToolGate` solely to feed one derivation that the `AccessPath` the gate already builds now owns.
|
||
|
||
### Remove the dead `getPlatform()` accessor (resolves [#513])
|
||
|
||
After the per-tool gate stops reading `platform`, `ToolCallGatePipeline.evaluate` no longer calls `getPlatform()`, and the accessor is dead.
|
||
Remove `getPlatform()` from `ToolCallGateInputs` (the pipeline's narrow input interface) and from `PermissionSession`, and drop the `getPlatform` field from `makeGateInputs`.
|
||
`this.platform` stays on `PermissionSession` (still feeds `getPathNormalizer()`); only the session-level accessor that existed to feed the residual reads is retired.
|
||
|
||
### Edge cases
|
||
|
||
- **Missing `input.path`:** `getPathBearingToolPath` returns `null` → `tool` intent → `["*"]` (preserved).
|
||
- **Not a symlink:** `matchValues()` collapses to the lexical aliases when canonical equals one of them — no spurious extra value.
|
||
- **Unresolvable path (ELOOP / EACCES / empty):** `canonicalNormalizePathForComparison` falls back to the lexical form; no new match beyond today's lexical behavior.
|
||
- **MCP / extension tools:** stay on `tool` (no change); their path is gated symlink-resistantly by the cross-cutting `path` gate.
|
||
|
||
## Module-Level Changes
|
||
|
||
Source:
|
||
|
||
- `src/handlers/gates/tool-call-gate-pipeline.ts` — sixth producer builds an `AccessPath` and emits `access-path` for path-bearing tools (else `tool`); pass `accessPath` to `describeToolGate`; remove the `const platform = this.inputs.getPlatform()` read and its arg.
|
||
Add imports: `getPathBearingToolPath` (`#src/path-utils`) and the `AccessPath` type (`#src/access-intent/access-path`).
|
||
- `src/handlers/gates/tool.ts` — `describeToolGate` and `deriveSuggestionValue` take an optional `accessPath` in place of `platform`; suggestion path branch reads `accessPath.value()`; remove the `normalizePathForComparison` import; add an `AccessPath` type import.
|
||
- `src/handlers/gates/tool-call-gate-pipeline.ts` (interface) — remove `getPlatform()` from `ToolCallGateInputs` and its doc comment.
|
||
- `src/permission-session.ts` — remove the `getPlatform()` method (keep the private `platform` field).
|
||
- `src/access-intent/access-intent.ts` — update the `AccessPathAccessIntent` doc comment: emitters now include the per-tool path-bearing surfaces (`read`/`write`/`edit`/`grep`/`find`/`ls`), not just `path` and `external_directory`.
|
||
|
||
Tests:
|
||
|
||
- `test/handlers/gates/tool.test.ts` — drop the `"linux"` fourth argument from every `describeToolGate` call; for the two session-approval cwd tests, build an `AccessPath` via `new PathNormalizer("linux", "/test/project").forPath(...)` and pass it; non-path / bash / mcp calls pass `undefined`; add a case asserting the suggestion derives from `accessPath.value()`.
|
||
- `test/handlers/gates/tool-call-gate-pipeline.test.ts` — add a test that a path-bearing tool with `input.path` emits an `access-path` intent on its tool-name surface (assert via a resolver mock dispatching on `intent.kind`/`intent.surface`); add a test that a per-tool `deny` matched against a symlinked path's canonical alias blocks; confirm non-path tools still emit `tool`.
|
||
- `test/helpers/gate-fixtures.ts` — remove the `getPlatform` override field and default from `makeGateInputs`.
|
||
|
||
Documentation (grep-verified — symbol/behavior named in prose):
|
||
|
||
- `docs/architecture/architecture.md` — mark Phase 7 Step 1 ([#502]) complete (`✅` on the step heading and the `S1` Mermaid node); update the `access-intent.ts` module entry (line ~678, per-tool surfaces now emit `access-path`) and the `tool.ts` entry (line ~712, approval value from `accessPath.value()`, no `platform`); rewrite the residual-`getPlatform()`-threading subsection (lines ~880–892) to record that both [#511] and [#502] have landed, so `getPlatform()` is removed and [#513] is resolved.
|
||
Leave the Phase-7 health-metric/target table unchanged (its rows describe the phase endpoint, not a per-step state).
|
||
- `docs/configuration.md` — the per-tool patterns section (lines ~356, ~404–407, and the per-tool surface description): state that per-tool path rules now also match the canonical (symlink-resolved) form, at parity with `path` (generalize the existing "Symlinked paths" note ~469–483 to name the per-tool surfaces).
|
||
- `README.md` — the per-tool path-patterns description (lines ~74–75): add that per-tool path rules now resist symlink-alias evasion like the cross-cutting `path` rule.
|
||
- `.pi/skills/package-pi-permission-system/SKILL.md` — update line ~127 (`getPathBearingToolPath` now also builds the per-tool gate's `AccessPath`, no longer only "cosmetic"), lines ~150–153 (the per-tool gate emits `access-path` on its tool-name surface; the `makeHandler` adapter still maps `path-values` → `surfaceCheck(surface, { path: values[0] })`), and line ~174 (drop the `/ getPlatform()` mention from the `PathNormalizer` exposure note).
|
||
|
||
## Test Impact Analysis
|
||
|
||
1. **New tests the change enables:**
|
||
- The per-tool gate denying/asking a symlink whose canonical target matches a per-tool `deny` pattern (e.g. `read: deny *.env` reached via a symlink) — testable at the pipeline level with an intent-kind-dispatching resolver mock.
|
||
- `describeToolGate` deriving the session-approval suggestion from an injected `AccessPath` — a more direct unit than the prior `input` + `cwd` + `platform` derivation.
|
||
2. **Tests that become redundant:** none removed; existing lexical-match and cwd-bounding assertions stay valid (lexical aliases are still in `matchValues()`, and `accessPath.value()` equals the old `normalizePathForComparison` result).
|
||
3. **Tests that must stay as-is:** the [#438] session-approval cwd-bounding tests in `tool.test.ts` ("binds a current-directory file's session approval to the cwd subtree", "resolves a sub-directory file's session approval to an absolute pattern") — they now pass an `AccessPath` but must keep asserting `/test/project/*` and `/test/project/src/*`; the bash/mcp suggestion and decision-value tests.
|
||
|
||
## Invariants at risk
|
||
|
||
This change touches surfaces [#486], [#438], and [#510] refactored.
|
||
Documented invariants and their pinning tests:
|
||
|
||
- **[#486] `path`/`external_directory` match lexical ∪ canonical** — extended (not regressed) to the per-tool surfaces; `path.ts` is untouched.
|
||
Pinned by `test/handlers/gates/path.test.ts` and `test/access-intent/access-path.test.ts`.
|
||
- **[#438] session approval is cwd-bounded (absolute, not `./*`)** — preserved: `accessPath.value()` equals the old `normalizePathForComparison(path, cwd, platform)`.
|
||
Pinned by the two cwd-bounding tests in `test/handlers/gates/tool.test.ts` — keep them green with the `AccessPath` argument.
|
||
- **[#510] `PathNormalizer` is the single platform/cwd home** — preserved: the per-tool gate now routes its path through `normalizer.forPath`, removing the last `platform` thread into the gate layer.
|
||
- **Missing-path `["*"]` fallback** — preserved by routing the no-`input.path` case through the `tool` intent; add/keep a pipeline test for a path-bearing tool with empty input.
|
||
|
||
## TDD Order
|
||
|
||
1. **`feat(pi-permission-system)!: match the canonical form on the per-tool path gate`** Test surface: `test/handlers/gates/tool.test.ts` + `test/handlers/gates/tool-call-gate-pipeline.test.ts`.
|
||
Migrate the sixth producer in `tool-call-gate-pipeline.ts` to emit `access-path` for path-bearing tools and pass the `AccessPath` to `describeToolGate`; change `describeToolGate`/`deriveSuggestionValue` to take the optional `accessPath` and read `value()`; remove the `platform` param, the `normalizePathForComparison` import, and the pipeline's `getPlatform()` read.
|
||
These break together at the type level (the `describeToolGate` signature change has a single call site and the test file), so they land in one commit.
|
||
Red: a `read`/`edit` on a symlink whose canonical target matches a per-tool `deny` is now blocked; the cwd-bounding approval tests stay green via the injected `AccessPath`.
|
||
Breaking — `feat!:` with a `BREAKING CHANGE:` footer.
|
||
`ToolCallGateInputs.getPlatform()` / `PermissionSession.getPlatform()` / `makeGateInputs.getPlatform` remain (defined but uncalled — a valid green state); run `pnpm run check` after this commit.
|
||
|
||
2. **`refactor(pi-permission-system): remove the unused getPlatform session accessor`** Test surface: type-level + `makeGateInputs`.
|
||
Remove `getPlatform()` from `ToolCallGateInputs` and `PermissionSession`, and the `getPlatform` field from `makeGateInputs`.
|
||
These break together (excess-property on the fixture literal once the interface drops the member), so one commit.
|
||
`tsc` + `pnpm fallow dead-code` confirm no remaining consumer.
|
||
Resolves [#513] (close it at ship with a "folded into #502" note).
|
||
|
||
3. **`docs(pi-permission-system): document canonical per-tool path matching`** Update `docs/architecture/architecture.md` (mark Step 1 ✅ + `S1` node ✅; module entries; residual-`getPlatform()` subsection), `docs/configuration.md`, `README.md`, `.pi/skills/package-pi-permission-system/SKILL.md`, and the `access-intent.ts` doc comment per Module-Level Changes.
|
||
No release impact on its own — rides the breaking `feat!:`.
|
||
|
||
## Risks and Mitigations
|
||
|
||
- **Risk: the suggestion value silently drifts from the policy values.**
|
||
Mitigation: `accessPath.value()` is provably identical to the old `normalizePathForComparison(path, cwd, platform)` (same cwd + platform via the session normalizer); the [#438] cwd-bounding tests pin it.
|
||
- **Risk: removing `getPlatform()` breaks an unseen consumer.**
|
||
Mitigation: grep confirms exactly three `src/` references and one fixture; `tsc` + `fallow dead-code` gate the removal.
|
||
- **Risk: a `prefer-const` / `no-unused-vars` lint snag on the new branch structure.**
|
||
Mitigation: if the two-`let` form trips a linter, extract a small `selectToolCheck` helper returning `{ accessPath, toolCheck }` (Code Design stepdown).
|
||
- **Risk: an existing user's per-tool rule starts matching a previously-unmatched symlinked path on upgrade.**
|
||
This is the intended breaking behavior; mitigation is the `BREAKING CHANGE:` footer and the docs update describing the new symlink-resistant per-tool matching.
|
||
|
||
## Open Questions
|
||
|
||
- **Close [#513] when this ships.**
|
||
Its scope (the `getPlatform()` removal) is folded into Step 2 here; no separate change remains, so close it at ship time with a pointer to the [#502] SHA.
|
||
- No other blocking questions.
|
||
The residual Phase 7 scope (Steps 2–5) is unaffected; this plan only completes Step 1 and the [#513] cleanup it forces.
|
||
|
||
[#438]: https://github.com/gotgenes/pi-packages/issues/438
|
||
[#486]: https://github.com/gotgenes/pi-packages/issues/486
|
||
[#487]: https://github.com/gotgenes/pi-packages/issues/487
|
||
[#503]: https://github.com/gotgenes/pi-packages/issues/503
|
||
[#504]: https://github.com/gotgenes/pi-packages/issues/504
|
||
[#510]: https://github.com/gotgenes/pi-packages/issues/510
|
||
[#511]: https://github.com/gotgenes/pi-packages/issues/511
|
||
[#513]: https://github.com/gotgenes/pi-packages/issues/513
|