@gotgenes/pi-permission-system 26.2.0 → 26.2.2

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ 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
+ ## [26.2.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.2.1...pi-permission-system-v26.2.2) (2026-08-18)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** derive session-approval patterns through the injected PathFlavor ([cf561de](https://github.com/gotgenes/pi-packages/commit/cf561de4c19895ed9495d82f22280fe3ba215aa5)), closes [#655](https://github.com/gotgenes/pi-packages/issues/655)
14
+
15
+ ## [26.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.2.0...pi-permission-system-v26.2.1) (2026-08-17)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **pi-permission-system:** accept pasted text in the denial-reason field ([8b33c38](https://github.com/gotgenes/pi-packages/commit/8b33c38084689dbb356fa0a0b7069bc610140736)), closes [#760](https://github.com/gotgenes/pi-packages/issues/760)
21
+
22
+
23
+ ### Documentation
24
+
25
+ * **pi-permission-system:** document the delegated denial-reason editor ([e7329f5](https://github.com/gotgenes/pi-packages/commit/e7329f5e81fc66e5317e958c9928262aaf433173)), closes [#760](https://github.com/gotgenes/pi-packages/issues/760)
26
+
8
27
  ## [26.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.1.0...pi-permission-system-v26.2.0) (2026-08-17)
9
28
 
10
29
 
@@ -135,6 +135,10 @@ It expands both the prompt itself — to the complete request, unbounded by `pro
135
135
  It only toggles the display — it never resolves, commits, or arms the pending decision.
136
136
  While you are typing a denial reason it is not intercepted, so a rebound printable key still reaches the reason editor.
137
137
 
138
+ The reason editor is Pi's own line editor, so it behaves like the chat input: pasting works, as do cursor movement, word and line deletion, the kill ring, and undo.
139
+ The reason is a single line — a pasted line break becomes a space, and a long reason scrolls sideways rather than growing the dialog.
140
+ `enter` submits it, and `esc` (or `Ctrl+C`) returns to the decision list without denying.
141
+
138
142
  ### What a prompt shows
139
143
 
140
144
  The prompt renders one fact per line, with the requesting agent (and, for a forwarded subagent ask, its session), the tool, the gate surface, the matched rule, the decision-relevant value, and — for a wrapper such as `xargs` — the command that will actually run.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "26.2.0",
3
+ "version": "26.2.2",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Bracketed-paste normalization for the inline permission dialog's reason field.
3
+ *
4
+ * A terminal in bracketed-paste mode wraps pasted text in these markers, and
5
+ * the TUI hands the wrapped chunk to the focused component in a single call.
6
+ */
7
+
8
+ const PASTE_START = "\u001b[200~";
9
+ const PASTE_END = "\u001b[201~";
10
+ const NEWLINE_RUN = /[\r\n]+/g;
11
+
12
+ /**
13
+ * Collapse newline runs inside a bracketed-paste chunk to single spaces.
14
+ *
15
+ * The framework line editor deletes newlines outright, which joins the words
16
+ * on either side of a line break; a reason pasted from a multi-line source
17
+ * should stay readable in the single-line field. The markers are preserved so
18
+ * the editor still recognizes the chunk as a paste, and anything that is not
19
+ * a complete paste chunk is returned unchanged.
20
+ */
21
+ export function collapsePastedNewlines(data: string): string {
22
+ const start = data.indexOf(PASTE_START);
23
+ if (start === -1) {
24
+ return data;
25
+ }
26
+ const contentStart = start + PASTE_START.length;
27
+ const contentEnd = data.indexOf(PASTE_END, contentStart);
28
+ if (contentEnd === -1) {
29
+ return data;
30
+ }
31
+ const content = data
32
+ .slice(contentStart, contentEnd)
33
+ .replace(NEWLINE_RUN, " ");
34
+ return data.slice(0, contentStart) + content + data.slice(contentEnd);
35
+ }
@@ -3,7 +3,8 @@ import type {
3
3
  ExtensionUIContext,
4
4
  KeybindingsManager,
5
5
  } from "@earendil-works/pi-coding-agent";
6
- import { type Component, matchesKey } from "@earendil-works/pi-tui";
6
+ import { type Component, Input, matchesKey } from "@earendil-works/pi-tui";
7
+ import { collapsePastedNewlines } from "#src/authority/bracketed-paste";
7
8
  import type {
8
9
  DecisionSource,
9
10
  UserDecisionSurface,
@@ -188,7 +189,8 @@ function handleToolsExpandAction(
188
189
 
189
190
  class PermissionPromptComponent implements Component {
190
191
  private state: PromptViewState;
191
- private reasonBuffer = "";
192
+ /** The denial-reason line editor, rebuilt each time the step is entered. */
193
+ private reason: Input;
192
194
  /** Whether the operator asked to see the complete request (ADR 0011 §4). */
193
195
  private expanded = false;
194
196
 
@@ -203,6 +205,28 @@ class PermissionPromptComponent implements Component {
203
205
  private readonly done: (decision: UnattributedDecision) => void,
204
206
  ) {
205
207
  this.state = initialPromptState(config);
208
+ this.reason = this.createReasonEditor();
209
+ }
210
+
211
+ /**
212
+ * A fresh editor per visit to the reason step.
213
+ *
214
+ * The framework editor carries an undo stack and a kill ring, so reusing one
215
+ * instance would let a reason the operator backed out of be restored into a
216
+ * later ask.
217
+ */
218
+ private createReasonEditor(): Input {
219
+ const editor = new Input();
220
+ // Emits pi-tui's zero-width cursor marker, which positions the hardware
221
+ // cursor for IME composition.
222
+ editor.focused = true;
223
+ editor.onSubmit = (draft) => {
224
+ this.apply({ type: "submitReason", draft });
225
+ };
226
+ editor.onEscape = () => {
227
+ this.apply({ type: "cancel" });
228
+ };
229
+ return editor;
206
230
  }
207
231
 
208
232
  invalidate(): void {
@@ -278,25 +302,18 @@ class PermissionPromptComponent implements Component {
278
302
  }
279
303
  }
280
304
 
305
+ /**
306
+ * Hand the keystroke to the framework line editor.
307
+ *
308
+ * Delegating is what makes the field accept a paste: a paste arrives as one
309
+ * multi-character chunk wrapped in bracketed-paste markers, which the editor
310
+ * understands and a per-character reader cannot. Submit and cancel come back
311
+ * through the editor's callbacks, so the decision model still owns them.
312
+ */
281
313
  private handleReasonInput(data: string): void {
282
- if (matchesKey(data, "enter")) {
283
- this.apply({ type: "submitReason", draft: this.reasonBuffer });
284
- return;
285
- }
286
- if (matchesKey(data, "escape")) {
287
- this.reasonBuffer = "";
288
- this.apply({ type: "cancel" });
289
- return;
290
- }
291
- if (matchesKey(data, "backspace")) {
292
- this.reasonBuffer = this.reasonBuffer.slice(0, -1);
293
- this.requestRender();
294
- return;
295
- }
296
- if (isPrintable(data)) {
297
- this.reasonBuffer += data;
298
- this.requestRender();
299
- }
314
+ this.reason.handleInput(collapsePastedNewlines(data));
315
+ // The editor mutates its own buffer silently; only the dialog can repaint.
316
+ this.requestRender();
300
317
  }
301
318
 
302
319
  private toEvent(data: string): PromptEvent | undefined {
@@ -328,7 +345,7 @@ class PermissionPromptComponent implements Component {
328
345
  return;
329
346
  }
330
347
  if (outcome.state.step === "reason" && this.state.step !== "reason") {
331
- this.reasonBuffer = "";
348
+ this.reason = this.createReasonEditor();
332
349
  }
333
350
  this.state = outcome.state;
334
351
  this.requestRender();
@@ -354,7 +371,9 @@ class PermissionPromptComponent implements Component {
354
371
  this.theme.fg("accent", this.title),
355
372
  ...this.renderAsk(width).lines,
356
373
  "",
357
- `Reason (required): ${this.reasonBuffer}\u2588`,
374
+ "Reason (required):",
375
+ // Exactly one row, whatever its length: the editor scrolls horizontally.
376
+ ...this.reason.render(width),
358
377
  ];
359
378
  if (this.state.reasonError) {
360
379
  lines.push(this.theme.fg("error", this.state.reasonError));
@@ -388,11 +407,3 @@ class PermissionPromptComponent implements Component {
388
407
  return lines;
389
408
  }
390
409
  }
391
-
392
- function isPrintable(data: string): boolean {
393
- if (data.length !== 1) {
394
- return false;
395
- }
396
- const code = data.charCodeAt(0);
397
- return code >= 0x20 && code !== 0x7f;
398
- }
@@ -52,7 +52,6 @@ export interface PromptViewState {
52
52
  armedKey?: PromptKey;
53
53
  /** "Press y again to approve." while armed; empty otherwise. */
54
54
  hint: string;
55
- reasonDraft: string;
56
55
  /** Set when an empty reason submit is rejected. */
57
56
  reasonError?: string;
58
57
  /** Scope step: false = subagent-only (default), true = whole serving session. */
@@ -80,7 +79,6 @@ export function initialPromptState(
80
79
  highlightedKey: "y",
81
80
  armedKey: undefined,
82
81
  hint: "",
83
- reasonDraft: "",
84
82
  reasonError: undefined,
85
83
  scopeServing: false,
86
84
  };
@@ -169,7 +167,6 @@ function commit(
169
167
  highlightedKey: "r",
170
168
  armedKey: undefined,
171
169
  hint: "",
172
- reasonDraft: "",
173
170
  reasonError: undefined,
174
171
  });
175
172
  case "s":
@@ -200,7 +197,6 @@ function reduceReasonStep(
200
197
  step: "decision",
201
198
  armedKey: undefined,
202
199
  hint: "",
203
- reasonDraft: "",
204
200
  reasonError: undefined,
205
201
  });
206
202
  }
@@ -209,7 +205,6 @@ function reduceReasonStep(
209
205
  if (reason === undefined) {
210
206
  return render({
211
207
  ...state,
212
- reasonDraft: event.draft,
213
208
  reasonError: "A reason is required.",
214
209
  });
215
210
  }
@@ -1,8 +1,8 @@
1
1
  import type { BashProgram } from "#src/access-intent/bash/program";
2
+ import type { PathNormalizer } from "#src/path-normalizer";
2
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
4
  import { buildBashExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
4
5
  import { SessionApproval } from "#src/session-approval";
5
- import { deriveApprovalPattern } from "#src/session-rules";
6
6
  import type { GateResult } from "./descriptor";
7
7
  import { selectUncoveredExternalPaths } from "./external-directory-policy";
8
8
  import { accessFactsFromPath } from "./helpers";
@@ -25,6 +25,7 @@ export function describeBashExternalDirectoryGate(
25
25
  tcc: ToolCallContext,
26
26
  bashProgram: BashProgram | null,
27
27
  resolver: ScopedPermissionResolver,
28
+ normalizer: PathNormalizer,
28
29
  ): GateResult {
29
30
  if (!bashProgram) return null;
30
31
  const command = bashProgram.commandText();
@@ -94,7 +95,9 @@ export function describeBashExternalDirectoryGate(
94
95
  matchedPattern: preCheck.matchedPattern,
95
96
  });
96
97
 
97
- const patterns = uncoveredPaths.map((p) => deriveApprovalPattern(p));
98
+ const patterns = uncoveredEntries.map(({ path }) =>
99
+ normalizer.approvalPatternFor(path),
100
+ );
98
101
 
99
102
  return {
100
103
  surface: "external_directory",
@@ -1,9 +1,9 @@
1
1
  import type { AccessPath } from "#src/access-intent/access-path";
2
2
  import type { BashProgram } from "#src/access-intent/bash/program";
3
+ import type { PathNormalizer } from "#src/path-normalizer";
3
4
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
5
  import { buildPathAskPayload } from "#src/presentation/path-ask-payload";
5
6
  import { SessionApproval } from "#src/session-approval";
6
- import { deriveApprovalPattern } from "#src/session-rules";
7
7
  import type { PermissionCheckResult } from "#src/types";
8
8
  import { pickMostRestrictive } from "./candidate-check";
9
9
  import type { GateResult } from "./descriptor";
@@ -33,6 +33,7 @@ export function describeBashPathGate(
33
33
  tcc: ToolCallContext,
34
34
  bashProgram: BashProgram | null,
35
35
  resolver: ScopedPermissionResolver,
36
+ normalizer: PathNormalizer,
36
37
  ): GateResult {
37
38
  if (!bashProgram) return null;
38
39
  const command = bashProgram.commandText();
@@ -120,7 +121,7 @@ export function describeBashPathGate(
120
121
  // Derive the pattern from the lexical absolute form (the cd-aware resolved
121
122
  // path), so it matches the values a later call produces. For an unknown base
122
123
  // (`forLiteral`) `value()` is the raw token.
123
- const pattern = deriveApprovalPattern(worstEntry.path.value());
124
+ const pattern = normalizer.approvalPatternFor(worstEntry.path);
124
125
  const payload = buildPathAskPayload({
125
126
  toolName: tcc.toolName,
126
127
  pathValue: worstToken,
@@ -3,7 +3,6 @@ import type { PathNormalizer } from "#src/path-normalizer";
3
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
4
  import { buildExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
5
5
  import { SessionApproval } from "#src/session-approval";
6
- import { deriveApprovalPattern } from "#src/session-rules";
7
6
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
8
7
  import type { GateResult } from "./descriptor";
9
8
  import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
@@ -78,7 +77,7 @@ export function describeExternalDirectoryGate(
78
77
  resolver,
79
78
  tcc.agentName ?? undefined,
80
79
  );
81
- const pattern = deriveApprovalPattern(accessPath.value());
80
+ const pattern = normalizer.approvalPatternFor(accessPath);
82
81
 
83
82
  const payload = buildExternalDirectoryAskPayload({
84
83
  toolName: tcc.toolName,
@@ -3,7 +3,6 @@ import type { PathNormalizer } from "#src/path-normalizer";
3
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
4
  import { buildPathAskPayload } from "#src/presentation/path-ask-payload";
5
5
  import { SessionApproval } from "#src/session-approval";
6
- import { deriveApprovalPattern } from "#src/session-rules";
7
6
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
8
7
  import type { GateDescriptor, GateResult } from "./descriptor";
9
8
  import { accessFactsFromPath } from "./helpers";
@@ -46,7 +45,7 @@ export function describePathGate(
46
45
 
47
46
  // Derive the approval pattern from the lexical absolute form so it matches
48
47
  // the policy values a later call produces.
49
- const pattern = deriveApprovalPattern(accessPath.value());
48
+ const pattern = normalizer.approvalPatternFor(accessPath);
50
49
 
51
50
  const payload = buildPathAskPayload({
52
51
  toolName: tcc.toolName,
@@ -1,4 +1,3 @@
1
- import type { AccessPath } from "#src/access-intent/access-path";
2
1
  import { BashProgram } from "#src/access-intent/bash/program";
3
2
  import { getPathBearingToolPath } from "#src/access-intent/tool-input-path";
4
3
  import {
@@ -24,7 +23,7 @@ import { describeExternalDirectoryGate } from "./external-directory";
24
23
  import { describePathGate } from "./path";
25
24
  import type { GateRunner } from "./runner";
26
25
  import { describeSkillReadGate } from "./skill-read";
27
- import { describeToolGate } from "./tool";
26
+ import { describeToolGate, type ToolPathAccess } from "./tool";
28
27
  import type { GateOutcome, ToolCallContext } from "./types";
29
28
 
30
29
  /**
@@ -115,10 +114,16 @@ export class ToolCallGatePipeline {
115
114
  normalizer,
116
115
  this.customExtractors,
117
116
  ),
118
- () => describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver),
119
- () => describeBashPathGate(tcc, bashProgram, this.resolver),
117
+ () =>
118
+ describeBashExternalDirectoryGate(
119
+ tcc,
120
+ bashProgram,
121
+ this.resolver,
122
+ normalizer,
123
+ ),
124
+ () => describeBashPathGate(tcc, bashProgram, this.resolver, normalizer),
120
125
  () => {
121
- const { toolCheck, accessPath } = this.resolvePerToolCheck(
126
+ const { toolCheck, pathAccess } = this.resolvePerToolCheck(
122
127
  tcc,
123
128
  shell,
124
129
  bashProgram,
@@ -128,7 +133,7 @@ export class ToolCallGatePipeline {
128
133
  tcc,
129
134
  toolCheck,
130
135
  formatter,
131
- accessPath,
136
+ pathAccess,
132
137
  shell,
133
138
  );
134
139
  toolDescriptor.preCheck = toolCheck;
@@ -153,15 +158,16 @@ export class ToolCallGatePipeline {
153
158
  * #502); every other tool (and a path-bearing tool with no path) keeps the
154
159
  * raw `tool` intent the manager normalizes.
155
160
  *
156
- * Returns the `AccessPath` alongside the check so `describeToolGate` derives
157
- * the session-approval value from `accessPath.value()`.
161
+ * Returns the resolved path alongside the check, already paired with the
162
+ * session scope approving it grants — derived here, where the normalizer
163
+ * lives, rather than inside the gate (#655).
158
164
  */
159
165
  private resolvePerToolCheck(
160
166
  tcc: ToolCallContext,
161
167
  shell: ShellInvocation | null,
162
168
  bashProgram: BashProgram | null,
163
169
  normalizer: PathNormalizer,
164
- ): { toolCheck: PermissionCheckResult; accessPath?: AccessPath } {
170
+ ): { toolCheck: PermissionCheckResult; pathAccess?: ToolPathAccess } {
165
171
  if (shell) {
166
172
  if (bashProgram) {
167
173
  return {
@@ -190,7 +196,10 @@ export class ToolCallGatePipeline {
190
196
  if (filePath !== null) {
191
197
  const accessPath = normalizer.forPath(filePath);
192
198
  return {
193
- accessPath,
199
+ pathAccess: {
200
+ path: accessPath,
201
+ approvalPattern: normalizer.approvalPatternFor(accessPath),
202
+ },
194
203
  toolCheck: this.resolver.resolve({
195
204
  kind: "access-path",
196
205
  surface: tcc.toolName,
@@ -5,7 +5,10 @@ import {
5
5
  classifyToolKind,
6
6
  type ShellInvocation,
7
7
  } from "#src/access-intent/tool-kind";
8
- import { suggestSessionPattern } from "#src/pattern-suggest";
8
+ import {
9
+ suggestPathSessionPattern,
10
+ suggestSessionPattern,
11
+ } from "#src/pattern-suggest";
9
12
  import { buildToolAskPayload } from "#src/presentation/tool-ask-payload";
10
13
  import { SessionApproval } from "#src/session-approval";
11
14
  import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
@@ -18,18 +21,30 @@ import {
18
21
  } from "./helpers";
19
22
  import type { ToolCallContext } from "./types";
20
23
 
24
+ /**
25
+ * A path-bearing tool call's resolved path, paired with the session scope
26
+ * approving it would grant.
27
+ *
28
+ * The pattern is derived by the pipeline's `PathNormalizer`, which owns the
29
+ * session's `PathFlavor`, rather than re-derived here from `path.value()` — so
30
+ * the gate carries the platform's separator semantics without holding them
31
+ * (#655).
32
+ */
33
+ export interface ToolPathAccess {
34
+ readonly path: AccessPath;
35
+ readonly approvalPattern: string;
36
+ }
37
+
21
38
  /**
22
39
  * Derive the value used for session-approval pattern suggestions.
23
40
  *
24
- * Bash → command string; MCP → qualified target;
25
- * path-bearing tools the `AccessPath`'s lexical absolute form (`value()`),
26
- * so the suggested pattern matches the policy values a later call produces;
27
- * others (or a path-bearing tool with no path) → catch-all wildcard.
41
+ * Bash → command string; MCP → qualified target; everything else → catch-all
42
+ * wildcard. A path-bearing tool that resolved a path never reaches here — its
43
+ * suggestion comes from the already-derived {@link ToolPathAccess} pattern.
28
44
  */
29
45
  function deriveSuggestionValue(
30
46
  toolName: string,
31
47
  check: PermissionCheckResult,
32
- accessPath?: AccessPath,
33
48
  ): string {
34
49
  switch (classifyToolKind(toolName)) {
35
50
  case "bash":
@@ -37,7 +52,7 @@ function deriveSuggestionValue(
37
52
  case "mcp":
38
53
  return check.target ?? "mcp";
39
54
  default:
40
- return accessPath ? accessPath.value() : "*";
55
+ return "*";
41
56
  }
42
57
  }
43
58
 
@@ -51,7 +66,7 @@ export function describeToolGate(
51
66
  tcc: ToolCallContext,
52
67
  check: PermissionCheckResult,
53
68
  formatter: ToolPreviewFormatter,
54
- accessPath?: AccessPath,
69
+ pathAccess?: ToolPathAccess,
55
70
  shell?: ShellInvocation | null,
56
71
  ): GateDescriptor {
57
72
  // A shell invocation (native `bash` or an aliased shell tool) is gated on the
@@ -67,10 +82,12 @@ export function describeToolGate(
67
82
  );
68
83
 
69
84
  // Compute session approval suggestion for the "for this session" option.
70
- const suggestion = suggestSessionPattern(
71
- gateSurface,
72
- deriveSuggestionValue(gateSurface, check, accessPath),
73
- );
85
+ const suggestion = pathAccess
86
+ ? suggestPathSessionPattern(gateSurface, pathAccess.approvalPattern)
87
+ : suggestSessionPattern(
88
+ gateSurface,
89
+ deriveSuggestionValue(gateSurface, check),
90
+ );
74
91
 
75
92
  const payload = buildToolAskPayload({
76
93
  check,
@@ -89,8 +106,8 @@ export function describeToolGate(
89
106
 
90
107
  // A path-bearing tool carries the AccessPath's alias set; every other surface
91
108
  // (bash command, MCP target, plain tool) carries its already-portable value.
92
- const accessIntent = accessPath
93
- ? accessFactsFromPath(gateSurface, accessPath)
109
+ const accessIntent = pathAccess
110
+ ? accessFactsFromPath(gateSurface, pathAccess.path)
94
111
  : accessFactsFromValue(gateSurface, decisionValue);
95
112
 
96
113
  return {
@@ -0,0 +1,27 @@
1
+ import type { PathFlavor } from "#src/path/path-flavor";
2
+
3
+ /**
4
+ * Derive the wildcard glob to record when a user approves an accessed path for
5
+ * the session: the path's directory scope, with `*` appended.
6
+ *
7
+ * The scope is the value up to and including its last path separator, so the
8
+ * pattern is spelled with the separator the value itself carries. That matters
9
+ * on a win32 host, where Git Bash tokens are POSIX-shaped while Node's own
10
+ * `sep` is a backslash: deriving `/tmp/logs\*` from `/tmp/logs/` widens the
11
+ * grant to the parent directory once the `windowsSeparators` fold (#653)
12
+ * normalizes both operands. A value carrying no separator falls back to the
13
+ * current directory, which is what callers see only if they skipped resolving
14
+ * the path to its absolute form first (#438).
15
+ *
16
+ * The platform's separator alphabet arrives as an injected {@link PathFlavor},
17
+ * never an ambient `node:path` read, so win32 derivation is decidable — and
18
+ * testable — on a POSIX host (#655).
19
+ */
20
+ export function deriveApprovalPattern(
21
+ pathValue: string,
22
+ flavor: PathFlavor,
23
+ ): string {
24
+ const lastSeparator = flavor.lastSeparatorIndex(pathValue);
25
+ if (lastSeparator < 0) return `.${flavor.impl.sep}*`;
26
+ return `${pathValue.slice(0, lastSeparator + 1)}*`;
27
+ }
@@ -17,7 +17,8 @@ import type { WildcardMatchOptions } from "#src/wildcard-matcher";
17
17
  * fold or separator fold is a silent bypass (the #382 / #508 class). `PathFlavor`
18
18
  * captures that mapping once so the leaves consume the resolved capability
19
19
  * instead of re-interpreting a raw `NodeJS.Platform` string. It owns platform
20
- * **semantics** — syntax ({@link hasPathSeparator}), token shape
20
+ * **semantics** — syntax ({@link hasPathSeparator} /
21
+ * {@link lastSeparatorIndex}), token shape
21
22
  * ({@link bashTokenShape}), and the equivalence relation ({@link fold} /
22
23
  * {@link comparable} / {@link isWithin} / {@link matchOptions}); domain policy
23
24
  * (lexical cleanup, alias generation, safe-system-path exclusions, rule
@@ -50,6 +51,15 @@ export interface PathFlavor {
50
51
  * POSIX; `/` or `\` on win32 (where a backslash is a separator, #520).
51
52
  */
52
53
  hasPathSeparator(token: string): boolean;
54
+ /**
55
+ * Index of the last path separator in `value`, or `-1` when it holds none.
56
+ *
57
+ * Reads the same separator alphabet as {@link hasPathSeparator}, so a caller
58
+ * that must split a path at its directory boundary uses the separator the
59
+ * value was written with rather than this platform's default `sep` — the two
60
+ * differ for a Git Bash token on a win32 host (`/dev/null`, `/tmp/logs/`).
61
+ */
62
+ lastSeparatorIndex(value: string): number;
53
63
  /**
54
64
  * The MSYS/Git-Bash interpretation of a bash-command token. On win32 this
55
65
  * carries device / drive-mount / posix-absolute / plain semantics; on POSIX
@@ -60,6 +70,8 @@ export interface PathFlavor {
60
70
 
61
71
  class PlatformPathFlavor implements PathFlavor {
62
72
  readonly matchOptions: WildcardMatchOptions | undefined;
73
+ /** Every separator spelling this platform recognizes, the one alphabet both separator answers read. */
74
+ private readonly separators: readonly string[];
63
75
 
64
76
  constructor(
65
77
  readonly impl: PlatformPath,
@@ -68,6 +80,7 @@ class PlatformPathFlavor implements PathFlavor {
68
80
  this.matchOptions = windows
69
81
  ? { caseInsensitive: true, windowsSeparators: true }
70
82
  : undefined;
83
+ this.separators = windows ? ["/", "\\"] : ["/"];
71
84
  }
72
85
 
73
86
  fold(value: string): string {
@@ -91,7 +104,14 @@ class PlatformPathFlavor implements PathFlavor {
91
104
  }
92
105
 
93
106
  hasPathSeparator(token: string): boolean {
94
- return token.includes("/") || (this.windows && token.includes("\\"));
107
+ return this.lastSeparatorIndex(token) >= 0;
108
+ }
109
+
110
+ lastSeparatorIndex(value: string): number {
111
+ return this.separators.reduce(
112
+ (last, separator) => Math.max(last, value.lastIndexOf(separator)),
113
+ -1,
114
+ );
95
115
  }
96
116
 
97
117
  bashTokenShape(token: string): BashTokenShape {
@@ -8,6 +8,7 @@ import {
8
8
  normalizePathForComparison,
9
9
  normalizePathPolicyLiteral,
10
10
  } from "./access-intent/path-normalization";
11
+ import { deriveApprovalPattern } from "./path/approval-pattern";
11
12
  import { isPathOutsideWorkingDirectory } from "./path/path-containment";
12
13
  import { isPiInfrastructureRead } from "./path/pi-infrastructure-read";
13
14
 
@@ -99,6 +100,20 @@ export class PathNormalizer {
99
100
  }
100
101
  }
101
102
 
103
+ /**
104
+ * The session-approval glob for an accessed path: its directory scope plus
105
+ * `*`, derived through the baked flavor.
106
+ *
107
+ * Takes the already-built {@link AccessPath} — the lexical form is what a
108
+ * later tool call is matched on, so the pattern must be derived from the
109
+ * same representation the decision displayed (#438). Deriving it here rather
110
+ * than at each gate keeps the platform's separator alphabet with the object
111
+ * that owns the flavor, instead of an ambient `node:path` read (#655).
112
+ */
113
+ approvalPatternFor(accessPath: AccessPath): string {
114
+ return deriveApprovalPattern(accessPath.value(), this.flavor);
115
+ }
116
+
102
117
  /** Platform-aware absoluteness (`win32` vs `posix` rules). */
103
118
  isAbsolute(pathValue: string): boolean {
104
119
  return this.flavor.impl.isAbsolute(pathValue);
@@ -1,6 +1,5 @@
1
1
  import { PATH_BEARING_TOOLS } from "./access-intent/path-surfaces";
2
2
  import { prefix, stripBashCommentLines } from "./bash-arity";
3
- import { deriveApprovalPattern } from "./session-rules";
4
3
 
5
4
  /** The suggestion returned for a "Yes, for this session" dialog option. */
6
5
  export interface SessionApprovalSuggestion {
@@ -115,14 +114,16 @@ function buildLabel(pattern: string, surface: string): string {
115
114
  }
116
115
 
117
116
  /**
118
- * Suggest a session-approval pattern for the given permission surface and value.
117
+ * Suggest a session-approval pattern from a surface's own value vocabulary
118
+ * a bash command, an MCP target, a skill name.
119
119
  *
120
120
  * Returns a `SessionApprovalSuggestion` with the surface, the wildcard pattern
121
- * to store in `SessionRules`, and a human-readable dialog label.
121
+ * to store in `SessionRules`, and a human-readable dialog label. Any surface
122
+ * with no vocabulary of its own falls back to the catch-all wildcard, which is
123
+ * also what a path-bearing tool invoked without a path resolves to.
122
124
  *
123
- * `value` is expected to be the canonical (cwd-resolved, absolute) path for
124
- * path surfaces callers resolve it before suggesting, so the derived pattern
125
- * matches the policy values a later tool call produces.
125
+ * A path surface goes through {@link suggestPathSessionPattern} instead: its
126
+ * pattern is a path-language product, and this module holds no path semantics.
126
127
  */
127
128
  export function suggestSessionPattern(
128
129
  surface: string,
@@ -140,22 +141,30 @@ export function suggestSessionPattern(
140
141
  case "skill":
141
142
  pattern = value;
142
143
  break;
143
- case "external_directory":
144
- pattern = deriveApprovalPattern(value);
145
- break;
146
- case "path":
147
- pattern = deriveApprovalPattern(value);
148
- break;
149
144
  default:
150
- // Path-bearing tools: derive a directory-scoped pattern from the path.
151
- if (PATH_BEARING_TOOLS.has(surface) && value !== "*") {
152
- pattern = deriveApprovalPattern(value);
153
- break;
154
- }
155
- // Extension tools / fallback.
145
+ // Extension tools, and path-bearing tools invoked without a path.
156
146
  pattern = "*";
157
147
  break;
158
148
  }
159
149
 
160
150
  return { surface, pattern, label: buildLabel(pattern, surface) };
161
151
  }
152
+
153
+ /**
154
+ * Build the suggestion for a path surface from a pattern the caller already
155
+ * derived through its `PathNormalizer` (#655).
156
+ *
157
+ * The derivation belongs to the normalizer, which owns the session's
158
+ * `PathFlavor`; this module labels the result and must not re-interpret the
159
+ * separators it is handed.
160
+ */
161
+ export function suggestPathSessionPattern(
162
+ surface: string,
163
+ approvalPattern: string,
164
+ ): SessionApprovalSuggestion {
165
+ return {
166
+ surface,
167
+ pattern: approvalPattern,
168
+ label: buildLabel(approvalPattern, surface),
169
+ };
170
+ }
@@ -1,5 +1,3 @@
1
- import { dirname, sep } from "node:path";
2
-
3
1
  import type { Ruleset } from "./rule";
4
2
  import type { SessionApproval } from "./session-approval";
5
3
  import type { SessionApprovalRecorder } from "./session-approval-recorder";
@@ -48,32 +46,3 @@ export class SessionRules implements SessionApprovalRecorder {
48
46
  this.rules = [];
49
47
  }
50
48
  }
51
-
52
- /**
53
- * Derive the wildcard glob pattern to approve from a normalized path.
54
- *
55
- * Returns `<parent-dir>/*` so that `evaluate()` / `wildcardMatch()` matches
56
- * all paths under the approved directory — identical semantics to the former
57
- * `SessionApprovalCache` prefix matching, using the unified wildcard engine.
58
- *
59
- * For paths that already end with a separator (directories), the separator
60
- * is treated as the directory boundary and `*` is appended directly.
61
- *
62
- * The path is expected to be the canonical (cwd-resolved, absolute) form used
63
- * for policy matching, so the derived pattern matches the same policy values a
64
- * later tool call produces. Callers that hold a working directory resolve the
65
- * path to that form first; the function itself stays free of cwd state.
66
- */
67
- export function deriveApprovalPattern(normalizedPath: string): string {
68
- // If the path already ends with a separator, it's a directory — glob its contents.
69
- if (normalizedPath.endsWith(sep)) {
70
- return `${normalizedPath}*`;
71
- }
72
- const dir = dirname(normalizedPath);
73
- if (dir === normalizedPath) {
74
- // Root path — dirname('/') === '/'
75
- return `${dir}*`;
76
- }
77
- const prefix = dir.endsWith(sep) ? dir : `${dir}${sep}`;
78
- return `${prefix}*`;
79
- }