16 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 108 | refactor: extract PolicyLoader from PermissionManager |
Extract PolicyLoader from PermissionManager
Problem Statement
PermissionManager conflates three responsibilities: file I/O with mtime caching, 4-scope policy merge with origin tracking, and permission evaluation.
Testing any one concern requires satisfying all three — most critically, every test that checks merge or evaluation logic must write real files to a temp directory.
Extracting the I/O layer into a dedicated PolicyLoader makes the merge and evaluation paths testable with pure in-memory stubs.
Goals
- Extract a
PolicyLoaderinterface and aFilePolicyLoaderimplementation that owns allreadFileSync/statSynccalls and mtime-based caching currently inPermissionManager. - Make
PermissionManageraccept aPolicyLoadervia constructor injection. - Preserve the existing public API of
PermissionManager— callers continue to construct it the same way (options bag) and callcheckPermission,getToolPermission,getComposedConfigRules, etc. - Enable future tests to supply an in-memory
PolicyLoaderstub (no filesystem).
Non-Goals
- Rewriting the existing
permission-manager-unified.test.tsorpermission-system.test.tssuites — they already work; converting them to use in-memory stubs is a follow-up. - Changing the merge algorithm or evaluation semantics.
- Extracting
getConfiguredMcpServerNamesinto a separate service (it can live onPolicyLoaderfor now since it reads from disk with caching). - Changing
HandlerDepsorcreatePermissionManagerForCwdsignatures.
Background
Permission surface
This change is surface-agnostic — it restructures internal plumbing, not permission evaluation.
Relevant modules
| File | Role |
|---|---|
src/permission-manager.ts |
All three responsibilities today |
src/runtime.ts |
createPermissionManagerForCwd() — constructs PermissionManager |
src/handlers/types.ts |
HandlerDeps.createPermissionManagerForCwd — factory signature |
src/config-loader.ts |
loadUnifiedConfig, normalizeUnifiedConfig, stripJsonComments — called by PM's load methods |
src/common.ts |
extractFrontmatter, parseSimpleYamlMap — called by agent-scope loading |
tests/permission-manager-unified.test.ts |
663-line test file exercising PM through temp files |
tests/permission-system.test.ts |
Integration tests constructing PM directly |
How PermissionManager is constructed today
The options bag already accepts path overrides (globalConfigPath, agentsDir, projectGlobalConfigPath, projectAgentsDir, globalMcpConfigPath, mcpServerNames).
createPermissionManagerForCwd in runtime.ts builds the options from agentDir + cwd.
Design Overview
New interface: PolicyLoader
interface PolicyLoader {
loadGlobalConfig(): ScopeConfig;
loadProjectConfig(): ScopeConfig;
loadAgentConfig(agentName?: string): ScopeConfig;
loadProjectAgentConfig(agentName?: string): ScopeConfig;
getConfiguredMcpServerNames(): readonly string[];
/** Combined mtime stamp for cache invalidation. */
getCacheStamp(agentName?: string): string;
/** Accumulated config-parse issues across all loads. */
getConfigIssues(): string[];
/** Resolved paths for the /permission-system show command. */
getResolvedPolicyPaths(): ResolvedPolicyPaths;
}
New class: FilePolicyLoader
Lives in src/policy-loader.ts.
Receives the same path/override options currently on PermissionManager's constructor.
Moves all readFileSync/statSync calls, mtime caches, and the getConfiguredMcpServerNames disk reader out of PermissionManager.
Slimmed PermissionManager
Constructor gains an optional policyLoader field in the options bag.
When omitted, PermissionManager constructs a FilePolicyLoader internally using the path options — this preserves backward compatibility so every existing new PermissionManager({...}) call continues to work without changes.
constructor(options: PermissionManagerOptions = {}) {
this.loader = options.policyLoader ?? new FilePolicyLoader(options);
// …no path fields stored on PM itself
}
resolvePermissions() calls this.loader.loadGlobalConfig() etc. instead of this.loadGlobalConfig().
checkPermission() calls this.loader.getConfiguredMcpServerNames().
getConfigIssues() delegates to this.loader.getConfigIssues().
getResolvedPolicyPaths() delegates to this.loader.getResolvedPolicyPaths().
getPolicyCacheStamp() delegates to this.loader.getCacheStamp().
The mtime-based resolvedPermissionsCache stays on PermissionManager — it caches the merge result, not raw I/O.
Backward compatibility
PermissionManagerOptionskeeps all existing path fields. They are forwarded toFilePolicyLoaderwhen no explicitpolicyLoaderis provided.- All external callers (
runtime.ts,config-reporter.ts, test files) continue to constructnew PermissionManager({ globalConfigPath, … })unchanged. createPermissionManagerForCwdinruntime.tsneeds no changes.
Module-Level Changes
src/policy-loader.ts (new)
- Export
PolicyLoaderinterface. - Export
FilePolicyLoaderclass implementing it. - Move from
permission-manager.ts:getFileStamp,readConfiguredMcpServerNamesFromConfigPath,getConfiguredMcpServerNamesFromPaths, allload*Configmethods,getConfiguredMcpServerNames,getPolicyCacheStamp,getResolvedPolicyPaths, the associated cache fields, andResolvedPolicyPaths. - Move imports of
readFileSync,statSync,existsSyncinto this file.
src/permission-manager.ts (changed)
- Remove all filesystem imports (
readFileSync,statSync,existsSync). - Remove moved methods and cache fields.
- Import
PolicyLoader,FilePolicyLoader,ResolvedPolicyPathsfrom./policy-loader. - Re-export
ResolvedPolicyPaths(it is part of the public API). - Add
policyLoader?: PolicyLoaderto the constructor options type. - Construct
FilePolicyLoaderwhen no loader provided. - Delegate
getConfigIssues,getResolvedPolicyPaths,getPolicyCacheStamptothis.loader. resolvePermissionscallsthis.loader.*for scope configs.checkPermissioncallsthis.loader.getConfiguredMcpServerNames().
tests/policy-loader.test.ts (new)
- Unit tests for
FilePolicyLoaderusing temp directories (same strategy as existing tests). - Test mtime cache invalidation, missing-file handling, MCP server name reading.
tests/permission-manager-unified.test.ts (changed — minimal)
- Add 1–2 tests demonstrating in-memory
PolicyLoaderstub usage for merge/evaluate logic without filesystem. - Existing file-based tests are NOT rewritten.
docs/architecture/target-architecture.md (updated)
- Note
PolicyLoaderas the I/O boundary in the module diagram.
Test Impact Analysis
The extraction creates a clean I/O boundary, which changes where tests belong and what they need to set up.
New tests enabled by the extraction
With a PolicyLoader interface, PermissionManager becomes testable with a pure in-memory stub.
The following test categories can be written without touching the filesystem:
- Merge logic — 4-scope merge (global → project → agent → project-agent), deep-shallow merge semantics,
permission["*"]universal fallback extraction, origin tracking across scopes. Today these requirecreateManagerWithProject()+ temp files (permission-system.test.ts lines 1372–1560, 1927–2013). AnInMemoryPolicyLoaderthat returns predeterminedScopeConfigobjects tests the same logic with no I/O. - Evaluation logic —
checkPermission()surface routing,deriveSource(),matchedPattern,resultExtras. Today these requirecreateManager()+ temp files (permission-system.test.ts lines 621–1210, permission-manager-unified.test.ts lines 1–663). With an in-memory loader, each test is a one-liner construction + assertion. - Session rule composition — session rules appended to composed rules, last-match-wins interaction with config rules. Already exercised in permission-manager-unified.test.ts but still writes temp files for the base config.
- Config issue accumulation —
getConfigIssues()aggregation across scopes. Can be tested by making the in-memory loader return preset issues.
Existing tests that become redundant or simplifiable
Once the in-memory PolicyLoader tests cover merge and evaluation thoroughly, the following filesystem-based tests in permission-system.test.ts become integration-level redundancy.
They should not be deleted in this PR — they serve as regression anchors — but they can be marked for future simplification.
| Test (permission-system.test.ts) | What it really tests | After extraction |
|---|---|---|
| "PermissionManager canonical built-in permission checking" (line 621) | Evaluation: tool surface routing | Covered by in-memory eval test; file-based version is redundant integration |
| "multiline bash command resolves to allow" (line 639) | Evaluation: dotAll matching | Same — pure evaluation concern |
| "Bash specific deny patterns override catch-all" (line 660) | Evaluation: last-match-wins | Same |
| "MCP wildcard matching" (line 685) | Evaluation: MCP target normalization + matching | Same |
| "Arbitrary extension tools" (line 718) | Evaluation: extension tool source derivation | Same |
| "Skill permission matching" (line 742) | Evaluation: skill surface | Same |
| "MCP proxy tool infers server-prefixed aliases" (line 778) | Evaluation: MCP name inference | Same |
| "Project-level config overrides base bash patterns" (line 1372) | Merge: project > global | Covered by in-memory merge test |
| "System-agent config overrides project-level" (line 1405) | Merge: agent > project | Same |
| "Project-agent config overrides system-agent" (line 1447) | Merge: project-agent > agent | Same |
| "Full precedence chain" (line 1481) | Merge: all 4 scopes | Same |
These tests write temp files solely to feed PermissionManager a known policy.
With an in-memory loader, the same assertions run faster, in isolation, and without cleanup.
Tests that must stay file-based
Some tests genuinely exercise the I/O layer and belong on FilePolicyLoader:
| Test | Why it must stay file-based |
|---|---|
| "getResolvedPolicyPaths returns correct paths and existence" (line 2304) | Tests existsSync path probing |
| "getResolvedPolicyPaths returns false for missing files" (line 2340) | Same |
| "PermissionManager reads config from PI_CODING_AGENT_DIR" (line 1561) | Tests env-driven path resolution |
| "MCP server names in settings.json are not used" (line 805) | Tests mcp.json file reading |
Agent frontmatter tests with .md files (lines 999–1099) |
Tests YAML frontmatter extraction from disk |
These move to tests/policy-loader.test.ts or remain as integration tests that exercise the full stack.
Simplification plan (deferred to follow-up)
In a follow-up PR after this extraction lands:
- Add a shared
InMemoryPolicyLoadertest helper totests/helpers/. - Rewrite the merge-logic tests (lines 1372–1560) to use the in-memory loader — delete temp-dir setup.
- Rewrite the evaluation-logic tests (lines 621–1210) similarly.
- Keep the file-based integration tests in
permission-system.test.tsfor the I/O-dependent subset listed above. - Move
getResolvedPolicyPathsandgetConfigIssuestests totests/policy-loader.test.ts.
This follow-up is tracked as a non-goal of the current issue.
TDD Order
-
red → green: Create
src/policy-loader.tswith thePolicyLoaderinterface andFilePolicyLoaderskeleton. Writetests/policy-loader.test.tswith basic tests: construct aFilePolicyLoaderpointing at a temp dir, load global config, verifyScopeConfigreturned.test: add PolicyLoader interface and FilePolicyLoader skeleton tests -
feat: Move all I/O methods and caching from
PermissionManagerintoFilePolicyLoader. WirePermissionManagerto acceptpolicyLoaderoption and delegate. Existing tests must continue to pass (backward-compat constructor).feat: extract FilePolicyLoader from PermissionManager -
test: Add in-memory
PolicyLoaderstub tests intests/permission-manager-unified.test.ts— demonstrate merge and evaluation without filesystem. Cover: universal fallback, surface routing, session rule composition, origin tracking, multi-scope merge.test: add in-memory PolicyLoader stub tests for PermissionManager -
test: Add
FilePolicyLoaderedge-case tests — mtime cache invalidation, agent frontmatter loading, MCP server name dedup, missing files, config issue accumulation.test: cover FilePolicyLoader caching and edge cases -
docs: Update
docs/architecture/target-architecture.mdto reflect thePolicyLoaderextraction.docs: add PolicyLoader to target architecture
Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Could this silently weaken a permission? | No — merge algorithm and evaluation logic are unchanged; only the I/O call site moves. |
| Breaking existing constructor API | FilePolicyLoader is constructed internally when policyLoader option is absent; all existing callers work unchanged. |
| Cache invalidation regression | FilePolicyLoader.getCacheStamp() uses the same getFileStamp() logic; PermissionManager.resolvedPermissionsCache continues to use the stamp for its own invalidation. |
| Large test rewrite risk | Existing test files are NOT rewritten — only additive tests are planned. |
Open Questions
- Should
getConfiguredMcpServerNamesmove to a separateMcpConfigLoaderinterface, or stay onPolicyLoader? Deferred — keeping it onPolicyLoaderis simpler for now; it can be split later if MCP config grows.