@gotgenes/pi-permission-system 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ });
@@ -2115,7 +2115,7 @@ permission:
2115
2115
  }
2116
2116
  });
2117
2117
 
2118
- test("external_directory permission is independent of doom_loop in the same special config", () => {
2118
+ test("external_directory permission is unaffected when doom_loop key is present in config (deprecated and ignored)", () => {
2119
2119
  const { manager, cleanup } = createManager({
2120
2120
  defaultPolicy: {
2121
2121
  tools: "allow",
@@ -2131,10 +2131,12 @@ test("external_directory permission is independent of doom_loop in the same spec
2131
2131
  });
2132
2132
 
2133
2133
  try {
2134
+ // doom_loop is deprecated and stripped — falls through to defaultPolicy.tools
2134
2135
  const doomResult = manager.checkPermission("doom_loop", {});
2135
- assert.equal(doomResult.state, "deny");
2136
- assert.equal(doomResult.matchedPattern, "doom_loop");
2136
+ assert.equal(doomResult.state, "allow"); // defaultPolicy.tools, not the stripped doom_loop: "deny"
2137
+ assert.equal(doomResult.matchedPattern, undefined);
2137
2138
 
2139
+ // external_directory still resolves from its own entry
2138
2140
  const extResult = manager.checkPermission("external_directory", {});
2139
2141
  assert.equal(extResult.state, "allow");
2140
2142
  assert.equal(extResult.matchedPattern, "external_directory");
@@ -2583,12 +2585,22 @@ test("normalizeRawPermission emits deprecation issue for special.tool_call_limit
2583
2585
  assert.equal(result.permissions.special?.tool_call_limit, undefined);
2584
2586
  });
2585
2587
 
2586
- test("normalizeRawPermission emits no issues for valid special keys", () => {
2588
+ test("normalizeRawPermission emits deprecation issue for special.doom_loop (string)", () => {
2589
+ const result = normalizeRawPermission({
2590
+ special: { doom_loop: "ask" },
2591
+ });
2592
+ assert.equal(result.configIssues.length, 1);
2593
+ assert.ok(result.configIssues[0].includes("doom_loop"));
2594
+ assert.equal(result.permissions.special?.doom_loop, undefined);
2595
+ });
2596
+
2597
+ test("normalizeRawPermission emits deprecation issue for special.doom_loop (deny)", () => {
2587
2598
  const result = normalizeRawPermission({
2588
2599
  special: { doom_loop: "deny" },
2589
2600
  });
2590
- assert.equal(result.configIssues.length, 0);
2591
- assert.equal(result.permissions.special?.doom_loop, "deny");
2601
+ assert.equal(result.configIssues.length, 1);
2602
+ assert.ok(result.configIssues[0].includes("doom_loop"));
2603
+ assert.equal(result.permissions.special?.doom_loop, undefined);
2592
2604
  });
2593
2605
 
2594
2606
  test("normalizeRawPermission emits no issues when special is absent", () => {
@@ -2609,7 +2621,7 @@ test("PermissionManager.getConfigIssues returns deprecation for tool_call_limit
2609
2621
  bash: {},
2610
2622
  mcp: {},
2611
2623
  skills: {},
2612
- special: { tool_call_limit: "allow" as PermissionState, doom_loop: "deny" },
2624
+ special: { tool_call_limit: "allow" as PermissionState },
2613
2625
  };
2614
2626
  const { manager, cleanup } = createManager(config);
2615
2627
  try {
@@ -2634,7 +2646,7 @@ test("PermissionManager.getConfigIssues returns empty array for clean config", (
2634
2646
  bash: {},
2635
2647
  mcp: {},
2636
2648
  skills: {},
2637
- special: { doom_loop: "deny" },
2649
+ special: { external_directory: "ask" },
2638
2650
  };
2639
2651
  const { manager, cleanup } = createManager(config);
2640
2652
  try {
@@ -2644,3 +2656,344 @@ test("PermissionManager.getConfigIssues returns empty array for clean config", (
2644
2656
  cleanup();
2645
2657
  }
2646
2658
  });
2659
+
2660
+ // --- doom_loop config-loader deprecation tests (#54) ---
2661
+
2662
+ test("PermissionManager.getConfigIssues returns deprecation for doom_loop in global config", () => {
2663
+ const config: GlobalPermissionConfig = {
2664
+ defaultPolicy: {
2665
+ tools: "ask",
2666
+ bash: "ask",
2667
+ mcp: "ask",
2668
+ skills: "ask",
2669
+ special: "ask",
2670
+ },
2671
+ tools: {},
2672
+ bash: {},
2673
+ mcp: {},
2674
+ skills: {},
2675
+ special: { doom_loop: "deny" },
2676
+ };
2677
+ const { manager, cleanup } = createManager(config);
2678
+ try {
2679
+ const issues = manager.getConfigIssues();
2680
+ assert.equal(issues.length, 1);
2681
+ assert.ok(issues[0].includes("doom_loop"));
2682
+ } finally {
2683
+ cleanup();
2684
+ }
2685
+ });
2686
+
2687
+ test("checkPermission doom_loop falls through to defaultPolicy.tools when stripped by config-loader", () => {
2688
+ const { manager, cleanup } = createManager({
2689
+ defaultPolicy: {
2690
+ tools: "allow",
2691
+ bash: "ask",
2692
+ mcp: "ask",
2693
+ skills: "ask",
2694
+ special: "deny",
2695
+ },
2696
+ tools: {},
2697
+ bash: {},
2698
+ mcp: {},
2699
+ skills: {},
2700
+ special: { doom_loop: "ask" },
2701
+ });
2702
+ try {
2703
+ const result = manager.checkPermission("doom_loop", {});
2704
+ // doom_loop stripped by config-loader — falls through to defaultPolicy.tools
2705
+ assert.equal(result.state, "allow");
2706
+ assert.equal(result.matchedPattern, undefined);
2707
+ } finally {
2708
+ cleanup();
2709
+ }
2710
+ });
2711
+
2712
+ // --- session-scoped approval tests (#45) ---
2713
+
2714
+ test("session approval: first prompt with 'Yes, for this session' skips subsequent prompts under same prefix", async () => {
2715
+ const rootDir = mkdtempSync(join(tmpdir(), "pi-session-approval-"));
2716
+ const cwd = join(rootDir, "repo");
2717
+ const siblingDir = join(rootDir, "sibling-project");
2718
+ mkdirSync(cwd, { recursive: true });
2719
+ mkdirSync(siblingDir, { recursive: true });
2720
+
2721
+ const harness = createToolCallHarness(
2722
+ {
2723
+ defaultPolicy: {
2724
+ tools: "allow",
2725
+ bash: "allow",
2726
+ mcp: "allow",
2727
+ skills: "allow",
2728
+ special: "ask",
2729
+ },
2730
+ special: { external_directory: "ask" },
2731
+ },
2732
+ ["read", "grep"],
2733
+ { cwd },
2734
+ );
2735
+
2736
+ try {
2737
+ // First access — user selects "Yes, for this session"
2738
+ const result1 = await runToolCall(
2739
+ harness,
2740
+ {
2741
+ toolName: "read",
2742
+ toolCallId: "ext-session-1",
2743
+ input: { path: join(siblingDir, "src", "foo.ts") },
2744
+ },
2745
+ { hasUI: true, selectResponse: "Yes, for this session" },
2746
+ );
2747
+ assert.deepEqual(result1, {});
2748
+ assert.equal(harness.prompts.length, 1);
2749
+
2750
+ // Second access under same prefix — should skip prompt
2751
+ const result2 = await runToolCall(
2752
+ harness,
2753
+ {
2754
+ toolName: "read",
2755
+ toolCallId: "ext-session-2",
2756
+ input: { path: join(siblingDir, "src", "bar.ts") },
2757
+ },
2758
+ { hasUI: true, selectResponse: "Yes, for this session" },
2759
+ );
2760
+ assert.deepEqual(result2, {});
2761
+ // No new prompt — still just the original one
2762
+ assert.equal(harness.prompts.length, 1);
2763
+
2764
+ // Third access with different tool under same prefix — also skipped
2765
+ const result3 = await runToolCall(
2766
+ harness,
2767
+ {
2768
+ toolName: "grep",
2769
+ toolCallId: "ext-session-3",
2770
+ input: { pattern: "needle", path: join(siblingDir, "src", "baz.ts") },
2771
+ },
2772
+ { hasUI: true, selectResponse: "Yes, for this session" },
2773
+ );
2774
+ assert.deepEqual(result3, {});
2775
+ assert.equal(harness.prompts.length, 1);
2776
+ } finally {
2777
+ await harness.cleanup();
2778
+ rmSync(rootDir, { recursive: true, force: true });
2779
+ }
2780
+ });
2781
+
2782
+ test("session approval: different directory prefix still prompts", async () => {
2783
+ const rootDir = mkdtempSync(join(tmpdir(), "pi-session-approval-"));
2784
+ const cwd = join(rootDir, "repo");
2785
+ const siblingA = join(rootDir, "sibling-a");
2786
+ const siblingB = join(rootDir, "sibling-b");
2787
+ mkdirSync(cwd, { recursive: true });
2788
+ mkdirSync(siblingA, { recursive: true });
2789
+ mkdirSync(siblingB, { recursive: true });
2790
+
2791
+ const harness = createToolCallHarness(
2792
+ {
2793
+ defaultPolicy: {
2794
+ tools: "allow",
2795
+ bash: "allow",
2796
+ mcp: "allow",
2797
+ skills: "allow",
2798
+ special: "ask",
2799
+ },
2800
+ special: { external_directory: "ask" },
2801
+ },
2802
+ ["read"],
2803
+ { cwd },
2804
+ );
2805
+
2806
+ try {
2807
+ // Approve sibling-a/src/ for session
2808
+ await runToolCall(
2809
+ harness,
2810
+ {
2811
+ toolName: "read",
2812
+ toolCallId: "ext-diff-1",
2813
+ input: { path: join(siblingA, "src", "foo.ts") },
2814
+ },
2815
+ { hasUI: true, selectResponse: "Yes, for this session" },
2816
+ );
2817
+ assert.equal(harness.prompts.length, 1);
2818
+
2819
+ // Access sibling-b — different prefix, should prompt again
2820
+ await runToolCall(
2821
+ harness,
2822
+ {
2823
+ toolName: "read",
2824
+ toolCallId: "ext-diff-2",
2825
+ input: { path: join(siblingB, "src", "bar.ts") },
2826
+ },
2827
+ { hasUI: true, selectResponse: "Yes" },
2828
+ );
2829
+ assert.equal(harness.prompts.length, 2);
2830
+ } finally {
2831
+ await harness.cleanup();
2832
+ rmSync(rootDir, { recursive: true, force: true });
2833
+ }
2834
+ });
2835
+
2836
+ test("session approval: session_shutdown clears session approvals", async () => {
2837
+ const rootDir = mkdtempSync(join(tmpdir(), "pi-session-approval-"));
2838
+ const cwd = join(rootDir, "repo");
2839
+ const siblingDir = join(rootDir, "sibling");
2840
+ mkdirSync(cwd, { recursive: true });
2841
+ mkdirSync(siblingDir, { recursive: true });
2842
+
2843
+ const harness = createToolCallHarness(
2844
+ {
2845
+ defaultPolicy: {
2846
+ tools: "allow",
2847
+ bash: "allow",
2848
+ mcp: "allow",
2849
+ skills: "allow",
2850
+ special: "ask",
2851
+ },
2852
+ special: { external_directory: "ask" },
2853
+ },
2854
+ ["read"],
2855
+ { cwd },
2856
+ );
2857
+
2858
+ try {
2859
+ // Approve for session
2860
+ await runToolCall(
2861
+ harness,
2862
+ {
2863
+ toolName: "read",
2864
+ toolCallId: "ext-shutdown-1",
2865
+ input: { path: join(siblingDir, "src", "foo.ts") },
2866
+ },
2867
+ { hasUI: true, selectResponse: "Yes, for this session" },
2868
+ );
2869
+ assert.equal(harness.prompts.length, 1);
2870
+
2871
+ // Trigger session_shutdown (clears cache)
2872
+ const shutdownCtx = createMockContext(cwd, harness.prompts, {
2873
+ hasUI: true,
2874
+ selectResponse: "Yes",
2875
+ });
2876
+ await Promise.resolve(harness.handlers.session_shutdown?.({}, shutdownCtx));
2877
+
2878
+ // Access same path again — should prompt because cache was cleared
2879
+ const result = await runToolCall(
2880
+ harness,
2881
+ {
2882
+ toolName: "read",
2883
+ toolCallId: "ext-shutdown-2",
2884
+ input: { path: join(siblingDir, "src", "foo.ts") },
2885
+ },
2886
+ { hasUI: true, selectResponse: "Yes" },
2887
+ );
2888
+ assert.deepEqual(result, {});
2889
+ assert.equal(harness.prompts.length, 2);
2890
+ } finally {
2891
+ await harness.cleanup();
2892
+ rmSync(rootDir, { recursive: true, force: true });
2893
+ }
2894
+ });
2895
+
2896
+ test("session approval: bash external directory with 'Yes, for this session' skips subsequent prompts", async () => {
2897
+ const rootDir = mkdtempSync(join(tmpdir(), "pi-session-approval-"));
2898
+ const cwd = join(rootDir, "repo");
2899
+ mkdirSync(cwd, { recursive: true });
2900
+
2901
+ const harness = createToolCallHarness(
2902
+ {
2903
+ defaultPolicy: {
2904
+ tools: "allow",
2905
+ bash: "allow",
2906
+ mcp: "allow",
2907
+ skills: "allow",
2908
+ special: "ask",
2909
+ },
2910
+ special: { external_directory: "ask" },
2911
+ },
2912
+ ["bash"],
2913
+ { cwd },
2914
+ );
2915
+
2916
+ try {
2917
+ const externalPath = join(rootDir, "other-project", "src");
2918
+ // First bash command referencing external path
2919
+ const result1 = await runToolCall(
2920
+ harness,
2921
+ {
2922
+ toolName: "bash",
2923
+ toolCallId: "bash-session-1",
2924
+ input: { command: `ls ${externalPath}/foo.ts` },
2925
+ },
2926
+ { hasUI: true, selectResponse: "Yes, for this session" },
2927
+ );
2928
+ assert.deepEqual(result1, {});
2929
+ assert.equal(harness.prompts.length, 1);
2930
+
2931
+ // Second bash command referencing path under same prefix — skips prompt
2932
+ const result2 = await runToolCall(
2933
+ harness,
2934
+ {
2935
+ toolName: "bash",
2936
+ toolCallId: "bash-session-2",
2937
+ input: { command: `cat ${externalPath}/bar.ts` },
2938
+ },
2939
+ { hasUI: true, selectResponse: "Yes, for this session" },
2940
+ );
2941
+ assert.deepEqual(result2, {});
2942
+ assert.equal(harness.prompts.length, 1);
2943
+ } finally {
2944
+ await harness.cleanup();
2945
+ rmSync(rootDir, { recursive: true, force: true });
2946
+ }
2947
+ });
2948
+
2949
+ test("session approval: regular 'Yes' does not create session approval", async () => {
2950
+ const rootDir = mkdtempSync(join(tmpdir(), "pi-session-approval-"));
2951
+ const cwd = join(rootDir, "repo");
2952
+ const siblingDir = join(rootDir, "sibling");
2953
+ mkdirSync(cwd, { recursive: true });
2954
+ mkdirSync(siblingDir, { recursive: true });
2955
+
2956
+ const harness = createToolCallHarness(
2957
+ {
2958
+ defaultPolicy: {
2959
+ tools: "allow",
2960
+ bash: "allow",
2961
+ mcp: "allow",
2962
+ skills: "allow",
2963
+ special: "ask",
2964
+ },
2965
+ special: { external_directory: "ask" },
2966
+ },
2967
+ ["read"],
2968
+ { cwd },
2969
+ );
2970
+
2971
+ try {
2972
+ // Approve once with "Yes" (not session)
2973
+ await runToolCall(
2974
+ harness,
2975
+ {
2976
+ toolName: "read",
2977
+ toolCallId: "ext-once-1",
2978
+ input: { path: join(siblingDir, "src", "foo.ts") },
2979
+ },
2980
+ { hasUI: true, selectResponse: "Yes" },
2981
+ );
2982
+ assert.equal(harness.prompts.length, 1);
2983
+
2984
+ // Same prefix — should still prompt since we used "Yes" not session
2985
+ await runToolCall(
2986
+ harness,
2987
+ {
2988
+ toolName: "read",
2989
+ toolCallId: "ext-once-2",
2990
+ input: { path: join(siblingDir, "src", "bar.ts") },
2991
+ },
2992
+ { hasUI: true, selectResponse: "Yes" },
2993
+ );
2994
+ assert.equal(harness.prompts.length, 2);
2995
+ } finally {
2996
+ await harness.cleanup();
2997
+ rmSync(rootDir, { recursive: true, force: true });
2998
+ }
2999
+ });