@gotgenes/pi-permission-system 4.2.0 → 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 +18 -0
- package/README.md +18 -9
- package/package.json +1 -1
- package/src/forwarded-permissions/polling.ts +7 -1
- package/src/handlers/tool-call.ts +36 -0
- 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/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,24 @@ 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
|
+
|
|
8
26
|
## [4.2.0](https://github.com/gotgenes/pi-permission-system/compare/v4.1.1...v4.2.0) (2026-05-04)
|
|
9
27
|
|
|
10
28
|
|
package/README.md
CHANGED
|
@@ -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
|
@@ -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 {
|
|
@@ -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" };
|
|
@@ -493,6 +493,20 @@ export class PermissionManager {
|
|
|
493
493
|
if (normalizedToolName === "skill") {
|
|
494
494
|
const skillName = toRecord(input).name;
|
|
495
495
|
const lookupValue = typeof skillName === "string" ? skillName : "*";
|
|
496
|
+
|
|
497
|
+
// Session check.
|
|
498
|
+
if (sessionRules && sessionRules.length > 0) {
|
|
499
|
+
const sessionRule = evaluate("skill", lookupValue, sessionRules);
|
|
500
|
+
if (sessionRules.includes(sessionRule)) {
|
|
501
|
+
return {
|
|
502
|
+
toolName,
|
|
503
|
+
state: "allow",
|
|
504
|
+
matchedPattern: sessionRule.pattern,
|
|
505
|
+
source: "session",
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
496
510
|
const rule = evaluate("skill", lookupValue, composedRules);
|
|
497
511
|
return {
|
|
498
512
|
toolName,
|
|
@@ -506,6 +520,21 @@ export class PermissionManager {
|
|
|
506
520
|
if (normalizedToolName === "bash") {
|
|
507
521
|
const record = toRecord(input);
|
|
508
522
|
const command = typeof record.command === "string" ? record.command : "";
|
|
523
|
+
|
|
524
|
+
// Session check.
|
|
525
|
+
if (sessionRules && sessionRules.length > 0) {
|
|
526
|
+
const sessionRule = evaluate("bash", command, sessionRules);
|
|
527
|
+
if (sessionRules.includes(sessionRule)) {
|
|
528
|
+
return {
|
|
529
|
+
toolName,
|
|
530
|
+
state: "allow",
|
|
531
|
+
command,
|
|
532
|
+
matchedPattern: sessionRule.pattern,
|
|
533
|
+
source: "session",
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
509
538
|
const rule = evaluate("bash", command, composedRules);
|
|
510
539
|
return {
|
|
511
540
|
toolName,
|
|
@@ -527,6 +556,22 @@ export class PermissionManager {
|
|
|
527
556
|
];
|
|
528
557
|
const fallbackTarget = mcpTargets[0] || "mcp";
|
|
529
558
|
|
|
559
|
+
// Session check: try each candidate target against session rules.
|
|
560
|
+
if (sessionRules && sessionRules.length > 0) {
|
|
561
|
+
for (const target of mcpTargets) {
|
|
562
|
+
const sessionRule = evaluate("mcp", target, sessionRules);
|
|
563
|
+
if (sessionRules.includes(sessionRule)) {
|
|
564
|
+
return {
|
|
565
|
+
toolName,
|
|
566
|
+
state: "allow",
|
|
567
|
+
matchedPattern: sessionRule.pattern,
|
|
568
|
+
target,
|
|
569
|
+
source: "session",
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
530
575
|
// Try each candidate target. Stop on the first non-default match.
|
|
531
576
|
for (const target of mcpTargets) {
|
|
532
577
|
const rule = evaluate("mcp", target, composedRules);
|
|
@@ -552,6 +597,20 @@ export class PermissionManager {
|
|
|
552
597
|
}
|
|
553
598
|
|
|
554
599
|
// --- Tools (read, write, edit, grep, find, ls, extension tools) ---
|
|
600
|
+
|
|
601
|
+
// Session check.
|
|
602
|
+
if (sessionRules && sessionRules.length > 0) {
|
|
603
|
+
const sessionRule = evaluate(normalizedToolName, "*", sessionRules);
|
|
604
|
+
if (sessionRules.includes(sessionRule)) {
|
|
605
|
+
return {
|
|
606
|
+
toolName,
|
|
607
|
+
state: "allow",
|
|
608
|
+
matchedPattern: sessionRule.pattern,
|
|
609
|
+
source: "session",
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
555
614
|
const rule = evaluate(normalizedToolName, "*", composedRules);
|
|
556
615
|
|
|
557
616
|
if (BUILT_IN_TOOL_PERMISSION_NAMES.has(normalizedToolName)) {
|
package/src/runtime.ts
CHANGED
|
@@ -447,3 +447,215 @@ describe("handleToolCall — bash external-directory gate", () => {
|
|
|
447
447
|
expect(result).toEqual({});
|
|
448
448
|
});
|
|
449
449
|
});
|
|
450
|
+
|
|
451
|
+
// ── session approval ───────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
describe("handleToolCall — session-hit detection (normal gate)", () => {
|
|
454
|
+
it("skips gate and logs session_approved when bash check returns source=session", async () => {
|
|
455
|
+
const sessionRules = {
|
|
456
|
+
approve: vi.fn(),
|
|
457
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
458
|
+
clear: vi.fn(),
|
|
459
|
+
} as unknown as ExtensionRuntime["sessionRules"];
|
|
460
|
+
const deps = makeDeps({
|
|
461
|
+
runtime: makeRuntime({
|
|
462
|
+
permissionManager: {
|
|
463
|
+
checkPermission: vi.fn().mockReturnValue({
|
|
464
|
+
state: "allow",
|
|
465
|
+
toolName: "bash",
|
|
466
|
+
source: "session",
|
|
467
|
+
command: "git status",
|
|
468
|
+
matchedPattern: "git *",
|
|
469
|
+
}),
|
|
470
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
471
|
+
sessionRules,
|
|
472
|
+
}),
|
|
473
|
+
});
|
|
474
|
+
const event = makeToolCallEvent("bash", {
|
|
475
|
+
input: { command: "git status" },
|
|
476
|
+
});
|
|
477
|
+
const result = await handleToolCall(deps, event, makeCtx());
|
|
478
|
+
expect(result).toEqual({});
|
|
479
|
+
expect(deps.runtime.writeReviewLog).toHaveBeenCalledWith(
|
|
480
|
+
"permission_request.session_approved",
|
|
481
|
+
expect.objectContaining({
|
|
482
|
+
resolution: "session_approved",
|
|
483
|
+
toolName: "bash",
|
|
484
|
+
}),
|
|
485
|
+
);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("skips gate and logs session_approved when mcp check returns source=session", async () => {
|
|
489
|
+
const deps = makeDeps({
|
|
490
|
+
runtime: makeRuntime({
|
|
491
|
+
permissionManager: {
|
|
492
|
+
checkPermission: vi.fn().mockReturnValue({
|
|
493
|
+
state: "allow",
|
|
494
|
+
toolName: "mcp",
|
|
495
|
+
source: "session",
|
|
496
|
+
target: "exa:search",
|
|
497
|
+
matchedPattern: "exa:*",
|
|
498
|
+
}),
|
|
499
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
500
|
+
}),
|
|
501
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "mcp" }]),
|
|
502
|
+
});
|
|
503
|
+
const event = makeToolCallEvent("mcp", { input: { tool: "exa:search" } });
|
|
504
|
+
const result = await handleToolCall(deps, event, makeCtx());
|
|
505
|
+
expect(result).toEqual({});
|
|
506
|
+
expect(deps.runtime.writeReviewLog).toHaveBeenCalledWith(
|
|
507
|
+
"permission_request.session_approved",
|
|
508
|
+
expect.objectContaining({
|
|
509
|
+
resolution: "session_approved",
|
|
510
|
+
toolName: "mcp",
|
|
511
|
+
}),
|
|
512
|
+
);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("does NOT call sessionRules.approve when source is session (already recorded)", async () => {
|
|
516
|
+
const sessionRules = {
|
|
517
|
+
approve: vi.fn(),
|
|
518
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
519
|
+
clear: vi.fn(),
|
|
520
|
+
} as unknown as ExtensionRuntime["sessionRules"];
|
|
521
|
+
const deps = makeDeps({
|
|
522
|
+
runtime: makeRuntime({
|
|
523
|
+
permissionManager: {
|
|
524
|
+
checkPermission: vi.fn().mockReturnValue({
|
|
525
|
+
state: "allow",
|
|
526
|
+
toolName: "bash",
|
|
527
|
+
source: "session",
|
|
528
|
+
command: "git status",
|
|
529
|
+
matchedPattern: "git *",
|
|
530
|
+
}),
|
|
531
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
532
|
+
sessionRules,
|
|
533
|
+
}),
|
|
534
|
+
});
|
|
535
|
+
const event = makeToolCallEvent("bash", {
|
|
536
|
+
input: { command: "git status" },
|
|
537
|
+
});
|
|
538
|
+
await handleToolCall(deps, event, makeCtx());
|
|
539
|
+
expect(sessionRules.approve).not.toHaveBeenCalled();
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
describe("handleToolCall — session recording on approved_for_session", () => {
|
|
544
|
+
it("records bash session approval with suggestBashPattern result", async () => {
|
|
545
|
+
const sessionRules = {
|
|
546
|
+
approve: vi.fn(),
|
|
547
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
548
|
+
clear: vi.fn(),
|
|
549
|
+
} as unknown as ExtensionRuntime["sessionRules"];
|
|
550
|
+
const deps = makeDeps({
|
|
551
|
+
runtime: makeRuntime({
|
|
552
|
+
permissionManager: {
|
|
553
|
+
checkPermission: vi.fn().mockReturnValue({
|
|
554
|
+
state: "ask",
|
|
555
|
+
toolName: "bash",
|
|
556
|
+
source: "bash",
|
|
557
|
+
command: "git status",
|
|
558
|
+
}),
|
|
559
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
560
|
+
sessionRules,
|
|
561
|
+
}),
|
|
562
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
563
|
+
promptPermission: vi
|
|
564
|
+
.fn()
|
|
565
|
+
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
566
|
+
});
|
|
567
|
+
const event = makeToolCallEvent("bash", {
|
|
568
|
+
input: { command: "git status" },
|
|
569
|
+
});
|
|
570
|
+
await handleToolCall(deps, event, makeCtx());
|
|
571
|
+
expect(sessionRules.approve).toHaveBeenCalledWith("bash", "git *");
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("records mcp session approval with suggestMcpPattern result", async () => {
|
|
575
|
+
const sessionRules = {
|
|
576
|
+
approve: vi.fn(),
|
|
577
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
578
|
+
clear: vi.fn(),
|
|
579
|
+
} as unknown as ExtensionRuntime["sessionRules"];
|
|
580
|
+
const deps = makeDeps({
|
|
581
|
+
runtime: makeRuntime({
|
|
582
|
+
permissionManager: {
|
|
583
|
+
checkPermission: vi.fn().mockReturnValue({
|
|
584
|
+
state: "ask",
|
|
585
|
+
toolName: "mcp",
|
|
586
|
+
source: "mcp",
|
|
587
|
+
target: "exa:search",
|
|
588
|
+
}),
|
|
589
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
590
|
+
sessionRules,
|
|
591
|
+
}),
|
|
592
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "mcp" }]),
|
|
593
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
594
|
+
promptPermission: vi
|
|
595
|
+
.fn()
|
|
596
|
+
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
597
|
+
});
|
|
598
|
+
const event = makeToolCallEvent("mcp", { input: { tool: "exa:search" } });
|
|
599
|
+
await handleToolCall(deps, event, makeCtx());
|
|
600
|
+
expect(sessionRules.approve).toHaveBeenCalledWith("mcp", "exa:*");
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("records tool session approval with * pattern for read surface", async () => {
|
|
604
|
+
const sessionRules = {
|
|
605
|
+
approve: vi.fn(),
|
|
606
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
607
|
+
clear: vi.fn(),
|
|
608
|
+
} as unknown as ExtensionRuntime["sessionRules"];
|
|
609
|
+
const deps = makeDeps({
|
|
610
|
+
runtime: makeRuntime({
|
|
611
|
+
permissionManager: {
|
|
612
|
+
checkPermission: vi.fn().mockReturnValue({
|
|
613
|
+
state: "ask",
|
|
614
|
+
toolName: "read",
|
|
615
|
+
source: "tool",
|
|
616
|
+
}),
|
|
617
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
618
|
+
sessionRules,
|
|
619
|
+
}),
|
|
620
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
621
|
+
promptPermission: vi
|
|
622
|
+
.fn()
|
|
623
|
+
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
624
|
+
});
|
|
625
|
+
const event = makeToolCallEvent("read", {
|
|
626
|
+
input: { path: "/test/project/foo.ts" },
|
|
627
|
+
});
|
|
628
|
+
await handleToolCall(deps, event, makeCtx());
|
|
629
|
+
expect(sessionRules.approve).toHaveBeenCalledWith("read", "*");
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it("does NOT call sessionRules.approve when user approves once (not for session)", async () => {
|
|
633
|
+
const sessionRules = {
|
|
634
|
+
approve: vi.fn(),
|
|
635
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
636
|
+
clear: vi.fn(),
|
|
637
|
+
} as unknown as ExtensionRuntime["sessionRules"];
|
|
638
|
+
const deps = makeDeps({
|
|
639
|
+
runtime: makeRuntime({
|
|
640
|
+
permissionManager: {
|
|
641
|
+
checkPermission: vi.fn().mockReturnValue({
|
|
642
|
+
state: "ask",
|
|
643
|
+
toolName: "bash",
|
|
644
|
+
source: "bash",
|
|
645
|
+
command: "git status",
|
|
646
|
+
}),
|
|
647
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
648
|
+
sessionRules,
|
|
649
|
+
}),
|
|
650
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
651
|
+
promptPermission: vi
|
|
652
|
+
.fn()
|
|
653
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
654
|
+
});
|
|
655
|
+
const event = makeToolCallEvent("bash", {
|
|
656
|
+
input: { command: "git status" },
|
|
657
|
+
});
|
|
658
|
+
await handleToolCall(deps, event, makeCtx());
|
|
659
|
+
expect(sessionRules.approve).not.toHaveBeenCalled();
|
|
660
|
+
});
|
|
661
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
suggestBashPattern,
|
|
4
|
+
suggestMcpPattern,
|
|
5
|
+
suggestSessionPattern,
|
|
6
|
+
} from "../src/pattern-suggest";
|
|
7
|
+
|
|
8
|
+
describe("suggestBashPattern", () => {
|
|
9
|
+
it("returns <command> * for a multi-word command", () => {
|
|
10
|
+
expect(suggestBashPattern("git status --short")).toBe("git *");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("uses only the first word as the base", () => {
|
|
14
|
+
expect(suggestBashPattern("npm run build")).toBe("npm *");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns the exact command when there are no arguments", () => {
|
|
18
|
+
expect(suggestBashPattern("ls")).toBe("ls");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("trims leading and trailing whitespace", () => {
|
|
22
|
+
expect(suggestBashPattern(" git log ")).toBe("git *");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("handles empty string gracefully", () => {
|
|
26
|
+
expect(suggestBashPattern("")).toBe("");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("suggestMcpPattern", () => {
|
|
31
|
+
it("suggests server:* for a qualified target (colon-separated)", () => {
|
|
32
|
+
expect(suggestMcpPattern("exa:search")).toBe("exa:*");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("suggests server_* for a munged target (underscore-separated)", () => {
|
|
36
|
+
expect(suggestMcpPattern("exa_search")).toBe("exa_*");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("suggests * for a bare 'mcp' target", () => {
|
|
40
|
+
expect(suggestMcpPattern("mcp")).toBe("*");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("suggests * for a plain tool name with no server prefix", () => {
|
|
44
|
+
expect(suggestMcpPattern("search")).toBe("*");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("prefers colon over underscore when both are present", () => {
|
|
48
|
+
// Qualified names contain ':'; the colon check runs first.
|
|
49
|
+
expect(suggestMcpPattern("my-server:some_tool")).toBe("my-server:*");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("suggestSessionPattern", () => {
|
|
54
|
+
describe("bash surface", () => {
|
|
55
|
+
it("returns bash surface with <command> * pattern for multi-word command", () => {
|
|
56
|
+
const result = suggestSessionPattern("bash", "git status --short");
|
|
57
|
+
expect(result).toMatchObject({
|
|
58
|
+
surface: "bash",
|
|
59
|
+
pattern: "git *",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns exact command for single-word bash command", () => {
|
|
64
|
+
const result = suggestSessionPattern("bash", "ls");
|
|
65
|
+
expect(result).toMatchObject({ surface: "bash", pattern: "ls" });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("mcp surface", () => {
|
|
70
|
+
it("returns mcp surface with server:* for qualified target", () => {
|
|
71
|
+
const result = suggestSessionPattern("mcp", "exa:search");
|
|
72
|
+
expect(result).toMatchObject({ surface: "mcp", pattern: "exa:*" });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns mcp surface with server_* for munged target", () => {
|
|
76
|
+
const result = suggestSessionPattern("mcp", "exa_search");
|
|
77
|
+
expect(result).toMatchObject({ surface: "mcp", pattern: "exa_*" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns * for bare mcp target", () => {
|
|
81
|
+
const result = suggestSessionPattern("mcp", "mcp");
|
|
82
|
+
expect(result).toMatchObject({ surface: "mcp", pattern: "*" });
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("skill surface", () => {
|
|
87
|
+
it("returns exact skill name as pattern", () => {
|
|
88
|
+
const result = suggestSessionPattern("skill", "librarian");
|
|
89
|
+
expect(result).toMatchObject({ surface: "skill", pattern: "librarian" });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("external_directory surface", () => {
|
|
94
|
+
it("returns parent-directory glob from deriveApprovalPattern", () => {
|
|
95
|
+
const result = suggestSessionPattern(
|
|
96
|
+
"external_directory",
|
|
97
|
+
"/tmp/foo.txt",
|
|
98
|
+
);
|
|
99
|
+
expect(result).toMatchObject({
|
|
100
|
+
surface: "external_directory",
|
|
101
|
+
pattern: "/tmp/*",
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("tool surfaces", () => {
|
|
107
|
+
it("returns * for read surface", () => {
|
|
108
|
+
const result = suggestSessionPattern("read", "*");
|
|
109
|
+
expect(result).toMatchObject({ surface: "read", pattern: "*" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns * for write surface", () => {
|
|
113
|
+
const result = suggestSessionPattern("write", "*");
|
|
114
|
+
expect(result).toMatchObject({ surface: "write", pattern: "*" });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns * for edit surface", () => {
|
|
118
|
+
const result = suggestSessionPattern("edit", "*");
|
|
119
|
+
expect(result).toMatchObject({ surface: "edit", pattern: "*" });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("label field", () => {
|
|
124
|
+
it("includes the suggested pattern in the label", () => {
|
|
125
|
+
const result = suggestSessionPattern("bash", "git status");
|
|
126
|
+
expect(result.label).toContain("git *");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("wraps the pattern in quotes in the label", () => {
|
|
130
|
+
const result = suggestSessionPattern("mcp", "exa:search");
|
|
131
|
+
expect(result.label).toContain('"exa:*"');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("label reads as a natural session-approval option", () => {
|
|
135
|
+
const result = suggestSessionPattern("skill", "librarian");
|
|
136
|
+
expect(result.label).toBe('Yes, allow "librarian" for this session');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -132,6 +132,45 @@ describe("requestPermissionDecisionFromUi", () => {
|
|
|
132
132
|
"No, provide reason",
|
|
133
133
|
]);
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
it("uses custom sessionLabel when provided", async () => {
|
|
137
|
+
const selectFn = vi.fn().mockResolvedValue("Yes");
|
|
138
|
+
const ui: PermissionDecisionUi = {
|
|
139
|
+
select: selectFn,
|
|
140
|
+
input: vi.fn(),
|
|
141
|
+
};
|
|
142
|
+
await requestPermissionDecisionFromUi(ui, "Title", "Message", {
|
|
143
|
+
sessionLabel: 'Yes, allow "git *" for this session',
|
|
144
|
+
});
|
|
145
|
+
const options = selectFn.mock.calls[0][1] as string[];
|
|
146
|
+
expect(options[1]).toBe('Yes, allow "git *" for this session');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("still returns approved_for_session when user selects the custom session label", async () => {
|
|
150
|
+
const customLabel = 'Yes, allow "git *" for this session';
|
|
151
|
+
const ui: PermissionDecisionUi = {
|
|
152
|
+
select: vi.fn().mockResolvedValue(customLabel),
|
|
153
|
+
input: vi.fn(),
|
|
154
|
+
};
|
|
155
|
+
const result = await requestPermissionDecisionFromUi(
|
|
156
|
+
ui,
|
|
157
|
+
"Title",
|
|
158
|
+
"Message",
|
|
159
|
+
{ sessionLabel: customLabel },
|
|
160
|
+
);
|
|
161
|
+
expect(result).toEqual({ approved: true, state: "approved_for_session" });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("falls back to default session label when no options provided", async () => {
|
|
165
|
+
const selectFn = vi.fn().mockResolvedValue("Yes");
|
|
166
|
+
const ui: PermissionDecisionUi = {
|
|
167
|
+
select: selectFn,
|
|
168
|
+
input: vi.fn(),
|
|
169
|
+
};
|
|
170
|
+
await requestPermissionDecisionFromUi(ui, "Title", "Message");
|
|
171
|
+
const options = selectFn.mock.calls[0][1] as string[];
|
|
172
|
+
expect(options[1]).toBe("Yes, for this session");
|
|
173
|
+
});
|
|
135
174
|
});
|
|
136
175
|
|
|
137
176
|
describe("normalizePermissionDenialReason", () => {
|
|
@@ -179,6 +179,74 @@ describe("applyPermissionGate", () => {
|
|
|
179
179
|
});
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
+
describe("ask branch — approved_for_session with sessionApproval", () => {
|
|
183
|
+
it("attaches sessionApproval to result when decision is approved_for_session and param provided", async () => {
|
|
184
|
+
const decision: PermissionPromptDecision = {
|
|
185
|
+
approved: true,
|
|
186
|
+
state: "approved_for_session",
|
|
187
|
+
};
|
|
188
|
+
const promptForApproval = vi.fn().mockResolvedValue(decision);
|
|
189
|
+
const params = makeParams({
|
|
190
|
+
state: "ask",
|
|
191
|
+
canConfirm: true,
|
|
192
|
+
promptForApproval,
|
|
193
|
+
sessionApproval: { surface: "bash", pattern: "git *" },
|
|
194
|
+
});
|
|
195
|
+
const result = await applyPermissionGate(params);
|
|
196
|
+
expect(result).toEqual({
|
|
197
|
+
action: "allow",
|
|
198
|
+
sessionApproval: { surface: "bash", pattern: "git *" },
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("does not attach sessionApproval when decision is approved (once)", async () => {
|
|
203
|
+
const decision: PermissionPromptDecision = {
|
|
204
|
+
approved: true,
|
|
205
|
+
state: "approved",
|
|
206
|
+
};
|
|
207
|
+
const promptForApproval = vi.fn().mockResolvedValue(decision);
|
|
208
|
+
const params = makeParams({
|
|
209
|
+
state: "ask",
|
|
210
|
+
canConfirm: true,
|
|
211
|
+
promptForApproval,
|
|
212
|
+
sessionApproval: { surface: "bash", pattern: "git *" },
|
|
213
|
+
});
|
|
214
|
+
const result = await applyPermissionGate(params);
|
|
215
|
+
expect(result).toEqual({ action: "allow" });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("does not attach sessionApproval when no sessionApproval param", async () => {
|
|
219
|
+
const decision: PermissionPromptDecision = {
|
|
220
|
+
approved: true,
|
|
221
|
+
state: "approved_for_session",
|
|
222
|
+
};
|
|
223
|
+
const promptForApproval = vi.fn().mockResolvedValue(decision);
|
|
224
|
+
const params = makeParams({
|
|
225
|
+
state: "ask",
|
|
226
|
+
canConfirm: true,
|
|
227
|
+
promptForApproval,
|
|
228
|
+
});
|
|
229
|
+
const result = await applyPermissionGate(params);
|
|
230
|
+
expect(result).toEqual({ action: "allow" });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("does not attach sessionApproval when user denies", async () => {
|
|
234
|
+
const decision: PermissionPromptDecision = {
|
|
235
|
+
approved: false,
|
|
236
|
+
state: "denied",
|
|
237
|
+
};
|
|
238
|
+
const promptForApproval = vi.fn().mockResolvedValue(decision);
|
|
239
|
+
const params = makeParams({
|
|
240
|
+
state: "ask",
|
|
241
|
+
canConfirm: true,
|
|
242
|
+
promptForApproval,
|
|
243
|
+
sessionApproval: { surface: "bash", pattern: "git *" },
|
|
244
|
+
});
|
|
245
|
+
const result = await applyPermissionGate(params);
|
|
246
|
+
expect(result).toEqual({ action: "block", reason: "User denied." });
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
182
250
|
describe("allow branch", () => {
|
|
183
251
|
it("returns allow immediately when state is allow", async () => {
|
|
184
252
|
const params = makeParams({ state: "allow" });
|
|
@@ -2620,6 +2620,187 @@ test("session rules override config deny for external_directory", () => {
|
|
|
2620
2620
|
}
|
|
2621
2621
|
});
|
|
2622
2622
|
|
|
2623
|
+
// ── Session rule evaluation for all surfaces ─────────────────────────────
|
|
2624
|
+
|
|
2625
|
+
test("checkPermission returns source 'session' for bash when session rules match", () => {
|
|
2626
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2627
|
+
|
|
2628
|
+
try {
|
|
2629
|
+
const sessionRules = [
|
|
2630
|
+
{
|
|
2631
|
+
surface: "bash",
|
|
2632
|
+
pattern: "git *",
|
|
2633
|
+
action: "allow" as const,
|
|
2634
|
+
layer: "session" as const,
|
|
2635
|
+
},
|
|
2636
|
+
];
|
|
2637
|
+
|
|
2638
|
+
const result = manager.checkPermission(
|
|
2639
|
+
"bash",
|
|
2640
|
+
{ command: "git status --short" },
|
|
2641
|
+
undefined,
|
|
2642
|
+
sessionRules,
|
|
2643
|
+
);
|
|
2644
|
+
assert.equal(result.state, "allow");
|
|
2645
|
+
assert.equal(result.source, "session");
|
|
2646
|
+
assert.equal(result.matchedPattern, "git *");
|
|
2647
|
+
} finally {
|
|
2648
|
+
cleanup();
|
|
2649
|
+
}
|
|
2650
|
+
});
|
|
2651
|
+
|
|
2652
|
+
test("checkPermission returns source 'session' for bash when session rule is exact match", () => {
|
|
2653
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2654
|
+
|
|
2655
|
+
try {
|
|
2656
|
+
const sessionRules = [
|
|
2657
|
+
{
|
|
2658
|
+
surface: "bash",
|
|
2659
|
+
pattern: "ls",
|
|
2660
|
+
action: "allow" as const,
|
|
2661
|
+
layer: "session" as const,
|
|
2662
|
+
},
|
|
2663
|
+
];
|
|
2664
|
+
|
|
2665
|
+
const result = manager.checkPermission(
|
|
2666
|
+
"bash",
|
|
2667
|
+
{ command: "ls" },
|
|
2668
|
+
undefined,
|
|
2669
|
+
sessionRules,
|
|
2670
|
+
);
|
|
2671
|
+
assert.equal(result.state, "allow");
|
|
2672
|
+
assert.equal(result.source, "session");
|
|
2673
|
+
} finally {
|
|
2674
|
+
cleanup();
|
|
2675
|
+
}
|
|
2676
|
+
});
|
|
2677
|
+
|
|
2678
|
+
test("checkPermission falls back to config for bash when session rules do not match the command", () => {
|
|
2679
|
+
const { manager, cleanup } = createManager({ permission: { bash: "deny" } });
|
|
2680
|
+
|
|
2681
|
+
try {
|
|
2682
|
+
const sessionRules = [
|
|
2683
|
+
{
|
|
2684
|
+
surface: "bash",
|
|
2685
|
+
pattern: "git *",
|
|
2686
|
+
action: "allow" as const,
|
|
2687
|
+
layer: "session" as const,
|
|
2688
|
+
},
|
|
2689
|
+
];
|
|
2690
|
+
|
|
2691
|
+
const result = manager.checkPermission(
|
|
2692
|
+
"bash",
|
|
2693
|
+
{ command: "npm run build" },
|
|
2694
|
+
undefined,
|
|
2695
|
+
sessionRules,
|
|
2696
|
+
);
|
|
2697
|
+
assert.equal(result.state, "deny");
|
|
2698
|
+
assert.equal(result.source, "bash");
|
|
2699
|
+
} finally {
|
|
2700
|
+
cleanup();
|
|
2701
|
+
}
|
|
2702
|
+
});
|
|
2703
|
+
|
|
2704
|
+
test("checkPermission returns source 'session' for mcp when session rules match the target", () => {
|
|
2705
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2706
|
+
|
|
2707
|
+
try {
|
|
2708
|
+
const sessionRules = [
|
|
2709
|
+
{
|
|
2710
|
+
surface: "mcp",
|
|
2711
|
+
pattern: "exa:*",
|
|
2712
|
+
action: "allow" as const,
|
|
2713
|
+
layer: "session" as const,
|
|
2714
|
+
},
|
|
2715
|
+
];
|
|
2716
|
+
|
|
2717
|
+
const result = manager.checkPermission(
|
|
2718
|
+
"mcp",
|
|
2719
|
+
{ tool: "exa:search" },
|
|
2720
|
+
undefined,
|
|
2721
|
+
sessionRules,
|
|
2722
|
+
);
|
|
2723
|
+
assert.equal(result.state, "allow");
|
|
2724
|
+
assert.equal(result.source, "session");
|
|
2725
|
+
} finally {
|
|
2726
|
+
cleanup();
|
|
2727
|
+
}
|
|
2728
|
+
});
|
|
2729
|
+
|
|
2730
|
+
test("checkPermission returns source 'session' for skill when session rules match", () => {
|
|
2731
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2732
|
+
|
|
2733
|
+
try {
|
|
2734
|
+
const sessionRules = [
|
|
2735
|
+
{
|
|
2736
|
+
surface: "skill",
|
|
2737
|
+
pattern: "librarian",
|
|
2738
|
+
action: "allow" as const,
|
|
2739
|
+
layer: "session" as const,
|
|
2740
|
+
},
|
|
2741
|
+
];
|
|
2742
|
+
|
|
2743
|
+
const result = manager.checkPermission(
|
|
2744
|
+
"skill",
|
|
2745
|
+
{ name: "librarian" },
|
|
2746
|
+
undefined,
|
|
2747
|
+
sessionRules,
|
|
2748
|
+
);
|
|
2749
|
+
assert.equal(result.state, "allow");
|
|
2750
|
+
assert.equal(result.source, "session");
|
|
2751
|
+
assert.equal(result.matchedPattern, "librarian");
|
|
2752
|
+
} finally {
|
|
2753
|
+
cleanup();
|
|
2754
|
+
}
|
|
2755
|
+
});
|
|
2756
|
+
|
|
2757
|
+
test("checkPermission returns source 'session' for tool surface when session rules match", () => {
|
|
2758
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2759
|
+
|
|
2760
|
+
try {
|
|
2761
|
+
const sessionRules = [
|
|
2762
|
+
{
|
|
2763
|
+
surface: "read",
|
|
2764
|
+
pattern: "*",
|
|
2765
|
+
action: "allow" as const,
|
|
2766
|
+
layer: "session" as const,
|
|
2767
|
+
},
|
|
2768
|
+
];
|
|
2769
|
+
|
|
2770
|
+
const result = manager.checkPermission("read", {}, undefined, sessionRules);
|
|
2771
|
+
assert.equal(result.state, "allow");
|
|
2772
|
+
assert.equal(result.source, "session");
|
|
2773
|
+
} finally {
|
|
2774
|
+
cleanup();
|
|
2775
|
+
}
|
|
2776
|
+
});
|
|
2777
|
+
|
|
2778
|
+
test("bash session rules do not bleed into mcp checks", () => {
|
|
2779
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2780
|
+
|
|
2781
|
+
try {
|
|
2782
|
+
const sessionRules = [
|
|
2783
|
+
{
|
|
2784
|
+
surface: "bash",
|
|
2785
|
+
pattern: "git *",
|
|
2786
|
+
action: "allow" as const,
|
|
2787
|
+
layer: "session" as const,
|
|
2788
|
+
},
|
|
2789
|
+
];
|
|
2790
|
+
|
|
2791
|
+
const result = manager.checkPermission(
|
|
2792
|
+
"mcp",
|
|
2793
|
+
{ tool: "exa:search" },
|
|
2794
|
+
undefined,
|
|
2795
|
+
sessionRules,
|
|
2796
|
+
);
|
|
2797
|
+
// bash session rule must not affect mcp surface
|
|
2798
|
+
assert.notEqual(result.source, "session");
|
|
2799
|
+
} finally {
|
|
2800
|
+
cleanup();
|
|
2801
|
+
}
|
|
2802
|
+
});
|
|
2803
|
+
|
|
2623
2804
|
// Suppress unused import warning — PermissionState used in type annotations
|
|
2624
2805
|
const _unused: PermissionState = "ask";
|
|
2625
2806
|
void _unused;
|