@gotgenes/pi-permission-system 10.5.0 → 10.5.2
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 +20 -0
- package/package.json +1 -1
- package/src/handlers/before-agent-start.ts +11 -6
- package/src/handlers/lifecycle.ts +7 -4
- package/src/handlers/permission-gate-handler.ts +3 -3
- package/src/index.ts +8 -3
- package/src/input-normalizer.ts +20 -8
- package/src/path-utils.ts +1 -10
- package/src/permission-resolver.ts +0 -3
- package/src/permission-session.ts +8 -52
- package/src/session-rules.ts +3 -2
- package/src/skill-prompt-sanitizer.ts +1 -1
- package/test/before-agent-start-cache.test.ts +89 -0
- package/test/handlers/before-agent-start.test.ts +56 -86
- package/test/handlers/external-directory-session-dedup.test.ts +175 -159
- package/test/handlers/gates/bash-path.test.ts +57 -0
- package/test/handlers/gates/path.test.ts +58 -0
- package/test/handlers/input.test.ts +5 -4
- package/test/handlers/lifecycle.test.ts +79 -85
- package/test/handlers/tool-call.test.ts +106 -2
- package/test/helpers/handler-fixtures.ts +99 -102
- package/test/helpers/manager-harness.ts +61 -0
- package/test/helpers/session-fixtures.ts +192 -0
- package/test/input-normalizer.test.ts +77 -1
- package/test/logging.test.ts +51 -0
- package/test/path-utils.test.ts +10 -0
- package/test/permission-forwarding.test.ts +73 -0
- package/test/permission-manager-unified.test.ts +1577 -3
- package/test/permission-resolver.test.ts +3 -1
- package/test/permission-session.test.ts +14 -198
- package/test/session-rules.test.ts +13 -5
- package/test/skill-prompt-sanitizer.test.ts +130 -0
- package/test/status.test.ts +10 -0
- package/test/system-prompt-sanitizer.test.ts +68 -0
- package/test/tool-registry.test.ts +42 -0
- package/test/yolo-mode.test.ts +78 -0
- package/src/agent-prep-session.ts +0 -28
- package/src/gate-handler-session.ts +0 -13
- package/src/session-lifecycle-session.ts +0 -24
- package/test/permission-system.test.ts +0 -2785
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
import type { AgentPrepSession } from "#src/agent-prep-session";
|
|
4
3
|
import {
|
|
5
4
|
AgentPrepHandler,
|
|
6
5
|
shouldExposeTool,
|
|
@@ -8,6 +7,10 @@ import {
|
|
|
8
7
|
import type { ToolRegistry } from "#src/tool-registry";
|
|
9
8
|
|
|
10
9
|
import { makeCheckResult, makeCtx } from "#test/helpers/handler-fixtures";
|
|
10
|
+
import {
|
|
11
|
+
makeRealResolver,
|
|
12
|
+
makeRealSession,
|
|
13
|
+
} from "#test/helpers/session-fixtures";
|
|
11
14
|
|
|
12
15
|
// ── SDK stubs ──────────────────────────────────────────────────────────────
|
|
13
16
|
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
|
@@ -25,51 +28,6 @@ function makeEvent(systemPrompt = "You are an assistant.") {
|
|
|
25
28
|
return { systemPrompt };
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
function makeSession(
|
|
29
|
-
overrides: Partial<AgentPrepSession> = {},
|
|
30
|
-
): AgentPrepSession {
|
|
31
|
-
return {
|
|
32
|
-
activate: overrides.activate ?? vi.fn<AgentPrepSession["activate"]>(),
|
|
33
|
-
refreshConfig:
|
|
34
|
-
overrides.refreshConfig ?? vi.fn<AgentPrepSession["refreshConfig"]>(),
|
|
35
|
-
resolveAgentName:
|
|
36
|
-
overrides.resolveAgentName ??
|
|
37
|
-
vi.fn<AgentPrepSession["resolveAgentName"]>().mockReturnValue(null),
|
|
38
|
-
checkPermission:
|
|
39
|
-
overrides.checkPermission ??
|
|
40
|
-
vi
|
|
41
|
-
.fn<AgentPrepSession["checkPermission"]>()
|
|
42
|
-
.mockReturnValue(makeCheckResult()),
|
|
43
|
-
getToolPermission:
|
|
44
|
-
overrides.getToolPermission ??
|
|
45
|
-
vi.fn<AgentPrepSession["getToolPermission"]>().mockReturnValue("allow"),
|
|
46
|
-
shouldUpdateActiveTools:
|
|
47
|
-
overrides.shouldUpdateActiveTools ??
|
|
48
|
-
vi
|
|
49
|
-
.fn<AgentPrepSession["shouldUpdateActiveTools"]>()
|
|
50
|
-
.mockReturnValue(true),
|
|
51
|
-
commitActiveToolsCacheKey:
|
|
52
|
-
overrides.commitActiveToolsCacheKey ??
|
|
53
|
-
vi.fn<AgentPrepSession["commitActiveToolsCacheKey"]>(),
|
|
54
|
-
getPolicyCacheStamp:
|
|
55
|
-
overrides.getPolicyCacheStamp ??
|
|
56
|
-
vi
|
|
57
|
-
.fn<AgentPrepSession["getPolicyCacheStamp"]>()
|
|
58
|
-
.mockReturnValue("stamp-1"),
|
|
59
|
-
shouldUpdatePromptState:
|
|
60
|
-
overrides.shouldUpdatePromptState ??
|
|
61
|
-
vi
|
|
62
|
-
.fn<AgentPrepSession["shouldUpdatePromptState"]>()
|
|
63
|
-
.mockReturnValue(true),
|
|
64
|
-
commitPromptStateCacheKey:
|
|
65
|
-
overrides.commitPromptStateCacheKey ??
|
|
66
|
-
vi.fn<AgentPrepSession["commitPromptStateCacheKey"]>(),
|
|
67
|
-
setActiveSkillEntries:
|
|
68
|
-
overrides.setActiveSkillEntries ??
|
|
69
|
-
vi.fn<AgentPrepSession["setActiveSkillEntries"]>(),
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
31
|
function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
|
|
74
32
|
return {
|
|
75
33
|
getAll: vi.fn().mockReturnValue([]),
|
|
@@ -78,18 +36,33 @@ function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
|
|
|
78
36
|
};
|
|
79
37
|
}
|
|
80
38
|
|
|
81
|
-
function
|
|
82
|
-
|
|
39
|
+
function makeSetup(opts?: {
|
|
40
|
+
toolPermission?: "allow" | "deny" | "ask";
|
|
83
41
|
toolRegistry?: Partial<ToolRegistry>;
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
42
|
+
}) {
|
|
43
|
+
const { session, permissionManager, sessionRules, configStore, forwarding } =
|
|
44
|
+
makeRealSession();
|
|
45
|
+
const { resolver } = makeRealResolver(permissionManager, sessionRules);
|
|
46
|
+
if (opts?.toolPermission !== undefined) {
|
|
47
|
+
vi.mocked(permissionManager.getToolPermission).mockReturnValue(
|
|
48
|
+
opts.toolPermission,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
// Default checkPermission returns allow (for skill-prompt sanitizer)
|
|
52
|
+
vi.mocked(permissionManager.checkPermission).mockReturnValue(
|
|
53
|
+
makeCheckResult(),
|
|
54
|
+
);
|
|
55
|
+
const toolRegistry = makeToolRegistry(opts?.toolRegistry);
|
|
56
|
+
const handler = new AgentPrepHandler(session, resolver, toolRegistry);
|
|
57
|
+
return {
|
|
58
|
+
handler,
|
|
59
|
+
session,
|
|
60
|
+
resolver,
|
|
61
|
+
permissionManager,
|
|
62
|
+
configStore,
|
|
63
|
+
forwarding,
|
|
64
|
+
toolRegistry,
|
|
65
|
+
};
|
|
93
66
|
}
|
|
94
67
|
|
|
95
68
|
// ── shouldExposeTool (pure helper) ─────────────────────────────────────────
|
|
@@ -128,31 +101,30 @@ describe("shouldExposeTool", () => {
|
|
|
128
101
|
describe("AgentPrepHandler.handle", () => {
|
|
129
102
|
it("activates the session with ctx", async () => {
|
|
130
103
|
const ctx = makeCtx();
|
|
131
|
-
const { handler,
|
|
104
|
+
const { handler, forwarding } = makeSetup();
|
|
132
105
|
await handler.handle(makeEvent(), ctx);
|
|
133
|
-
|
|
106
|
+
// Real session.activate calls forwarding.start
|
|
107
|
+
expect(forwarding.start).toHaveBeenCalledWith(ctx);
|
|
134
108
|
});
|
|
135
109
|
|
|
136
110
|
it("refreshes config with ctx", async () => {
|
|
137
111
|
const ctx = makeCtx();
|
|
138
|
-
const { handler,
|
|
112
|
+
const { handler, configStore } = makeSetup();
|
|
139
113
|
await handler.handle(makeEvent(), ctx);
|
|
140
|
-
expect(
|
|
114
|
+
expect(configStore.refresh).toHaveBeenCalledWith(ctx);
|
|
141
115
|
});
|
|
142
116
|
|
|
143
117
|
it("resolves agent name using systemPrompt", async () => {
|
|
144
118
|
const ctx = makeCtx();
|
|
145
|
-
const { handler, session } =
|
|
119
|
+
const { handler, session } = makeSetup();
|
|
120
|
+
const spy = vi.spyOn(session, "resolveAgentName");
|
|
146
121
|
await handler.handle(makeEvent("<active_agent name='x'>"), ctx);
|
|
147
|
-
expect(
|
|
148
|
-
ctx,
|
|
149
|
-
"<active_agent name='x'>",
|
|
150
|
-
);
|
|
122
|
+
expect(spy).toHaveBeenCalledWith(ctx, "<active_agent name='x'>");
|
|
151
123
|
});
|
|
152
124
|
|
|
153
125
|
it("filters out denied tools from allowed list", async () => {
|
|
154
|
-
const { handler, toolRegistry } =
|
|
155
|
-
|
|
126
|
+
const { handler, toolRegistry } = makeSetup({
|
|
127
|
+
toolPermission: "deny",
|
|
156
128
|
toolRegistry: {
|
|
157
129
|
getAll: vi.fn().mockReturnValue([{ name: "write" }, { name: "read" }]),
|
|
158
130
|
},
|
|
@@ -162,7 +134,7 @@ describe("AgentPrepHandler.handle", () => {
|
|
|
162
134
|
});
|
|
163
135
|
|
|
164
136
|
it("includes allowed and ask tools in the active list", async () => {
|
|
165
|
-
const { handler, toolRegistry } =
|
|
137
|
+
const { handler, toolRegistry } = makeSetup({
|
|
166
138
|
toolRegistry: {
|
|
167
139
|
getAll: vi.fn().mockReturnValue([{ name: "read" }, { name: "write" }]),
|
|
168
140
|
},
|
|
@@ -172,60 +144,58 @@ describe("AgentPrepHandler.handle", () => {
|
|
|
172
144
|
});
|
|
173
145
|
|
|
174
146
|
it("commits active-tools cache key after applying", async () => {
|
|
175
|
-
const { handler, session } =
|
|
147
|
+
const { handler, session } = makeSetup({
|
|
176
148
|
toolRegistry: {
|
|
177
149
|
getAll: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
178
150
|
},
|
|
179
151
|
});
|
|
152
|
+
const spy = vi.spyOn(session, "commitActiveToolsCacheKey");
|
|
180
153
|
await handler.handle(makeEvent(), makeCtx());
|
|
181
|
-
expect(
|
|
154
|
+
expect(spy).toHaveBeenCalled();
|
|
182
155
|
});
|
|
183
156
|
|
|
184
157
|
it("skips setActive when cache key is unchanged", async () => {
|
|
185
|
-
const { handler, session, toolRegistry } =
|
|
186
|
-
session: { shouldUpdateActiveTools: vi.fn().mockReturnValue(false) },
|
|
158
|
+
const { handler, session, toolRegistry } = makeSetup({
|
|
187
159
|
toolRegistry: {
|
|
188
160
|
getAll: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
189
161
|
},
|
|
190
162
|
});
|
|
163
|
+
vi.spyOn(session, "shouldUpdateActiveTools").mockReturnValue(false);
|
|
191
164
|
await handler.handle(makeEvent(), makeCtx());
|
|
192
165
|
expect(toolRegistry.setActive).not.toHaveBeenCalled();
|
|
193
|
-
expect(session.commitActiveToolsCacheKey).not.toHaveBeenCalled();
|
|
194
166
|
});
|
|
195
167
|
|
|
196
168
|
it("returns empty object when prompt cache is unchanged", async () => {
|
|
197
|
-
const { handler, session } =
|
|
198
|
-
|
|
199
|
-
});
|
|
169
|
+
const { handler, session } = makeSetup();
|
|
170
|
+
vi.spyOn(session, "shouldUpdatePromptState").mockReturnValue(false);
|
|
200
171
|
const result = await handler.handle(makeEvent(), makeCtx());
|
|
201
172
|
expect(result).toEqual({});
|
|
202
|
-
expect(session.commitPromptStateCacheKey).not.toHaveBeenCalled();
|
|
203
173
|
});
|
|
204
174
|
|
|
205
175
|
it("commits prompt-state cache key and processes prompt when cache is new", async () => {
|
|
206
|
-
const { handler, session } =
|
|
176
|
+
const { handler, session } = makeSetup();
|
|
177
|
+
const spy = vi.spyOn(session, "commitPromptStateCacheKey");
|
|
207
178
|
await handler.handle(makeEvent(), makeCtx());
|
|
208
|
-
expect(
|
|
179
|
+
expect(spy).toHaveBeenCalled();
|
|
209
180
|
});
|
|
210
181
|
|
|
211
182
|
it("stores resolved skill entries on the session", async () => {
|
|
212
|
-
const { handler, session } =
|
|
183
|
+
const { handler, session } = makeSetup();
|
|
184
|
+
const spy = vi.spyOn(session, "setActiveSkillEntries");
|
|
213
185
|
await handler.handle(makeEvent(), makeCtx());
|
|
214
|
-
expect(
|
|
215
|
-
expect.any(Array),
|
|
216
|
-
);
|
|
186
|
+
expect(spy).toHaveBeenCalledWith(expect.any(Array));
|
|
217
187
|
});
|
|
218
188
|
|
|
219
189
|
it("returns modified systemPrompt when prompt changes", async () => {
|
|
220
190
|
const systemPrompt = `You are an assistant.\n\nAvailable tools:\n- read\n- write\n`;
|
|
221
|
-
const { handler } =
|
|
191
|
+
const { handler } = makeSetup();
|
|
222
192
|
const result = await handler.handle(makeEvent(systemPrompt), makeCtx());
|
|
223
193
|
expect(result).toHaveProperty("systemPrompt");
|
|
224
194
|
});
|
|
225
195
|
|
|
226
196
|
it("returns empty object when systemPrompt is unchanged", async () => {
|
|
227
197
|
const prompt = "No tools section here.";
|
|
228
|
-
const { handler } =
|
|
198
|
+
const { handler } = makeSetup();
|
|
229
199
|
const result = await handler.handle(makeEvent(prompt), makeCtx());
|
|
230
200
|
expect(result).toEqual({});
|
|
231
201
|
});
|
|
@@ -3,33 +3,30 @@
|
|
|
3
3
|
* external path only prompt once — the session-approval recorded by the
|
|
4
4
|
* first call covers the second.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the real interaction between PermissionSession, SessionRules, and
|
|
9
|
-
* PermissionManager.
|
|
6
|
+
* Uses real PermissionSession + PermissionResolver + SessionRules so the
|
|
7
|
+
* stateful approval-tracking path is exercised end-to-end.
|
|
10
8
|
*/
|
|
11
9
|
|
|
12
10
|
import { describe, expect, it, vi } from "vitest";
|
|
13
11
|
|
|
14
12
|
import { GateDecisionReporter } from "#src/decision-reporter";
|
|
15
|
-
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
16
13
|
import type { GatePrompter } from "#src/gate-prompter";
|
|
17
14
|
import { GateRunner } from "#src/handlers/gates/runner";
|
|
18
15
|
import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
|
|
19
16
|
import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
|
|
20
17
|
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
|
|
21
|
-
import type { Rule } from "#src/rule";
|
|
22
|
-
import type { SessionApproval } from "#src/session-approval";
|
|
23
|
-
import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
|
|
24
|
-
import type { ToolRegistry } from "#src/tool-registry";
|
|
25
18
|
import type { PermissionCheckResult } from "#src/types";
|
|
26
19
|
import { wildcardMatch } from "#src/wildcard-matcher";
|
|
27
20
|
|
|
28
21
|
import {
|
|
29
|
-
type MockGateHandlerSession,
|
|
30
22
|
makeCtx,
|
|
31
23
|
makeEvents,
|
|
24
|
+
makeToolRegistry,
|
|
32
25
|
} from "#test/helpers/handler-fixtures";
|
|
26
|
+
import {
|
|
27
|
+
makeRealResolver,
|
|
28
|
+
makeRealSession,
|
|
29
|
+
} from "#test/helpers/session-fixtures";
|
|
33
30
|
|
|
34
31
|
// ── SDK stub ───────────────────────────────────────────────────────────────
|
|
35
32
|
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
|
@@ -41,177 +38,105 @@ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
|
|
41
38
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
42
39
|
|
|
43
40
|
/**
|
|
44
|
-
* Build a
|
|
41
|
+
* Build a fully wired PermissionGateHandler for external-directory dedup
|
|
42
|
+
* tests.
|
|
45
43
|
*
|
|
46
|
-
* `checkPermission`
|
|
47
|
-
*
|
|
48
|
-
* it
|
|
49
|
-
* "allow"
|
|
44
|
+
* `permissionManager.checkPermission` is configured so that:
|
|
45
|
+
* - `external_directory` surface returns "ask" on first call
|
|
46
|
+
* - On subsequent calls it checks the shared `sessionRules` store; if a
|
|
47
|
+
* matching rule was recorded by the runner, it returns "allow" with
|
|
48
|
+
* `source: "session"`.
|
|
49
|
+
* - All other surfaces return "allow".
|
|
50
50
|
*/
|
|
51
|
-
function
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
state: "allow",
|
|
82
|
-
toolName: surface,
|
|
83
|
-
source: "session",
|
|
84
|
-
origin: "session",
|
|
85
|
-
matchedPattern: match.pattern,
|
|
86
|
-
};
|
|
87
|
-
}
|
|
51
|
+
function makeDeduplicatingHandler(prompter?: GatePrompter): {
|
|
52
|
+
handler: PermissionGateHandler;
|
|
53
|
+
prompter: GatePrompter;
|
|
54
|
+
} {
|
|
55
|
+
const { session, permissionManager, sessionRules, logger } =
|
|
56
|
+
makeRealSession();
|
|
57
|
+
const { resolver } = makeRealResolver(permissionManager, sessionRules);
|
|
58
|
+
|
|
59
|
+
// Configure checkPermission to simulate config-level "ask" for external_directory
|
|
60
|
+
// but return "allow/session" when a session rule has been recorded.
|
|
61
|
+
vi.mocked(permissionManager.checkPermission).mockImplementation(
|
|
62
|
+
(surface, input, _agentName, rules): PermissionCheckResult => {
|
|
63
|
+
if (surface === "external_directory") {
|
|
64
|
+
const record = (input ?? {}) as Record<string, unknown>;
|
|
65
|
+
const pathValue = typeof record.path === "string" ? record.path : null;
|
|
66
|
+
|
|
67
|
+
if (pathValue && rules && rules.length > 0) {
|
|
68
|
+
const match = rules.findLast(
|
|
69
|
+
(r) =>
|
|
70
|
+
r.surface === "external_directory" &&
|
|
71
|
+
wildcardMatch(r.pattern, pathValue),
|
|
72
|
+
);
|
|
73
|
+
if (match) {
|
|
74
|
+
return {
|
|
75
|
+
state: "allow",
|
|
76
|
+
toolName: surface,
|
|
77
|
+
source: "session",
|
|
78
|
+
origin: "session",
|
|
79
|
+
matchedPattern: match.pattern,
|
|
80
|
+
};
|
|
88
81
|
}
|
|
89
|
-
|
|
90
|
-
// No session match → config-level "ask"
|
|
91
|
-
return {
|
|
92
|
-
state: "ask",
|
|
93
|
-
toolName: surface,
|
|
94
|
-
source: "special",
|
|
95
|
-
origin: "global",
|
|
96
|
-
};
|
|
97
82
|
}
|
|
98
83
|
|
|
99
|
-
// All other surfaces: allow
|
|
100
84
|
return {
|
|
101
|
-
state: "
|
|
85
|
+
state: "ask",
|
|
102
86
|
toolName: surface,
|
|
103
|
-
source: "
|
|
104
|
-
origin: "
|
|
87
|
+
source: "special",
|
|
88
|
+
origin: "global",
|
|
105
89
|
};
|
|
106
|
-
},
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
const recordSessionApproval = vi
|
|
110
|
-
.fn<MockGateHandlerSession["recordSessionApproval"]>()
|
|
111
|
-
.mockImplementation((approval: SessionApproval) => {
|
|
112
|
-
for (const pattern of approval.patterns) {
|
|
113
|
-
sessionRules.push({
|
|
114
|
-
surface: approval.surface,
|
|
115
|
-
pattern,
|
|
116
|
-
action: "allow",
|
|
117
|
-
layer: "session",
|
|
118
|
-
origin: "session",
|
|
119
|
-
});
|
|
120
90
|
}
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
const getSessionRuleset = vi
|
|
124
|
-
.fn<MockGateHandlerSession["getSessionRuleset"]>()
|
|
125
|
-
.mockImplementation(() => [...sessionRules]);
|
|
126
91
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
92
|
+
return {
|
|
93
|
+
state: "allow",
|
|
94
|
+
toolName: surface,
|
|
95
|
+
source: "tool",
|
|
96
|
+
origin: "builtin",
|
|
97
|
+
};
|
|
132
98
|
},
|
|
133
|
-
|
|
134
|
-
resolveAgentName:
|
|
135
|
-
overrides.resolveAgentName ??
|
|
136
|
-
vi.fn<MockGateHandlerSession["resolveAgentName"]>().mockReturnValue(null),
|
|
137
|
-
checkPermission: overrides.checkPermission ?? checkPermission,
|
|
138
|
-
getSessionRuleset: overrides.getSessionRuleset ?? getSessionRuleset,
|
|
139
|
-
recordSessionApproval:
|
|
140
|
-
overrides.recordSessionApproval ?? recordSessionApproval,
|
|
141
|
-
getActiveSkillEntries:
|
|
142
|
-
overrides.getActiveSkillEntries ??
|
|
143
|
-
vi
|
|
144
|
-
.fn<MockGateHandlerSession["getActiveSkillEntries"]>()
|
|
145
|
-
.mockReturnValue([]),
|
|
146
|
-
getInfrastructureReadDirs:
|
|
147
|
-
overrides.getInfrastructureReadDirs ??
|
|
148
|
-
vi
|
|
149
|
-
.fn<MockGateHandlerSession["getInfrastructureReadDirs"]>()
|
|
150
|
-
.mockReturnValue([]),
|
|
151
|
-
getToolPreviewLimits:
|
|
152
|
-
overrides.getToolPreviewLimits ??
|
|
153
|
-
vi
|
|
154
|
-
.fn<MockGateHandlerSession["getToolPreviewLimits"]>()
|
|
155
|
-
.mockReturnValue(resolveToolPreviewLimits(DEFAULT_EXTENSION_CONFIG)),
|
|
156
|
-
};
|
|
157
|
-
return session;
|
|
158
|
-
}
|
|
99
|
+
);
|
|
159
100
|
|
|
160
|
-
function makeHandlerForSession(
|
|
161
|
-
session: MockGateHandlerSession,
|
|
162
|
-
prompter?: GatePrompter,
|
|
163
|
-
): { handler: PermissionGateHandler; prompter: GatePrompter } {
|
|
164
101
|
const events = makeEvents();
|
|
165
|
-
const reporter = new GateDecisionReporter(
|
|
102
|
+
const reporter = new GateDecisionReporter(logger, events);
|
|
166
103
|
const resolvedPrompter: GatePrompter = prompter ?? {
|
|
167
104
|
canConfirm: vi.fn().mockReturnValue(true),
|
|
168
105
|
prompt: vi
|
|
169
106
|
.fn<GatePrompter["prompt"]>()
|
|
170
107
|
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
171
108
|
};
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
input,
|
|
179
|
-
agentName,
|
|
180
|
-
session.getSessionRuleset(),
|
|
181
|
-
),
|
|
182
|
-
};
|
|
183
|
-
const runner = new GateRunner(resolver, session, resolvedPrompter, reporter);
|
|
109
|
+
const runner = new GateRunner(
|
|
110
|
+
resolver,
|
|
111
|
+
sessionRules,
|
|
112
|
+
resolvedPrompter,
|
|
113
|
+
reporter,
|
|
114
|
+
);
|
|
184
115
|
const handler = new PermissionGateHandler(
|
|
185
116
|
session,
|
|
186
|
-
makeToolRegistry(
|
|
117
|
+
makeToolRegistry({
|
|
118
|
+
getAll: vi
|
|
119
|
+
.fn()
|
|
120
|
+
.mockReturnValue([
|
|
121
|
+
{ name: "read" },
|
|
122
|
+
{ name: "write" },
|
|
123
|
+
{ name: "edit" },
|
|
124
|
+
{ name: "bash" },
|
|
125
|
+
]),
|
|
126
|
+
}),
|
|
187
127
|
new ToolCallGatePipeline(resolver, session),
|
|
188
|
-
new SkillInputGatePipeline(
|
|
128
|
+
new SkillInputGatePipeline(resolver),
|
|
189
129
|
runner,
|
|
190
130
|
);
|
|
191
131
|
return { handler, prompter: resolvedPrompter };
|
|
192
132
|
}
|
|
193
133
|
|
|
194
|
-
function makeToolRegistry(): ToolRegistry {
|
|
195
|
-
return {
|
|
196
|
-
getAll: vi
|
|
197
|
-
.fn()
|
|
198
|
-
.mockReturnValue([
|
|
199
|
-
{ name: "read" },
|
|
200
|
-
{ name: "write" },
|
|
201
|
-
{ name: "edit" },
|
|
202
|
-
{ name: "bash" },
|
|
203
|
-
]),
|
|
204
|
-
setActive: vi.fn(),
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
|
|
208
134
|
// ── tests ──────────────────────────────────────────────────────────────────
|
|
209
135
|
|
|
210
136
|
describe("external-directory session dedup", () => {
|
|
211
137
|
describe("path-bearing tools (read, write, edit)", () => {
|
|
212
138
|
it("does not re-prompt for the same external path after session approval", async () => {
|
|
213
|
-
const
|
|
214
|
-
const { handler, prompter } = makeHandlerForSession(session);
|
|
139
|
+
const { handler, prompter } = makeDeduplicatingHandler();
|
|
215
140
|
const ctx = makeCtx();
|
|
216
141
|
const externalPath = "/outside/project/data.txt";
|
|
217
142
|
|
|
@@ -239,8 +164,7 @@ describe("external-directory session dedup", () => {
|
|
|
239
164
|
});
|
|
240
165
|
|
|
241
166
|
it("does not re-prompt for a different file in the same external directory", async () => {
|
|
242
|
-
const
|
|
243
|
-
const { handler, prompter } = makeHandlerForSession(session);
|
|
167
|
+
const { handler, prompter } = makeDeduplicatingHandler();
|
|
244
168
|
const ctx = makeCtx();
|
|
245
169
|
|
|
246
170
|
// First call — prompt for /outside/project/a.txt
|
|
@@ -265,8 +189,7 @@ describe("external-directory session dedup", () => {
|
|
|
265
189
|
});
|
|
266
190
|
|
|
267
191
|
it("does prompt for a file in a different external directory", async () => {
|
|
268
|
-
const
|
|
269
|
-
const { handler, prompter } = makeHandlerForSession(session);
|
|
192
|
+
const { handler, prompter } = makeDeduplicatingHandler();
|
|
270
193
|
const ctx = makeCtx();
|
|
271
194
|
|
|
272
195
|
// First call — /outside/alpha/file.txt
|
|
@@ -291,14 +214,13 @@ describe("external-directory session dedup", () => {
|
|
|
291
214
|
});
|
|
292
215
|
|
|
293
216
|
it("re-prompts when user approved once (not for session)", async () => {
|
|
294
|
-
const session = makeStatefulSession();
|
|
295
217
|
const approveOnce: GatePrompter = {
|
|
296
218
|
canConfirm: vi.fn().mockReturnValue(true),
|
|
297
219
|
prompt: vi
|
|
298
220
|
.fn<GatePrompter["prompt"]>()
|
|
299
221
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
300
222
|
};
|
|
301
|
-
const { handler, prompter } =
|
|
223
|
+
const { handler, prompter } = makeDeduplicatingHandler(approveOnce);
|
|
302
224
|
const ctx = makeCtx();
|
|
303
225
|
const externalPath = "/outside/project/data.txt";
|
|
304
226
|
|
|
@@ -326,8 +248,7 @@ describe("external-directory session dedup", () => {
|
|
|
326
248
|
|
|
327
249
|
describe("bash commands with external paths", () => {
|
|
328
250
|
it("does not re-prompt for a bash command referencing the same external path after session approval", async () => {
|
|
329
|
-
const
|
|
330
|
-
const { handler, prompter } = makeHandlerForSession(session);
|
|
251
|
+
const { handler, prompter } = makeDeduplicatingHandler();
|
|
331
252
|
const ctx = makeCtx();
|
|
332
253
|
|
|
333
254
|
// First call — bash referencing /tmp/out.txt
|
|
@@ -354,8 +275,7 @@ describe("external-directory session dedup", () => {
|
|
|
354
275
|
});
|
|
355
276
|
|
|
356
277
|
it("does not re-prompt for read after bash already approved the same directory", async () => {
|
|
357
|
-
const
|
|
358
|
-
const { handler, prompter } = makeHandlerForSession(session);
|
|
278
|
+
const { handler, prompter } = makeDeduplicatingHandler();
|
|
359
279
|
const ctx = makeCtx();
|
|
360
280
|
|
|
361
281
|
// First call — bash writes to /tmp/out.txt
|
|
@@ -380,3 +300,99 @@ describe("external-directory session dedup", () => {
|
|
|
380
300
|
});
|
|
381
301
|
});
|
|
382
302
|
});
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// Moved from permission-system.test.ts catch-all (#342)
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
describe("session shutdown clears external-directory approvals", () => {
|
|
309
|
+
it("re-prompts for the same path after session shutdown", async () => {
|
|
310
|
+
// Build a fully wired handler inline so we can access session directly.
|
|
311
|
+
const { session, permissionManager, sessionRules, logger } =
|
|
312
|
+
makeRealSession();
|
|
313
|
+
const { resolver } = makeRealResolver(permissionManager, sessionRules);
|
|
314
|
+
|
|
315
|
+
// external_directory=ask; session-covered paths return allow/session.
|
|
316
|
+
vi.mocked(permissionManager.checkPermission).mockImplementation(
|
|
317
|
+
(surface, input, _agentName, rules): PermissionCheckResult => {
|
|
318
|
+
if (surface === "external_directory") {
|
|
319
|
+
const record = (input ?? {}) as Record<string, unknown>;
|
|
320
|
+
const pathValue =
|
|
321
|
+
typeof record.path === "string" ? record.path : null;
|
|
322
|
+
if (pathValue && rules && rules.length > 0) {
|
|
323
|
+
const match = rules.findLast(
|
|
324
|
+
(r) =>
|
|
325
|
+
r.surface === "external_directory" &&
|
|
326
|
+
wildcardMatch(r.pattern, pathValue),
|
|
327
|
+
);
|
|
328
|
+
if (match) {
|
|
329
|
+
return {
|
|
330
|
+
state: "allow",
|
|
331
|
+
toolName: surface,
|
|
332
|
+
source: "session",
|
|
333
|
+
origin: "session",
|
|
334
|
+
matchedPattern: match.pattern,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
state: "ask",
|
|
340
|
+
toolName: surface,
|
|
341
|
+
source: "special",
|
|
342
|
+
origin: "global",
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
state: "allow",
|
|
347
|
+
toolName: surface,
|
|
348
|
+
source: "tool",
|
|
349
|
+
origin: "builtin",
|
|
350
|
+
};
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const events = makeEvents();
|
|
355
|
+
const reporter = new GateDecisionReporter(logger, events);
|
|
356
|
+
const prompter: GatePrompter = {
|
|
357
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
358
|
+
// Simulate "Yes, for this session" on first call, "Yes" on subsequent.
|
|
359
|
+
prompt: vi
|
|
360
|
+
.fn<GatePrompter["prompt"]>()
|
|
361
|
+
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
362
|
+
};
|
|
363
|
+
const runner = new GateRunner(resolver, sessionRules, prompter, reporter);
|
|
364
|
+
const handler = new PermissionGateHandler(
|
|
365
|
+
session,
|
|
366
|
+
makeToolRegistry({
|
|
367
|
+
getAll: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
368
|
+
}),
|
|
369
|
+
new ToolCallGatePipeline(resolver, session),
|
|
370
|
+
new SkillInputGatePipeline(resolver),
|
|
371
|
+
runner,
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const externalPath = "/tmp/sibling/foo.ts";
|
|
375
|
+
const ctx = makeCtx();
|
|
376
|
+
const event = {
|
|
377
|
+
type: "tool_call",
|
|
378
|
+
toolCallId: "tc-1",
|
|
379
|
+
toolName: "read",
|
|
380
|
+
input: { path: externalPath },
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// First access: prompt fires and records session approval.
|
|
384
|
+
await handler.handleToolCall(event, ctx);
|
|
385
|
+
expect(vi.mocked(prompter.prompt)).toHaveBeenCalledTimes(1);
|
|
386
|
+
|
|
387
|
+
// Second access: covered by session approval — no re-prompt.
|
|
388
|
+
await handler.handleToolCall({ ...event, toolCallId: "tc-2" }, ctx);
|
|
389
|
+
expect(vi.mocked(prompter.prompt)).toHaveBeenCalledTimes(1);
|
|
390
|
+
|
|
391
|
+
// Shutdown clears session approvals.
|
|
392
|
+
session.shutdown();
|
|
393
|
+
|
|
394
|
+
// Third access: session rules cleared — must re-prompt.
|
|
395
|
+
await handler.handleToolCall({ ...event, toolCallId: "tc-3" }, ctx);
|
|
396
|
+
expect(vi.mocked(prompter.prompt)).toHaveBeenCalledTimes(2);
|
|
397
|
+
});
|
|
398
|
+
});
|