@gotgenes/pi-permission-system 8.0.0 → 8.1.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 +13 -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/tool.ts +4 -2
- package/src/handlers/permission-gate-handler.ts +106 -138
- package/src/permission-prompts.ts +5 -2
- 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 +2 -0
- package/test/handlers/external-directory-session-dedup.test.ts +2 -0
- package/test/handlers/gates/tool.test.ts +29 -2
- package/test/handlers/tool-call-events.test.ts +2 -1
- package/test/handlers/tool-call.test.ts +2 -1
- package/test/handlers/validate-requested-tool.test.ts +92 -0
- package/test/permission-prompts.test.ts +66 -38
- package/test/tool-input-preview.test.ts +0 -244
- package/test/tool-preview-formatter.test.ts +385 -0
|
@@ -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 {
|
|
@@ -116,6 +117,7 @@ function makeSession(
|
|
|
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,
|
|
@@ -11,6 +11,7 @@
|
|
|
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";
|
|
@@ -139,6 +140,7 @@ function makeStatefulSession(
|
|
|
139
140
|
getActiveSkillEntries: vi.fn().mockReturnValue([]),
|
|
140
141
|
getInfrastructureDirs: vi.fn().mockReturnValue([]),
|
|
141
142
|
getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
|
|
143
|
+
config: DEFAULT_EXTENSION_CONFIG,
|
|
142
144
|
canPrompt: vi.fn().mockReturnValue(true),
|
|
143
145
|
prompt: vi
|
|
144
146
|
.fn()
|
|
@@ -2,10 +2,24 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import { describeToolGate } from "#src/handlers/gates/tool";
|
|
4
4
|
import type { ToolCallContext } from "#src/handlers/gates/types";
|
|
5
|
+
import {
|
|
6
|
+
TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
|
|
7
|
+
TOOL_INPUT_PREVIEW_MAX_LENGTH,
|
|
8
|
+
TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
|
9
|
+
} from "#src/tool-input-preview";
|
|
10
|
+
import { ToolPreviewFormatter } from "#src/tool-preview-formatter";
|
|
5
11
|
import type { PermissionCheckResult } from "#src/types";
|
|
6
12
|
|
|
7
13
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
8
14
|
|
|
15
|
+
function makeFormatter(): ToolPreviewFormatter {
|
|
16
|
+
return new ToolPreviewFormatter({
|
|
17
|
+
toolInputPreviewMaxLength: TOOL_INPUT_PREVIEW_MAX_LENGTH,
|
|
18
|
+
toolTextSummaryMaxLength: TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
|
19
|
+
toolInputLogPreviewMaxLength: TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
|
|
10
24
|
return {
|
|
11
25
|
toolName: "read",
|
|
@@ -38,6 +52,7 @@ describe("describeToolGate", () => {
|
|
|
38
52
|
const desc = describeToolGate(
|
|
39
53
|
makeTcc({ toolName: "read" }),
|
|
40
54
|
makeCheckResult("ask"),
|
|
55
|
+
makeFormatter(),
|
|
41
56
|
);
|
|
42
57
|
expect(desc.surface).toBe("read");
|
|
43
58
|
expect(desc.decision.surface).toBe("read");
|
|
@@ -47,6 +62,7 @@ describe("describeToolGate", () => {
|
|
|
47
62
|
const desc = describeToolGate(
|
|
48
63
|
makeTcc({ toolName: "write" }),
|
|
49
64
|
makeCheckResult("ask"),
|
|
65
|
+
makeFormatter(),
|
|
50
66
|
);
|
|
51
67
|
expect(desc.decision.value).toBe("write");
|
|
52
68
|
});
|
|
@@ -59,6 +75,7 @@ describe("describeToolGate", () => {
|
|
|
59
75
|
const desc = describeToolGate(
|
|
60
76
|
makeTcc({ toolName: "bash", input: { command: "git status" } }),
|
|
61
77
|
check,
|
|
78
|
+
makeFormatter(),
|
|
62
79
|
);
|
|
63
80
|
expect(desc.surface).toBe("bash");
|
|
64
81
|
expect(desc.decision.surface).toBe("bash");
|
|
@@ -73,6 +90,7 @@ describe("describeToolGate", () => {
|
|
|
73
90
|
const desc = describeToolGate(
|
|
74
91
|
makeTcc({ toolName: "mcp", input: { tool: "server:tool" } }),
|
|
75
92
|
check,
|
|
93
|
+
makeFormatter(),
|
|
76
94
|
);
|
|
77
95
|
expect(desc.surface).toBe("mcp");
|
|
78
96
|
expect(desc.decision.surface).toBe("mcp");
|
|
@@ -81,7 +99,7 @@ describe("describeToolGate", () => {
|
|
|
81
99
|
|
|
82
100
|
it("populates denialContext with kind 'tool' and check result", () => {
|
|
83
101
|
const check = makeCheckResult("deny", { toolName: "read" });
|
|
84
|
-
const desc = describeToolGate(makeTcc(), check);
|
|
102
|
+
const desc = describeToolGate(makeTcc(), check, makeFormatter());
|
|
85
103
|
expect(desc.denialContext).toEqual({
|
|
86
104
|
kind: "tool",
|
|
87
105
|
check,
|
|
@@ -92,7 +110,11 @@ describe("describeToolGate", () => {
|
|
|
92
110
|
|
|
93
111
|
it("populates denialContext with agent name when provided", () => {
|
|
94
112
|
const check = makeCheckResult("ask", { toolName: "read" });
|
|
95
|
-
const desc = describeToolGate(
|
|
113
|
+
const desc = describeToolGate(
|
|
114
|
+
makeTcc({ agentName: "my-agent" }),
|
|
115
|
+
check,
|
|
116
|
+
makeFormatter(),
|
|
117
|
+
);
|
|
96
118
|
expect(desc.denialContext.agentName).toBe("my-agent");
|
|
97
119
|
});
|
|
98
120
|
|
|
@@ -101,6 +123,7 @@ describe("describeToolGate", () => {
|
|
|
101
123
|
const desc = describeToolGate(
|
|
102
124
|
makeTcc({ toolName: "bash", input: { command: "ls" } }),
|
|
103
125
|
check,
|
|
126
|
+
makeFormatter(),
|
|
104
127
|
);
|
|
105
128
|
expect(desc.denialContext).toMatchObject({
|
|
106
129
|
kind: "tool",
|
|
@@ -116,6 +139,7 @@ describe("describeToolGate", () => {
|
|
|
116
139
|
const desc = describeToolGate(
|
|
117
140
|
makeTcc({ toolName: "bash", input: { command: "git status" } }),
|
|
118
141
|
check,
|
|
142
|
+
makeFormatter(),
|
|
119
143
|
);
|
|
120
144
|
expect(desc.sessionApproval).toBeDefined();
|
|
121
145
|
expect(desc.sessionApproval!).toHaveProperty("surface", "bash");
|
|
@@ -127,6 +151,7 @@ describe("describeToolGate", () => {
|
|
|
127
151
|
const desc = describeToolGate(
|
|
128
152
|
makeTcc({ toolName: "read", agentName: "my-agent", toolCallId: "tc-42" }),
|
|
129
153
|
check,
|
|
154
|
+
makeFormatter(),
|
|
130
155
|
);
|
|
131
156
|
expect(desc.promptDetails).toMatchObject({
|
|
132
157
|
source: "tool_call",
|
|
@@ -143,6 +168,7 @@ describe("describeToolGate", () => {
|
|
|
143
168
|
const desc = describeToolGate(
|
|
144
169
|
makeTcc({ toolName: "bash", input: { command: "ls" } }),
|
|
145
170
|
check,
|
|
171
|
+
makeFormatter(),
|
|
146
172
|
);
|
|
147
173
|
expect(desc.logContext).toMatchObject({
|
|
148
174
|
source: "tool_call",
|
|
@@ -155,6 +181,7 @@ describe("describeToolGate", () => {
|
|
|
155
181
|
const desc = describeToolGate(
|
|
156
182
|
makeTcc({ toolName: "edit", input: { path: "/a.ts" } }),
|
|
157
183
|
makeCheckResult("ask", { toolName: "edit" }),
|
|
184
|
+
makeFormatter(),
|
|
158
185
|
);
|
|
159
186
|
expect(desc.surface).toBe("edit");
|
|
160
187
|
expect(desc.input).toEqual({ path: "/a.ts" });
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { describe, expect, it, vi } from "vitest";
|
|
7
|
-
|
|
7
|
+
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
8
8
|
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
|
|
9
9
|
import type { PermissionDecisionEvent } from "#src/permission-events";
|
|
10
10
|
import { PERMISSIONS_DECISION_CHANNEL } from "#src/permission-events";
|
|
@@ -83,6 +83,7 @@ function makeSession(
|
|
|
83
83
|
.fn()
|
|
84
84
|
.mockReturnValue(["/test/agent", "/test/agent/git"]),
|
|
85
85
|
getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
|
|
86
|
+
config: DEFAULT_EXTENSION_CONFIG,
|
|
86
87
|
canPrompt: vi.fn().mockReturnValue(true),
|
|
87
88
|
prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
|
|
88
89
|
...overrides,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { describe, expect, it, vi } from "vitest";
|
|
3
|
-
|
|
3
|
+
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
4
4
|
import {
|
|
5
5
|
getEventInput,
|
|
6
6
|
PermissionGateHandler,
|
|
@@ -74,6 +74,7 @@ function makeSession(
|
|
|
74
74
|
.fn()
|
|
75
75
|
.mockReturnValue(["/test/agent", "/test/agent/git"]),
|
|
76
76
|
getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
|
|
77
|
+
config: DEFAULT_EXTENSION_CONFIG,
|
|
77
78
|
canPrompt: vi.fn().mockReturnValue(true),
|
|
78
79
|
prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
|
|
79
80
|
...overrides,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type RequestedToolValidation,
|
|
5
|
+
validateRequestedTool,
|
|
6
|
+
} from "#src/handlers/permission-gate-handler";
|
|
7
|
+
|
|
8
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
function makeTools(names: string[]): { name: string }[] {
|
|
11
|
+
return names.map((name) => ({ name }));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const TOOLS = makeTools(["read", "bash", "edit"]);
|
|
15
|
+
|
|
16
|
+
// ── validateRequestedTool ──────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
describe("validateRequestedTool", () => {
|
|
19
|
+
describe("missing / unresolvable tool name", () => {
|
|
20
|
+
it("blocks when event has no name field", () => {
|
|
21
|
+
const result = validateRequestedTool({ type: "tool_call" }, TOOLS);
|
|
22
|
+
expect(result.status).toBe("block");
|
|
23
|
+
expect(
|
|
24
|
+
(result as Extract<RequestedToolValidation, { status: "block" }>)
|
|
25
|
+
.reason,
|
|
26
|
+
).toBeTruthy();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("blocks when name field is an empty string", () => {
|
|
30
|
+
const result = validateRequestedTool({ name: "" }, TOOLS);
|
|
31
|
+
expect(result.status).toBe("block");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("blocks when name field is null", () => {
|
|
35
|
+
const result = validateRequestedTool({ name: null }, TOOLS);
|
|
36
|
+
expect(result.status).toBe("block");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("blocks when event is a primitive", () => {
|
|
40
|
+
const result = validateRequestedTool("not-an-object", TOOLS);
|
|
41
|
+
expect(result.status).toBe("block");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("unregistered tool", () => {
|
|
46
|
+
it("blocks when the tool name is not in the registered list", () => {
|
|
47
|
+
const result = validateRequestedTool({ name: "unknown-tool" }, TOOLS);
|
|
48
|
+
expect(result.status).toBe("block");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("includes available tool names in the block reason", () => {
|
|
52
|
+
const result = validateRequestedTool({ name: "unknown-tool" }, TOOLS);
|
|
53
|
+
expect(result.status).toBe("block");
|
|
54
|
+
const { reason } = result as Extract<
|
|
55
|
+
RequestedToolValidation,
|
|
56
|
+
{ status: "block" }
|
|
57
|
+
>;
|
|
58
|
+
expect(reason).toContain("read");
|
|
59
|
+
expect(reason).toContain("bash");
|
|
60
|
+
expect(reason).toContain("edit");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("blocks with empty available list when no tools are registered", () => {
|
|
64
|
+
const result = validateRequestedTool({ name: "anything" }, []);
|
|
65
|
+
expect(result.status).toBe("block");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("registered tool (ok path)", () => {
|
|
70
|
+
it("returns ok with the raw tool name for a known tool", () => {
|
|
71
|
+
const result = validateRequestedTool({ name: "read" }, TOOLS);
|
|
72
|
+
expect(result).toEqual({ status: "ok", toolName: "read" });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns the raw name as it appeared in the event (not normalised)", () => {
|
|
76
|
+
// If an alias mechanism were to normalise "Read" → "read",
|
|
77
|
+
// validateRequestedTool still returns the raw value from the event.
|
|
78
|
+
// Without aliases the raw name and registered name are the same; this
|
|
79
|
+
// asserts the contract that toolName comes from the event, not from the
|
|
80
|
+
// registration lookup's normalizedToolName field.
|
|
81
|
+
const result = validateRequestedTool({ name: "bash" }, TOOLS);
|
|
82
|
+
expect(result).toEqual({ status: "ok", toolName: "bash" });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("resolves tool name via the `arguments` field naming convention", () => {
|
|
86
|
+
// getToolNameFromValue reads `.name` then falls back to other fields;
|
|
87
|
+
// a plain `{ name: "edit" }` event is sufficient here.
|
|
88
|
+
const result = validateRequestedTool({ name: "edit" }, TOOLS);
|
|
89
|
+
expect(result).toEqual({ status: "ok", toolName: "edit" });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|