Files
my-pi/pi-permission-system/docs/plans/archive/0055-extract-pure-evaluate.md
T

15 KiB

issue, issue_title
issue issue_title
55 Extract pure evaluate() function from PermissionManager

Extract pure evaluate() function from PermissionManager

Problem Statement

PermissionManager.checkPermission() is a ~120-line method dispatching on surface type via if/else if branches. Every branch does the same thing: match input against compiled patterns, fall back to a default. Only MCP has genuinely different logic (multi-name lookup + baseline auto-allow).

Permission evaluation is not independently testable — it requires a PermissionManager instance with filesystem access for config loading. AGENTS.md states: "Permission decisions should be pure functions of (policy, request) wherever possible — keep IO at the edges."

Goals

  • Extract a pure evaluate() function into src/rule.ts that takes a surface name, a match pattern, and one or more rulesets, returning the winning rule.
  • Define Rule and Ruleset types that align with the target architecture (docs/architecture/target-architecture.md).
  • Refactor PermissionManager.checkPermission() to call evaluate() internally — no change to external behavior or return types.
  • Add focused unit tests for evaluate() covering all surfaces, wildcard matching, last-match-wins semantics, and default fallback.
  • Preserve all existing PermissionManager tests without modification.

Non-Goals

  • Config normalization into flat Ruleset at load time (deferred to #56).
  • Removing per-surface compiled pattern arrays or BashFilter class (deferred to #56).
  • Changing the on-disk config format or PermissionCheckResult return type.
  • Replacing SessionApprovalCache with session rules (deferred to #57).
  • Extracting event handlers (#42) or eliminating module-scope state (#43).

Background

Permission surfaces involved

All: tools, bash, mcp, skills, special, external_directory.

Relevant modules

Module Role
src/permission-manager.ts Owns checkPermission() — the method being refactored
src/wildcard-matcher.ts findCompiledWildcardMatch / findCompiledWildcardMatchForNames — used for pattern matching
src/bash-filter.ts BashFilter.check() — wraps wildcard matching for bash commands
src/types.ts PermissionState, PermissionCheckResult, GlobalPermissionConfig, AgentPermissions
docs/architecture/target-architecture.md Defines the target Rule/Ruleset/evaluate() shape

Current flow

  1. resolvePermissions(agentName) loads and merges config into merged: GlobalPermissionConfig + compiled pattern arrays per surface.
  2. checkPermission(toolName, input, agentName) dispatches on surface type:
    • specialfindCompiledPermissionMatch(compiledSpecial, name) → fallback to defaultPolicy.special
    • skillfindCompiledPermissionMatch(compiledSkills, skillName) → fallback to defaultPolicy.skills
    • bashbashFilter.check(command) → fallback to bash default
    • mcpfindCompiledPermissionMatchForNames(compiledMcp, targets) → tool-level mcp → baseline auto-allow → fallback to defaultPolicy.mcp
    • built-in tool → merged.tools[name] → fallback to defaultPolicy.tools
    • other tool → merged.tools[name] → fallback to defaultPolicy.tools

Merge precedence

Global → project → per-agent frontmatter (unchanged by this issue). Compiled pattern arrays preserve insertion order; findCompiledWildcardMatch iterates last to first (last-match-wins).

Design Overview

New types (src/rule.ts)

import type { PermissionState } from "./types";

/** A single permission rule — the atomic unit of policy. */
export interface Rule {
  /** The permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
  surface: string;
  /** The match pattern: a command glob, tool name, skill name, or "*". */
  pattern: string;
  /** The permission decision. */
  action: PermissionState;
}

/** An ordered list of rules. Later rules take priority (last-match-wins). */
export type Ruleset = Rule[];

evaluate() function (src/rule.ts)

import { wildcardMatch } from "./wildcard-matcher";

/**
 * Pure permission evaluation.
 * Returns the last matching rule across all provided rulesets,
 * or a synthetic rule with the surface default if no match is found.
 */
export function evaluate(
  surface: string,
  pattern: string,
  ...rulesets: Ruleset[]
): Rule {
  const rules = rulesets.flat();
  const match = rules.findLast(
    (rule) => wildcardMatch(rule.surface, surface) && wildcardMatch(rule.pattern, pattern),
  );
  return match ?? { surface, pattern, action: getDefaultAction(surface) };
}

getDefaultAction() (src/rule.ts)

const SURFACE_DEFAULTS: Record<string, PermissionState> = {
  tools: "ask",
  bash: "ask",
  mcp: "ask",
  skill: "ask",
  special: "ask",
};

/**
 * Returns the default action for a surface when no rules match.
 * Defaults to "ask" for unknown surfaces (least privilege).
 */
export function getDefaultAction(surface: string): PermissionState {
  return SURFACE_DEFAULTS[surface] ?? "ask";
}

Note: getDefaultAction is a simple fallback for the pure function. The actual per-surface defaults from defaultPolicy in the merged config will be passed as an explicit final rule or a fallback override when checkPermission() calls evaluate(). This keeps evaluate() pure — it does not need access to the loaded config.

wildcardMatch() helper (src/wildcard-matcher.ts)

A new convenience export wrapping the existing compiled pattern infrastructure for single-shot matching:

/**
 * Test whether `value` matches `pattern` using wildcard rules.
 * Used by evaluate() for rule matching.
 */
export function wildcardMatch(pattern: string, value: string): boolean {
  if (pattern === "*") return true;
  if (!pattern.includes("*")) return pattern === value;
  const compiled = compileWildcardPattern(pattern, true);
  return compiled.regex.test(value);
}

Integration into checkPermission()

checkPermission() converts the existing compiled-pattern lookup into evaluate() calls per surface. Because #56 has not yet normalized config into flat rulesets, the integration layer builds temporary Ruleset values from the already-compiled patterns:

// Helper: convert compiled patterns into a Ruleset for evaluate()
function compiledToRuleset(
  surface: string,
  patterns: CompiledPermissionPatterns,
): Ruleset {
  return patterns.map((p) => ({ surface, pattern: p.pattern, action: p.state }));
}

Each surface branch in checkPermission() becomes a thin call to evaluate():

  • special: evaluate("special", normalizedToolName, compiledToRuleset("special", compiledSpecial))
  • skill: evaluate("skill", skillName, compiledToRuleset("skill", compiledSkills))
  • bash: evaluate("bash", command, compiledToRuleset("bash", bashPatterns))
  • built-in tool / other tool: evaluate(normalizedToolName, "*", toolsRuleset)
  • mcp: loops evaluate("mcp", candidate, compiledToRuleset("mcp", compiledMcp)) over derived targets — existing multi-name logic preserved, baseline auto-allow logic preserved.

The PermissionCheckResult return type and source field remain unchanged.

Edge cases

  1. MCP baseline auto-allow: remains outside evaluate() — it is a heuristic that fires only when no rule matches and certain preconditions hold. Preserved as-is.
  2. BashFilter: still instantiated and used (deferred removal to #56). Internally its check() method will delegate to evaluate() or remain unchanged for this step — TBD during implementation based on code clarity. The plan prefers minimal changes: keep BashFilter.check() as-is and call evaluate() only from checkPermission() for the bash surface.
  3. Compiled pattern caching: no change. Patterns are still compiled once per config load; compiledToRuleset() is cheap (array map, no regex compilation).
  4. defaultPolicy injection: each surface branch passes the relevant default as a fallback after calling evaluate(), rather than encoding it in SURFACE_DEFAULTS. This preserves the current behavior where user-configured defaults override the hardcoded ones.

Module-Level Changes

src/rule.ts (new)

  • Export Rule, Ruleset, evaluate(), getDefaultAction().
  • Pure module — no IO, no imports beyond ./wildcard-matcher.

src/wildcard-matcher.ts (modified)

  • Add exported wildcardMatch(pattern, value): boolean convenience function.
  • No changes to existing exports.

src/permission-manager.ts (modified)

  • Import evaluate, Rule, Ruleset from ./rule.
  • Add private helper compiledToRuleset().
  • Refactor checkPermission() to use evaluate() for each surface, preserving PermissionCheckResult construction.
  • No change to public API surface or return types.

tests/rule.test.ts (new)

  • Unit tests for evaluate() and getDefaultAction().

tests/wildcard-matcher.test.ts (modified)

  • Add tests for the new wildcardMatch() convenience function.

No changes to

  • schemas/permissions.schema.json — no config format change.
  • config/config.example.json — no config format change.
  • README.md — internal refactor, no user-facing change.
  • src/types.tsPermissionCheckResult and related types unchanged.
  • src/bash-filter.ts — kept as-is (removed in #56).
  • Existing tests in tests/permission-system.test.ts, tests/bash-filter.test.ts, etc.

TDD Order

  1. Red: test wildcardMatch("*", "anything") returns true, exact match returns true, non-match returns false, glob patterns match correctly. Green: implement wildcardMatch() in src/wildcard-matcher.ts. test: wildcardMatch convenience function

  2. Red: test getDefaultAction("bash") returns "ask", getDefaultAction("unknown_surface") returns "ask". Green: implement getDefaultAction() in src/rule.ts. test: getDefaultAction returns per-surface defaults

  3. Red: test evaluate("bash", "git status", rules) returns the matching rule when one exists; returns a synthetic rule with default action when no match. Green: implement evaluate() skeleton. feat: add evaluate() pure function in src/rule.ts

  4. Red: test last-match-wins — given two conflicting rules for the same surface/pattern, evaluate() returns the later one. Green: already passes if findLast is used correctly. test: evaluate last-match-wins semantics

  5. Red: test evaluate() with wildcard surface matching (e.g. rule with surface: "*" matches any surface). Green: ensure wildcardMatch is applied to the surface field. test: evaluate wildcard surface matching

  6. Red: test evaluate() with multiple rulesets — rules from later rulesets take priority. Green: verify rulesets.flat() ordering is correct (later rulesets' rules appear last). test: evaluate multi-ruleset precedence

  7. Red: test evaluate() for each permission surface (tool, bash, mcp, skill, special) with realistic rules and patterns. Green: should pass with existing implementation. test: evaluate covers all permission surfaces

  8. Refactor: wire evaluate() into checkPermission() for the special surface branch. Run full test suite. refactor: checkPermission special branch uses evaluate()

  9. Refactor: wire evaluate() into the skill surface branch. Run full test suite. refactor: checkPermission skill branch uses evaluate()

  10. Refactor: wire evaluate() into the built-in tool and other-tool branches. Run full test suite. refactor: checkPermission tool branches use evaluate()

  11. Refactor: wire evaluate() into the bash surface branch (calling evaluate() from checkPermission(), keeping BashFilter alive for now). Run full test suite. refactor: checkPermission bash branch uses evaluate()

  12. Refactor: wire evaluate() into the mcp surface branch (loop over candidates). Run full test suite. refactor: checkPermission mcp branch uses evaluate()

  13. Verify: run pnpm run build (typecheck) and npx vitest run (full suite). Confirm no regressions. chore: verify clean build after evaluate() extraction

  14. Docs: update docs/architecture/target-architecture.md to mark #55 as complete in the refactoring sequence diagram. docs: mark #55 complete in target architecture

Risks and Mitigations

Risk Mitigation
Semantic drift during refactor (different match result) Each surface branch is wired one at a time with full test suite between steps. evaluate() uses the same wildcardMatch logic as the existing findCompiledWildcardMatch.
Could this silently weaken a permission? No new "allow" path is introduced. evaluate() falls back to getDefaultAction() which returns "ask" (least privilege). Each checkPermission() call site still applies its own default from merged.defaultPolicy as before.
Performance regression from compiledToRuleset() array allocation Negligible — called once per checkPermission() invocation, patterns are already in memory. Profiling deferred to #56 which removes the intermediate step entirely.
MCP baseline auto-allow logic could be accidentally removed The MCP branch is the most complex; it retains its bespoke logic after the evaluate() call fails to match. Existing MCP tests explicitly cover the baseline auto-allow path.
wildcardMatch convenience function compiles a regex per call Only used by evaluate() for small rulesets. Once #56 normalizes config into pre-compiled rulesets, this path is optimized away. For now the per-call cost is acceptable (< 1μs per pattern).

Open Questions

  • Should evaluate() accept a defaultAction override parameter instead of calling getDefaultAction()? Leaning yes — checkPermission() already has the merged defaultPolicy and should pass it through. Defer final decision to implementation; the test surface covers both behaviors.
  • Should we add a compiledEvaluate() variant that takes pre-compiled patterns? Defer to #56 where compiled patterns become the primary representation. For now, evaluate() operates on string patterns and compiles on the fly.