@gotgenes/pi-permission-system 8.0.0 → 8.2.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 +21 -0
- package/config/config.example.json +3 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +12 -0
- package/src/extension-config.ts +23 -0
- package/src/handlers/gates/bash-external-directory.ts +2 -4
- package/src/handlers/gates/bash-path.ts +2 -4
- package/src/handlers/gates/descriptor.ts +6 -6
- package/src/handlers/gates/external-directory.ts +2 -4
- package/src/handlers/gates/helpers.ts +30 -1
- package/src/handlers/gates/path.ts +2 -4
- package/src/handlers/gates/runner.ts +29 -56
- package/src/handlers/gates/tool.ts +9 -6
- package/src/handlers/permission-gate-handler.ts +110 -141
- package/src/permission-manager.ts +6 -49
- package/src/permission-prompts.ts +5 -2
- package/src/permission-session.ts +3 -2
- package/src/scope-merge.ts +72 -0
- package/src/session-approval.ts +43 -0
- package/src/session-rules.ts +13 -0
- package/src/tool-input-preview.ts +0 -116
- package/src/tool-preview-formatter.ts +188 -0
- package/test/extension-config.test.ts +93 -0
- package/test/handlers/external-directory-integration.test.ts +3 -1
- package/test/handlers/external-directory-session-dedup.test.ts +17 -12
- package/test/handlers/gates/bash-external-directory.test.ts +11 -9
- package/test/handlers/gates/external-directory.test.ts +2 -5
- package/test/handlers/gates/helpers.test.ts +81 -0
- package/test/handlers/gates/path.test.ts +2 -2
- package/test/handlers/gates/runner.test.ts +18 -23
- package/test/handlers/gates/tool.test.ts +31 -4
- package/test/handlers/input-events.test.ts +1 -1
- package/test/handlers/input.test.ts +1 -1
- package/test/handlers/tool-call-events.test.ts +3 -2
- package/test/handlers/tool-call.test.ts +3 -2
- package/test/handlers/validate-requested-tool.test.ts +92 -0
- package/test/permission-prompts.test.ts +66 -38
- package/test/permission-session.test.ts +6 -3
- package/test/scope-merge.test.ts +116 -0
- package/test/session-approval.test.ts +75 -0
- package/test/session-rules.test.ts +49 -0
- package/test/tool-input-preview.test.ts +0 -244
- package/test/tool-preview-formatter.test.ts +385 -0
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { getNonEmptyString, toRecord } from "./common";
|
|
2
2
|
import { safeJsonStringify } from "./logging";
|
|
3
|
-
import type { PermissionCheckResult } from "./types";
|
|
4
3
|
|
|
5
4
|
export const TOOL_INPUT_PREVIEW_MAX_LENGTH = 200;
|
|
6
5
|
export const TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH = 1000;
|
|
@@ -10,14 +9,6 @@ export function truncateInlineText(value: string, maxLength: number): string {
|
|
|
10
9
|
return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
|
|
11
10
|
}
|
|
12
11
|
|
|
13
|
-
export function sanitizeInlineText(
|
|
14
|
-
value: string,
|
|
15
|
-
maxLength = TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
|
16
|
-
): string {
|
|
17
|
-
const normalized = value.replace(/\s+/g, " ").trim();
|
|
18
|
-
return normalized ? truncateInlineText(normalized, maxLength) : "empty text";
|
|
19
|
-
}
|
|
20
|
-
|
|
21
12
|
export function countTextLines(value: string): number {
|
|
22
13
|
if (!value) {
|
|
23
14
|
return 0;
|
|
@@ -95,30 +86,6 @@ export function formatReadInputForPrompt(
|
|
|
95
86
|
return parts.length > 0 ? `for ${parts.join(", ")}` : "";
|
|
96
87
|
}
|
|
97
88
|
|
|
98
|
-
export function formatSearchInputForPrompt(
|
|
99
|
-
toolName: string,
|
|
100
|
-
input: Record<string, unknown>,
|
|
101
|
-
): string {
|
|
102
|
-
const parts: string[] = [];
|
|
103
|
-
const path = getPromptPath(input);
|
|
104
|
-
const pattern = getNonEmptyString(input.pattern);
|
|
105
|
-
const glob = getNonEmptyString(input.glob);
|
|
106
|
-
|
|
107
|
-
if (pattern) {
|
|
108
|
-
parts.push(`pattern '${sanitizeInlineText(pattern)}'`);
|
|
109
|
-
}
|
|
110
|
-
if (glob) {
|
|
111
|
-
parts.push(`glob '${sanitizeInlineText(glob)}'`);
|
|
112
|
-
}
|
|
113
|
-
if (path) {
|
|
114
|
-
parts.push(`path '${path}'`);
|
|
115
|
-
} else if (toolName === "find" || toolName === "grep" || toolName === "ls") {
|
|
116
|
-
parts.push("current working directory");
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return parts.length > 0 ? `for ${parts.join(", ")}` : "";
|
|
120
|
-
}
|
|
121
|
-
|
|
122
89
|
export function serializeToolInputPreview(input: unknown): string {
|
|
123
90
|
const serialized = safeJsonStringify(input);
|
|
124
91
|
if (!serialized || serialized === "{}" || serialized === "null") {
|
|
@@ -127,86 +94,3 @@ export function serializeToolInputPreview(input: unknown): string {
|
|
|
127
94
|
|
|
128
95
|
return serialized.replace(/\s+/g, " ").trim();
|
|
129
96
|
}
|
|
130
|
-
|
|
131
|
-
export function formatJsonInputForPrompt(input: unknown): string {
|
|
132
|
-
const inline = serializeToolInputPreview(input);
|
|
133
|
-
return inline
|
|
134
|
-
? `with input ${truncateInlineText(inline, TOOL_INPUT_PREVIEW_MAX_LENGTH)}`
|
|
135
|
-
: "";
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export function formatToolInputForPrompt(
|
|
139
|
-
toolName: string,
|
|
140
|
-
input: unknown,
|
|
141
|
-
): string {
|
|
142
|
-
const inputRecord = toRecord(input);
|
|
143
|
-
|
|
144
|
-
switch (toolName) {
|
|
145
|
-
case "edit":
|
|
146
|
-
return formatEditInputForPrompt(inputRecord);
|
|
147
|
-
case "write":
|
|
148
|
-
return formatWriteInputForPrompt(inputRecord);
|
|
149
|
-
case "read":
|
|
150
|
-
return formatReadInputForPrompt(inputRecord);
|
|
151
|
-
case "find":
|
|
152
|
-
case "grep":
|
|
153
|
-
case "ls":
|
|
154
|
-
return formatSearchInputForPrompt(toolName, inputRecord);
|
|
155
|
-
default:
|
|
156
|
-
return formatJsonInputForPrompt(input);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export function formatGenericToolInputForLog(
|
|
161
|
-
input: unknown,
|
|
162
|
-
): string | undefined {
|
|
163
|
-
const inline = serializeToolInputPreview(input);
|
|
164
|
-
return inline
|
|
165
|
-
? `input ${truncateInlineText(inline, TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH)}`
|
|
166
|
-
: undefined;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export function getToolInputPreviewForLog(
|
|
170
|
-
result: PermissionCheckResult,
|
|
171
|
-
input: unknown,
|
|
172
|
-
pathBearingTools: ReadonlySet<string>,
|
|
173
|
-
): string | undefined {
|
|
174
|
-
if (
|
|
175
|
-
result.toolName === "bash" ||
|
|
176
|
-
result.toolName === "mcp" ||
|
|
177
|
-
result.source === "mcp"
|
|
178
|
-
) {
|
|
179
|
-
return undefined;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (pathBearingTools.has(result.toolName)) {
|
|
183
|
-
const inputPreview = formatToolInputForPrompt(result.toolName, input);
|
|
184
|
-
return inputPreview
|
|
185
|
-
? truncateInlineText(inputPreview, TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH)
|
|
186
|
-
: undefined;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return formatGenericToolInputForLog(input);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export function getPermissionLogContext(
|
|
193
|
-
result: PermissionCheckResult,
|
|
194
|
-
input: unknown,
|
|
195
|
-
pathBearingTools: ReadonlySet<string>,
|
|
196
|
-
): {
|
|
197
|
-
command?: string;
|
|
198
|
-
target?: string;
|
|
199
|
-
toolInputPreview?: string;
|
|
200
|
-
origin?: string;
|
|
201
|
-
} {
|
|
202
|
-
return {
|
|
203
|
-
command: result.command,
|
|
204
|
-
target: result.target,
|
|
205
|
-
toolInputPreview: getToolInputPreviewForLog(
|
|
206
|
-
result,
|
|
207
|
-
input,
|
|
208
|
-
pathBearingTools,
|
|
209
|
-
),
|
|
210
|
-
origin: result.origin,
|
|
211
|
-
};
|
|
212
|
-
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { getNonEmptyString, toRecord } from "./common";
|
|
2
|
+
import type { PermissionSystemExtensionConfig } from "./extension-config";
|
|
3
|
+
import {
|
|
4
|
+
formatEditInputForPrompt,
|
|
5
|
+
formatReadInputForPrompt,
|
|
6
|
+
formatWriteInputForPrompt,
|
|
7
|
+
getPromptPath,
|
|
8
|
+
serializeToolInputPreview,
|
|
9
|
+
TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
|
|
10
|
+
TOOL_INPUT_PREVIEW_MAX_LENGTH,
|
|
11
|
+
TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
|
12
|
+
truncateInlineText,
|
|
13
|
+
} from "./tool-input-preview";
|
|
14
|
+
import type { PermissionCheckResult } from "./types";
|
|
15
|
+
|
|
16
|
+
export interface ToolPreviewFormatterOptions {
|
|
17
|
+
toolInputPreviewMaxLength: number;
|
|
18
|
+
toolTextSummaryMaxLength: number;
|
|
19
|
+
toolInputLogPreviewMaxLength: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type ConfigurablePreviewLimits = Pick<
|
|
23
|
+
PermissionSystemExtensionConfig,
|
|
24
|
+
"toolInputPreviewMaxLength" | "toolTextSummaryMaxLength"
|
|
25
|
+
>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve `ToolPreviewFormatterOptions` from a config object, falling back to
|
|
29
|
+
* the built-in defaults for any field that is absent.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveToolPreviewLimits(
|
|
32
|
+
config: ConfigurablePreviewLimits,
|
|
33
|
+
): ToolPreviewFormatterOptions {
|
|
34
|
+
return {
|
|
35
|
+
toolInputPreviewMaxLength:
|
|
36
|
+
config.toolInputPreviewMaxLength ?? TOOL_INPUT_PREVIEW_MAX_LENGTH,
|
|
37
|
+
toolTextSummaryMaxLength:
|
|
38
|
+
config.toolTextSummaryMaxLength ?? TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
|
39
|
+
toolInputLogPreviewMaxLength: TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Formats tool inputs for permission prompts and review logs.
|
|
45
|
+
*
|
|
46
|
+
* Accepts configurable limits in its constructor — the single injection
|
|
47
|
+
* point for preview-length configuration (#266).
|
|
48
|
+
*/
|
|
49
|
+
export class ToolPreviewFormatter {
|
|
50
|
+
constructor(private readonly options: ToolPreviewFormatterOptions) {}
|
|
51
|
+
|
|
52
|
+
// ── Prompt formatting ───────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Collapse whitespace, trim, and truncate a string to fit inline.
|
|
56
|
+
* An explicit `maxLength` overrides the constructor default.
|
|
57
|
+
*/
|
|
58
|
+
sanitizeInlineText(value: string, maxLength?: number): string {
|
|
59
|
+
const limit = maxLength ?? this.options.toolTextSummaryMaxLength;
|
|
60
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
61
|
+
return normalized ? truncateInlineText(normalized, limit) : "empty text";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Serialize `input` to inline JSON and truncate at `toolInputPreviewMaxLength`. */
|
|
65
|
+
formatJsonInputForPrompt(input: unknown): string {
|
|
66
|
+
const inline = serializeToolInputPreview(input);
|
|
67
|
+
return inline
|
|
68
|
+
? `with input ${truncateInlineText(inline, this.options.toolInputPreviewMaxLength)}`
|
|
69
|
+
: "";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Format search-tool (grep/find/ls) input for a permission prompt. */
|
|
73
|
+
formatSearchInputForPrompt(
|
|
74
|
+
toolName: string,
|
|
75
|
+
input: Record<string, unknown>,
|
|
76
|
+
): string {
|
|
77
|
+
const parts: string[] = [];
|
|
78
|
+
const path = getPromptPath(input);
|
|
79
|
+
const pattern = getNonEmptyString(input.pattern);
|
|
80
|
+
const glob = getNonEmptyString(input.glob);
|
|
81
|
+
|
|
82
|
+
if (pattern) {
|
|
83
|
+
parts.push(`pattern '${this.sanitizeInlineText(pattern)}'`);
|
|
84
|
+
}
|
|
85
|
+
if (glob) {
|
|
86
|
+
parts.push(`glob '${this.sanitizeInlineText(glob)}'`);
|
|
87
|
+
}
|
|
88
|
+
if (path) {
|
|
89
|
+
parts.push(`path '${path}'`);
|
|
90
|
+
} else if (
|
|
91
|
+
toolName === "find" ||
|
|
92
|
+
toolName === "grep" ||
|
|
93
|
+
toolName === "ls"
|
|
94
|
+
) {
|
|
95
|
+
parts.push("current working directory");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return parts.length > 0 ? `for ${parts.join(", ")}` : "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Format any tool input for display in a permission ask-prompt.
|
|
103
|
+
*
|
|
104
|
+
* Dispatches to the appropriate pure formatter for known tools
|
|
105
|
+
* and falls back to inline JSON for everything else.
|
|
106
|
+
*/
|
|
107
|
+
formatToolInputForPrompt(toolName: string, input: unknown): string {
|
|
108
|
+
const inputRecord = toRecord(input);
|
|
109
|
+
|
|
110
|
+
switch (toolName) {
|
|
111
|
+
case "edit":
|
|
112
|
+
return formatEditInputForPrompt(inputRecord);
|
|
113
|
+
case "write":
|
|
114
|
+
return formatWriteInputForPrompt(inputRecord);
|
|
115
|
+
case "read":
|
|
116
|
+
return formatReadInputForPrompt(inputRecord);
|
|
117
|
+
case "find":
|
|
118
|
+
case "grep":
|
|
119
|
+
case "ls":
|
|
120
|
+
return this.formatSearchInputForPrompt(toolName, inputRecord);
|
|
121
|
+
default:
|
|
122
|
+
return this.formatJsonInputForPrompt(input);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Log formatting ──────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/** Serialize `input` to inline JSON and truncate at `toolInputLogPreviewMaxLength`. */
|
|
129
|
+
formatGenericToolInputForLog(input: unknown): string | undefined {
|
|
130
|
+
const inline = serializeToolInputPreview(input);
|
|
131
|
+
return inline
|
|
132
|
+
? `input ${truncateInlineText(inline, this.options.toolInputLogPreviewMaxLength)}`
|
|
133
|
+
: undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Derive a loggable input preview string for the review log. */
|
|
137
|
+
getToolInputPreviewForLog(
|
|
138
|
+
result: PermissionCheckResult,
|
|
139
|
+
input: unknown,
|
|
140
|
+
pathBearingTools: ReadonlySet<string>,
|
|
141
|
+
): string | undefined {
|
|
142
|
+
if (
|
|
143
|
+
result.toolName === "bash" ||
|
|
144
|
+
result.toolName === "mcp" ||
|
|
145
|
+
result.source === "mcp"
|
|
146
|
+
) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (pathBearingTools.has(result.toolName)) {
|
|
151
|
+
const inputPreview = this.formatToolInputForPrompt(
|
|
152
|
+
result.toolName,
|
|
153
|
+
input,
|
|
154
|
+
);
|
|
155
|
+
return inputPreview
|
|
156
|
+
? truncateInlineText(
|
|
157
|
+
inputPreview,
|
|
158
|
+
this.options.toolInputLogPreviewMaxLength,
|
|
159
|
+
)
|
|
160
|
+
: undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return this.formatGenericToolInputForLog(input);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Build the structured log context object for a permission review log entry. */
|
|
167
|
+
getPermissionLogContext(
|
|
168
|
+
result: PermissionCheckResult,
|
|
169
|
+
input: unknown,
|
|
170
|
+
pathBearingTools: ReadonlySet<string>,
|
|
171
|
+
): {
|
|
172
|
+
command?: string;
|
|
173
|
+
target?: string;
|
|
174
|
+
toolInputPreview?: string;
|
|
175
|
+
origin?: string;
|
|
176
|
+
} {
|
|
177
|
+
return {
|
|
178
|
+
command: result.command,
|
|
179
|
+
target: result.target,
|
|
180
|
+
toolInputPreview: this.getToolInputPreviewForLog(
|
|
181
|
+
result,
|
|
182
|
+
input,
|
|
183
|
+
pathBearingTools,
|
|
184
|
+
),
|
|
185
|
+
origin: result.origin,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
detectMisplacedPermissionKeys,
|
|
5
|
+
normalizeOptionalPositiveInt,
|
|
5
6
|
normalizePermissionSystemConfig,
|
|
6
7
|
} from "#src/extension-config";
|
|
7
8
|
|
|
@@ -74,6 +75,36 @@ describe("detectMisplacedPermissionKeys", () => {
|
|
|
74
75
|
});
|
|
75
76
|
});
|
|
76
77
|
|
|
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
|
+
|
|
77
108
|
describe("normalizePermissionSystemConfig", () => {
|
|
78
109
|
it("normalizes a valid config object", () => {
|
|
79
110
|
const result = normalizePermissionSystemConfig({
|
|
@@ -122,4 +153,66 @@ describe("normalizePermissionSystemConfig", () => {
|
|
|
122
153
|
yoloMode: false,
|
|
123
154
|
});
|
|
124
155
|
});
|
|
156
|
+
|
|
157
|
+
it("includes toolInputPreviewMaxLength when a valid positive integer is provided", () => {
|
|
158
|
+
const result = normalizePermissionSystemConfig({
|
|
159
|
+
toolInputPreviewMaxLength: 400,
|
|
160
|
+
});
|
|
161
|
+
expect(result.toolInputPreviewMaxLength).toBe(400);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("omits toolInputPreviewMaxLength when absent", () => {
|
|
165
|
+
const result = normalizePermissionSystemConfig({});
|
|
166
|
+
expect("toolInputPreviewMaxLength" in result).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("omits toolInputPreviewMaxLength for invalid values", () => {
|
|
170
|
+
expect(
|
|
171
|
+
normalizePermissionSystemConfig({ toolInputPreviewMaxLength: 0 })
|
|
172
|
+
.toolInputPreviewMaxLength,
|
|
173
|
+
).toBeUndefined();
|
|
174
|
+
expect(
|
|
175
|
+
normalizePermissionSystemConfig({ toolInputPreviewMaxLength: -1 })
|
|
176
|
+
.toolInputPreviewMaxLength,
|
|
177
|
+
).toBeUndefined();
|
|
178
|
+
expect(
|
|
179
|
+
normalizePermissionSystemConfig({ toolInputPreviewMaxLength: 200.5 })
|
|
180
|
+
.toolInputPreviewMaxLength,
|
|
181
|
+
).toBeUndefined();
|
|
182
|
+
expect(
|
|
183
|
+
normalizePermissionSystemConfig({ toolInputPreviewMaxLength: "200" })
|
|
184
|
+
.toolInputPreviewMaxLength,
|
|
185
|
+
).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("includes toolTextSummaryMaxLength when a valid positive integer is provided", () => {
|
|
189
|
+
const result = normalizePermissionSystemConfig({
|
|
190
|
+
toolTextSummaryMaxLength: 120,
|
|
191
|
+
});
|
|
192
|
+
expect(result.toolTextSummaryMaxLength).toBe(120);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("omits toolTextSummaryMaxLength when absent", () => {
|
|
196
|
+
const result = normalizePermissionSystemConfig({});
|
|
197
|
+
expect("toolTextSummaryMaxLength" in result).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("omits toolTextSummaryMaxLength for invalid values", () => {
|
|
201
|
+
expect(
|
|
202
|
+
normalizePermissionSystemConfig({ toolTextSummaryMaxLength: 0 })
|
|
203
|
+
.toolTextSummaryMaxLength,
|
|
204
|
+
).toBeUndefined();
|
|
205
|
+
expect(
|
|
206
|
+
normalizePermissionSystemConfig({ toolTextSummaryMaxLength: -1 })
|
|
207
|
+
.toolTextSummaryMaxLength,
|
|
208
|
+
).toBeUndefined();
|
|
209
|
+
expect(
|
|
210
|
+
normalizePermissionSystemConfig({ toolTextSummaryMaxLength: 80.1 })
|
|
211
|
+
.toolTextSummaryMaxLength,
|
|
212
|
+
).toBeUndefined();
|
|
213
|
+
expect(
|
|
214
|
+
normalizePermissionSystemConfig({ toolTextSummaryMaxLength: true })
|
|
215
|
+
.toolTextSummaryMaxLength,
|
|
216
|
+
).toBeUndefined();
|
|
217
|
+
});
|
|
125
218
|
});
|
|
@@ -12,6 +12,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
12
12
|
import { describe, expect, it, vi } from "vitest";
|
|
13
13
|
|
|
14
14
|
import { EXTENSION_TAG } from "#src/denial-messages";
|
|
15
|
+
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
15
16
|
import { formatExternalDirectoryAskPrompt } from "#src/handlers/gates/external-directory-messages";
|
|
16
17
|
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
|
|
17
18
|
import {
|
|
@@ -112,10 +113,11 @@ function makeSession(
|
|
|
112
113
|
checkPermission: makeCheckPermission("deny"),
|
|
113
114
|
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
114
115
|
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
115
|
-
|
|
116
|
+
recordSessionApproval: vi.fn(),
|
|
116
117
|
getActiveSkillEntries: vi.fn().mockReturnValue([]),
|
|
117
118
|
getInfrastructureDirs: vi.fn().mockReturnValue([]),
|
|
118
119
|
getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
|
|
120
|
+
config: DEFAULT_EXTENSION_CONFIG,
|
|
119
121
|
canPrompt: vi.fn().mockReturnValue(true),
|
|
120
122
|
prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
|
|
121
123
|
...overrides,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* external path only prompt once — the session-approval recorded by the
|
|
4
4
|
* first call covers the second.
|
|
5
5
|
*
|
|
6
|
-
* These tests use stateful mocks: `
|
|
6
|
+
* These tests use stateful mocks: `recordSessionApproval` records rules,
|
|
7
7
|
* and `checkPermission` consults them via `getSessionRuleset`, mirroring
|
|
8
8
|
* the real interaction between PermissionSession, SessionRules, and
|
|
9
9
|
* PermissionManager.
|
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { describe, expect, it, vi } from "vitest";
|
|
13
13
|
|
|
14
|
+
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
14
15
|
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
|
|
15
16
|
import type { PermissionSession } from "#src/permission-session";
|
|
16
17
|
import type { Rule } from "#src/rule";
|
|
18
|
+
import type { SessionApproval } from "#src/session-approval";
|
|
17
19
|
import type { ToolRegistry } from "#src/tool-registry";
|
|
18
20
|
import type { PermissionCheckResult } from "#src/types";
|
|
19
21
|
import { wildcardMatch } from "#src/wildcard-matcher";
|
|
@@ -52,7 +54,7 @@ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
|
|
52
54
|
* Build a PermissionSession mock with stateful session-rule tracking.
|
|
53
55
|
*
|
|
54
56
|
* `checkPermission` returns "ask" for `external_directory` unless a
|
|
55
|
-
* matching session rule exists (via `
|
|
57
|
+
* matching session rule exists (via `recordSessionApproval`), in which case
|
|
56
58
|
* it returns "allow" with `source: "session"`. All other surfaces return
|
|
57
59
|
* "allow" by default.
|
|
58
60
|
*/
|
|
@@ -114,16 +116,18 @@ function makeStatefulSession(
|
|
|
114
116
|
},
|
|
115
117
|
);
|
|
116
118
|
|
|
117
|
-
const
|
|
119
|
+
const recordSessionApproval = vi
|
|
118
120
|
.fn()
|
|
119
|
-
.mockImplementation((
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
121
|
+
.mockImplementation((approval: SessionApproval) => {
|
|
122
|
+
for (const pattern of approval.patterns) {
|
|
123
|
+
sessionRules.push({
|
|
124
|
+
surface: approval.surface,
|
|
125
|
+
pattern,
|
|
126
|
+
action: "allow",
|
|
127
|
+
layer: "session",
|
|
128
|
+
origin: "session",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
127
131
|
});
|
|
128
132
|
|
|
129
133
|
const getSessionRuleset = vi.fn().mockImplementation(() => [...sessionRules]);
|
|
@@ -135,10 +139,11 @@ function makeStatefulSession(
|
|
|
135
139
|
checkPermission,
|
|
136
140
|
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
137
141
|
getSessionRuleset,
|
|
138
|
-
|
|
142
|
+
recordSessionApproval,
|
|
139
143
|
getActiveSkillEntries: vi.fn().mockReturnValue([]),
|
|
140
144
|
getInfrastructureDirs: vi.fn().mockReturnValue([]),
|
|
141
145
|
getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
|
|
146
|
+
config: DEFAULT_EXTENSION_CONFIG,
|
|
142
147
|
canPrompt: vi.fn().mockReturnValue(true),
|
|
143
148
|
prompt: vi
|
|
144
149
|
.fn()
|
|
@@ -93,9 +93,8 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
93
93
|
expect(isGateDescriptor(result)).toBe(true);
|
|
94
94
|
const desc = result as GateDescriptor;
|
|
95
95
|
expect(desc.sessionApproval).toBeDefined();
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
expect(patterns.length).toBeGreaterThan(0);
|
|
96
|
+
if (!desc.sessionApproval) return;
|
|
97
|
+
expect(desc.sessionApproval.patterns.length).toBeGreaterThan(0);
|
|
99
98
|
});
|
|
100
99
|
|
|
101
100
|
it("returns GateBypass when all external paths are config-level allowed", async () => {
|
|
@@ -211,8 +210,9 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
211
210
|
);
|
|
212
211
|
expect(isGateDescriptor(result)).toBe(true);
|
|
213
212
|
const desc = result as GateDescriptor;
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
expect(desc.sessionApproval).toBeDefined();
|
|
214
|
+
if (!desc.sessionApproval) return;
|
|
215
|
+
expect(desc.sessionApproval.patterns.length).toBe(1);
|
|
216
216
|
expect(desc.preCheck?.state).toBe("ask");
|
|
217
217
|
});
|
|
218
218
|
|
|
@@ -236,8 +236,9 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
236
236
|
const desc = result as GateDescriptor;
|
|
237
237
|
expect(desc.preCheck?.state).toBe("deny");
|
|
238
238
|
// Both paths are uncovered (neither is allow), so both patterns are included.
|
|
239
|
-
|
|
240
|
-
|
|
239
|
+
expect(desc.sessionApproval).toBeDefined();
|
|
240
|
+
if (!desc.sessionApproval) return;
|
|
241
|
+
expect(desc.sessionApproval.patterns.length).toBe(2);
|
|
241
242
|
});
|
|
242
243
|
|
|
243
244
|
it("only includes uncovered paths when some are session-covered", async () => {
|
|
@@ -259,7 +260,8 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
259
260
|
expect(isGateDescriptor(result)).toBe(true);
|
|
260
261
|
const desc = result as GateDescriptor;
|
|
261
262
|
// Should have patterns only for the uncovered path
|
|
262
|
-
|
|
263
|
-
|
|
263
|
+
expect(desc.sessionApproval).toBeDefined();
|
|
264
|
+
if (!desc.sessionApproval) return;
|
|
265
|
+
expect(desc.sessionApproval.patterns.length).toBe(1);
|
|
264
266
|
});
|
|
265
267
|
});
|
|
@@ -126,11 +126,8 @@ describe("describeExternalDirectoryGate", () => {
|
|
|
126
126
|
["/test/agent"],
|
|
127
127
|
) as GateDescriptor;
|
|
128
128
|
expect(result.sessionApproval).toBeDefined();
|
|
129
|
-
expect(result.sessionApproval).
|
|
130
|
-
|
|
131
|
-
"external_directory",
|
|
132
|
-
);
|
|
133
|
-
expect(result.sessionApproval).toHaveProperty("pattern");
|
|
129
|
+
expect(result.sessionApproval?.surface).toBe("external_directory");
|
|
130
|
+
expect(result.sessionApproval?.representativePattern).toBeDefined();
|
|
134
131
|
});
|
|
135
132
|
|
|
136
133
|
it("denialContext contains the external path and cwd", () => {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
buildDecisionEvent,
|
|
4
5
|
deriveDecisionValue,
|
|
5
6
|
deriveResolution,
|
|
6
7
|
} from "#src/handlers/gates/helpers";
|
|
8
|
+
import type { PermissionCheckResult } from "#src/types";
|
|
7
9
|
|
|
8
10
|
describe("deriveDecisionValue", () => {
|
|
9
11
|
it("returns command for bash", () => {
|
|
@@ -82,3 +84,82 @@ describe("deriveResolution", () => {
|
|
|
82
84
|
);
|
|
83
85
|
});
|
|
84
86
|
});
|
|
87
|
+
|
|
88
|
+
describe("buildDecisionEvent", () => {
|
|
89
|
+
function makeCheck(
|
|
90
|
+
overrides: Partial<PermissionCheckResult> = {},
|
|
91
|
+
): PermissionCheckResult {
|
|
92
|
+
return {
|
|
93
|
+
state: "allow",
|
|
94
|
+
toolName: "read",
|
|
95
|
+
source: "tool",
|
|
96
|
+
origin: "builtin",
|
|
97
|
+
matchedPattern: "*",
|
|
98
|
+
...overrides,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
it("builds a decision event with all fields populated", () => {
|
|
103
|
+
const event = buildDecisionEvent(
|
|
104
|
+
{ surface: "read", value: "read" },
|
|
105
|
+
makeCheck({ origin: "global", matchedPattern: "read" }),
|
|
106
|
+
"test-agent",
|
|
107
|
+
"allow",
|
|
108
|
+
"policy_allow",
|
|
109
|
+
);
|
|
110
|
+
expect(event).toEqual({
|
|
111
|
+
surface: "read",
|
|
112
|
+
value: "read",
|
|
113
|
+
result: "allow",
|
|
114
|
+
resolution: "policy_allow",
|
|
115
|
+
origin: "global",
|
|
116
|
+
agentName: "test-agent",
|
|
117
|
+
matchedPattern: "read",
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("normalises undefined origin to null", () => {
|
|
122
|
+
const event = buildDecisionEvent(
|
|
123
|
+
{ surface: "bash", value: "git status" },
|
|
124
|
+
makeCheck({ origin: undefined }),
|
|
125
|
+
null,
|
|
126
|
+
"allow",
|
|
127
|
+
"user_approved",
|
|
128
|
+
);
|
|
129
|
+
expect(event.origin).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("normalises null agentName to null", () => {
|
|
133
|
+
const event = buildDecisionEvent(
|
|
134
|
+
{ surface: "read", value: "read" },
|
|
135
|
+
makeCheck(),
|
|
136
|
+
null,
|
|
137
|
+
"deny",
|
|
138
|
+
"policy_deny",
|
|
139
|
+
);
|
|
140
|
+
expect(event.agentName).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("normalises undefined matchedPattern to null", () => {
|
|
144
|
+
const event = buildDecisionEvent(
|
|
145
|
+
{ surface: "read", value: "read" },
|
|
146
|
+
makeCheck({ matchedPattern: undefined }),
|
|
147
|
+
null,
|
|
148
|
+
"deny",
|
|
149
|
+
"policy_deny",
|
|
150
|
+
);
|
|
151
|
+
expect(event.matchedPattern).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("passes result and resolution through", () => {
|
|
155
|
+
const event = buildDecisionEvent(
|
|
156
|
+
{ surface: "bash", value: "rm -rf /" },
|
|
157
|
+
makeCheck(),
|
|
158
|
+
null,
|
|
159
|
+
"deny",
|
|
160
|
+
"user_denied",
|
|
161
|
+
);
|
|
162
|
+
expect(event.result).toBe("deny");
|
|
163
|
+
expect(event.resolution).toBe("user_denied");
|
|
164
|
+
});
|
|
165
|
+
});
|