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,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 8194) 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