@gotgenes/pi-permission-system 39.0.0 → 39.0.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.
- package/CHANGELOG.md +13 -0
- package/docs/session-approvals.md +8 -0
- package/package.json +1 -1
- package/src/authority/local-user-authorizer.ts +10 -4
- package/src/handlers/gates/bash-external-directory.ts +5 -4
- package/src/handlers/gates/bash-path.ts +4 -4
- package/src/handlers/gates/external-directory.ts +2 -2
- package/src/handlers/gates/path.ts +3 -3
- package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -1
- package/src/handlers/gates/tool.ts +42 -11
- package/src/path/approval-pattern.ts +33 -8
- package/src/path/path-normalizer.ts +31 -7
- package/src/presentation/pattern-suggest.ts +51 -12
- package/src/session/approval-grant.ts +33 -0
- package/src/session/session-approval.ts +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ 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.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.0.0...pi-permission-system-v39.0.1) (2026-10-03)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **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)
|
|
14
|
+
* **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)
|
|
15
|
+
* **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)
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* **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)
|
|
20
|
+
|
|
8
21
|
## [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
22
|
|
|
10
23
|
|
|
@@ -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
|
@@ -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
|
|
130
|
-
*
|
|
131
|
-
*
|
|
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 =
|
|
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 } : {}),
|
|
@@ -109,10 +109,11 @@ export function describeBashExternalDirectoryGate(
|
|
|
109
109
|
input: {},
|
|
110
110
|
payload,
|
|
111
111
|
sessionApproval: SessionApproval.forGrants(
|
|
112
|
-
uncoveredEntries.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
uncoveredEntries.flatMap((entry) =>
|
|
113
|
+
normalizer
|
|
114
|
+
.approvalPatternsFor(entry.path)
|
|
115
|
+
.map((pattern) => ({ surface: entry.surface, pattern })),
|
|
116
|
+
),
|
|
116
117
|
),
|
|
117
118
|
promptDetails: {
|
|
118
119
|
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
|
|
134
|
-
// path), so
|
|
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
|
|
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.
|
|
150
|
+
sessionApproval: SessionApproval.forPatterns(surface, patterns),
|
|
151
151
|
promptDetails: {
|
|
152
152
|
source: "tool_call",
|
|
153
153
|
agentName: tcc.agentName,
|
|
@@ -97,7 +97,7 @@ export function describeExternalDirectoryGate(
|
|
|
97
97
|
|
|
98
98
|
// ── Build descriptor for permission check ───────────────────────────────
|
|
99
99
|
const resolvedAlias = accessPath.resolvedAlias();
|
|
100
|
-
const
|
|
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.
|
|
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
|
|
61
|
+
// Derive the approval patterns from the lexical absolute form so they match
|
|
62
62
|
// the policy values a later call produces.
|
|
63
|
-
const
|
|
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.
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
-
//
|
|
122
|
-
const
|
|
123
|
-
?
|
|
124
|
-
:
|
|
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:
|
|
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:
|
|
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
|
|
5
|
-
* the session
|
|
4
|
+
* Derive the wildcard globs to record when a user approves an accessed path
|
|
5
|
+
* for the session.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
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
|
|
26
|
+
export function deriveApprovalPatterns(
|
|
21
27
|
pathValue: string,
|
|
22
28
|
flavor: PathFlavor,
|
|
23
|
-
|
|
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 {
|
|
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
|
|
135
|
-
*
|
|
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
|
|
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
|
-
|
|
144
|
-
return
|
|
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
|
|
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
|
|
84
|
-
* external-directory gate aggregates an ask over many paths and there is
|
|
85
|
-
* pattern that describes them all.
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
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
|
-
|
|
235
|
-
):
|
|
236
|
-
|
|
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
|