mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
---
|
||||
issue: 55
|
||||
issue_title: "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:
|
||||
- `special` → `findCompiledPermissionMatch(compiledSpecial, name)` → fallback to `defaultPolicy.special`
|
||||
- `skill` → `findCompiledPermissionMatch(compiledSkills, skillName)` → fallback to `defaultPolicy.skills`
|
||||
- `bash` → `bashFilter.check(command)` → fallback to bash default
|
||||
- `mcp` → `findCompiledPermissionMatchForNames(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`)
|
||||
|
||||
```typescript
|
||||
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`)
|
||||
|
||||
```typescript
|
||||
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`)
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 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:
|
||||
|
||||
```typescript
|
||||
// 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.ts` — `PermissionCheckResult` 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.
|
||||
Reference in New Issue
Block a user