mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
252 lines
19 KiB
Markdown
252 lines
19 KiB
Markdown
---
|
||
issue: 477
|
||
issue_title: "pi-permission-system: collapse the two external-directory gates onto one AccessPath policy check (Phase 6 Step 5)"
|
||
---
|
||
|
||
# Collapse the two external-directory gates onto one AccessPath policy check
|
||
|
||
## Release Recommendation
|
||
|
||
**Release:** ship now — batch "access-path-unification" tail (this issue completes the batch)
|
||
|
||
This issue is Phase 6 Step 5, the tail of the two-member release batch "access-path-unification" (Steps 4 and 5).
|
||
Step 4 ([#476]) landed the `AccessPath` value object but left the duplicated external-directory policy logic in place — a transitional state the roadmap explicitly defers shipping until Step 5 collapses it.
|
||
Its release-please PR (#485) was held open by Step 4's `mid-batch — defer` marker; landing this step completes the batch, so both ship together.
|
||
|
||
## Problem Statement
|
||
|
||
`describeExternalDirectoryGate` (the single-tool path) and `describeBashExternalDirectoryGate` (the multi-path bash command path) each independently derive a path's `external_directory` policy.
|
||
Both call `resolver.resolvePathPolicy(path.matchValues(), agentName, "external_directory")` — the alias-derivation-plus-surface-tagged resolve that the [#418] fix introduced — and the bash gate additionally selects the worst uncovered path.
|
||
Because the same `external_directory` resolution lives in two places, the [#418] lexical/canonical conflation bug was acquired twice and had to be fixed twice.
|
||
Once `AccessPath` ([#476]) exists to hold the typed and canonical forms behind distinct accessors, the policy-resolution line belongs in one place with both gates delegating to it.
|
||
|
||
## Goals
|
||
|
||
- Single-source the `external_directory` policy resolution (`resolvePathPolicy(path.matchValues(), agentName, "external_directory")`) so the [#418]-prone line exists exactly once.
|
||
- Route both gate factories through the shared helper, removing the duplicated inline logic.
|
||
- Preserve behavior exactly — no change to any allow/ask/deny decision, log shape, or descriptor.
|
||
- This change is **not breaking**: it is a behavior-preserving internal refactor with no change to config, output shape, or defaults.
|
||
|
||
## Non-Goals
|
||
|
||
- Merging the two gate *functions* into one.
|
||
The gates have genuinely different control flow (the single gate does an infra-read bypass and an outside-CWD boundary check, always emits a descriptor; the bash gate filters N paths to uncovered and early-bypasses when all are covered) and remain two separate pipeline producers — only the duplicated policy-check logic is collapsed.
|
||
- Narrowing `ScopedPermissionResolver` to a single `resolve(intent)` — that is Phase 6 Step 6 ([#478]).
|
||
- Extracting shared test fixtures for the external-directory integration tests — that is Phase 6 Step 8 ([#480]), which targets the collapsed gate this step produces.
|
||
- Touching the outside-CWD boundary derivation (`isPathOutsideWorkingDirectory`, `BashProgram.externalPaths()`), the infra-read bypass, or message formatting.
|
||
|
||
## Background
|
||
|
||
Relevant modules under `packages/pi-permission-system/`:
|
||
|
||
- `src/handlers/gates/external-directory.ts` — `describeExternalDirectoryGate`: extracts one tool-input path, checks the outside-CWD boundary, bypasses Pi infrastructure reads, builds an `AccessPath`, resolves its `external_directory` policy as `preCheck`, and emits a single-pattern descriptor.
|
||
- `src/handlers/gates/bash-external-directory.ts` — `describeBashExternalDirectoryGate`: reads `AccessPath[]` from the injected `BashProgram.externalPaths()`, resolves each path's `external_directory` policy, filters to uncovered (state ≠ `allow`), early-bypasses when all are covered, selects the worst uncovered via `pickMostRestrictive`, and emits a multi-pattern descriptor.
|
||
- `src/access-intent/access-path.ts` — `AccessPath` value object ([#476]): `matchValues()` (lexical alias union ∪ canonical, the [#418] match set), `boundaryValue()` (canonical, for the outside-CWD boundary and infra-read containment), `value()` (lexical display form).
|
||
- `src/handlers/gates/candidate-check.ts` — `pickMostRestrictive(results)`: selects the worst (`deny` > `ask` > `allow`) `PermissionCheckResult`, first-occurrence-wins on ties.
|
||
Already shared by the bash path, bash command, and bash external-directory gates.
|
||
|
||
Constraints from AGENTS.md and the package skill that apply:
|
||
|
||
- Run `pnpm fallow dead-code` locally before pushing — a new export with no live consumer fails the CI gate.
|
||
The new helper functions must land with their gate consumers, not as a standalone pure-addition commit.
|
||
- Biome's `noUnusedImports` is warning-level (exit 0), so the pre-completion reviewer is the only backstop for orphaned imports left after the bash gate's inline logic is removed.
|
||
- When a gate resolves through a resolver method, the test fixture must wire it through the same surface dispatcher in `makeHandler` — the helper makes the same `resolvePathPolicy(..., "external_directory")` call the gates made, so the existing `makeHandler` wiring already covers it.
|
||
|
||
## Design Overview
|
||
|
||
### Decision model
|
||
|
||
The truly shared, [#418]-prone operation is resolving a single `AccessPath`'s `external_directory` policy: `resolver.resolvePathPolicy(path.matchValues(), agentName, "external_directory")`.
|
||
Worst-path selection is inherently bash-only — the single-tool gate has exactly one path and no selection to make.
|
||
Per the operator's design decision, the helper is **two focused functions sharing a private per-path core** rather than one combined helper over `AccessPath[]` returning a wide result object (which each consumer would read only a subset of — a dependency-width smell).
|
||
|
||
### New module
|
||
|
||
`src/handlers/gates/external-directory-policy.ts`:
|
||
|
||
```typescript
|
||
import type { AccessPath } from "#src/access-intent/access-path";
|
||
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
||
import type { PermissionCheckResult } from "#src/types";
|
||
import { pickMostRestrictive } from "./candidate-check";
|
||
|
||
/** An external path whose resolved external_directory state is not "allow". */
|
||
export interface UncoveredExternalPath {
|
||
path: AccessPath;
|
||
check: PermissionCheckResult;
|
||
}
|
||
|
||
/** The uncovered external paths plus the most restrictive check among them. */
|
||
export interface UncoveredExternalPaths {
|
||
uncovered: UncoveredExternalPath[];
|
||
/** Worst check among uncovered paths; undefined only when none are uncovered. */
|
||
worstCheck: PermissionCheckResult | undefined;
|
||
}
|
||
|
||
/**
|
||
* Resolve one external path's policy on the `external_directory` surface.
|
||
*
|
||
* Matches against the typed and symlink-resolved aliases (AccessPath.matchValues())
|
||
* so a config pattern on either form applies (#418). Single source for the
|
||
* alias-derivation + surface-tagged resolve the two gates previously duplicated.
|
||
*/
|
||
export function resolveExternalDirectoryPolicy(
|
||
path: AccessPath,
|
||
resolver: ScopedPermissionResolver,
|
||
agentName: string | undefined,
|
||
): PermissionCheckResult {
|
||
return resolver.resolvePathPolicy(
|
||
path.matchValues(),
|
||
agentName,
|
||
"external_directory",
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Resolve a set of external paths and select those not already allowed.
|
||
*
|
||
* Filters on state (not source) so config-level allow rules suppress the prompt
|
||
* just as session-level allow rules do; returns the most restrictive uncovered
|
||
* check so a config "deny" is not downgraded to the catch-all "ask".
|
||
*/
|
||
export function selectUncoveredExternalPaths(
|
||
paths: readonly AccessPath[],
|
||
resolver: ScopedPermissionResolver,
|
||
agentName: string | undefined,
|
||
): UncoveredExternalPaths {
|
||
const uncovered: UncoveredExternalPath[] = [];
|
||
for (const path of paths) {
|
||
const check = resolveExternalDirectoryPolicy(path, resolver, agentName);
|
||
if (check.state !== "allow") {
|
||
uncovered.push({ path, check });
|
||
}
|
||
}
|
||
return {
|
||
uncovered,
|
||
worstCheck: pickMostRestrictive(uncovered.map(({ check }) => check)),
|
||
};
|
||
}
|
||
```
|
||
|
||
### Consumer call sites
|
||
|
||
Single-tool gate (`external-directory.ts`) — the boundary check, `AccessPath` construction, and infra-read bypass are unchanged; only the inline `resolvePathPolicy` call is replaced:
|
||
|
||
```typescript
|
||
const accessPath = AccessPath.forExternalDirectory(externalDirectoryPath, tcc.cwd);
|
||
// ... infra-read bypass on accessPath.boundaryValue() (unchanged) ...
|
||
// The runner consumes this preCheck and skips its own resolve.
|
||
const preCheck = resolveExternalDirectoryPolicy(
|
||
accessPath,
|
||
resolver,
|
||
tcc.agentName ?? undefined,
|
||
);
|
||
```
|
||
|
||
Bash gate (`bash-external-directory.ts`) — the inline loop and worst-selection are replaced by one call:
|
||
|
||
```typescript
|
||
const { uncovered: uncoveredEntries, worstCheck } = selectUncoveredExternalPaths(
|
||
externalPaths,
|
||
resolver,
|
||
tcc.agentName ?? undefined,
|
||
);
|
||
const uncoveredPaths = uncoveredEntries.map(({ path }) => path.value());
|
||
if (uncoveredPaths.length === 0) {
|
||
return { action: "allow", /* session_approved log (unchanged) */ };
|
||
}
|
||
const preCheck = worstCheck ?? uncoveredEntries[0].check;
|
||
```
|
||
|
||
### Upstream-interaction check
|
||
|
||
The helper reads `path.matchValues()` (Tell-Don't-Ask compliant — `AccessPath` owns its alias derivation) and calls `resolver.resolvePathPolicy` and `pickMostRestrictive`.
|
||
It mutates no received argument, performs no reverse search, and carries no output-argument pattern from the original gates.
|
||
`ScopedPermissionResolver` is the resolver interface the gates already depend on — no widening.
|
||
`resolveExternalDirectoryPolicy` reads only `AccessPath.matchValues()`; `selectUncoveredExternalPaths` reads only `AccessPath.matchValues()` (via the per-path core) and `.value()` is read by the caller, not the helper — ISP-clean.
|
||
|
||
### Edge cases
|
||
|
||
- Empty `externalPaths` in the bash gate is handled *before* the helper call (the gate returns `null`); `selectUncoveredExternalPaths([])` would return `{ uncovered: [], worstCheck: undefined }`, but that path is unreachable.
|
||
- After the early bypass, `uncoveredEntries.length > 0`, so `worstCheck` is defined; TypeScript cannot narrow that across the early return, so the `?? uncoveredEntries[0].check` fallback is retained exactly as today.
|
||
- The single-tool gate does not filter on `allow` — it always emits a descriptor with `preCheck`, and the runner handles the allow.
|
||
This is why the per-path `resolveExternalDirectoryPolicy` (not the filtering `selectUncoveredExternalPaths`) is the right fit for that gate.
|
||
|
||
## Module-Level Changes
|
||
|
||
- `src/handlers/gates/external-directory-policy.ts` — **new**.
|
||
Exports `resolveExternalDirectoryPolicy`, `selectUncoveredExternalPaths`, and the `UncoveredExternalPath` / `UncoveredExternalPaths` interfaces.
|
||
- `src/handlers/gates/external-directory.ts` — replace the inline `resolver.resolvePathPolicy(accessPath.matchValues(), …, "external_directory")` call with `resolveExternalDirectoryPolicy(accessPath, resolver, …)`; add the helper import.
|
||
`AccessPath` (the class) stays imported — still used for `AccessPath.forExternalDirectory` and `boundaryValue()`.
|
||
- `src/handlers/gates/bash-external-directory.ts` — replace the inline uncovered-collection loop and `pickMostRestrictive` worst-selection with one `selectUncoveredExternalPaths(...)` call.
|
||
Remove three imports that become orphaned: `AccessPath` (only used in the inline array-entry type), `PermissionCheckResult` (same), and `pickMostRestrictive` (now called inside the helper).
|
||
- `docs/architecture/architecture.md` — doc updates (see below).
|
||
- No README change: README documents the user-facing `external_directory` surface and config, not internal gate symbols (verified by grep — only config/precedence prose, no symbol references).
|
||
- No package SKILL change: the skill's external-directory reference (line 150) describes behavior ("both external-directory gates pass `external_directory` … to match a path's typed and symlink-resolved aliases (#418)") that remains accurate — both gates still drive that resolution, now via the shared helper (verified by grep — no removed symbol the skill names).
|
||
|
||
### Architecture doc updates (`docs/architecture/architecture.md`)
|
||
|
||
- Reword the `external-directory.ts` tree entry to note the policy resolution now delegates to `resolveExternalDirectoryPolicy` (from the new `external-directory-policy.ts`).
|
||
- Reword the `bash-external-directory.ts` tree entry to note it delegates to `selectUncoveredExternalPaths` (which owns the per-path resolve and `pickMostRestrictive` worst-selection) instead of resolving and selecting inline.
|
||
- Add a tree entry for the new `external-directory-policy.ts` module under `handlers/gates/`.
|
||
- Apply the ✅ completion marker to the Step 5 ([#477]) heading and to the `S5` node in the Phase 6 Mermaid diagram (per the package convention of marking a roadmap step complete as part of the change that lands it, as Step 4 did).
|
||
|
||
## Test Impact Analysis
|
||
|
||
1. **Newly enabled** — `test/handlers/gates/external-directory-policy.test.ts` (new) can unit-test the policy resolution directly, which was previously only reachable through each gate's full descriptor assembly:
|
||
- `resolveExternalDirectoryPolicy` calls `resolvePathPolicy` with `path.matchValues()` and the `"external_directory"` surface ([#418]).
|
||
- `selectUncoveredExternalPaths` filters out `allow` results, collects uncovered entries, and returns the worst uncovered (config `deny` not downgraded to catch-all `ask`, [#393]); returns `{ uncovered: [], worstCheck: undefined }` for an all-allowed set.
|
||
2. **Redundant but retained** — the gate-level [#418] alias assertions (`external-directory.test.ts` line 137; `bash-external-directory.test.ts` line 79) and the bash worst-check assertions (lines 136, 220) are now also backstopped by the helper unit tests.
|
||
They stay as-is: they pin the gate → helper wiring and the gates' full descriptor assembly (sessionApproval shape, denialContext, decision value), which the helper tests do not cover.
|
||
This is behavior-preserving — no gate test is rewritten.
|
||
3. **Must stay as-is** — both gate test files genuinely exercise the gate layer (descriptor assembly, infra bypass, early bypass, message formatting) and are unaffected by the extraction.
|
||
The existing `makeHandler` surface dispatcher already routes the `"external_directory"` resolve, so integration tests (`external-directory-integration.test.ts`, `external-directory-session-dedup.test.ts`, `external-directory-symlink-acceptance.test.ts`) stay green unchanged.
|
||
|
||
## Invariants at risk
|
||
|
||
This step touches the external-directory surface that [#418], [#382], [#393], and [#476] (Step 4) refactored.
|
||
The extraction preserves each invariant; each is already pinned by a test that stays green:
|
||
|
||
| Invariant | Source | Pinned by (stays green) |
|
||
| ---------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------- |
|
||
| Both gates match on `external_directory` against typed ∪ canonical aliases | [#418] | `external-directory.test.ts:137`; `bash-external-directory.test.ts:79` |
|
||
| Config `deny` not downgraded to catch-all `ask` (worst uncovered wins) | [#393] | `bash-external-directory.test.ts:136,220` |
|
||
| `matchValues()` for matching, `boundaryValue()` for the boundary/infra check | [#476] | `external-directory.test.ts` boundary/infra cases (unchanged) |
|
||
| Win32-lowercased canonical boundary | [#382] | `external-directory-symlink-acceptance.test.ts` |
|
||
|
||
No new test is needed to guard an invariant living only in prose — all four are already test-pinned, and the new helper unit tests additionally lower-source the [#418] and [#393] invariants.
|
||
|
||
## TDD Order
|
||
|
||
1. **Collapse the duplicated policy logic into the shared helper** (red → green → one commit).
|
||
- Red: add `test/handlers/gates/external-directory-policy.test.ts` asserting `resolveExternalDirectoryPolicy` resolves on the `external_directory` surface with `matchValues()`, and `selectUncoveredExternalPaths` filters `allow` and returns the worst uncovered.
|
||
- Green: add `src/handlers/gates/external-directory-policy.ts`; rewire `external-directory.ts` (single-path) and `bash-external-directory.ts` (multi-path) to delegate; remove the bash gate's three orphaned imports (`AccessPath`, `PermissionCheckResult`, `pickMostRestrictive`).
|
||
- This is one atomic commit: `fallow dead-code` flags an unconsumed export, so the helper must land with both gate consumers in the same commit; the gate test files are untouched (behavior-preserving), so no large test rewrite is involved.
|
||
- Verify: `pnpm --filter @gotgenes/pi-permission-system run check`, full `vitest run` (all external-directory tests green), `pnpm fallow dead-code` (no new dead export, `pickMostRestrictive` still has live callers in `bash-command.ts`, `bash-path.ts`, and the new helper).
|
||
- Suggested commit: `refactor(pi-permission-system): collapse external-directory gates onto a shared policy helper (#477)`.
|
||
2. **Update the architecture roadmap** (docs commit).
|
||
- Reword the `external-directory.ts` and `bash-external-directory.ts` tree entries to note delegation; add the `external-directory-policy.ts` tree entry; apply ✅ to the Step 5 heading and the `S5` Mermaid node.
|
||
- Verify: `pnpm --filter @gotgenes/pi-permission-system run lint` (rumdl), and a grep that no architecture prose still describes the inline duplication in the present tense.
|
||
- Suggested commit: `docs(pi-permission-system): mark Phase 6 Step 5 complete (#477)`.
|
||
|
||
## Risks and Mitigations
|
||
|
||
- **Risk:** orphaned imports left in the bash gate after removing its inline logic (Biome `noUnusedImports` is warning-level, exit 0).
|
||
**Mitigation:** the TDD step explicitly enumerates the three imports to remove; the pre-completion reviewer and `tsc` (`AccessPath`/`PermissionCheckResult` become unused type imports — `tsc` does not error on those, so this relies on the reviewer and an explicit re-read of the file) guard it.
|
||
- **Risk:** `fallow dead-code` flags a helper export if a gate is not actually rewired.
|
||
**Mitigation:** single atomic commit lands the helper with both consumers; `pnpm fallow dead-code` runs in the step's verify.
|
||
- **Risk:** silently changing the bash gate's worst-check fallback semantics.
|
||
**Mitigation:** the `worstCheck ?? uncoveredEntries[0].check` fallback is preserved verbatim; the [#393] worst-check tests stay green.
|
||
|
||
## Open Questions
|
||
|
||
None.
|
||
No follow-up issues are introduced by this plan — Steps 6 ([#478]) and 8 ([#480]) are already filed and tracked in the roadmap.
|
||
|
||
[#382]: https://github.com/gotgenes/pi-packages/issues/382
|
||
[#393]: https://github.com/gotgenes/pi-packages/issues/393
|
||
[#418]: https://github.com/gotgenes/pi-packages/issues/418
|
||
[#476]: https://github.com/gotgenes/pi-packages/issues/476
|
||
[#478]: https://github.com/gotgenes/pi-packages/issues/478
|
||
[#480]: https://github.com/gotgenes/pi-packages/issues/480
|