@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
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 extCheck = permissionManager.checkPermission(
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 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: {
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
- message: extDirMessage,
850
- },
851
- messages: {
852
- denyReason: formatExternalDirectoryDenyReason(
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
- externalDirectoryPath,
855
- ctx.cwd,
856
- agentName ?? undefined,
857
- ),
858
- unavailableReason: `Accessing '${externalDirectoryPath}' outside the working directory requires approval, but no interactive UI is available.`,
859
- userDeniedReason: (decision) =>
860
- formatExternalDirectoryUserDeniedReason(
878
+ agentName,
879
+ path: externalDirectoryPath,
880
+ message: extDirMessage,
881
+ },
882
+ messages: {
883
+ denyReason: formatExternalDirectoryDenyReason(
861
884
  toolName,
862
885
  externalDirectoryPath,
863
- decision.denialReason,
886
+ ctx.cwd,
887
+ agentName ?? undefined,
864
888
  ),
865
- },
866
- });
867
- if (extDirGate.action === "block") {
868
- return { block: true, reason: extDirGate.reason };
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
- // state === "allow" → fall through to normal permission check
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
- const extCheck = permissionManager.checkPermission(
883
- "external_directory",
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
- 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: {
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
- message: bashExtMessage,
916
- },
917
- messages: {
918
- denyReason: formatBashExternalDirectoryDenyReason(
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
- ctx.cwd,
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
- if (bashExtGate.action === "block") {
934
- return { block: true, reason: bashExtGate.reason };
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
- // state === "allow" → fall through to normal bash permission check
1003
+ // Fall through to normal bash permission check
937
1004
  }
938
1005
  }
939
1006
  }
@@ -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" || value === "denied" || value === "denied_with_reason"
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
+ });