mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
227 lines
12 KiB
Markdown
227 lines
12 KiB
Markdown
---
|
|
issue: 81
|
|
issue_title: "Unify checkPermission() surface branching into single evaluate path"
|
|
---
|
|
|
|
# Unify checkPermission() surface branching into single evaluate path
|
|
|
|
## Problem Statement
|
|
|
|
`PermissionManager.checkPermission()` is a ~200-line `if/else if` chain dispatching on tool name across five branches (special → skill → bash → mcp → tools).
|
|
Each branch duplicates the same pattern: extract lookup value, check session rules in a separate pass, check composed config rules, build result.
|
|
The target architecture envisions a single code path where surface-specific logic is limited to input normalization and everything else is shared.
|
|
|
|
## Goals
|
|
|
|
- Concatenate session rules into the composed ruleset so `evaluate()` handles them via last-match-wins — no separate per-branch pre-check.
|
|
- Extract surface-specific input normalization into a pure helper: `(toolName, input, configuredMcpServerNames) → NormalizedInput`.
|
|
- Reduce `checkPermission()` to: normalize → evaluate → build result.
|
|
- Extract MCP target derivation helpers to `src/mcp-targets.ts`.
|
|
- Pure refactor: no change to permission decisions, `PermissionCheckResult` shape, config format, or `Rule` type.
|
|
|
|
## Non-Goals
|
|
|
|
- Changing any permission decision output (same policy + same input = same result).
|
|
- Changing `PermissionCheckResult` shape or its `source` field semantics.
|
|
- Changing config format or `Rule` type.
|
|
- Changing the `/permission-system` slash command.
|
|
- Refactoring `getToolPermission()` (simpler, already unified enough).
|
|
|
|
## Background
|
|
|
|
### Related issues
|
|
|
|
| Issue | Title | State | Relevance |
|
|
| ----- | ------------------------------------------------------------ | ------ | -------------------------------------------------------------------------------- |
|
|
| #65 | Synthesize defaults into ruleset and unify the evaluate path | Closed | Unified `evaluate()` but left per-surface branching in `checkPermission()` |
|
|
| #57 | Replace SessionApprovalCache with session Ruleset | Closed | Changed session storage to `Ruleset` but did not concatenate into composed rules |
|
|
|
|
### Relevant modules
|
|
|
|
- `src/permission-manager.ts` — contains `checkPermission()`, `getToolPermission()`, MCP target helpers (~784 lines).
|
|
- `src/rule.ts` — `Rule`, `Ruleset`, `evaluate()`.
|
|
- `src/synthesize.ts` — `composeRuleset()`, `synthesizeDefaults()`, `synthesizeBaseline()`.
|
|
- `src/session-rules.ts` — `SessionRules` class producing `Ruleset`.
|
|
- `docs/architecture/target-architecture.md` — documents the target of session rules being concatenated.
|
|
|
|
### Permission surfaces involved
|
|
|
|
All five: tools, bash, mcp, skills, special (external_directory).
|
|
|
|
## Design Overview
|
|
|
|
### Step 1: Concatenate session rules into composed ruleset
|
|
|
|
Currently `checkPermission()` receives `sessionRules?: Ruleset` and checks it in a separate pass per branch.
|
|
After this change, session rules are appended **after** config rules in the composed array (highest priority), and `evaluate()` naturally finds them via last-match-wins.
|
|
|
|
```typescript
|
|
// In checkPermission():
|
|
const fullRules = sessionRules?.length
|
|
? [...composedRules, ...sessionRules]
|
|
: composedRules;
|
|
```
|
|
|
|
This eliminates the duplicated `if (sessionRules.includes(sessionRule))` guard in every branch.
|
|
|
|
### Step 2: Extract input normalization
|
|
|
|
A pure function maps `(toolName, input, configuredMcpServerNames)` to a normalized structure:
|
|
|
|
```typescript
|
|
interface NormalizedInput {
|
|
/** The primary surface name for evaluate(). */
|
|
surface: string;
|
|
/**
|
|
* Candidate values to try, in priority order.
|
|
* For most surfaces this is a single element; for MCP it is the multi-name candidate list.
|
|
*/
|
|
values: string[];
|
|
/** Extra fields to include in PermissionCheckResult (e.g. command, target). */
|
|
resultExtras: Record<string, unknown>;
|
|
}
|
|
```
|
|
|
|
Surface dispatch becomes a simple mapping table (or small function) rather than repeated if/else blocks.
|
|
|
|
### Step 3: Single evaluate loop
|
|
|
|
```typescript
|
|
function evaluateFirst(
|
|
surface: string,
|
|
values: string[],
|
|
rules: Ruleset,
|
|
): { rule: Rule; value: string } {
|
|
for (const value of values) {
|
|
const rule = evaluate(surface, value, rules);
|
|
if (rule.layer !== "default") {
|
|
return { rule, value };
|
|
}
|
|
}
|
|
// Fall back to evaluating the first candidate (picks up the default).
|
|
const fallbackValue = values[0] ?? "*";
|
|
return { rule: evaluate(surface, fallbackValue, rules), value: fallbackValue };
|
|
}
|
|
```
|
|
|
|
MCP's multi-candidate logic becomes a natural use of this helper.
|
|
Non-MCP surfaces pass a single-element `values` array and get the same behavior.
|
|
|
|
### Step 4: Result construction
|
|
|
|
A single result builder maps `(rule, toolName, surface, resultExtras)` → `PermissionCheckResult`, replacing the per-branch construction.
|
|
|
|
```typescript
|
|
function buildResult(
|
|
toolName: string,
|
|
rule: Rule,
|
|
matchedValue: string,
|
|
extras: Record<string, unknown>,
|
|
): PermissionCheckResult {
|
|
return {
|
|
toolName,
|
|
state: rule.action,
|
|
matchedPattern: rule.layer === "config" || rule.layer === "session"
|
|
? rule.pattern : undefined,
|
|
source: deriveSource(rule, toolName),
|
|
...extras,
|
|
};
|
|
}
|
|
```
|
|
|
|
The `source` derivation must preserve current semantics:
|
|
|
|
- `layer: "session"` → `source: "session"`
|
|
- `layer: "config"` → surface-specific source (`"bash"`, `"mcp"`, `"skill"`, `"special"`, `"tool"`)
|
|
- `layer: "default"` → `source: "default"`
|
|
- `layer: "override"` → `source: "tool"`
|
|
|
|
### Step 5: Extract MCP target helpers to `src/mcp-targets.ts`
|
|
|
|
Move `parseQualifiedMcpToolName`, `addDerivedMcpServerTargets`, `pushMcpToolPermissionTargets`, and `createMcpPermissionTargets` into a focused module.
|
|
This is purely a file-move with re-export.
|
|
|
|
## Module-Level Changes
|
|
|
|
| File | Action |
|
|
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
|
|
| `src/mcp-targets.ts` | **New** — MCP target derivation helpers extracted from `permission-manager.ts` |
|
|
| `src/input-normalizer.ts` | **New** — `normalizeInput()` pure function + `NormalizedInput` type |
|
|
| `src/permission-manager.ts` | **Changed** — `checkPermission()` reduced to ~30 lines; MCP helpers removed; imports from new modules |
|
|
| `src/synthesize.ts` | **Unchanged** — `composeRuleset()` signature stays the same; session concatenation happens at call site |
|
|
| `src/rule.ts` | **Unchanged** |
|
|
| `tests/mcp-targets.test.ts` | **New** — unit tests for extracted MCP helpers |
|
|
| `tests/input-normalizer.test.ts` | **New** — unit tests for input normalization |
|
|
| `tests/permission-manager-unified.test.ts` | **New** — integration tests verifying identical decisions before/after refactor |
|
|
| `tests/session-rules.test.ts` | **Unchanged** — existing tests continue to pass |
|
|
| `tests/handlers/tool-call.test.ts` | **Verify** — existing handler tests pass without modification |
|
|
| `docs/architecture/target-architecture.md` | **Updated** — mark session concatenation as ✅ implemented |
|
|
|
|
## TDD Order
|
|
|
|
### 1. Extract MCP target helpers
|
|
|
|
- **Test surface**: `tests/mcp-targets.test.ts`
|
|
- **Covers**: `parseQualifiedMcpToolName`, `addDerivedMcpServerTargets`, `createMcpPermissionTargets` — port existing implicit coverage into explicit unit tests.
|
|
- **Commit**: `refactor: extract MCP target derivation to src/mcp-targets.ts`
|
|
|
|
### 2. Input normalization — non-MCP surfaces
|
|
|
|
- **Test surface**: `tests/input-normalizer.test.ts`
|
|
- **Covers**: special/external_directory, skill, bash, and tool surfaces produce correct `NormalizedInput`.
|
|
- **Commit**: `feat: add input normalizer for non-MCP surfaces`
|
|
|
|
### 3. Input normalization — MCP surface
|
|
|
|
- **Test surface**: `tests/input-normalizer.test.ts` (extend)
|
|
- **Covers**: MCP input produces multi-candidate `values[]` matching current `createMcpPermissionTargets` output.
|
|
- **Commit**: `feat: add MCP input normalization to input-normalizer`
|
|
|
|
### 4. evaluateFirst helper
|
|
|
|
- **Test surface**: `tests/rule.test.ts` (extend) or inline in `tests/input-normalizer.test.ts`
|
|
- **Covers**: multi-candidate evaluation stops at first non-default match; falls back to default on all-default.
|
|
- **Commit**: `feat: add evaluateFirst multi-candidate evaluate helper`
|
|
|
|
### 5. Session rules concatenation
|
|
|
|
- **Test surface**: `tests/permission-manager-unified.test.ts`
|
|
- **Covers**: session rules appended after composed rules; session rule wins over config rule for same surface/pattern; session rule with narrower pattern does not shadow broader config allow.
|
|
- **Commit**: `feat: concatenate session rules into composed ruleset`
|
|
|
|
### 6. Unified checkPermission
|
|
|
|
- **Test surface**: `tests/permission-manager-unified.test.ts` (extend)
|
|
- **Covers**: all five surfaces produce identical `PermissionCheckResult` as current implementation (snapshot-style comparison against known inputs).
|
|
Verify `source` field derivation.
|
|
- **Commit**: `refactor: unify checkPermission into single evaluate path`
|
|
|
|
### 7. Remove dead code and verify full suite
|
|
|
|
- **Test surface**: full `npx vitest run`
|
|
- **Covers**: old per-branch code deleted; all existing tests pass; no regressions.
|
|
- **Commit**: `refactor: remove legacy per-branch checkPermission code`
|
|
|
|
### 8. Update architecture docs
|
|
|
|
- **Test surface**: N/A (docs only)
|
|
- **Covers**: mark session concatenation and unified evaluate path as ✅ in `docs/architecture/target-architecture.md`.
|
|
- **Commit**: `docs: mark unified checkPermission as implemented in target architecture`
|
|
|
|
## Risks and Mitigations
|
|
|
|
| Risk | Mitigation |
|
|
| ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| Session rule concatenation changes evaluation order, silently weakening a permission | Step 5 includes snapshot tests comparing old vs. new decisions for a matrix of inputs. Session rules go last (highest priority) — same semantics as the current separate pre-check. |
|
|
| MCP multi-candidate logic subtly differs after refactor | Step 3 tests `createMcpPermissionTargets` output directly; Step 6 runs MCP permission checks through the unified path and compares results. |
|
|
| `source` field in `PermissionCheckResult` changes for some edge case | Step 6 explicitly tests `source` derivation for each layer type. The `deriveSource()` function is unit-testable. |
|
|
| Extracting MCP helpers breaks imports elsewhere | Grep for all import sites before extracting; re-export from `permission-manager.ts` if needed during transition. |
|
|
|
|
## Open Questions
|
|
|
|
- Should `evaluateFirst` live in `src/rule.ts` (alongside `evaluate`) or in `src/input-normalizer.ts`?
|
|
Leaning toward `src/rule.ts` since it's a pure evaluation helper.
|
|
Decide during implementation.
|
|
- Should the `NormalizedInput.resultExtras` carry typed fields per surface, or is `Record<string, unknown>` sufficient?
|
|
Start with the record; refine if type-safety issues arise.
|