@gotgenes/pi-permission-system 8.1.0 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +1 -1
  3. package/src/handlers/gates/bash-external-directory.ts +2 -4
  4. package/src/handlers/gates/bash-path.ts +2 -4
  5. package/src/handlers/gates/descriptor.ts +6 -6
  6. package/src/handlers/gates/external-directory.ts +2 -4
  7. package/src/handlers/gates/helpers.ts +30 -1
  8. package/src/handlers/gates/path.ts +2 -4
  9. package/src/handlers/gates/runner.ts +29 -56
  10. package/src/handlers/gates/tool.ts +5 -4
  11. package/src/handlers/permission-gate-handler.ts +4 -3
  12. package/src/permission-manager.ts +6 -49
  13. package/src/permission-session.ts +3 -2
  14. package/src/scope-merge.ts +72 -0
  15. package/src/session-approval.ts +43 -0
  16. package/src/session-rules.ts +13 -0
  17. package/test/handlers/external-directory-integration.test.ts +1 -1
  18. package/test/handlers/external-directory-session-dedup.test.ts +15 -12
  19. package/test/handlers/gates/bash-external-directory.test.ts +11 -9
  20. package/test/handlers/gates/external-directory.test.ts +2 -5
  21. package/test/handlers/gates/helpers.test.ts +81 -0
  22. package/test/handlers/gates/path.test.ts +2 -2
  23. package/test/handlers/gates/runner.test.ts +18 -23
  24. package/test/handlers/gates/tool.test.ts +2 -2
  25. package/test/handlers/input-events.test.ts +1 -1
  26. package/test/handlers/input.test.ts +1 -1
  27. package/test/handlers/tool-call-events.test.ts +1 -1
  28. package/test/handlers/tool-call.test.ts +1 -1
  29. package/test/permission-session.test.ts +6 -3
  30. package/test/scope-merge.test.ts +116 -0
  31. package/test/session-approval.test.ts +75 -0
  32. package/test/session-rules.test.ts +49 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [8.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.1.0...pi-permission-system-v8.2.0) (2026-05-31)
9
+
10
+
11
+ ### Features
12
+
13
+ * add SessionApproval value object and SessionRules.record ([8f98d92](https://github.com/gotgenes/pi-packages/commit/8f98d9223a424b0993d51c2d9106e7d01c6819d7))
14
+ * centralize decision-event construction in buildDecisionEvent ([19c2c83](https://github.com/gotgenes/pi-packages/commit/19c2c837b1907a4c302105ee86715533477247d4))
15
+
8
16
  ## [8.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.0.0...pi-permission-system-v8.1.0) (2026-05-31)
9
17
 
10
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "8.1.0",
3
+ "version": "8.2.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,5 +1,6 @@
1
1
  import { getNonEmptyString, toRecord } from "#src/common";
2
2
  import type { Rule } from "#src/rule";
3
+ import { SessionApproval } from "#src/session-approval";
3
4
  import { deriveApprovalPattern } from "#src/session-rules";
4
5
  import type { PermissionCheckResult } from "#src/types";
5
6
  import { extractExternalPathsFromBashCommand } from "./bash-path-extractor";
@@ -106,10 +107,7 @@ export async function describeBashExternalDirectoryGate(
106
107
  cwd: tcc.cwd,
107
108
  agentName: tcc.agentName ?? undefined,
108
109
  },
109
- sessionApproval: {
110
- surface: "external_directory",
111
- patterns,
112
- },
110
+ sessionApproval: SessionApproval.multiple("external_directory", patterns),
113
111
  promptDetails: {
114
112
  source: "tool_call",
115
113
  agentName: tcc.agentName,
@@ -1,5 +1,6 @@
1
1
  import { getNonEmptyString, toRecord } from "#src/common";
2
2
  import type { Rule } from "#src/rule";
3
+ import { SessionApproval } from "#src/session-approval";
3
4
  import { deriveApprovalPattern } from "#src/session-rules";
4
5
  import type { PermissionCheckResult } from "#src/types";
5
6
  import { extractTokensForPathRules } from "./bash-path-extractor";
@@ -117,10 +118,7 @@ export async function describeBashPathGate(
117
118
  pathValue: worstToken,
118
119
  agentName: tcc.agentName ?? undefined,
119
120
  },
120
- sessionApproval: {
121
- surface: "path",
122
- pattern,
123
- },
121
+ sessionApproval: SessionApproval.single("path", pattern),
124
122
  promptDetails: {
125
123
  source: "tool_call",
126
124
  agentName: tcc.agentName,
@@ -3,6 +3,7 @@ import type { PermissionPromptDecision } from "#src/permission-dialog";
3
3
  import type { PermissionDecisionEvent } from "#src/permission-events";
4
4
  import type { PromptPermissionDetails } from "#src/permission-prompter";
5
5
  import type { Rule } from "#src/rule";
6
+ import type { SessionApproval } from "#src/session-approval";
6
7
  import type { PermissionCheckResult, PermissionState } from "#src/types";
7
8
 
8
9
  // ── Descriptor types ───────────────────────────────────────────────────────
@@ -22,12 +23,11 @@ export interface GateDescriptor {
22
23
  /** Structured denial context — the runner formats messages from this. */
23
24
  denialContext: DenialContext;
24
25
  /**
25
- * Session-approval suggestion for "for this session" option.
26
- * Single pattern or multiple patterns (bash external-directory gate).
26
+ * Session-approval suggestion for the "for this session" option.
27
+ * Wraps either a single pattern or multiple patterns behind a unified
28
+ * interface — the runner never needs to know which case applies.
27
29
  */
28
- sessionApproval?:
29
- | { surface: string; pattern: string }
30
- | { surface: string; patterns: string[] };
30
+ sessionApproval?: SessionApproval;
31
31
  /** Details passed to the interactive permission prompt (requestId is added by the runner). */
32
32
  promptDetails: Omit<PromptPermissionDetails, "requestId">;
33
33
  /** Extra context fields written to the review log alongside gate outcomes. */
@@ -87,7 +87,7 @@ export interface GateRunnerDeps {
87
87
  sessionRules?: Rule[],
88
88
  ): PermissionCheckResult;
89
89
  getSessionRuleset(): Rule[];
90
- approveSessionRule(surface: string, pattern: string): void;
90
+ recordSessionApproval(approval: SessionApproval): void;
91
91
  writeReviewLog(event: string, details: Record<string, unknown>): void;
92
92
  emitDecision(event: PermissionDecisionEvent): void;
93
93
  canConfirm(): boolean;
@@ -4,6 +4,7 @@ import {
4
4
  isPiInfrastructureRead,
5
5
  normalizePathForComparison,
6
6
  } from "#src/path-utils";
7
+ import { SessionApproval } from "#src/session-approval";
7
8
  import { deriveApprovalPattern } from "#src/session-rules";
8
9
  import type { GateResult } from "./descriptor";
9
10
  import { formatExternalDirectoryAskPrompt } from "./external-directory-messages";
@@ -83,10 +84,7 @@ export function describeExternalDirectoryGate(
83
84
  cwd: tcc.cwd,
84
85
  agentName: tcc.agentName ?? undefined,
85
86
  },
86
- sessionApproval: {
87
- surface: "external_directory",
88
- pattern,
89
- },
87
+ sessionApproval: SessionApproval.single("external_directory", pattern),
90
88
  promptDetails: {
91
89
  source: "tool_call",
92
90
  agentName: tcc.agentName,
@@ -1,4 +1,7 @@
1
- import type { PermissionDecisionResolution } from "#src/permission-events";
1
+ import type {
2
+ PermissionDecisionEvent,
3
+ PermissionDecisionResolution,
4
+ } from "#src/permission-events";
2
5
  import type { PermissionCheckResult } from "#src/types";
3
6
 
4
7
  /**
@@ -17,6 +20,32 @@ export function deriveDecisionValue(
17
20
  return toolName;
18
21
  }
19
22
 
23
+ /**
24
+ * Build a `PermissionDecisionEvent` from the gate's inputs.
25
+ *
26
+ * Centralises the `origin / agentName / matchedPattern ?? null` normalization
27
+ * that is otherwise duplicated across the session-hit path and the gate-result
28
+ * path in `runGateCheck`.
29
+ */
30
+ export function buildDecisionEvent(
31
+ decision: { surface: string; value: string },
32
+ check: Pick<PermissionCheckResult, "origin" | "matchedPattern">,
33
+ agentName: string | null,
34
+ result: "allow" | "deny",
35
+ resolution: PermissionDecisionResolution,
36
+ ): PermissionDecisionEvent {
37
+ return {
38
+ surface: decision.surface,
39
+ value: decision.value,
40
+ result,
41
+ resolution,
42
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ?? null normalises undefined to null for the log record
43
+ origin: check.origin ?? null,
44
+ agentName: agentName ?? null,
45
+ matchedPattern: check.matchedPattern ?? null,
46
+ };
47
+ }
48
+
20
49
  /**
21
50
  * Map the gate outcome back to a PermissionDecisionResolution.
22
51
  *
@@ -1,5 +1,6 @@
1
1
  import { getPathBearingToolPath } from "#src/path-utils";
2
2
  import type { Rule } from "#src/rule";
3
+ import { SessionApproval } from "#src/session-approval";
3
4
  import { deriveApprovalPattern } from "#src/session-rules";
4
5
  import type { PermissionCheckResult } from "#src/types";
5
6
  import type { GateDescriptor, GateResult } from "./descriptor";
@@ -55,10 +56,7 @@ export function describePathGate(
55
56
  pathValue: filePath,
56
57
  agentName: tcc.agentName ?? undefined,
57
58
  },
58
- sessionApproval: {
59
- surface: "path",
60
- pattern,
61
- },
59
+ sessionApproval: SessionApproval.single("path", pattern),
62
60
  promptDetails: {
63
61
  source: "tool_call",
64
62
  agentName: tcc.agentName,
@@ -7,7 +7,7 @@ import type { PermissionPromptDecision } from "#src/permission-dialog";
7
7
  import { applyPermissionGate } from "#src/permission-gate";
8
8
  import type { PermissionCheckResult } from "#src/types";
9
9
  import type { GateDescriptor, GateRunnerDeps } from "./descriptor";
10
- import { deriveResolution } from "./helpers";
10
+ import { buildDecisionEvent, deriveResolution } from "./helpers";
11
11
  import type { GateOutcome } from "./types";
12
12
 
13
13
  /**
@@ -56,37 +56,21 @@ export async function runGateCheck(
56
56
  resolution: "session_approved",
57
57
  sessionApprovalPattern: check.matchedPattern,
58
58
  });
59
- deps.emitDecision({
60
- surface: descriptor.decision.surface,
61
- value: descriptor.decision.value,
62
- result: "allow",
63
- resolution: "session_approved",
64
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ?? null normalises undefined to null for the log record
65
- origin: check.origin ?? null,
66
- agentName: agentName ?? null,
67
- matchedPattern: check.matchedPattern ?? null,
68
- });
59
+ deps.emitDecision(
60
+ buildDecisionEvent(
61
+ descriptor.decision,
62
+ check,
63
+ agentName,
64
+ "allow",
65
+ "session_approved",
66
+ ),
67
+ );
69
68
  return { action: "allow" };
70
69
  }
71
70
 
72
71
  // 3. Apply the deny/ask/allow gate
73
72
  const canConfirm = deps.canConfirm();
74
73
 
75
- // Resolve the first pattern for applyPermissionGate's sessionApproval param
76
- const singleSessionApproval = descriptor.sessionApproval
77
- ? "pattern" in descriptor.sessionApproval
78
- ? {
79
- surface: descriptor.sessionApproval.surface,
80
- pattern: descriptor.sessionApproval.pattern,
81
- }
82
- : descriptor.sessionApproval.patterns.length > 0
83
- ? {
84
- surface: descriptor.sessionApproval.surface,
85
- pattern: descriptor.sessionApproval.patterns[0],
86
- }
87
- : undefined
88
- : undefined;
89
-
90
74
  // Construct messages from the centralized formatter.
91
75
  const messages = {
92
76
  denyReason: formatDenyReason(descriptor.denialContext),
@@ -99,7 +83,7 @@ export async function runGateCheck(
99
83
  const gateResult = await applyPermissionGate({
100
84
  state: check.state,
101
85
  canConfirm,
102
- sessionApproval: singleSessionApproval,
86
+ sessionApproval: descriptor.sessionApproval?.toGateApproval(),
103
87
  promptForApproval: async () => {
104
88
  const decision = await deps.promptPermission({
105
89
  requestId: toolCallId,
@@ -119,37 +103,26 @@ export async function runGateCheck(
119
103
  gateResult.action === "allow" && gateResult.sessionApproval !== undefined;
120
104
 
121
105
  // 5. Emit decision event
122
- deps.emitDecision({
123
- surface: descriptor.decision.surface,
124
- value: descriptor.decision.value,
125
- result: gateResult.action === "allow" ? "allow" : "deny",
126
- resolution: deriveResolution(
127
- check.state,
128
- gateResult.action,
129
- hasSessionApproval,
130
- canConfirm,
131
- autoApproved,
106
+ deps.emitDecision(
107
+ buildDecisionEvent(
108
+ descriptor.decision,
109
+ check,
110
+ agentName,
111
+ gateResult.action === "allow" ? "allow" : "deny",
112
+ deriveResolution(
113
+ check.state,
114
+ gateResult.action,
115
+ hasSessionApproval,
116
+ canConfirm,
117
+ autoApproved,
118
+ ),
132
119
  ),
133
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ?? null normalises undefined to null for the log record
134
- origin: check.origin ?? null,
135
- agentName: agentName ?? null,
136
- matchedPattern: check.matchedPattern ?? null,
137
- });
120
+ );
138
121
 
139
- // 6. Record session approval(s)
140
- if (gateResult.action === "allow" && hasSessionApproval) {
141
- if (descriptor.sessionApproval) {
142
- if ("patterns" in descriptor.sessionApproval) {
143
- for (const pattern of descriptor.sessionApproval.patterns) {
144
- deps.approveSessionRule(descriptor.sessionApproval.surface, pattern);
145
- }
146
- } else {
147
- deps.approveSessionRule(
148
- descriptor.sessionApproval.surface,
149
- descriptor.sessionApproval.pattern,
150
- );
151
- }
152
- }
122
+ // 6. Record session approval — tell the store; it owns the per-pattern loop
123
+ // hasSessionApproval already implies gateResult.action === "allow"
124
+ if (hasSessionApproval && descriptor.sessionApproval) {
125
+ deps.recordSessionApproval(descriptor.sessionApproval);
153
126
  }
154
127
 
155
128
  if (gateResult.action === "block") {
@@ -1,6 +1,7 @@
1
1
  import { getPathBearingToolPath, PATH_BEARING_TOOLS } from "#src/path-utils";
2
2
  import { suggestSessionPattern } from "#src/pattern-suggest";
3
3
  import { formatAskPrompt } from "#src/permission-prompts";
4
+ import { SessionApproval } from "#src/session-approval";
4
5
  import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
5
6
  import type { PermissionCheckResult } from "#src/types";
6
7
  import type { GateDescriptor } from "./descriptor";
@@ -61,10 +62,10 @@ export function describeToolGate(
61
62
  agentName: tcc.agentName ?? undefined,
62
63
  input: tcc.input,
63
64
  },
64
- sessionApproval: {
65
- surface: suggestion.surface,
66
- pattern: suggestion.pattern,
67
- },
65
+ sessionApproval: SessionApproval.single(
66
+ suggestion.surface,
67
+ suggestion.pattern,
68
+ ),
68
69
  promptDetails: {
69
70
  source: "tool_call",
70
71
  agentName: tcc.agentName,
@@ -99,14 +99,15 @@ export class PermissionGateHandler {
99
99
  sessionRules,
100
100
  ) => this.session.checkPermission(surface, input, agent, sessionRules);
101
101
  const getSessionRuleset = () => this.session.getSessionRuleset();
102
- const approveSessionRule = (surface: string, pattern: string) =>
103
- this.session.approveSessionRule(surface, pattern);
102
+ const recordSessionApproval: GateRunnerDeps["recordSessionApproval"] = (
103
+ approval,
104
+ ) => this.session.recordSessionApproval(approval);
104
105
 
105
106
  // ── Shared runner deps (built once, reused for all gates) ────────────
106
107
  const runnerDeps: GateRunnerDeps = {
107
108
  checkPermission,
108
109
  getSessionRuleset,
109
- approveSessionRule,
110
+ recordSessionApproval,
110
111
  writeReviewLog,
111
112
  emitDecision,
112
113
  canConfirm,
@@ -1,7 +1,6 @@
1
1
  import { isPermissionState } from "./common";
2
2
  import { normalizeInput } from "./input-normalizer";
3
3
  import { normalizeFlatConfig } from "./normalize";
4
- import { mergeFlatPermissions } from "./permission-merge";
5
4
  import {
6
5
  FilePolicyLoader,
7
6
  type PolicyLoader,
@@ -10,6 +9,7 @@ import {
10
9
  } from "./policy-loader";
11
10
  import type { Rule, RuleOrigin, Ruleset } from "./rule";
12
11
  import { evaluate, evaluateFirst } from "./rule";
12
+ import { mergeScopesWithOrigins } from "./scope-merge";
13
13
  import {
14
14
  composeRuleset,
15
15
  synthesizeBaseline,
@@ -90,58 +90,15 @@ export class PermissionManager {
90
90
  const agentConfig = this.loader.loadAgentConfig(agentName);
91
91
  const projectAgentConfig = this.loader.loadProjectAgentConfig(agentName);
92
92
 
93
- // Merge permission objects across scopes (lowest → highest precedence).
94
- // Build a parallel origin map that tracks which scope contributed each
95
- // (surface, pattern) entry, mirroring mergeFlatPermissions() semantics.
96
- type OriginMap = Map<string, Map<string, RuleOrigin>>;
97
- const origins: OriginMap = new Map();
98
- let mergedPermission: FlatPermissionConfig = {};
99
-
100
- for (const [scopeName, scope] of [
93
+ // Merge permission objects across scopes (lowest → highest precedence),
94
+ // building a parallel origin map that tracks which scope contributed each
95
+ // (surface, pattern) entry.
96
+ const { mergedPermission, origins } = mergeScopesWithOrigins([
101
97
  ["global", globalConfig],
102
98
  ["project", projectConfig],
103
99
  ["agent", agentConfig],
104
100
  ["project-agent", projectAgentConfig],
105
- ] as const) {
106
- if (!scope.permission) continue;
107
-
108
- for (const [surface, value] of Object.entries(scope.permission)) {
109
- const baseVal = mergedPermission[surface];
110
- /* eslint-disable @typescript-eslint/no-unnecessary-condition -- defensive null/type checks; config values may differ at runtime */
111
- const bothObjects =
112
- typeof baseVal === "object" &&
113
- baseVal !== null &&
114
- typeof value === "object" &&
115
- value !== null;
116
- /* eslint-enable @typescript-eslint/no-unnecessary-condition */
117
-
118
- if (bothObjects) {
119
- // Shallow-merge: each incoming pattern is attributed to this scope;
120
- // existing patterns from lower scopes keep their earlier origin.
121
- if (!origins.has(surface)) origins.set(surface, new Map());
122
- for (const pattern of Object.keys(value)) {
123
- origins.get(surface)?.set(pattern, scopeName);
124
- }
125
- } else {
126
- // Full replacement: this scope takes over the entire surface entry.
127
- const surfaceOrigins = new Map<string, RuleOrigin>();
128
- if (typeof value === "string") {
129
- surfaceOrigins.set("*", scopeName);
130
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive null check
131
- } else if (typeof value === "object" && value !== null) {
132
- for (const pattern of Object.keys(value)) {
133
- surfaceOrigins.set(pattern, scopeName);
134
- }
135
- }
136
- origins.set(surface, surfaceOrigins);
137
- }
138
- }
139
-
140
- mergedPermission = mergeFlatPermissions(
141
- mergedPermission,
142
- scope.permission,
143
- );
144
- }
101
+ ]);
145
102
 
146
103
  // Extract the universal fallback from permission["*"].
147
104
  // The "*" key feeds synthesizeDefaults() only — it is NOT included as a
@@ -12,6 +12,7 @@ import type { PermissionManager } from "./permission-manager";
12
12
  import type { PromptPermissionDetails } from "./permission-prompter";
13
13
  import type { Rule } from "./rule";
14
14
  import { createPermissionManagerForCwd } from "./runtime";
15
+ import type { SessionApproval } from "./session-approval";
15
16
  import type { SessionLogger } from "./session-logger";
16
17
  import { SessionRules } from "./session-rules";
17
18
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
@@ -127,8 +128,8 @@ export class PermissionSession {
127
128
  return this.sessionRules.getRuleset();
128
129
  }
129
130
 
130
- approveSessionRule(surface: string, pattern: string): void {
131
- this.sessionRules.approve(surface, pattern);
131
+ recordSessionApproval(approval: SessionApproval): void {
132
+ this.sessionRules.record(approval);
132
133
  }
133
134
 
134
135
  // ── Session lifecycle ────────────────────────────────────────────────────
@@ -0,0 +1,72 @@
1
+ import { mergeFlatPermissions } from "#src/permission-merge";
2
+ import type { RuleOrigin } from "#src/rule";
3
+ import type { FlatPermissionConfig, ScopeConfig } from "#src/types";
4
+
5
+ /** Surface → (pattern → originating scope). */
6
+ type OriginMap = Map<string, Map<string, RuleOrigin>>;
7
+
8
+ /** Result of merging permission objects across scopes with provenance tracking. */
9
+ export interface MergedScopes {
10
+ /** Fully merged flat permission config (lowest → highest precedence). */
11
+ mergedPermission: FlatPermissionConfig;
12
+ /** Maps each surface to a per-pattern origin (which scope contributed it). */
13
+ origins: OriginMap;
14
+ }
15
+
16
+ /**
17
+ * Merge permission objects across scopes (lowest → highest precedence) while
18
+ * tracking which scope contributed each (surface, pattern) entry.
19
+ *
20
+ * Mirrors mergeFlatPermissions() semantics for origin attribution:
21
+ * - Both base and incoming are objects → shallow-merge: each incoming pattern
22
+ * is attributed to this scope; patterns the higher scope does not redefine
23
+ * keep their earlier origin.
24
+ * - Otherwise → full replacement: this scope takes over the entire surface
25
+ * entry, discarding all lower-scope attribution.
26
+ */
27
+ export function mergeScopesWithOrigins(
28
+ scopes: readonly (readonly [RuleOrigin, ScopeConfig])[],
29
+ ): MergedScopes {
30
+ const origins: OriginMap = new Map();
31
+ let mergedPermission: FlatPermissionConfig = {};
32
+
33
+ for (const [scopeName, scope] of scopes) {
34
+ if (!scope.permission) continue;
35
+
36
+ for (const [surface, value] of Object.entries(scope.permission)) {
37
+ const baseVal = mergedPermission[surface];
38
+ /* eslint-disable @typescript-eslint/no-unnecessary-condition -- defensive null/type checks; config values may differ at runtime */
39
+ const bothObjects =
40
+ typeof baseVal === "object" &&
41
+ baseVal !== null &&
42
+ typeof value === "object" &&
43
+ value !== null;
44
+ /* eslint-enable @typescript-eslint/no-unnecessary-condition */
45
+
46
+ if (bothObjects) {
47
+ // Shallow-merge: each incoming pattern is attributed to this scope;
48
+ // existing patterns from lower scopes keep their earlier origin.
49
+ if (!origins.has(surface)) origins.set(surface, new Map());
50
+ for (const pattern of Object.keys(value)) {
51
+ origins.get(surface)?.set(pattern, scopeName);
52
+ }
53
+ } else {
54
+ // Full replacement: this scope takes over the entire surface entry.
55
+ const surfaceOrigins = new Map<string, RuleOrigin>();
56
+ if (typeof value === "string") {
57
+ surfaceOrigins.set("*", scopeName);
58
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive null check
59
+ } else if (typeof value === "object" && value !== null) {
60
+ for (const pattern of Object.keys(value)) {
61
+ surfaceOrigins.set(pattern, scopeName);
62
+ }
63
+ }
64
+ origins.set(surface, surfaceOrigins);
65
+ }
66
+ }
67
+
68
+ mergedPermission = mergeFlatPermissions(mergedPermission, scope.permission);
69
+ }
70
+
71
+ return { mergedPermission, origins };
72
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Value object for a session-scoped approval: one surface, one-or-more patterns.
3
+ *
4
+ * Owned by gate descriptors and passed to the session store — the runner never
5
+ * needs to know whether there is one pattern or many.
6
+ */
7
+ export class SessionApproval {
8
+ private constructor(
9
+ readonly surface: string,
10
+ readonly patterns: readonly string[],
11
+ ) {}
12
+
13
+ /** Create an approval for a single pattern (the common case). */
14
+ static single(surface: string, pattern: string): SessionApproval {
15
+ return new SessionApproval(surface, [pattern]);
16
+ }
17
+
18
+ /**
19
+ * Create an approval for multiple patterns (e.g. bash external-directory
20
+ * gates that cover several uncovered paths in one prompt).
21
+ */
22
+ static multiple(
23
+ surface: string,
24
+ patterns: readonly string[],
25
+ ): SessionApproval {
26
+ return new SessionApproval(surface, [...patterns]);
27
+ }
28
+
29
+ /** Representative pattern for the interactive prompt — the first, if any. */
30
+ get representativePattern(): string | undefined {
31
+ return this.patterns[0];
32
+ }
33
+
34
+ /**
35
+ * Single-pattern shape `applyPermissionGate` echoes back to the caller.
36
+ * Returns `undefined` when patterns is empty (degenerate case).
37
+ */
38
+ toGateApproval(): { surface: string; pattern: string } | undefined {
39
+ const pattern = this.representativePattern;
40
+ if (pattern === undefined) return undefined;
41
+ return { surface: this.surface, pattern };
42
+ }
43
+ }
@@ -1,6 +1,7 @@
1
1
  import { dirname, sep } from "node:path";
2
2
 
3
3
  import type { Ruleset } from "./rule";
4
+ import type { SessionApproval } from "./session-approval";
4
5
 
5
6
  /**
6
7
  * Ephemeral in-memory store of session-scoped permission approvals.
@@ -29,6 +30,18 @@ export class SessionRules {
29
30
  return [...this.rules];
30
31
  }
31
32
 
33
+ /**
34
+ * Record all patterns from a `SessionApproval` value object.
35
+ *
36
+ * The loop lives here so callers never need to know whether an approval
37
+ * carries one pattern or many — they just tell the store to record it.
38
+ */
39
+ record(approval: SessionApproval): void {
40
+ for (const pattern of approval.patterns) {
41
+ this.approve(approval.surface, pattern);
42
+ }
43
+ }
44
+
32
45
  /** Remove all session approvals. */
33
46
  clear(): void {
34
47
  this.rules = [];
@@ -113,7 +113,7 @@ function makeSession(
113
113
  checkPermission: makeCheckPermission("deny"),
114
114
  getToolPermission: vi.fn().mockReturnValue("allow"),
115
115
  getSessionRuleset: vi.fn().mockReturnValue([]),
116
- approveSessionRule: vi.fn(),
116
+ recordSessionApproval: vi.fn(),
117
117
  getActiveSkillEntries: vi.fn().mockReturnValue([]),
118
118
  getInfrastructureDirs: vi.fn().mockReturnValue([]),
119
119
  getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
@@ -3,7 +3,7 @@
3
3
  * external path only prompt once — the session-approval recorded by the
4
4
  * first call covers the second.
5
5
  *
6
- * These tests use stateful mocks: `approveSessionRule` records rules,
6
+ * These tests use stateful mocks: `recordSessionApproval` records rules,
7
7
  * and `checkPermission` consults them via `getSessionRuleset`, mirroring
8
8
  * the real interaction between PermissionSession, SessionRules, and
9
9
  * PermissionManager.
@@ -15,6 +15,7 @@ import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
15
15
  import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
16
16
  import type { PermissionSession } from "#src/permission-session";
17
17
  import type { Rule } from "#src/rule";
18
+ import type { SessionApproval } from "#src/session-approval";
18
19
  import type { ToolRegistry } from "#src/tool-registry";
19
20
  import type { PermissionCheckResult } from "#src/types";
20
21
  import { wildcardMatch } from "#src/wildcard-matcher";
@@ -53,7 +54,7 @@ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
53
54
  * Build a PermissionSession mock with stateful session-rule tracking.
54
55
  *
55
56
  * `checkPermission` returns "ask" for `external_directory` unless a
56
- * matching session rule exists (via `approveSessionRule`), in which case
57
+ * matching session rule exists (via `recordSessionApproval`), in which case
57
58
  * it returns "allow" with `source: "session"`. All other surfaces return
58
59
  * "allow" by default.
59
60
  */
@@ -115,16 +116,18 @@ function makeStatefulSession(
115
116
  },
116
117
  );
117
118
 
118
- const approveSessionRule = vi
119
+ const recordSessionApproval = vi
119
120
  .fn()
120
- .mockImplementation((surface: string, pattern: string) => {
121
- sessionRules.push({
122
- surface,
123
- pattern,
124
- action: "allow",
125
- layer: "session",
126
- origin: "session",
127
- });
121
+ .mockImplementation((approval: SessionApproval) => {
122
+ for (const pattern of approval.patterns) {
123
+ sessionRules.push({
124
+ surface: approval.surface,
125
+ pattern,
126
+ action: "allow",
127
+ layer: "session",
128
+ origin: "session",
129
+ });
130
+ }
128
131
  });
129
132
 
130
133
  const getSessionRuleset = vi.fn().mockImplementation(() => [...sessionRules]);
@@ -136,7 +139,7 @@ function makeStatefulSession(
136
139
  checkPermission,
137
140
  getToolPermission: vi.fn().mockReturnValue("allow"),
138
141
  getSessionRuleset,
139
- approveSessionRule,
142
+ recordSessionApproval,
140
143
  getActiveSkillEntries: vi.fn().mockReturnValue([]),
141
144
  getInfrastructureDirs: vi.fn().mockReturnValue([]),
142
145
  getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
@@ -93,9 +93,8 @@ describe("describeBashExternalDirectoryGate", () => {
93
93
  expect(isGateDescriptor(result)).toBe(true);
94
94
  const desc = result as GateDescriptor;
95
95
  expect(desc.sessionApproval).toBeDefined();
96
- expect(desc.sessionApproval).toHaveProperty("patterns");
97
- const patterns = (desc.sessionApproval as { patterns: string[] }).patterns;
98
- expect(patterns.length).toBeGreaterThan(0);
96
+ if (!desc.sessionApproval) return;
97
+ expect(desc.sessionApproval.patterns.length).toBeGreaterThan(0);
99
98
  });
100
99
 
101
100
  it("returns GateBypass when all external paths are config-level allowed", async () => {
@@ -211,8 +210,9 @@ describe("describeBashExternalDirectoryGate", () => {
211
210
  );
212
211
  expect(isGateDescriptor(result)).toBe(true);
213
212
  const desc = result as GateDescriptor;
214
- const patterns = (desc.sessionApproval as { patterns: string[] }).patterns;
215
- expect(patterns.length).toBe(1);
213
+ expect(desc.sessionApproval).toBeDefined();
214
+ if (!desc.sessionApproval) return;
215
+ expect(desc.sessionApproval.patterns.length).toBe(1);
216
216
  expect(desc.preCheck?.state).toBe("ask");
217
217
  });
218
218
 
@@ -236,8 +236,9 @@ describe("describeBashExternalDirectoryGate", () => {
236
236
  const desc = result as GateDescriptor;
237
237
  expect(desc.preCheck?.state).toBe("deny");
238
238
  // Both paths are uncovered (neither is allow), so both patterns are included.
239
- const patterns = (desc.sessionApproval as { patterns: string[] }).patterns;
240
- expect(patterns.length).toBe(2);
239
+ expect(desc.sessionApproval).toBeDefined();
240
+ if (!desc.sessionApproval) return;
241
+ expect(desc.sessionApproval.patterns.length).toBe(2);
241
242
  });
242
243
 
243
244
  it("only includes uncovered paths when some are session-covered", async () => {
@@ -259,7 +260,8 @@ describe("describeBashExternalDirectoryGate", () => {
259
260
  expect(isGateDescriptor(result)).toBe(true);
260
261
  const desc = result as GateDescriptor;
261
262
  // Should have patterns only for the uncovered path
262
- const patterns = (desc.sessionApproval as { patterns: string[] }).patterns;
263
- expect(patterns.length).toBe(1);
263
+ expect(desc.sessionApproval).toBeDefined();
264
+ if (!desc.sessionApproval) return;
265
+ expect(desc.sessionApproval.patterns.length).toBe(1);
264
266
  });
265
267
  });
@@ -126,11 +126,8 @@ describe("describeExternalDirectoryGate", () => {
126
126
  ["/test/agent"],
127
127
  ) as GateDescriptor;
128
128
  expect(result.sessionApproval).toBeDefined();
129
- expect(result.sessionApproval).toHaveProperty(
130
- "surface",
131
- "external_directory",
132
- );
133
- expect(result.sessionApproval).toHaveProperty("pattern");
129
+ expect(result.sessionApproval?.surface).toBe("external_directory");
130
+ expect(result.sessionApproval?.representativePattern).toBeDefined();
134
131
  });
135
132
 
136
133
  it("denialContext contains the external path and cwd", () => {
@@ -1,9 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
3
  import {
4
+ buildDecisionEvent,
4
5
  deriveDecisionValue,
5
6
  deriveResolution,
6
7
  } from "#src/handlers/gates/helpers";
8
+ import type { PermissionCheckResult } from "#src/types";
7
9
 
8
10
  describe("deriveDecisionValue", () => {
9
11
  it("returns command for bash", () => {
@@ -82,3 +84,82 @@ describe("deriveResolution", () => {
82
84
  );
83
85
  });
84
86
  });
87
+
88
+ describe("buildDecisionEvent", () => {
89
+ function makeCheck(
90
+ overrides: Partial<PermissionCheckResult> = {},
91
+ ): PermissionCheckResult {
92
+ return {
93
+ state: "allow",
94
+ toolName: "read",
95
+ source: "tool",
96
+ origin: "builtin",
97
+ matchedPattern: "*",
98
+ ...overrides,
99
+ };
100
+ }
101
+
102
+ it("builds a decision event with all fields populated", () => {
103
+ const event = buildDecisionEvent(
104
+ { surface: "read", value: "read" },
105
+ makeCheck({ origin: "global", matchedPattern: "read" }),
106
+ "test-agent",
107
+ "allow",
108
+ "policy_allow",
109
+ );
110
+ expect(event).toEqual({
111
+ surface: "read",
112
+ value: "read",
113
+ result: "allow",
114
+ resolution: "policy_allow",
115
+ origin: "global",
116
+ agentName: "test-agent",
117
+ matchedPattern: "read",
118
+ });
119
+ });
120
+
121
+ it("normalises undefined origin to null", () => {
122
+ const event = buildDecisionEvent(
123
+ { surface: "bash", value: "git status" },
124
+ makeCheck({ origin: undefined }),
125
+ null,
126
+ "allow",
127
+ "user_approved",
128
+ );
129
+ expect(event.origin).toBeNull();
130
+ });
131
+
132
+ it("normalises null agentName to null", () => {
133
+ const event = buildDecisionEvent(
134
+ { surface: "read", value: "read" },
135
+ makeCheck(),
136
+ null,
137
+ "deny",
138
+ "policy_deny",
139
+ );
140
+ expect(event.agentName).toBeNull();
141
+ });
142
+
143
+ it("normalises undefined matchedPattern to null", () => {
144
+ const event = buildDecisionEvent(
145
+ { surface: "read", value: "read" },
146
+ makeCheck({ matchedPattern: undefined }),
147
+ null,
148
+ "deny",
149
+ "policy_deny",
150
+ );
151
+ expect(event.matchedPattern).toBeNull();
152
+ });
153
+
154
+ it("passes result and resolution through", () => {
155
+ const event = buildDecisionEvent(
156
+ { surface: "bash", value: "rm -rf /" },
157
+ makeCheck(),
158
+ null,
159
+ "deny",
160
+ "user_denied",
161
+ );
162
+ expect(event.result).toBe("deny");
163
+ expect(event.resolution).toBe("user_denied");
164
+ });
165
+ });
@@ -160,8 +160,8 @@ describe("describePathGate", () => {
160
160
  getSessionRuleset,
161
161
  ) as GateDescriptor;
162
162
  expect(result.sessionApproval).toBeDefined();
163
- expect(result.sessionApproval).toHaveProperty("surface", "path");
164
- expect(result.sessionApproval).toHaveProperty("pattern");
163
+ expect(result.sessionApproval?.surface).toBe("path");
164
+ expect(result.sessionApproval?.representativePattern).toBeDefined();
165
165
  });
166
166
 
167
167
  it("descriptor denialContext references the file path and tool name", () => {
@@ -7,6 +7,7 @@ import type {
7
7
  GateRunnerDeps,
8
8
  } from "#src/handlers/gates/descriptor";
9
9
  import { runGateCheck } from "#src/handlers/gates/runner";
10
+ import { SessionApproval } from "#src/session-approval";
10
11
  import type { PermissionCheckResult } from "#src/types";
11
12
 
12
13
  // ── helpers ────────────────────────────────────────────────────────────────
@@ -61,7 +62,7 @@ function makeRunnerDeps(
61
62
  return {
62
63
  checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
63
64
  getSessionRuleset: vi.fn().mockReturnValue([]),
64
- approveSessionRule: vi.fn(),
65
+ recordSessionApproval: vi.fn(),
65
66
  writeReviewLog: vi.fn(),
66
67
  emitDecision: vi.fn(),
67
68
  canConfirm: vi.fn().mockReturnValue(true),
@@ -167,7 +168,7 @@ describe("runGateCheck", () => {
167
168
  .mockResolvedValue({ approved: true, state: "approved_for_session" }),
168
169
  });
169
170
  const descriptor = makeDescriptor({
170
- sessionApproval: { surface: "read", pattern: "*" },
171
+ sessionApproval: SessionApproval.single("read", "*"),
171
172
  });
172
173
  const result = await runGateCheck(descriptor, null, "tc-1", deps);
173
174
  expect(result).toEqual({ action: "allow" });
@@ -176,33 +177,27 @@ describe("runGateCheck", () => {
176
177
  resolution: "user_approved_for_session",
177
178
  }),
178
179
  );
179
- expect(deps.approveSessionRule).toHaveBeenCalledWith("read", "*");
180
+ expect(deps.recordSessionApproval).toHaveBeenCalledWith(
181
+ SessionApproval.single("read", "*"),
182
+ );
180
183
  });
181
184
 
182
- it("calls approveSessionRule once per pattern when sessionApproval has multiple patterns", async () => {
185
+ it("calls recordSessionApproval once with the full SessionApproval when sessionApproval has multiple patterns", async () => {
183
186
  const deps = makeRunnerDeps({
184
187
  checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
185
188
  promptPermission: vi
186
189
  .fn()
187
190
  .mockResolvedValue({ approved: true, state: "approved_for_session" }),
188
191
  });
189
- const descriptor = makeDescriptor({
190
- sessionApproval: {
191
- surface: "external_directory",
192
- patterns: ["/outside/a/*", "/outside/b/*"],
193
- },
194
- });
195
- const result = await runGateCheck(descriptor, null, "tc-1", deps);
196
- expect(result).toEqual({ action: "allow" });
197
- expect(deps.approveSessionRule).toHaveBeenCalledTimes(2);
198
- expect(deps.approveSessionRule).toHaveBeenCalledWith(
199
- "external_directory",
192
+ const approval = SessionApproval.multiple("external_directory", [
200
193
  "/outside/a/*",
201
- );
202
- expect(deps.approveSessionRule).toHaveBeenCalledWith(
203
- "external_directory",
204
194
  "/outside/b/*",
205
- );
195
+ ]);
196
+ const descriptor = makeDescriptor({ sessionApproval: approval });
197
+ const result = await runGateCheck(descriptor, null, "tc-1", deps);
198
+ expect(result).toEqual({ action: "allow" });
199
+ expect(deps.recordSessionApproval).toHaveBeenCalledTimes(1);
200
+ expect(deps.recordSessionApproval).toHaveBeenCalledWith(approval);
206
201
  });
207
202
 
208
203
  it("returns block and emits user_denied when ask + user denies", async () => {
@@ -317,7 +312,7 @@ describe("runGateCheck", () => {
317
312
  );
318
313
  });
319
314
 
320
- it("does not call approveSessionRule when user approves once (no sessionApproval)", async () => {
315
+ it("does not call recordSessionApproval when user approves once (no sessionApproval)", async () => {
321
316
  const deps = makeRunnerDeps({
322
317
  checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
323
318
  promptPermission: vi
@@ -325,7 +320,7 @@ describe("runGateCheck", () => {
325
320
  .mockResolvedValue({ approved: true, state: "approved" }),
326
321
  });
327
322
  await runGateCheck(makeDescriptor(), null, "tc-1", deps);
328
- expect(deps.approveSessionRule).not.toHaveBeenCalled();
323
+ expect(deps.recordSessionApproval).not.toHaveBeenCalled();
329
324
  });
330
325
 
331
326
  it("uses preCheck result directly instead of calling checkPermission", async () => {
@@ -348,7 +343,7 @@ describe("runGateCheck", () => {
348
343
  );
349
344
  });
350
345
 
351
- it("does not call approveSessionRule when user approves for session but no sessionApproval on descriptor", async () => {
346
+ it("does not call recordSessionApproval when user approves for session but no sessionApproval on descriptor", async () => {
352
347
  const deps = makeRunnerDeps({
353
348
  checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
354
349
  promptPermission: vi
@@ -357,7 +352,7 @@ describe("runGateCheck", () => {
357
352
  });
358
353
  // No sessionApproval on descriptor
359
354
  await runGateCheck(makeDescriptor(), null, "tc-1", deps);
360
- expect(deps.approveSessionRule).not.toHaveBeenCalled();
355
+ expect(deps.recordSessionApproval).not.toHaveBeenCalled();
361
356
  });
362
357
 
363
358
  describe("denialContext formatting", () => {
@@ -142,8 +142,8 @@ describe("describeToolGate", () => {
142
142
  makeFormatter(),
143
143
  );
144
144
  expect(desc.sessionApproval).toBeDefined();
145
- expect(desc.sessionApproval!).toHaveProperty("surface", "bash");
146
- expect(desc.sessionApproval!).toHaveProperty("pattern");
145
+ expect(desc.sessionApproval?.surface).toBe("bash");
146
+ expect(desc.sessionApproval?.representativePattern).toBeDefined();
147
147
  });
148
148
 
149
149
  it("populates promptDetails with correct fields", () => {
@@ -55,7 +55,7 @@ function makeSession(
55
55
  }),
56
56
  getToolPermission: vi.fn().mockReturnValue("allow"),
57
57
  getSessionRuleset: vi.fn().mockReturnValue([]),
58
- approveSessionRule: vi.fn(),
58
+ recordSessionApproval: vi.fn(),
59
59
  canPrompt: vi.fn().mockReturnValue(true),
60
60
  prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
61
61
  createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
@@ -43,7 +43,7 @@ function makeSession(
43
43
  checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
44
44
  getToolPermission: vi.fn().mockReturnValue("allow"),
45
45
  getSessionRuleset: vi.fn().mockReturnValue([]),
46
- approveSessionRule: vi.fn(),
46
+ recordSessionApproval: vi.fn(),
47
47
  canPrompt: vi.fn().mockReturnValue(true),
48
48
  prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
49
49
  createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
@@ -77,7 +77,7 @@ function makeSession(
77
77
  checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
78
78
  getToolPermission: vi.fn().mockReturnValue("allow"),
79
79
  getSessionRuleset: vi.fn().mockReturnValue([]),
80
- approveSessionRule: vi.fn(),
80
+ recordSessionApproval: vi.fn(),
81
81
  getActiveSkillEntries: vi.fn().mockReturnValue([]),
82
82
  getInfrastructureDirs: vi
83
83
  .fn()
@@ -68,7 +68,7 @@ function makeSession(
68
68
  checkPermission: vi.fn().mockReturnValue(makePermissionResult("allow")),
69
69
  getToolPermission: vi.fn().mockReturnValue("allow"),
70
70
  getSessionRuleset: vi.fn().mockReturnValue([]),
71
- approveSessionRule: vi.fn(),
71
+ recordSessionApproval: vi.fn(),
72
72
  getActiveSkillEntries: vi.fn().mockReturnValue([]),
73
73
  getInfrastructureDirs: vi
74
74
  .fn()
@@ -36,6 +36,7 @@ import {
36
36
  PermissionSession,
37
37
  type PermissionSessionRuntimeDeps,
38
38
  } from "#src/permission-session";
39
+ import { SessionApproval } from "#src/session-approval";
39
40
  import type { SessionLogger } from "#src/session-logger";
40
41
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
41
42
 
@@ -219,9 +220,11 @@ describe("PermissionSession", () => {
219
220
  expect(rules).toEqual([]);
220
221
  });
221
222
 
222
- it("delegates approveSessionRule to internal SessionRules", () => {
223
+ it("delegates recordSessionApproval to internal SessionRules", () => {
223
224
  const { session } = createSession();
224
- session.approveSessionRule("bash", "/usr/bin/*");
225
+ session.recordSessionApproval(
226
+ SessionApproval.single("bash", "/usr/bin/*"),
227
+ );
225
228
  const rules = session.getSessionRuleset();
226
229
  expect(rules).toHaveLength(1);
227
230
  expect(rules[0]).toMatchObject({
@@ -325,7 +328,7 @@ describe("PermissionSession", () => {
325
328
  describe("shutdown", () => {
326
329
  it("clears session rules", () => {
327
330
  const { session } = createSession();
328
- session.approveSessionRule("bash", "*");
331
+ session.recordSessionApproval(SessionApproval.single("bash", "*"));
329
332
  expect(session.getSessionRuleset()).toHaveLength(1);
330
333
 
331
334
  session.shutdown();
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { MergedScopes } from "#src/scope-merge";
3
+ import { mergeScopesWithOrigins } from "#src/scope-merge";
4
+
5
+ describe("mergeScopesWithOrigins", () => {
6
+ it("returns empty result for empty scopes array", () => {
7
+ const result: MergedScopes = mergeScopesWithOrigins([]);
8
+ expect(result.mergedPermission).toEqual({});
9
+ expect(result.origins.size).toBe(0);
10
+ });
11
+
12
+ it("attributes a string surface value to the contributing scope via the '*' pattern", () => {
13
+ const result = mergeScopesWithOrigins([
14
+ ["global", { permission: { bash: "allow" } }],
15
+ ]);
16
+ expect(result.mergedPermission).toEqual({ bash: "allow" });
17
+ expect(result.origins.get("bash")?.get("*")).toBe("global");
18
+ });
19
+
20
+ it("attributes each pattern of an object surface value to the contributing scope", () => {
21
+ const result = mergeScopesWithOrigins([
22
+ [
23
+ "project",
24
+ { permission: { bash: { "git *": "allow", "npm *": "deny" } } },
25
+ ],
26
+ ]);
27
+ expect(result.mergedPermission).toEqual({
28
+ bash: { "git *": "allow", "npm *": "deny" },
29
+ });
30
+ expect(result.origins.get("bash")?.get("git *")).toBe("project");
31
+ expect(result.origins.get("bash")?.get("npm *")).toBe("project");
32
+ });
33
+
34
+ it(
35
+ "shallow-merge: patterns not redefined by the higher scope keep their lower-scope origin;" +
36
+ " patterns the higher scope defines switch to the higher scope",
37
+ () => {
38
+ const result = mergeScopesWithOrigins([
39
+ [
40
+ "global",
41
+ { permission: { bash: { "ls *": "allow", "git *": "allow" } } },
42
+ ],
43
+ ["project", { permission: { bash: { "git *": "deny" } } }],
44
+ ]);
45
+ expect(result.mergedPermission).toEqual({
46
+ bash: { "ls *": "allow", "git *": "deny" },
47
+ });
48
+ // "ls *" was not touched by project — retains global attribution
49
+ expect(result.origins.get("bash")?.get("ls *")).toBe("global");
50
+ // "git *" was overridden by project — switches to project attribution
51
+ expect(result.origins.get("bash")?.get("git *")).toBe("project");
52
+ },
53
+ );
54
+
55
+ it("full replacement (string over object): higher scope re-attributes the entire surface to its own origin", () => {
56
+ const result = mergeScopesWithOrigins([
57
+ ["global", { permission: { bash: { "ls *": "allow" } } }],
58
+ ["project", { permission: { bash: "deny" } }],
59
+ ]);
60
+ expect(result.mergedPermission).toEqual({ bash: "deny" });
61
+ // The string value produces a single "*" pattern for the replacing scope
62
+ expect(result.origins.get("bash")?.get("*")).toBe("project");
63
+ // The former "ls *" pattern from global is gone — origins are replaced, not merged
64
+ expect(result.origins.get("bash")?.has("ls *")).toBe(false);
65
+ });
66
+
67
+ it("full replacement (object over string): higher scope re-attributes the entire surface to its own origin", () => {
68
+ const result = mergeScopesWithOrigins([
69
+ ["global", { permission: { bash: "ask" } }],
70
+ ["project", { permission: { bash: { "git *": "deny" } } }],
71
+ ]);
72
+ expect(result.mergedPermission).toEqual({ bash: { "git *": "deny" } });
73
+ // The object value attributes each pattern to the replacing scope
74
+ expect(result.origins.get("bash")?.get("git *")).toBe("project");
75
+ // The former "*" attribution from global is gone
76
+ expect(result.origins.get("bash")?.has("*")).toBe(false);
77
+ });
78
+
79
+ it("applies four-scope precedence in lowest→highest order (global → project → agent → project-agent)", () => {
80
+ const result = mergeScopesWithOrigins([
81
+ ["global", { permission: { read: "ask" } }],
82
+ ["project", { permission: { write: "deny" } }],
83
+ ["agent", { permission: { bash: "deny" } }],
84
+ ["project-agent", { permission: { mcp: "allow" } }],
85
+ ]);
86
+ expect(result.mergedPermission).toEqual({
87
+ read: "ask",
88
+ write: "deny",
89
+ bash: "deny",
90
+ mcp: "allow",
91
+ });
92
+ expect(result.origins.get("read")?.get("*")).toBe("global");
93
+ expect(result.origins.get("write")?.get("*")).toBe("project");
94
+ expect(result.origins.get("bash")?.get("*")).toBe("agent");
95
+ expect(result.origins.get("mcp")?.get("*")).toBe("project-agent");
96
+ });
97
+
98
+ it("skips scopes with no permission key, contributing nothing to either map", () => {
99
+ const result = mergeScopesWithOrigins([
100
+ ["global", {}],
101
+ ["project", { permission: { bash: "allow" } }],
102
+ ]);
103
+ expect(result.mergedPermission).toEqual({ bash: "allow" });
104
+ expect(result.origins.get("bash")?.get("*")).toBe("project");
105
+ });
106
+
107
+ it("attributes the universal '*' surface like any other (downstream reads origins.get('*')?.get('*') for universalFallbackOrigin)", () => {
108
+ const result = mergeScopesWithOrigins([
109
+ ["global", { permission: { "*": "deny" } }],
110
+ ["project", { permission: { "*": "allow" } }],
111
+ ]);
112
+ expect(result.mergedPermission).toEqual({ "*": "allow" });
113
+ // Both scopes write a string — each is a full replacement; project wins last
114
+ expect(result.origins.get("*")?.get("*")).toBe("project");
115
+ });
116
+ });
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { SessionApproval } from "#src/session-approval";
4
+
5
+ describe("SessionApproval", () => {
6
+ describe("single", () => {
7
+ it("stores surface and one pattern", () => {
8
+ const approval = SessionApproval.single("bash", "git *");
9
+ expect(approval.surface).toBe("bash");
10
+ expect(approval.patterns).toEqual(["git *"]);
11
+ });
12
+
13
+ it("representativePattern returns the pattern", () => {
14
+ const approval = SessionApproval.single("bash", "git *");
15
+ expect(approval.representativePattern).toBe("git *");
16
+ });
17
+
18
+ it("toGateApproval returns { surface, pattern }", () => {
19
+ const approval = SessionApproval.single("bash", "git *");
20
+ expect(approval.toGateApproval()).toEqual({
21
+ surface: "bash",
22
+ pattern: "git *",
23
+ });
24
+ });
25
+ });
26
+
27
+ describe("multiple", () => {
28
+ it("stores surface and all patterns", () => {
29
+ const approval = SessionApproval.multiple("external_directory", [
30
+ "/outside/a/*",
31
+ "/outside/b/*",
32
+ ]);
33
+ expect(approval.surface).toBe("external_directory");
34
+ expect(approval.patterns).toEqual(["/outside/a/*", "/outside/b/*"]);
35
+ });
36
+
37
+ it("representativePattern returns the first pattern", () => {
38
+ const approval = SessionApproval.multiple("external_directory", [
39
+ "/outside/a/*",
40
+ "/outside/b/*",
41
+ ]);
42
+ expect(approval.representativePattern).toBe("/outside/a/*");
43
+ });
44
+
45
+ it("toGateApproval returns { surface, pattern } using the first pattern", () => {
46
+ const approval = SessionApproval.multiple("external_directory", [
47
+ "/outside/a/*",
48
+ "/outside/b/*",
49
+ ]);
50
+ expect(approval.toGateApproval()).toEqual({
51
+ surface: "external_directory",
52
+ pattern: "/outside/a/*",
53
+ });
54
+ });
55
+
56
+ it("defensive copy — mutating the source array does not affect patterns", () => {
57
+ const source = ["/outside/a/*", "/outside/b/*"];
58
+ const approval = SessionApproval.multiple("external_directory", source);
59
+ source.push("/outside/c/*");
60
+ expect(approval.patterns).toEqual(["/outside/a/*", "/outside/b/*"]);
61
+ });
62
+ });
63
+
64
+ describe("empty patterns (degenerate case)", () => {
65
+ it("representativePattern returns undefined", () => {
66
+ const approval = SessionApproval.multiple("external_directory", []);
67
+ expect(approval.representativePattern).toBeUndefined();
68
+ });
69
+
70
+ it("toGateApproval returns undefined", () => {
71
+ const approval = SessionApproval.multiple("external_directory", []);
72
+ expect(approval.toGateApproval()).toBeUndefined();
73
+ });
74
+ });
75
+ });
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
3
  import { evaluate } from "#src/rule";
4
+ import { SessionApproval } from "#src/session-approval";
4
5
  import { deriveApprovalPattern, SessionRules } from "#src/session-rules";
5
6
 
6
7
  // ── SessionRules ───────────────────────────────────────────────────────────
@@ -66,6 +67,54 @@ describe("SessionRules", () => {
66
67
  });
67
68
  });
68
69
 
70
+ describe("record", () => {
71
+ it("records a single-pattern approval as one rule", () => {
72
+ const rules = new SessionRules();
73
+ rules.record(SessionApproval.single("bash", "git *"));
74
+ expect(rules.getRuleset()).toEqual([
75
+ {
76
+ surface: "bash",
77
+ pattern: "git *",
78
+ action: "allow",
79
+ layer: "session",
80
+ origin: "session",
81
+ },
82
+ ]);
83
+ });
84
+
85
+ it("records a multi-pattern approval as one rule per pattern", () => {
86
+ const rules = new SessionRules();
87
+ rules.record(
88
+ SessionApproval.multiple("external_directory", [
89
+ "/outside/a/*",
90
+ "/outside/b/*",
91
+ ]),
92
+ );
93
+ expect(rules.getRuleset()).toHaveLength(2);
94
+ expect(rules.getRuleset()[0].pattern).toBe("/outside/a/*");
95
+ expect(rules.getRuleset()[1].pattern).toBe("/outside/b/*");
96
+ });
97
+
98
+ it("records each rule with the correct surface", () => {
99
+ const rules = new SessionRules();
100
+ rules.record(
101
+ SessionApproval.multiple("external_directory", [
102
+ "/outside/a/*",
103
+ "/outside/b/*",
104
+ ]),
105
+ );
106
+ for (const rule of rules.getRuleset()) {
107
+ expect(rule.surface).toBe("external_directory");
108
+ }
109
+ });
110
+
111
+ it("records nothing for an empty patterns list", () => {
112
+ const rules = new SessionRules();
113
+ rules.record(SessionApproval.multiple("external_directory", []));
114
+ expect(rules.getRuleset()).toEqual([]);
115
+ });
116
+ });
117
+
69
118
  describe("evaluate() integration", () => {
70
119
  it("returns allow for a path under an approved directory", () => {
71
120
  const session = new SessionRules();