@gotgenes/pi-permission-system 3.1.0 → 3.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 +29 -0
- package/README.md +1 -1
- package/package.json +3 -8
- package/src/external-directory.ts +26 -5
- package/src/index.ts +172 -231
- package/src/permission-gate.ts +74 -0
- package/tests/bash-external-directory.test.ts +56 -0
- package/tests/external-directory.test.ts +65 -0
- package/tests/permission-gate.test.ts +201 -0
- package/index.ts +0 -3
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.3.0](https://github.com/gotgenes/pi-permission-system/compare/v3.2.0...v3.3.0) (2026-05-03)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* 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)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* 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))
|
|
19
|
+
* **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))
|
|
20
|
+
|
|
21
|
+
## [3.2.0](https://github.com/gotgenes/pi-permission-system/compare/v3.1.0...v3.2.0) (2026-05-03)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Features
|
|
25
|
+
|
|
26
|
+
* add SAFE_SYSTEM_PATHS allowlist and isSafeSystemPath helper ([#44](https://github.com/gotgenes/pi-permission-system/issues/44)) ([331b53f](https://github.com/gotgenes/pi-permission-system/commit/331b53f1a6425c7ee641127cbc82b5aada1e7018))
|
|
27
|
+
* filter safe system paths from bash external path extraction ([#44](https://github.com/gotgenes/pi-permission-system/issues/44)) ([a0a907f](https://github.com/gotgenes/pi-permission-system/commit/a0a907f020cbf08722ea47be11d3f67fc95ef448))
|
|
28
|
+
* skip safe system paths in isPathOutsideWorkingDirectory ([#44](https://github.com/gotgenes/pi-permission-system/issues/44)) ([360594c](https://github.com/gotgenes/pi-permission-system/commit/360594c8ddfd6f7f45abe04352a514de292df357))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Documentation
|
|
32
|
+
|
|
33
|
+
* clarify /dev/null redirect risks in plan [#44](https://github.com/gotgenes/pi-permission-system/issues/44) ([00c61e7](https://github.com/gotgenes/pi-permission-system/commit/00c61e75eb5bf3cd9d5fc3297024ef9642655b86))
|
|
34
|
+
* note safe system path allowlist in external-directory section ([#44](https://github.com/gotgenes/pi-permission-system/issues/44)) ([eaec9ae](https://github.com/gotgenes/pi-permission-system/commit/eaec9ae4ad88155bf2630bba9607920cbbdc8583))
|
|
35
|
+
* plan auto-allow /dev/null in external directory checks ([#44](https://github.com/gotgenes/pi-permission-system/issues/44)) ([90b94f4](https://github.com/gotgenes/pi-permission-system/commit/90b94f4e0ae01b3a9f9dc90c5426720742a652e2))
|
|
36
|
+
|
|
8
37
|
## [3.1.0](https://github.com/gotgenes/pi-permission-system/compare/v3.0.5...v3.1.0) (2026-05-03)
|
|
9
38
|
|
|
10
39
|
|
package/README.md
CHANGED
|
@@ -367,7 +367,7 @@ Reserved permission checks:
|
|
|
367
367
|
|
|
368
368
|
`external_directory` is evaluated before the normal tool permission check. For example, `tools.read: "allow"` can permit ordinary reads while `special.external_directory: "ask"` still requires confirmation before reading `../outside.txt` or an absolute path outside `ctx.cwd`. Optional-path search tools (`find`, `grep`, `ls`) skip this check when no `path` is provided because they default to the active working directory.
|
|
369
369
|
|
|
370
|
-
Bash commands are also covered: the extension extracts path-like tokens from the command string and applies the same gate when any resolve outside `ctx.cwd`. Quoted strings are stripped first to reduce false positives (e.g., paths inside `git commit -m "..."` messages). This is a best-effort heuristic — variable expansion, subshells, and escaped quotes are not parsed.
|
|
370
|
+
Bash commands are also covered: the extension extracts path-like tokens from the command string and applies the same gate when any resolve outside `ctx.cwd`. Quoted strings are stripped first to reduce false positives (e.g., paths inside `git commit -m "..."` messages). This is a best-effort heuristic — variable expansion, subshells, and escaped quotes are not parsed. OS device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are always excluded from this check — they cannot hold or leak data and commonly appear in stderr-redirect idioms such as `command 2>/dev/null`.
|
|
371
371
|
|
|
372
372
|
---
|
|
373
373
|
|
package/package.json
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotgenes/pi-permission-system",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./index.ts",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": "./index.ts"
|
|
9
|
-
},
|
|
10
6
|
"files": [
|
|
11
|
-
"index.ts",
|
|
12
7
|
"src",
|
|
13
8
|
"tests",
|
|
14
9
|
"config/config.example.json",
|
|
@@ -49,7 +44,7 @@
|
|
|
49
44
|
},
|
|
50
45
|
"pi": {
|
|
51
46
|
"extensions": [
|
|
52
|
-
"./index.ts"
|
|
47
|
+
"./src/index.ts"
|
|
53
48
|
]
|
|
54
49
|
},
|
|
55
50
|
"peerDependencies": {
|
|
@@ -71,7 +66,7 @@
|
|
|
71
66
|
"lint:fix": "biome check --write .",
|
|
72
67
|
"lint:md": "markdownlint-cli2 '*.md' 'docs/**/*.md' '.pi/prompts/**/*.md'",
|
|
73
68
|
"lint:md:fix": "markdownlint-cli2 --fix '*.md' 'docs/**/*.md' '.pi/prompts/**/*.md'",
|
|
74
|
-
"lint:imports": "! grep -rn --include='*.ts' 'from \"\\.[./][^\"]*\\.js\"' src/ tests/
|
|
69
|
+
"lint:imports": "! grep -rn --include='*.ts' 'from \"\\.[./][^\"]*\\.js\"' src/ tests/",
|
|
75
70
|
"lint:all": "pnpm run lint && pnpm run lint:md && pnpm run lint:imports",
|
|
76
71
|
"format": "biome format --write .",
|
|
77
72
|
"test": "vitest run",
|
|
@@ -3,6 +3,25 @@ import { join, normalize, resolve, sep } from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import { getNonEmptyString, toRecord } from "./common";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Paths that are universally safe and should never trigger external-directory checks.
|
|
8
|
+
* These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
|
|
9
|
+
*/
|
|
10
|
+
export const SAFE_SYSTEM_PATHS: ReadonlySet<string> = new Set([
|
|
11
|
+
"/dev/null",
|
|
12
|
+
"/dev/stdin",
|
|
13
|
+
"/dev/stdout",
|
|
14
|
+
"/dev/stderr",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Returns true if the given normalized path is a safe OS device file
|
|
19
|
+
* that should never trigger external-directory checks.
|
|
20
|
+
*/
|
|
21
|
+
export function isSafeSystemPath(normalizedPath: string): boolean {
|
|
22
|
+
return SAFE_SYSTEM_PATHS.has(normalizedPath);
|
|
23
|
+
}
|
|
24
|
+
|
|
6
25
|
export const PATH_BEARING_TOOLS = new Set([
|
|
7
26
|
"read",
|
|
8
27
|
"write",
|
|
@@ -72,11 +91,13 @@ export function isPathOutsideWorkingDirectory(
|
|
|
72
91
|
): boolean {
|
|
73
92
|
const normalizedCwd = normalizePathForComparison(cwd, cwd);
|
|
74
93
|
const normalizedPath = normalizePathForComparison(pathValue, cwd);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
94
|
+
if (!normalizedCwd || !normalizedPath) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
if (isSafeSystemPath(normalizedPath)) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
return !isPathWithinDirectory(normalizedPath, normalizedCwd);
|
|
80
101
|
}
|
|
81
102
|
|
|
82
103
|
export function formatExternalDirectoryHardStopHint(): string {
|
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,
|
|
@@ -673,45 +674,44 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
673
674
|
agentName ?? undefined,
|
|
674
675
|
);
|
|
675
676
|
|
|
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" };
|
|
677
|
+
if (check.state === "deny" && ctx.hasUI) {
|
|
678
|
+
const notifyMessage = agentName
|
|
679
|
+
? `Skill '${skillName}' is not permitted for agent '${agentName}'.`
|
|
680
|
+
: `Skill '${skillName}' is not permitted by the current skill policy.`;
|
|
681
|
+
ctx.ui.notify(notifyMessage, "warning");
|
|
690
682
|
}
|
|
691
683
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
684
|
+
const skillInputMessage = formatSkillAskPrompt(
|
|
685
|
+
skillName,
|
|
686
|
+
agentName ?? undefined,
|
|
687
|
+
);
|
|
688
|
+
const skillInputGate = await applyPermissionGate({
|
|
689
|
+
state: check.state,
|
|
690
|
+
canConfirm: canRequestPermissionConfirmation(ctx),
|
|
691
|
+
promptForApproval: () =>
|
|
692
|
+
promptPermission(ctx, {
|
|
693
|
+
requestId: createPermissionRequestId("skill-input"),
|
|
696
694
|
source: "skill_input",
|
|
697
|
-
skillName,
|
|
698
695
|
agentName,
|
|
699
|
-
message,
|
|
700
|
-
|
|
701
|
-
})
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
const decision = await promptPermission(ctx, {
|
|
706
|
-
requestId: createPermissionRequestId("skill-input"),
|
|
696
|
+
message: skillInputMessage,
|
|
697
|
+
skillName,
|
|
698
|
+
}),
|
|
699
|
+
writeLog: writeReviewLog,
|
|
700
|
+
logContext: {
|
|
707
701
|
source: "skill_input",
|
|
708
|
-
agentName,
|
|
709
|
-
message,
|
|
710
702
|
skillName,
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
703
|
+
agentName,
|
|
704
|
+
message: skillInputMessage,
|
|
705
|
+
},
|
|
706
|
+
messages: {
|
|
707
|
+
denyReason: skillInputMessage,
|
|
708
|
+
unavailableReason:
|
|
709
|
+
"Skill requires approval, but no interactive UI is available.",
|
|
710
|
+
userDeniedReason: () => "User denied skill.",
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
if (skillInputGate.action === "block") {
|
|
714
|
+
return { action: "handled" };
|
|
715
715
|
}
|
|
716
716
|
|
|
717
717
|
return { action: "continue" };
|
|
@@ -756,64 +756,50 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
756
756
|
);
|
|
757
757
|
|
|
758
758
|
if (matchedSkill) {
|
|
759
|
-
|
|
760
|
-
|
|
759
|
+
const skillReadMessage = formatSkillPathAskPrompt(
|
|
760
|
+
matchedSkill,
|
|
761
|
+
event.input.path,
|
|
762
|
+
agentName ?? undefined,
|
|
763
|
+
);
|
|
764
|
+
const skillReadGate = await applyPermissionGate({
|
|
765
|
+
state: matchedSkill.state,
|
|
766
|
+
canConfirm: canRequestPermissionConfirmation(ctx),
|
|
767
|
+
promptForApproval: () =>
|
|
768
|
+
promptPermission(ctx, {
|
|
769
|
+
requestId: event.toolCallId,
|
|
770
|
+
source: "skill_read",
|
|
771
|
+
agentName,
|
|
772
|
+
message: skillReadMessage,
|
|
773
|
+
toolCallId: event.toolCallId,
|
|
774
|
+
toolName: toolName,
|
|
775
|
+
skillName: matchedSkill.name,
|
|
776
|
+
path: event.input.path,
|
|
777
|
+
}),
|
|
778
|
+
writeLog: writeReviewLog,
|
|
779
|
+
logContext: {
|
|
761
780
|
source: "skill_read",
|
|
762
781
|
skillName: matchedSkill.name,
|
|
763
782
|
agentName,
|
|
764
783
|
path: event.input.path,
|
|
765
|
-
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
reason: formatSkillPathDenyReason(
|
|
784
|
+
message: skillReadMessage,
|
|
785
|
+
},
|
|
786
|
+
messages: {
|
|
787
|
+
denyReason: formatSkillPathDenyReason(
|
|
770
788
|
matchedSkill,
|
|
771
789
|
event.input.path,
|
|
772
790
|
agentName ?? undefined,
|
|
773
791
|
),
|
|
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
|
-
}
|
|
792
|
+
unavailableReason: `Accessing skill '${matchedSkill.name}' requires approval, but no interactive UI is available.`,
|
|
793
|
+
userDeniedReason: (decision) => {
|
|
794
|
+
const denialReason = decision.denialReason
|
|
795
|
+
? ` Reason: ${decision.denialReason}.`
|
|
796
|
+
: "";
|
|
797
|
+
return `User denied access to skill '${matchedSkill.name}'.${denialReason}`;
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
});
|
|
801
|
+
if (skillReadGate.action === "block") {
|
|
802
|
+
return { block: true, reason: skillReadGate.reason };
|
|
817
803
|
}
|
|
818
804
|
}
|
|
819
805
|
}
|
|
@@ -834,69 +820,52 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
834
820
|
agentName ?? undefined,
|
|
835
821
|
);
|
|
836
822
|
|
|
837
|
-
|
|
838
|
-
|
|
823
|
+
const extDirMessage = formatExternalDirectoryAskPrompt(
|
|
824
|
+
toolName,
|
|
825
|
+
externalDirectoryPath,
|
|
826
|
+
ctx.cwd,
|
|
827
|
+
agentName ?? undefined,
|
|
828
|
+
);
|
|
829
|
+
const extDirGate = await applyPermissionGate({
|
|
830
|
+
state: extCheck.state,
|
|
831
|
+
canConfirm: canRequestPermissionConfirmation(ctx),
|
|
832
|
+
promptForApproval: () =>
|
|
833
|
+
promptPermission(ctx, {
|
|
834
|
+
requestId: event.toolCallId,
|
|
835
|
+
source: "tool_call",
|
|
836
|
+
agentName,
|
|
837
|
+
message: extDirMessage,
|
|
838
|
+
toolCallId: event.toolCallId,
|
|
839
|
+
toolName,
|
|
840
|
+
path: externalDirectoryPath,
|
|
841
|
+
}),
|
|
842
|
+
writeLog: writeReviewLog,
|
|
843
|
+
logContext: {
|
|
839
844
|
source: "tool_call",
|
|
840
845
|
toolCallId: event.toolCallId,
|
|
841
846
|
toolName,
|
|
842
847
|
agentName,
|
|
843
848
|
path: externalDirectoryPath,
|
|
844
|
-
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
reason: formatExternalDirectoryDenyReason(
|
|
849
|
+
message: extDirMessage,
|
|
850
|
+
},
|
|
851
|
+
messages: {
|
|
852
|
+
denyReason: formatExternalDirectoryDenyReason(
|
|
849
853
|
toolName,
|
|
850
854
|
externalDirectoryPath,
|
|
851
855
|
ctx.cwd,
|
|
852
856
|
agentName ?? undefined,
|
|
853
857
|
),
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
if (extCheck.state === "ask") {
|
|
858
|
-
const message = formatExternalDirectoryAskPrompt(
|
|
859
|
-
toolName,
|
|
860
|
-
externalDirectoryPath,
|
|
861
|
-
ctx.cwd,
|
|
862
|
-
agentName ?? undefined,
|
|
863
|
-
);
|
|
864
|
-
if (!canRequestPermissionConfirmation(ctx)) {
|
|
865
|
-
writeReviewLog("permission_request.blocked", {
|
|
866
|
-
source: "tool_call",
|
|
867
|
-
toolCallId: event.toolCallId,
|
|
868
|
-
toolName,
|
|
869
|
-
agentName,
|
|
870
|
-
path: externalDirectoryPath,
|
|
871
|
-
message,
|
|
872
|
-
resolution: "confirmation_unavailable",
|
|
873
|
-
});
|
|
874
|
-
return {
|
|
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(
|
|
858
|
+
unavailableReason: `Accessing '${externalDirectoryPath}' outside the working directory requires approval, but no interactive UI is available.`,
|
|
859
|
+
userDeniedReason: (decision) =>
|
|
860
|
+
formatExternalDirectoryUserDeniedReason(
|
|
894
861
|
toolName,
|
|
895
862
|
externalDirectoryPath,
|
|
896
|
-
|
|
863
|
+
decision.denialReason,
|
|
897
864
|
),
|
|
898
|
-
|
|
899
|
-
|
|
865
|
+
},
|
|
866
|
+
});
|
|
867
|
+
if (extDirGate.action === "block") {
|
|
868
|
+
return { block: true, reason: extDirGate.reason };
|
|
900
869
|
}
|
|
901
870
|
// state === "allow" → fall through to normal permission check
|
|
902
871
|
}
|
|
@@ -916,69 +885,53 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
916
885
|
agentName ?? undefined,
|
|
917
886
|
);
|
|
918
887
|
|
|
919
|
-
|
|
920
|
-
|
|
888
|
+
const bashExtMessage = formatBashExternalDirectoryAskPrompt(
|
|
889
|
+
command,
|
|
890
|
+
externalPaths,
|
|
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: {
|
|
921
909
|
source: "tool_call",
|
|
922
910
|
toolCallId: event.toolCallId,
|
|
923
911
|
toolName,
|
|
924
912
|
agentName,
|
|
925
913
|
command,
|
|
926
914
|
externalPaths,
|
|
927
|
-
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
reason: formatBashExternalDirectoryDenyReason(
|
|
915
|
+
message: bashExtMessage,
|
|
916
|
+
},
|
|
917
|
+
messages: {
|
|
918
|
+
denyReason: formatBashExternalDirectoryDenyReason(
|
|
932
919
|
command,
|
|
933
920
|
externalPaths,
|
|
934
921
|
ctx.cwd,
|
|
935
922
|
agentName ?? undefined,
|
|
936
923
|
),
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
writeReviewLog("permission_request.blocked", {
|
|
949
|
-
source: "tool_call",
|
|
950
|
-
toolCallId: event.toolCallId,
|
|
951
|
-
toolName,
|
|
952
|
-
agentName,
|
|
953
|
-
command,
|
|
954
|
-
externalPaths,
|
|
955
|
-
message,
|
|
956
|
-
resolution: "confirmation_unavailable",
|
|
957
|
-
});
|
|
958
|
-
return {
|
|
959
|
-
block: true,
|
|
960
|
-
reason: `Bash command '${command}' references path(s) outside the working directory and requires approval, but no interactive UI is available.`,
|
|
961
|
-
};
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
const extDecision = await promptPermission(ctx, {
|
|
965
|
-
requestId: event.toolCallId,
|
|
966
|
-
source: "tool_call",
|
|
967
|
-
agentName,
|
|
968
|
-
message,
|
|
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
|
-
};
|
|
981
|
-
}
|
|
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()}`;
|
|
930
|
+
},
|
|
931
|
+
},
|
|
932
|
+
});
|
|
933
|
+
if (bashExtGate.action === "block") {
|
|
934
|
+
return { block: true, reason: bashExtGate.reason };
|
|
982
935
|
}
|
|
983
936
|
// state === "allow" → fall through to normal bash permission check
|
|
984
937
|
}
|
|
@@ -996,61 +949,49 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
996
949
|
PATH_BEARING_TOOLS,
|
|
997
950
|
);
|
|
998
951
|
|
|
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
|
-
}
|
|
952
|
+
const toolUnavailableReason =
|
|
953
|
+
toolName === "bash" && isToolCallEventType("bash", event)
|
|
954
|
+
? `Running bash command '${event.input.command}' requires approval, but no interactive UI is available.`
|
|
955
|
+
: toolName === "mcp"
|
|
956
|
+
? "Using tool 'mcp' requires approval, but no interactive UI is available."
|
|
957
|
+
: `Using tool '${toolName}' requires approval, but no interactive UI is available.`;
|
|
1013
958
|
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
959
|
+
const toolAskMessage = formatAskPrompt(
|
|
960
|
+
check,
|
|
961
|
+
agentName ?? undefined,
|
|
962
|
+
input,
|
|
963
|
+
);
|
|
964
|
+
const toolGate = await applyPermissionGate({
|
|
965
|
+
state: check.state,
|
|
966
|
+
canConfirm: canRequestPermissionConfirmation(ctx),
|
|
967
|
+
promptForApproval: () =>
|
|
968
|
+
promptPermission(ctx, {
|
|
969
|
+
requestId: event.toolCallId,
|
|
1025
970
|
source: "tool_call",
|
|
971
|
+
agentName,
|
|
972
|
+
message: toolAskMessage,
|
|
1026
973
|
toolCallId: event.toolCallId,
|
|
1027
974
|
toolName,
|
|
1028
|
-
agentName,
|
|
1029
|
-
message,
|
|
1030
975
|
...permissionLogContext,
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
block: true,
|
|
1035
|
-
reason: unavailableReason,
|
|
1036
|
-
};
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
const decision = await promptPermission(ctx, {
|
|
1040
|
-
requestId: event.toolCallId,
|
|
976
|
+
}),
|
|
977
|
+
writeLog: writeReviewLog,
|
|
978
|
+
logContext: {
|
|
1041
979
|
source: "tool_call",
|
|
1042
|
-
agentName,
|
|
1043
|
-
message,
|
|
1044
980
|
toolCallId: event.toolCallId,
|
|
1045
981
|
toolName,
|
|
982
|
+
agentName,
|
|
983
|
+
message: toolAskMessage,
|
|
1046
984
|
...permissionLogContext,
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
}
|
|
985
|
+
},
|
|
986
|
+
messages: {
|
|
987
|
+
denyReason: formatDenyReason(check, agentName ?? undefined),
|
|
988
|
+
unavailableReason: toolUnavailableReason,
|
|
989
|
+
userDeniedReason: (decision) =>
|
|
990
|
+
formatUserDeniedReason(check, decision.denialReason),
|
|
991
|
+
},
|
|
992
|
+
});
|
|
993
|
+
if (toolGate.action === "block") {
|
|
994
|
+
return { block: true, reason: toolGate.reason };
|
|
1054
995
|
}
|
|
1055
996
|
|
|
1056
997
|
return {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
2
|
+
|
|
3
|
+
/** Result of applying the permission gate. */
|
|
4
|
+
export type PermissionGateResult =
|
|
5
|
+
| { action: "allow" }
|
|
6
|
+
| { action: "block"; reason: string };
|
|
7
|
+
|
|
8
|
+
/** Everything the gate needs — no direct dependency on ExtensionContext. */
|
|
9
|
+
export interface PermissionGateParams {
|
|
10
|
+
/** The resolved permission state from checkPermission(). */
|
|
11
|
+
state: "allow" | "deny" | "ask";
|
|
12
|
+
|
|
13
|
+
/** Whether the current context supports interactive prompts. */
|
|
14
|
+
canConfirm: boolean;
|
|
15
|
+
|
|
16
|
+
/** Prompt the user for approval. Only called when state === "ask" and canConfirm is true. */
|
|
17
|
+
promptForApproval: () => Promise<PermissionPromptDecision>;
|
|
18
|
+
|
|
19
|
+
/** Write a review-log entry. Called for deny and ask-but-unavailable paths. */
|
|
20
|
+
writeLog: (event: string, extra: Record<string, unknown>) => void;
|
|
21
|
+
|
|
22
|
+
/** Log context fields shared across all log calls for this gate. */
|
|
23
|
+
logContext: Record<string, unknown>;
|
|
24
|
+
|
|
25
|
+
/** Message strings/factories for each outcome. */
|
|
26
|
+
messages: {
|
|
27
|
+
denyReason: string;
|
|
28
|
+
unavailableReason: string;
|
|
29
|
+
userDeniedReason: (decision: PermissionPromptDecision) => string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Apply the deny/ask/allow permission gate.
|
|
35
|
+
*
|
|
36
|
+
* This is a pure decision function: all IO is injected via callbacks.
|
|
37
|
+
*/
|
|
38
|
+
export async function applyPermissionGate(
|
|
39
|
+
params: PermissionGateParams,
|
|
40
|
+
): Promise<PermissionGateResult> {
|
|
41
|
+
const {
|
|
42
|
+
state,
|
|
43
|
+
canConfirm,
|
|
44
|
+
promptForApproval,
|
|
45
|
+
writeLog,
|
|
46
|
+
logContext,
|
|
47
|
+
messages,
|
|
48
|
+
} = params;
|
|
49
|
+
|
|
50
|
+
if (state === "deny") {
|
|
51
|
+
writeLog("permission_request.blocked", {
|
|
52
|
+
...logContext,
|
|
53
|
+
resolution: "policy_denied",
|
|
54
|
+
});
|
|
55
|
+
return { action: "block", reason: messages.denyReason };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (state === "ask") {
|
|
59
|
+
if (!canConfirm) {
|
|
60
|
+
writeLog("permission_request.blocked", {
|
|
61
|
+
...logContext,
|
|
62
|
+
resolution: "confirmation_unavailable",
|
|
63
|
+
});
|
|
64
|
+
return { action: "block", reason: messages.unavailableReason };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const decision = await promptForApproval();
|
|
68
|
+
if (!decision.approved) {
|
|
69
|
+
return { action: "block", reason: messages.userDeniedReason(decision) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { action: "allow" };
|
|
74
|
+
}
|
|
@@ -223,6 +223,62 @@ describe("extractExternalPathsFromBashCommand", () => {
|
|
|
223
223
|
});
|
|
224
224
|
});
|
|
225
225
|
|
|
226
|
+
describe("safe system paths are filtered", () => {
|
|
227
|
+
test("does not flag /dev/null in stderr redirect", () => {
|
|
228
|
+
const result = extractExternalPathsFromBashCommand(
|
|
229
|
+
"command 2>/dev/null",
|
|
230
|
+
cwd,
|
|
231
|
+
);
|
|
232
|
+
expect(result).toHaveLength(0);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("does not flag /dev/null as a redirect target", () => {
|
|
236
|
+
const result = extractExternalPathsFromBashCommand(
|
|
237
|
+
"echo hello > /dev/null",
|
|
238
|
+
cwd,
|
|
239
|
+
);
|
|
240
|
+
expect(result).toHaveLength(0);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("does not flag /dev/stdin", () => {
|
|
244
|
+
const result = extractExternalPathsFromBashCommand("cat /dev/stdin", cwd);
|
|
245
|
+
expect(result).toHaveLength(0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("does not flag /dev/stdout", () => {
|
|
249
|
+
const result = extractExternalPathsFromBashCommand(
|
|
250
|
+
"cat /dev/stdout",
|
|
251
|
+
cwd,
|
|
252
|
+
);
|
|
253
|
+
expect(result).toHaveLength(0);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("does not flag /dev/stderr", () => {
|
|
257
|
+
const result = extractExternalPathsFromBashCommand(
|
|
258
|
+
"cat /dev/stderr",
|
|
259
|
+
cwd,
|
|
260
|
+
);
|
|
261
|
+
expect(result).toHaveLength(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("still flags a real external path alongside /dev/null", () => {
|
|
265
|
+
const result = extractExternalPathsFromBashCommand(
|
|
266
|
+
"cat /etc/hosts 2>/dev/null",
|
|
267
|
+
cwd,
|
|
268
|
+
);
|
|
269
|
+
expect(result).toContain("/etc/hosts");
|
|
270
|
+
expect(result).not.toContain("/dev/null");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("does not flag /dev/null/subdir (not a safe path)", () => {
|
|
274
|
+
const result = extractExternalPathsFromBashCommand(
|
|
275
|
+
"cat /dev/null/subdir",
|
|
276
|
+
cwd,
|
|
277
|
+
);
|
|
278
|
+
expect(result).toContain("/dev/null/subdir");
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
226
282
|
describe("deduplication", () => {
|
|
227
283
|
test("returns deduplicated paths", () => {
|
|
228
284
|
const result = extractExternalPathsFromBashCommand(
|
|
@@ -18,8 +18,10 @@ import {
|
|
|
18
18
|
getPathBearingToolPath,
|
|
19
19
|
isPathOutsideWorkingDirectory,
|
|
20
20
|
isPathWithinDirectory,
|
|
21
|
+
isSafeSystemPath,
|
|
21
22
|
normalizePathForComparison,
|
|
22
23
|
PATH_BEARING_TOOLS,
|
|
24
|
+
SAFE_SYSTEM_PATHS,
|
|
23
25
|
} from "../src/external-directory";
|
|
24
26
|
|
|
25
27
|
afterEach(() => {
|
|
@@ -39,6 +41,49 @@ describe("PATH_BEARING_TOOLS", () => {
|
|
|
39
41
|
});
|
|
40
42
|
});
|
|
41
43
|
|
|
44
|
+
describe("SAFE_SYSTEM_PATHS", () => {
|
|
45
|
+
test("contains /dev/null, /dev/stdin, /dev/stdout, /dev/stderr", () => {
|
|
46
|
+
expect(SAFE_SYSTEM_PATHS.has("/dev/null")).toBe(true);
|
|
47
|
+
expect(SAFE_SYSTEM_PATHS.has("/dev/stdin")).toBe(true);
|
|
48
|
+
expect(SAFE_SYSTEM_PATHS.has("/dev/stdout")).toBe(true);
|
|
49
|
+
expect(SAFE_SYSTEM_PATHS.has("/dev/stderr")).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("isSafeSystemPath", () => {
|
|
54
|
+
test("returns true for /dev/null", () => {
|
|
55
|
+
expect(isSafeSystemPath("/dev/null")).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("returns true for /dev/stdin", () => {
|
|
59
|
+
expect(isSafeSystemPath("/dev/stdin")).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("returns true for /dev/stdout", () => {
|
|
63
|
+
expect(isSafeSystemPath("/dev/stdout")).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("returns true for /dev/stderr", () => {
|
|
67
|
+
expect(isSafeSystemPath("/dev/stderr")).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("returns false for an arbitrary absolute path", () => {
|
|
71
|
+
expect(isSafeSystemPath("/etc/passwd")).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("returns false for a path prefixed with a safe system path", () => {
|
|
75
|
+
expect(isSafeSystemPath("/dev/null/subdir")).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("returns false for an empty string", () => {
|
|
79
|
+
expect(isSafeSystemPath("")).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("returns false for a relative path", () => {
|
|
83
|
+
expect(isSafeSystemPath("dev/null")).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
42
87
|
describe("normalizePathForComparison", () => {
|
|
43
88
|
const cwd = "/projects/my-app";
|
|
44
89
|
|
|
@@ -163,6 +208,26 @@ describe("isPathOutsideWorkingDirectory", () => {
|
|
|
163
208
|
test("returns false for empty path (normalizes to empty string)", () => {
|
|
164
209
|
expect(isPathOutsideWorkingDirectory("", cwd)).toBe(false);
|
|
165
210
|
});
|
|
211
|
+
|
|
212
|
+
test("returns false for /dev/null regardless of cwd", () => {
|
|
213
|
+
expect(isPathOutsideWorkingDirectory("/dev/null", cwd)).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("returns false for /dev/stdin regardless of cwd", () => {
|
|
217
|
+
expect(isPathOutsideWorkingDirectory("/dev/stdin", cwd)).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("returns false for /dev/stdout regardless of cwd", () => {
|
|
221
|
+
expect(isPathOutsideWorkingDirectory("/dev/stdout", cwd)).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("returns false for /dev/stderr regardless of cwd", () => {
|
|
225
|
+
expect(isPathOutsideWorkingDirectory("/dev/stderr", cwd)).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("returns true for /dev/null/subdir (not a safe path)", () => {
|
|
229
|
+
expect(isPathOutsideWorkingDirectory("/dev/null/subdir", cwd)).toBe(true);
|
|
230
|
+
});
|
|
166
231
|
});
|
|
167
232
|
|
|
168
233
|
describe("formatExternalDirectoryHardStopHint", () => {
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { PermissionPromptDecision } from "../src/permission-dialog";
|
|
3
|
+
import {
|
|
4
|
+
applyPermissionGate,
|
|
5
|
+
type PermissionGateParams,
|
|
6
|
+
} from "../src/permission-gate";
|
|
7
|
+
|
|
8
|
+
function makeParams(
|
|
9
|
+
overrides: Partial<PermissionGateParams> = {},
|
|
10
|
+
): PermissionGateParams {
|
|
11
|
+
return {
|
|
12
|
+
state: "allow",
|
|
13
|
+
canConfirm: true,
|
|
14
|
+
promptForApproval: vi.fn<() => Promise<PermissionPromptDecision>>(),
|
|
15
|
+
writeLog: vi.fn(),
|
|
16
|
+
logContext: { source: "test" },
|
|
17
|
+
messages: {
|
|
18
|
+
denyReason: "Denied by policy.",
|
|
19
|
+
unavailableReason: "No interactive UI available.",
|
|
20
|
+
userDeniedReason: (d) =>
|
|
21
|
+
d.denialReason
|
|
22
|
+
? `User denied. Reason: ${d.denialReason}.`
|
|
23
|
+
: "User denied.",
|
|
24
|
+
},
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("applyPermissionGate", () => {
|
|
30
|
+
describe("deny branch", () => {
|
|
31
|
+
it("returns block with deny reason when state is deny", async () => {
|
|
32
|
+
const params = makeParams({ state: "deny" });
|
|
33
|
+
const result = await applyPermissionGate(params);
|
|
34
|
+
expect(result).toEqual({
|
|
35
|
+
action: "block",
|
|
36
|
+
reason: "Denied by policy.",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("calls writeLog with policy_denied resolution", async () => {
|
|
41
|
+
const params = makeParams({
|
|
42
|
+
state: "deny",
|
|
43
|
+
logContext: { source: "tool_call", toolName: "bash" },
|
|
44
|
+
});
|
|
45
|
+
await applyPermissionGate(params);
|
|
46
|
+
expect(params.writeLog).toHaveBeenCalledOnce();
|
|
47
|
+
expect(params.writeLog).toHaveBeenCalledWith(
|
|
48
|
+
"permission_request.blocked",
|
|
49
|
+
{
|
|
50
|
+
source: "tool_call",
|
|
51
|
+
toolName: "bash",
|
|
52
|
+
resolution: "policy_denied",
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("does not call promptForApproval when state is deny", async () => {
|
|
58
|
+
const params = makeParams({ state: "deny" });
|
|
59
|
+
await applyPermissionGate(params);
|
|
60
|
+
expect(params.promptForApproval).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("ask branch — unavailable", () => {
|
|
65
|
+
it("returns block with unavailable reason when canConfirm is false", async () => {
|
|
66
|
+
const params = makeParams({ state: "ask", canConfirm: false });
|
|
67
|
+
const result = await applyPermissionGate(params);
|
|
68
|
+
expect(result).toEqual({
|
|
69
|
+
action: "block",
|
|
70
|
+
reason: "No interactive UI available.",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("calls writeLog with confirmation_unavailable resolution", async () => {
|
|
75
|
+
const params = makeParams({
|
|
76
|
+
state: "ask",
|
|
77
|
+
canConfirm: false,
|
|
78
|
+
logContext: { source: "skill_read", skillName: "foo" },
|
|
79
|
+
});
|
|
80
|
+
await applyPermissionGate(params);
|
|
81
|
+
expect(params.writeLog).toHaveBeenCalledOnce();
|
|
82
|
+
expect(params.writeLog).toHaveBeenCalledWith(
|
|
83
|
+
"permission_request.blocked",
|
|
84
|
+
{
|
|
85
|
+
source: "skill_read",
|
|
86
|
+
skillName: "foo",
|
|
87
|
+
resolution: "confirmation_unavailable",
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("does not call promptForApproval when canConfirm is false", async () => {
|
|
93
|
+
const params = makeParams({ state: "ask", canConfirm: false });
|
|
94
|
+
await applyPermissionGate(params);
|
|
95
|
+
expect(params.promptForApproval).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("ask branch — user rejects", () => {
|
|
100
|
+
it("returns block with user-denied reason when user rejects", async () => {
|
|
101
|
+
const decision: PermissionPromptDecision = {
|
|
102
|
+
approved: false,
|
|
103
|
+
state: "denied",
|
|
104
|
+
};
|
|
105
|
+
const promptForApproval = vi.fn().mockResolvedValue(decision);
|
|
106
|
+
const params = makeParams({
|
|
107
|
+
state: "ask",
|
|
108
|
+
canConfirm: true,
|
|
109
|
+
promptForApproval,
|
|
110
|
+
});
|
|
111
|
+
const result = await applyPermissionGate(params);
|
|
112
|
+
expect(result).toEqual({ action: "block", reason: "User denied." });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("passes denial reason through userDeniedReason formatter", async () => {
|
|
116
|
+
const decision: PermissionPromptDecision = {
|
|
117
|
+
approved: false,
|
|
118
|
+
state: "denied_with_reason",
|
|
119
|
+
denialReason: "not now",
|
|
120
|
+
};
|
|
121
|
+
const promptForApproval = vi.fn().mockResolvedValue(decision);
|
|
122
|
+
const params = makeParams({
|
|
123
|
+
state: "ask",
|
|
124
|
+
canConfirm: true,
|
|
125
|
+
promptForApproval,
|
|
126
|
+
});
|
|
127
|
+
const result = await applyPermissionGate(params);
|
|
128
|
+
expect(result).toEqual({
|
|
129
|
+
action: "block",
|
|
130
|
+
reason: "User denied. Reason: not now.",
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("does not call writeLog when user rejects (logged by promptPermission)", async () => {
|
|
135
|
+
const decision: PermissionPromptDecision = {
|
|
136
|
+
approved: false,
|
|
137
|
+
state: "denied",
|
|
138
|
+
};
|
|
139
|
+
const promptForApproval = vi.fn().mockResolvedValue(decision);
|
|
140
|
+
const params = makeParams({
|
|
141
|
+
state: "ask",
|
|
142
|
+
canConfirm: true,
|
|
143
|
+
promptForApproval,
|
|
144
|
+
});
|
|
145
|
+
await applyPermissionGate(params);
|
|
146
|
+
expect(params.writeLog).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("ask branch — user approves", () => {
|
|
151
|
+
it("returns allow when user approves", async () => {
|
|
152
|
+
const decision: PermissionPromptDecision = {
|
|
153
|
+
approved: true,
|
|
154
|
+
state: "approved",
|
|
155
|
+
};
|
|
156
|
+
const promptForApproval = vi.fn().mockResolvedValue(decision);
|
|
157
|
+
const params = makeParams({
|
|
158
|
+
state: "ask",
|
|
159
|
+
canConfirm: true,
|
|
160
|
+
promptForApproval,
|
|
161
|
+
});
|
|
162
|
+
const result = await applyPermissionGate(params);
|
|
163
|
+
expect(result).toEqual({ action: "allow" });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("does not call writeLog when user approves", async () => {
|
|
167
|
+
const decision: PermissionPromptDecision = {
|
|
168
|
+
approved: true,
|
|
169
|
+
state: "approved",
|
|
170
|
+
};
|
|
171
|
+
const promptForApproval = vi.fn().mockResolvedValue(decision);
|
|
172
|
+
const params = makeParams({
|
|
173
|
+
state: "ask",
|
|
174
|
+
canConfirm: true,
|
|
175
|
+
promptForApproval,
|
|
176
|
+
});
|
|
177
|
+
await applyPermissionGate(params);
|
|
178
|
+
expect(params.writeLog).not.toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("allow branch", () => {
|
|
183
|
+
it("returns allow immediately when state is allow", async () => {
|
|
184
|
+
const params = makeParams({ state: "allow" });
|
|
185
|
+
const result = await applyPermissionGate(params);
|
|
186
|
+
expect(result).toEqual({ action: "allow" });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("does not call writeLog when state is allow", async () => {
|
|
190
|
+
const params = makeParams({ state: "allow" });
|
|
191
|
+
await applyPermissionGate(params);
|
|
192
|
+
expect(params.writeLog).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("does not call promptForApproval when state is allow", async () => {
|
|
196
|
+
const params = makeParams({ state: "allow" });
|
|
197
|
+
await applyPermissionGate(params);
|
|
198
|
+
expect(params.promptForApproval).not.toHaveBeenCalled();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|
package/index.ts
DELETED