@gotgenes/pi-permission-system 3.3.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 +16 -0
- package/README.md +18 -0
- package/package.json +1 -1
- package/src/index.ts +152 -85
- package/src/permission-dialog.ts +14 -1
- package/src/session-approval-cache.ts +81 -0
- package/tests/permission-dialog.test.ts +166 -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,22 @@ 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
|
+
|
|
8
24
|
## [3.3.0](https://github.com/gotgenes/pi-permission-system/compare/v3.2.0...v3.3.0) (2026-05-03)
|
|
9
25
|
|
|
10
26
|
|
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
|
@@ -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(
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createDeniedPermissionDecision,
|
|
4
|
+
isPermissionDecisionState,
|
|
5
|
+
normalizePermissionDenialReason,
|
|
6
|
+
type PermissionDecisionUi,
|
|
7
|
+
requestPermissionDecisionFromUi,
|
|
8
|
+
} from "../src/permission-dialog";
|
|
9
|
+
|
|
10
|
+
describe("isPermissionDecisionState", () => {
|
|
11
|
+
it("accepts approved", () => {
|
|
12
|
+
expect(isPermissionDecisionState("approved")).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("accepts denied", () => {
|
|
16
|
+
expect(isPermissionDecisionState("denied")).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("accepts denied_with_reason", () => {
|
|
20
|
+
expect(isPermissionDecisionState("denied_with_reason")).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("accepts approved_for_session", () => {
|
|
24
|
+
expect(isPermissionDecisionState("approved_for_session")).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("rejects unknown strings", () => {
|
|
28
|
+
expect(isPermissionDecisionState("unknown")).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects non-strings", () => {
|
|
32
|
+
expect(isPermissionDecisionState(42)).toBe(false);
|
|
33
|
+
expect(isPermissionDecisionState(null)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("requestPermissionDecisionFromUi", () => {
|
|
38
|
+
it("returns approved when user selects Yes", async () => {
|
|
39
|
+
const ui: PermissionDecisionUi = {
|
|
40
|
+
select: vi.fn().mockResolvedValue("Yes"),
|
|
41
|
+
input: vi.fn(),
|
|
42
|
+
};
|
|
43
|
+
const result = await requestPermissionDecisionFromUi(
|
|
44
|
+
ui,
|
|
45
|
+
"Title",
|
|
46
|
+
"Message",
|
|
47
|
+
);
|
|
48
|
+
expect(result).toEqual({ approved: true, state: "approved" });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns approved_for_session when user selects session option", async () => {
|
|
52
|
+
const ui: PermissionDecisionUi = {
|
|
53
|
+
select: vi.fn().mockResolvedValue("Yes, for this session"),
|
|
54
|
+
input: vi.fn(),
|
|
55
|
+
};
|
|
56
|
+
const result = await requestPermissionDecisionFromUi(
|
|
57
|
+
ui,
|
|
58
|
+
"Title",
|
|
59
|
+
"Message",
|
|
60
|
+
);
|
|
61
|
+
expect(result).toEqual({ approved: true, state: "approved_for_session" });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns denied when user selects No", async () => {
|
|
65
|
+
const ui: PermissionDecisionUi = {
|
|
66
|
+
select: vi.fn().mockResolvedValue("No"),
|
|
67
|
+
input: vi.fn(),
|
|
68
|
+
};
|
|
69
|
+
const result = await requestPermissionDecisionFromUi(
|
|
70
|
+
ui,
|
|
71
|
+
"Title",
|
|
72
|
+
"Message",
|
|
73
|
+
);
|
|
74
|
+
expect(result).toEqual({ approved: false, state: "denied" });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns denied_with_reason when user provides reason", async () => {
|
|
78
|
+
const ui: PermissionDecisionUi = {
|
|
79
|
+
select: vi.fn().mockResolvedValue("No, provide reason"),
|
|
80
|
+
input: vi.fn().mockResolvedValue("not now"),
|
|
81
|
+
};
|
|
82
|
+
const result = await requestPermissionDecisionFromUi(
|
|
83
|
+
ui,
|
|
84
|
+
"Title",
|
|
85
|
+
"Message",
|
|
86
|
+
);
|
|
87
|
+
expect(result).toEqual({
|
|
88
|
+
approved: false,
|
|
89
|
+
state: "denied_with_reason",
|
|
90
|
+
denialReason: "not now",
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns denied when user selects deny-with-reason but gives empty input", async () => {
|
|
95
|
+
const ui: PermissionDecisionUi = {
|
|
96
|
+
select: vi.fn().mockResolvedValue("No, provide reason"),
|
|
97
|
+
input: vi.fn().mockResolvedValue(""),
|
|
98
|
+
};
|
|
99
|
+
const result = await requestPermissionDecisionFromUi(
|
|
100
|
+
ui,
|
|
101
|
+
"Title",
|
|
102
|
+
"Message",
|
|
103
|
+
);
|
|
104
|
+
expect(result).toEqual({ approved: false, state: "denied" });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns denied when user dismisses dialog (undefined)", async () => {
|
|
108
|
+
const ui: PermissionDecisionUi = {
|
|
109
|
+
select: vi.fn().mockResolvedValue(undefined),
|
|
110
|
+
input: vi.fn(),
|
|
111
|
+
};
|
|
112
|
+
const result = await requestPermissionDecisionFromUi(
|
|
113
|
+
ui,
|
|
114
|
+
"Title",
|
|
115
|
+
"Message",
|
|
116
|
+
);
|
|
117
|
+
expect(result).toEqual({ approved: false, state: "denied" });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("passes four options to ui.select", async () => {
|
|
121
|
+
const selectFn = vi.fn().mockResolvedValue("Yes");
|
|
122
|
+
const ui: PermissionDecisionUi = {
|
|
123
|
+
select: selectFn,
|
|
124
|
+
input: vi.fn(),
|
|
125
|
+
};
|
|
126
|
+
await requestPermissionDecisionFromUi(ui, "Title", "Message");
|
|
127
|
+
const options = selectFn.mock.calls[0][1] as string[];
|
|
128
|
+
expect(options).toEqual([
|
|
129
|
+
"Yes",
|
|
130
|
+
"Yes, for this session",
|
|
131
|
+
"No",
|
|
132
|
+
"No, provide reason",
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("normalizePermissionDenialReason", () => {
|
|
138
|
+
it("returns trimmed string for non-empty input", () => {
|
|
139
|
+
expect(normalizePermissionDenialReason(" reason ")).toBe("reason");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("returns undefined for empty string", () => {
|
|
143
|
+
expect(normalizePermissionDenialReason("")).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns undefined for non-string", () => {
|
|
147
|
+
expect(normalizePermissionDenialReason(42)).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("createDeniedPermissionDecision", () => {
|
|
152
|
+
it("returns denied_with_reason when reason provided", () => {
|
|
153
|
+
expect(createDeniedPermissionDecision("nope")).toEqual({
|
|
154
|
+
approved: false,
|
|
155
|
+
state: "denied_with_reason",
|
|
156
|
+
denialReason: "nope",
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns denied when no reason", () => {
|
|
161
|
+
expect(createDeniedPermissionDecision()).toEqual({
|
|
162
|
+
approved: false,
|
|
163
|
+
state: "denied",
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -2644,3 +2644,292 @@ test("PermissionManager.getConfigIssues returns empty array for clean config", (
|
|
|
2644
2644
|
cleanup();
|
|
2645
2645
|
}
|
|
2646
2646
|
});
|
|
2647
|
+
|
|
2648
|
+
// --- session-scoped approval tests (#45) ---
|
|
2649
|
+
|
|
2650
|
+
test("session approval: first prompt with 'Yes, for this session' skips subsequent prompts under same prefix", async () => {
|
|
2651
|
+
const rootDir = mkdtempSync(join(tmpdir(), "pi-session-approval-"));
|
|
2652
|
+
const cwd = join(rootDir, "repo");
|
|
2653
|
+
const siblingDir = join(rootDir, "sibling-project");
|
|
2654
|
+
mkdirSync(cwd, { recursive: true });
|
|
2655
|
+
mkdirSync(siblingDir, { recursive: true });
|
|
2656
|
+
|
|
2657
|
+
const harness = createToolCallHarness(
|
|
2658
|
+
{
|
|
2659
|
+
defaultPolicy: {
|
|
2660
|
+
tools: "allow",
|
|
2661
|
+
bash: "allow",
|
|
2662
|
+
mcp: "allow",
|
|
2663
|
+
skills: "allow",
|
|
2664
|
+
special: "ask",
|
|
2665
|
+
},
|
|
2666
|
+
special: { external_directory: "ask" },
|
|
2667
|
+
},
|
|
2668
|
+
["read", "grep"],
|
|
2669
|
+
{ cwd },
|
|
2670
|
+
);
|
|
2671
|
+
|
|
2672
|
+
try {
|
|
2673
|
+
// First access — user selects "Yes, for this session"
|
|
2674
|
+
const result1 = await runToolCall(
|
|
2675
|
+
harness,
|
|
2676
|
+
{
|
|
2677
|
+
toolName: "read",
|
|
2678
|
+
toolCallId: "ext-session-1",
|
|
2679
|
+
input: { path: join(siblingDir, "src", "foo.ts") },
|
|
2680
|
+
},
|
|
2681
|
+
{ hasUI: true, selectResponse: "Yes, for this session" },
|
|
2682
|
+
);
|
|
2683
|
+
assert.deepEqual(result1, {});
|
|
2684
|
+
assert.equal(harness.prompts.length, 1);
|
|
2685
|
+
|
|
2686
|
+
// Second access under same prefix — should skip prompt
|
|
2687
|
+
const result2 = await runToolCall(
|
|
2688
|
+
harness,
|
|
2689
|
+
{
|
|
2690
|
+
toolName: "read",
|
|
2691
|
+
toolCallId: "ext-session-2",
|
|
2692
|
+
input: { path: join(siblingDir, "src", "bar.ts") },
|
|
2693
|
+
},
|
|
2694
|
+
{ hasUI: true, selectResponse: "Yes, for this session" },
|
|
2695
|
+
);
|
|
2696
|
+
assert.deepEqual(result2, {});
|
|
2697
|
+
// No new prompt — still just the original one
|
|
2698
|
+
assert.equal(harness.prompts.length, 1);
|
|
2699
|
+
|
|
2700
|
+
// Third access with different tool under same prefix — also skipped
|
|
2701
|
+
const result3 = await runToolCall(
|
|
2702
|
+
harness,
|
|
2703
|
+
{
|
|
2704
|
+
toolName: "grep",
|
|
2705
|
+
toolCallId: "ext-session-3",
|
|
2706
|
+
input: { pattern: "needle", path: join(siblingDir, "src", "baz.ts") },
|
|
2707
|
+
},
|
|
2708
|
+
{ hasUI: true, selectResponse: "Yes, for this session" },
|
|
2709
|
+
);
|
|
2710
|
+
assert.deepEqual(result3, {});
|
|
2711
|
+
assert.equal(harness.prompts.length, 1);
|
|
2712
|
+
} finally {
|
|
2713
|
+
await harness.cleanup();
|
|
2714
|
+
rmSync(rootDir, { recursive: true, force: true });
|
|
2715
|
+
}
|
|
2716
|
+
});
|
|
2717
|
+
|
|
2718
|
+
test("session approval: different directory prefix still prompts", async () => {
|
|
2719
|
+
const rootDir = mkdtempSync(join(tmpdir(), "pi-session-approval-"));
|
|
2720
|
+
const cwd = join(rootDir, "repo");
|
|
2721
|
+
const siblingA = join(rootDir, "sibling-a");
|
|
2722
|
+
const siblingB = join(rootDir, "sibling-b");
|
|
2723
|
+
mkdirSync(cwd, { recursive: true });
|
|
2724
|
+
mkdirSync(siblingA, { recursive: true });
|
|
2725
|
+
mkdirSync(siblingB, { recursive: true });
|
|
2726
|
+
|
|
2727
|
+
const harness = createToolCallHarness(
|
|
2728
|
+
{
|
|
2729
|
+
defaultPolicy: {
|
|
2730
|
+
tools: "allow",
|
|
2731
|
+
bash: "allow",
|
|
2732
|
+
mcp: "allow",
|
|
2733
|
+
skills: "allow",
|
|
2734
|
+
special: "ask",
|
|
2735
|
+
},
|
|
2736
|
+
special: { external_directory: "ask" },
|
|
2737
|
+
},
|
|
2738
|
+
["read"],
|
|
2739
|
+
{ cwd },
|
|
2740
|
+
);
|
|
2741
|
+
|
|
2742
|
+
try {
|
|
2743
|
+
// Approve sibling-a/src/ for session
|
|
2744
|
+
await runToolCall(
|
|
2745
|
+
harness,
|
|
2746
|
+
{
|
|
2747
|
+
toolName: "read",
|
|
2748
|
+
toolCallId: "ext-diff-1",
|
|
2749
|
+
input: { path: join(siblingA, "src", "foo.ts") },
|
|
2750
|
+
},
|
|
2751
|
+
{ hasUI: true, selectResponse: "Yes, for this session" },
|
|
2752
|
+
);
|
|
2753
|
+
assert.equal(harness.prompts.length, 1);
|
|
2754
|
+
|
|
2755
|
+
// Access sibling-b — different prefix, should prompt again
|
|
2756
|
+
await runToolCall(
|
|
2757
|
+
harness,
|
|
2758
|
+
{
|
|
2759
|
+
toolName: "read",
|
|
2760
|
+
toolCallId: "ext-diff-2",
|
|
2761
|
+
input: { path: join(siblingB, "src", "bar.ts") },
|
|
2762
|
+
},
|
|
2763
|
+
{ hasUI: true, selectResponse: "Yes" },
|
|
2764
|
+
);
|
|
2765
|
+
assert.equal(harness.prompts.length, 2);
|
|
2766
|
+
} finally {
|
|
2767
|
+
await harness.cleanup();
|
|
2768
|
+
rmSync(rootDir, { recursive: true, force: true });
|
|
2769
|
+
}
|
|
2770
|
+
});
|
|
2771
|
+
|
|
2772
|
+
test("session approval: session_shutdown clears session approvals", async () => {
|
|
2773
|
+
const rootDir = mkdtempSync(join(tmpdir(), "pi-session-approval-"));
|
|
2774
|
+
const cwd = join(rootDir, "repo");
|
|
2775
|
+
const siblingDir = join(rootDir, "sibling");
|
|
2776
|
+
mkdirSync(cwd, { recursive: true });
|
|
2777
|
+
mkdirSync(siblingDir, { recursive: true });
|
|
2778
|
+
|
|
2779
|
+
const harness = createToolCallHarness(
|
|
2780
|
+
{
|
|
2781
|
+
defaultPolicy: {
|
|
2782
|
+
tools: "allow",
|
|
2783
|
+
bash: "allow",
|
|
2784
|
+
mcp: "allow",
|
|
2785
|
+
skills: "allow",
|
|
2786
|
+
special: "ask",
|
|
2787
|
+
},
|
|
2788
|
+
special: { external_directory: "ask" },
|
|
2789
|
+
},
|
|
2790
|
+
["read"],
|
|
2791
|
+
{ cwd },
|
|
2792
|
+
);
|
|
2793
|
+
|
|
2794
|
+
try {
|
|
2795
|
+
// Approve for session
|
|
2796
|
+
await runToolCall(
|
|
2797
|
+
harness,
|
|
2798
|
+
{
|
|
2799
|
+
toolName: "read",
|
|
2800
|
+
toolCallId: "ext-shutdown-1",
|
|
2801
|
+
input: { path: join(siblingDir, "src", "foo.ts") },
|
|
2802
|
+
},
|
|
2803
|
+
{ hasUI: true, selectResponse: "Yes, for this session" },
|
|
2804
|
+
);
|
|
2805
|
+
assert.equal(harness.prompts.length, 1);
|
|
2806
|
+
|
|
2807
|
+
// Trigger session_shutdown (clears cache)
|
|
2808
|
+
const shutdownCtx = createMockContext(cwd, harness.prompts, {
|
|
2809
|
+
hasUI: true,
|
|
2810
|
+
selectResponse: "Yes",
|
|
2811
|
+
});
|
|
2812
|
+
await Promise.resolve(harness.handlers.session_shutdown?.({}, shutdownCtx));
|
|
2813
|
+
|
|
2814
|
+
// Access same path again — should prompt because cache was cleared
|
|
2815
|
+
const result = await runToolCall(
|
|
2816
|
+
harness,
|
|
2817
|
+
{
|
|
2818
|
+
toolName: "read",
|
|
2819
|
+
toolCallId: "ext-shutdown-2",
|
|
2820
|
+
input: { path: join(siblingDir, "src", "foo.ts") },
|
|
2821
|
+
},
|
|
2822
|
+
{ hasUI: true, selectResponse: "Yes" },
|
|
2823
|
+
);
|
|
2824
|
+
assert.deepEqual(result, {});
|
|
2825
|
+
assert.equal(harness.prompts.length, 2);
|
|
2826
|
+
} finally {
|
|
2827
|
+
await harness.cleanup();
|
|
2828
|
+
rmSync(rootDir, { recursive: true, force: true });
|
|
2829
|
+
}
|
|
2830
|
+
});
|
|
2831
|
+
|
|
2832
|
+
test("session approval: bash external directory with 'Yes, for this session' skips subsequent prompts", async () => {
|
|
2833
|
+
const rootDir = mkdtempSync(join(tmpdir(), "pi-session-approval-"));
|
|
2834
|
+
const cwd = join(rootDir, "repo");
|
|
2835
|
+
mkdirSync(cwd, { recursive: true });
|
|
2836
|
+
|
|
2837
|
+
const harness = createToolCallHarness(
|
|
2838
|
+
{
|
|
2839
|
+
defaultPolicy: {
|
|
2840
|
+
tools: "allow",
|
|
2841
|
+
bash: "allow",
|
|
2842
|
+
mcp: "allow",
|
|
2843
|
+
skills: "allow",
|
|
2844
|
+
special: "ask",
|
|
2845
|
+
},
|
|
2846
|
+
special: { external_directory: "ask" },
|
|
2847
|
+
},
|
|
2848
|
+
["bash"],
|
|
2849
|
+
{ cwd },
|
|
2850
|
+
);
|
|
2851
|
+
|
|
2852
|
+
try {
|
|
2853
|
+
const externalPath = join(rootDir, "other-project", "src");
|
|
2854
|
+
// First bash command referencing external path
|
|
2855
|
+
const result1 = await runToolCall(
|
|
2856
|
+
harness,
|
|
2857
|
+
{
|
|
2858
|
+
toolName: "bash",
|
|
2859
|
+
toolCallId: "bash-session-1",
|
|
2860
|
+
input: { command: `ls ${externalPath}/foo.ts` },
|
|
2861
|
+
},
|
|
2862
|
+
{ hasUI: true, selectResponse: "Yes, for this session" },
|
|
2863
|
+
);
|
|
2864
|
+
assert.deepEqual(result1, {});
|
|
2865
|
+
assert.equal(harness.prompts.length, 1);
|
|
2866
|
+
|
|
2867
|
+
// Second bash command referencing path under same prefix — skips prompt
|
|
2868
|
+
const result2 = await runToolCall(
|
|
2869
|
+
harness,
|
|
2870
|
+
{
|
|
2871
|
+
toolName: "bash",
|
|
2872
|
+
toolCallId: "bash-session-2",
|
|
2873
|
+
input: { command: `cat ${externalPath}/bar.ts` },
|
|
2874
|
+
},
|
|
2875
|
+
{ hasUI: true, selectResponse: "Yes, for this session" },
|
|
2876
|
+
);
|
|
2877
|
+
assert.deepEqual(result2, {});
|
|
2878
|
+
assert.equal(harness.prompts.length, 1);
|
|
2879
|
+
} finally {
|
|
2880
|
+
await harness.cleanup();
|
|
2881
|
+
rmSync(rootDir, { recursive: true, force: true });
|
|
2882
|
+
}
|
|
2883
|
+
});
|
|
2884
|
+
|
|
2885
|
+
test("session approval: regular 'Yes' does not create session approval", async () => {
|
|
2886
|
+
const rootDir = mkdtempSync(join(tmpdir(), "pi-session-approval-"));
|
|
2887
|
+
const cwd = join(rootDir, "repo");
|
|
2888
|
+
const siblingDir = join(rootDir, "sibling");
|
|
2889
|
+
mkdirSync(cwd, { recursive: true });
|
|
2890
|
+
mkdirSync(siblingDir, { recursive: true });
|
|
2891
|
+
|
|
2892
|
+
const harness = createToolCallHarness(
|
|
2893
|
+
{
|
|
2894
|
+
defaultPolicy: {
|
|
2895
|
+
tools: "allow",
|
|
2896
|
+
bash: "allow",
|
|
2897
|
+
mcp: "allow",
|
|
2898
|
+
skills: "allow",
|
|
2899
|
+
special: "ask",
|
|
2900
|
+
},
|
|
2901
|
+
special: { external_directory: "ask" },
|
|
2902
|
+
},
|
|
2903
|
+
["read"],
|
|
2904
|
+
{ cwd },
|
|
2905
|
+
);
|
|
2906
|
+
|
|
2907
|
+
try {
|
|
2908
|
+
// Approve once with "Yes" (not session)
|
|
2909
|
+
await runToolCall(
|
|
2910
|
+
harness,
|
|
2911
|
+
{
|
|
2912
|
+
toolName: "read",
|
|
2913
|
+
toolCallId: "ext-once-1",
|
|
2914
|
+
input: { path: join(siblingDir, "src", "foo.ts") },
|
|
2915
|
+
},
|
|
2916
|
+
{ hasUI: true, selectResponse: "Yes" },
|
|
2917
|
+
);
|
|
2918
|
+
assert.equal(harness.prompts.length, 1);
|
|
2919
|
+
|
|
2920
|
+
// Same prefix — should still prompt since we used "Yes" not session
|
|
2921
|
+
await runToolCall(
|
|
2922
|
+
harness,
|
|
2923
|
+
{
|
|
2924
|
+
toolName: "read",
|
|
2925
|
+
toolCallId: "ext-once-2",
|
|
2926
|
+
input: { path: join(siblingDir, "src", "bar.ts") },
|
|
2927
|
+
},
|
|
2928
|
+
{ hasUI: true, selectResponse: "Yes" },
|
|
2929
|
+
);
|
|
2930
|
+
assert.equal(harness.prompts.length, 2);
|
|
2931
|
+
} finally {
|
|
2932
|
+
await harness.cleanup();
|
|
2933
|
+
rmSync(rootDir, { recursive: true, force: true });
|
|
2934
|
+
}
|
|
2935
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock node:os so tilde-expansion is deterministic across platforms.
|
|
4
|
+
vi.mock("node:os", () => {
|
|
5
|
+
const homedir = vi.fn(() => "/mock/home");
|
|
6
|
+
return {
|
|
7
|
+
homedir,
|
|
8
|
+
default: { homedir },
|
|
9
|
+
};
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
deriveApprovalPrefix,
|
|
14
|
+
SessionApprovalCache,
|
|
15
|
+
} from "../src/session-approval-cache";
|
|
16
|
+
|
|
17
|
+
describe("SessionApprovalCache", () => {
|
|
18
|
+
describe("approve and has", () => {
|
|
19
|
+
it("returns false when no approvals exist", () => {
|
|
20
|
+
const cache = new SessionApprovalCache();
|
|
21
|
+
expect(cache.has("external_directory", "/some/path")).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("returns true for a path under an approved prefix", () => {
|
|
25
|
+
const cache = new SessionApprovalCache();
|
|
26
|
+
cache.approve("external_directory", "/other/project/src/");
|
|
27
|
+
expect(cache.has("external_directory", "/other/project/src/foo.ts")).toBe(
|
|
28
|
+
true,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns true for the exact approved prefix path", () => {
|
|
33
|
+
const cache = new SessionApprovalCache();
|
|
34
|
+
cache.approve("external_directory", "/other/project/src/");
|
|
35
|
+
expect(cache.has("external_directory", "/other/project/src/")).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns false for a path outside the approved prefix", () => {
|
|
39
|
+
const cache = new SessionApprovalCache();
|
|
40
|
+
cache.approve("external_directory", "/other/project/src/");
|
|
41
|
+
expect(cache.has("external_directory", "/other/project/lib/foo.ts")).toBe(
|
|
42
|
+
false,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns false for a sibling directory that shares a string prefix", () => {
|
|
47
|
+
const cache = new SessionApprovalCache();
|
|
48
|
+
cache.approve("external_directory", "/other/project/");
|
|
49
|
+
// /other/project-b/ should NOT match /other/project/
|
|
50
|
+
expect(cache.has("external_directory", "/other/project-b/foo.ts")).toBe(
|
|
51
|
+
false,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("handles multiple approved prefixes for the same surface", () => {
|
|
56
|
+
const cache = new SessionApprovalCache();
|
|
57
|
+
cache.approve("external_directory", "/other/project-a/");
|
|
58
|
+
cache.approve("external_directory", "/other/project-b/");
|
|
59
|
+
expect(cache.has("external_directory", "/other/project-a/foo.ts")).toBe(
|
|
60
|
+
true,
|
|
61
|
+
);
|
|
62
|
+
expect(cache.has("external_directory", "/other/project-b/bar.ts")).toBe(
|
|
63
|
+
true,
|
|
64
|
+
);
|
|
65
|
+
expect(cache.has("external_directory", "/other/project-c/baz.ts")).toBe(
|
|
66
|
+
false,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("does not duplicate identical prefixes", () => {
|
|
71
|
+
const cache = new SessionApprovalCache();
|
|
72
|
+
cache.approve("external_directory", "/other/project/");
|
|
73
|
+
cache.approve("external_directory", "/other/project/");
|
|
74
|
+
// Set semantics — just verify it still works
|
|
75
|
+
expect(cache.has("external_directory", "/other/project/foo.ts")).toBe(
|
|
76
|
+
true,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("surface isolation", () => {
|
|
82
|
+
it("does not match across different surface types", () => {
|
|
83
|
+
const cache = new SessionApprovalCache();
|
|
84
|
+
cache.approve("external_directory", "/other/project/");
|
|
85
|
+
expect(cache.has("some_other_surface", "/other/project/foo.ts")).toBe(
|
|
86
|
+
false,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("clear", () => {
|
|
92
|
+
it("removes all approvals", () => {
|
|
93
|
+
const cache = new SessionApprovalCache();
|
|
94
|
+
cache.approve("external_directory", "/other/project/");
|
|
95
|
+
cache.approve("some_surface", "/another/path/");
|
|
96
|
+
cache.clear();
|
|
97
|
+
expect(cache.has("external_directory", "/other/project/foo.ts")).toBe(
|
|
98
|
+
false,
|
|
99
|
+
);
|
|
100
|
+
expect(cache.has("some_surface", "/another/path/file")).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("deriveApprovalPrefix", () => {
|
|
106
|
+
it("returns parent directory with trailing separator for a file path", () => {
|
|
107
|
+
expect(deriveApprovalPrefix("/other/project/src/foo.ts")).toBe(
|
|
108
|
+
"/other/project/src/",
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns the directory itself with trailing separator for a directory path", () => {
|
|
113
|
+
expect(deriveApprovalPrefix("/other/project/src/")).toBe(
|
|
114
|
+
"/other/project/src/",
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("returns the directory itself when path has no trailing separator", () => {
|
|
119
|
+
// For a path like /other/project/src (directory), dirname gives /other/project
|
|
120
|
+
// but we can't distinguish dir from file without stat. dirname is the safe choice.
|
|
121
|
+
expect(deriveApprovalPrefix("/other/project/src")).toBe("/other/project/");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("handles root path", () => {
|
|
125
|
+
expect(deriveApprovalPrefix("/")).toBe("/");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("handles single-level path", () => {
|
|
129
|
+
expect(deriveApprovalPrefix("/foo")).toBe("/");
|
|
130
|
+
});
|
|
131
|
+
});
|