@gotgenes/pi-permission-system 5.3.4 → 5.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 +34 -0
- package/package.json +1 -1
- package/src/handlers/gates/bash-external-directory.ts +134 -0
- package/src/handlers/gates/external-directory.ts +189 -0
- package/src/handlers/gates/helpers.ts +41 -0
- package/src/handlers/gates/index.ts +6 -0
- package/src/handlers/gates/skill-read.ts +111 -0
- package/src/handlers/gates/tool.ts +160 -0
- package/src/handlers/gates/types.ts +15 -0
- package/src/handlers/tool-call.ts +33 -523
- package/src/permission-manager.ts +28 -279
- package/src/policy-loader.ts +350 -0
- package/tests/handlers/gates/bash-external-directory.test.ts +247 -0
- package/tests/handlers/gates/external-directory.test.ts +320 -0
- package/tests/handlers/gates/helpers.test.ts +71 -0
- package/tests/handlers/gates/skill-read.test.ts +204 -0
- package/tests/handlers/gates/tool.test.ts +417 -0
- package/tests/handlers/tool-call.test.ts +0 -504
- package/tests/permission-manager-unified.test.ts +319 -0
- package/tests/policy-loader.test.ts +561 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import { extractFrontmatter, parseSimpleYamlMap, toRecord } from "./common";
|
|
6
|
+
import {
|
|
7
|
+
loadUnifiedConfig,
|
|
8
|
+
normalizeUnifiedConfig,
|
|
9
|
+
stripJsonComments,
|
|
10
|
+
} from "./config-loader";
|
|
11
|
+
import { getGlobalConfigPath } from "./config-paths";
|
|
12
|
+
import type { ScopeConfig } from "./types";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// File-stamp helper
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
function getFileStamp(path: string): string {
|
|
19
|
+
try {
|
|
20
|
+
return String(statSync(path).mtimeMs);
|
|
21
|
+
} catch {
|
|
22
|
+
return "missing";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// MCP server-name reading helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
function readConfiguredMcpServerNamesFromConfigPath(
|
|
31
|
+
configPath: string,
|
|
32
|
+
): string[] {
|
|
33
|
+
try {
|
|
34
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
35
|
+
const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
|
|
36
|
+
const root = toRecord(parsed);
|
|
37
|
+
const serverRecord = toRecord(root.mcpServers ?? root["mcp-servers"]);
|
|
38
|
+
|
|
39
|
+
return Object.keys(serverRecord)
|
|
40
|
+
.map((name) => name.trim())
|
|
41
|
+
.filter((name) => name.length > 0);
|
|
42
|
+
} catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getConfiguredMcpServerNamesFromPaths(
|
|
48
|
+
paths: readonly string[],
|
|
49
|
+
): string[] {
|
|
50
|
+
const seen = new Set<string>();
|
|
51
|
+
|
|
52
|
+
for (const path of paths) {
|
|
53
|
+
for (const name of readConfiguredMcpServerNamesFromConfigPath(path)) {
|
|
54
|
+
seen.add(name);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return [...seen].sort(
|
|
59
|
+
(left, right) => right.length - left.length || left.localeCompare(right),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Resolved policy paths
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export interface ResolvedPolicyPaths {
|
|
68
|
+
globalConfigPath: string;
|
|
69
|
+
globalConfigExists: boolean;
|
|
70
|
+
projectConfigPath: string | null;
|
|
71
|
+
projectConfigExists: boolean;
|
|
72
|
+
agentsDir: string;
|
|
73
|
+
agentsDirExists: boolean;
|
|
74
|
+
projectAgentsDir: string | null;
|
|
75
|
+
projectAgentsDirExists: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// PolicyLoader interface
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Abstraction over file I/O for loading permission policy from disk.
|
|
84
|
+
* Implementations handle caching, path resolution, and config-issue
|
|
85
|
+
* accumulation. `PermissionManager` depends on this interface so that
|
|
86
|
+
* merge + evaluation logic can be tested with an in-memory stub.
|
|
87
|
+
*/
|
|
88
|
+
export interface PolicyLoader {
|
|
89
|
+
loadGlobalConfig(): ScopeConfig;
|
|
90
|
+
loadProjectConfig(): ScopeConfig;
|
|
91
|
+
loadAgentConfig(agentName?: string): ScopeConfig;
|
|
92
|
+
loadProjectAgentConfig(agentName?: string): ScopeConfig;
|
|
93
|
+
getConfiguredMcpServerNames(): readonly string[];
|
|
94
|
+
/** Combined mtime stamp for cache invalidation. */
|
|
95
|
+
getCacheStamp(agentName?: string): string;
|
|
96
|
+
/** Accumulated config-parse issues across all loads. */
|
|
97
|
+
getConfigIssues(): string[];
|
|
98
|
+
/** Resolved paths for the /permission-system show command. */
|
|
99
|
+
getResolvedPolicyPaths(): ResolvedPolicyPaths;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Default path factories (deferred until call-time, not module scope)
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function defaultGlobalConfigPath(): string {
|
|
107
|
+
return getGlobalConfigPath(getAgentDir());
|
|
108
|
+
}
|
|
109
|
+
function defaultAgentsDir(): string {
|
|
110
|
+
return join(getAgentDir(), "agents");
|
|
111
|
+
}
|
|
112
|
+
function defaultGlobalMcpConfigPath(): string {
|
|
113
|
+
return join(getAgentDir(), "mcp.json");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// File cache helper type
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
type FileCacheEntry<TValue> = {
|
|
121
|
+
stamp: string;
|
|
122
|
+
value: TValue;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Options shared between FilePolicyLoader and the backward-compat
|
|
127
|
+
// PermissionManager constructor.
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
export interface PolicyLoaderOptions {
|
|
131
|
+
globalConfigPath?: string;
|
|
132
|
+
agentsDir?: string;
|
|
133
|
+
projectGlobalConfigPath?: string;
|
|
134
|
+
projectAgentsDir?: string;
|
|
135
|
+
globalMcpConfigPath?: string;
|
|
136
|
+
mcpServerNames?: readonly string[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// FilePolicyLoader — the production implementation
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Production `PolicyLoader` that reads config files from disk with
|
|
145
|
+
* mtime-based caching.
|
|
146
|
+
*/
|
|
147
|
+
export class FilePolicyLoader implements PolicyLoader {
|
|
148
|
+
private readonly globalConfigPath: string;
|
|
149
|
+
private readonly agentsDir: string;
|
|
150
|
+
private readonly projectGlobalConfigPath: string | null;
|
|
151
|
+
private readonly projectAgentsDir: string | null;
|
|
152
|
+
private readonly globalMcpConfigPath: string;
|
|
153
|
+
private readonly configuredMcpServerNamesOverride: readonly string[] | null;
|
|
154
|
+
|
|
155
|
+
private globalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
|
|
156
|
+
private projectGlobalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
|
|
157
|
+
private readonly agentConfigCache = new Map<
|
|
158
|
+
string,
|
|
159
|
+
FileCacheEntry<ScopeConfig>
|
|
160
|
+
>();
|
|
161
|
+
private readonly projectAgentConfigCache = new Map<
|
|
162
|
+
string,
|
|
163
|
+
FileCacheEntry<ScopeConfig>
|
|
164
|
+
>();
|
|
165
|
+
private configuredMcpServerNamesCache: FileCacheEntry<
|
|
166
|
+
readonly string[]
|
|
167
|
+
> | null = null;
|
|
168
|
+
private accumulatedConfigIssues: string[] = [];
|
|
169
|
+
|
|
170
|
+
constructor(options: PolicyLoaderOptions = {}) {
|
|
171
|
+
this.globalConfigPath =
|
|
172
|
+
options.globalConfigPath || defaultGlobalConfigPath();
|
|
173
|
+
this.agentsDir = options.agentsDir || defaultAgentsDir();
|
|
174
|
+
this.projectGlobalConfigPath = options.projectGlobalConfigPath || null;
|
|
175
|
+
this.projectAgentsDir = options.projectAgentsDir || null;
|
|
176
|
+
this.globalMcpConfigPath =
|
|
177
|
+
options.globalMcpConfigPath || defaultGlobalMcpConfigPath();
|
|
178
|
+
this.configuredMcpServerNamesOverride = options.mcpServerNames
|
|
179
|
+
? [
|
|
180
|
+
...new Set(
|
|
181
|
+
options.mcpServerNames
|
|
182
|
+
.map((name) => name.trim())
|
|
183
|
+
.filter((name) => name.length > 0),
|
|
184
|
+
),
|
|
185
|
+
]
|
|
186
|
+
: null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Config issue accumulation ────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
private accumulateConfigIssues(issues: string[]): void {
|
|
192
|
+
for (const issue of issues) {
|
|
193
|
+
if (!this.accumulatedConfigIssues.includes(issue)) {
|
|
194
|
+
this.accumulatedConfigIssues.push(issue);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
getConfigIssues(): string[] {
|
|
200
|
+
return [...this.accumulatedConfigIssues];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Scope loaders ────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
loadGlobalConfig(): ScopeConfig {
|
|
206
|
+
const stamp = getFileStamp(this.globalConfigPath);
|
|
207
|
+
if (this.globalConfigCache?.stamp === stamp) {
|
|
208
|
+
return this.globalConfigCache.value;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const { config, issues } = loadUnifiedConfig(this.globalConfigPath);
|
|
212
|
+
this.accumulateConfigIssues(issues);
|
|
213
|
+
|
|
214
|
+
const value: ScopeConfig = {
|
|
215
|
+
permission: config.permission,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
this.globalConfigCache = { stamp, value };
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
loadProjectConfig(): ScopeConfig {
|
|
223
|
+
if (!this.projectGlobalConfigPath) {
|
|
224
|
+
return {};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const stamp = getFileStamp(this.projectGlobalConfigPath);
|
|
228
|
+
if (this.projectGlobalConfigCache?.stamp === stamp) {
|
|
229
|
+
return this.projectGlobalConfigCache.value;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const { config, issues } = loadUnifiedConfig(this.projectGlobalConfigPath);
|
|
233
|
+
this.accumulateConfigIssues(issues);
|
|
234
|
+
|
|
235
|
+
const value: ScopeConfig = {
|
|
236
|
+
permission: config.permission,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
this.projectGlobalConfigCache = { stamp, value };
|
|
240
|
+
return value;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private loadScopeConfigFrom(
|
|
244
|
+
dir: string | null,
|
|
245
|
+
cache: Map<string, FileCacheEntry<ScopeConfig>>,
|
|
246
|
+
agentName?: string,
|
|
247
|
+
): ScopeConfig {
|
|
248
|
+
if (!dir || !agentName) {
|
|
249
|
+
return {};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const filePath = join(dir, `${agentName}.md`);
|
|
253
|
+
const stamp = getFileStamp(filePath);
|
|
254
|
+
const cached = cache.get(agentName);
|
|
255
|
+
if (cached?.stamp === stamp) {
|
|
256
|
+
return cached.value;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let value: ScopeConfig;
|
|
260
|
+
try {
|
|
261
|
+
const markdown = readFileSync(filePath, "utf-8");
|
|
262
|
+
const frontmatter = extractFrontmatter(markdown);
|
|
263
|
+
if (!frontmatter) {
|
|
264
|
+
value = {};
|
|
265
|
+
} else {
|
|
266
|
+
const parsed = parseSimpleYamlMap(frontmatter);
|
|
267
|
+
const { config, issues } = normalizeUnifiedConfig(parsed);
|
|
268
|
+
this.accumulateConfigIssues(issues);
|
|
269
|
+
value = { permission: config.permission };
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
value = {};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
cache.set(agentName, { stamp, value });
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
loadAgentConfig(agentName?: string): ScopeConfig {
|
|
280
|
+
return this.loadScopeConfigFrom(
|
|
281
|
+
this.agentsDir,
|
|
282
|
+
this.agentConfigCache,
|
|
283
|
+
agentName,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
loadProjectAgentConfig(agentName?: string): ScopeConfig {
|
|
288
|
+
return this.loadScopeConfigFrom(
|
|
289
|
+
this.projectAgentsDir,
|
|
290
|
+
this.projectAgentConfigCache,
|
|
291
|
+
agentName,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── MCP server names ─────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
getConfiguredMcpServerNames(): readonly string[] {
|
|
298
|
+
if (this.configuredMcpServerNamesOverride) {
|
|
299
|
+
return this.configuredMcpServerNamesOverride;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const paths = [this.globalMcpConfigPath];
|
|
303
|
+
const stamp = paths
|
|
304
|
+
.map((path) => `${path}:${getFileStamp(path)}`)
|
|
305
|
+
.join("|");
|
|
306
|
+
if (this.configuredMcpServerNamesCache?.stamp === stamp) {
|
|
307
|
+
return this.configuredMcpServerNamesCache.value;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const value = getConfiguredMcpServerNamesFromPaths(paths);
|
|
311
|
+
this.configuredMcpServerNamesCache = { stamp, value };
|
|
312
|
+
return value;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Cache stamp ───────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
getCacheStamp(agentName?: string): string {
|
|
318
|
+
const agentStamp = agentName
|
|
319
|
+
? getFileStamp(join(this.agentsDir, `${agentName}.md`))
|
|
320
|
+
: "missing";
|
|
321
|
+
const projectStamp = this.projectGlobalConfigPath
|
|
322
|
+
? getFileStamp(this.projectGlobalConfigPath)
|
|
323
|
+
: "none";
|
|
324
|
+
const projectAgentStamp =
|
|
325
|
+
this.projectAgentsDir && agentName
|
|
326
|
+
? getFileStamp(join(this.projectAgentsDir, `${agentName}.md`))
|
|
327
|
+
: "none";
|
|
328
|
+
|
|
329
|
+
return `${getFileStamp(this.globalConfigPath)}|${projectStamp}|${agentStamp}|${projectAgentStamp}`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── Resolved paths ────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
getResolvedPolicyPaths(): ResolvedPolicyPaths {
|
|
335
|
+
return {
|
|
336
|
+
globalConfigPath: this.globalConfigPath,
|
|
337
|
+
globalConfigExists: existsSync(this.globalConfigPath),
|
|
338
|
+
projectConfigPath: this.projectGlobalConfigPath,
|
|
339
|
+
projectConfigExists: this.projectGlobalConfigPath
|
|
340
|
+
? existsSync(this.projectGlobalConfigPath)
|
|
341
|
+
: false,
|
|
342
|
+
agentsDir: this.agentsDir,
|
|
343
|
+
agentsDirExists: existsSync(this.agentsDir),
|
|
344
|
+
projectAgentsDir: this.projectAgentsDir,
|
|
345
|
+
projectAgentsDirExists: this.projectAgentsDir
|
|
346
|
+
? existsSync(this.projectAgentsDir)
|
|
347
|
+
: false,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { evaluateBashExternalDirectoryGate } from "../../../src/handlers/gates/bash-external-directory";
|
|
4
|
+
import type { ToolCallContext } from "../../../src/handlers/gates/types";
|
|
5
|
+
import type { HandlerDeps } from "../../../src/handlers/types";
|
|
6
|
+
import type { PermissionEventBus } from "../../../src/permission-events";
|
|
7
|
+
import type { PermissionCheckResult } from "../../../src/types";
|
|
8
|
+
|
|
9
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
|
|
12
|
+
return {
|
|
13
|
+
toolName: "bash",
|
|
14
|
+
agentName: null,
|
|
15
|
+
input: { command: "cat /outside/project/file.ts" },
|
|
16
|
+
toolCallId: "tc-1",
|
|
17
|
+
cwd: "/test/project",
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeCheckResult(
|
|
23
|
+
state: "allow" | "deny" | "ask",
|
|
24
|
+
overrides: Partial<PermissionCheckResult> = {},
|
|
25
|
+
): PermissionCheckResult {
|
|
26
|
+
return {
|
|
27
|
+
state,
|
|
28
|
+
toolName: "external_directory",
|
|
29
|
+
source: "special",
|
|
30
|
+
origin: "builtin",
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeEvents(): PermissionEventBus {
|
|
36
|
+
return { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeRuntime(
|
|
40
|
+
overrides: Record<string, unknown> = {},
|
|
41
|
+
): HandlerDeps["runtime"] {
|
|
42
|
+
return {
|
|
43
|
+
config: { debugLog: false, permissionReviewLog: true, yoloMode: false },
|
|
44
|
+
runtimeContext: {} as HandlerDeps["runtime"]["runtimeContext"],
|
|
45
|
+
permissionManager: {
|
|
46
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
47
|
+
},
|
|
48
|
+
sessionRules: {
|
|
49
|
+
approve: vi.fn(),
|
|
50
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
51
|
+
clear: vi.fn(),
|
|
52
|
+
},
|
|
53
|
+
writeReviewLog: vi.fn(),
|
|
54
|
+
...overrides,
|
|
55
|
+
} as unknown as HandlerDeps["runtime"];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeDeps(overrides: Record<string, unknown> = {}): HandlerDeps {
|
|
59
|
+
const { runtime: runtimeOverrides, events, ...rest } = overrides;
|
|
60
|
+
return {
|
|
61
|
+
runtime: makeRuntime(runtimeOverrides as Record<string, unknown>),
|
|
62
|
+
events: events ?? makeEvents(),
|
|
63
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
64
|
+
promptPermission: vi
|
|
65
|
+
.fn()
|
|
66
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
67
|
+
...rest,
|
|
68
|
+
} as unknown as HandlerDeps;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── tests ──────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
describe("evaluateBashExternalDirectoryGate", () => {
|
|
74
|
+
it("returns null when tool is not bash", async () => {
|
|
75
|
+
const tcc = makeTcc({ toolName: "read" });
|
|
76
|
+
const result = await evaluateBashExternalDirectoryGate(tcc, makeDeps());
|
|
77
|
+
expect(result).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns null when no CWD", async () => {
|
|
81
|
+
const tcc = makeTcc({ cwd: undefined });
|
|
82
|
+
const result = await evaluateBashExternalDirectoryGate(tcc, makeDeps());
|
|
83
|
+
expect(result).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns null when command has no external paths", async () => {
|
|
87
|
+
const tcc = makeTcc({ input: { command: "ls -la" } });
|
|
88
|
+
const result = await evaluateBashExternalDirectoryGate(tcc, makeDeps());
|
|
89
|
+
expect(result).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns null and logs when all external paths are session-covered", async () => {
|
|
93
|
+
const writeReviewLog = vi.fn();
|
|
94
|
+
const deps = makeDeps({
|
|
95
|
+
runtime: {
|
|
96
|
+
permissionManager: {
|
|
97
|
+
checkPermission: vi
|
|
98
|
+
.fn()
|
|
99
|
+
.mockReturnValue(makeCheckResult("allow", { source: "session" })),
|
|
100
|
+
},
|
|
101
|
+
writeReviewLog,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
const tcc = makeTcc();
|
|
105
|
+
const result = await evaluateBashExternalDirectoryGate(tcc, deps);
|
|
106
|
+
expect(result).toBeNull();
|
|
107
|
+
expect(writeReviewLog).toHaveBeenCalledWith(
|
|
108
|
+
"permission_request.session_approved",
|
|
109
|
+
expect.objectContaining({ resolution: "session_approved" }),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("blocks when policy is deny", async () => {
|
|
114
|
+
const deps = makeDeps({
|
|
115
|
+
runtime: {
|
|
116
|
+
permissionManager: {
|
|
117
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("deny")),
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
const tcc = makeTcc();
|
|
122
|
+
const result = await evaluateBashExternalDirectoryGate(tcc, deps);
|
|
123
|
+
expect(result).toMatchObject({ action: "block" });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("allows without recording session rules when user approves once", async () => {
|
|
127
|
+
const sessionRules = {
|
|
128
|
+
approve: vi.fn(),
|
|
129
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
130
|
+
clear: vi.fn(),
|
|
131
|
+
};
|
|
132
|
+
const deps = makeDeps({
|
|
133
|
+
runtime: {
|
|
134
|
+
permissionManager: {
|
|
135
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
136
|
+
},
|
|
137
|
+
sessionRules,
|
|
138
|
+
},
|
|
139
|
+
promptPermission: vi
|
|
140
|
+
.fn()
|
|
141
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
142
|
+
});
|
|
143
|
+
const tcc = makeTcc();
|
|
144
|
+
const result = await evaluateBashExternalDirectoryGate(tcc, deps);
|
|
145
|
+
expect(result).toEqual({ action: "allow" });
|
|
146
|
+
expect(sessionRules.approve).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("records one session rule per uncovered path on approved_for_session", async () => {
|
|
150
|
+
const sessionRules = {
|
|
151
|
+
approve: vi.fn(),
|
|
152
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
153
|
+
clear: vi.fn(),
|
|
154
|
+
};
|
|
155
|
+
const deps = makeDeps({
|
|
156
|
+
runtime: {
|
|
157
|
+
permissionManager: {
|
|
158
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
159
|
+
},
|
|
160
|
+
sessionRules,
|
|
161
|
+
},
|
|
162
|
+
promptPermission: vi
|
|
163
|
+
.fn()
|
|
164
|
+
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
165
|
+
});
|
|
166
|
+
// Command referencing two external paths
|
|
167
|
+
const tcc = makeTcc({
|
|
168
|
+
input: {
|
|
169
|
+
command: "diff /outside/a.ts /outside/b.ts",
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
const result = await evaluateBashExternalDirectoryGate(tcc, deps);
|
|
173
|
+
expect(result).toEqual({ action: "allow" });
|
|
174
|
+
// Each uncovered path gets its own session rule
|
|
175
|
+
expect(sessionRules.approve).toHaveBeenCalledTimes(2);
|
|
176
|
+
for (const call of (sessionRules.approve as ReturnType<typeof vi.fn>).mock
|
|
177
|
+
.calls) {
|
|
178
|
+
expect(call[0]).toBe("external_directory");
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("blocks when user denies", async () => {
|
|
183
|
+
const deps = makeDeps({
|
|
184
|
+
runtime: {
|
|
185
|
+
permissionManager: {
|
|
186
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
promptPermission: vi
|
|
190
|
+
.fn()
|
|
191
|
+
.mockResolvedValue({ approved: false, state: "denied" }),
|
|
192
|
+
});
|
|
193
|
+
const tcc = makeTcc();
|
|
194
|
+
const result = await evaluateBashExternalDirectoryGate(tcc, deps);
|
|
195
|
+
expect(result).toMatchObject({ action: "block" });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("blocks when no UI available", async () => {
|
|
199
|
+
const deps = makeDeps({
|
|
200
|
+
runtime: {
|
|
201
|
+
permissionManager: {
|
|
202
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
|
|
206
|
+
});
|
|
207
|
+
const tcc = makeTcc();
|
|
208
|
+
const result = await evaluateBashExternalDirectoryGate(tcc, deps);
|
|
209
|
+
expect(result).toMatchObject({ action: "block" });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("only prompts about uncovered paths when some are session-covered", async () => {
|
|
213
|
+
// First call (for getRuleset path filter): session covers /outside/a.ts
|
|
214
|
+
// Second call (for config-level policy): returns ask
|
|
215
|
+
const checkPermission = vi
|
|
216
|
+
.fn()
|
|
217
|
+
.mockImplementation(
|
|
218
|
+
(
|
|
219
|
+
surface: string,
|
|
220
|
+
input: Record<string, unknown>,
|
|
221
|
+
): PermissionCheckResult => {
|
|
222
|
+
if (
|
|
223
|
+
surface === "external_directory" &&
|
|
224
|
+
input.path === "/outside/a.ts"
|
|
225
|
+
) {
|
|
226
|
+
return makeCheckResult("allow", { source: "session" });
|
|
227
|
+
}
|
|
228
|
+
return makeCheckResult("ask");
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
const deps = makeDeps({
|
|
232
|
+
runtime: {
|
|
233
|
+
permissionManager: { checkPermission },
|
|
234
|
+
},
|
|
235
|
+
promptPermission: vi
|
|
236
|
+
.fn()
|
|
237
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
238
|
+
});
|
|
239
|
+
const tcc = makeTcc({
|
|
240
|
+
input: { command: "diff /outside/a.ts /outside/b.ts" },
|
|
241
|
+
});
|
|
242
|
+
const result = await evaluateBashExternalDirectoryGate(tcc, deps);
|
|
243
|
+
expect(result).toEqual({ action: "allow" });
|
|
244
|
+
// The prompt should have been called (for uncovered /outside/b.ts)
|
|
245
|
+
expect(deps.promptPermission).toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
});
|