@gotgenes/pi-permission-system 10.6.0 → 10.7.1
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 +19 -0
- package/package.json +1 -1
- package/src/common.ts +10 -0
- package/src/config-loader.ts +17 -0
- package/src/extension-config.ts +8 -7
- package/src/permission-prompts.ts +8 -1
- package/test/common.test.ts +39 -0
- package/test/config-loader.test.ts +71 -0
- package/test/config-store.test.ts +26 -0
- package/test/permission-prompts.test.ts +67 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ 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.7.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.7.0...pi-permission-system-v10.7.1) (2026-06-09)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* surface full chained command in bash permission prompt ([#333](https://github.com/gotgenes/pi-packages/issues/333)) ([7f448fb](https://github.com/gotgenes/pi-packages/commit/7f448fb6e394bc37f94c98e04332abdcc8528c46))
|
|
14
|
+
|
|
15
|
+
## [10.7.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.6.0...pi-permission-system-v10.7.0) (2026-06-09)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* add normalizeOptionalStringArray to common ([8be9154](https://github.com/gotgenes/pi-packages/commit/8be9154d7a492f13526f7bd8d4e33fc2e209f98d))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Bug Fixes
|
|
24
|
+
|
|
25
|
+
* carry piInfrastructureReadPaths through the unified config loader ([#347](https://github.com/gotgenes/pi-packages/issues/347)) ([51bc145](https://github.com/gotgenes/pi-packages/commit/51bc145c15cc54bc69333d1e6cc48c74dda267d1))
|
|
26
|
+
|
|
8
27
|
## [10.6.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.5.3...pi-permission-system-v10.6.0) (2026-06-08)
|
|
9
28
|
|
|
10
29
|
|
package/package.json
CHANGED
package/src/common.ts
CHANGED
|
@@ -17,6 +17,16 @@ 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 an array of strings; otherwise `undefined`. */
|
|
21
|
+
export function normalizeOptionalStringArray(
|
|
22
|
+
raw: unknown,
|
|
23
|
+
): string[] | undefined {
|
|
24
|
+
return Array.isArray(raw) &&
|
|
25
|
+
raw.every((p): p is string => typeof p === "string")
|
|
26
|
+
? raw
|
|
27
|
+
: undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
/** Returns `raw` if it is a positive integer; otherwise `undefined`. */
|
|
21
31
|
export function normalizeOptionalPositiveInt(raw: unknown): number | undefined {
|
|
22
32
|
return typeof raw === "number" && Number.isInteger(raw) && raw > 0
|
package/src/config-loader.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { normalize } from "node:path";
|
|
|
4
4
|
import {
|
|
5
5
|
isPermissionState,
|
|
6
6
|
normalizeOptionalPositiveInt,
|
|
7
|
+
normalizeOptionalStringArray,
|
|
7
8
|
toRecord,
|
|
8
9
|
} from "./common";
|
|
9
10
|
import {
|
|
@@ -27,6 +28,7 @@ export interface UnifiedPermissionConfig {
|
|
|
27
28
|
yoloMode?: boolean;
|
|
28
29
|
toolInputPreviewMaxLength?: number;
|
|
29
30
|
toolTextSummaryMaxLength?: number;
|
|
31
|
+
piInfrastructureReadPaths?: string[];
|
|
30
32
|
|
|
31
33
|
// Flat permission policy
|
|
32
34
|
permission?: FlatPermissionConfig;
|
|
@@ -201,6 +203,12 @@ export function normalizeUnifiedConfig(raw: unknown): {
|
|
|
201
203
|
if (toolTextSummaryMaxLength !== undefined)
|
|
202
204
|
config.toolTextSummaryMaxLength = toolTextSummaryMaxLength;
|
|
203
205
|
|
|
206
|
+
const piInfrastructureReadPaths = normalizeOptionalStringArray(
|
|
207
|
+
record.piInfrastructureReadPaths,
|
|
208
|
+
);
|
|
209
|
+
if (piInfrastructureReadPaths !== undefined)
|
|
210
|
+
config.piInfrastructureReadPaths = piInfrastructureReadPaths;
|
|
211
|
+
|
|
204
212
|
// Flat permission policy
|
|
205
213
|
const permission = normalizeFlatPermissionValue(record.permission);
|
|
206
214
|
if (permission !== undefined) config.permission = permission;
|
|
@@ -213,6 +221,8 @@ export function normalizeUnifiedConfig(raw: unknown): {
|
|
|
213
221
|
* - `permission` is deep-shallow merged (surface-level object maps are shallow-merged).
|
|
214
222
|
* - Scalar fields (debugLog, permissionReviewLog, yoloMode) are replaced when
|
|
215
223
|
* present in the override.
|
|
224
|
+
* - Array fields (piInfrastructureReadPaths) replace the base when present in
|
|
225
|
+
* the override (override-wins, same as scalars).
|
|
216
226
|
*/
|
|
217
227
|
export function mergeUnifiedConfigs(
|
|
218
228
|
base: UnifiedPermissionConfig,
|
|
@@ -239,6 +249,13 @@ export function mergeUnifiedConfigs(
|
|
|
239
249
|
}
|
|
240
250
|
}
|
|
241
251
|
|
|
252
|
+
// Array fields: override replaces base when defined
|
|
253
|
+
const piInfrastructureReadPaths =
|
|
254
|
+
override.piInfrastructureReadPaths ?? base.piInfrastructureReadPaths;
|
|
255
|
+
if (piInfrastructureReadPaths !== undefined) {
|
|
256
|
+
merged.piInfrastructureReadPaths = piInfrastructureReadPaths;
|
|
257
|
+
}
|
|
258
|
+
|
|
242
259
|
// Permission: deep-shallow merge
|
|
243
260
|
const basePerm = base.permission;
|
|
244
261
|
const overridePerm = override.permission;
|
package/src/extension-config.ts
CHANGED
|
@@ -2,7 +2,11 @@ import { mkdirSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
normalizeOptionalPositiveInt,
|
|
7
|
+
normalizeOptionalStringArray,
|
|
8
|
+
toRecord,
|
|
9
|
+
} from "./common";
|
|
6
10
|
|
|
7
11
|
export const EXTENSION_ID = "pi-permission-system";
|
|
8
12
|
|
|
@@ -50,12 +54,9 @@ export function normalizePermissionSystemConfig(
|
|
|
50
54
|
raw: unknown,
|
|
51
55
|
): PermissionSystemExtensionConfig {
|
|
52
56
|
const record = toRecord(raw);
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
rawPaths.every((p): p is string => typeof p === "string")
|
|
57
|
-
? rawPaths
|
|
58
|
-
: undefined;
|
|
57
|
+
const piInfrastructureReadPaths = normalizeOptionalStringArray(
|
|
58
|
+
record.piInfrastructureReadPaths,
|
|
59
|
+
);
|
|
59
60
|
const result: PermissionSystemExtensionConfig = {
|
|
60
61
|
debugLog: record.debugLog === true,
|
|
61
62
|
permissionReviewLog: record.permissionReviewLog !== false,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getNonEmptyString, toRecord } from "./common";
|
|
1
2
|
import { matchQualifier } from "./denial-messages";
|
|
2
3
|
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
3
4
|
import type { ToolPreviewFormatter } from "./tool-preview-formatter";
|
|
@@ -37,12 +38,18 @@ export function formatAskPrompt(
|
|
|
37
38
|
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
38
39
|
|
|
39
40
|
if (result.toolName === "bash") {
|
|
41
|
+
const subCommand = result.command ?? "";
|
|
40
42
|
const qualifier = matchQualifier(
|
|
41
43
|
result.matchedPattern,
|
|
42
44
|
result.commandContext,
|
|
43
45
|
);
|
|
44
46
|
const qualifierInfo = qualifier ? ` ${qualifier}` : "";
|
|
45
|
-
|
|
47
|
+
const fullCommand = getNonEmptyString(toRecord(input).command);
|
|
48
|
+
const fullCommandInfo =
|
|
49
|
+
fullCommand && fullCommand !== subCommand
|
|
50
|
+
? ` (full command: '${fullCommand}')`
|
|
51
|
+
: "";
|
|
52
|
+
return `${subject} requested bash command '${subCommand}'${qualifierInfo}${fullCommandInfo}. Allow this command?`;
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
|
package/test/common.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
getNonEmptyString,
|
|
6
6
|
isPermissionState,
|
|
7
7
|
normalizeOptionalPositiveInt,
|
|
8
|
+
normalizeOptionalStringArray,
|
|
8
9
|
parseSimpleYamlMap,
|
|
9
10
|
toRecord,
|
|
10
11
|
} from "#src/common";
|
|
@@ -189,6 +190,44 @@ describe("parseSimpleYamlMap", () => {
|
|
|
189
190
|
});
|
|
190
191
|
});
|
|
191
192
|
|
|
193
|
+
describe("normalizeOptionalStringArray", () => {
|
|
194
|
+
it("returns the array for a valid string array", () => {
|
|
195
|
+
expect(normalizeOptionalStringArray(["a", "b", "c"])).toEqual([
|
|
196
|
+
"a",
|
|
197
|
+
"b",
|
|
198
|
+
"c",
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("returns an empty array for an empty array", () => {
|
|
203
|
+
expect(normalizeOptionalStringArray([])).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("returns undefined for a plain string", () => {
|
|
207
|
+
expect(normalizeOptionalStringArray("x")).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("returns undefined for a number", () => {
|
|
211
|
+
expect(normalizeOptionalStringArray(42)).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("returns undefined for a plain object", () => {
|
|
215
|
+
expect(normalizeOptionalStringArray({ a: "b" })).toBeUndefined();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("returns undefined for a mixed-type array", () => {
|
|
219
|
+
expect(normalizeOptionalStringArray(["a", 1])).toBeUndefined();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("returns undefined for undefined", () => {
|
|
223
|
+
expect(normalizeOptionalStringArray(undefined)).toBeUndefined();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("returns undefined for null", () => {
|
|
227
|
+
expect(normalizeOptionalStringArray(null)).toBeUndefined();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
192
231
|
describe("normalizeOptionalPositiveInt", () => {
|
|
193
232
|
it("returns the value for a valid positive integer", () => {
|
|
194
233
|
expect(normalizeOptionalPositiveInt(1)).toBe(1);
|
|
@@ -324,6 +324,48 @@ describe("loadUnifiedConfig", () => {
|
|
|
324
324
|
const result = loadUnifiedConfig(configPath);
|
|
325
325
|
expect(result.config).not.toHaveProperty("toolTextSummaryMaxLength");
|
|
326
326
|
});
|
|
327
|
+
|
|
328
|
+
it("parses piInfrastructureReadPaths when a valid string array is present", () => {
|
|
329
|
+
const configPath = join(tempDir, "config.json");
|
|
330
|
+
writeFileSync(
|
|
331
|
+
configPath,
|
|
332
|
+
JSON.stringify({ piInfrastructureReadPaths: ["/extra/path"] }),
|
|
333
|
+
);
|
|
334
|
+
const result = loadUnifiedConfig(configPath);
|
|
335
|
+
expect(result.config.piInfrastructureReadPaths).toEqual(["/extra/path"]);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("parses piInfrastructureReadPaths as empty array when set to []", () => {
|
|
339
|
+
const configPath = join(tempDir, "config.json");
|
|
340
|
+
writeFileSync(
|
|
341
|
+
configPath,
|
|
342
|
+
JSON.stringify({ piInfrastructureReadPaths: [] }),
|
|
343
|
+
);
|
|
344
|
+
const result = loadUnifiedConfig(configPath);
|
|
345
|
+
expect(result.config.piInfrastructureReadPaths).toEqual([]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("omits piInfrastructureReadPaths when absent", () => {
|
|
349
|
+
const configPath = join(tempDir, "config.json");
|
|
350
|
+
writeFileSync(configPath, JSON.stringify({ debugLog: false }));
|
|
351
|
+
const result = loadUnifiedConfig(configPath);
|
|
352
|
+
expect(result.config).not.toHaveProperty("piInfrastructureReadPaths");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it.each([
|
|
356
|
+
["string", "not-an-array"],
|
|
357
|
+
["number", 42],
|
|
358
|
+
["mixed-type array", ["a", 1]],
|
|
359
|
+
["object", { a: "b" }],
|
|
360
|
+
] as const)("omits piInfrastructureReadPaths for invalid value: %s", (_label, value) => {
|
|
361
|
+
const configPath = join(tempDir, "config.json");
|
|
362
|
+
writeFileSync(
|
|
363
|
+
configPath,
|
|
364
|
+
JSON.stringify({ piInfrastructureReadPaths: value }),
|
|
365
|
+
);
|
|
366
|
+
const result = loadUnifiedConfig(configPath);
|
|
367
|
+
expect(result.config).not.toHaveProperty("piInfrastructureReadPaths");
|
|
368
|
+
});
|
|
327
369
|
});
|
|
328
370
|
|
|
329
371
|
describe("mergeUnifiedConfigs", () => {
|
|
@@ -460,6 +502,35 @@ describe("mergeUnifiedConfigs", () => {
|
|
|
460
502
|
const merged = mergeUnifiedConfigs({}, { permissionReviewLog: true });
|
|
461
503
|
expect(merged).not.toHaveProperty("toolTextSummaryMaxLength");
|
|
462
504
|
});
|
|
505
|
+
|
|
506
|
+
it("override piInfrastructureReadPaths replaces base array", () => {
|
|
507
|
+
const merged = mergeUnifiedConfigs(
|
|
508
|
+
{ piInfrastructureReadPaths: ["/base/path"] },
|
|
509
|
+
{ piInfrastructureReadPaths: ["/override/path"] },
|
|
510
|
+
);
|
|
511
|
+
expect(merged.piInfrastructureReadPaths).toEqual(["/override/path"]);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("base piInfrastructureReadPaths survives when override omits it", () => {
|
|
515
|
+
const merged = mergeUnifiedConfigs(
|
|
516
|
+
{ piInfrastructureReadPaths: ["/kept/path"] },
|
|
517
|
+
{ debugLog: true },
|
|
518
|
+
);
|
|
519
|
+
expect(merged.piInfrastructureReadPaths).toEqual(["/kept/path"]);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("piInfrastructureReadPaths is absent when both base and override omit it", () => {
|
|
523
|
+
const merged = mergeUnifiedConfigs({ debugLog: true }, { yoloMode: false });
|
|
524
|
+
expect(merged).not.toHaveProperty("piInfrastructureReadPaths");
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("override piInfrastructureReadPaths as empty array replaces non-empty base", () => {
|
|
528
|
+
const merged = mergeUnifiedConfigs(
|
|
529
|
+
{ piInfrastructureReadPaths: ["/base/path"] },
|
|
530
|
+
{ piInfrastructureReadPaths: [] },
|
|
531
|
+
);
|
|
532
|
+
expect(merged.piInfrastructureReadPaths).toEqual([]);
|
|
533
|
+
});
|
|
463
534
|
});
|
|
464
535
|
|
|
465
536
|
describe("loadAndMergeConfigs", () => {
|
|
@@ -293,6 +293,18 @@ describe("ConfigStore", () => {
|
|
|
293
293
|
store.refresh(ctx);
|
|
294
294
|
expect(mockSyncPermissionSystemStatus).not.toHaveBeenCalled();
|
|
295
295
|
});
|
|
296
|
+
|
|
297
|
+
it("carries piInfrastructureReadPaths from merged config into current()", () => {
|
|
298
|
+
const { store } = makeStore();
|
|
299
|
+
mockLoadAndMergeConfigs.mockReturnValue({
|
|
300
|
+
merged: { piInfrastructureReadPaths: ["/extra/path"] },
|
|
301
|
+
issues: [],
|
|
302
|
+
});
|
|
303
|
+
store.refresh();
|
|
304
|
+
expect(store.current().piInfrastructureReadPaths).toEqual([
|
|
305
|
+
"/extra/path",
|
|
306
|
+
]);
|
|
307
|
+
});
|
|
296
308
|
});
|
|
297
309
|
|
|
298
310
|
// ── save() ─────────────────────────────────────────────────────────────
|
|
@@ -385,6 +397,20 @@ describe("ConfigStore", () => {
|
|
|
385
397
|
"utf-8",
|
|
386
398
|
);
|
|
387
399
|
});
|
|
400
|
+
|
|
401
|
+
it("preserves an existing global piInfrastructureReadPaths on save", () => {
|
|
402
|
+
const { store } = makeStore();
|
|
403
|
+
// Simulate a global config.json that already has the infra-paths field.
|
|
404
|
+
mockLoadUnifiedConfig.mockReturnValue({
|
|
405
|
+
config: { piInfrastructureReadPaths: ["/extra/path"] },
|
|
406
|
+
});
|
|
407
|
+
store.save({ ...DEFAULT_EXTENSION_CONFIG }, makeCommandCtx());
|
|
408
|
+
expect(mockWriteFileSync).toHaveBeenCalledWith(
|
|
409
|
+
expect.stringContaining(".tmp"),
|
|
410
|
+
expect.stringContaining('"piInfrastructureReadPaths"'),
|
|
411
|
+
"utf-8",
|
|
412
|
+
);
|
|
413
|
+
});
|
|
388
414
|
});
|
|
389
415
|
|
|
390
416
|
// ── logResolvedPaths() ─────────────────────────────────────────────────
|
|
@@ -151,6 +151,73 @@ describe("formatAskPrompt", () => {
|
|
|
151
151
|
expect(result).toContain("matched 'git *'");
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
test("appends full command when input contains a chain that differs from the sub-command", () => {
|
|
155
|
+
const result = formatAskPrompt(
|
|
156
|
+
toolResult("bash", { command: "rm -rf ." }),
|
|
157
|
+
undefined,
|
|
158
|
+
{ command: 'echo "hello" && rm -rf .' },
|
|
159
|
+
makeFormatter(),
|
|
160
|
+
);
|
|
161
|
+
expect(result).toBe(
|
|
162
|
+
`Current agent requested bash command 'rm -rf .' (full command: 'echo "hello" && rm -rf .'). Allow this command?`,
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("suppresses full-command suffix when input command matches the sub-command (no chain)", () => {
|
|
167
|
+
const result = formatAskPrompt(
|
|
168
|
+
toolResult("bash", { command: "git push" }),
|
|
169
|
+
undefined,
|
|
170
|
+
{ command: "git push" },
|
|
171
|
+
makeFormatter(),
|
|
172
|
+
);
|
|
173
|
+
expect(result).not.toContain("full command:");
|
|
174
|
+
expect(result).toBe(
|
|
175
|
+
"Current agent requested bash command 'git push'. Allow this command?",
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("suppresses full-command suffix when input is undefined", () => {
|
|
180
|
+
const result = formatAskPrompt(
|
|
181
|
+
toolResult("bash", { command: "git push" }),
|
|
182
|
+
undefined,
|
|
183
|
+
undefined,
|
|
184
|
+
makeFormatter(),
|
|
185
|
+
);
|
|
186
|
+
expect(result).not.toContain("full command:");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("suppresses full-command suffix when input has no command field", () => {
|
|
190
|
+
const result = formatAskPrompt(
|
|
191
|
+
toolResult("bash", { command: "git push" }),
|
|
192
|
+
undefined,
|
|
193
|
+
{ unrelated: "value" },
|
|
194
|
+
makeFormatter(),
|
|
195
|
+
);
|
|
196
|
+
expect(result).not.toContain("full command:");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("suppresses full-command suffix when input command is empty", () => {
|
|
200
|
+
const result = formatAskPrompt(
|
|
201
|
+
toolResult("bash", { command: "git push" }),
|
|
202
|
+
undefined,
|
|
203
|
+
{ command: "" },
|
|
204
|
+
makeFormatter(),
|
|
205
|
+
);
|
|
206
|
+
expect(result).not.toContain("full command:");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("places full-command suffix after the qualifier and before the terminal sentence", () => {
|
|
210
|
+
const result = formatAskPrompt(
|
|
211
|
+
toolResult("bash", { command: "rm -rf foo", matchedPattern: "rm *" }),
|
|
212
|
+
undefined,
|
|
213
|
+
{ command: "cd /tmp && rm -rf foo" },
|
|
214
|
+
makeFormatter(),
|
|
215
|
+
);
|
|
216
|
+
expect(result).toBe(
|
|
217
|
+
"Current agent requested bash command 'rm -rf foo' (matched 'rm *') (full command: 'cd /tmp && rm -rf foo'). Allow this command?",
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
154
221
|
test("formats bash prompt with nested execution context", () => {
|
|
155
222
|
const result = formatAskPrompt(
|
|
156
223
|
toolResult("bash", {
|