@gotgenes/pi-permission-system 3.2.0 → 3.4.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 +29 -0
- package/README.md +18 -0
- package/package.json +1 -1
- package/src/index.ts +231 -223
- package/src/permission-dialog.ts +14 -1
- package/src/permission-gate.ts +74 -0
- package/src/session-approval-cache.ts +81 -0
- package/tests/permission-dialog.test.ts +166 -0
- package/tests/permission-gate.test.ts +201 -0
- package/tests/permission-system.test.ts +289 -0
- package/tests/session-approval-cache.test.ts +131 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,35 @@ 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.4.0](https://github.com/gotgenes/pi-permission-system/compare/v3.3.0...v3.4.0) (2026-05-03)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* 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))
|
|
14
|
+
* 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))
|
|
15
|
+
* 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))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Documentation
|
|
19
|
+
|
|
20
|
+
* 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))
|
|
21
|
+
* 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))
|
|
22
|
+
* **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))
|
|
23
|
+
|
|
24
|
+
## [3.3.0](https://github.com/gotgenes/pi-permission-system/compare/v3.2.0...v3.3.0) (2026-05-03)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Features
|
|
28
|
+
|
|
29
|
+
* add permission-gate module ([507a1b6](https://github.com/gotgenes/pi-permission-system/commit/507a1b6155513562958bb277cd7c38ed8d44c215)), closes [#41](https://github.com/gotgenes/pi-permission-system/issues/41)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
|
|
34
|
+
* plan extract reusable permission-gate function ([#41](https://github.com/gotgenes/pi-permission-system/issues/41)) ([2458bf2](https://github.com/gotgenes/pi-permission-system/commit/2458bf28f6c698db78c7a65cfdb6afa488e5b6ee))
|
|
35
|
+
* **retro:** add retro notes for issue [#44](https://github.com/gotgenes/pi-permission-system/issues/44) ([963bb1b](https://github.com/gotgenes/pi-permission-system/commit/963bb1ba78d7e305a80732a55e385266e5222b82))
|
|
36
|
+
|
|
8
37
|
## [3.2.0](https://github.com/gotgenes/pi-permission-system/compare/v3.1.0...v3.2.0) (2026-05-03)
|
|
9
38
|
|
|
10
39
|
|
package/README.md
CHANGED
|
@@ -470,6 +470,23 @@ Example edit approval prompt:
|
|
|
470
470
|
Current agent requested tool 'edit' for '.gitignore' (1 replacement: edit #1 replaces 5 lines with 2 lines). Allow this call?
|
|
471
471
|
```
|
|
472
472
|
|
|
473
|
+
### Session-Scoped Approvals
|
|
474
|
+
|
|
475
|
+
When `special.external_directory` resolves to `ask`, the permission dialog offers four options:
|
|
476
|
+
|
|
477
|
+
```text
|
|
478
|
+
Yes | Yes, for this session | No | No, provide reason
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
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.
|
|
482
|
+
For example, approving access to `~/other-project/src/foo.ts` covers all paths under `~/other-project/src/` until the session ends.
|
|
483
|
+
|
|
484
|
+
Session approvals are ephemeral — they are never persisted to disk and are cleared on `session_shutdown`.
|
|
485
|
+
The review log records these decisions with `resolution: "session_approved"` so they remain auditable.
|
|
486
|
+
|
|
487
|
+
This is currently scoped to the `external_directory` surface only.
|
|
488
|
+
Other permission surfaces (tools, bash patterns, MCP, skills) always use the standard one-time approval flow.
|
|
489
|
+
|
|
473
490
|
### Subagent Permission Forwarding
|
|
474
491
|
|
|
475
492
|
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 +531,7 @@ This makes it easy to verify which files the extension actually loaded:
|
|
|
514
531
|
index.ts → Root Pi entrypoint shim
|
|
515
532
|
src/
|
|
516
533
|
├── index.ts → Extension bootstrap, permission checks, readable prompts, review logging, reload handling, and subagent forwarding
|
|
534
|
+
├── session-approval-cache.ts → Ephemeral session-scoped approval cache for external-directory access
|
|
517
535
|
├── config-loader.ts → Unified config loader, merger, and legacy-path detection
|
|
518
536
|
├── config-paths.ts → Path derivation for global, project, and legacy config locations
|
|
519
537
|
├── config-reporter.ts → Resolved config path reporting for diagnostic logs
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
requestPermissionDecisionFromUi,
|
|
69
69
|
} from "./permission-dialog";
|
|
70
70
|
import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
|
|
71
|
+
import { applyPermissionGate } from "./permission-gate";
|
|
71
72
|
import { PermissionManager } from "./permission-manager";
|
|
72
73
|
import {
|
|
73
74
|
formatAskPrompt,
|
|
@@ -79,6 +80,10 @@ import {
|
|
|
79
80
|
formatUnknownToolReason,
|
|
80
81
|
formatUserDeniedReason,
|
|
81
82
|
} from "./permission-prompts";
|
|
83
|
+
import {
|
|
84
|
+
deriveApprovalPrefix,
|
|
85
|
+
SessionApprovalCache,
|
|
86
|
+
} from "./session-approval-cache";
|
|
82
87
|
import {
|
|
83
88
|
findSkillPathMatch,
|
|
84
89
|
resolveSkillPromptEntries,
|
|
@@ -233,6 +238,7 @@ function createPermissionManagerForCwd(
|
|
|
233
238
|
|
|
234
239
|
export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
235
240
|
let permissionManager = new PermissionManager();
|
|
241
|
+
const sessionApprovalCache = new SessionApprovalCache();
|
|
236
242
|
let activeSkillEntries: SkillPromptEntry[] = [];
|
|
237
243
|
let lastKnownActiveAgentName: string | null = null;
|
|
238
244
|
let lastActiveToolsCacheKey: string | null = null;
|
|
@@ -586,6 +592,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
586
592
|
runtimeContext?.ui.setStatus(PERMISSION_SYSTEM_STATUS_KEY, undefined);
|
|
587
593
|
runtimeContext = null;
|
|
588
594
|
invalidateAgentStartCache();
|
|
595
|
+
sessionApprovalCache.clear();
|
|
589
596
|
stopForwardedPermissionPolling();
|
|
590
597
|
});
|
|
591
598
|
|
|
@@ -673,45 +680,44 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
673
680
|
agentName ?? undefined,
|
|
674
681
|
);
|
|
675
682
|
|
|
676
|
-
if (check.state === "deny") {
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
ctx.ui.notify(message, "warning");
|
|
682
|
-
}
|
|
683
|
-
writeReviewLog("permission_request.blocked", {
|
|
684
|
-
source: "skill_input",
|
|
685
|
-
skillName,
|
|
686
|
-
agentName,
|
|
687
|
-
resolution: "policy_denied",
|
|
688
|
-
});
|
|
689
|
-
return { action: "handled" };
|
|
683
|
+
if (check.state === "deny" && ctx.hasUI) {
|
|
684
|
+
const notifyMessage = agentName
|
|
685
|
+
? `Skill '${skillName}' is not permitted for agent '${agentName}'.`
|
|
686
|
+
: `Skill '${skillName}' is not permitted by the current skill policy.`;
|
|
687
|
+
ctx.ui.notify(notifyMessage, "warning");
|
|
690
688
|
}
|
|
691
689
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
690
|
+
const skillInputMessage = formatSkillAskPrompt(
|
|
691
|
+
skillName,
|
|
692
|
+
agentName ?? undefined,
|
|
693
|
+
);
|
|
694
|
+
const skillInputGate = await applyPermissionGate({
|
|
695
|
+
state: check.state,
|
|
696
|
+
canConfirm: canRequestPermissionConfirmation(ctx),
|
|
697
|
+
promptForApproval: () =>
|
|
698
|
+
promptPermission(ctx, {
|
|
699
|
+
requestId: createPermissionRequestId("skill-input"),
|
|
696
700
|
source: "skill_input",
|
|
697
|
-
skillName,
|
|
698
701
|
agentName,
|
|
699
|
-
message,
|
|
700
|
-
|
|
701
|
-
})
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
const decision = await promptPermission(ctx, {
|
|
706
|
-
requestId: createPermissionRequestId("skill-input"),
|
|
702
|
+
message: skillInputMessage,
|
|
703
|
+
skillName,
|
|
704
|
+
}),
|
|
705
|
+
writeLog: writeReviewLog,
|
|
706
|
+
logContext: {
|
|
707
707
|
source: "skill_input",
|
|
708
|
-
agentName,
|
|
709
|
-
message,
|
|
710
708
|
skillName,
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
709
|
+
agentName,
|
|
710
|
+
message: skillInputMessage,
|
|
711
|
+
},
|
|
712
|
+
messages: {
|
|
713
|
+
denyReason: skillInputMessage,
|
|
714
|
+
unavailableReason:
|
|
715
|
+
"Skill requires approval, but no interactive UI is available.",
|
|
716
|
+
userDeniedReason: () => "User denied skill.",
|
|
717
|
+
},
|
|
718
|
+
});
|
|
719
|
+
if (skillInputGate.action === "block") {
|
|
720
|
+
return { action: "handled" };
|
|
715
721
|
}
|
|
716
722
|
|
|
717
723
|
return { action: "continue" };
|
|
@@ -756,64 +762,50 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
756
762
|
);
|
|
757
763
|
|
|
758
764
|
if (matchedSkill) {
|
|
759
|
-
|
|
760
|
-
|
|
765
|
+
const skillReadMessage = formatSkillPathAskPrompt(
|
|
766
|
+
matchedSkill,
|
|
767
|
+
event.input.path,
|
|
768
|
+
agentName ?? undefined,
|
|
769
|
+
);
|
|
770
|
+
const skillReadGate = await applyPermissionGate({
|
|
771
|
+
state: matchedSkill.state,
|
|
772
|
+
canConfirm: canRequestPermissionConfirmation(ctx),
|
|
773
|
+
promptForApproval: () =>
|
|
774
|
+
promptPermission(ctx, {
|
|
775
|
+
requestId: event.toolCallId,
|
|
776
|
+
source: "skill_read",
|
|
777
|
+
agentName,
|
|
778
|
+
message: skillReadMessage,
|
|
779
|
+
toolCallId: event.toolCallId,
|
|
780
|
+
toolName: toolName,
|
|
781
|
+
skillName: matchedSkill.name,
|
|
782
|
+
path: event.input.path,
|
|
783
|
+
}),
|
|
784
|
+
writeLog: writeReviewLog,
|
|
785
|
+
logContext: {
|
|
761
786
|
source: "skill_read",
|
|
762
787
|
skillName: matchedSkill.name,
|
|
763
788
|
agentName,
|
|
764
789
|
path: event.input.path,
|
|
765
|
-
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
reason: formatSkillPathDenyReason(
|
|
790
|
+
message: skillReadMessage,
|
|
791
|
+
},
|
|
792
|
+
messages: {
|
|
793
|
+
denyReason: formatSkillPathDenyReason(
|
|
770
794
|
matchedSkill,
|
|
771
795
|
event.input.path,
|
|
772
796
|
agentName ?? undefined,
|
|
773
797
|
),
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
source: "skill_read",
|
|
786
|
-
skillName: matchedSkill.name,
|
|
787
|
-
agentName,
|
|
788
|
-
path: event.input.path,
|
|
789
|
-
message,
|
|
790
|
-
resolution: "confirmation_unavailable",
|
|
791
|
-
});
|
|
792
|
-
return {
|
|
793
|
-
block: true,
|
|
794
|
-
reason: `Accessing skill '${matchedSkill.name}' requires approval, but no interactive UI is available.`,
|
|
795
|
-
};
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
const decision = await promptPermission(ctx, {
|
|
799
|
-
requestId: event.toolCallId,
|
|
800
|
-
source: "skill_read",
|
|
801
|
-
agentName,
|
|
802
|
-
message,
|
|
803
|
-
toolCallId: event.toolCallId,
|
|
804
|
-
toolName: toolName,
|
|
805
|
-
skillName: matchedSkill.name,
|
|
806
|
-
path: event.input.path,
|
|
807
|
-
});
|
|
808
|
-
if (!decision.approved) {
|
|
809
|
-
const denialReason = decision.denialReason
|
|
810
|
-
? ` Reason: ${decision.denialReason}.`
|
|
811
|
-
: "";
|
|
812
|
-
return {
|
|
813
|
-
block: true,
|
|
814
|
-
reason: `User denied access to skill '${matchedSkill.name}'.${denialReason}`,
|
|
815
|
-
};
|
|
816
|
-
}
|
|
798
|
+
unavailableReason: `Accessing skill '${matchedSkill.name}' requires approval, but no interactive UI is available.`,
|
|
799
|
+
userDeniedReason: (decision) => {
|
|
800
|
+
const denialReason = decision.denialReason
|
|
801
|
+
? ` Reason: ${decision.denialReason}.`
|
|
802
|
+
: "";
|
|
803
|
+
return `User denied access to skill '${matchedSkill.name}'.${denialReason}`;
|
|
804
|
+
},
|
|
805
|
+
},
|
|
806
|
+
});
|
|
807
|
+
if (skillReadGate.action === "block") {
|
|
808
|
+
return { block: true, reason: skillReadGate.reason };
|
|
817
809
|
}
|
|
818
810
|
}
|
|
819
811
|
}
|
|
@@ -828,77 +820,91 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
828
820
|
externalDirectoryPath &&
|
|
829
821
|
isPathOutsideWorkingDirectory(externalDirectoryPath, ctx.cwd)
|
|
830
822
|
) {
|
|
831
|
-
const
|
|
823
|
+
const normalizedExtPath = normalizePathForComparison(
|
|
824
|
+
externalDirectoryPath,
|
|
825
|
+
ctx.cwd,
|
|
826
|
+
);
|
|
827
|
+
const sessionPrefix = sessionApprovalCache.findMatchingPrefix(
|
|
832
828
|
"external_directory",
|
|
833
|
-
|
|
834
|
-
agentName ?? undefined,
|
|
829
|
+
normalizedExtPath,
|
|
835
830
|
);
|
|
836
831
|
|
|
837
|
-
if (
|
|
838
|
-
writeReviewLog("permission_request.
|
|
832
|
+
if (sessionPrefix) {
|
|
833
|
+
writeReviewLog("permission_request.session_approved", {
|
|
839
834
|
source: "tool_call",
|
|
840
835
|
toolCallId: event.toolCallId,
|
|
841
836
|
toolName,
|
|
842
837
|
agentName,
|
|
843
838
|
path: externalDirectoryPath,
|
|
844
|
-
resolution: "
|
|
839
|
+
resolution: "session_approved",
|
|
840
|
+
sessionApprovalPrefix: sessionPrefix,
|
|
845
841
|
});
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
),
|
|
854
|
-
};
|
|
855
|
-
}
|
|
842
|
+
// Fall through to normal permission check
|
|
843
|
+
} else {
|
|
844
|
+
const extCheck = permissionManager.checkPermission(
|
|
845
|
+
"external_directory",
|
|
846
|
+
{},
|
|
847
|
+
agentName ?? undefined,
|
|
848
|
+
);
|
|
856
849
|
|
|
857
|
-
|
|
858
|
-
const
|
|
850
|
+
let extDirDecision: PermissionPromptDecision | null = null;
|
|
851
|
+
const extDirMessage = formatExternalDirectoryAskPrompt(
|
|
859
852
|
toolName,
|
|
860
853
|
externalDirectoryPath,
|
|
861
854
|
ctx.cwd,
|
|
862
855
|
agentName ?? undefined,
|
|
863
856
|
);
|
|
864
|
-
|
|
865
|
-
|
|
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: {
|
|
866
875
|
source: "tool_call",
|
|
867
876
|
toolCallId: event.toolCallId,
|
|
868
877
|
toolName,
|
|
869
878
|
agentName,
|
|
870
879
|
path: externalDirectoryPath,
|
|
871
|
-
message,
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
block: true,
|
|
876
|
-
reason: `Accessing '${externalDirectoryPath}' outside the working directory requires approval, but no interactive UI is available.`,
|
|
877
|
-
};
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
const extDecision = await promptPermission(ctx, {
|
|
881
|
-
requestId: event.toolCallId,
|
|
882
|
-
source: "tool_call",
|
|
883
|
-
agentName,
|
|
884
|
-
message,
|
|
885
|
-
toolCallId: event.toolCallId,
|
|
886
|
-
toolName,
|
|
887
|
-
path: externalDirectoryPath,
|
|
888
|
-
});
|
|
889
|
-
|
|
890
|
-
if (!extDecision.approved) {
|
|
891
|
-
return {
|
|
892
|
-
block: true,
|
|
893
|
-
reason: formatExternalDirectoryUserDeniedReason(
|
|
880
|
+
message: extDirMessage,
|
|
881
|
+
},
|
|
882
|
+
messages: {
|
|
883
|
+
denyReason: formatExternalDirectoryDenyReason(
|
|
894
884
|
toolName,
|
|
895
885
|
externalDirectoryPath,
|
|
896
|
-
|
|
886
|
+
ctx.cwd,
|
|
887
|
+
agentName ?? undefined,
|
|
897
888
|
),
|
|
898
|
-
|
|
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);
|
|
899
905
|
}
|
|
900
906
|
}
|
|
901
|
-
//
|
|
907
|
+
// Fall through to normal permission check
|
|
902
908
|
}
|
|
903
909
|
|
|
904
910
|
// Bash external directory gate: extract paths from bash commands
|
|
@@ -910,77 +916,91 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
910
916
|
ctx.cwd,
|
|
911
917
|
);
|
|
912
918
|
if (externalPaths.length > 0) {
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
agentName ?? undefined,
|
|
919
|
+
// Filter out paths already covered by session approvals
|
|
920
|
+
const uncoveredPaths = externalPaths.filter(
|
|
921
|
+
(p) => !sessionApprovalCache.has("external_directory", p),
|
|
917
922
|
);
|
|
918
923
|
|
|
919
|
-
if (
|
|
920
|
-
|
|
924
|
+
if (uncoveredPaths.length === 0) {
|
|
925
|
+
// All external paths are session-approved
|
|
926
|
+
writeReviewLog("permission_request.session_approved", {
|
|
921
927
|
source: "tool_call",
|
|
922
928
|
toolCallId: event.toolCallId,
|
|
923
929
|
toolName,
|
|
924
930
|
agentName,
|
|
925
931
|
command,
|
|
926
932
|
externalPaths,
|
|
927
|
-
resolution: "
|
|
933
|
+
resolution: "session_approved",
|
|
928
934
|
});
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
),
|
|
937
|
-
};
|
|
938
|
-
}
|
|
935
|
+
// Fall through to normal bash permission check
|
|
936
|
+
} else {
|
|
937
|
+
const extCheck = permissionManager.checkPermission(
|
|
938
|
+
"external_directory",
|
|
939
|
+
{},
|
|
940
|
+
agentName ?? undefined,
|
|
941
|
+
);
|
|
939
942
|
|
|
940
|
-
|
|
941
|
-
const
|
|
943
|
+
let bashExtDecision: PermissionPromptDecision | null = null;
|
|
944
|
+
const bashExtMessage = formatBashExternalDirectoryAskPrompt(
|
|
942
945
|
command,
|
|
943
|
-
|
|
946
|
+
uncoveredPaths,
|
|
944
947
|
ctx.cwd,
|
|
945
948
|
agentName ?? undefined,
|
|
946
949
|
);
|
|
947
|
-
|
|
948
|
-
|
|
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: {
|
|
949
968
|
source: "tool_call",
|
|
950
969
|
toolCallId: event.toolCallId,
|
|
951
970
|
toolName,
|
|
952
971
|
agentName,
|
|
953
972
|
command,
|
|
954
|
-
externalPaths,
|
|
955
|
-
message,
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
973
|
+
externalPaths: uncoveredPaths,
|
|
974
|
+
message: bashExtMessage,
|
|
975
|
+
},
|
|
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 };
|
|
962
994
|
}
|
|
963
995
|
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
toolCallId: event.toolCallId,
|
|
970
|
-
toolName,
|
|
971
|
-
command,
|
|
972
|
-
});
|
|
973
|
-
if (!extDecision.approved) {
|
|
974
|
-
const reasonSuffix = extDecision.denialReason
|
|
975
|
-
? ` Reason: ${extDecision.denialReason}.`
|
|
976
|
-
: "";
|
|
977
|
-
return {
|
|
978
|
-
block: true,
|
|
979
|
-
reason: `User denied external directory access for bash command '${command}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`,
|
|
980
|
-
};
|
|
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
|
+
}
|
|
981
1001
|
}
|
|
982
1002
|
}
|
|
983
|
-
//
|
|
1003
|
+
// Fall through to normal bash permission check
|
|
984
1004
|
}
|
|
985
1005
|
}
|
|
986
1006
|
}
|
|
@@ -996,61 +1016,49 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
996
1016
|
PATH_BEARING_TOOLS,
|
|
997
1017
|
);
|
|
998
1018
|
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
...permissionLogContext,
|
|
1006
|
-
resolution: "policy_denied",
|
|
1007
|
-
});
|
|
1008
|
-
return {
|
|
1009
|
-
block: true,
|
|
1010
|
-
reason: formatDenyReason(check, agentName ?? undefined),
|
|
1011
|
-
};
|
|
1012
|
-
}
|
|
1019
|
+
const toolUnavailableReason =
|
|
1020
|
+
toolName === "bash" && isToolCallEventType("bash", event)
|
|
1021
|
+
? `Running bash command '${event.input.command}' requires approval, but no interactive UI is available.`
|
|
1022
|
+
: toolName === "mcp"
|
|
1023
|
+
? "Using tool 'mcp' requires approval, but no interactive UI is available."
|
|
1024
|
+
: `Using tool '${toolName}' requires approval, but no interactive UI is available.`;
|
|
1013
1025
|
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1026
|
+
const toolAskMessage = formatAskPrompt(
|
|
1027
|
+
check,
|
|
1028
|
+
agentName ?? undefined,
|
|
1029
|
+
input,
|
|
1030
|
+
);
|
|
1031
|
+
const toolGate = await applyPermissionGate({
|
|
1032
|
+
state: check.state,
|
|
1033
|
+
canConfirm: canRequestPermissionConfirmation(ctx),
|
|
1034
|
+
promptForApproval: () =>
|
|
1035
|
+
promptPermission(ctx, {
|
|
1036
|
+
requestId: event.toolCallId,
|
|
1025
1037
|
source: "tool_call",
|
|
1038
|
+
agentName,
|
|
1039
|
+
message: toolAskMessage,
|
|
1026
1040
|
toolCallId: event.toolCallId,
|
|
1027
1041
|
toolName,
|
|
1028
|
-
agentName,
|
|
1029
|
-
message,
|
|
1030
1042
|
...permissionLogContext,
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
block: true,
|
|
1035
|
-
reason: unavailableReason,
|
|
1036
|
-
};
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
const decision = await promptPermission(ctx, {
|
|
1040
|
-
requestId: event.toolCallId,
|
|
1043
|
+
}),
|
|
1044
|
+
writeLog: writeReviewLog,
|
|
1045
|
+
logContext: {
|
|
1041
1046
|
source: "tool_call",
|
|
1042
|
-
agentName,
|
|
1043
|
-
message,
|
|
1044
1047
|
toolCallId: event.toolCallId,
|
|
1045
1048
|
toolName,
|
|
1049
|
+
agentName,
|
|
1050
|
+
message: toolAskMessage,
|
|
1046
1051
|
...permissionLogContext,
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
}
|
|
1052
|
+
},
|
|
1053
|
+
messages: {
|
|
1054
|
+
denyReason: formatDenyReason(check, agentName ?? undefined),
|
|
1055
|
+
unavailableReason: toolUnavailableReason,
|
|
1056
|
+
userDeniedReason: (decision) =>
|
|
1057
|
+
formatUserDeniedReason(check, decision.denialReason),
|
|
1058
|
+
},
|
|
1059
|
+
});
|
|
1060
|
+
if (toolGate.action === "block") {
|
|
1061
|
+
return { block: true, reason: toolGate.reason };
|
|
1054
1062
|
}
|
|
1055
1063
|
|
|
1056
1064
|
return {};
|