@gotgenes/pi-permission-system 3.3.0 → 3.5.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 +33 -0
- package/README.md +19 -3
- package/config/config.example.json +0 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +2 -6
- package/src/config-loader.ts +2 -1
- package/src/extension-config.ts +0 -1
- package/src/index.ts +152 -85
- package/src/permission-dialog.ts +14 -1
- package/src/permission-manager.ts +2 -1
- package/src/session-approval-cache.ts +81 -0
- package/src/types.ts +1 -1
- package/tests/config-loader.test.ts +5 -4
- package/tests/extension-config.test.ts +7 -2
- package/tests/permission-dialog.test.ts +166 -0
- package/tests/permission-system.test.ts +361 -8
- package/tests/session-approval-cache.test.ts +131 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,39 @@ 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
|
+
## [3.5.0](https://github.com/gotgenes/pi-permission-system/compare/v3.4.0...v3.5.0) (2026-05-03)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* deprecate doom_loop special permission key ([68e70e7](https://github.com/gotgenes/pi-permission-system/commit/68e70e71b68e5a76a071ef4613da356a91080158))
|
|
14
|
+
* remove doom_loop from type union and config-loader ([bf2f288](https://github.com/gotgenes/pi-permission-system/commit/bf2f2886a800187337e82954e812e6d05e9bd451))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* add architecture documents for current and target permission model ([aab1ac5](https://github.com/gotgenes/pi-permission-system/commit/aab1ac50c4478d2e393c2a796bf6fcc4ec606f79))
|
|
20
|
+
* plan doom_loop deprecation ([#54](https://github.com/gotgenes/pi-permission-system/issues/54)) ([2e730f5](https://github.com/gotgenes/pi-permission-system/commit/2e730f52189dd2996ebbe90dd5d2b3206a45d1f6))
|
|
21
|
+
* plan handler extraction from piPermissionSystemExtension ([#42](https://github.com/gotgenes/pi-permission-system/issues/42)) ([6ecd419](https://github.com/gotgenes/pi-permission-system/commit/6ecd4190fb9a60009eb695b4998ab8a1d1419139))
|
|
22
|
+
* remove doom_loop from schema, example, and README ([7f422e0](https://github.com/gotgenes/pi-permission-system/commit/7f422e086f0052e0d9449dbd0122c57b923b053d))
|
|
23
|
+
* **retro:** add retro notes for issue [#45](https://github.com/gotgenes/pi-permission-system/issues/45) ([14c5559](https://github.com/gotgenes/pi-permission-system/commit/14c55595c5abfaa51f8ec83369452db5f457836c))
|
|
24
|
+
|
|
25
|
+
## [3.4.0](https://github.com/gotgenes/pi-permission-system/compare/v3.3.0...v3.4.0) (2026-05-03)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Features
|
|
29
|
+
|
|
30
|
+
* add "approve for session" option to permission dialog ([#45](https://github.com/gotgenes/pi-permission-system/issues/45)) ([909d5ee](https://github.com/gotgenes/pi-permission-system/commit/909d5ee540615f876852b3bdb60154487c2570fd))
|
|
31
|
+
* add SessionApprovalCache for ephemeral session approvals ([#45](https://github.com/gotgenes/pi-permission-system/issues/45)) ([4f97779](https://github.com/gotgenes/pi-permission-system/commit/4f9777980eba139c9c85a027eeddc84ff932911c))
|
|
32
|
+
* wire session approvals into external-directory gates ([#45](https://github.com/gotgenes/pi-permission-system/issues/45)) ([3ab156d](https://github.com/gotgenes/pi-permission-system/commit/3ab156dc4ad10bd37938a5096dc6c33970767b1a))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
### Documentation
|
|
36
|
+
|
|
37
|
+
* document session-scoped approval option ([#45](https://github.com/gotgenes/pi-permission-system/issues/45)) ([eb1eb9c](https://github.com/gotgenes/pi-permission-system/commit/eb1eb9c0052e7dd88ad7162b322160cfd6e0e62b))
|
|
38
|
+
* plan session-scoped approvals for permission prompts ([#45](https://github.com/gotgenes/pi-permission-system/issues/45)) ([29dcede](https://github.com/gotgenes/pi-permission-system/commit/29dcede17ad68fd3980bf3289069e237de2b4ef0))
|
|
39
|
+
* **retro:** add retro notes for issue [#41](https://github.com/gotgenes/pi-permission-system/issues/41) ([fd2755f](https://github.com/gotgenes/pi-permission-system/commit/fd2755fcf4ec68c9e65e6128e9b869da1f368abb))
|
|
40
|
+
|
|
8
41
|
## [3.3.0](https://github.com/gotgenes/pi-permission-system/compare/v3.2.0...v3.3.0) (2026-05-03)
|
|
9
42
|
|
|
10
43
|
|
package/README.md
CHANGED
|
@@ -137,7 +137,7 @@ The config file combines runtime knobs and permission policy in one object:
|
|
|
137
137
|
"bash": { "git status": "allow", "git *": "ask" },
|
|
138
138
|
"mcp": { "mcp_status": "allow" },
|
|
139
139
|
"skills": { "*": "ask" },
|
|
140
|
-
"special": { "
|
|
140
|
+
"special": { "external_directory": "ask" }
|
|
141
141
|
}
|
|
142
142
|
```
|
|
143
143
|
|
|
@@ -353,13 +353,11 @@ Reserved permission checks:
|
|
|
353
353
|
|
|
354
354
|
| Key | Description |
|
|
355
355
|
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
356
|
-
| `doom_loop` | Controls doom loop detection behavior |
|
|
357
356
|
| `external_directory` | Enforces ask/allow/deny decisions for path-bearing tools and bash commands that reference paths outside the active working directory |
|
|
358
357
|
|
|
359
358
|
```jsonc
|
|
360
359
|
{
|
|
361
360
|
"special": {
|
|
362
|
-
"doom_loop": "deny",
|
|
363
361
|
"external_directory": "ask",
|
|
364
362
|
},
|
|
365
363
|
}
|
|
@@ -470,6 +468,23 @@ Example edit approval prompt:
|
|
|
470
468
|
Current agent requested tool 'edit' for '.gitignore' (1 replacement: edit #1 replaces 5 lines with 2 lines). Allow this call?
|
|
471
469
|
```
|
|
472
470
|
|
|
471
|
+
### Session-Scoped Approvals
|
|
472
|
+
|
|
473
|
+
When `special.external_directory` resolves to `ask`, the permission dialog offers four options:
|
|
474
|
+
|
|
475
|
+
```text
|
|
476
|
+
Yes | Yes, for this session | No | No, provide reason
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
Selecting **Yes, for this session** approves the current request and caches the directory prefix so that subsequent accesses under the same directory skip the prompt for the remainder of the session.
|
|
480
|
+
For example, approving access to `~/other-project/src/foo.ts` covers all paths under `~/other-project/src/` until the session ends.
|
|
481
|
+
|
|
482
|
+
Session approvals are ephemeral — they are never persisted to disk and are cleared on `session_shutdown`.
|
|
483
|
+
The review log records these decisions with `resolution: "session_approved"` so they remain auditable.
|
|
484
|
+
|
|
485
|
+
This is currently scoped to the `external_directory` surface only.
|
|
486
|
+
Other permission surfaces (tools, bash patterns, MCP, skills) always use the standard one-time approval flow.
|
|
487
|
+
|
|
473
488
|
### Subagent Permission Forwarding
|
|
474
489
|
|
|
475
490
|
When a delegated or routed subagent runs without direct UI access, `ask` permissions can still be enforced by forwarding the confirmation request through Pi session directories. The main interactive session polls for forwarded requests, shows the confirmation prompt, writes the response, and the subagent resumes once that decision is available.
|
|
@@ -514,6 +529,7 @@ This makes it easy to verify which files the extension actually loaded:
|
|
|
514
529
|
index.ts → Root Pi entrypoint shim
|
|
515
530
|
src/
|
|
516
531
|
├── index.ts → Extension bootstrap, permission checks, readable prompts, review logging, reload handling, and subagent forwarding
|
|
532
|
+
├── session-approval-cache.ts → Ephemeral session-scoped approval cache for external-directory access
|
|
517
533
|
├── config-loader.ts → Unified config loader, merger, and legacy-path detection
|
|
518
534
|
├── config-paths.ts → Path derivation for global, project, and legacy config locations
|
|
519
535
|
├── config-reporter.ts → Resolved config path reporting for diagnostic logs
|
package/package.json
CHANGED
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
"default": "ask"
|
|
61
61
|
},
|
|
62
62
|
"special": {
|
|
63
|
-
"description": "Default permission for special checks (
|
|
64
|
-
"markdownDescription": "Default permission for special checks (`
|
|
63
|
+
"description": "Default permission for special checks (external_directory) when no explicit rule matches.",
|
|
64
|
+
"markdownDescription": "Default permission for special checks (`external_directory`) when no explicit rule matches.",
|
|
65
65
|
"$ref": "#/$defs/permissionState",
|
|
66
66
|
"default": "ask"
|
|
67
67
|
}
|
|
@@ -122,10 +122,6 @@
|
|
|
122
122
|
"type": "object",
|
|
123
123
|
"additionalProperties": false,
|
|
124
124
|
"properties": {
|
|
125
|
-
"doom_loop": {
|
|
126
|
-
"description": "Controls doom loop detection behavior.",
|
|
127
|
-
"$ref": "#/$defs/permissionState"
|
|
128
|
-
},
|
|
129
125
|
"external_directory": {
|
|
130
126
|
"description": "Enforces permission checks for path-bearing file tools (read, write, edit, find, grep, ls) when they target paths outside the active working directory.",
|
|
131
127
|
"markdownDescription": "Enforces permission checks for path-bearing file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`) when they target paths outside the active working directory.\n\nEvaluated **before** the normal tool permission check — so `tools.read: \"allow\"` can permit ordinary reads while `external_directory: \"ask\"` still requires confirmation for paths outside `cwd`.",
|
package/src/config-loader.ts
CHANGED
|
@@ -36,6 +36,7 @@ export interface UnifiedConfigLoadResult {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
const DEPRECATED_SPECIAL_KEYS: ReadonlySet<string> = new Set([
|
|
39
|
+
"doom_loop",
|
|
39
40
|
"tool_call_limit",
|
|
40
41
|
]);
|
|
41
42
|
|
|
@@ -49,7 +50,7 @@ const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
|
|
|
49
50
|
"ls",
|
|
50
51
|
]);
|
|
51
52
|
|
|
52
|
-
const SPECIAL_PERMISSION_KEYS = new Set(["
|
|
53
|
+
const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
|
|
53
54
|
|
|
54
55
|
export function stripJsonComments(input: string): string {
|
|
55
56
|
let output = "";
|
package/src/extension-config.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -80,6 +80,10 @@ import {
|
|
|
80
80
|
formatUnknownToolReason,
|
|
81
81
|
formatUserDeniedReason,
|
|
82
82
|
} from "./permission-prompts";
|
|
83
|
+
import {
|
|
84
|
+
deriveApprovalPrefix,
|
|
85
|
+
SessionApprovalCache,
|
|
86
|
+
} from "./session-approval-cache";
|
|
83
87
|
import {
|
|
84
88
|
findSkillPathMatch,
|
|
85
89
|
resolveSkillPromptEntries,
|
|
@@ -234,6 +238,7 @@ function createPermissionManagerForCwd(
|
|
|
234
238
|
|
|
235
239
|
export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
236
240
|
let permissionManager = new PermissionManager();
|
|
241
|
+
const sessionApprovalCache = new SessionApprovalCache();
|
|
237
242
|
let activeSkillEntries: SkillPromptEntry[] = [];
|
|
238
243
|
let lastKnownActiveAgentName: string | null = null;
|
|
239
244
|
let lastActiveToolsCacheKey: string | null = null;
|
|
@@ -587,6 +592,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
587
592
|
runtimeContext?.ui.setStatus(PERMISSION_SYSTEM_STATUS_KEY, undefined);
|
|
588
593
|
runtimeContext = null;
|
|
589
594
|
invalidateAgentStartCache();
|
|
595
|
+
sessionApprovalCache.clear();
|
|
590
596
|
stopForwardedPermissionPolling();
|
|
591
597
|
});
|
|
592
598
|
|
|
@@ -814,60 +820,91 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
814
820
|
externalDirectoryPath &&
|
|
815
821
|
isPathOutsideWorkingDirectory(externalDirectoryPath, ctx.cwd)
|
|
816
822
|
) {
|
|
817
|
-
const
|
|
818
|
-
"external_directory",
|
|
819
|
-
{},
|
|
820
|
-
agentName ?? undefined,
|
|
821
|
-
);
|
|
822
|
-
|
|
823
|
-
const extDirMessage = formatExternalDirectoryAskPrompt(
|
|
824
|
-
toolName,
|
|
823
|
+
const normalizedExtPath = normalizePathForComparison(
|
|
825
824
|
externalDirectoryPath,
|
|
826
825
|
ctx.cwd,
|
|
827
|
-
agentName ?? undefined,
|
|
828
826
|
);
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
agentName,
|
|
837
|
-
message: extDirMessage,
|
|
838
|
-
toolCallId: event.toolCallId,
|
|
839
|
-
toolName,
|
|
840
|
-
path: externalDirectoryPath,
|
|
841
|
-
}),
|
|
842
|
-
writeLog: writeReviewLog,
|
|
843
|
-
logContext: {
|
|
827
|
+
const sessionPrefix = sessionApprovalCache.findMatchingPrefix(
|
|
828
|
+
"external_directory",
|
|
829
|
+
normalizedExtPath,
|
|
830
|
+
);
|
|
831
|
+
|
|
832
|
+
if (sessionPrefix) {
|
|
833
|
+
writeReviewLog("permission_request.session_approved", {
|
|
844
834
|
source: "tool_call",
|
|
845
835
|
toolCallId: event.toolCallId,
|
|
846
836
|
toolName,
|
|
847
837
|
agentName,
|
|
848
838
|
path: externalDirectoryPath,
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
839
|
+
resolution: "session_approved",
|
|
840
|
+
sessionApprovalPrefix: sessionPrefix,
|
|
841
|
+
});
|
|
842
|
+
// Fall through to normal permission check
|
|
843
|
+
} else {
|
|
844
|
+
const extCheck = permissionManager.checkPermission(
|
|
845
|
+
"external_directory",
|
|
846
|
+
{},
|
|
847
|
+
agentName ?? undefined,
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
let extDirDecision: PermissionPromptDecision | null = null;
|
|
851
|
+
const extDirMessage = formatExternalDirectoryAskPrompt(
|
|
852
|
+
toolName,
|
|
853
|
+
externalDirectoryPath,
|
|
854
|
+
ctx.cwd,
|
|
855
|
+
agentName ?? undefined,
|
|
856
|
+
);
|
|
857
|
+
const extDirGate = await applyPermissionGate({
|
|
858
|
+
state: extCheck.state,
|
|
859
|
+
canConfirm: canRequestPermissionConfirmation(ctx),
|
|
860
|
+
promptForApproval: async () => {
|
|
861
|
+
const decision = await promptPermission(ctx, {
|
|
862
|
+
requestId: event.toolCallId,
|
|
863
|
+
source: "tool_call",
|
|
864
|
+
agentName,
|
|
865
|
+
message: extDirMessage,
|
|
866
|
+
toolCallId: event.toolCallId,
|
|
867
|
+
toolName,
|
|
868
|
+
path: externalDirectoryPath,
|
|
869
|
+
});
|
|
870
|
+
extDirDecision = decision;
|
|
871
|
+
return decision;
|
|
872
|
+
},
|
|
873
|
+
writeLog: writeReviewLog,
|
|
874
|
+
logContext: {
|
|
875
|
+
source: "tool_call",
|
|
876
|
+
toolCallId: event.toolCallId,
|
|
853
877
|
toolName,
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
formatExternalDirectoryUserDeniedReason(
|
|
878
|
+
agentName,
|
|
879
|
+
path: externalDirectoryPath,
|
|
880
|
+
message: extDirMessage,
|
|
881
|
+
},
|
|
882
|
+
messages: {
|
|
883
|
+
denyReason: formatExternalDirectoryDenyReason(
|
|
861
884
|
toolName,
|
|
862
885
|
externalDirectoryPath,
|
|
863
|
-
|
|
886
|
+
ctx.cwd,
|
|
887
|
+
agentName ?? undefined,
|
|
864
888
|
),
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
889
|
+
unavailableReason: `Accessing '${externalDirectoryPath}' outside the working directory requires approval, but no interactive UI is available.`,
|
|
890
|
+
userDeniedReason: (decision) =>
|
|
891
|
+
formatExternalDirectoryUserDeniedReason(
|
|
892
|
+
toolName,
|
|
893
|
+
externalDirectoryPath,
|
|
894
|
+
decision.denialReason,
|
|
895
|
+
),
|
|
896
|
+
},
|
|
897
|
+
});
|
|
898
|
+
if (extDirGate.action === "block") {
|
|
899
|
+
return { block: true, reason: extDirGate.reason };
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (extDirDecision?.state === "approved_for_session") {
|
|
903
|
+
const prefix = deriveApprovalPrefix(normalizedExtPath);
|
|
904
|
+
sessionApprovalCache.approve("external_directory", prefix);
|
|
905
|
+
}
|
|
869
906
|
}
|
|
870
|
-
//
|
|
907
|
+
// Fall through to normal permission check
|
|
871
908
|
}
|
|
872
909
|
|
|
873
910
|
// Bash external directory gate: extract paths from bash commands
|
|
@@ -879,61 +916,91 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
879
916
|
ctx.cwd,
|
|
880
917
|
);
|
|
881
918
|
if (externalPaths.length > 0) {
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
agentName ?? undefined,
|
|
919
|
+
// Filter out paths already covered by session approvals
|
|
920
|
+
const uncoveredPaths = externalPaths.filter(
|
|
921
|
+
(p) => !sessionApprovalCache.has("external_directory", p),
|
|
886
922
|
);
|
|
887
923
|
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
ctx.cwd,
|
|
892
|
-
agentName ?? undefined,
|
|
893
|
-
);
|
|
894
|
-
const bashExtGate = await applyPermissionGate({
|
|
895
|
-
state: extCheck.state,
|
|
896
|
-
canConfirm: canRequestPermissionConfirmation(ctx),
|
|
897
|
-
promptForApproval: () =>
|
|
898
|
-
promptPermission(ctx, {
|
|
899
|
-
requestId: event.toolCallId,
|
|
900
|
-
source: "tool_call",
|
|
901
|
-
agentName,
|
|
902
|
-
message: bashExtMessage,
|
|
903
|
-
toolCallId: event.toolCallId,
|
|
904
|
-
toolName,
|
|
905
|
-
command,
|
|
906
|
-
}),
|
|
907
|
-
writeLog: writeReviewLog,
|
|
908
|
-
logContext: {
|
|
924
|
+
if (uncoveredPaths.length === 0) {
|
|
925
|
+
// All external paths are session-approved
|
|
926
|
+
writeReviewLog("permission_request.session_approved", {
|
|
909
927
|
source: "tool_call",
|
|
910
928
|
toolCallId: event.toolCallId,
|
|
911
929
|
toolName,
|
|
912
930
|
agentName,
|
|
913
931
|
command,
|
|
914
932
|
externalPaths,
|
|
915
|
-
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
|
|
933
|
+
resolution: "session_approved",
|
|
934
|
+
});
|
|
935
|
+
// Fall through to normal bash permission check
|
|
936
|
+
} else {
|
|
937
|
+
const extCheck = permissionManager.checkPermission(
|
|
938
|
+
"external_directory",
|
|
939
|
+
{},
|
|
940
|
+
agentName ?? undefined,
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
let bashExtDecision: PermissionPromptDecision | null = null;
|
|
944
|
+
const bashExtMessage = formatBashExternalDirectoryAskPrompt(
|
|
945
|
+
command,
|
|
946
|
+
uncoveredPaths,
|
|
947
|
+
ctx.cwd,
|
|
948
|
+
agentName ?? undefined,
|
|
949
|
+
);
|
|
950
|
+
const bashExtGate = await applyPermissionGate({
|
|
951
|
+
state: extCheck.state,
|
|
952
|
+
canConfirm: canRequestPermissionConfirmation(ctx),
|
|
953
|
+
promptForApproval: async () => {
|
|
954
|
+
const decision = await promptPermission(ctx, {
|
|
955
|
+
requestId: event.toolCallId,
|
|
956
|
+
source: "tool_call",
|
|
957
|
+
agentName,
|
|
958
|
+
message: bashExtMessage,
|
|
959
|
+
toolCallId: event.toolCallId,
|
|
960
|
+
toolName,
|
|
961
|
+
command,
|
|
962
|
+
});
|
|
963
|
+
bashExtDecision = decision;
|
|
964
|
+
return decision;
|
|
965
|
+
},
|
|
966
|
+
writeLog: writeReviewLog,
|
|
967
|
+
logContext: {
|
|
968
|
+
source: "tool_call",
|
|
969
|
+
toolCallId: event.toolCallId,
|
|
970
|
+
toolName,
|
|
971
|
+
agentName,
|
|
919
972
|
command,
|
|
920
|
-
externalPaths,
|
|
921
|
-
|
|
922
|
-
agentName ?? undefined,
|
|
923
|
-
),
|
|
924
|
-
unavailableReason: `Bash command '${command}' references path(s) outside the working directory and requires approval, but no interactive UI is available.`,
|
|
925
|
-
userDeniedReason: (decision) => {
|
|
926
|
-
const reasonSuffix = decision.denialReason
|
|
927
|
-
? ` Reason: ${decision.denialReason}.`
|
|
928
|
-
: "";
|
|
929
|
-
return `User denied external directory access for bash command '${command}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
|
|
973
|
+
externalPaths: uncoveredPaths,
|
|
974
|
+
message: bashExtMessage,
|
|
930
975
|
},
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
976
|
+
messages: {
|
|
977
|
+
denyReason: formatBashExternalDirectoryDenyReason(
|
|
978
|
+
command,
|
|
979
|
+
uncoveredPaths,
|
|
980
|
+
ctx.cwd,
|
|
981
|
+
agentName ?? undefined,
|
|
982
|
+
),
|
|
983
|
+
unavailableReason: `Bash command '${command}' references path(s) outside the working directory and requires approval, but no interactive UI is available.`,
|
|
984
|
+
userDeniedReason: (decision) => {
|
|
985
|
+
const reasonSuffix = decision.denialReason
|
|
986
|
+
? ` Reason: ${decision.denialReason}.`
|
|
987
|
+
: "";
|
|
988
|
+
return `User denied external directory access for bash command '${command}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
|
|
989
|
+
},
|
|
990
|
+
},
|
|
991
|
+
});
|
|
992
|
+
if (bashExtGate.action === "block") {
|
|
993
|
+
return { block: true, reason: bashExtGate.reason };
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (bashExtDecision?.state === "approved_for_session") {
|
|
997
|
+
for (const extPath of uncoveredPaths) {
|
|
998
|
+
const prefix = deriveApprovalPrefix(extPath);
|
|
999
|
+
sessionApprovalCache.approve("external_directory", prefix);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
935
1002
|
}
|
|
936
|
-
//
|
|
1003
|
+
// Fall through to normal bash permission check
|
|
937
1004
|
}
|
|
938
1005
|
}
|
|
939
1006
|
}
|
package/src/permission-dialog.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type PermissionDecisionState =
|
|
2
2
|
| "approved"
|
|
3
|
+
| "approved_for_session"
|
|
3
4
|
| "denied"
|
|
4
5
|
| "denied_with_reason";
|
|
5
6
|
|
|
@@ -15,10 +16,12 @@ export interface PermissionDecisionUi {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
const APPROVE_OPTION = "Yes";
|
|
19
|
+
const APPROVE_FOR_SESSION_OPTION = "Yes, for this session";
|
|
18
20
|
const DENY_OPTION = "No";
|
|
19
21
|
const DENY_WITH_REASON_OPTION = "No, provide reason";
|
|
20
22
|
const PERMISSION_DECISION_OPTIONS = [
|
|
21
23
|
APPROVE_OPTION,
|
|
24
|
+
APPROVE_FOR_SESSION_OPTION,
|
|
22
25
|
DENY_OPTION,
|
|
23
26
|
DENY_WITH_REASON_OPTION,
|
|
24
27
|
] as const;
|
|
@@ -54,7 +57,10 @@ export function isPermissionDecisionState(
|
|
|
54
57
|
value: unknown,
|
|
55
58
|
): value is PermissionDecisionState {
|
|
56
59
|
return (
|
|
57
|
-
value === "approved" ||
|
|
60
|
+
value === "approved" ||
|
|
61
|
+
value === "approved_for_session" ||
|
|
62
|
+
value === "denied" ||
|
|
63
|
+
value === "denied_with_reason"
|
|
58
64
|
);
|
|
59
65
|
}
|
|
60
66
|
|
|
@@ -74,6 +80,13 @@ export async function requestPermissionDecisionFromUi(
|
|
|
74
80
|
};
|
|
75
81
|
}
|
|
76
82
|
|
|
83
|
+
if (selected === APPROVE_FOR_SESSION_OPTION) {
|
|
84
|
+
return {
|
|
85
|
+
approved: true,
|
|
86
|
+
state: "approved_for_session",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
77
90
|
if (selected === DENY_WITH_REASON_OPTION) {
|
|
78
91
|
const denialReason = normalizePermissionDenialReason(
|
|
79
92
|
await ui.input(
|
|
@@ -46,7 +46,7 @@ const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
|
|
|
46
46
|
"find",
|
|
47
47
|
"ls",
|
|
48
48
|
]);
|
|
49
|
-
const SPECIAL_PERMISSION_KEYS = new Set(["
|
|
49
|
+
const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
|
|
50
50
|
const MCP_BASELINE_TARGETS = new Set([
|
|
51
51
|
"mcp_status",
|
|
52
52
|
"mcp_list",
|
|
@@ -156,6 +156,7 @@ function getConfiguredMcpServerNamesFromPaths(
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
const DEPRECATED_SPECIAL_KEYS: ReadonlySet<string> = new Set([
|
|
159
|
+
"doom_loop",
|
|
159
160
|
"tool_call_limit",
|
|
160
161
|
]);
|
|
161
162
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { dirname, sep } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { isPathWithinDirectory } from "./external-directory";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Ephemeral in-memory cache of session-scoped permission approvals.
|
|
7
|
+
* Keyed by permission surface (e.g. "external_directory"), values are
|
|
8
|
+
* normalized directory prefixes that have been approved for the session.
|
|
9
|
+
*
|
|
10
|
+
* Cleared on session_shutdown — never persisted to disk.
|
|
11
|
+
*/
|
|
12
|
+
export class SessionApprovalCache {
|
|
13
|
+
private approvals = new Map<string, Set<string>>();
|
|
14
|
+
|
|
15
|
+
/** Record a directory prefix as approved for the given surface. */
|
|
16
|
+
approve(surface: string, prefix: string): void {
|
|
17
|
+
let prefixes = this.approvals.get(surface);
|
|
18
|
+
if (!prefixes) {
|
|
19
|
+
prefixes = new Set();
|
|
20
|
+
this.approvals.set(surface, prefixes);
|
|
21
|
+
}
|
|
22
|
+
prefixes.add(prefix);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check whether a path falls under any approved prefix for the given surface.
|
|
27
|
+
* Uses `isPathWithinDirectory()` for correct separator-aware prefix matching.
|
|
28
|
+
*/
|
|
29
|
+
has(surface: string, path: string): boolean {
|
|
30
|
+
const prefixes = this.approvals.get(surface);
|
|
31
|
+
if (!prefixes) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
for (const prefix of prefixes) {
|
|
35
|
+
if (isPathWithinDirectory(path, prefix)) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Find and return the matching approved prefix, or null if none matches. */
|
|
43
|
+
findMatchingPrefix(surface: string, path: string): string | null {
|
|
44
|
+
const prefixes = this.approvals.get(surface);
|
|
45
|
+
if (!prefixes) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
for (const prefix of prefixes) {
|
|
49
|
+
if (isPathWithinDirectory(path, prefix)) {
|
|
50
|
+
return prefix;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Remove all session approvals. */
|
|
57
|
+
clear(): void {
|
|
58
|
+
this.approvals.clear();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Derive the directory prefix to approve from a normalized path.
|
|
64
|
+
* Returns `dirname(path)` with a trailing separator so that
|
|
65
|
+
* prefix matching via `isPathWithinDirectory()` works correctly.
|
|
66
|
+
*
|
|
67
|
+
* For paths that already end with a separator (directories),
|
|
68
|
+
* the trailing separator is stripped by dirname and re-added.
|
|
69
|
+
*/
|
|
70
|
+
export function deriveApprovalPrefix(normalizedPath: string): string {
|
|
71
|
+
// If the path already ends with a separator, it's a directory — return as-is.
|
|
72
|
+
if (normalizedPath.endsWith(sep)) {
|
|
73
|
+
return normalizedPath;
|
|
74
|
+
}
|
|
75
|
+
const dir = dirname(normalizedPath);
|
|
76
|
+
if (dir === normalizedPath) {
|
|
77
|
+
// Root path — dirname('/') === '/'
|
|
78
|
+
return dir;
|
|
79
|
+
}
|
|
80
|
+
return dir.endsWith(sep) ? dir : `${dir}${sep}`;
|
|
81
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -15,7 +15,7 @@ export type BashPermissions = Record<string, PermissionState>;
|
|
|
15
15
|
|
|
16
16
|
export type SkillPermissions = Record<string, PermissionState>;
|
|
17
17
|
|
|
18
|
-
export type SpecialPermissionName = "
|
|
18
|
+
export type SpecialPermissionName = "external_directory";
|
|
19
19
|
|
|
20
20
|
export type SpecialPermissions = Record<string, PermissionState>;
|
|
21
21
|
|
|
@@ -133,7 +133,7 @@ describe("loadUnifiedConfig", () => {
|
|
|
133
133
|
expect(result.config.bash).toEqual({ "git *": "ask" });
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
it("collects deprecated special key issues", () => {
|
|
136
|
+
it("collects deprecated special key issues (doom_loop and tool_call_limit)", () => {
|
|
137
137
|
const configPath = join(tempDir, "config.json");
|
|
138
138
|
writeFileSync(
|
|
139
139
|
configPath,
|
|
@@ -143,9 +143,10 @@ describe("loadUnifiedConfig", () => {
|
|
|
143
143
|
);
|
|
144
144
|
|
|
145
145
|
const result = loadUnifiedConfig(configPath);
|
|
146
|
-
expect(result.issues).toHaveLength(
|
|
147
|
-
expect(result.issues
|
|
148
|
-
expect(result.
|
|
146
|
+
expect(result.issues).toHaveLength(2);
|
|
147
|
+
expect(result.issues.some((i) => i.includes("doom_loop"))).toBe(true);
|
|
148
|
+
expect(result.issues.some((i) => i.includes("tool_call_limit"))).toBe(true);
|
|
149
|
+
expect(result.config.special).toBeUndefined();
|
|
149
150
|
});
|
|
150
151
|
});
|
|
151
152
|
|
|
@@ -42,7 +42,6 @@ describe("detectMisplacedPermissionKeys", () => {
|
|
|
42
42
|
skills: {},
|
|
43
43
|
special: {},
|
|
44
44
|
external_directory: {},
|
|
45
|
-
doom_loop: {},
|
|
46
45
|
});
|
|
47
46
|
expect(result).toEqual([
|
|
48
47
|
"defaultPolicy",
|
|
@@ -52,10 +51,16 @@ describe("detectMisplacedPermissionKeys", () => {
|
|
|
52
51
|
"skills",
|
|
53
52
|
"special",
|
|
54
53
|
"external_directory",
|
|
55
|
-
"doom_loop",
|
|
56
54
|
]);
|
|
57
55
|
});
|
|
58
56
|
|
|
57
|
+
it("does not detect doom_loop as a misplaced permission key (deprecated)", () => {
|
|
58
|
+
const result = detectMisplacedPermissionKeys({
|
|
59
|
+
doom_loop: {},
|
|
60
|
+
});
|
|
61
|
+
expect(result).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
59
64
|
it("ignores unknown keys that are not permission-rule keys", () => {
|
|
60
65
|
const result = detectMisplacedPermissionKeys({
|
|
61
66
|
debugLog: true,
|