@gotgenes/pi-permission-system 8.0.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 (43) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/config/config.example.json +3 -0
  3. package/package.json +1 -1
  4. package/schemas/permissions.schema.json +12 -0
  5. package/src/extension-config.ts +23 -0
  6. package/src/handlers/gates/bash-external-directory.ts +2 -4
  7. package/src/handlers/gates/bash-path.ts +2 -4
  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 +9 -6
  14. package/src/handlers/permission-gate-handler.ts +110 -141
  15. package/src/permission-manager.ts +6 -49
  16. package/src/permission-prompts.ts +5 -2
  17. package/src/permission-session.ts +3 -2
  18. package/src/scope-merge.ts +72 -0
  19. package/src/session-approval.ts +43 -0
  20. package/src/session-rules.ts +13 -0
  21. package/src/tool-input-preview.ts +0 -116
  22. package/src/tool-preview-formatter.ts +188 -0
  23. package/test/extension-config.test.ts +93 -0
  24. package/test/handlers/external-directory-integration.test.ts +3 -1
  25. package/test/handlers/external-directory-session-dedup.test.ts +17 -12
  26. package/test/handlers/gates/bash-external-directory.test.ts +11 -9
  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 +2 -2
  30. package/test/handlers/gates/runner.test.ts +18 -23
  31. package/test/handlers/gates/tool.test.ts +31 -4
  32. package/test/handlers/input-events.test.ts +1 -1
  33. package/test/handlers/input.test.ts +1 -1
  34. package/test/handlers/tool-call-events.test.ts +3 -2
  35. package/test/handlers/tool-call.test.ts +3 -2
  36. package/test/handlers/validate-requested-tool.test.ts +92 -0
  37. package/test/permission-prompts.test.ts +66 -38
  38. package/test/permission-session.test.ts +6 -3
  39. package/test/scope-merge.test.ts +116 -0
  40. package/test/session-approval.test.ts +75 -0
  41. package/test/session-rules.test.ts +49 -0
  42. package/test/tool-input-preview.test.ts +0 -244
  43. package/test/tool-preview-formatter.test.ts +385 -0
@@ -16,6 +16,10 @@ import {
16
16
  formatUnknownToolReason,
17
17
  } from "#src/permission-prompts";
18
18
  import type { PermissionSession } from "#src/permission-session";
19
+ import {
20
+ resolveToolPreviewLimits,
21
+ ToolPreviewFormatter,
22
+ } from "#src/tool-preview-formatter";
19
23
  import {
20
24
  checkRequestedToolRegistration,
21
25
  getToolNameFromValue,
@@ -23,7 +27,7 @@ import {
23
27
  } from "#src/tool-registry";
24
28
  import { describeBashExternalDirectoryGate } from "./gates/bash-external-directory";
25
29
  import { describeBashPathGate } from "./gates/bash-path";
26
- import type { GateRunnerDeps } from "./gates/descriptor";
30
+ import type { GateResult, GateRunnerDeps } from "./gates/descriptor";
27
31
  import { isGateBypass } from "./gates/descriptor";
28
32
  import { describeExternalDirectoryGate } from "./gates/external-directory";
29
33
  import { describePathGate } from "./gates/path";
@@ -58,30 +62,13 @@ export class PermissionGateHandler {
58
62
  ): Promise<{ block?: true; reason?: string }> {
59
63
  this.session.activate(ctx);
60
64
 
61
- const agentName = this.session.resolveAgentName(ctx);
62
- const toolName = getToolNameFromValue(event);
63
-
64
- if (!toolName) {
65
- return { block: true, reason: formatMissingToolNameReason() };
66
- }
67
-
68
- const registrationCheck = checkRequestedToolRegistration(
69
- toolName,
70
- this.toolRegistry.getAll(),
71
- );
72
- if (registrationCheck.status === "missing-tool-name") {
73
- return { block: true, reason: formatMissingToolNameReason() };
65
+ const validation = validateRequestedTool(event, this.toolRegistry.getAll());
66
+ if (validation.status === "block") {
67
+ return { block: true, reason: validation.reason };
74
68
  }
69
+ const toolName = validation.toolName;
75
70
 
76
- if (registrationCheck.status === "unregistered") {
77
- return {
78
- block: true,
79
- reason: formatUnknownToolReason(
80
- registrationCheck.requestedToolName,
81
- registrationCheck.availableToolNames,
82
- ),
83
- };
84
- }
71
+ const agentName = this.session.resolveAgentName(ctx);
85
72
 
86
73
  const input = getEventInput(event);
87
74
  const toolCallId =
@@ -112,150 +99,93 @@ export class PermissionGateHandler {
112
99
  sessionRules,
113
100
  ) => this.session.checkPermission(surface, input, agent, sessionRules);
114
101
  const getSessionRuleset = () => this.session.getSessionRuleset();
115
- const approveSessionRule = (surface: string, pattern: string) =>
116
- this.session.approveSessionRule(surface, pattern);
102
+ const recordSessionApproval: GateRunnerDeps["recordSessionApproval"] = (
103
+ approval,
104
+ ) => this.session.recordSessionApproval(approval);
117
105
 
118
106
  // ── Shared runner deps (built once, reused for all gates) ────────────
119
107
  const runnerDeps: GateRunnerDeps = {
120
108
  checkPermission,
121
109
  getSessionRuleset,
122
- approveSessionRule,
110
+ recordSessionApproval,
123
111
  writeReviewLog,
124
112
  emitDecision,
125
113
  canConfirm,
126
114
  promptPermission,
127
115
  };
128
116
 
129
- // ── Skill-read gate (descriptor + runner) ───────────────────────────────
130
- const skillDescriptor = describeSkillReadGate(tcc, () =>
131
- this.session.getActiveSkillEntries(),
132
- );
133
- if (skillDescriptor) {
134
- const skillResult = await runGateCheck(
135
- skillDescriptor,
117
+ // ── Unified gate executor ─────────────────────────────────────────────
118
+ // Handles the bypass log/emit branch, calls runGateCheck for descriptors,
119
+ // and returns a block result or undefined (allow / no-op).
120
+ const runGate = async (
121
+ gate: GateResult,
122
+ ): Promise<{ block: true; reason: string } | undefined> => {
123
+ if (!gate) {
124
+ return undefined;
125
+ }
126
+ if (isGateBypass(gate)) {
127
+ if (gate.log) {
128
+ writeReviewLog(gate.log.event, gate.log.details);
129
+ }
130
+ if (gate.decision) {
131
+ emitDecision(gate.decision);
132
+ }
133
+ return undefined;
134
+ }
135
+ const result = await runGateCheck(
136
+ gate,
136
137
  tcc.agentName,
137
138
  tcc.toolCallId,
138
139
  runnerDeps,
139
140
  );
140
- if (skillResult.action === "block") {
141
- return { block: true, reason: skillResult.reason };
142
- }
143
- }
141
+ return result.action === "block"
142
+ ? { block: true, reason: result.reason }
143
+ : undefined;
144
+ };
144
145
 
145
- // ── Path gate for tools (descriptor + runner) ────────────────────────────
146
- const pathDesc = describePathGate(tcc, checkPermission, getSessionRuleset);
147
- if (pathDesc) {
148
- if (isGateBypass(pathDesc)) {
149
- if (pathDesc.log) {
150
- writeReviewLog(pathDesc.log.event, pathDesc.log.details);
151
- }
152
- } else {
153
- const pathResult = await runGateCheck(
154
- pathDesc,
155
- tcc.agentName,
156
- tcc.toolCallId,
157
- runnerDeps,
158
- );
159
- if (pathResult.action === "block") {
160
- return { block: true, reason: pathResult.reason };
161
- }
162
- }
163
- }
146
+ const formatter = new ToolPreviewFormatter(
147
+ resolveToolPreviewLimits(this.session.config),
148
+ );
164
149
 
165
- // ── External-directory gate (descriptor + runner) ────────────────────────
150
+ // ── Ordered gate pipeline ─────────────────────────────────────────────
151
+ // infraDirs is computed once, outside the pipeline, exactly as before.
166
152
  const infraDirs = [
167
153
  ...this.session.getInfrastructureDirs(),
168
154
  ...this.session.getInfrastructureReadPaths(),
169
155
  ];
170
- const extDirDesc = describeExternalDirectoryGate(tcc, infraDirs);
171
- if (extDirDesc) {
172
- if (isGateBypass(extDirDesc)) {
173
- if (extDirDesc.log) {
174
- writeReviewLog(extDirDesc.log.event, extDirDesc.log.details);
175
- }
176
- if (extDirDesc.decision) {
177
- emitDecision(extDirDesc.decision);
178
- }
179
- } else {
180
- const extDirResult = await runGateCheck(
181
- extDirDesc,
182
- tcc.agentName,
183
- tcc.toolCallId,
184
- runnerDeps,
185
- );
186
- if (extDirResult.action === "block") {
187
- return { block: true, reason: extDirResult.reason };
188
- }
189
- }
190
- }
191
156
 
192
- // ── Bash external-directory gate (descriptor + runner) ───────────────────
193
- const bashExtDesc = await describeBashExternalDirectoryGate(
194
- tcc,
195
- checkPermission,
196
- getSessionRuleset,
197
- );
198
- if (bashExtDesc) {
199
- if (isGateBypass(bashExtDesc)) {
200
- if (bashExtDesc.log) {
201
- writeReviewLog(bashExtDesc.log.event, bashExtDesc.log.details);
202
- }
203
- } else {
204
- const bashExtResult = await runGateCheck(
205
- bashExtDesc,
206
- tcc.agentName,
207
- tcc.toolCallId,
208
- runnerDeps,
157
+ const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
158
+ () =>
159
+ describeSkillReadGate(tcc, () => this.session.getActiveSkillEntries()),
160
+ () => describePathGate(tcc, checkPermission, getSessionRuleset),
161
+ () => describeExternalDirectoryGate(tcc, infraDirs),
162
+ () =>
163
+ describeBashExternalDirectoryGate(
164
+ tcc,
165
+ checkPermission,
166
+ getSessionRuleset,
167
+ ),
168
+ () => describeBashPathGate(tcc, checkPermission, getSessionRuleset),
169
+ () => {
170
+ const toolCheck = checkPermission(
171
+ tcc.toolName,
172
+ tcc.input,
173
+ tcc.agentName ?? undefined,
174
+ getSessionRuleset(),
209
175
  );
210
- if (bashExtResult.action === "block") {
211
- return { block: true, reason: bashExtResult.reason };
212
- }
213
- }
214
- }
176
+ const toolDescriptor = describeToolGate(tcc, toolCheck, formatter);
177
+ toolDescriptor.preCheck = toolCheck;
178
+ return toolDescriptor;
179
+ },
180
+ ];
215
181
 
216
- // ── Bash path gate (descriptor + runner) ────────────────────────────────
217
- const bashPathDesc = await describeBashPathGate(
218
- tcc,
219
- checkPermission,
220
- getSessionRuleset,
221
- );
222
- if (bashPathDesc) {
223
- if (isGateBypass(bashPathDesc)) {
224
- if (bashPathDesc.log) {
225
- writeReviewLog(bashPathDesc.log.event, bashPathDesc.log.details);
226
- }
227
- } else {
228
- const bashPathResult = await runGateCheck(
229
- bashPathDesc,
230
- tcc.agentName,
231
- tcc.toolCallId,
232
- runnerDeps,
233
- );
234
- if (bashPathResult.action === "block") {
235
- return { block: true, reason: bashPathResult.reason };
236
- }
182
+ for (const produce of gateProducers) {
183
+ const blocked = await runGate(await produce());
184
+ if (blocked) {
185
+ return blocked;
237
186
  }
238
187
  }
239
188
 
240
- // ── Normal tool permission gate (descriptor + runner) ────────────────────
241
- const toolCheck = checkPermission(
242
- tcc.toolName,
243
- tcc.input,
244
- tcc.agentName ?? undefined,
245
- getSessionRuleset(),
246
- );
247
- const toolDescriptor = describeToolGate(tcc, toolCheck);
248
- toolDescriptor.preCheck = toolCheck;
249
- const toolResult = await runGateCheck(
250
- toolDescriptor,
251
- tcc.agentName,
252
- tcc.toolCallId,
253
- runnerDeps,
254
- );
255
- if (toolResult.action === "block") {
256
- return { block: true, reason: toolResult.reason };
257
- }
258
-
259
189
  return {};
260
190
  }
261
191
 
@@ -352,7 +282,46 @@ export class PermissionGateHandler {
352
282
  }
353
283
  }
354
284
 
355
- // ── Pure helpers (re-exported from original modules) ──────────────────────
285
+ // ── Pure helpers ─────────────────────────────────────────────────────────
286
+
287
+ /** Discriminated result of validating a tool-call event's name and registration. */
288
+ export type RequestedToolValidation =
289
+ | { status: "ok"; toolName: string }
290
+ | { status: "block"; reason: string };
291
+
292
+ /**
293
+ * Validate the tool name from a raw event against the registered tool list.
294
+ *
295
+ * Composes `getToolNameFromValue` + `checkRequestedToolRegistration` + the
296
+ * two reason formatters and returns a discriminated result so `handleToolCall`
297
+ * reads as a straight validate → proceed path without nested early-returns.
298
+ *
299
+ * Returns the **raw** tool name (not the normalised form) so that
300
+ * `ToolCallContext.toolName` stays identical to the pre-extraction behaviour.
301
+ */
302
+ export function validateRequestedTool(
303
+ event: unknown,
304
+ availableTools: readonly unknown[],
305
+ ): RequestedToolValidation {
306
+ const toolName = getToolNameFromValue(event);
307
+ if (!toolName) {
308
+ return { status: "block", reason: formatMissingToolNameReason() };
309
+ }
310
+ const check = checkRequestedToolRegistration(toolName, availableTools);
311
+ if (check.status === "missing-tool-name") {
312
+ return { status: "block", reason: formatMissingToolNameReason() };
313
+ }
314
+ if (check.status === "unregistered") {
315
+ return {
316
+ status: "block",
317
+ reason: formatUnknownToolReason(
318
+ check.requestedToolName,
319
+ check.availableToolNames,
320
+ ),
321
+ };
322
+ }
323
+ return { status: "ok", toolName };
324
+ }
356
325
 
357
326
  /**
358
327
  * Extract the tool input from an event, checking both `input` and `arguments`
@@ -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
@@ -1,5 +1,5 @@
1
1
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
2
- import { formatToolInputForPrompt } from "./tool-input-preview";
2
+ import type { ToolPreviewFormatter } from "./tool-preview-formatter";
3
3
  import type { PermissionCheckResult } from "./types";
4
4
 
5
5
  // NOTE: formatDenyReason, formatUserDeniedReason, and
@@ -31,6 +31,7 @@ export function formatAskPrompt(
31
31
  result: PermissionCheckResult,
32
32
  agentName?: string,
33
33
  input?: unknown,
34
+ formatter?: ToolPreviewFormatter,
34
35
  ): string {
35
36
  const subject = agentName ? `Agent '${agentName}'` : "Current agent";
36
37
 
@@ -51,7 +52,9 @@ export function formatAskPrompt(
51
52
  const patternInfo = result.matchedPattern
52
53
  ? ` (matched '${result.matchedPattern}')`
53
54
  : "";
54
- const inputPreview = formatToolInputForPrompt(result.toolName, input);
55
+ const inputPreview = formatter
56
+ ? formatter.formatToolInputForPrompt(result.toolName, input)
57
+ : "";
55
58
  const inputSuffix = inputPreview ? ` ${inputPreview}` : "";
56
59
  return `${subject} requested tool '${result.toolName}'${patternInfo}${inputSuffix}. Allow this call?`;
57
60
  }
@@ -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 = [];