@gotgenes/pi-permission-system 8.1.0 → 8.2.1

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 (44) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +1 -1
  3. package/src/config-loader.ts +53 -46
  4. package/src/handlers/gates/bash-external-directory.ts +2 -4
  5. package/src/handlers/gates/bash-path-extractor.ts +135 -169
  6. package/src/handlers/gates/bash-path.ts +2 -4
  7. package/src/handlers/gates/bash-token-classification.ts +105 -0
  8. package/src/handlers/gates/descriptor.ts +6 -6
  9. package/src/handlers/gates/external-directory.ts +2 -4
  10. package/src/handlers/gates/helpers.ts +30 -1
  11. package/src/handlers/gates/path.ts +2 -4
  12. package/src/handlers/gates/runner.ts +29 -56
  13. package/src/handlers/gates/tool.ts +5 -4
  14. package/src/handlers/permission-gate-handler.ts +4 -3
  15. package/src/permission-manager.ts +6 -49
  16. package/src/permission-session.ts +3 -2
  17. package/src/scope-merge.ts +72 -0
  18. package/src/session-approval.ts +43 -0
  19. package/src/session-rules.ts +13 -0
  20. package/test/config-loader.test.ts +82 -0
  21. package/test/handlers/before-agent-start.test.ts +2 -20
  22. package/test/handlers/external-directory-integration.test.ts +44 -82
  23. package/test/handlers/external-directory-session-dedup.test.ts +17 -41
  24. package/test/handlers/gates/bash-external-directory.test.ts +11 -9
  25. package/test/handlers/gates/bash-path.test.ts +5 -26
  26. package/test/handlers/gates/bash-token-classification.test.ts +241 -0
  27. package/test/handlers/gates/external-directory.test.ts +2 -5
  28. package/test/handlers/gates/helpers.test.ts +81 -0
  29. package/test/handlers/gates/path.test.ts +5 -14
  30. package/test/handlers/gates/runner.test.ts +95 -113
  31. package/test/handlers/gates/tool.test.ts +2 -2
  32. package/test/handlers/input-events.test.ts +42 -95
  33. package/test/handlers/input.test.ts +3 -71
  34. package/test/handlers/lifecycle.test.ts +3 -20
  35. package/test/handlers/tool-call-events.test.ts +30 -127
  36. package/test/handlers/tool-call.test.ts +21 -110
  37. package/test/helpers/gate-fixtures.ts +105 -0
  38. package/test/helpers/handler-fixtures.ts +141 -0
  39. package/test/helpers/manager-harness.ts +51 -0
  40. package/test/permission-session.test.ts +7 -22
  41. package/test/permission-system.test.ts +4 -40
  42. package/test/scope-merge.test.ts +116 -0
  43. package/test/session-approval.test.ts +75 -0
  44. package/test/session-rules.test.ts +49 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Pure, synchronous token-classification helpers for bash path extraction.
3
+ *
4
+ * Exports two classifiers consumed by `bash-path-extractor.ts`:
5
+ * - `classifyTokenAsPathCandidate` — strict gate for the external-directory guard.
6
+ * - `classifyTokenAsRuleCandidate` — broader gate for cross-cutting `path` rules.
7
+ *
8
+ * Both classifiers share the private `rejectNonPathToken` predicate that captures
9
+ * the seven rejection cases common to both (the production clone this module was
10
+ * extracted to eliminate).
11
+ */
12
+
13
+ // ── Public classifiers ─────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Strict path-candidate classifier for the external-directory guard.
17
+ *
18
+ * Accepts tokens that unambiguously look like filesystem paths:
19
+ * - Absolute paths (starting with `/`)
20
+ * - Home-relative paths (starting with `~/`)
21
+ * - Parent-traversal paths (containing `..`)
22
+ *
23
+ * Returns the raw token string if it qualifies, or `null` to skip.
24
+ */
25
+ export function classifyTokenAsPathCandidate(token: string): string | null {
26
+ if (rejectNonPathToken(token)) return null;
27
+
28
+ if (token.startsWith("/")) return token;
29
+ if (token.startsWith("~/")) return token;
30
+ if (token.includes("..")) return token;
31
+
32
+ return null;
33
+ }
34
+
35
+ /**
36
+ * Broader token classifier for cross-cutting `path` permission rules.
37
+ *
38
+ * Accepts the same shapes as `classifyTokenAsPathCandidate`, plus:
39
+ * - Dot-files and `./`-relative paths (starting with `.`)
40
+ * - Any relative path containing `/` (e.g. `src/foo.ts`)
41
+ *
42
+ * The `~/foo` case is covered by `includes("/")` — no separate `~/` branch needed.
43
+ *
44
+ * Does NOT require the strict "must start with `/` or `~/` or contain `..`"
45
+ * gate that the external-directory classifier uses.
46
+ *
47
+ * Returns the raw token string if it qualifies, or `null` to skip.
48
+ */
49
+ export function classifyTokenAsRuleCandidate(token: string): string | null {
50
+ if (rejectNonPathToken(token)) return null;
51
+
52
+ if (token.startsWith(".")) return token;
53
+ if (token.includes("/")) return token; // covers ~/ paths and all relative paths with /
54
+ if (token.includes("..")) return token; // bare ".." (no slash)
55
+
56
+ return null;
57
+ }
58
+
59
+ // ── Private rejection predicate ────────────────────────────────────────────
60
+
61
+ /**
62
+ * URL pattern to skip tokens that look like URLs rather than paths.
63
+ */
64
+ const URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
65
+
66
+ /**
67
+ * Regex metacharacter sequences that are never found in real filesystem paths.
68
+ * If a token contains any of these, it is almost certainly a regex pattern
69
+ * (e.g. a grep argument) rather than a path.
70
+ */
71
+ const REGEX_METACHAR_PATTERN = /\.\*|\.\+|\\\||\\\(|\\\)|\[.*?\]|\^\//;
72
+
73
+ /**
74
+ * Shared rejection prelude: returns `true` when a token can never be a
75
+ * filesystem path, regardless of which classifier is asking.
76
+ *
77
+ * Rejects: empty tokens, flags (leading `-`), env assignments (`FOO=/bar`),
78
+ * URLs, `@scope/package` patterns, bare-slash tokens, and regex metacharacter
79
+ * sequences.
80
+ */
81
+ function rejectNonPathToken(token: string): boolean {
82
+ if (!token) return true;
83
+ if (token.startsWith("-")) return true;
84
+
85
+ // Env assignment: = appears before any / (FOO=/bar is an assignment,
86
+ // /foo=bar is not because the slash comes first).
87
+ const eqIndex = token.indexOf("=");
88
+ const slashIndex = token.indexOf("/");
89
+ if (eqIndex !== -1 && (slashIndex === -1 || eqIndex < slashIndex))
90
+ return true;
91
+
92
+ if (URL_PATTERN.test(token)) return true;
93
+
94
+ // @scope/package patterns (npm scoped packages) — but @/ is allowed through
95
+ // since it looks like an absolute-rooted path, not an npm scope.
96
+ if (token.startsWith("@") && !token.startsWith("@/")) return true;
97
+
98
+ // Bare-slash tokens (/, //, ///) resolve to filesystem root and are never
99
+ // meaningful path arguments in practice.
100
+ if (/^\/+$/.test(token)) return true;
101
+
102
+ if (REGEX_METACHAR_PATTERN.test(token)) return true;
103
+
104
+ return false;
105
+ }
@@ -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 = [];