@gotgenes/pi-permission-system 39.0.0 → 39.0.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,30 @@ 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
+ ## [39.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.0.1...pi-permission-system-v39.0.2) (2026-10-03)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** stop logging config- and default-allowed bash external paths as session approvals ([1e597c0](https://github.com/gotgenes/pi-packages/commit/1e597c033c58843f298d9fd0f01cf227dc649e60)), closes [#1011](https://github.com/gotgenes/pi-packages/issues/1011)
14
+
15
+ ### Documentation
16
+
17
+ * **pi-permission-system:** say what external_directory does when the key is absent ([7319277](https://github.com/gotgenes/pi-packages/commit/7319277d338665225d59af21c43b463e94bda6e6)), closes [#1011](https://github.com/gotgenes/pi-packages/issues/1011)
18
+
19
+ ## [39.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.0.0...pi-permission-system-v39.0.1) (2026-10-03)
20
+
21
+
22
+ ### Bug Fixes
23
+
24
+ * **pi-permission-system:** label two paths that share one session glob by that glob ([3ebe54f](https://github.com/gotgenes/pi-packages/commit/3ebe54fc5dd1d7900dfe4927f923e0f223dcb65c)), closes [#989](https://github.com/gotgenes/pi-packages/issues/989)
25
+ * **pi-permission-system:** scope a directory's session approval to that directory, not its parent ([a0bcf97](https://github.com/gotgenes/pi-packages/commit/a0bcf979e1e4bdb8b0c1cf9575432cc6e3ce4525)), closes [#989](https://github.com/gotgenes/pi-packages/issues/989)
26
+ * **pi-permission-system:** name the session-approval scope in a path ask that proves no direction ([483a1ef](https://github.com/gotgenes/pi-packages/commit/483a1ef951dc8c4677a9e02e94c5ce7487535cdf)), closes [#989](https://github.com/gotgenes/pi-packages/issues/989)
27
+
28
+ ### Documentation
29
+
30
+ * **pi-permission-system:** document directory-scoped session approvals ([402cdf7](https://github.com/gotgenes/pi-packages/commit/402cdf721eb628a4eea926e3310acf1f1e07c8df)), closes [#989](https://github.com/gotgenes/pi-packages/issues/989)
31
+
8
32
  ## [39.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v38.0.2...pi-permission-system-v39.0.0) (2026-10-03)
9
33
 
10
34
 
@@ -789,6 +789,9 @@ Use a pattern map to allow specific directories without opening all external acc
789
789
  For example, `read: "allow"` can permit ordinary reads while `external_directory: "ask"` still requires confirmation before reading `../outside.txt` or an absolute path outside `ctx.cwd`.
790
790
  Optional-path search tools (`find`, `grep`, `ls`) skip this check when no `path` is provided.
791
791
 
792
+ When no `external_directory` key is present, the universal fallback (`permission["*"]`) applies.
793
+ So `"*": "allow"` lets every outside-CWD access through with no prompt and, like any policy allow, no review-log entry; the examples on this page set `"*": "ask"` inside `external_directory` so the boundary asks regardless of the fallback.
794
+
792
795
  #### Allow an outside-CWD cache directory
793
796
 
794
797
  When an agent keeps reading a local cache outside the working tree — `~/.cargo/registry`, `~/.npm`, `~/go/pkg/mod` — and you want to stop confirming it every time, allow that directory on the `external_directory` surface:
@@ -59,6 +59,14 @@ The suggested pattern is surface-specific:
59
59
  | tool with path (read, write, …) | `read` for `src/foo.ts` | `src/*` |
60
60
  | tool catch-all | `read` (no extractable path) | `*` |
61
61
  | external_directory | `/other/project/src/foo.ts` | `/other/project/src/*` |
62
+ | external_directory (directory) | `ls /other/project` | `/other/project/*` |
63
+
64
+ A path that names an existing directory (`ls`, `find`, `grep`, or an extension tool's directory argument) is scoped to that directory, not its parent.
65
+ The approval records two rules — the directory itself and `<dir>/*` — so a repeated `ls` of the directory and every file beneath it are covered, while a sibling directory such as `/other/project-b` still asks.
66
+ The dialog names the pair by its contents glob, `"/other/project/*"`.
67
+ A path that does not exist yet, or a bash token whose base directory is unknown, keeps the parent-directory scope.
68
+
69
+ When a path ask proves no read/write direction (an extension tool, or `edit`), the session option names its scope as `Yes, allow access to "<pattern>" for this session`.
62
70
 
63
71
  ## Bash Arity Table
64
72
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "39.0.0",
3
+ "version": "39.0.2",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -2,6 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
3
  buildDirectionalSessionLabels,
4
4
  buildForwardedScopeLabels,
5
+ buildPathAccessSessionLabel,
5
6
  describeGrantTarget,
6
7
  } from "#src/presentation/pattern-suggest";
7
8
  import {
@@ -126,9 +127,10 @@ function unansweredDecision(reason: string): PermissionPromptDecision {
126
127
  /**
127
128
  * The dialog options this ask offers, composed from three independent groups.
128
129
  *
129
- * The label names what the session grant covers (a gate-supplied one, or one
130
- * derived from the grants themselves for a path ask). An ask whose grants all
131
- * prove the same direction additionally offers the both-directions width
130
+ * The label names what the session grant covers: the proven direction and
131
+ * target for a directional path ask, else a gate-supplied label, else the
132
+ * target alone for a path ask that proves no direction. An ask whose grants
133
+ * all prove the same direction additionally offers the both-directions width
132
134
  * (#813). A forwarded ask additionally offers the scope choice (subagent vs
133
135
  * whole session).
134
136
  *
@@ -144,7 +146,11 @@ function buildRequestOptions(
144
146
  const widths = direction
145
147
  ? buildDirectionalSessionLabels(direction, describeGrantTarget(grants))
146
148
  : null;
147
- const sessionLabel = widths?.sessionLabel ?? details.sessionLabel;
149
+ const sessionLabel =
150
+ widths?.sessionLabel ??
151
+ details.sessionLabel ??
152
+ buildPathAccessSessionLabel(grants) ??
153
+ undefined;
148
154
 
149
155
  const options: RequestPermissionOptions = {
150
156
  ...(sessionLabel ? { sessionLabel } : {}),
@@ -14,7 +14,10 @@ import type { ToolCallContext } from "./types";
14
14
  * Reads the external paths from the injected `BashProgram` and checks whether
15
15
  * any reference directories outside the working directory. Returns `null` when the gate
16
16
  * does not apply (not a shell invocation, no command, or no external paths found).
17
- * Returns a `GateBypass` when all paths are allowed (by config or session rule).
17
+ * Returns `null` when every path is allowed by a config rule or the universal
18
+ * fallback — like any policy allow, it writes no review entry. Returns a
19
+ * `GateBypass` recording a session approval when every path is allowed and at
20
+ * least one was covered by a session grant.
18
21
  * Returns a `GateDescriptor` with multi-pattern sessionApproval for uncovered paths.
19
22
  *
20
23
  * Each path is resolved on the narrowest `external_directory`-family surface
@@ -42,23 +45,29 @@ export function describeBashExternalDirectoryGate(
42
45
 
43
46
  // Resolve every external path on the external_directory surface and keep the
44
47
  // ones not already allowed (config-level allows suppress the prompt just as
45
- // session-level allows do); the shared helper single-sources the #418 alias
46
- // matching and the worst-uncovered selection.
47
- const { uncovered: uncoveredEntries, worstCheck } =
48
- selectUncoveredExternalPaths(
49
- externalAccesses,
50
- resolver,
51
- tcc.agentName ?? undefined,
52
- );
48
+ // session-level allows do), noting which allowed ones a session grant
49
+ // covered; the shared helper single-sources the #418 alias matching and the
50
+ // worst-uncovered selection.
51
+ const {
52
+ uncovered: uncoveredEntries,
53
+ sessionCovered,
54
+ worstCheck,
55
+ } = selectUncoveredExternalPaths(
56
+ externalAccesses,
57
+ resolver,
58
+ tcc.agentName ?? undefined,
59
+ );
53
60
  const uncoveredPaths = uncoveredEntries.map(({ path }) => path.value());
54
61
 
55
62
  if (uncoveredPaths.length === 0) {
63
+ // No session grant decided any path: policy alone allowed the command.
64
+ if (sessionCovered.length === 0) return null;
56
65
  return {
57
66
  action: "allow",
58
- // A whole-command bypass covers every external path at once, and each
59
- // may have matched a different session pattern -- so the surface is one
60
- // value and the pattern is not. The entry's `externalPaths` lists what
61
- // was covered.
67
+ // A whole-command bypass covers every session-granted path at once, and
68
+ // each may have matched a different session pattern -- so the surface is
69
+ // one value and the pattern is not. The entry's `externalPaths` lists the
70
+ // paths a grant covered, never the ones policy allowed.
62
71
  decidedBy: {
63
72
  kind: "session_approval",
64
73
  surface: "external_directory",
@@ -72,7 +81,7 @@ export function describeBashExternalDirectoryGate(
72
81
  toolName: tcc.toolName,
73
82
  agentName: tcc.agentName,
74
83
  command,
75
- externalPaths: externalAccesses.map(({ path }) => path.value()),
84
+ externalPaths: sessionCovered.map((path) => path.value()),
76
85
  resolution: "session_approved",
77
86
  },
78
87
  },
@@ -109,10 +118,11 @@ export function describeBashExternalDirectoryGate(
109
118
  input: {},
110
119
  payload,
111
120
  sessionApproval: SessionApproval.forGrants(
112
- uncoveredEntries.map((entry) => ({
113
- surface: entry.surface,
114
- pattern: normalizer.approvalPatternFor(entry.path),
115
- })),
121
+ uncoveredEntries.flatMap((entry) =>
122
+ normalizer
123
+ .approvalPatternsFor(entry.path)
124
+ .map((pattern) => ({ surface: entry.surface, pattern })),
125
+ ),
116
126
  ),
117
127
  promptDetails: {
118
128
  source: "tool_call",
@@ -130,10 +130,10 @@ export function describeBashPathGate(
130
130
  // All tokens evaluate to allow — no restriction.
131
131
  if (!worstCheck || !worstToken || !worstEntry) return null;
132
132
 
133
- // Derive the pattern from the lexical absolute form (the cd-aware resolved
134
- // path), so it matches the values a later call produces. For an unknown base
133
+ // Derive the patterns from the lexical absolute form (the cd-aware resolved
134
+ // path), so they match the values a later call produces. For an unknown base
135
135
  // (`forLiteral`) `value()` is the raw token.
136
- const pattern = normalizer.approvalPatternFor(worstEntry.path);
136
+ const patterns = normalizer.approvalPatternsFor(worstEntry.path);
137
137
  const surface = worstEntry.surface;
138
138
  const payload = buildPathAskPayload({
139
139
  toolName: tcc.toolName,
@@ -147,7 +147,7 @@ export function describeBashPathGate(
147
147
  surface,
148
148
  input: { path: worstToken },
149
149
  payload,
150
- sessionApproval: SessionApproval.single(surface, pattern),
150
+ sessionApproval: SessionApproval.forPatterns(surface, patterns),
151
151
  promptDetails: {
152
152
  source: "tool_call",
153
153
  agentName: tcc.agentName,
@@ -16,9 +16,18 @@ export interface UncoveredExternalPath {
16
16
  check: PermissionCheckResult;
17
17
  }
18
18
 
19
- /** The uncovered external paths plus the most restrictive check among them. */
20
- export interface UncoveredExternalPaths {
19
+ /**
20
+ * How a set of external paths resolved: the uncovered ones, the worst among
21
+ * them, and which allowed paths a session grant covered.
22
+ */
23
+ export interface ExternalPathCoverage {
21
24
  uncovered: UncoveredExternalPath[];
25
+ /**
26
+ * Allowed paths whose winning rule was a session grant. A path allowed by a
27
+ * config rule or the universal fallback is in neither list, so a caller can
28
+ * stamp `session_approval` only where a grant actually decided.
29
+ */
30
+ sessionCovered: AccessPath[];
22
31
  /** Worst check among uncovered paths; `undefined` only when none are uncovered. */
23
32
  worstCheck: PermissionCheckResult | undefined;
24
33
  }
@@ -60,14 +69,16 @@ export function resolveExternalDirectoryPolicy(
60
69
  * not "allow" are collected (filtering on state, not source, so config-level
61
70
  * allow rules suppress the prompt just as session-level allow rules do), and
62
71
  * the most restrictive uncovered check is returned so a config "deny" is not
63
- * downgraded to the catch-all "ask".
72
+ * downgraded to the catch-all "ask". Allowed entries a session rule decided
73
+ * are reported apart, as the provenance a whole-command bypass records.
64
74
  */
65
75
  export function selectUncoveredExternalPaths(
66
76
  accesses: readonly BashExternalPath[],
67
77
  resolver: ScopedPermissionResolver,
68
78
  agentName: string | undefined,
69
- ): UncoveredExternalPaths {
79
+ ): ExternalPathCoverage {
70
80
  const uncovered: UncoveredExternalPath[] = [];
81
+ const sessionCovered: AccessPath[] = [];
71
82
  for (const { path, effect } of accesses) {
72
83
  const surface = capabilitySurfaceForEffect(
73
84
  "external_directory",
@@ -81,10 +92,13 @@ export function selectUncoveredExternalPaths(
81
92
  );
82
93
  if (check.state !== "allow") {
83
94
  uncovered.push({ path, surface, effect, check });
95
+ } else if (check.source === "session") {
96
+ sessionCovered.push(path);
84
97
  }
85
98
  }
86
99
  return {
87
100
  uncovered,
101
+ sessionCovered,
88
102
  worstCheck: pickMostRestrictive(uncovered.map(({ check }) => check)),
89
103
  };
90
104
  }
@@ -97,7 +97,7 @@ export function describeExternalDirectoryGate(
97
97
 
98
98
  // ── Build descriptor for permission check ───────────────────────────────
99
99
  const resolvedAlias = accessPath.resolvedAlias();
100
- const pattern = normalizer.approvalPatternFor(accessPath);
100
+ const patterns = normalizer.approvalPatternsFor(accessPath);
101
101
 
102
102
  const payload = buildExternalDirectoryAskPayload({
103
103
  toolName: tcc.toolName,
@@ -114,7 +114,7 @@ export function describeExternalDirectoryGate(
114
114
  input: {},
115
115
  preCheck,
116
116
  payload,
117
- sessionApproval: SessionApproval.single(surface, pattern),
117
+ sessionApproval: SessionApproval.forPatterns(surface, patterns),
118
118
  promptDetails: buildPathGatePromptDetails(
119
119
  tcc,
120
120
  externalDirectoryPath,
@@ -58,9 +58,9 @@ export function describePathGate(
58
58
  // "path" key should not trigger path-level prompts (#58).
59
59
  if (check.matchedPattern === undefined) return null;
60
60
 
61
- // Derive the approval pattern from the lexical absolute form so it matches
61
+ // Derive the approval patterns from the lexical absolute form so they match
62
62
  // the policy values a later call produces.
63
- const pattern = normalizer.approvalPatternFor(accessPath);
63
+ const patterns = normalizer.approvalPatternsFor(accessPath);
64
64
 
65
65
  const payload = buildPathAskPayload({
66
66
  toolName: tcc.toolName,
@@ -75,7 +75,7 @@ export function describePathGate(
75
75
  surface,
76
76
  input: { path: filePath },
77
77
  payload,
78
- sessionApproval: SessionApproval.single(surface, pattern),
78
+ sessionApproval: SessionApproval.forPatterns(surface, patterns),
79
79
  promptDetails: buildPathGatePromptDetails(
80
80
  tcc,
81
81
  filePath,
@@ -210,7 +210,7 @@ export class ToolCallGatePipeline {
210
210
  return {
211
211
  pathAccess: {
212
212
  path: accessPath,
213
- approvalPattern: normalizer.approvalPatternFor(accessPath),
213
+ approvalPatterns: normalizer.approvalPatternsFor(accessPath),
214
214
  },
215
215
  toolCheck: this.resolver.resolve({
216
216
  kind: "access-path",
@@ -34,7 +34,7 @@ import type { ToolCallContext } from "./types";
34
34
  */
35
35
  export interface ToolPathAccess {
36
36
  readonly path: AccessPath;
37
- readonly approvalPattern: string;
37
+ readonly approvalPatterns: readonly string[];
38
38
  }
39
39
 
40
40
  /**
@@ -42,7 +42,7 @@ export interface ToolPathAccess {
42
42
  *
43
43
  * Bash → command string; MCP → qualified target; everything else → catch-all
44
44
  * wildcard. A path-bearing tool that resolved a path never reaches here — its
45
- * suggestion comes from the already-derived {@link ToolPathAccess} pattern.
45
+ * suggestion comes from the already-derived {@link ToolPathAccess} patterns.
46
46
  */
47
47
  function deriveSuggestionValue(
48
48
  toolName: string,
@@ -76,6 +76,40 @@ function gateSurfaceOf(
76
76
  return toolName;
77
77
  }
78
78
 
79
+ /** A session option: the approval to record and the label offering it. */
80
+ interface SessionOption {
81
+ readonly approval: SessionApproval;
82
+ readonly label: string;
83
+ }
84
+
85
+ /** The session option for a path-bearing tool, from its derived patterns. */
86
+ function pathSessionOption(
87
+ gateSurface: string,
88
+ approvalPatterns: readonly string[],
89
+ ): SessionOption {
90
+ const suggestion = suggestPathSessionPattern(gateSurface, approvalPatterns);
91
+ return {
92
+ approval: SessionApproval.forPatterns(
93
+ suggestion.surface,
94
+ suggestion.patterns,
95
+ ),
96
+ label: suggestion.label,
97
+ };
98
+ }
99
+
100
+ /** The session option for a gate whose value is not a path. */
101
+ function valueSessionOption(
102
+ toolName: string,
103
+ gateSurface: string,
104
+ check: PermissionCheckResult,
105
+ ): SessionOption {
106
+ const suggestion = suggestValueSessionPattern(toolName, gateSurface, check);
107
+ return {
108
+ approval: SessionApproval.single(suggestion.surface, suggestion.pattern),
109
+ label: suggestion.label,
110
+ };
111
+ }
112
+
79
113
  /**
80
114
  * The session approval for a gate whose value is not a path.
81
115
  *
@@ -118,10 +152,10 @@ export function describeToolGate(
118
152
  PATH_BEARING_TOOLS,
119
153
  );
120
154
 
121
- // Compute session approval suggestion for the "for this session" option.
122
- const suggestion = pathAccess
123
- ? suggestPathSessionPattern(gateSurface, pathAccess.approvalPattern)
124
- : suggestValueSessionPattern(tcc.toolName, gateSurface, check);
155
+ // The session approval and label for the "for this session" option.
156
+ const sessionOption = pathAccess
157
+ ? pathSessionOption(gateSurface, pathAccess.approvalPatterns)
158
+ : valueSessionOption(tcc.toolName, gateSurface, check);
125
159
 
126
160
  const payload = buildToolAskPayload({
127
161
  check,
@@ -148,16 +182,13 @@ export function describeToolGate(
148
182
  surface: gateSurface,
149
183
  input: tcc.input,
150
184
  payload,
151
- sessionApproval: SessionApproval.single(
152
- suggestion.surface,
153
- suggestion.pattern,
154
- ),
185
+ sessionApproval: sessionOption.approval,
155
186
  promptDetails: {
156
187
  source: "tool_call",
157
188
  agentName: tcc.agentName,
158
189
  toolCallId: tcc.toolCallId,
159
190
  toolName: tcc.toolName,
160
- sessionLabel: suggestion.label,
191
+ sessionLabel: sessionOption.label,
161
192
  accessIntent,
162
193
  ...permissionLogContext,
163
194
  },
@@ -1,13 +1,19 @@
1
1
  import type { PathFlavor } from "./path-flavor";
2
2
 
3
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.
4
+ * Derive the wildcard globs to record when a user approves an accessed path
5
+ * for the session.
6
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
7
+ * A non-directory (or a path the caller could not confirm is a directory) is
8
+ * scoped to its enclosing directory: the value up to and including its last
9
+ * path separator, with `*` appended. A directory is scoped to itself and its
10
+ * contents — `D` and `D<sep>*` — rather than to its parent, which would grant
11
+ * every sibling directory (#989). Two patterns are needed because `D<sep>*`
12
+ * does not match `D` itself, and `D*` would also match a sibling `D-evil`.
13
+ *
14
+ * Each pattern is spelled with the separator the value itself carries. That
15
+ * matters on a win32 host, where Git Bash tokens are POSIX-shaped while Node's
16
+ * own `sep` is a backslash: deriving `/tmp/logs\*` from `/tmp/logs/` widens the
11
17
  * grant to the parent directory once the `windowsSeparators` fold (#653)
12
18
  * normalizes both operands. A value carrying no separator falls back to the
13
19
  * current directory, which is what callers see only if they skipped resolving
@@ -17,11 +23,30 @@ import type { PathFlavor } from "./path-flavor";
17
23
  * never an ambient `node:path` read, so win32 derivation is decidable — and
18
24
  * testable — on a POSIX host (#655).
19
25
  */
20
- export function deriveApprovalPattern(
26
+ export function deriveApprovalPatterns(
21
27
  pathValue: string,
22
28
  flavor: PathFlavor,
23
- ): string {
29
+ isDirectory: boolean,
30
+ ): readonly string[] {
31
+ if (!isDirectory) return [parentScopePattern(pathValue, flavor)];
32
+ return [pathValue, directoryContentsPattern(pathValue, flavor)];
33
+ }
34
+
35
+ /** The value's enclosing directory scope (up to its last separator) plus `*`. */
36
+ function parentScopePattern(pathValue: string, flavor: PathFlavor): string {
24
37
  const lastSeparator = flavor.lastSeparatorIndex(pathValue);
25
38
  if (lastSeparator < 0) return `.${flavor.impl.sep}*`;
26
39
  return `${pathValue.slice(0, lastSeparator + 1)}*`;
27
40
  }
41
+
42
+ /** Everything beneath a directory value: the value, a separator, then `*`. */
43
+ function directoryContentsPattern(
44
+ pathValue: string,
45
+ flavor: PathFlavor,
46
+ ): string {
47
+ const lastSeparator = flavor.lastSeparatorIndex(pathValue);
48
+ if (lastSeparator === pathValue.length - 1) return `${pathValue}*`;
49
+ const separator =
50
+ lastSeparator < 0 ? flavor.impl.sep : pathValue[lastSeparator];
51
+ return `${pathValue}${separator}*`;
52
+ }
@@ -1,4 +1,4 @@
1
- import { existsSync, lstatSync } from "node:fs";
1
+ import { existsSync, lstatSync, statSync } from "node:fs";
2
2
  import { AccessPath } from "#src/access-intent/access-path";
3
3
  import {
4
4
  canonicalNormalizePathForComparison,
@@ -6,7 +6,7 @@ import {
6
6
  normalizePathPolicyLiteral,
7
7
  } from "#src/access-intent/path-normalization";
8
8
  import { classifyToolKind } from "#src/access-intent/tool-kind";
9
- import { deriveApprovalPattern } from "./approval-pattern";
9
+ import { deriveApprovalPatterns } from "./approval-pattern";
10
10
  import { resolveNativeToolTarget } from "./native-tool-target";
11
11
  import { isPathOutsideWorkingDirectory } from "./path-containment";
12
12
  import type { PathFlavor } from "./path-flavor";
@@ -131,17 +131,41 @@ export class PathNormalizer {
131
131
  }
132
132
 
133
133
  /**
134
- * The session-approval glob for an accessed path: its directory scope plus
135
- * `*`, derived through the baked flavor.
134
+ * The session-approval globs for an accessed path, derived through the baked
135
+ * flavor: an existing directory's own scope (itself and its contents), else
136
+ * the enclosing directory's scope plus `*`.
136
137
  *
137
138
  * Takes the already-built {@link AccessPath} — the lexical form is what a
138
- * later tool call is matched on, so the pattern must be derived from the
139
+ * later tool call is matched on, so the patterns must be derived from the
139
140
  * same representation the decision displayed (#438). Deriving it here rather
140
141
  * than at each gate keeps the platform's separator alphabet with the object
141
142
  * that owns the flavor, instead of an ambient `node:path` read (#655).
142
143
  */
143
- approvalPatternFor(accessPath: AccessPath): string {
144
- return deriveApprovalPattern(accessPath.value(), this.flavor);
144
+ approvalPatternsFor(accessPath: AccessPath): readonly string[] {
145
+ return deriveApprovalPatterns(
146
+ accessPath.value(),
147
+ this.flavor,
148
+ this.namesDirectory(accessPath),
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Whether an accessed path names an existing directory, so its session grant
154
+ * covers that directory rather than its parent.
155
+ *
156
+ * Uses `stat`, following symlinks: a link to a directory is what `ls` lists.
157
+ * A literal-only path (no canonical form — an unknown base, or a win32
158
+ * non-mount POSIX absolute) is never probed, since `stat` would resolve it
159
+ * against the process cwd or a fabricated drive. Any error answers `false`,
160
+ * which keeps the parent-directory grant callers had before this probe.
161
+ */
162
+ private namesDirectory(accessPath: AccessPath): boolean {
163
+ if (!accessPath.boundaryValue()) return false;
164
+ try {
165
+ return statSync(accessPath.value()).isDirectory();
166
+ } catch {
167
+ return false;
168
+ }
145
169
  }
146
170
 
147
171
  /** Platform-aware absoluteness (`win32` vs `posix` rules). */
@@ -5,9 +5,10 @@ import {
5
5
  import {
6
6
  type CapabilityDirection,
7
7
  PATH_BEARING_TOOLS,
8
+ surfaceFamilyMembers,
8
9
  surfaceFamilyOf,
9
10
  } from "#src/access-intent/path-surfaces";
10
- import type { ApprovalGrant } from "#src/session/approval-grant";
11
+ import { type ApprovalGrant, grantTargets } from "#src/session/approval-grant";
11
12
 
12
13
  /** The suggestion returned for a "Yes, for this session" dialog option. */
13
14
  export interface SessionApprovalSuggestion {
@@ -80,15 +81,16 @@ export interface ForwardedScopeLabels {
80
81
  /**
81
82
  * What an approval's grants cover, as one phrase.
82
83
  *
83
- * A single grant names its pattern; several name their count, because only the
84
- * external-directory gate aggregates an ask over many paths and there is no
85
- * pattern that describes them all. Requires at least one grant — an approval
84
+ * A single target names its pattern; several name their count, because only
85
+ * the external-directory gate aggregates an ask over many paths and there is
86
+ * no pattern that describes them all. Targets, not grants: a directory's
87
+ * two-grant approval and two paths sharing one glob each count once
88
+ * ({@link grantTargets}). Requires at least one grant — an approval
86
89
  * with none is never offered as a session option.
87
90
  */
88
91
  export function describeGrantTarget(grants: readonly ApprovalGrant[]): string {
89
- return grants.length === 1
90
- ? `"${grants[0].pattern}"`
91
- : `${grants.length} paths`;
92
+ const targets = grantTargets(grants);
93
+ return targets.length === 1 ? `"${targets[0]}"` : `${targets.length} paths`;
92
94
  }
93
95
 
94
96
  /** The two session-option labels for an ask whose grants prove one direction. */
@@ -120,6 +122,25 @@ export function buildDirectionalSessionLabels(
120
122
  };
121
123
  }
122
124
 
125
+ /**
126
+ * Label a session grant on the `path` / `external_directory` families that
127
+ * proves no direction (an extension tool, `edit`), so the option still names
128
+ * its scope instead of the dialog's bare default. Returns `null` when any
129
+ * grant lies outside those families — their gates supply their own label.
130
+ */
131
+ export function buildPathAccessSessionLabel(
132
+ grants: readonly ApprovalGrant[],
133
+ ): string | null {
134
+ const allPathFamily =
135
+ grants.length > 0 &&
136
+ grants.every(
137
+ (grant) => surfaceFamilyMembers(surfaceFamilyOf(grant.surface)) !== null,
138
+ );
139
+ return allPathFamily
140
+ ? `Yes, allow access to ${describeGrantTarget(grants)} for this session`
141
+ : null;
142
+ }
143
+
123
144
  /**
124
145
  * Build the two scope labels shown when a human grants a forwarded request
125
146
  * "for this session."
@@ -221,19 +242,37 @@ export function suggestSessionPattern(
221
242
  return { surface, pattern, label: buildLabel(pattern, surface) };
222
243
  }
223
244
 
245
+ /** The suggestion for a path surface: every pattern to record, one label. */
246
+ export interface PathSessionSuggestion {
247
+ /** The permission surface this approval applies to. */
248
+ surface: string;
249
+ /** The wildcard patterns to store as session rules. */
250
+ patterns: readonly string[];
251
+ /** Human-readable label for the "for session" dialog option. */
252
+ label: string;
253
+ }
254
+
224
255
  /**
225
- * Build the suggestion for a path surface from a pattern the caller already
256
+ * Build the suggestion for a path surface from patterns the caller already
226
257
  * derived through its `PathNormalizer` (#655).
227
258
  *
228
259
  * The derivation belongs to the normalizer, which owns the session's
229
260
  * `PathFlavor`; this module labels the result and must not re-interpret the
230
- * separators it is handed.
261
+ * separators it is handed. The label names the approval's target
262
+ * ({@link grantTargets}), so a directory's pair reads as its contents glob.
231
263
  */
232
264
  export function suggestPathSessionPattern(
233
265
  surface: string,
234
- approvalPattern: string,
235
- ): SessionApprovalSuggestion {
236
- return suggestExactSessionPattern(surface, approvalPattern);
266
+ approvalPatterns: readonly string[],
267
+ ): PathSessionSuggestion {
268
+ const targets = grantTargets(
269
+ approvalPatterns.map((pattern) => ({ surface, pattern })),
270
+ );
271
+ return {
272
+ surface,
273
+ patterns: approvalPatterns,
274
+ label: buildLabel(targets.join('", "'), surface),
275
+ };
237
276
  }
238
277
 
239
278
  /**
@@ -75,3 +75,36 @@ export function provenDirectionOf(
75
75
  ? direction
76
76
  : null;
77
77
  }
78
+
79
+ /**
80
+ * The distinct targets an approval covers, for naming it in a prompt.
81
+ *
82
+ * A directory approval is recorded as two grants on one surface — the
83
+ * directory itself (`D`) and its contents (`D/*`) — because no single wildcard
84
+ * matches both without also matching a sibling like `D-evil`. Here the exact
85
+ * grant folds into its contents grant, so the pair reads as the one target
86
+ * `D/*`. Identical patterns (two paths in one directory) collapse to one.
87
+ */
88
+ export function grantTargets(grants: readonly ApprovalGrant[]): string[] {
89
+ const targets = grants
90
+ .filter((grant) => !hasContentsSibling(grant, grants))
91
+ .map((grant) => grant.pattern);
92
+ return [...new Set(targets)];
93
+ }
94
+
95
+ /** Whether `grants` also holds `grant`'s contents pattern on the same surface. */
96
+ function hasContentsSibling(
97
+ grant: ApprovalGrant,
98
+ grants: readonly ApprovalGrant[],
99
+ ): boolean {
100
+ return grants.some(
101
+ (other) =>
102
+ other.surface === grant.surface &&
103
+ CONTENTS_SUFFIXES.some(
104
+ (suffix) => other.pattern === `${grant.pattern}${suffix}`,
105
+ ),
106
+ );
107
+ }
108
+
109
+ /** A separator (either alphabet) followed by the wildcard. */
110
+ const CONTENTS_SUFFIXES = ["/*", "\\*"] as const;
@@ -23,6 +23,19 @@ export class SessionApproval {
23
23
  return new SessionApproval([{ surface, pattern }]);
24
24
  }
25
25
 
26
+ /**
27
+ * Create an approval whose patterns were all proven on one surface — a
28
+ * directory approval records the directory and its contents as two patterns.
29
+ */
30
+ static forPatterns(
31
+ surface: string,
32
+ patterns: readonly string[],
33
+ ): SessionApproval {
34
+ return new SessionApproval(
35
+ patterns.map((pattern) => ({ surface, pattern })),
36
+ );
37
+ }
38
+
26
39
  /**
27
40
  * Create an approval from grants that may name different surfaces (e.g. a
28
41
  * bash external-directory ask whose uncovered paths proved different