@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.
- package/CHANGELOG.md +33 -0
- package/README.md +19 -3
- package/config/config.example.json +0 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +2 -6
- package/src/config-loader.ts +2 -1
- package/src/extension-config.ts +0 -1
- package/src/index.ts +152 -85
- package/src/permission-dialog.ts +14 -1
- package/src/permission-manager.ts +2 -1
- package/src/session-approval-cache.ts +81 -0
- package/src/types.ts +1 -1
- package/tests/config-loader.test.ts +5 -4
- package/tests/extension-config.test.ts +7 -2
- package/tests/permission-dialog.test.ts +166 -0
- package/tests/permission-system.test.ts +361 -8
- package/tests/session-approval-cache.test.ts +131 -0
|
@@ -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
|
|
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, "
|
|
2136
|
-
assert.equal(doomResult.matchedPattern,
|
|
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
|
|
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,
|
|
2591
|
-
assert.
|
|
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
|
|
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: {
|
|
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
|
+
});
|