mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
---
|
||||
issue: 503
|
||||
issue_title: "pi-permission-system: migrate the service/RPC path queries onto AccessPath (Phase 7 Step 2)"
|
||||
---
|
||||
|
||||
# Migrate the service/RPC path queries onto `AccessPath`
|
||||
|
||||
## Release Recommendation
|
||||
|
||||
**Release:** mid-batch — defer (batch "symlink-resistant-path-matching"); confirm at ship time
|
||||
|
||||
This is Phase 7 Step 2 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 2, not the tail, so its breaking `feat!:` commits land on `main` and auto-batch; the major-bump release cuts when Step 3 lands.
|
||||
Confirm the deferral at ship time.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
External callers query our policy two ways: the `Symbol.for()` service (`LocalPermissionsService.checkPermission`) and the deprecated event-bus RPC (`permissions:rpc:check`).
|
||||
Both build a query input with `buildInputForSurface` and resolve a `kind: "tool"` intent, which the manager normalizes **lexically only** via `normalizeInput` → `normalizePathSurfaceValues` → `getPathPolicyValues`.
|
||||
So an external caller asking "would this path be allowed?"
|
||||
for `path` / `external_directory` / a path-bearing surface gets lexical-only matching — inconsistent with the gates, which match the lexical aliases ∪ canonical (symlink-resolved) set after [#486] and Phase 7 Step 1 ([#502]).
|
||||
A query for a symlinked path therefore misses a rule that fires on the canonical alias at the gate.
|
||||
|
||||
This is the second of the two residual lexical-only path-derivation paths Phase 7 closes (Step 1 closed the per-tool gate).
|
||||
Route the service/RPC path queries through `AccessPath` and the resolver so external policy queries match the same set the gates do.
|
||||
|
||||
## Goals
|
||||
|
||||
- For `path` / `external_directory` / path-bearing surface queries (the `PATH_SURFACES` set) with a non-empty value, build an `AccessPath` from the value and emit a `kind: "access-path"` intent, so external policy queries match the lexical aliases ∪ canonical form — at parity with the gates.
|
||||
- Route both the service and RPC check paths through the **resolver** (`resolve(intent)`), which already unwraps `access-path` → `path-values` via `matchValues()`, keeping the manager string-based.
|
||||
This makes the resolver the sole `path-values` producer, the premise Phase 7 Step 5 ([#506]) decides the boundary against.
|
||||
- Keep non-path surfaces (bash, skill, mcp, extension tools) and value-less surface-level queries on the existing `kind: "tool"` intent — `["*"]` fallback preserved.
|
||||
- Narrow the service and RPC collaborators: the resolver subsumes the manager + `SessionRules` pair (it composes the session ruleset internally), so each consumer holds one resolution collaborator plus the session's `PathNormalizer`.
|
||||
|
||||
This is a **breaking change** for external consumers: a service/RPC path query now resolves against the canonical alias too.
|
||||
Two observable shifts on upgrade with no caller edit:
|
||||
|
||||
1. An `external_directory` query for a symlinked path now matches a rule on its canonical target where it previously matched lexically only.
|
||||
2. A `path` or path-bearing-tool query (`read` / `write` / `edit` / `grep` / `find` / `ls`) now evaluates the supplied path instead of collapsing to `["*"]` — see Background (latent gap).
|
||||
|
||||
The behavior commits are `feat(pi-permission-system)!:` with a `BREAKING CHANGE:` footer.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No removal of `input-normalizer`'s `normalizePathSurfaceValues` / special-surface / `PATH_BEARING_TOOLS` branches — that is Phase 7 Step 3 ([#504]), after Steps 1 and 2 strip their last gate/service/RPC callers.
|
||||
This plan leaves `normalizeInput` intact; the per-tool gate's missing-path case still routes through it for the `["*"]` fallback.
|
||||
- No decision on the `path-values` boundary (formalize the string seam vs. move the unwrap into the manager) — that is Phase 7 Step 5 ([#506]).
|
||||
This plan only advances its premise (resolver = sole producer).
|
||||
- No change to the RPC **prompt** handler (`permissions:rpc:prompt`) — it shows a dialog and never resolves policy.
|
||||
- No change to `path-utils.ts` derivation consolidation — that is Phase 7 Step 4 ([#505]).
|
||||
- No change to dedup/approval-key identity or principal identity on `AccessIntent`.
|
||||
|
||||
## Background
|
||||
|
||||
Relevant modules (all in `packages/pi-permission-system/`):
|
||||
|
||||
- `src/permissions-service.ts` — `LocalPermissionsService.checkPermission(surface, value, agentName)` builds `buildInputForSurface(surface, value)` and calls `permissionManager.check({ kind: "tool", surface, input, agentName }, sessionRules.getRuleset())`.
|
||||
`getToolPermission` delegates to `permissionManager.getToolPermission`.
|
||||
Constructed in `index.ts` with `(permissionManager, sessionRules, formatterRegistry, accessExtractorRegistry)` (narrowed in [#366]).
|
||||
- `src/permission-event-rpc.ts` — `handleCheckRpc` builds `buildInputForSurface(surface, value)` and calls `deps.permissionManager.check({ kind: "tool", ... }, deps.sessionRules.getRuleset())`.
|
||||
The deprecated channel (`/* eslint-disable @typescript-eslint/no-deprecated */` at the top).
|
||||
`handlePromptRpc` uses `value` only for the UI prompt display; it never resolves policy.
|
||||
- `src/input-normalizer.ts` — `buildInputForSurface(surface, value)` is the inverse of `normalizeInput`: it builds the minimal `tool`-intent input from a `(surface, value)` pair.
|
||||
It maps `bash` → `{ command }`, `skill` → `{ name }`, `external_directory` → `{ path }`, and **everything else (including `path` and the path-bearing tools) → `{}`** — so the value is dropped for those surfaces.
|
||||
- `src/permission-resolver.ts` — `PermissionResolver.resolve(intent: AccessIntent)` reduces a gate-emitted intent to the manager's `ResolvedAccessIntent`, unwrapping `access-path` → `path-values` via `path.matchValues()`, and composes `sessionRules.getRuleset()` internally so callers never thread it.
|
||||
Also exposes `getToolPermission`, `getConfigIssues`, `checkPermission` (the no-session-rules skill path).
|
||||
Constructed in `index.ts` as `new PermissionResolver(permissionManager, sessionRules)` — **after** the service and RPC today (line ~177 vs. ~145 / ~137); the move-up is mechanical (it depends only on `permissionManager` + `sessionRules`).
|
||||
- `src/permission-session.ts` — `getPathNormalizer(): PathNormalizer` returns the session's normalizer, rebuilt on each `activate(ctx)` / `resetForNewSession` so it tracks the active cwd.
|
||||
A placeholder (`new PathNormalizer(platform, "")`) until the first `activate` binds the real cwd.
|
||||
- `src/path-normalizer.ts` — `PathNormalizer.forPath(value)` builds an `AccessPath` resolved against the baked session `cwd` + `platform`.
|
||||
- `src/access-intent/access-intent.ts` — `AccessPathAccessIntent` (`kind: "access-path"`); its doc comment names the gate emitters (the `path`/`external_directory`/per-tool surfaces).
|
||||
- `src/path-utils.ts` — `PATH_SURFACES = PATH_BEARING_TOOLS ∪ { "external_directory", "path" }`.
|
||||
- `src/value-guards.ts` — `getNonEmptyString(value): string | null` (trims; `null` for empty/whitespace).
|
||||
|
||||
### Latent gap this fixes
|
||||
|
||||
Because `buildInputForSurface` only wires the value into `external_directory` (returns `{ path }`) and the catch-all `{}` for `path` and the path-bearing tools, a service/RPC query like `checkPermission("read", "/etc/passwd")` today normalizes to `["*"]` — the supplied path is **silently dropped** (asserted today by `test/service.test.ts`: `checkPermission("read", "/tmp/file")` → input `{}`).
|
||||
So in practice the only meaningful path query was `external_directory`.
|
||||
Building an `AccessPath` for the whole `PATH_SURFACES` set fixes this drop as a natural consequence — `path` and path-bearing queries now evaluate the supplied path.
|
||||
This is part of the breaking surface and is documented as such.
|
||||
|
||||
### Constraints (AGENTS.md / SKILL)
|
||||
|
||||
- The manager stays string-based and never imports `AccessPath`; the resolver does the `matchValues()` unwrap.
|
||||
This plan preserves that — the service/RPC emit `access-path` to the resolver, the resolver unwraps, the manager is untouched.
|
||||
- Default to least privilege: the change only widens the match set (more rules can fire), never loosens — no `ask`/`deny` becomes `allow`.
|
||||
|
||||
## Design Overview
|
||||
|
||||
### Routing decision: through the resolver, not a second `path-values` producer
|
||||
|
||||
The manager's `check` accepts only `ResolvedAccessIntent` (`tool | path-values`); it cannot consume an `access-path` intent.
|
||||
Two ways to give the service/RPC canonical parity:
|
||||
|
||||
1. Have the service/RPC build the `AccessPath`, call `matchValues()` themselves, and pass a `path-values` intent to `manager.check`.
|
||||
2. Have the service/RPC emit an `access-path` intent to `resolver.resolve`, which unwraps it.
|
||||
|
||||
Option 1 makes the service/RPC a **second** `path-values` producer, contradicting the premise Phase 7 Step 5 ([#506]) decides against ("with the resolver the sole `path-values` producer after Steps 1 and 2").
|
||||
Option 2 is chosen: it routes both consumers through the resolver — the single unwrap site — and is a clean 1:1 substitution for today's `manager.check(..., sessionRules.getRuleset())`, since `resolver.resolve` does exactly that plus the unwrap.
|
||||
|
||||
### Shared intent builder (`input-normalizer.ts`)
|
||||
|
||||
Add `buildAccessIntentForSurface`, the surface→intent mapping shared by the service and RPC.
|
||||
It builds an `access-path` intent for a `PATH_SURFACES` surface carrying a non-empty value, and a `tool` intent (via the existing `buildInputForSurface`) otherwise:
|
||||
|
||||
```typescript
|
||||
import type { AccessIntent } from "./access-intent/access-intent";
|
||||
import type { PathNormalizer } from "./path-normalizer";
|
||||
import { PATH_SURFACES } from "./path-utils";
|
||||
import { getNonEmptyString } from "./value-guards";
|
||||
|
||||
export function buildAccessIntentForSurface(
|
||||
surface: string,
|
||||
value: string | undefined,
|
||||
normalizer: PathNormalizer,
|
||||
agentName: string | undefined,
|
||||
): AccessIntent {
|
||||
const pathValue = getNonEmptyString(value);
|
||||
if (pathValue !== null && PATH_SURFACES.has(surface)) {
|
||||
return { kind: "access-path", surface, path: normalizer.forPath(pathValue), agentName };
|
||||
}
|
||||
return { kind: "tool", surface, input: buildInputForSurface(surface, value), agentName };
|
||||
}
|
||||
```
|
||||
|
||||
`buildInputForSurface` stays exported (still used here for the `tool` branch, and imported by `test/service.test.ts`).
|
||||
No import cycle: `path-normalizer.ts` and `access-intent/access-intent.ts` do not import `input-normalizer.ts`; `PathNormalizer` / `AccessIntent` are `import type`.
|
||||
|
||||
The `getNonEmptyString` guard preserves the value-less surface-level query (`checkPermission("path")` → `tool` → `["*"]`) and the whitespace-only case, matching today's `normalizePathSurfaceValues` `["*"]` fallback.
|
||||
|
||||
### Service (`permissions-service.ts`)
|
||||
|
||||
Swap the `(permissionManager, sessionRules)` pair for a single resolver plus the session's `PathNormalizer` provider:
|
||||
|
||||
```typescript
|
||||
interface ResolverForService {
|
||||
resolve(intent: AccessIntent): PermissionCheckResult;
|
||||
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
||||
}
|
||||
interface PathNormalizerProvider {
|
||||
getPathNormalizer(): PathNormalizer;
|
||||
}
|
||||
|
||||
export class LocalPermissionsService implements PermissionsService {
|
||||
constructor(
|
||||
private readonly resolver: ResolverForService,
|
||||
private readonly session: PathNormalizerProvider,
|
||||
private readonly formatterRegistry: ToolInputFormatterRegistrar,
|
||||
private readonly accessExtractorRegistry: ToolAccessExtractorRegistrar,
|
||||
) {}
|
||||
|
||||
checkPermission(surface, value, agentName) {
|
||||
const intent = buildAccessIntentForSurface(
|
||||
surface, value, this.session.getPathNormalizer(), agentName,
|
||||
);
|
||||
return this.resolver.resolve(intent);
|
||||
}
|
||||
|
||||
getToolPermission(toolName, agentName) {
|
||||
return this.resolver.getToolPermission(toolName, agentName);
|
||||
}
|
||||
// registerToolInputFormatter / registerToolAccessExtractor unchanged
|
||||
}
|
||||
```
|
||||
|
||||
`PermissionResolver` satisfies `ResolverForService`; `PermissionSession` satisfies `PathNormalizerProvider`.
|
||||
`getPathNormalizer()` is fetched **per call** (the normalizer rebinds on cwd change), and the published service always answers against the parent session's cwd (a child never publishes, [#302]).
|
||||
The service holds one resolution collaborator — narrower than today's manager + `SessionRules`, keeping the [#366] narrowing intent (4 fields → 4 fields, but the resolution surface collapses to one).
|
||||
|
||||
### RPC (`permission-event-rpc.ts`)
|
||||
|
||||
`PermissionRpcDeps` drops `permissionManager` and `sessionRules`, gains `resolver`, and extends the narrow `session` view with `getPathNormalizer`:
|
||||
|
||||
```typescript
|
||||
export interface PermissionRpcDeps {
|
||||
resolver: Pick<ScopedPermissionResolver, "resolve">;
|
||||
session: {
|
||||
getRuntimeContext(): ExtensionContext | null;
|
||||
getPathNormalizer(): PathNormalizer;
|
||||
};
|
||||
requestPermissionDecisionFromUi(/* … */): Promise<PermissionPromptDecision>;
|
||||
logger: ReviewLogger;
|
||||
}
|
||||
```
|
||||
|
||||
`handleCheckRpc` builds the intent and resolves:
|
||||
|
||||
```typescript
|
||||
const intent = buildAccessIntentForSurface(
|
||||
surface, value, deps.session.getPathNormalizer(), agentName ?? undefined,
|
||||
);
|
||||
const result = deps.resolver.resolve(intent);
|
||||
```
|
||||
|
||||
The reply shape (`result.state` / `matchedPattern` / `origin`) is unchanged.
|
||||
`handlePromptRpc` is untouched (it uses neither collaborator).
|
||||
|
||||
### Composition root (`index.ts`)
|
||||
|
||||
- Move `const resolver = new PermissionResolver(permissionManager, sessionRules);` up to before `registerPermissionRpcHandlers` (the only ordering change; downstream consumers reference the same const).
|
||||
- RPC deps: `{ resolver, session, requestPermissionDecisionFromUi, logger }` (the full `PermissionSession` satisfies both narrow `session` needs).
|
||||
- `new LocalPermissionsService(resolver, session, formatterRegistry, accessExtractorRegistry)`.
|
||||
|
||||
### Call-site interaction sketch (Law of Demeter / Tell-Don't-Ask)
|
||||
|
||||
The service hands the normalizer to the builder rather than reaching through it:
|
||||
|
||||
```typescript
|
||||
const normalizer = this.session.getPathNormalizer(); // a.b() — the documented session accessor
|
||||
const intent = buildAccessIntentForSurface(surface, value, normalizer, agentName);
|
||||
return this.resolver.resolve(intent); // resolver owns the unwrap + session-rule composition
|
||||
```
|
||||
|
||||
`forPath` is invoked inside the builder, not by the service — no `session.getPathNormalizer().forPath(...)` chain at the consumer.
|
||||
This mirrors the gate pipeline's established `inputs.getPathNormalizer()` → builder convention.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- **Value-less / whitespace-only path query:** `getNonEmptyString` → `null` → `tool` intent → `["*"]` (preserved).
|
||||
- **Non-path surface (bash/skill/mcp/extension):** `tool` intent via `buildInputForSurface` (unchanged).
|
||||
- **Not a symlink:** `matchValues()` collapses to the lexical aliases — no spurious extra value.
|
||||
- **Unresolvable path (ELOOP / EACCES):** `AccessPath`'s canonical step falls back to the lexical form — no new match beyond today's lexical behavior.
|
||||
- **Child session:** never publishes the service; the parent's normalizer answers external queries.
|
||||
|
||||
## Module-Level Changes
|
||||
|
||||
Source:
|
||||
|
||||
- `src/input-normalizer.ts` — add `buildAccessIntentForSurface` (path-surface → `access-path`, else `tool`); keep `buildInputForSurface` exported as the `tool`-branch input builder.
|
||||
Add imports: `PATH_SURFACES` (`#src/path-utils`), `getNonEmptyString` is already imported, type imports `AccessIntent` (`#src/access-intent/access-intent`) and `PathNormalizer` (`#src/path-normalizer`).
|
||||
- `src/permissions-service.ts` — constructor takes `(resolver, session, formatterRegistry, accessExtractorRegistry)`; `checkPermission` builds the intent via `buildAccessIntentForSurface` and calls `resolver.resolve`; `getToolPermission` delegates to `resolver.getToolPermission`.
|
||||
Define local `ResolverForService` + `PathNormalizerProvider` interfaces; drop the `ScopedPermissionManager` / `SessionRules` / `buildInputForSurface` imports, add `buildAccessIntentForSurface`, `AccessIntent`, `PathNormalizer`, `PermissionState` type imports.
|
||||
- `src/permission-event-rpc.ts` — `PermissionRpcDeps` drops `permissionManager` + `sessionRules`, adds `resolver: Pick<ScopedPermissionResolver, "resolve">`, extends `session` with `getPathNormalizer`; `handleCheckRpc` builds the intent and calls `deps.resolver.resolve`.
|
||||
Swap the `ScopedPermissionManager` import for `ScopedPermissionResolver`, drop `buildInputForSurface`, add `buildAccessIntentForSurface` and a `PathNormalizer` type import.
|
||||
- `src/index.ts` — move the `resolver` construction above the RPC registration; pass `resolver` + `session` into the RPC deps and `LocalPermissionsService`.
|
||||
- `src/access-intent/access-intent.ts` — update the `AccessPathAccessIntent` doc comment: emitters now also include the service/RPC path queries (not only the gates).
|
||||
|
||||
Tests:
|
||||
|
||||
- `test/input-normalizer.test.ts` — add a `buildAccessIntentForSurface` describe: a `PATH_SURFACES` surface (`path`, `external_directory`, `read`) with a value emits `access-path` whose `path.matchValues()` carries the canonical alias (use the `node:fs` `realpathSync` mock convention from `path.test.ts` for the symlink case); a non-path surface (`bash`) emits `tool` with `buildInputForSurface` input; an empty/whitespace value on a path surface emits `tool` (`["*"]` path via the manager).
|
||||
- `test/permissions-service.test.ts` — rewrite to inject a fake resolver (`resolve` + `getToolPermission` stubs) and a real `PathNormalizer` provider; assert `checkPermission("bash", "echo hi")` calls `resolver.resolve` with a `tool` intent; `checkPermission("external_directory", "/sym/link")` and `checkPermission("read", "/p")` call it with an `access-path` intent whose `path` matches the expected lexical ∪ canonical set; a value-less path query falls to `tool`; `getToolPermission` delegates to `resolver.getToolPermission`.
|
||||
Drop the `vi.mock("#src/input-normalizer")` `buildInputForSurface` stub.
|
||||
- `test/permission-event-rpc.test.ts` — `makeDeps` swaps `permissionManager` + `sessionRules` for `resolver: { resolve: vi.fn() }` and adds `session.getPathNormalizer`; the existing allow/deny check-RPC tests assert on `resolver.resolve` instead of `permissionManager.check`; add a path-surface RPC test asserting an `access-path` intent reaches the resolver (canonical alias in the match set).
|
||||
- `test/service.test.ts` — update the "service adapter delegation" describe: replace the hand-rolled `buildInputForSurface` adapter (which simulated the **old** `index.ts` wiring) with the current `buildAccessIntentForSurface` + resolver pattern, so the round-trip tests document the new wiring; the stale `checkPermission("read", "/tmp/file") → {}` assertion is replaced by an `access-path`-intent assertion.
|
||||
- `test/composition-root.test.ts` — the existing `checkPermission("demo")` queries (non-path) stay green through the new wiring; add a path-surface service query (`checkPermission("path", <symlink>)`) asserting canonical matching end-to-end via the real factory (the harness fires `session_start`, so `getPathNormalizer()` is cwd-bound).
|
||||
|
||||
Documentation (grep-verified — symbol/behavior named in prose):
|
||||
|
||||
- `docs/architecture/architecture.md` — mark Phase 7 Step 2 ([#503]) complete (`✅` on the step heading ~line 801 and the `S2` Mermaid node ~line 835); update the `permissions-service.ts` entry (~line 716) and `permission-event-rpc.ts` entry (~line 720) to note they route path-surface queries through the resolver as `access-path`; rewrite the Phase-7 intro framing (~line 768, "Two ad-hoc path-derivation paths remain") and the residual "Service/RPC queries" bullet (~line 788) to past tense now both access-side parity migrations (Steps 1 and 2) have landed.
|
||||
Leave the health-metric/target table (~line 778) unchanged (it describes the phase endpoint, not a per-step state — per the [#502] precedent).
|
||||
- `docs/cross-extension-api.md` — the `checkPermission` section (~lines 81–94) and the RPC `permissions:rpc:check` section (~line 444): add that `path` / `external_directory` / path-bearing path values now match the canonical (symlink-resolved) form, at parity with the gates, and that a path-bearing-surface query now evaluates the supplied path (previously collapsed to `*`).
|
||||
- `.pi/skills/package-pi-permission-system/SKILL.md` — update the `LocalPermissionsService` note (~line 118) to record that path-surface service/RPC queries route through the resolver as `access-path` (canonical parity), fetching the session `PathNormalizer` per call.
|
||||
|
||||
README is not updated: it documents config surfaces, not the `getPermissionsService` query API, and already describes symlink-resistant `path`/per-tool matching.
|
||||
|
||||
## Test Impact Analysis
|
||||
|
||||
1. **New tests the change enables:**
|
||||
- `buildAccessIntentForSurface` as a directly unit-testable surface→intent mapping (no manager round-trip needed) — the symlink-canonical match set is asserted on the built `AccessPath`.
|
||||
- A service/RPC path query whose canonical alias matches a `deny` (e.g. `external_directory` reached via a symlink) — assertable with a fake resolver dispatching on `intent.kind` / `intent.surface`.
|
||||
- A `read`/`path` query now evaluates the supplied path (the latent-gap fix), replacing the old `→ {}` drop assertion.
|
||||
2. **Tests that become redundant:** the `test/service.test.ts` hand-rolled-adapter tests that simulated the old `buildInputForSurface` index.ts wiring lose their reason to exist as wiring docs — folded into the rewritten "service adapter delegation" block (current wiring) and `permissions-service.test.ts` (real class).
|
||||
No test is deleted outright.
|
||||
3. **Tests that must stay as-is:** the existing `permission-event-rpc.test.ts` reply-shape / error-path / prompt-RPC tests (they exercise the envelope and the untouched prompt handler); the `normalizeInput` / `buildInputForSurface` tests in `input-normalizer.test.ts` (the `tool`-branch path is unchanged); `permission-resolver.test.ts` (the unwrap site, now load-bearing for two more consumers).
|
||||
|
||||
## Invariants at risk
|
||||
|
||||
This change touches surfaces [#478], [#486], and [#366] refactored.
|
||||
|
||||
- **[#486] / [#478] — the resolver is the sole `path-values` producer; the manager stays string-based and never imports `AccessPath`.**
|
||||
Preserved and advanced: the service/RPC now emit `access-path` to the resolver (not `path-values` to the manager).
|
||||
Pinned by `test/permission-resolver.test.ts` (the unwrap) plus the new service/RPC tests asserting an `access-path` (not `path-values`) intent reaches `resolve`.
|
||||
- **[#478] — manager/resolver each expose a single resolution method.**
|
||||
Preserved: no new resolution method; the service routes through `resolve` / `getToolPermission`.
|
||||
- **[#366] — narrow service collaborators.**
|
||||
Preserved/advanced: the resolution surface collapses from manager + `SessionRules` to one resolver.
|
||||
Pinned by `test/permissions-service.test.ts`.
|
||||
- **Value-less / missing-path `["*"]` fallback.**
|
||||
Preserved by the `getNonEmptyString` guard routing value-less path queries through the `tool` intent.
|
||||
Pinned by a `buildAccessIntentForSurface` test and a service value-less-query test.
|
||||
|
||||
No [#438] session-approval invariant is at risk: the service/RPC are query-only (no prompting / approval-pattern derivation).
|
||||
|
||||
## TDD Order
|
||||
|
||||
1. **`feat(pi-permission-system)!: match the canonical form on service path queries`** Test surface: `test/input-normalizer.test.ts` + `test/permissions-service.test.ts` + `test/service.test.ts` + `test/composition-root.test.ts`.
|
||||
Add `buildAccessIntentForSurface` to `input-normalizer.ts`; migrate `LocalPermissionsService` onto `(resolver, session, …)` using it; move the `resolver` construction up in `index.ts` and change the `LocalPermissionsService` call site.
|
||||
The helper's first consumer is the service, so it lands non-dead.
|
||||
The constructor change has a single production call site (`index.ts`), so the class change + call-site update land together.
|
||||
Red: a service `external_directory` query for a symlinked path now matches a `deny` on its canonical target; a `read` query now evaluates the supplied path (not `*`); the bash/non-path queries stay on `tool`.
|
||||
Breaking — `feat!:` with a `BREAKING CHANGE:` footer (service path queries now match canonical; path-bearing queries now evaluate the path).
|
||||
Run `pnpm run check` after this commit (constructor + interface change).
|
||||
The RPC still uses its old `{ permissionManager, sessionRules, … }` deps here — a valid green state.
|
||||
|
||||
2. **`feat(pi-permission-system)!: match the canonical form on the RPC check query`** Test surface: `test/permission-event-rpc.test.ts`.
|
||||
Change `PermissionRpcDeps` (drop `permissionManager` + `sessionRules`, add `resolver`, extend `session` with `getPathNormalizer`); migrate `handleCheckRpc` onto `buildAccessIntentForSurface` + `resolver.resolve`; update the RPC deps in `index.ts` (the `resolver` is already constructed above from Step 1).
|
||||
The deps change + `makeDeps` fixture + `index.ts` call site break together (one commit).
|
||||
Red: a `permissions:rpc:check` query for a symlinked `external_directory` path now matches the canonical alias; allow/deny/reply-shape tests stay green against the resolver.
|
||||
Breaking — `feat!:` with a `BREAKING CHANGE:` footer (RPC check now matches canonical).
|
||||
|
||||
3. **`docs(pi-permission-system): document canonical service/RPC path matching`** Update `docs/architecture/architecture.md` (mark Step 2 ✅ + `S2` node; `permissions-service.ts` / `permission-event-rpc.ts` entries; intro framing + residual bullet to past tense), `docs/cross-extension-api.md` (checkPermission + RPC-check canonical note + path-bearing-query fix), `.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!:` commits.
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
- **Risk: the resolver-injection rewiring is broader than "swap the intent."**
|
||||
Mitigation: it is a clean 1:1 substitution — `resolver.resolve(intent)` does exactly what `manager.check(toResolvedIntent(intent), sessionRules.getRuleset())` did, plus the `access-path` unwrap; the resolver subsumes the dropped `SessionRules` dependency.
|
||||
The only ordering change is moving one `const resolver = …` up, with no new dependency for the resolver.
|
||||
- **Risk: the published service answers against the wrong cwd.**
|
||||
Mitigation: `getPathNormalizer()` is fetched per call and the service is published only by the parent ([#302]); a `composition-root.test.ts` path query exercises the cwd-bound normalizer after `session_start`.
|
||||
- **Risk: the latent path-bearing-query fix surprises a consumer relying on the old `*` collapse.**
|
||||
Mitigation: this is the intended breaking behavior; documented in the `BREAKING CHANGE:` footer, the cross-extension API doc, and the close comment.
|
||||
The change only widens the match set (least-privilege preserving).
|
||||
- **Risk: an import cycle from `input-normalizer.ts` importing `PathNormalizer` / `AccessIntent`.**
|
||||
Mitigation: both are `import type`, and neither `path-normalizer.ts` nor `access-intent/access-intent.ts` imports `input-normalizer.ts`; `pnpm run check` confirms.
|
||||
- **Risk: a stale fallow suppression surfaces (as in [#502]).**
|
||||
Mitigation: run `pnpm fallow dead-code` after the source steps; the baseline check/lint/test triad does not catch a now-stale suppression.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None blocking.
|
||||
Step 3 ([#504]) removes the now-callerless `input-normalizer` path branches; Step 5 ([#506]) decides the `path-values` boundary — both already filed, both unblocked by this work.
|
||||
No new follow-up issue is needed (the latent path-bearing-query gap is fixed inline, not deferred).
|
||||
|
||||
[#302]: https://github.com/gotgenes/pi-packages/issues/302
|
||||
[#366]: https://github.com/gotgenes/pi-packages/issues/366
|
||||
[#438]: https://github.com/gotgenes/pi-packages/issues/438
|
||||
[#478]: https://github.com/gotgenes/pi-packages/issues/478
|
||||
[#486]: https://github.com/gotgenes/pi-packages/issues/486
|
||||
[#487]: https://github.com/gotgenes/pi-packages/issues/487
|
||||
[#502]: https://github.com/gotgenes/pi-packages/issues/502
|
||||
[#504]: https://github.com/gotgenes/pi-packages/issues/504
|
||||
[#505]: https://github.com/gotgenes/pi-packages/issues/505
|
||||
[#506]: https://github.com/gotgenes/pi-packages/issues/506
|
||||
Reference in New Issue
Block a user