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,518 @@
---
issue: 56
issue_title: "Unify Rule type and normalize config into flat Ruleset"
---
# Unify Rule type and normalize config into flat Ruleset
## Problem Statement
After #55 extracted `evaluate()`, the codebase has two representations of permission rules:
1. The `Rule` / `Ruleset` types used by `evaluate()` in `src/rule.ts`.
2. The legacy per-surface types (`ToolPermissions`, `BashPermissions`, `SkillPermissions`, `SpecialPermissions`) — all `Record<string, PermissionState>` — used by config loading, merging, and compiled pattern caches in `PermissionManager`.
`PermissionManager` still maintains separate compiled pattern arrays per surface (`compiledBash`, `compiledMcp`, `compiledSkills`, `compiledSpecial`) and a separate `BashFilter` class, even though `evaluate()` handles all surfaces uniformly.
The `compiledToRuleset()` bridge in `checkPermission()` converts between these representations on every call — an adapter that exists only because config loading hasn't caught up with the evaluation model.
## Goals
- Add `normalizeConfig()` in `src/normalize.ts` that converts the on-disk config shape into a flat `Ruleset` at load time.
- Replace `mergePermissions()` (per-category object spread) with array concatenation of `Ruleset` values — later scopes' rules appear last and take priority via last-match-wins.
- Extract per-surface default policy into `src/defaults.ts`, kept separate from the `Ruleset` (see Design Overview for rationale).
- Remove `BashFilter` class — `evaluate("bash", command, rules)` replaces it.
- Remove `ToolPermissions`, `BashPermissions`, `SkillPermissions`, `SpecialPermissions` type aliases from `src/types.ts`.
- Remove `GlobalPermissionConfig` and `AgentPermissions` interfaces — each config scope becomes a `Ruleset` at runtime.
- Preserve all existing external behavior: on-disk config format, `PermissionCheckResult` return type, `checkPermission()` / `getToolPermission()` public API, MCP baseline auto-allow, and two-phase tool filtering.
## Non-Goals
- Changing the on-disk config JSON format (preserved exactly).
- Replacing `SessionApprovalCache` with session rules (deferred to #57).
- Extracting event handlers (#42) or eliminating module-scope state (#43).
- Pre-compiling regex patterns inside normalized `Rule` objects — `wildcardMatch()` per-call is fast enough for current ruleset sizes and this can be optimized in a follow-up.
- Changing the JSON schema or example config — no user-facing config changes.
## Background
### Permission surfaces involved
All: tools (read, write, edit, bash, grep, find, ls, plus extension tools), bash, mcp, skills, special (external_directory).
### Relevant modules
| Module | Role |
| ------------------------------------------ | ---------------------------------------------------------------------------- |
| `src/permission-manager.ts` | Config loading, merge, `checkPermission()`, `getToolPermission()` |
| `src/rule.ts` | `Rule`, `Ruleset`, `evaluate()`, `getDefaultAction()` — introduced in #55 |
| `src/wildcard-matcher.ts` | `wildcardMatch()`, compiled pattern infrastructure |
| `src/bash-filter.ts` | `BashFilter` class — already unused in `checkPermission()` post-#55 |
| `src/config-loader.ts` | `UnifiedPermissionConfig`, `loadUnifiedConfig()`, `normalizeUnifiedConfig()` |
| `src/types.ts` | Per-surface type aliases, `AgentPermissions`, `GlobalPermissionConfig` |
| `docs/architecture/target-architecture.md` | Target `normalizeConfig()` shape and module layout |
### Current flow (post-#55)
1. `resolvePermissions(agentName)` loads four config scopes (global, project, agent frontmatter, project-agent frontmatter) into per-surface `Record<string, PermissionState>` maps.
2. `mergePermissions()` shallow-spreads maps per category across scopes.
3. Compiled pattern arrays are built per surface (`compiledBash`, `compiledMcp`, etc.).
4. `checkPermission()` converts compiled patterns into temporary `Ruleset` values via `compiledToRuleset()`, then calls `evaluate()`.
### What changes
Step 4's on-the-fly conversion moves to step 1 — `normalizeConfig()` produces a `Ruleset` at load time.
Steps 23 collapse into array concatenation.
Step 4 calls `evaluate()` directly against the merged `Ruleset`.
## Design Overview
### Surface naming: tool-name-as-surface
Following the issue proposal and target architecture, tool names and special keys become surfaces:
```typescript
// tools.read: "allow" → tool name is the surface
{ surface: "read", pattern: "*", action: "allow" }
// bash["git *"]: "ask" → "bash" is the surface, command is the pattern
{ surface: "bash", pattern: "git *", action: "ask" }
// special.external_directory: "ask" → special key is the surface
{ surface: "external_directory", pattern: "*", action: "ask" }
```
This means `tools.bash: "allow"` normalizes to `{ surface: "bash", pattern: "*", action: "allow" }` — a bash catch-all.
This naturally preserves the current dual-purpose behavior where `tools.bash` controls both tool exposure (phase 1) and bash command fallback (phase 2).
Similarly, `tools.mcp: "allow"``{ surface: "mcp", pattern: "*", action: "allow" }`.
### `normalizeConfig()` (`src/normalize.ts`)
```typescript
import type { PermissionState } from "./types";
import type { Rule, Ruleset } from "./rule";
interface NormalizableConfig {
tools?: Record<string, PermissionState>;
bash?: Record<string, PermissionState>;
mcp?: Record<string, PermissionState>;
skills?: Record<string, PermissionState>;
special?: Record<string, PermissionState>;
}
/**
* Convert the on-disk config shape into a flat Ruleset.
*
* Ordering within a scope:
* 1. tools entries (tool-name-as-surface, pattern "*")
* 2. bash entries (surface "bash", pattern = command glob)
* 3. mcp entries (surface "mcp", pattern = target glob)
* 4. skills entries (surface "skill", pattern = skill glob)
* 5. special entries (key-as-surface, pattern "*")
*
* defaultPolicy is NOT included — handled separately (see below).
*/
export function normalizeConfig(config: NormalizableConfig): Ruleset {
const rules: Ruleset = [];
for (const [name, action] of Object.entries(config.tools ?? {}))
rules.push({ surface: name, pattern: "*", action });
for (const [pattern, action] of Object.entries(config.bash ?? {}))
rules.push({ surface: "bash", pattern, action });
for (const [pattern, action] of Object.entries(config.mcp ?? {}))
rules.push({ surface: "mcp", pattern, action });
for (const [pattern, action] of Object.entries(config.skills ?? {}))
rules.push({ surface: "skill", pattern, action });
for (const [name, action] of Object.entries(config.special ?? {}))
rules.push({ surface: name, pattern: "*", action });
return rules;
}
```
### Why `defaultPolicy` stays separate from the Ruleset
`defaultPolicy` cannot be fully represented as catch-all rules in the `Ruleset` because:
1. `defaultPolicy.tools` would need to match ALL tool-name surfaces (read, write, edit, bash, grep, find, ls, plus unknown extension tools).
No single rule surface pattern can match exactly "all tool names" without also matching bash/mcp/skill surfaces.
2. `defaultPolicy.special` has the same problem — special keys (e.g., `external_directory`) become their own surfaces.
3. MCP baseline auto-allow depends on distinguishing "no rule matched" from "a defaultPolicy catch-all matched."
If `defaultPolicy.mcp` were a catch-all in the ruleset, it would always match and prevent the baseline heuristic from firing.
`defaultPolicy` is loaded and merged separately as `PermissionDefaultPolicy` (shallow spread across scopes, same as today).
When `evaluate()` returns a synthetic default (no rule matched), `checkPermission()` consults the merged `defaultPolicy` for the appropriate surface fallback.
Note: `defaultPolicy.bash`, `defaultPolicy.mcp`, and `defaultPolicy.skills` COULD be represented as catch-all rules (they have fixed surface names), but excluding them preserves MCP baseline auto-allow and keeps all defaults in one place.
Once MCP baseline auto-allow is formalized as explicit rules (#57), defaults can optionally move into the ruleset.
### `src/defaults.ts`
```typescript
import type { PermissionDefaultPolicy, PermissionState } from "./types";
export const DEFAULT_POLICY: PermissionDefaultPolicy = {
tools: "ask",
bash: "ask",
mcp: "ask",
skills: "ask",
special: "ask",
};
/**
* Map a surface name used in evaluate() to the corresponding
* defaultPolicy key. Returns undefined for unknown surfaces.
*/
const SURFACE_TO_DEFAULT_KEY: Record<string, keyof PermissionDefaultPolicy> = {
bash: "bash",
mcp: "mcp",
skill: "skill",
// tool-name surfaces (read, write, edit, etc.) and special-key surfaces
// (external_directory) do not have dedicated keys — they fall back to
// "tools" or "special" respectively via getSurfaceDefault().
};
/**
* Resolve the default action for a surface, consulting merged defaults.
*/
export function getSurfaceDefault(
surface: string,
defaults: PermissionDefaultPolicy,
specialKeys: ReadonlySet<string>,
): PermissionState {
const key = SURFACE_TO_DEFAULT_KEY[surface];
if (key) return defaults[key];
if (specialKeys.has(surface)) return defaults.special;
return defaults.tools;
}
```
### Merge
```typescript
// Config normalization produces a Ruleset per scope
const globalRules = normalizeConfig(globalConfig);
const projectRules = normalizeConfig(projectConfig);
const agentRules = normalizeConfig(agentFrontmatter);
const projectAgentRules = normalizeConfig(projectAgentFrontmatter);
// Concatenation — later scopes appear last → higher priority via last-match-wins
const mergedRules = [
...globalRules,
...projectRules,
...agentRules,
...projectAgentRules,
];
// Defaults merged separately (shallow spread, same as today)
const mergedDefaults = {
...DEFAULT_POLICY,
...globalDefaults,
...projectDefaults,
...agentDefaults,
...projectAgentDefaults,
};
```
### Simplified `ResolvedPermissions`
```typescript
type ResolvedPermissions = {
rules: Ruleset;
defaults: PermissionDefaultPolicy;
configuredMcpServerNames: readonly string[];
// merged.mcp is still needed for MCP baseline auto-allow heuristic
// (checks whether ANY mcp rule has action "allow")
hasAnyMcpAllowRule: boolean;
};
```
### Simplified `checkPermission()`
Each surface branch reduces to `evaluate()` + default fallback:
```typescript
// Tools (read, write, edit, grep, find, ls, extension tools)
const rule = evaluate(normalizedToolName, "*", rules);
const explicit = rules.includes(rule);
return { state: explicit ? rule.action : defaults.tools, source: explicit ? "tool" : "default" };
// Bash
const rule = evaluate("bash", command, rules);
const explicit = rules.includes(rule);
return { state: explicit ? rule.action : defaults.bash, source: explicit ? "bash" : "default" };
// Skills
const rule = evaluate("skill", skillName, rules);
const explicit = rules.includes(rule);
return { state: explicit ? rule.action : defaults.skills, source: explicit ? "skill" : "default" };
// Special (external_directory)
const rule = evaluate(normalizedToolName, "*", rules);
const explicit = rules.includes(rule);
return { state: explicit ? rule.action : defaults.special, source: explicit ? "special" : "default" };
// MCP — multi-name loop preserved, baseline auto-allow preserved
for (const target of mcpTargets) {
const rule = evaluate("mcp", target, rules);
if (rules.includes(rule)) return { state: rule.action, source: "mcp" };
}
// ... baseline auto-allow heuristic ...
return { state: defaults.mcp, source: "default" };
```
### Simplified `getToolPermission()`
```typescript
getToolPermission(toolName: string, agentName?: string): PermissionState {
const { rules, defaults } = this.resolvePermissions(agentName);
const rule = evaluate(normalizedToolName, "*", rules);
if (rules.includes(rule)) return rule.action;
if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) return defaults.special;
return defaults.tools;
}
```
### Edge cases
1. **MCP baseline auto-allow**: preserved.
`hasAnyMcpAllowRule` is derived from the merged `Ruleset` by checking if any rule with `surface: "mcp"` has `action: "allow"`.
The heuristic fires only when no explicit rule matched AND no catch-all (from `tools.mcp`) matched.
2. **`tools.bash` dual-purpose**: preserved naturally.
`tools.bash: "allow"``{ surface: "bash", pattern: "*", action: "allow" }` is a bash catch-all.
Phase 1 (`getToolPermission("bash")`) matches it → tool exposed.
Phase 2 (`evaluate("bash", command, ...)`) uses it as fallback for unmatched commands.
3. **`getBashPermissions()` removal**: the method returns `merged.bash` which no longer exists in the simplified model.
It has no callers outside `permission-manager.ts` and can be removed.
4. **`normalizeRawPermission()` (frontmatter parsing)**: updated to return a shape compatible with `normalizeConfig()` input.
Its deprecated-key detection and top-level shorthand logic are preserved.
5. **Compiled regex caching**: deferred.
`evaluate()` uses `wildcardMatch()` which compiles a regex per call.
This is fast enough for current ruleset sizes (< 1μs per pattern).
Pre-compiled rules can be introduced in a follow-up if profiling shows a need.
6. **`source` field in `PermissionCheckResult`**: preserved with the same values.
`"tool"` for tool-surface matches, `"bash"` for bash matches, `"mcp"` for MCP matches, `"skill"` for skill matches, `"special"` for special matches, `"default"` when no rule matched.
### Behavioral difference: `getToolPermission()` now sees command-level catch-alls
With tool-name-as-surface, `getToolPermission("bash")` calls `evaluate("bash", "*", rules)`.
If the user has `bash: { "*": "allow" }` (a command-level catch-all), this matches and returns `"allow"` — exposing the bash tool.
Previously, `getToolPermission("bash")` only checked `tools.bash` and `defaultPolicy.bash`, ignoring command-level patterns.
The new behavior is more consistent: if every bash command is allowed via a catch-all, the tool should be exposed.
Conversely, if `bash: { "*": "deny" }`, the tool is hidden — which is better UX than showing a tool that always fails.
## Module-Level Changes
### `src/normalize.ts` (new)
- Export `normalizeConfig(config): Ruleset`.
- Export `NormalizableConfig` interface (subset of `UnifiedPermissionConfig` covering only policy fields).
- Pure module — no IO, imports only `./rule` and `./types`.
### `src/defaults.ts` (new)
- Export `DEFAULT_POLICY: PermissionDefaultPolicy`.
- Export `getSurfaceDefault(surface, defaults, specialKeys): PermissionState`.
- Export `mergeDefaults(...partials): PermissionDefaultPolicy`.
- Move `DEFAULT_POLICY` constant from `permission-manager.ts`.
- Move `normalizePolicy()` / `normalizePartialPolicy()` here as `mergeDefaults()`.
### `src/rule.ts` (modified)
- Remove `SURFACE_DEFAULTS` and `getDefaultAction()` — moved to `src/defaults.ts` as `getSurfaceDefault()`.
- `evaluate()` accepts an optional `defaultAction` parameter (defaults to `"ask"`) instead of calling `getDefaultAction()`.
This keeps `evaluate()` pure without depending on `defaults.ts`.
### `src/permission-manager.ts` (modified — major)
- Remove `compiledToRuleset()` helper.
- Remove `compilePermissionPatternsFromSources()` helper.
- Remove `findCompiledPermissionMatch()` / `findCompiledPermissionMatchForNames()` helpers.
- Remove `mergePermissions()` — replaced by array concatenation.
- Remove `normalizePolicy()` / `normalizePartialPolicy()` — moved to `defaults.ts`.
- Remove `normalizePermissionRecord()``normalizeConfig()` handles this.
- Remove `BashFilter` import and usage.
- Simplify `ResolvedPermissions` to `{ rules: Ruleset, defaults: PermissionDefaultPolicy, hasAnyMcpAllowRule: boolean }`.
- Simplify `resolvePermissions()`: call `normalizeConfig()` per scope, concatenate, merge defaults.
- Simplify `checkPermission()`: direct `evaluate()` calls against merged ruleset, no more per-surface compiled pattern intermediary.
- Simplify `getToolPermission()`: single `evaluate()` call + default fallback.
- Remove `getBashPermissions()` (dead method, no external callers).
- Update `normalizeRawPermission()` to return a shape compatible with `normalizeConfig()` input.
- Update config caches to store `Ruleset` + defaults per scope instead of `GlobalPermissionConfig` / `AgentPermissions`.
### `src/bash-filter.ts` (removed)
- `BashFilter` class is already dead code in `checkPermission()` (destructured as `_bashFilter` since #55).
- All functionality replaced by `evaluate("bash", command, rules)`.
### `src/types.ts` (modified)
- Remove `ToolPermissions`, `BashPermissions`, `SkillPermissions`, `SpecialPermissions` type aliases.
- Remove `AgentPermissions` interface.
- Remove `GlobalPermissionConfig` interface.
- Keep `PermissionState`, `BuiltInToolName`, `PermissionDefaultPolicy`, `PermissionCheckResult`.
- Keep `SpecialPermissionName` (used for type-level documentation).
### `src/wildcard-matcher.ts` (no change)
- `wildcardMatch()`, `compileWildcardPattern()`, etc. remain as-is.
- `compileWildcardPatternEntries()` and `findCompiledWildcardMatch()` may become unused after this change — removal deferred to a cleanup pass.
### `src/config-loader.ts` (no change)
- `UnifiedPermissionConfig` and loading functions unchanged.
- `normalizeConfig()` in `src/normalize.ts` takes the loaded config as input.
### `schemas/permissions.schema.json` (no change)
- On-disk format unchanged.
### `config/config.example.json` (no change)
- Example config unchanged.
### Tests
| File | Change |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `tests/normalize.test.ts` (new) | Unit tests for `normalizeConfig()`: per-surface conversion, ordering, edge cases |
| `tests/defaults.test.ts` (new) | Unit tests for `getSurfaceDefault()`, `mergeDefaults()` |
| `tests/rule.test.ts` (modified) | Update `evaluate()` tests for optional `defaultAction` parameter; remove `getDefaultAction` tests (moved to defaults.test.ts) |
| `tests/permission-system.test.ts` (modified) | Update to remove `GlobalPermissionConfig`/`AgentPermissions` references; remove `BashFilter` test; adapt helper functions |
| `tests/bash-filter.test.ts` (removed) | Covered by `normalize.test.ts` + `rule.test.ts` + `permission-system.test.ts` |
| `tests/session-start.test.ts` (modified) | Update `GlobalPermissionConfig` references |
## TDD Order
### Phase 1: New modules (additive, no existing code changes)
1. **test: normalizeConfig converts tools entries to tool-name-as-surface rules** Red: test that `normalizeConfig({ tools: { read: "allow", write: "deny" } })` produces `[{ surface: "read", pattern: "*", action: "allow" }, { surface: "write", pattern: "*", action: "deny" }]`.
Green: implement `normalizeConfig()` tools path in `src/normalize.ts`.
`test: normalizeConfig tools entries`
2. **test: normalizeConfig converts bash entries to surface "bash" rules** Red: test `normalizeConfig({ bash: { "git *": "allow", "rm -rf *": "deny" } })` produces bash rules.
Green: implement bash path.
`test: normalizeConfig bash entries`
3. **test: normalizeConfig converts mcp, skills, special entries** Red: test all remaining surfaces.
Verify special keys become their own surface (`external_directory`).
Green: implement remaining paths.
`test: normalizeConfig mcp, skills, special entries`
4. **test: normalizeConfig ordering — tools before bash/mcp/skills/special** Red: test that with `{ tools: { bash: "allow" }, bash: { "git *": "ask" } }`, the tools catch-all appears before the bash-specific rule.
Green: already passes from implementation order.
`test: normalizeConfig rule ordering`
5. **test: normalizeConfig empty/missing sections produce empty ruleset** Red: test `normalizeConfig({})` returns `[]`.
Green: already passes.
`test: normalizeConfig empty config`
6. **test: getSurfaceDefault returns correct defaults for each surface category** Red: test that `getSurfaceDefault("bash", defaults, specialKeys)` returns `defaults.bash`, tool surfaces return `defaults.tools`, special surfaces return `defaults.special`.
Green: implement `getSurfaceDefault()` in `src/defaults.ts`.
`test: getSurfaceDefault per-surface dispatch`
7. **test: mergeDefaults shallow-merges partial policies** Red: test that `mergeDefaults(globalDefaults, projectDefaults)` produces correct merged result.
Green: implement `mergeDefaults()` in `src/defaults.ts`.
`test: mergeDefaults shallow merge`
8. **feat: add normalizeConfig and defaults modules**
Commit the new modules (`src/normalize.ts`, `src/defaults.ts`) and their tests.
`feat: add normalizeConfig and defaults modules`
### Phase 2: Update evaluate() signature
1. **test: update evaluate() tests for optional defaultAction parameter** Red→Green: update `tests/rule.test.ts``evaluate()` now accepts an optional `defaultAction` instead of calling `getDefaultAction()`.
Move `getDefaultAction()` tests to `tests/defaults.test.ts`.
`test: evaluate with optional defaultAction parameter`
2. **feat: evaluate() accepts optional defaultAction** Change `evaluate()` signature to accept `defaultAction?: PermissionState`.
When no rule matches, use `defaultAction ?? "ask"` instead of `getDefaultAction(surface)`.
Remove `getDefaultAction()` and `SURFACE_DEFAULTS` from `src/rule.ts`.
`feat: evaluate accepts optional defaultAction parameter`
### Phase 3: Refactor PermissionManager internals
1. **refactor: update permission-system.test.ts helpers for new types** Update test helper functions that construct `GlobalPermissionConfig` / `AgentPermissions` to use `UnifiedPermissionConfig` or inline `Record<string, PermissionState>`.
Remove the `BashFilter` test from `permission-system.test.ts`.
All tests should still pass (helpers produce equivalent data).
`test: update permission-system test helpers for new types`
2. **refactor: resolvePermissions uses normalizeConfig and array concat** Replace per-surface compiled pattern arrays with `normalizeConfig()` per scope.
Replace `mergePermissions()` with array concatenation.
Replace per-scope `GlobalPermissionConfig` / `AgentPermissions` caches with `Ruleset` + defaults.
Simplify `ResolvedPermissions` type.
Run full test suite.
`refactor: resolvePermissions uses normalizeConfig and array concat`
3. **refactor: checkPermission uses merged Ruleset directly** Remove `compiledToRuleset()`.
Each surface branch calls `evaluate()` against the merged ruleset.
Fallback uses `getSurfaceDefault()`.
MCP baseline auto-allow logic preserved (uses `hasAnyMcpAllowRule`).
Run full test suite.
`refactor: checkPermission uses merged Ruleset directly`
4. **refactor: getToolPermission uses evaluate** Replace the per-surface `if/else if` chain with a single `evaluate()` call + `getSurfaceDefault()` fallback.
Run full test suite.
`refactor: getToolPermission uses evaluate`
### Phase 4: Remove dead code
1. **refactor: remove BashFilter class** Delete `src/bash-filter.ts`.
Delete `tests/bash-filter.test.ts`.
Remove import from `permission-manager.ts`.
Run full test suite.
`refactor: remove BashFilter class`
2. **refactor: remove per-surface type aliases** Remove `ToolPermissions`, `BashPermissions`, `SkillPermissions`, `SpecialPermissions` from `src/types.ts`.
Remove `AgentPermissions`, `GlobalPermissionConfig` from `src/types.ts`.
Update all remaining imports (tests, other modules).
Run `pnpm run build` (typecheck).
`refactor: remove per-surface type aliases and wrapper interfaces`
3. **refactor: remove getBashPermissions dead method** Remove `getBashPermissions()` from `PermissionManager` (no callers).
Run full test suite.
`refactor: remove getBashPermissions dead method`
4. **refactor: remove unused compiled-pattern helpers** If `compilePermissionPatternsFromSources()`, `findCompiledPermissionMatch()`, `findCompiledPermissionMatchForNames()` are now unused, remove them.
Check whether `compileWildcardPatternEntries()`, `compileWildcardPatterns()`, `findCompiledWildcardMatch()`, `findCompiledWildcardMatchForNames()` in `wildcard-matcher.ts` still have callers.
Remove any that are dead.
Run full test suite + `pnpm run build`.
`refactor: remove unused compiled-pattern helpers`
### Phase 5: Docs and verification
1. **docs: update target architecture to mark #56 complete**
Update `docs/architecture/target-architecture.md` refactoring sequence to mark #56 as done.
`docs: mark #56 complete in target architecture`
2. **verify: full build and test suite** Run `pnpm run build` and `npx vitest run`.
Confirm no regressions.
`chore: verify clean build after config normalization refactor`
## Risks and Mitigations
| Risk | Mitigation |
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Could this silently weaken a permission? | No new `"allow"` path is introduced. When no rule matches, `getSurfaceDefault()` consults the merged `defaultPolicy` which defaults to `"ask"` (least privilege). The `evaluate()` fallback is `"ask"`. Each refactor step runs the full test suite. |
| Semantic change: `getToolPermission()` now considers command-level catch-alls | With tool-name-as-surface, `getToolPermission("bash")` can match `bash: { "*": "allow" }`. This is more consistent (don't expose a tool if all commands are denied) and strictly tighter or equivalent. Covered by existing tests + new normalize tests. |
| MCP baseline auto-allow could be bypassed | `defaultPolicy` is NOT included in the ruleset, so `evaluate("mcp", target, rules)` returns synthetic default when no explicit/tools.mcp rule matches — baseline heuristic fires as before. Explicit test coverage for this path. |
| `tools.bash` dual-purpose semantic drift | `tools.bash: "allow"` normalizes to `{ surface: "bash", pattern: "*", action: "allow" }` — a bash catch-all. This naturally preserves both tool exposure and command fallback. Edge case: `tools.bash: "deny"` + `bash: { "*": "allow" }` now exposes the tool (bash catch-all overrides tools entry). Previously, `tools.bash: "deny"` always hid the tool. This contradictory config is likely a user error, and the new behavior is arguable. Add explicit test. |
| Large test file churn in permission-system.test.ts | Step 11 updates test helpers BEFORE refactoring production code. Changes are mechanical (type alias replacement). Intermediate commits keep the suite green. |
| Performance regression from losing compiled regex cache | `wildcardMatch()` compiles a regex per call (~1μs). For typical rulesets (< 50 rules), total overhead is < 50μs per permission check. Acceptable. Pre-compiled rules deferred to follow-up if needed. |
| `normalizeRawPermission()` (frontmatter) diverges from `normalizeConfig()` | Both share the same input shape (`NormalizableConfig`). `normalizeRawPermission()` handles raw YAML parsing + deprecated keys, then passes the validated shape to `normalizeConfig()`. Tested explicitly. |
## Open Questions
- **Should `evaluate()` accept `defaultAction` as a parameter or keep calling `getDefaultAction()`?**
Plan proposes parameter — cleaner for testing and keeps `evaluate()` independent of `defaults.ts`.
Final decision during implementation.
- **Should unused `compileWildcardPatternEntries` / `findCompiledWildcardMatch` exports be removed in this PR?**
They may be used by other modules not yet migrated to `evaluate()`.
Removal is in step 18 but gated on checking callers.
- **Should `normalizeConfig()` accept raw `Record<string, unknown>` or the already-validated `Record<string, PermissionState>` sub-objects?**
Plan uses the validated shape (`NormalizableConfig`).
Raw validation stays in `config-loader.ts` / `normalizeRawPermission()`.