feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -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.