Files
my-pi/pi-permission-system/docs/plans/archive/0041-extract-permission-gate.md
T

185 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
issue: 41
issue_title: "Extract a reusable permission-gate function to eliminate repeated deny/ask/allow branching"
---
# Extract a reusable permission-gate function
## Problem Statement
`src/index.ts` contains five near-identical deny/ask/allow branching blocks (skill input, skill-read path, external-directory for file tools, external-directory for bash, and normal tool permission).
Each block repeats the same three-branch structure: deny → log + block, ask → check UI availability → prompt → maybe block, allow → fall through.
The only variation is the log context, message formatters, and return shape.
This duplication inflates the tool_call handler by ~170 lines and makes every future permission-surface addition copy-paste-prone.
## Goals
- Extract a single `applyPermissionGate()` function into `src/permission-gate.ts`.
- Replace all five inline deny/ask/allow branches with calls to it.
- Add focused unit tests for the gate function.
- Net-reduce `src/index.ts` by ~150 lines with no change to permission semantics.
## Non-Goals
- Splitting the `tool_call` / `input` handlers into separate files (tracked by #21 phase 2).
- Changing any permission resolution logic, merge precedence, or default policy.
- Refactoring `promptPermission` or `writeReviewLog` internals.
## Background
The repeated pattern lives inside two event handlers registered by `piPermissionSystemExtension()` in `src/index.ts`:
| Handler | Surface | Approx lines |
| --------------- | ------------------------------- | ------------ |
| `input` (skill) | skill input gate | 676717 |
| `tool_call` | skill-read path | 762822 |
| `tool_call` | external-directory (file tools) | 843894 |
| `tool_call` | external-directory (bash) | 921978 |
| `tool_call` | normal tool permission | 10001053 |
Key dependencies consumed inside the branches:
- `writeReviewLog` — closure over the review-log writer.
- `promptPermission` — closure that delegates to the permission dialog or auto-approves in yolo mode.
- `canRequestPermissionConfirmation(ctx)` — pure check for interactive UI.
- Various `format*` helpers from `src/format-messages.ts`.
The skill-input handler returns `{ action: "handled" }` to block, while the tool_call handler returns `{ block: true, reason }`.
The gate function must be agnostic to this — it returns its own result type and each call site maps it to the handler's expected shape.
## Design Overview
### Types
```typescript
/** Result of applying the permission gate. */
export type PermissionGateResult =
| { action: "allow" }
| { action: "block"; reason: string };
/** Everything the gate needs — no direct dependency on ExtensionContext. */
export interface PermissionGateParams {
/** The resolved permission state from checkPermission(). */
state: "allow" | "deny" | "ask";
/** Whether the current context supports interactive prompts. */
canConfirm: boolean;
/** Prompt the user for approval. Only called when state === "ask" and canConfirm is true. */
promptForApproval: () => Promise<PermissionPromptDecision>;
/** Write a review-log entry. Called for deny and ask-but-unavailable paths. */
writeLog: (event: string, extra: Record<string, unknown>) => void;
/** Log context fields shared across all log calls for this gate. */
logContext: Record<string, unknown>;
/** Message strings/factories for each outcome. */
messages: {
denyReason: string;
unavailableReason: string;
userDeniedReason: (decision: PermissionPromptDecision) => string;
};
}
```
### Behaviour (pure decision logic)
1. **deny** → call `writeLog("permission_request.blocked", { ...logContext, resolution: "policy_denied" })`, return `{ action: "block", reason: messages.denyReason }`.
2. **ask + !canConfirm** → call `writeLog("permission_request.blocked", { ...logContext, resolution: "confirmation_unavailable" })`, return `{ action: "block", reason: messages.unavailableReason }`.
3. **ask + canConfirm** → call `promptForApproval()`.
If `!decision.approved`, return `{ action: "block", reason: messages.userDeniedReason(decision) }`.
Otherwise fall through.
4. **allow** (or ask + approved) → return `{ action: "allow" }`.
### Call-site mapping
Each handler maps the gate result to its own return shape:
```typescript
// tool_call handler
const result = await applyPermissionGate({ ... });
if (result.action === "block") return { block: true, reason: result.reason };
// input handler (skill)
const result = await applyPermissionGate({ ... });
if (result.action === "block") return { action: "handled" };
```
The skill-input handler currently shows a UI notification on deny before returning.
That notification stays at the call site (before calling the gate or after inspecting its result); the gate itself is UI-agnostic.
### Design decisions
- **`promptForApproval` is a pre-bound closure** rather than passing `ctx` + details into the gate.
This keeps the gate free of `ExtensionContext` and `promptPermission` signature coupling.
- **`writeLog` is a thin callback** so the gate does not depend on the review-log writer's closure.
- **No `writeLog` call on the allow or user-approved path** — those are logged by `promptPermission` internally (via `reviewPermissionDecision`), not by the gate.
- **The skill-input deny path's UI notification** remains outside the gate at the call site, keeping the gate headless.
## Module-Level Changes
### `src/permission-gate.ts` (new)
- Export `PermissionGateResult`, `PermissionGateParams`, `applyPermissionGate`.
- Pure async function, no imports beyond the `PermissionPromptDecision` type from `src/permission-dialog.ts`.
### `src/index.ts` (modified)
- Import `applyPermissionGate` and its param/result types.
- Replace the five inline deny/ask/allow blocks with calls to `applyPermissionGate`.
- Each call site constructs `PermissionGateParams` from existing local variables and format helpers.
- Net deletion: ~150 lines.
### `tests/permission-gate.test.ts` (new)
- Unit tests exercising each branch of `applyPermissionGate` in isolation with mock callbacks.
### No changes to
- `schemas/permissions.schema.json`, `config/config.example.json`, `README.md` — this is an internal refactor with no config or schema impact.
- `src/permission-manager.ts`, `src/permission-dialog.ts`, `src/format-messages.ts` — consumed but not modified.
## TDD Order
1. **Red**: test `applyPermissionGate` returns `{ action: "block" }` with deny reason when `state === "deny"`, and calls `writeLog` with `resolution: "policy_denied"`.
**Green**: implement the deny branch in `src/permission-gate.ts`.
`test: permission-gate deny branch`
2. **Red**: test gate returns block with unavailable reason when `state === "ask"` and `canConfirm === false`, and calls `writeLog` with `resolution: "confirmation_unavailable"`.
**Green**: implement the ask-unavailable branch.
`test: permission-gate ask-unavailable branch`
3. **Red**: test gate calls `promptForApproval` and returns block with user-denied reason when the user rejects.
**Green**: implement the ask-rejected branch.
`test: permission-gate ask-rejected branch`
4. **Red**: test gate calls `promptForApproval` and returns `{ action: "allow" }` when user approves.
**Green**: implement the ask-approved branch.
`test: permission-gate ask-approved path`
5. **Red**: test gate returns `{ action: "allow" }` immediately when `state === "allow"` without calling `writeLog` or `promptForApproval`.
**Green**: implement the allow fast-path.
`feat: add permission-gate module`
6. **Refactor**: replace all five inline branches in `src/index.ts` with `applyPermissionGate` calls.
Run the full existing test suite to confirm no regressions.
`refactor: replace inline deny/ask/allow branches with applyPermissionGate`
7. **Verify**: run `pnpm run build` to confirm no type errors, then full `npx vitest run`.
`chore: verify clean build after permission-gate extraction`
## Risks and Mitigations
| Risk | Mitigation |
| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Subtle semantic drift during extraction (e.g. missing a log field) | Each call site is converted one at a time with the full test suite run after each batch. The gate's own unit tests cover every branch. |
| Could this silently weaken a permission? | No — the gate is a strict refactor. The deny and ask branches produce identical block results. The allow path is unchanged. No new `"allow"` path is introduced. |
| Skill-input handler returns `{ action: "handled" }` not `{ block, reason }` | The gate returns its own `PermissionGateResult`; each call site maps it. The skill-input site discards `reason` and returns `{ action: "handled" }` as before. |
| `promptForApproval` closure captures stale variables | Each closure is constructed fresh inside the event handler per invocation — same lifetime as the current inline code. |
## Open Questions
- None — the issue is self-contained and the proposed API is straightforward.
If #21 phase 2 (handler extraction) lands first, the call sites will move to different files, but the gate function itself is unaffected.