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,120 @@
---
issue: 510
issue_title: "Thread an injected platform/path-semantics seam through the bash path pipeline"
---
# Retro: #510 — Thread an injected platform/path-semantics seam through the bash path pipeline
## Stage: Planning (2026-06-28T00:00:00Z)
### Session summary
Planned the refactor that completes the half-built platform seam in the bash path pipeline.
The operator's `ask_user` answers reshaped the design away from the issue's literal "thread a `PathSemantics`/`NodeJS.Platform` knob" framing toward a single injected collaborator — `PathNormalizer` — constructed at the edge with both `platform` and `cwd` baked in, handed raw tokens, and returning the prepared values (`AccessPath`s + routing answers) the gates expect ("prepare the data before evaluation, not during evaluation").
Confirmed a behavior-preserving `refactor:` that batches into [#508]'s `fix:` release, full enforcement scope (lint guard + `rule.ts`/`subagent-context.ts` cleanup), and a 10-step lift-and-shift TDD order.
### Observations
- **Design steer (via `ask_user`).**
Rejected both "bare `NodeJS.Platform`" and a passive `PathSemantics` value bag.
The operator's framing: hand paths to a collaborator that *owns* platform + cwd and figures out the normalized forms.
Result: `PathNormalizer` (name chosen over `AccessPathFactory`/`PathInterpreter`), single collaborator owning both `AccessPath` construction and routing (`isAbsolute`/`resolveBase`/`joinBase`/`isWithinDirectory`/`isOutsideWorkingDirectory`).
- **Two distinct edges.**
`process.platform` is process-global → read once in `index.ts` (factory body), injected into `PermissionManager` (for `rule.ts`) and `PermissionSession`.
`cwd` is session-scoped → not available in the factory body; arrives at `session_start` via `resetForNewSession(ctx)`, so `PathNormalizer` is built there and exposed via `getPathNormalizer()` on the existing `ToolCallGateInputs` seam.
- **`cwd` source change is the main risk.**
Today the pipeline reads `ctx.cwd` per tool call; baking it into the session normalizer assumes per-session stability.
Holds for Pi (a session is bound to one project dir), and `resetForNewSession` rebinds on every `/new`/`/resume`/`/fork`.
Flagged with a composition-root regression test.
- **Behavior-preserving by construction.**
Every converted interior op already used host `node:path`/`process.platform`, so the host-default result is identical; the only POSIX-hard-coded drift (`isRelativeCandidate`'s `startsWith("/")`) is deliberately left as-is and deferred to [#508], keeping this a pure `refactor:` with no observable POSIX change.
`canonicalizePath`'s new `win32` split is a no-op on POSIX and a latent fix validated by injected-`win32` unit tests.
- **Release framing.**
Not in the Phase 7 roadmap (the issue proposes it as a new step); marker is "ship independently" with the rationale that a `refactor:` does not cut its own release — it auto-batches into [#508]'s `fix:`.
Avoided the formal batch-tail marker since there is no named roadmap batch.
- **Scope boundary vs. [#505].**
`PathNormalizer` is a facade over the platform-parameterized `path-utils`/`AccessPath`, not a relocation; the Phase 7 [#505] path-utils dissolution can later move internals behind it without re-touching the seam.
No new follow-up issue filed (the deferred `isRelativeCandidate` conversion already lives in [#508]).
- **Testability payoff.**
The whole point is exercising Windows behavior on a POSIX CI by injecting a `win32` `PathNormalizer` — no `vi.mock("node:path")`.
## Stage: Implementation — TDD (2026-06-28T18:35:00Z)
### Session summary
Completed the 10-step plan: steps 16 (leaf-normalizer platform flavor, `AccessPath` platform option, the `PathNormalizer` collaborator, session-edge construction, the `BashPathResolver` rename, and the gate migration) landed in a prior session; this session executed steps 710 (inject `platform` into `rule.ts` evaluation, into `subagent-context.ts` detection, the `process.platform` ESLint guard + removal of all interior defaults, and the architecture/SKILL docs).
The test count rose from 2183 to 2189 (+6: the `rule.ts` and `subagent-context.ts` win32/posix injection assertions).
Final state: all 10 `#510` commits green on `pnpm run check` / `pnpm run lint` / 2189 tests / `pnpm fallow dead-code`; pre-completion reviewer returned **PASS**.
### Observations
- **`required` vs defaulted platform param (operator decision via `ask_user`).**
Chose required params (no `= process.platform` default) on `rule.ts`, `subagent-context.ts`, and every `path-utils`/`canonicalize-path` leaf — fully `tsc`-enforced threading — over a lower-churn posix-literal default.
`PermissionManager`'s constructor option `platform?` keeps an internal `?? "linux"` default (its only production caller, `index.ts`, passes `hostPlatform`), which contained the manager-construction test churn.
- **`evaluate` param reorder.**
`evaluate`'s optional `defaultAction` blocked a required trailing `platform`, so the signature became `evaluate(surface, pattern, rules, platform, defaultAction?)`.
Migrated ~84 `rule.ts`/`synthesize`/`session-rules` test call sites with a paren-balancing Python script (append `"linux"` for 3-arg calls, insert before `"deny"` for `defaultAction` calls, drop the old `undefined,` slot for the win32 calls).
- **Step-9 blast radius exceeded the plan (operator-confirmed deviation).**
The plan named only `isPathWithinDirectory`/`isPiInfrastructureRead`, but the package-wide lint guard forbids the *text* `process.platform`, so **all** leaf defaults had to go.
That forced threading `platform` to ~6 production sites the plan did not enumerate: `input-normalizer` (via `manager.platform`), the `tool`/`skill-read`/`external-directory` gates (via a new `ToolCallGateInputs.getPlatform()` off the session), and `skill-prompt-sanitizer` (via `before-agent-start``session.getPlatform()`).
Test churn (~93 path-utils call sites + `AccessPath.forPath({ cwd })` object injection) was automated with a second paren-balancing script.
- **Lint-guard sanity check gotcha.**
Verifying the guard fires (temporarily adding an interior `process.platform`) used `git checkout` to revert — which also reverted the *uncommitted* step-9 change to `canonicalize-path.ts`.
Caught it (`grep "= process.platform"`) and re-applied before committing.
- **`getPlatform()` alongside `getPathNormalizer()`.**
Two session accessors on `ToolCallGateInputs`: the normalizer for the bash pipeline, and the bare `platform` for the rule/gate sites that call raw `path-utils`/`isPiInfrastructureRead` rather than an `AccessPath` op.
The reviewer confirmed the split is correct (path-interpretation vs. rule case-folding are distinct concerns).
- **Pre-completion reviewer: PASS** — all deterministic checks green, all cross-step invariants ([#418], [#393], [#308], [#382], [#478]) preserved, all 4 Mermaid diagrams validated, no dead code, docs forward/reverse complete.
No WARN findings.
- **Doc note.** `architecture.md` recorded the seam as "### Related: PathNormalizer platform seam ([#510])" under Phase 7 (a precursor refactor, not one of the five Phase 7 steps), updated the `cwd-projection.ts``bash-path-resolver.ts` rename, the `BashProgram.parse` signature, the `evaluate()` pseudo-code, and added a `path-normalizer.ts` module entry.
## Stage: Final Retrospective (2026-06-28T20:30:00Z)
### Session summary
Shipped [#510] as `pi-permission-system` v17.1.0 (the lone `feat:``add PathNormalizer collaborator` — promoted the otherwise-`refactor:` batch into a minor release, exactly as the plan predicted).
During the post-implementation review the operator's question "what besides `PathNormalizer` references the platform-taking utilities?"
surfaced residual `getPlatform()` threading the seam had left behind, which was documented in `architecture.md` and filed as follow-up [#511].
The dominant arc-wide theme: the operator's `required`-param choice generated large mechanical test churn, and the step-9 lint-guard blast radius repeatedly exceeded the plan's stated scope.
### Observations
#### What went well
- **Paren-balancing migration scripts.**
Two disposable Python scripts (top-level-arg splitter + balanced-paren insertion) migrated ~177 call sites across `rule.ts`/`synthesize`/`session-rules` (step 7) and the path-utils/`AccessPath.forPath` consumers (step 9) — a novel, effective answer to a required-param signature change with noise-arg churn that hand-editing would have made error-prone.
- **Operator Q&A surfaced real debt.**
The "what else references these utilities?"
question caught the residual `getPlatform()` straggler sites (infra-read containment, skill-prompt sanitization) the implementation left un-folded; it was mapped to the Phase 7 roadmap and tracked as [#511] rather than left implicit.
A clean bidirectional win.
- **Clean ship.**
The release-please PR's `UNSTABLE`-no-checks state was the expected `GITHUB_TOKEN` case; the documented `gh pr merge --rebase` fallback worked, and v17.1.0 landed by rebase with the baseline auto-advanced.
#### What caused friction (agent side)
- `missing-context` (planning) — step 9 underscoped the lint-guard blast radius: the plan named only `isPathWithinDirectory`/`isPiInfrastructureRead`, but a `no-restricted-syntax` guard bans the *text* `process.platform` everywhere, so **all** seven leaf defaults had to go in one atomic commit, making each param required and cascading to ~6 unplanned production callers (`input-normalizer`, the `tool`/`skill-read`/`external-directory` gates, `skill-prompt-sanitizer`) plus ~93 test edits.
Impact: self-identified (caught by `tsc`), no wrong-direction rework, but two mid-TDD `ask_user` rounds, a new `getPlatform()` session accessor, and a second migration script.
- `premature-convergence` — the first param-style `ask_user` ("required vs posix-default") was framed around `rule.ts` before the path-utils blast radius was scoped, so the same underlying decision re-opened for a second `ask_user` at step 9.
Impact: two ask rounds for one decision; the second could have been folded in had the full caller set been enumerated before the first.
- `other` (tooling hazard) — the lint-guard sanity check (temporarily adding an interior `process.platform` to confirm the guard fires) used `git checkout` to revert, which also reverted the *uncommitted* step-9 change to `canonicalize-path.ts`.
Impact: caught immediately via `grep "= process.platform"` and re-applied; ~3 tool calls, no rework.
#### What caused friction (user side)
- Opportunity, not criticism: the `required`-vs-`defaulted` preference was asked twice across the TDD session.
Presenting the full blast radius (path-utils leaves + ~177 test edits) in the *first* question would have made it a single decision boundary — but that gap was the agent's scoping, not the operator's.
### Diagnostic details
- **Unused-tool (planning).**
A `grep -rn "process.platform"` plus a caller-enumeration pass at plan time would have surfaced the true blast radius (all seven leaf functions + ~6 production callers) instead of the representative two the plan named.
This is the root of the step-9 deviation.
- **Feedback-loop.**
Verification cadence was healthy: `tsc` after each interface-changing step, `vitest` per affected file, full suite + lint + `fallow` at TDD end, and lint + `fallow` pre-push.
No end-only-verification gap.
- **Model-performance.**
The only subagent (the `pre-completion-reviewer`, dispatched in the TDD session) ran fresh-context on judgment-heavy review work — an appropriate match; no mismatch.
### Changes made
1. `.pi/skills/testing/SKILL.md` — added a TDD-planning rule (after the "parameter that flows through callback chains" rule) that a plan adding a lint guard forbidding a global read bans the *text* everywhere (including `= process.platform` default params), so every default must be removed in the guard's commit and every occurrence + caller enumerated at plan time.