22 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 65 | Synthesize defaults into ruleset and unify the evaluate path |
Synthesize defaults into ruleset and unify the evaluate path
Problem Statement
checkPermission() in permission-manager.ts has ~120 lines of per-surface branching with side-channel fallback values (bashDefault, mcpToolLevel, hasAnyMcpAllowRule) computed outside the ruleset.
These are effectively implicit rules that evaluate() never sees.
This means:
- Session rules cannot participate in the main evaluation path (they are checked separately in a pre-gate step in
tool-call.ts) - Adding session approvals to new surfaces (#51) would require duplicating the separate pre-check pattern for each surface
- The permission model has two decision engines:
evaluate()for explicit rules and per-surfaceif/elsefor defaults and fallbacks
Goals
- Add
synthesizeDefaults()that convertsdefaultPolicyinto catch-all rules at lowest priority. - Add
synthesizeOverrides()that convertstools.bash/tools.mcpinto catch-all rules between defaults and config rules. - Synthesize MCP baseline auto-allow rules conditionally when any explicit MCP allow rule exists.
- Thread session rules into
checkPermission()so they participate inevaluate()at highest priority. - Simplify
checkPermission()to rely onevaluate()alone — eliminatebashDefault,mcpToolLevel,hasAnyMcpAllowRuleside-channel values. - Remove the separate session-rule pre-check from
tool-call.ts—evaluate()handles it. - Add
source: "session"as a validPermissionCheckResult.sourcevalue. - All existing tests pass — behavior is unchanged.
Non-Goals
- Generalizing session approvals to non-external_directory surfaces (#51 — follow-up, blocked on this).
- Adding pattern suggestions to the permission dialog (#51).
- Changing the on-disk config format or
/permission-systemslash command name. - Changing
evaluate()itself — it remains a pure last-match-wins scanner. - Persisting session approvals to disk.
Background
Dependencies
| Issue | Status | Relationship |
|---|---|---|
| #55 | Closed | Extracted evaluate() — prerequisite, landed |
| #56 | Closed | Unified Rule type and normalizeConfig — prerequisite, landed |
| #57 | Closed | Replaced SessionApprovalCache with SessionRules — prerequisite, landed |
| #51 | Open | Generalize session approvals — blocked on this issue |
Relevant modules
| File | Role |
|---|---|
src/permission-manager.ts |
resolvePermissions() builds rules + side-channels; checkPermission() uses per-surface branching |
src/rule.ts |
Rule, Ruleset, evaluate() — the target sole decision engine |
src/normalize.ts |
normalizeConfig() converts on-disk config → Ruleset (excludes tools.bash/mcp) |
src/defaults.ts |
mergeDefaults(), getSurfaceDefault(), DEFAULT_POLICY |
src/session-rules.ts |
SessionRules class wrapping a Ruleset |
src/handlers/tool-call.ts |
Separate session-rule pre-check for external_directory, normal permission gate |
src/types.ts |
PermissionCheckResult (source field), PermissionDefaultPolicy |
Permission surfaces involved
All: tools, bash, mcp, skills, special (external_directory).
Current ResolvedPermissions type
type ResolvedPermissions = {
rules: Ruleset;
defaults: PermissionDefaultPolicy;
bashDefault: PermissionState;
mcpToolLevel: PermissionState | undefined;
hasAnyMcpAllowRule: boolean;
};
After this change, ResolvedPermissions simplifies to:
type ResolvedPermissions = {
/** Fully composed ruleset: defaults + overrides + baseline + config rules. */
composedRules: Ruleset;
};
Session rules are appended at call-time (not cached in resolvedPermissionsCache) because they change mid-session.
Design Overview
Composed ruleset layout
Index 0..D: Synthesized defaults (lowest priority)
{ surface: "*", pattern: "*", action: defaults.tools }
{ surface: "bash", pattern: "*", action: defaults.bash }
{ surface: "mcp", pattern: "*", action: defaults.mcp }
{ surface: "skill", pattern: "*", action: defaults.skills }
{ surface: "special", pattern: "*", action: defaults.special }
Index D+1..O: Synthesized overrides (tools.bash / tools.mcp, per-scope)
{ surface: "bash", pattern: "*", action: globalConfig.tools.bash }
{ surface: "bash", pattern: "*", action: projectConfig.tools.bash }
...
{ surface: "mcp", pattern: "*", action: globalConfig.tools.mcp }
...
Index O+1..B: MCP baseline auto-allow (conditional)
{ surface: "mcp", pattern: "mcp_status", action: "allow" }
{ surface: "mcp", pattern: "mcp_list", action: "allow" }
{ surface: "mcp", pattern: "mcp_search", action: "allow" }
{ surface: "mcp", pattern: "mcp_describe", action: "allow" }
{ surface: "mcp", pattern: "mcp_connect", action: "allow" }
Index B+1..C: Config rules (global → project → agent → project-agent)
{ surface: "bash", pattern: "git *", action: "allow" }
{ surface: "mcp", pattern: "exa:*", action: "allow" }
...
Index C+1..end: Session rules (highest priority, appended at call-time)
{ surface: "external_directory", pattern: "/other/proj/*", action: "allow" }
evaluate() scans from end → last-match-wins → session rules override config, config overrides baseline/overrides, overrides override defaults.
MCP baseline auto-allow as synthesized rules
Current behavior: if the MCP operation is a metadata target (status, list, search, describe, connect) AND (hasAnyMcpAllowRule OR defaults.mcp === "allow"), auto-allow.
After:
- If
defaults.mcp === "allow"→ the synthesized default{ surface: "mcp", pattern: "*", action: "allow" }catches all targets, including baseline ones. No separate baseline rules needed. - If any config rule has
surface: "mcp" && action: "allow"→ synthesize explicit baseline rules for the 5 targets, placed BEFORE config rules so explicit denies can still override them. - If neither condition → no baseline rules synthesized → baseline targets fall through to MCP default (ask or deny).
This preserves exact current behavior while expressing it as rules.
tools.bash / tools.mcp override rules
AGENTS.md states: "tools.bash and tools.mcp are fallback overrides — they set the default when no bash/mcp pattern matches, but specific patterns from any scope always have priority."
These become { surface: "bash"|"mcp", pattern: "*" } catch-all rules placed BETWEEN defaults and config rules.
Specific patterns from config rules sit at higher indices → last-match-wins ensures they override the catch-all.
Multiple scopes each contribute their own override rule; scope ordering (global → project → agent → project-agent) and last-match-wins handle precedence.
Rule.layer metadata
Add an optional layer field to Rule for source reporting:
export interface Rule {
surface: string;
pattern: string;
action: PermissionState;
/** Origin layer — used to derive PermissionCheckResult.source. Not used by evaluate(). */
layer?: "default" | "override" | "baseline" | "config" | "session";
}
evaluate() ignores this field.
Post-evaluation, checkPermission() derives PermissionCheckResult.source:
rule.layer |
Derived source |
|---|---|
"default" |
"default" for extension tools; "tool" for built-in tools; surface name for bash/mcp/skill/special |
"override" |
"tool" (preserves current tools.bash/tools.mcp → source: "tool" behavior) |
"baseline" |
"mcp" |
"config" or undefined |
Derived from rule.surface: bash→"bash", mcp→"mcp", skill→"skill", special→"special", else→"tool" |
"session" |
"session" (new value) |
Threading session rules into checkPermission()
Add an optional sessionRules parameter:
checkPermission(
toolName: string,
input: unknown,
agentName?: string,
sessionRules?: Ruleset,
): PermissionCheckResult
When provided, session rules are appended to the composed ruleset before evaluate().
This keeps PermissionManager stateless regarding sessions — sessions are runtime state, not configuration.
Removing the external_directory pre-check
After threading session rules, tool-call.ts changes from:
// Before: separate session pre-check
const sessionRuleset = deps.runtime.sessionRules.getRuleset();
const sessionMatch = evaluate("external_directory", path, sessionRuleset);
if (sessionRuleset.includes(sessionMatch)) { /* log + skip */ }
else { /* normal gate */ }
To:
// After: unified check
const extCheck = deps.runtime.permissionManager.checkPermission(
"external_directory", { path: normalizedExtPath }, agentName,
deps.runtime.sessionRules.getRuleset(),
);
if (extCheck.source === "session") { /* log session_approved + skip */ }
else { /* normal gate using extCheck.state */ }
getToolPermission() simplification
getToolPermission() also uses bashDefault/mcpToolLevel.
After the change, it evaluates against the composed rules directly.
For "bash", it evaluates evaluate("bash", "*", composedRules).
For "mcp", it evaluates evaluate("mcp", "*", composedRules).
The synthesized override rules ensure correct results.
PermissionCheckResult.source update
Add "session" to the source union:
export interface PermissionCheckResult {
// ...
source: "tool" | "bash" | "mcp" | "skill" | "special" | "default" | "session";
}
External-directory input normalization
Currently checkPermission("external_directory", {}) always matches the generic policy.
After the change, when called with path info ({ path: normalizedExtPath }), it evaluates evaluate("external_directory", normalizedExtPath, composedRules) so session rules can match specific paths.
When called without path info (e.g., to get the general policy for tool filtering), it evaluates with "*" as the value.
Module-Level Changes
src/rule.ts
- Add optional
layer?: "default" | "override" | "baseline" | "config" | "session"toRuleinterface. - No changes to
evaluate()— it ignoreslayer.
src/synthesize.ts (new)
synthesizeDefaults(defaults: PermissionDefaultPolicy): Ruleset— 5 catch-all rules withlayer: "default".synthesizeOverrides(overrides: Array<{ bash?: PermissionState; mcp?: PermissionState }>): Ruleset— per-scope override rules withlayer: "override".synthesizeBaseline(configRules: Ruleset): Ruleset— conditional MCP baseline rules withlayer: "baseline". Emits rules only whenconfigRulescontains at least onesurface: "mcp" && action: "allow"rule.composeRuleset(defaults: Ruleset, overrides: Ruleset, baseline: Ruleset, configRules: Ruleset): Ruleset— concatenates in priority order.
src/permission-manager.ts
- Remove
bashDefault,mcpToolLevel,hasAnyMcpAllowRulefromResolvedPermissions. - Replace with
composedRules: Ruleset(excludes session rules — those are appended at call-time). resolvePermissions()callssynthesizeDefaults(),synthesizeOverrides(),synthesizeBaseline(),composeRuleset().- Simplify
checkPermission(): input normalization →evaluate(surface, value, [...composedRules, ...sessionRules])→ derive source fromrule.layer. - Simplify
getToolPermission(): evaluate against composed rules directly. - MCP multi-target loop remains: iterate targets, call
evaluate()for each, return first match. - Add
sessionRules?: Rulesetparameter tocheckPermission(). - Remove
TOOL_SURFACE_OVERRIDE_KEYSimport usage from resolve flow (overrides extracted separately).
src/normalize.ts
- No changes —
normalizeConfig()continues to excludetools.bash/tools.mcpviaTOOL_SURFACE_OVERRIDE_KEYS. - Config rules emitted by
normalizeConfig()getlayer: "config"(either innormalizeConfig()or applied by the caller).
src/defaults.ts
getSurfaceDefault()can be removed after the refactor (defaults are rules now). Defer removal to avoid breaking other callers — mark as@deprecated.mergeDefaults()remains (needed to compute the merged default policy before synthesizing).
src/types.ts
- Add
"session"toPermissionCheckResult.sourceunion type.
src/session-rules.ts
- Add
layer: "session"to rules created bySessionRules.approve().
src/handlers/tool-call.ts
- Remove separate session-rule pre-check for file-tool external_directory gate.
- Remove separate session-rule pre-check for bash external_directory gate.
- Pass
deps.runtime.sessionRules.getRuleset()tocheckPermission(). - Check
result.source === "session"to logsession_approved. - Keep
deriveApprovalPattern()usage for recording new session approvals on "approved_for_session". - Remove
import { evaluate } from "../rule"(no longer needed in handler).
tests/synthesize.test.ts (new)
- Unit tests for
synthesizeDefaults(),synthesizeOverrides(),synthesizeBaseline(),composeRuleset().
tests/permission-system.test.ts
- Update tests that assert
source: "default"for built-in tools (behavior unchanged, but verify). - Add tests for session-rule-aware
checkPermission().
tests/handlers/tool-call.test.ts
- Remove session pre-check mock setup.
- Add assertions that
checkPermissionis called with session rules. - Verify
session_approvedlogging still works viasource === "session".
tests/rule.test.ts
- Add test verifying
evaluate()ignoreslayerfield (doesn't affect matching).
TDD Order
-
test: add Rule.layer type and verify evaluate() ignores it
- Red: test that a rule with
layer: "config"matches identically to one without. - Green: add
layer?toRuleinterface. - Commit:
test: verify evaluate() ignores Rule.layer metadata
- Red: test that a rule with
-
test: add synthesizeDefaults unit tests
- Red: write tests for
synthesizeDefaults()output shape and layer tagging. - Green: implement
src/synthesize.tswithsynthesizeDefaults(). - Commit:
feat: add synthesizeDefaults() (#65)
- Red: write tests for
-
test: add synthesizeOverrides unit tests
- Red: test per-scope override generation, empty-input handling.
- Green: implement
synthesizeOverrides(). - Commit:
feat: add synthesizeOverrides() (#65)
-
test: add synthesizeBaseline unit tests
- Red: test conditional MCP baseline synthesis (present when allow exists, absent when not).
- Green: implement
synthesizeBaseline(). - Commit:
feat: add synthesizeBaseline() for MCP auto-allow (#65)
-
test: add composeRuleset unit tests
- Red: test correct ordering of layers, last-match-wins behavior across layers.
- Green: implement
composeRuleset(). - Commit:
feat: add composeRuleset() (#65)
-
feat: add layer tagging to SessionRules.approve()
- Update
SessionRules.approve()to setlayer: "session". - Update session-rules tests.
- Commit:
feat: tag session rules with layer metadata (#65)
- Update
-
feat: add "session" to PermissionCheckResult.source
- Update
src/types.ts. - Commit:
feat: add "session" source to PermissionCheckResult (#65)
- Update
-
feat: refactor resolvePermissions() to use composed ruleset
- Replace
bashDefault,mcpToolLevel,hasAnyMcpAllowRulewithcomposedRules. - Call
synthesizeDefaults(),synthesizeOverrides(),synthesizeBaseline(),composeRuleset(). - Update
ResolvedPermissionstype. - Keep
checkPermission()andgetToolPermission()working (adapt them to usecomposedRules). - All existing permission-system tests must pass.
- Commit:
feat: compose ruleset with synthesized defaults and overrides (#65)
- Replace
-
feat: simplify checkPermission() to use evaluate() alone
- Replace per-surface branching with unified evaluate loop.
- Add
sessionRules?: Rulesetparameter. - Derive
sourcefromrule.layer. - MCP multi-target pre-processing remains (loop over candidates).
- All existing tests must pass.
- Commit:
feat: unify checkPermission() through evaluate() (#65)
-
feat: simplify getToolPermission() to use composed rules
- Remove bashDefault/mcpToolLevel references.
- Evaluate against composed rules directly.
- Commit:
feat: simplify getToolPermission() with composed ruleset (#65)
-
feat: remove external_directory session pre-check from tool-call handler
- Pass session rules to
checkPermission(). - Check
source === "session"for logging. - Remove direct
evaluate()call and session ruleset handling. - Update tool-call handler tests.
- Commit:
feat: remove separate session pre-check from tool_call (#65)
- Pass session rules to
-
test: add integration tests for session-aware checkPermission
- Test that session rules override config for external_directory.
- Test that session rules don't affect surfaces they weren't approved for.
- Commit:
test: integration coverage for session-aware evaluation (#65)
-
docs: update architecture docs and deprecate getSurfaceDefault()
- Mark
getSurfaceDefault()as@deprecated. - Update
docs/architecture/target-architecture.mdto reflect implementation. - Commit:
docs: update architecture for synthesized defaults (#65)
- Mark
Risks and Mitigations
| Risk | Mitigation |
|---|---|
| MCP baseline auto-allow semantics change subtly when expressed as rules | Synthesized baseline rules are placed BEFORE config rules, so explicit deny rules override them. Condition matches exactly: configRules.some(r => r.surface === "mcp" && r.action === "allow"). Existing MCP baseline tests verify behavior. |
source field derivation changes for edge cases, breaking tests |
Explicit derivation table with per-built-in-tool handling. Run full test suite at each step. |
| tools.bash/tools.mcp override rules accidentally override explicit patterns from lower-priority scopes | Override rules use pattern "*" and are placed BEFORE config rules. Any specific pattern in config sits at higher index and wins via last-match-wins. Explicit test for this case. |
| Session rules appended at call-time cause cache invalidation thrash | Session rules are NOT part of resolvedPermissionsCache — they're appended fresh on each checkPermission() call. The composed config rules remain cached. |
| Could this silently weaken a permission? | No — the change is purely structural. Every decision path is verified against existing tests. Synthesized defaults use the same values as the current hardcoded fallbacks. Session rules remain allow-only and user-approved. |
| Performance regression from larger rule arrays | Rule arrays are small (typically <50 entries). evaluate() is a linear scan from end. No measurable impact. |
normalizeConfig() layer tagging changes existing rule objects |
Layer is added during composition, not in normalizeConfig(). Existing callers of normalizeConfig() see rules without layer tags — no behavioral change. |
Open Questions
- Should
composeRuleset()live insrc/synthesize.tsorsrc/compose.ts? Leaning towardsrc/synthesize.tssince it co-locates all rule synthesis logic. Revisit if the file grows beyond ~100 lines. - Should
getSurfaceDefault()be removed immediately or deprecated? Deprecation is safer — it may have callers inbefore-agent-start.tsor tool filtering. Remove in a follow-up cleanup. - Should the
PermissionCheckResultinclude amatchedRule?: Rulefield for debugging? Useful for #51 (session approval pattern display) but adds coupling. Defer to #51 — for now,matchedPatternandsourceare sufficient.