17 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 88 | Track and report provenance of each permission rule |
Plan: Track and report provenance of each permission rule
Problem Statement
When a permission decision (allow/deny/ask) is made, there is no way to determine which config source contributed the winning rule.
All four config sources — global, project, agent frontmatter, and project-agent frontmatter — are merged into a single FlatPermissionConfig before rules are created, so every config rule receives the generic layer: "config" label.
Debugging "why is tool X denied?"
requires manually inspecting up to four config locations.
The review log records the decision but not its source, and /permission-system cannot show where each effective rule came from.
Additionally, the "override" value in the Rule.layer type union is dead code — introduced in #65, the layer value was removed in #66 but left in the type.
The deriveSource() function still has a branch for layer === "override" that is unreachable.
Goals
- Add an optional
originfield toRulethat records which config scope contributed the rule. - Tag config rules with their origin during the merge loop in
resolvePermissions(). - Propagate origin into
PermissionCheckResultso callers can report it. - Include origin in review log entries for permission decisions.
- Include origin in
/permission-system showoutput when displaying effective policy. - Remove the dead
"override"value from theRule.layertype union and its unreachablederiveSource()branch. - Add tests for provenance correctness across merge-precedence scenarios.
Non-Goals
- Changing the merge-precedence semantics (global → project → agent → project-agent). Origin tracking is read-only metadata; it must not alter any permission decision.
- Displaying origin in the interactive permission prompt dialog (deferred — see issue § "Permission dialog").
- Tracking origin for synthesized defaults (
layer: "default") or baseline rules (layer: "baseline"). Onlylayer: "config"rules carry origin. - Adding origin to session rules (
layer: "session"). Session rules are runtime-only and always come from the current session.
Background
Relevant modules
| File | Role |
|---|---|
src/rule.ts |
Rule interface, Ruleset, evaluate(), evaluateFirst() |
src/types.ts |
PermissionCheckResult, FlatPermissionConfig, ScopeConfig |
src/normalize.ts |
normalizeFlatConfig() — converts flat config to Ruleset |
src/synthesize.ts |
synthesizeDefaults(), synthesizeBaseline(), composeRuleset() |
src/permission-manager.ts |
PermissionManager — resolvePermissions(), checkPermission(), deriveSource() |
src/permission-prompter.ts |
PermissionPrompter — writes review log entries with permission details |
src/logging.ts |
createPermissionSystemLogger() — writes review and debug log lines |
src/config-modal.ts |
/permission-system slash command handler |
docs/architecture/target-architecture.md |
Living architecture doc with Rule type definition |
Permission surfaces involved
All surfaces (tools, bash, mcp, skills, special/external_directory).
Origin is surface-agnostic metadata on Rule.
Current merge flow
resolvePermissions() merges four FlatPermissionConfig objects using mergeFlatPermissions() (deep-shallow merge), normalizes the merged result into rules via normalizeFlatConfig(), and stamps every rule with layer: "config".
The origin of each rule is lost at the mergeFlatPermissions() step.
Design Overview
New RuleOrigin type and Rule.origin field
/** Which config scope contributed a rule. Only set for layer="config". */
export type RuleOrigin = "global" | "project" | "agent" | "project-agent";
export interface Rule {
surface: string;
pattern: string;
action: PermissionState;
layer?: "default" | "baseline" | "config" | "session"; // "override" removed
origin?: RuleOrigin;
}
Origin tracking strategy
The current resolvePermissions() loop merges flat configs then normalizes.
Changing this to per-scope normalization + concatenation would subtly alter the deep-shallow merge semantics (e.g., when a higher-precedence scope replaces a lower scope's object entry with a string, the lower scope's pattern rules would incorrectly survive as last-match-wins candidates).
Instead, build a parallel origin map alongside the existing merge loop — it mirrors mergeFlatPermissions() semantics exactly without changing any permission decision:
type OriginMap = Map<string, Map<string, RuleOrigin>>;
const origins: OriginMap = new Map();
for (const [scopeName, scope] of [
["global", globalConfig],
["project", projectConfig],
["agent", agentConfig],
["project-agent", projectAgentConfig],
] as const) {
if (!scope.permission) continue;
for (const [surface, value] of Object.entries(scope.permission)) {
const baseVal = mergedPermission[surface];
const bothObjects =
typeof baseVal === "object" && baseVal !== null &&
typeof value === "object" && value !== null;
if (bothObjects) {
// Shallow merge: new patterns attributed to this scope,
// existing patterns keep their earlier origin.
if (!origins.has(surface)) origins.set(surface, new Map());
for (const pattern of Object.keys(value as Record<string, unknown>)) {
origins.get(surface)!.set(pattern, scopeName);
}
} else {
// Full replacement: reset all origins for this surface.
const surfaceOrigins = new Map<string, RuleOrigin>();
if (typeof value === "string") {
surfaceOrigins.set("*", scopeName);
} else if (typeof value === "object" && value !== null) {
for (const pattern of Object.keys(value as Record<string, unknown>)) {
surfaceOrigins.set(pattern, scopeName);
}
}
origins.set(surface, surfaceOrigins);
}
}
// Existing merge (unchanged)
mergedPermission = mergeFlatPermissions(mergedPermission, scope.permission);
}
After normalization, stamp each config rule:
const configRules: Ruleset = normalizeFlatConfig(permissionWithoutUniversal)
.map((r): Rule => ({
...r,
layer: "config",
origin: origins.get(r.surface)?.get(r.pattern),
}));
The universal fallback permission["*"] also needs origin tracking.
Track it separately:
const universalFallbackOrigin: RuleOrigin | undefined =
origins.get("*")?.get("*");
Pass it to synthesizeDefaults() so the synthesized default rule can optionally carry an origin when it came from a user config (not the built-in fallback).
PermissionCheckResult.origin
Add an optional origin field to PermissionCheckResult:
export interface PermissionCheckResult {
toolName: string;
state: PermissionState;
matchedPattern?: string;
command?: string;
target?: string;
source: "tool" | "bash" | "mcp" | "skill" | "special" | "default" | "session";
/** Which config scope contributed the winning rule (only for config rules). */
origin?: RuleOrigin;
}
In checkPermission(), propagate rule.origin into the result.
Review log entries
The review log entries already include fields from PermissionCheckResult (via getPermissionLogContext() and PermissionPrompter.writeReviewEntry()).
Add origin to the structured log details wherever a check result is logged.
This requires changes to:
src/tool-input-preview.ts—getPermissionLogContext()includesoriginfrom the check result.src/permission-gate.ts—logContexttype gains an optionaloriginfield.src/handlers/tool-call.ts— passoriginthroughlogContextin the normal tool permission gate.
/permission-system show output
Extend the show subcommand to display the composed config-layer rules with their origins.
Example output:
permission-system: yoloMode=off, permissionReviewLog=on, debugLog=off
rules: read=allow (global), bash["*"]=allow (global), bash["rm *"]=deny (project), mcp["exa:*"]=allow (agent)
This requires config-modal.ts to accept a function that returns the composed ruleset (or a formatted summary) from the PermissionManager.
Remove dead "override" layer
- Remove
"override"from theRule.layerunion insrc/rule.ts. - Remove the
if (rule.layer === "override") return "tool";branch fromderiveSource()insrc/permission-manager.ts. - Update the
Ruletype indocs/architecture/target-architecture.md.
Module-Level Changes
src/rule.ts
- Add
export type RuleOrigin = "global" | "project" | "agent" | "project-agent";. - Add
origin?: RuleOriginto theRuleinterface. - Remove
"override"from thelayerunion.
src/types.ts
- Re-export
RuleOrigin(or import it) and addorigin?: RuleOrigintoPermissionCheckResult.
src/normalize.ts
- No functional changes.
normalizeFlatConfig()returns rules withoutlayerororigin; callers stamp them.
src/synthesize.ts
synthesizeDefaults()gains an optionaloriginparameter so the universal default rule can carry an origin when it was set by a user config rather than the built-in fallback.
src/permission-manager.ts
resolvePermissions(): build anOriginMapalongside the merge loop; stamp config rules withoriginafter normalization. PassuniversalFallbackOrigintosynthesizeDefaults().checkPermission(): includerule.originin the returnedPermissionCheckResult.deriveSource(): remove theif (rule.layer === "override")branch and its JSDoc entry.getToolPermission(): no change — it returnsPermissionState, notPermissionCheckResult.
src/tool-input-preview.ts
getPermissionLogContext(): includeoriginfrom the check result in the returned log details.
src/config-modal.ts
PermissionSystemConfigController: add an optionalgetComposedRules?: () => Rulesetmethod.summarizeConfig(): append a compact rules-with-origin summary when composed rules are available.handleArgs()showbranch: pass composed rules into the summary.
src/index.ts
- Pass a
getComposedRulescallback toregisterPermissionSystemCommandthat callsruntime.permissionManager.
src/handlers/tool-call.ts
- No structural changes.
logContextobjects already spreadgetPermissionLogContext(check, ...), which will now includeorigin.
docs/architecture/target-architecture.md
- Update
Ruletype definition: remove"override"fromlayer, addorigin?: RuleOrigin. - Add a note in the "Composed Ruleset" diagram about origin metadata on config rules.
tests/
tests/rule.test.ts— verifyevaluate()preservesoriginon matched rules.tests/permission-manager-unified.test.ts— add provenance tests covering:- Single-scope origin attribution.
- Multi-scope deep-shallow merge (both-object): each pattern's origin is correct.
- Replacement semantics (string replaces object, object replaces string): origins reset.
- Universal fallback origin.
PermissionCheckResult.originpropagation.
tests/synthesize.test.ts— test thatsynthesizeDefaults()passes through an origin when provided.tests/config-modal.test.ts— test thatshowincludes origin annotations when composed rules are available.
TDD Order
1. Remove dead "override" layer value
- Test surface:
tests/rule.test.ts,tests/permission-manager-unified.test.ts(type-check viapnpm run build). - What's covered:
"override"removed fromRule.layerunion;deriveSource()branch removed; architecture doc updated. Existing tests that referencelayercontinue to pass since no test used"override". - Commit:
refactor: remove dead "override" layer value from Rule type
2. Add RuleOrigin type and origin field to Rule
- Test surface:
tests/rule.test.ts. - What's covered:
RuleOrigintype exported;Rule.originaccepted byevaluate();evaluate()preservesoriginon matched rules (new test cases). - Commit:
feat: add RuleOrigin type and origin field to Rule
3. Tag config rules with origin during resolvePermissions()
- Test surface:
tests/permission-manager-unified.test.ts. - What's covered: new
describeblock for provenance — single-scope, multi-scope merge, replacement semantics, universal fallback origin. Tests callcheckPermission()and assertresult.originvalues. Requires addingorigintoPermissionCheckResultsocheckPermission()can return it. - Commit:
feat: track and propagate rule origin through checkPermission
4. Propagate origin to synthesizeDefaults()
- Test surface:
tests/synthesize.test.ts,tests/permission-manager-unified.test.ts. - What's covered:
synthesizeDefaults(universalDefault, origin?)passesoriginto the default rule;checkPermission()returns origin when the universal fallback was set by a user config. - Commit:
feat: propagate origin to synthesized default rule
5. Include origin in review log entries
- Test surface:
tests/handlers/tool-call.test.ts(or a newtests/tool-input-preview.test.tsif not yet covered). - What's covered:
getPermissionLogContext()includesoriginfrom the check result; review log entries contain the winning rule's origin. - Commit:
feat: include rule origin in permission review log entries
6. Display origin in /permission-system show output
- Test surface:
tests/config-modal.test.ts. - What's covered:
showsubcommand output includes per-rule origin annotations when composed rules are available; omits them when not. - Commit:
feat: display rule origins in /permission-system show output
7. Update architecture doc
- Test surface: manual review only.
- What's covered:
docs/architecture/target-architecture.mdupdated withRuleOrigin,originfield, and removal of"override". - Commit:
docs: update target architecture for rule origin provenance
Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Origin tracking diverges from merge semantics, causing incorrect attribution. | The origin map mirrors mergeFlatPermissions() case-by-case (both-objects, string-replaces, object-replaces). Unit tests cover all three merge modes. |
Adding origin to Rule or PermissionCheckResult breaks deep-equality assertions in existing tests. |
origin is optional and only set for config rules. Existing tests that use makeManager() with no config will see rules without origin, preserving deep equality. Tests with config fixtures may need origin in expected values — addressed in step 3. |
| Could this silently weaken a permission? | No. origin is read-only metadata. It is not consumed by evaluate(), does not appear in any guard condition, and does not alter any allow/deny/ask decision. The evaluate() function's behavior is unchanged. |
Removing "override" layer breaks a runtime path. |
No code path creates a rule with layer: "override". The deriveSource() branch is unreachable. Removing both is safe. pnpm run build confirms no type errors. |
/permission-system show output becomes noisy with many rules. |
Keep the display compact (one line per rule, abbreviated origin labels). If the rule count exceeds a threshold, truncate with a count summary. |
Open Questions
- Should the
originfield also appear on synthesized default and baseline rules (e.g.,origin: "builtin")? The issue scopes it tolayer: "config"only; this could be revisited if debugging of defaults becomes a pain point. - Should origin be exposed in the interactive permission dialog prompt (e.g., "This permission comes from your project config")? The issue mentions this as a future benefit but does not include it in scope. Deferred.