@gotgenes/pi-permission-system 4.1.1 → 4.3.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.
- package/CHANGELOG.md +35 -0
- package/README.md +19 -10
- package/package.json +3 -3
- package/src/external-directory.ts +208 -11
- package/src/forwarded-permissions/polling.ts +7 -1
- package/src/handlers/tool-call.ts +37 -1
- package/src/handlers/types.ts +2 -0
- package/src/pattern-suggest.ts +91 -0
- package/src/permission-dialog.ts +16 -2
- package/src/permission-gate.ts +11 -1
- package/src/permission-manager.ts +59 -0
- package/src/runtime.ts +1 -0
- package/tests/bash-external-directory.test.ts +244 -94
- package/tests/handlers/tool-call.test.ts +212 -0
- package/tests/pattern-suggest.test.ts +139 -0
- package/tests/permission-dialog.test.ts +39 -0
- package/tests/permission-gate.test.ts +68 -0
- package/tests/permission-system.test.ts +181 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,41 @@ 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
|
+
## [4.3.0](https://github.com/gotgenes/pi-permission-system/compare/v4.2.0...v4.3.0) (2026-05-04)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add pattern-suggest module for session approval patterns ([0752604](https://github.com/gotgenes/pi-permission-system/commit/0752604ea63a3bcbf4a8d15f6a9760dde4de9b9d))
|
|
14
|
+
* dynamic session approval label in permission dialog ([4737f0d](https://github.com/gotgenes/pi-permission-system/commit/4737f0dbe69b579dca057a149788408cb63e52ec))
|
|
15
|
+
* extend checkPermission session evaluation to all surfaces ([ffc6731](https://github.com/gotgenes/pi-permission-system/commit/ffc67312e09fb0df0c4dbcb572a625b92c3cd018))
|
|
16
|
+
* extend permission gate with sessionApproval pass-through ([a77bad7](https://github.com/gotgenes/pi-permission-system/commit/a77bad7d9193a5500678bf18ac7854da0be2e79f))
|
|
17
|
+
* generalize session approvals to all permission surfaces ([#51](https://github.com/gotgenes/pi-permission-system/issues/51)) ([2fcc2e3](https://github.com/gotgenes/pi-permission-system/commit/2fcc2e37db4f704702fd3d4c64a1388ab417c407))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
* document generalized session approvals ([#51](https://github.com/gotgenes/pi-permission-system/issues/51)) ([233666e](https://github.com/gotgenes/pi-permission-system/commit/233666e496dd81165dec44ef4242bca090750edd))
|
|
23
|
+
* plan generalized session approvals for all surfaces ([#51](https://github.com/gotgenes/pi-permission-system/issues/51)) ([3b40cf9](https://github.com/gotgenes/pi-permission-system/commit/3b40cf954c9f598c7c3c199a4137e897c4fce4b2))
|
|
24
|
+
* **retro:** add retro notes for issue [#74](https://github.com/gotgenes/pi-permission-system/issues/74) ([0eb2ea0](https://github.com/gotgenes/pi-permission-system/commit/0eb2ea001669cba1436d564af9e28ca0ff26c77e))
|
|
25
|
+
|
|
26
|
+
## [4.2.0](https://github.com/gotgenes/pi-permission-system/compare/v4.1.1...v4.2.0) (2026-05-04)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
### Features
|
|
30
|
+
|
|
31
|
+
* replace shell-quote with tree-sitter-bash for AST-based path extraction ([7dce2a4](https://github.com/gotgenes/pi-permission-system/commit/7dce2a4d264d26171a1d54db265f12f3f1d342c6))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
### Documentation
|
|
35
|
+
|
|
36
|
+
* note tree-sitter follow-up addressed by [#74](https://github.com/gotgenes/pi-permission-system/issues/74) ([bd835bd](https://github.com/gotgenes/pi-permission-system/commit/bd835bda62af9aa9149149c24ddb14927d52abf4))
|
|
37
|
+
* note tree-sitter-bash AST parser in architecture docs ([ecec2a6](https://github.com/gotgenes/pi-permission-system/commit/ecec2a6db375434a4bc2f920a21741fe29786896))
|
|
38
|
+
* plan tree-sitter-bash AST-based path extraction ([#74](https://github.com/gotgenes/pi-permission-system/issues/74)) ([1693794](https://github.com/gotgenes/pi-permission-system/commit/1693794fd423eaf872400a2a6dc3b0d0faeba13a))
|
|
39
|
+
* rename current-architecture.md to v3-architecture.md ([38d91c5](https://github.com/gotgenes/pi-permission-system/commit/38d91c587a842caa71b32f379ed5723e73f490f4))
|
|
40
|
+
* **retro:** add retro notes for issue [#73](https://github.com/gotgenes/pi-permission-system/issues/73) ([d73097d](https://github.com/gotgenes/pi-permission-system/commit/d73097d7dcda097fb79b6213b482bac8642f4a90))
|
|
41
|
+
* update bash external-directory description for tree-sitter AST parser ([d022d3d](https://github.com/gotgenes/pi-permission-system/commit/d022d3d87ab0cc0192ba9adbaf3b9bf379dfa414))
|
|
42
|
+
|
|
8
43
|
## [4.1.1](https://github.com/gotgenes/pi-permission-system/compare/v4.1.0...v4.1.1) (2026-05-04)
|
|
9
44
|
|
|
10
45
|
|
package/README.md
CHANGED
|
@@ -91,7 +91,7 @@ The extension integrates via Pi's lifecycle hooks:
|
|
|
91
91
|
- Generic extension-tool approval prompts include a bounded input preview; built-in file tools use concise human-readable summaries instead of raw multiline JSON
|
|
92
92
|
- Permission review logs include bounded `toolInputPreview` values for non-bash/non-MCP tool calls so approvals can be audited without writing raw full payloads
|
|
93
93
|
- Path-bearing file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`) evaluate `permission.external_directory` before their normal tool permission when an explicit path points outside `ctx.cwd`
|
|
94
|
-
- Bash commands are
|
|
94
|
+
- Bash commands are parsed with a full bash AST (`web-tree-sitter` + `tree-sitter-bash`) to extract path-bearing arguments; only genuine command arguments and redirect destinations are checked — heredoc bodies, comments, and quoted string contents are correctly excluded — and paths that resolve outside `ctx.cwd` trigger the same `permission.external_directory` gate before the normal bash pattern check
|
|
95
95
|
|
|
96
96
|
## Configuration
|
|
97
97
|
|
|
@@ -437,20 +437,28 @@ Current agent requested tool 'edit' for '.gitignore' (1 replacement: edit #1 rep
|
|
|
437
437
|
|
|
438
438
|
### Session-Scoped Approvals
|
|
439
439
|
|
|
440
|
-
When
|
|
440
|
+
When any permission resolves to `ask`, the permission dialog offers four options:
|
|
441
441
|
|
|
442
442
|
```text
|
|
443
|
-
Yes | Yes, for this session | No | No, provide reason
|
|
443
|
+
Yes | Yes, allow "<pattern>" for this session | No | No, provide reason
|
|
444
444
|
```
|
|
445
445
|
|
|
446
|
-
Selecting **Yes, for this session** approves the current request and
|
|
447
|
-
|
|
446
|
+
Selecting **Yes, allow "\<pattern\>" for this session** approves the current request and records the suggested wildcard pattern as a session rule.
|
|
447
|
+
Subsequent requests that match the pattern skip the prompt for the remainder of the session.
|
|
448
448
|
|
|
449
|
-
|
|
450
|
-
|
|
449
|
+
The suggested pattern is surface-specific:
|
|
450
|
+
|
|
451
|
+
|Surface|Example request|Suggested session pattern|
|
|
452
|
+
|---|---|---|
|
|
453
|
+
|bash|`git status --short`|`git *`|
|
|
454
|
+
|mcp (qualified)|`exa:search`|`exa:*`|
|
|
455
|
+
|mcp (munged)|`exa_search`|`exa_*`|
|
|
456
|
+
|skill|`librarian`|`librarian`|
|
|
457
|
+
|tool (read, write, …)|`read`|`*`|
|
|
458
|
+
|external_directory|`/other/project/src/foo.ts`|`/other/project/src/*`|
|
|
451
459
|
|
|
452
|
-
|
|
453
|
-
|
|
460
|
+
Session approvals are ephemeral — they are never persisted to disk and are cleared on `session_shutdown`.
|
|
461
|
+
The review log records these decisions: `resolution: "approved_for_session"` when the user approves, and `resolution: "session_approved"` when a later request is matched by an existing session rule.
|
|
454
462
|
|
|
455
463
|
### Subagent Permission Forwarding
|
|
456
464
|
|
|
@@ -496,7 +504,8 @@ This makes it easy to verify which files the extension actually loaded:
|
|
|
496
504
|
index.ts → Root Pi entrypoint shim
|
|
497
505
|
src/
|
|
498
506
|
├── index.ts → Extension bootstrap, permission checks, readable prompts, review logging, reload handling, and subagent forwarding
|
|
499
|
-
├──
|
|
507
|
+
├── pattern-suggest.ts → Per-surface session approval pattern suggestions
|
|
508
|
+
├── session-rules.ts → Ephemeral session-scoped approval rules (Ruleset-based, wildcard patterns across all surfaces)
|
|
500
509
|
├── config-loader.ts → Unified config loader, merger, and legacy-path detection
|
|
501
510
|
├── config-paths.ts → Path derivation for global, project, and legacy config locations
|
|
502
511
|
├── config-reporter.ts → Resolved config path reporting for diagnostic logs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotgenes/pi-permission-system",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -56,13 +56,13 @@
|
|
|
56
56
|
"@mariozechner/pi-coding-agent": "^0.72.1",
|
|
57
57
|
"@mariozechner/pi-tui": "^0.72.1",
|
|
58
58
|
"@types/node": "^25.6.0",
|
|
59
|
-
"@types/shell-quote": "^1.7.5",
|
|
60
59
|
"markdownlint-cli2": "^0.22.1",
|
|
61
60
|
"typescript": "6.0.3",
|
|
62
61
|
"vitest": "^4.1.5"
|
|
63
62
|
},
|
|
64
63
|
"dependencies": {
|
|
65
|
-
"
|
|
64
|
+
"tree-sitter-bash": "^0.25.1",
|
|
65
|
+
"web-tree-sitter": "^0.26.8"
|
|
66
66
|
},
|
|
67
67
|
"scripts": {
|
|
68
68
|
"build": "tsc -p tsconfig.json",
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import { homedir } from "node:os";
|
|
2
3
|
import { join, normalize, resolve, sep } from "node:path";
|
|
3
4
|
|
|
4
|
-
import { parse } from "shell-quote";
|
|
5
|
-
|
|
6
5
|
import { getNonEmptyString, toRecord } from "./common";
|
|
7
6
|
|
|
8
7
|
/**
|
|
@@ -157,6 +156,197 @@ export function formatBashExternalDirectoryDenyReason(
|
|
|
157
156
|
return `${subject} is not permitted to run bash command '${command}' which references path(s) outside working directory '${cwd}': ${pathList}. ${formatExternalDirectoryHardStopHint()}`;
|
|
158
157
|
}
|
|
159
158
|
|
|
159
|
+
// ── tree-sitter-bash lazy parser ───────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Minimal subset of web-tree-sitter's SyntaxNode used by the AST walker.
|
|
163
|
+
* Defined locally so callers do not need to import web-tree-sitter types.
|
|
164
|
+
*/
|
|
165
|
+
interface TSNode {
|
|
166
|
+
readonly type: string;
|
|
167
|
+
readonly text: string;
|
|
168
|
+
readonly childCount: number;
|
|
169
|
+
child(index: number): TSNode | null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Minimal subset of web-tree-sitter's Parser used by this module.
|
|
174
|
+
*/
|
|
175
|
+
interface TSParser {
|
|
176
|
+
parse(input: string): { rootNode: TSNode; delete(): void } | null;
|
|
177
|
+
delete(): void;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let parserPromise: Promise<TSParser> | null = null;
|
|
181
|
+
|
|
182
|
+
async function initParser(): Promise<TSParser> {
|
|
183
|
+
// Use named imports — web-tree-sitter exports Parser as a named class.
|
|
184
|
+
const { Parser, Language } = await import("web-tree-sitter");
|
|
185
|
+
const req = createRequire(import.meta.url);
|
|
186
|
+
const treeSitterWasm = req.resolve("web-tree-sitter/web-tree-sitter.wasm");
|
|
187
|
+
await Parser.init({ locateFile: () => treeSitterWasm });
|
|
188
|
+
|
|
189
|
+
const parser = new Parser();
|
|
190
|
+
const bashWasm = req.resolve("tree-sitter-bash/tree-sitter-bash.wasm");
|
|
191
|
+
const bash = await Language.load(bashWasm);
|
|
192
|
+
parser.setLanguage(bash);
|
|
193
|
+
return parser as TSParser;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function getParser(): Promise<TSParser> {
|
|
197
|
+
if (!parserPromise) {
|
|
198
|
+
parserPromise = initParser();
|
|
199
|
+
}
|
|
200
|
+
return parserPromise;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Reset the cached parser promise. Only used by tests to avoid
|
|
205
|
+
* cross-test pollution or to inject a mock parser.
|
|
206
|
+
*/
|
|
207
|
+
export function resetParserForTesting(): void {
|
|
208
|
+
parserPromise = null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── AST walker ─────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Node types whose subtrees must never be descended into for
|
|
215
|
+
* path extraction — their text content is not a command argument.
|
|
216
|
+
*/
|
|
217
|
+
const SKIP_SUBTREE_TYPES = new Set(["heredoc_body", "heredoc_end", "comment"]);
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Resolve the "shell value" of an argument node — the string the shell
|
|
221
|
+
* would pass to the command after quote removal.
|
|
222
|
+
*
|
|
223
|
+
* - `word` → `.text` (already unquoted)
|
|
224
|
+
* - `raw_string` → strip surrounding single quotes
|
|
225
|
+
* - `string` → strip surrounding double quotes, concatenate children text
|
|
226
|
+
* - `concatenation` → concatenate resolved children
|
|
227
|
+
* - other → `.text` as fallback
|
|
228
|
+
*/
|
|
229
|
+
function resolveNodeText(node: TSNode): string {
|
|
230
|
+
switch (node.type) {
|
|
231
|
+
case "word":
|
|
232
|
+
return node.text;
|
|
233
|
+
case "raw_string": {
|
|
234
|
+
// Strip surrounding single quotes: 'content' → content
|
|
235
|
+
const t = node.text;
|
|
236
|
+
if (t.length >= 2 && t[0] === "'" && t[t.length - 1] === "'") {
|
|
237
|
+
return t.slice(1, -1);
|
|
238
|
+
}
|
|
239
|
+
return t;
|
|
240
|
+
}
|
|
241
|
+
case "string": {
|
|
242
|
+
// Double-quoted string: concatenate the resolved text of inner children,
|
|
243
|
+
// skipping the quote-delimiter nodes (literal `"`).
|
|
244
|
+
let result = "";
|
|
245
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
246
|
+
const child = node.child(i);
|
|
247
|
+
if (!child) continue;
|
|
248
|
+
// Skip the literal `"` delimiters
|
|
249
|
+
if (child.type === '"') continue;
|
|
250
|
+
result += resolveNodeText(child);
|
|
251
|
+
}
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
case "string_content":
|
|
255
|
+
case "simple_expansion":
|
|
256
|
+
case "expansion":
|
|
257
|
+
return node.text;
|
|
258
|
+
case "concatenation": {
|
|
259
|
+
let result = "";
|
|
260
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
261
|
+
const child = node.child(i);
|
|
262
|
+
if (!child) continue;
|
|
263
|
+
result += resolveNodeText(child);
|
|
264
|
+
}
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
default:
|
|
268
|
+
return node.text;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Recursively visit the AST and collect resolved text of nodes that
|
|
274
|
+
* represent command arguments or redirect destinations.
|
|
275
|
+
*
|
|
276
|
+
* Skips `heredoc_body`, `heredoc_end`, and `comment` subtrees entirely.
|
|
277
|
+
*/
|
|
278
|
+
function collectPathCandidateTokens(node: TSNode, tokens: string[]): void {
|
|
279
|
+
if (SKIP_SUBTREE_TYPES.has(node.type)) return;
|
|
280
|
+
|
|
281
|
+
// Extract arguments from `command` nodes (skip the command name).
|
|
282
|
+
if (node.type === "command") {
|
|
283
|
+
let seenCommandName = false;
|
|
284
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
285
|
+
const child = node.child(i);
|
|
286
|
+
if (!child) continue;
|
|
287
|
+
|
|
288
|
+
if (child.type === "command_name") {
|
|
289
|
+
seenCommandName = true;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
// Skip variable_assignment nodes (FOO=/bar)
|
|
293
|
+
if (child.type === "variable_assignment") continue;
|
|
294
|
+
|
|
295
|
+
// If there was no explicit command_name node, the first word-like
|
|
296
|
+
// child is the command name itself — skip it.
|
|
297
|
+
if (
|
|
298
|
+
!seenCommandName &&
|
|
299
|
+
(child.type === "word" ||
|
|
300
|
+
child.type === "concatenation" ||
|
|
301
|
+
child.type === "string" ||
|
|
302
|
+
child.type === "raw_string")
|
|
303
|
+
) {
|
|
304
|
+
seenCommandName = true;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Argument nodes: resolve their text and collect.
|
|
309
|
+
if (
|
|
310
|
+
child.type === "word" ||
|
|
311
|
+
child.type === "concatenation" ||
|
|
312
|
+
child.type === "string" ||
|
|
313
|
+
child.type === "raw_string"
|
|
314
|
+
) {
|
|
315
|
+
tokens.push(resolveNodeText(child));
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Recurse into other children (e.g. command_substitution nested in args)
|
|
320
|
+
collectPathCandidateTokens(child, tokens);
|
|
321
|
+
}
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Extract redirect destinations from `file_redirect` nodes.
|
|
326
|
+
if (node.type === "file_redirect") {
|
|
327
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
328
|
+
const child = node.child(i);
|
|
329
|
+
if (!child) continue;
|
|
330
|
+
if (
|
|
331
|
+
child.type === "word" ||
|
|
332
|
+
child.type === "concatenation" ||
|
|
333
|
+
child.type === "string" ||
|
|
334
|
+
child.type === "raw_string"
|
|
335
|
+
) {
|
|
336
|
+
tokens.push(resolveNodeText(child));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// For all other node types, recurse into children.
|
|
343
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
344
|
+
const child = node.child(i);
|
|
345
|
+
if (!child) continue;
|
|
346
|
+
collectPathCandidateTokens(child, tokens);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
160
350
|
/**
|
|
161
351
|
* URL pattern to skip tokens that look like URLs rather than paths.
|
|
162
352
|
*/
|
|
@@ -200,18 +390,25 @@ function classifyTokenAsPathCandidate(token: string): string | null {
|
|
|
200
390
|
|
|
201
391
|
/**
|
|
202
392
|
* Extracts paths from a bash command string that resolve outside CWD.
|
|
203
|
-
* Uses
|
|
204
|
-
*
|
|
393
|
+
* Uses tree-sitter-bash to parse the command into a full AST, then walks
|
|
394
|
+
* command argument and redirect-destination nodes. Heredoc bodies, comments,
|
|
395
|
+
* and other non-argument content are skipped, eliminating false positives.
|
|
205
396
|
*/
|
|
206
|
-
export function extractExternalPathsFromBashCommand(
|
|
397
|
+
export async function extractExternalPathsFromBashCommand(
|
|
207
398
|
command: string,
|
|
208
399
|
cwd: string,
|
|
209
|
-
): string[] {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
400
|
+
): Promise<string[]> {
|
|
401
|
+
const parser = await getParser();
|
|
402
|
+
const tree = parser.parse(command);
|
|
403
|
+
if (!tree) return [];
|
|
404
|
+
|
|
405
|
+
const tokens: string[] = [];
|
|
406
|
+
try {
|
|
407
|
+
collectPathCandidateTokens(tree.rootNode, tokens);
|
|
408
|
+
} finally {
|
|
409
|
+
tree.delete();
|
|
410
|
+
}
|
|
411
|
+
|
|
215
412
|
const seen = new Set<string>();
|
|
216
413
|
const externalPaths: string[] = [];
|
|
217
414
|
|
|
@@ -7,7 +7,10 @@ import {
|
|
|
7
7
|
getActiveAgentNameFromSystemPrompt,
|
|
8
8
|
} from "../active-agent";
|
|
9
9
|
import { toRecord } from "../common";
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
PermissionPromptDecision,
|
|
12
|
+
RequestPermissionOptions,
|
|
13
|
+
} from "../permission-dialog";
|
|
11
14
|
import {
|
|
12
15
|
type ForwardedPermissionRequest,
|
|
13
16
|
type ForwardedPermissionResponse,
|
|
@@ -42,6 +45,7 @@ export interface PermissionForwardingDeps {
|
|
|
42
45
|
ui: ExtensionContext["ui"],
|
|
43
46
|
title: string,
|
|
44
47
|
message: string,
|
|
48
|
+
options?: RequestPermissionOptions,
|
|
45
49
|
) => Promise<PermissionPromptDecision>;
|
|
46
50
|
shouldAutoApprove: () => boolean;
|
|
47
51
|
}
|
|
@@ -339,12 +343,14 @@ export async function confirmPermission(
|
|
|
339
343
|
ctx: ExtensionContext,
|
|
340
344
|
message: string,
|
|
341
345
|
deps: PermissionForwardingDeps,
|
|
346
|
+
options?: RequestPermissionOptions,
|
|
342
347
|
): Promise<PermissionPromptDecision> {
|
|
343
348
|
if (ctx.hasUI) {
|
|
344
349
|
return deps.requestPermissionDecisionFromUi(
|
|
345
350
|
ctx.ui,
|
|
346
351
|
"Permission Required",
|
|
347
352
|
message,
|
|
353
|
+
options,
|
|
348
354
|
);
|
|
349
355
|
}
|
|
350
356
|
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
normalizePathForComparison,
|
|
19
19
|
PATH_BEARING_TOOLS,
|
|
20
20
|
} from "../external-directory";
|
|
21
|
+
import { suggestSessionPattern } from "../pattern-suggest";
|
|
21
22
|
import type { PermissionPromptDecision } from "../permission-dialog";
|
|
22
23
|
import { applyPermissionGate } from "../permission-gate";
|
|
23
24
|
import {
|
|
@@ -252,7 +253,7 @@ export async function handleToolCall(
|
|
|
252
253
|
if (ctx.cwd && toolName === "bash") {
|
|
253
254
|
const command = getNonEmptyString(toRecord(input).command);
|
|
254
255
|
if (command) {
|
|
255
|
-
const externalPaths = extractExternalPathsFromBashCommand(
|
|
256
|
+
const externalPaths = await extractExternalPathsFromBashCommand(
|
|
256
257
|
command,
|
|
257
258
|
ctx.cwd,
|
|
258
259
|
);
|
|
@@ -359,12 +360,35 @@ export async function handleToolCall(
|
|
|
359
360
|
agentName ?? undefined,
|
|
360
361
|
deps.runtime.sessionRules.getRuleset(),
|
|
361
362
|
);
|
|
363
|
+
|
|
364
|
+
// Session-hit: already approved by a session rule — skip the gate entirely.
|
|
365
|
+
if (check.source === "session") {
|
|
366
|
+
deps.runtime.writeReviewLog("permission_request.session_approved", {
|
|
367
|
+
source: "tool_call",
|
|
368
|
+
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
369
|
+
toolName,
|
|
370
|
+
agentName,
|
|
371
|
+
resolution: "session_approved",
|
|
372
|
+
sessionApprovalPattern: check.matchedPattern,
|
|
373
|
+
});
|
|
374
|
+
return {};
|
|
375
|
+
}
|
|
376
|
+
|
|
362
377
|
const permissionLogContext = getPermissionLogContext(
|
|
363
378
|
check,
|
|
364
379
|
input,
|
|
365
380
|
PATH_BEARING_TOOLS,
|
|
366
381
|
);
|
|
367
382
|
|
|
383
|
+
// Compute session approval suggestion for the "for this session" option.
|
|
384
|
+
const suggestionValue =
|
|
385
|
+
toolName === "bash"
|
|
386
|
+
? (check.command ?? "")
|
|
387
|
+
: toolName === "mcp"
|
|
388
|
+
? (check.target ?? "mcp")
|
|
389
|
+
: "*";
|
|
390
|
+
const suggestion = suggestSessionPattern(toolName, suggestionValue);
|
|
391
|
+
|
|
368
392
|
const toolUnavailableReason =
|
|
369
393
|
toolName === "bash" && isToolCallEventType("bash", event as ToolCallEvent)
|
|
370
394
|
? `Running bash command '${(event as ToolCallEvent & { input: { command: string } }).input.command}' requires approval, but no interactive UI is available.`
|
|
@@ -376,6 +400,10 @@ export async function handleToolCall(
|
|
|
376
400
|
const toolGate = await applyPermissionGate({
|
|
377
401
|
state: check.state,
|
|
378
402
|
canConfirm: deps.canRequestPermissionConfirmation(ctx),
|
|
403
|
+
sessionApproval: {
|
|
404
|
+
surface: suggestion.surface,
|
|
405
|
+
pattern: suggestion.pattern,
|
|
406
|
+
},
|
|
379
407
|
promptForApproval: () =>
|
|
380
408
|
deps.promptPermission(ctx, {
|
|
381
409
|
requestId: (event as { toolCallId: string }).toolCallId,
|
|
@@ -384,6 +412,7 @@ export async function handleToolCall(
|
|
|
384
412
|
message: toolAskMessage,
|
|
385
413
|
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
386
414
|
toolName,
|
|
415
|
+
sessionLabel: suggestion.label,
|
|
387
416
|
...permissionLogContext,
|
|
388
417
|
}),
|
|
389
418
|
writeLog: deps.runtime.writeReviewLog,
|
|
@@ -407,5 +436,12 @@ export async function handleToolCall(
|
|
|
407
436
|
return { block: true, reason: toolGate.reason };
|
|
408
437
|
}
|
|
409
438
|
|
|
439
|
+
if (toolGate.sessionApproval) {
|
|
440
|
+
deps.runtime.sessionRules.approve(
|
|
441
|
+
toolGate.sessionApproval.surface,
|
|
442
|
+
toolGate.sessionApproval.pattern,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
410
446
|
return {};
|
|
411
447
|
}
|
package/src/handlers/types.ts
CHANGED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { deriveApprovalPattern } from "./session-rules";
|
|
2
|
+
|
|
3
|
+
/** The suggestion returned for a "Yes, for this session" dialog option. */
|
|
4
|
+
export interface SessionApprovalSuggestion {
|
|
5
|
+
/** The permission surface this approval applies to. */
|
|
6
|
+
surface: string;
|
|
7
|
+
/** The wildcard pattern to store as a session rule. */
|
|
8
|
+
pattern: string;
|
|
9
|
+
/** Human-readable label for the "for session" dialog option. */
|
|
10
|
+
label: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Suggest a bash session-approval pattern from a command string.
|
|
15
|
+
*
|
|
16
|
+
* Heuristic: split on the first space to get the base command.
|
|
17
|
+
* Multi-word commands → `<command> *`.
|
|
18
|
+
* Single-word commands → exact command (no wildcard).
|
|
19
|
+
*
|
|
20
|
+
* This is intentionally conservative. The arity table (#52) will refine
|
|
21
|
+
* suggestions later (e.g. `git checkout *` instead of `git *`).
|
|
22
|
+
*/
|
|
23
|
+
export function suggestBashPattern(command: string): string {
|
|
24
|
+
const trimmed = command.trim();
|
|
25
|
+
const spaceIndex = trimmed.indexOf(" ");
|
|
26
|
+
if (spaceIndex === -1) {
|
|
27
|
+
return trimmed;
|
|
28
|
+
}
|
|
29
|
+
return `${trimmed.slice(0, spaceIndex)} *`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Suggest an MCP session-approval pattern from a resolved target string.
|
|
34
|
+
*
|
|
35
|
+
* - Qualified target (`server:tool`) → `server:*`
|
|
36
|
+
* - Munged target (`server_tool`) → `server_*`
|
|
37
|
+
* - Bare target (no separator) → `*`
|
|
38
|
+
*/
|
|
39
|
+
export function suggestMcpPattern(target: string): string {
|
|
40
|
+
const trimmed = target.trim();
|
|
41
|
+
|
|
42
|
+
const colonIndex = trimmed.indexOf(":");
|
|
43
|
+
if (colonIndex > 0) {
|
|
44
|
+
return `${trimmed.slice(0, colonIndex)}:*`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const underscoreIndex = trimmed.indexOf("_");
|
|
48
|
+
if (underscoreIndex > 0) {
|
|
49
|
+
return `${trimmed.slice(0, underscoreIndex)}_*`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return "*";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildLabel(pattern: string): string {
|
|
56
|
+
return `Yes, allow "${pattern}" for this session`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Suggest a session-approval pattern for the given permission surface and value.
|
|
61
|
+
*
|
|
62
|
+
* Returns a `SessionApprovalSuggestion` with the surface, the wildcard pattern
|
|
63
|
+
* to store in `SessionRules`, and a human-readable dialog label.
|
|
64
|
+
*/
|
|
65
|
+
export function suggestSessionPattern(
|
|
66
|
+
surface: string,
|
|
67
|
+
value: string,
|
|
68
|
+
): SessionApprovalSuggestion {
|
|
69
|
+
let pattern: string;
|
|
70
|
+
|
|
71
|
+
switch (surface) {
|
|
72
|
+
case "bash":
|
|
73
|
+
pattern = suggestBashPattern(value);
|
|
74
|
+
break;
|
|
75
|
+
case "mcp":
|
|
76
|
+
pattern = suggestMcpPattern(value);
|
|
77
|
+
break;
|
|
78
|
+
case "skill":
|
|
79
|
+
pattern = value;
|
|
80
|
+
break;
|
|
81
|
+
case "external_directory":
|
|
82
|
+
pattern = deriveApprovalPattern(value);
|
|
83
|
+
break;
|
|
84
|
+
default:
|
|
85
|
+
// Tool surfaces (read, write, edit, grep, find, ls, extension tools)
|
|
86
|
+
pattern = "*";
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { surface, pattern, label: buildLabel(pattern) };
|
|
91
|
+
}
|
package/src/permission-dialog.ts
CHANGED
|
@@ -64,13 +64,27 @@ export function isPermissionDecisionState(
|
|
|
64
64
|
);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
export interface RequestPermissionOptions {
|
|
68
|
+
/** Override the "for this session" option label (e.g. to show the suggested pattern). */
|
|
69
|
+
sessionLabel?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
67
72
|
export async function requestPermissionDecisionFromUi(
|
|
68
73
|
ui: PermissionDecisionUi,
|
|
69
74
|
title: string,
|
|
70
75
|
message: string,
|
|
76
|
+
options?: RequestPermissionOptions,
|
|
71
77
|
): Promise<PermissionPromptDecision> {
|
|
78
|
+
const sessionOption = options?.sessionLabel ?? APPROVE_FOR_SESSION_OPTION;
|
|
79
|
+
const decisionOptions = [
|
|
80
|
+
APPROVE_OPTION,
|
|
81
|
+
sessionOption,
|
|
82
|
+
DENY_OPTION,
|
|
83
|
+
DENY_WITH_REASON_OPTION,
|
|
84
|
+
] as const;
|
|
85
|
+
|
|
72
86
|
const selected = await ui.select(`${title}\n${message}`, [
|
|
73
|
-
...
|
|
87
|
+
...decisionOptions,
|
|
74
88
|
]);
|
|
75
89
|
|
|
76
90
|
if (selected === APPROVE_OPTION) {
|
|
@@ -80,7 +94,7 @@ export async function requestPermissionDecisionFromUi(
|
|
|
80
94
|
};
|
|
81
95
|
}
|
|
82
96
|
|
|
83
|
-
if (selected ===
|
|
97
|
+
if (selected === sessionOption) {
|
|
84
98
|
return {
|
|
85
99
|
approved: true,
|
|
86
100
|
state: "approved_for_session",
|
package/src/permission-gate.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { PermissionPromptDecision } from "./permission-dialog";
|
|
|
2
2
|
|
|
3
3
|
/** Result of applying the permission gate. */
|
|
4
4
|
export type PermissionGateResult =
|
|
5
|
-
| { action: "allow" }
|
|
5
|
+
| { action: "allow"; sessionApproval?: { surface: string; pattern: string } }
|
|
6
6
|
| { action: "block"; reason: string };
|
|
7
7
|
|
|
8
8
|
/** Everything the gate needs — no direct dependency on ExtensionContext. */
|
|
@@ -16,6 +16,13 @@ export interface PermissionGateParams {
|
|
|
16
16
|
/** Prompt the user for approval. Only called when state === "ask" and canConfirm is true. */
|
|
17
17
|
promptForApproval: () => Promise<PermissionPromptDecision>;
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Session approval suggestion to record when the user selects
|
|
21
|
+
* "for this session". When present and the decision is `approved_for_session`,
|
|
22
|
+
* the result carries the suggestion back to the caller for recording.
|
|
23
|
+
*/
|
|
24
|
+
sessionApproval?: { surface: string; pattern: string };
|
|
25
|
+
|
|
19
26
|
/** Write a review-log entry. Called for deny and ask-but-unavailable paths. */
|
|
20
27
|
writeLog: (event: string, extra: Record<string, unknown>) => void;
|
|
21
28
|
|
|
@@ -68,6 +75,9 @@ export async function applyPermissionGate(
|
|
|
68
75
|
if (!decision.approved) {
|
|
69
76
|
return { action: "block", reason: messages.userDeniedReason(decision) };
|
|
70
77
|
}
|
|
78
|
+
if (decision.state === "approved_for_session" && params.sessionApproval) {
|
|
79
|
+
return { action: "allow", sessionApproval: params.sessionApproval };
|
|
80
|
+
}
|
|
71
81
|
}
|
|
72
82
|
|
|
73
83
|
return { action: "allow" };
|