@gotgenes/pi-permission-system 10.5.1 → 10.5.3
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 +21 -0
- package/package.json +1 -1
- package/src/common.ts +7 -0
- package/src/config-loader.ts +31 -2
- package/src/extension-config.ts +1 -8
- package/src/input-normalizer.ts +20 -8
- package/src/path-utils.ts +1 -10
- package/test/before-agent-start-cache.test.ts +89 -0
- package/test/common.test.ts +32 -1
- package/test/config-loader.test.ts +108 -0
- package/test/config-store.test.ts +14 -0
- package/test/extension-config.test.ts +0 -31
- package/test/handlers/external-directory-session-dedup.test.ts +96 -0
- package/test/handlers/gates/bash-path.test.ts +57 -0
- package/test/handlers/gates/path.test.ts +58 -0
- package/test/handlers/tool-call.test.ts +103 -0
- package/test/helpers/manager-harness.ts +61 -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/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/test/permission-system.test.ts +0 -2785
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [10.5.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.5.2...pi-permission-system-v10.5.3) (2026-06-08)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* merge tool preview length fields across config layers ([803fbb4](https://github.com/gotgenes/pi-packages/commit/803fbb4a118d4c26dc7b23fcec3f88d23aec0065))
|
|
14
|
+
* parse tool preview length fields in unified config loader ([3241956](https://github.com/gotgenes/pi-packages/commit/3241956b5656bc44788061c4a0a4ee334cb3ace5))
|
|
15
|
+
|
|
16
|
+
## [10.5.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.5.1...pi-permission-system-v10.5.2) (2026-06-08)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Bug Fixes
|
|
20
|
+
|
|
21
|
+
* **pi-permission-system:** expand $HOME in normalizePathForComparison ([#350](https://github.com/gotgenes/pi-packages/issues/350)) ([1b92ed3](https://github.com/gotgenes/pi-packages/commit/1b92ed3d2364174d3287171c58ce8452239b3e8d))
|
|
22
|
+
* **pi-permission-system:** home-expand path values before matching ([#350](https://github.com/gotgenes/pi-packages/issues/350)) ([48a7b37](https://github.com/gotgenes/pi-packages/commit/48a7b3783857b449442d30edefe04f8255e5f4f8))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Documentation
|
|
26
|
+
|
|
27
|
+
* **pi-permission-system:** note path values are home-expanded for matching ([#350](https://github.com/gotgenes/pi-packages/issues/350)) ([e9c264d](https://github.com/gotgenes/pi-packages/commit/e9c264de85d327a0bfbcd84401a259cb509a5dfa))
|
|
28
|
+
|
|
8
29
|
## [10.5.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.5.0...pi-permission-system-v10.5.1) (2026-06-07)
|
|
9
30
|
|
|
10
31
|
|
package/package.json
CHANGED
package/src/common.ts
CHANGED
|
@@ -17,6 +17,13 @@ export function getNonEmptyString(value: unknown): string | null {
|
|
|
17
17
|
return trimmed.length > 0 ? trimmed : null;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** Returns `raw` if it is a positive integer; otherwise `undefined`. */
|
|
21
|
+
export function normalizeOptionalPositiveInt(raw: unknown): number | undefined {
|
|
22
|
+
return typeof raw === "number" && Number.isInteger(raw) && raw > 0
|
|
23
|
+
? raw
|
|
24
|
+
: undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
20
27
|
export function isPermissionState(value: unknown): value is PermissionState {
|
|
21
28
|
return value === "allow" || value === "deny" || value === "ask";
|
|
22
29
|
}
|
package/src/config-loader.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { normalize } from "node:path";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
isPermissionState,
|
|
6
|
+
normalizeOptionalPositiveInt,
|
|
7
|
+
toRecord,
|
|
8
|
+
} from "./common";
|
|
5
9
|
import {
|
|
6
10
|
getGlobalConfigPath,
|
|
7
11
|
getLegacyExtensionConfigPath,
|
|
@@ -21,6 +25,8 @@ export interface UnifiedPermissionConfig {
|
|
|
21
25
|
debugLog?: boolean;
|
|
22
26
|
permissionReviewLog?: boolean;
|
|
23
27
|
yoloMode?: boolean;
|
|
28
|
+
toolInputPreviewMaxLength?: number;
|
|
29
|
+
toolTextSummaryMaxLength?: number;
|
|
24
30
|
|
|
25
31
|
// Flat permission policy
|
|
26
32
|
permission?: FlatPermissionConfig;
|
|
@@ -183,6 +189,18 @@ export function normalizeUnifiedConfig(raw: unknown): {
|
|
|
183
189
|
const yoloMode = normalizeOptionalBoolean(record.yoloMode);
|
|
184
190
|
if (yoloMode !== undefined) config.yoloMode = yoloMode;
|
|
185
191
|
|
|
192
|
+
const toolInputPreviewMaxLength = normalizeOptionalPositiveInt(
|
|
193
|
+
record.toolInputPreviewMaxLength,
|
|
194
|
+
);
|
|
195
|
+
if (toolInputPreviewMaxLength !== undefined)
|
|
196
|
+
config.toolInputPreviewMaxLength = toolInputPreviewMaxLength;
|
|
197
|
+
|
|
198
|
+
const toolTextSummaryMaxLength = normalizeOptionalPositiveInt(
|
|
199
|
+
record.toolTextSummaryMaxLength,
|
|
200
|
+
);
|
|
201
|
+
if (toolTextSummaryMaxLength !== undefined)
|
|
202
|
+
config.toolTextSummaryMaxLength = toolTextSummaryMaxLength;
|
|
203
|
+
|
|
186
204
|
// Flat permission policy
|
|
187
205
|
const permission = normalizeFlatPermissionValue(record.permission);
|
|
188
206
|
if (permission !== undefined) config.permission = permission;
|
|
@@ -202,7 +220,7 @@ export function mergeUnifiedConfigs(
|
|
|
202
220
|
): UnifiedPermissionConfig {
|
|
203
221
|
const merged: UnifiedPermissionConfig = {};
|
|
204
222
|
|
|
205
|
-
//
|
|
223
|
+
// Boolean scalars: override replaces base when defined
|
|
206
224
|
for (const key of ["debugLog", "permissionReviewLog", "yoloMode"] as const) {
|
|
207
225
|
const value = override[key] ?? base[key];
|
|
208
226
|
if (value !== undefined) {
|
|
@@ -210,6 +228,17 @@ export function mergeUnifiedConfigs(
|
|
|
210
228
|
}
|
|
211
229
|
}
|
|
212
230
|
|
|
231
|
+
// Number scalars: override replaces base when defined
|
|
232
|
+
for (const key of [
|
|
233
|
+
"toolInputPreviewMaxLength",
|
|
234
|
+
"toolTextSummaryMaxLength",
|
|
235
|
+
] as const) {
|
|
236
|
+
const value = override[key] ?? base[key];
|
|
237
|
+
if (value !== undefined) {
|
|
238
|
+
merged[key] = value;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
213
242
|
// Permission: deep-shallow merge
|
|
214
243
|
const basePerm = base.permission;
|
|
215
244
|
const overridePerm = override.permission;
|
package/src/extension-config.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdirSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
|
|
5
|
-
import { toRecord } from "./common";
|
|
5
|
+
import { normalizeOptionalPositiveInt, toRecord } from "./common";
|
|
6
6
|
|
|
7
7
|
export const EXTENSION_ID = "pi-permission-system";
|
|
8
8
|
|
|
@@ -46,13 +46,6 @@ export function detectMisplacedPermissionKeys(
|
|
|
46
46
|
return Object.keys(raw).filter((key) => PERMISSION_POLICY_KEYS.has(key));
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/** Returns `raw` if it is a positive integer; otherwise `undefined`. */
|
|
50
|
-
export function normalizeOptionalPositiveInt(raw: unknown): number | undefined {
|
|
51
|
-
return typeof raw === "number" && Number.isInteger(raw) && raw > 0
|
|
52
|
-
? raw
|
|
53
|
-
: undefined;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
49
|
export function normalizePermissionSystemConfig(
|
|
57
50
|
raw: unknown,
|
|
58
51
|
): PermissionSystemExtensionConfig {
|
package/src/input-normalizer.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { toRecord } from "./common";
|
|
1
|
+
import { getNonEmptyString, toRecord } from "./common";
|
|
2
|
+
import { expandHomePath } from "./expand-home";
|
|
2
3
|
import { createMcpPermissionTargets } from "./mcp-targets";
|
|
3
|
-
import {
|
|
4
|
+
import { PATH_BEARING_TOOLS } from "./path-utils";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Construct a surface-appropriate input object from a raw value string.
|
|
@@ -66,13 +67,11 @@ export function normalizeInput(
|
|
|
66
67
|
input: unknown,
|
|
67
68
|
configuredMcpServerNames: readonly string[],
|
|
68
69
|
): NormalizedInput {
|
|
69
|
-
// --- Special surfaces (external_directory) ---
|
|
70
|
+
// --- Special surfaces (path, external_directory) ---
|
|
70
71
|
if (SPECIAL_PERMISSION_KEYS.has(toolName)) {
|
|
71
|
-
const record = toRecord(input);
|
|
72
|
-
const pathValue = typeof record.path === "string" ? record.path : null;
|
|
73
72
|
return {
|
|
74
73
|
surface: toolName,
|
|
75
|
-
values: [
|
|
74
|
+
values: [normalizePathSurfaceValue(input)],
|
|
76
75
|
resultExtras: {},
|
|
77
76
|
};
|
|
78
77
|
}
|
|
@@ -116,10 +115,9 @@ export function normalizeInput(
|
|
|
116
115
|
|
|
117
116
|
// --- Path-bearing tools (read, write, edit, grep, find, ls) ---
|
|
118
117
|
if (PATH_BEARING_TOOLS.has(toolName)) {
|
|
119
|
-
const path = getPathBearingToolPath(toolName, input);
|
|
120
118
|
return {
|
|
121
119
|
surface: toolName,
|
|
122
|
-
values: [
|
|
120
|
+
values: [normalizePathSurfaceValue(input)],
|
|
123
121
|
resultExtras: {},
|
|
124
122
|
};
|
|
125
123
|
}
|
|
@@ -131,3 +129,17 @@ export function normalizeInput(
|
|
|
131
129
|
resultExtras: {},
|
|
132
130
|
};
|
|
133
131
|
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Extract and home-expand the `input.path` lookup value shared by every path
|
|
135
|
+
* surface (`path`, `external_directory`, and the path-bearing tools).
|
|
136
|
+
*
|
|
137
|
+
* Missing, empty, or whitespace-only paths collapse to the surface catch-all
|
|
138
|
+
* `"*"`; otherwise `~/…` and `$HOME/…` prefixes are expanded to the OS home
|
|
139
|
+
* directory so values match home-anchored patterns symmetrically with how
|
|
140
|
+
* `compileWildcardPattern` expands the patterns themselves (#350).
|
|
141
|
+
*/
|
|
142
|
+
function normalizePathSurfaceValue(input: unknown): string {
|
|
143
|
+
const path = getNonEmptyString(toRecord(input).path);
|
|
144
|
+
return path === null ? "*" : expandHomePath(path);
|
|
145
|
+
}
|
package/src/path-utils.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { homedir } from "node:os";
|
|
2
1
|
import { join, normalize, resolve, sep } from "node:path";
|
|
3
2
|
|
|
4
3
|
import { getNonEmptyString, toRecord } from "./common";
|
|
@@ -15,15 +14,7 @@ export function normalizePathForComparison(
|
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
18
|
-
|
|
19
|
-
if (normalizedPath === "~") {
|
|
20
|
-
normalizedPath = homedir();
|
|
21
|
-
} else if (
|
|
22
|
-
normalizedPath.startsWith("~/") ||
|
|
23
|
-
normalizedPath.startsWith("~\\")
|
|
24
|
-
) {
|
|
25
|
-
normalizedPath = join(homedir(), normalizedPath.slice(2));
|
|
26
|
-
}
|
|
17
|
+
normalizedPath = expandHomePath(normalizedPath);
|
|
27
18
|
|
|
28
19
|
const absolutePath = resolve(cwd, normalizedPath);
|
|
29
20
|
const normalizedAbsolutePath = normalize(absolutePath);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { expect, test } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
createActiveToolsCacheKey,
|
|
5
|
+
createBeforeAgentStartPromptStateKey,
|
|
6
|
+
shouldApplyCachedAgentStartState,
|
|
7
|
+
} from "#src/before-agent-start-cache";
|
|
8
|
+
import { createManager } from "#test/helpers/manager-harness";
|
|
9
|
+
|
|
10
|
+
test("Before-agent-start cache dedupes unchanged active-tool exposure and prompt state", () => {
|
|
11
|
+
const allowedTools = ["read", "mcp"];
|
|
12
|
+
const activeToolsKey = createActiveToolsCacheKey(allowedTools);
|
|
13
|
+
const promptStateKey = createBeforeAgentStartPromptStateKey({
|
|
14
|
+
agentName: "code",
|
|
15
|
+
cwd: "C:/workspace/project",
|
|
16
|
+
permissionStamp: "permissions-v1",
|
|
17
|
+
systemPrompt: "Available tools:\n- read\n- mcp",
|
|
18
|
+
allowedToolNames: allowedTools,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(shouldApplyCachedAgentStartState(null, activeToolsKey)).toBe(true);
|
|
22
|
+
expect(shouldApplyCachedAgentStartState(activeToolsKey, activeToolsKey)).toBe(
|
|
23
|
+
false,
|
|
24
|
+
);
|
|
25
|
+
expect(shouldApplyCachedAgentStartState(null, promptStateKey)).toBe(true);
|
|
26
|
+
expect(shouldApplyCachedAgentStartState(promptStateKey, promptStateKey)).toBe(
|
|
27
|
+
false,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("Before-agent-start prompt cache invalidates on permission changes while runtime enforcement stays authoritative", () => {
|
|
32
|
+
const { manager, globalConfigPath, cleanup } = createManager({
|
|
33
|
+
permission: { "*": "allow", write: "deny" },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const baselineStamp = manager.getPolicyCacheStamp();
|
|
38
|
+
const baselineKey = createBeforeAgentStartPromptStateKey({
|
|
39
|
+
agentName: null,
|
|
40
|
+
cwd: "C:/workspace/project",
|
|
41
|
+
permissionStamp: baselineStamp,
|
|
42
|
+
systemPrompt: "Available tools:\n- read\n- write",
|
|
43
|
+
allowedToolNames: ["read"],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(shouldApplyCachedAgentStartState(baselineKey, baselineKey)).toBe(
|
|
47
|
+
false,
|
|
48
|
+
);
|
|
49
|
+
expect(manager.checkPermission("write", {}, undefined).state).toBe("deny");
|
|
50
|
+
|
|
51
|
+
const updatedConfig = `${JSON.stringify(
|
|
52
|
+
{ permission: { "*": "allow", write: "allow" } },
|
|
53
|
+
null,
|
|
54
|
+
2,
|
|
55
|
+
)}\n`;
|
|
56
|
+
|
|
57
|
+
let updatedStamp = baselineStamp;
|
|
58
|
+
for (
|
|
59
|
+
let attempt = 0;
|
|
60
|
+
attempt < 10 && updatedStamp === baselineStamp;
|
|
61
|
+
attempt += 1
|
|
62
|
+
) {
|
|
63
|
+
const waitUntil = Date.now() + 2;
|
|
64
|
+
while (Date.now() < waitUntil) {
|
|
65
|
+
// Wait for the filesystem timestamp granularity to advance.
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
writeFileSync(globalConfigPath, updatedConfig, "utf8");
|
|
69
|
+
updatedStamp = manager.getPolicyCacheStamp();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
expect(updatedStamp).not.toBe(baselineStamp);
|
|
73
|
+
|
|
74
|
+
const invalidatedKey = createBeforeAgentStartPromptStateKey({
|
|
75
|
+
agentName: null,
|
|
76
|
+
cwd: "C:/workspace/project",
|
|
77
|
+
permissionStamp: updatedStamp,
|
|
78
|
+
systemPrompt: "Available tools:\n- read\n- write",
|
|
79
|
+
allowedToolNames: ["read", "write"],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(shouldApplyCachedAgentStartState(baselineKey, invalidatedKey)).toBe(
|
|
83
|
+
true,
|
|
84
|
+
);
|
|
85
|
+
expect(manager.checkPermission("write", {}, undefined).state).toBe("allow");
|
|
86
|
+
} finally {
|
|
87
|
+
cleanup();
|
|
88
|
+
}
|
|
89
|
+
});
|
package/test/common.test.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, test, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
extractFrontmatter,
|
|
5
5
|
getNonEmptyString,
|
|
6
6
|
isPermissionState,
|
|
7
|
+
normalizeOptionalPositiveInt,
|
|
7
8
|
parseSimpleYamlMap,
|
|
8
9
|
toRecord,
|
|
9
10
|
} from "#src/common";
|
|
@@ -187,3 +188,33 @@ describe("parseSimpleYamlMap", () => {
|
|
|
187
188
|
expect(result["quoted-key"]).toBe("value");
|
|
188
189
|
});
|
|
189
190
|
});
|
|
191
|
+
|
|
192
|
+
describe("normalizeOptionalPositiveInt", () => {
|
|
193
|
+
it("returns the value for a valid positive integer", () => {
|
|
194
|
+
expect(normalizeOptionalPositiveInt(1)).toBe(1);
|
|
195
|
+
expect(normalizeOptionalPositiveInt(200)).toBe(200);
|
|
196
|
+
expect(normalizeOptionalPositiveInt(9999)).toBe(9999);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("returns undefined for zero", () => {
|
|
200
|
+
expect(normalizeOptionalPositiveInt(0)).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("returns undefined for negative integers", () => {
|
|
204
|
+
expect(normalizeOptionalPositiveInt(-1)).toBeUndefined();
|
|
205
|
+
expect(normalizeOptionalPositiveInt(-100)).toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("returns undefined for non-integer numbers (floats)", () => {
|
|
209
|
+
expect(normalizeOptionalPositiveInt(400.5)).toBeUndefined();
|
|
210
|
+
expect(normalizeOptionalPositiveInt(1.1)).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("returns undefined for non-number types", () => {
|
|
214
|
+
expect(normalizeOptionalPositiveInt("200")).toBeUndefined();
|
|
215
|
+
expect(normalizeOptionalPositiveInt(true)).toBeUndefined();
|
|
216
|
+
expect(normalizeOptionalPositiveInt(null)).toBeUndefined();
|
|
217
|
+
expect(normalizeOptionalPositiveInt(undefined)).toBeUndefined();
|
|
218
|
+
expect(normalizeOptionalPositiveInt({})).toBeUndefined();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -258,6 +258,72 @@ describe("loadUnifiedConfig", () => {
|
|
|
258
258
|
const result = loadUnifiedConfig(configPath);
|
|
259
259
|
expect(result.config.permission).toBeUndefined();
|
|
260
260
|
});
|
|
261
|
+
|
|
262
|
+
it("parses toolInputPreviewMaxLength when a valid positive integer is present", () => {
|
|
263
|
+
const configPath = join(tempDir, "config.json");
|
|
264
|
+
writeFileSync(
|
|
265
|
+
configPath,
|
|
266
|
+
JSON.stringify({ toolInputPreviewMaxLength: 1000 }),
|
|
267
|
+
);
|
|
268
|
+
const result = loadUnifiedConfig(configPath);
|
|
269
|
+
expect(result.config.toolInputPreviewMaxLength).toBe(1000);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("parses toolTextSummaryMaxLength when a valid positive integer is present", () => {
|
|
273
|
+
const configPath = join(tempDir, "config.json");
|
|
274
|
+
writeFileSync(
|
|
275
|
+
configPath,
|
|
276
|
+
JSON.stringify({ toolTextSummaryMaxLength: 120 }),
|
|
277
|
+
);
|
|
278
|
+
const result = loadUnifiedConfig(configPath);
|
|
279
|
+
expect(result.config.toolTextSummaryMaxLength).toBe(120);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("omits toolInputPreviewMaxLength when absent", () => {
|
|
283
|
+
const configPath = join(tempDir, "config.json");
|
|
284
|
+
writeFileSync(configPath, JSON.stringify({ debugLog: false }));
|
|
285
|
+
const result = loadUnifiedConfig(configPath);
|
|
286
|
+
expect(result.config).not.toHaveProperty("toolInputPreviewMaxLength");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("omits toolTextSummaryMaxLength when absent", () => {
|
|
290
|
+
const configPath = join(tempDir, "config.json");
|
|
291
|
+
writeFileSync(configPath, JSON.stringify({ debugLog: false }));
|
|
292
|
+
const result = loadUnifiedConfig(configPath);
|
|
293
|
+
expect(result.config).not.toHaveProperty("toolTextSummaryMaxLength");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it.each([
|
|
297
|
+
["zero", 0],
|
|
298
|
+
["negative", -1],
|
|
299
|
+
["float", 1.5],
|
|
300
|
+
["string", "200"],
|
|
301
|
+
["boolean", true],
|
|
302
|
+
] as const)("omits toolInputPreviewMaxLength for invalid value: %s", (_label, value) => {
|
|
303
|
+
const configPath = join(tempDir, "config.json");
|
|
304
|
+
writeFileSync(
|
|
305
|
+
configPath,
|
|
306
|
+
JSON.stringify({ toolInputPreviewMaxLength: value }),
|
|
307
|
+
);
|
|
308
|
+
const result = loadUnifiedConfig(configPath);
|
|
309
|
+
expect(result.config).not.toHaveProperty("toolInputPreviewMaxLength");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it.each([
|
|
313
|
+
["zero", 0],
|
|
314
|
+
["negative", -1],
|
|
315
|
+
["float", 1.5],
|
|
316
|
+
["string", "80"],
|
|
317
|
+
["boolean", false],
|
|
318
|
+
] as const)("omits toolTextSummaryMaxLength for invalid value: %s", (_label, value) => {
|
|
319
|
+
const configPath = join(tempDir, "config.json");
|
|
320
|
+
writeFileSync(
|
|
321
|
+
configPath,
|
|
322
|
+
JSON.stringify({ toolTextSummaryMaxLength: value }),
|
|
323
|
+
);
|
|
324
|
+
const result = loadUnifiedConfig(configPath);
|
|
325
|
+
expect(result.config).not.toHaveProperty("toolTextSummaryMaxLength");
|
|
326
|
+
});
|
|
261
327
|
});
|
|
262
328
|
|
|
263
329
|
describe("mergeUnifiedConfigs", () => {
|
|
@@ -352,6 +418,48 @@ describe("mergeUnifiedConfigs", () => {
|
|
|
352
418
|
expect(merged).not.toHaveProperty("permissionReviewLog");
|
|
353
419
|
expect(merged).not.toHaveProperty("permission");
|
|
354
420
|
});
|
|
421
|
+
|
|
422
|
+
it("override toolInputPreviewMaxLength replaces base value", () => {
|
|
423
|
+
const merged = mergeUnifiedConfigs(
|
|
424
|
+
{ toolInputPreviewMaxLength: 200 },
|
|
425
|
+
{ toolInputPreviewMaxLength: 1000 },
|
|
426
|
+
);
|
|
427
|
+
expect(merged.toolInputPreviewMaxLength).toBe(1000);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("base toolInputPreviewMaxLength survives when override omits it", () => {
|
|
431
|
+
const merged = mergeUnifiedConfigs(
|
|
432
|
+
{ toolInputPreviewMaxLength: 500 },
|
|
433
|
+
{ debugLog: true },
|
|
434
|
+
);
|
|
435
|
+
expect(merged.toolInputPreviewMaxLength).toBe(500);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("toolInputPreviewMaxLength is absent when both base and override omit it", () => {
|
|
439
|
+
const merged = mergeUnifiedConfigs({ debugLog: true }, { yoloMode: false });
|
|
440
|
+
expect(merged).not.toHaveProperty("toolInputPreviewMaxLength");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("override toolTextSummaryMaxLength replaces base value", () => {
|
|
444
|
+
const merged = mergeUnifiedConfigs(
|
|
445
|
+
{ toolTextSummaryMaxLength: 80 },
|
|
446
|
+
{ toolTextSummaryMaxLength: 200 },
|
|
447
|
+
);
|
|
448
|
+
expect(merged.toolTextSummaryMaxLength).toBe(200);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("base toolTextSummaryMaxLength survives when override omits it", () => {
|
|
452
|
+
const merged = mergeUnifiedConfigs(
|
|
453
|
+
{ toolTextSummaryMaxLength: 120 },
|
|
454
|
+
{ debugLog: false },
|
|
455
|
+
);
|
|
456
|
+
expect(merged.toolTextSummaryMaxLength).toBe(120);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("toolTextSummaryMaxLength is absent when both base and override omit it", () => {
|
|
460
|
+
const merged = mergeUnifiedConfigs({}, { permissionReviewLog: true });
|
|
461
|
+
expect(merged).not.toHaveProperty("toolTextSummaryMaxLength");
|
|
462
|
+
});
|
|
355
463
|
});
|
|
356
464
|
|
|
357
465
|
describe("loadAndMergeConfigs", () => {
|
|
@@ -371,6 +371,20 @@ describe("ConfigStore", () => {
|
|
|
371
371
|
store.save({ ...DEFAULT_EXTENSION_CONFIG }, ctx);
|
|
372
372
|
expect(mockUnlinkSync).toHaveBeenCalled();
|
|
373
373
|
});
|
|
374
|
+
|
|
375
|
+
it("preserves an existing global toolInputPreviewMaxLength on save", () => {
|
|
376
|
+
const { store } = makeStore();
|
|
377
|
+
// Simulate a global config.json that already has the preview-length field.
|
|
378
|
+
mockLoadUnifiedConfig.mockReturnValue({
|
|
379
|
+
config: { toolInputPreviewMaxLength: 800 },
|
|
380
|
+
});
|
|
381
|
+
store.save({ ...DEFAULT_EXTENSION_CONFIG }, makeCommandCtx());
|
|
382
|
+
expect(mockWriteFileSync).toHaveBeenCalledWith(
|
|
383
|
+
expect.stringContaining(".tmp"),
|
|
384
|
+
expect.stringContaining('"toolInputPreviewMaxLength": 800'),
|
|
385
|
+
"utf-8",
|
|
386
|
+
);
|
|
387
|
+
});
|
|
374
388
|
});
|
|
375
389
|
|
|
376
390
|
// ── logResolvedPaths() ─────────────────────────────────────────────────
|
|
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
detectMisplacedPermissionKeys,
|
|
5
|
-
normalizeOptionalPositiveInt,
|
|
6
5
|
normalizePermissionSystemConfig,
|
|
7
6
|
} from "#src/extension-config";
|
|
8
7
|
|
|
@@ -75,36 +74,6 @@ describe("detectMisplacedPermissionKeys", () => {
|
|
|
75
74
|
});
|
|
76
75
|
});
|
|
77
76
|
|
|
78
|
-
describe("normalizeOptionalPositiveInt", () => {
|
|
79
|
-
it("returns the value for a valid positive integer", () => {
|
|
80
|
-
expect(normalizeOptionalPositiveInt(1)).toBe(1);
|
|
81
|
-
expect(normalizeOptionalPositiveInt(200)).toBe(200);
|
|
82
|
-
expect(normalizeOptionalPositiveInt(9999)).toBe(9999);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("returns undefined for zero", () => {
|
|
86
|
-
expect(normalizeOptionalPositiveInt(0)).toBeUndefined();
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("returns undefined for negative integers", () => {
|
|
90
|
-
expect(normalizeOptionalPositiveInt(-1)).toBeUndefined();
|
|
91
|
-
expect(normalizeOptionalPositiveInt(-100)).toBeUndefined();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("returns undefined for non-integer numbers (floats)", () => {
|
|
95
|
-
expect(normalizeOptionalPositiveInt(400.5)).toBeUndefined();
|
|
96
|
-
expect(normalizeOptionalPositiveInt(1.1)).toBeUndefined();
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("returns undefined for non-number types", () => {
|
|
100
|
-
expect(normalizeOptionalPositiveInt("200")).toBeUndefined();
|
|
101
|
-
expect(normalizeOptionalPositiveInt(true)).toBeUndefined();
|
|
102
|
-
expect(normalizeOptionalPositiveInt(null)).toBeUndefined();
|
|
103
|
-
expect(normalizeOptionalPositiveInt(undefined)).toBeUndefined();
|
|
104
|
-
expect(normalizeOptionalPositiveInt({})).toBeUndefined();
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
77
|
describe("normalizePermissionSystemConfig", () => {
|
|
109
78
|
it("normalizes a valid config object", () => {
|
|
110
79
|
const result = normalizePermissionSystemConfig({
|
|
@@ -300,3 +300,99 @@ describe("external-directory session dedup", () => {
|
|
|
300
300
|
});
|
|
301
301
|
});
|
|
302
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
|
+
});
|