@gotgenes/pi-permission-system 3.0.0 → 3.0.2
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 +20 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +96 -12
- package/src/active-agent.ts +58 -0
- package/src/external-directory.ts +113 -0
- package/src/forwarded-permissions/io.ts +328 -0
- package/src/forwarded-permissions/polling.ts +334 -0
- package/src/index.ts +59 -1062
- package/src/permission-prompts.ts +131 -0
- package/src/subagent-context.ts +52 -0
- package/src/tool-input-preview.ts +206 -0
- package/tests/active-agent.test.ts +160 -0
- package/tests/bash-filter.test.ts +142 -0
- package/tests/common.test.ts +189 -0
- package/tests/external-directory.test.ts +254 -0
- package/tests/permission-prompts.test.ts +304 -0
- package/tests/skill-prompt-sanitizer.test.ts +244 -0
- package/tests/subagent-context.test.ts +124 -0
- package/tests/system-prompt-sanitizer.test.ts +186 -0
- package/tests/tool-input-preview.test.ts +455 -0
- package/tests/tool-registry.test.ts +155 -0
- package/tests/wildcard-matcher.test.ts +180 -0
- package/tests/yolo-mode.test.ts +110 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { sanitizeAvailableToolsSection } from "../src/system-prompt-sanitizer.js";
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.restoreAllMocks();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// Helpers for building prompt sections.
|
|
10
|
+
function availableToolsSection(tools: string[]): string {
|
|
11
|
+
return ["Available tools:", ...tools.map((t) => `- ${t}`)].join("\n");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function guidelinesSection(guidelines: string[]): string {
|
|
15
|
+
return ["Guidelines:", ...guidelines.map((g) => `- ${g}`)].join("\n");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function prompt(...sections: string[]): string {
|
|
19
|
+
return sections.join("\n\n");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("sanitizeAvailableToolsSection — Available tools section", () => {
|
|
23
|
+
test("sets removed:true and strips the Available tools header", () => {
|
|
24
|
+
const input = prompt(
|
|
25
|
+
availableToolsSection(["bash", "read"]),
|
|
26
|
+
"Other content",
|
|
27
|
+
);
|
|
28
|
+
const result = sanitizeAvailableToolsSection(input, ["bash", "read"]);
|
|
29
|
+
expect(result.removed).toBe(true);
|
|
30
|
+
expect(result.prompt).not.toContain("Available tools:");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Bug #33: findSection extends to lines.length when no subsequent recognised
|
|
34
|
+
// header follows, so content after the last section is silently deleted.
|
|
35
|
+
test.fails("preserves content that follows the Available tools section (bug #33)", () => {
|
|
36
|
+
const input = prompt(
|
|
37
|
+
availableToolsSection(["bash", "read"]),
|
|
38
|
+
"Other content",
|
|
39
|
+
);
|
|
40
|
+
const result = sanitizeAvailableToolsSection(input, ["bash", "read"]);
|
|
41
|
+
expect(result.prompt).toContain("Other content");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("removed flag is false when no Available tools section is present", () => {
|
|
45
|
+
const input = "Just some instructions.\n\nNo tools section.";
|
|
46
|
+
const result = sanitizeAvailableToolsSection(input, ["bash"]);
|
|
47
|
+
expect(result.removed).toBe(false);
|
|
48
|
+
expect(result.prompt).toBe(input);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("removes only the tools section and leaves other sections intact", () => {
|
|
52
|
+
const input = prompt(
|
|
53
|
+
"Preamble text",
|
|
54
|
+
availableToolsSection(["bash"]),
|
|
55
|
+
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
|
56
|
+
);
|
|
57
|
+
const result = sanitizeAvailableToolsSection(input, ["bash"]);
|
|
58
|
+
expect(result.prompt).not.toContain("Available tools:");
|
|
59
|
+
expect(result.prompt).toContain("Guidelines:");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("returns original prompt reference unchanged when nothing is removed", () => {
|
|
63
|
+
const input = "No tools section here.";
|
|
64
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
65
|
+
expect(result.prompt).toBe(input);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("sanitizeAvailableToolsSection — Guidelines section", () => {
|
|
70
|
+
test("removes bash guideline when bash is not in allowed tools", () => {
|
|
71
|
+
const input = prompt(
|
|
72
|
+
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
|
73
|
+
);
|
|
74
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
75
|
+
expect(result.removed).toBe(true);
|
|
76
|
+
expect(result.prompt).not.toContain("use bash for file operations");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("keeps bash guideline when bash is in allowed tools", () => {
|
|
80
|
+
const input = prompt(
|
|
81
|
+
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
|
82
|
+
);
|
|
83
|
+
const result = sanitizeAvailableToolsSection(input, ["bash"]);
|
|
84
|
+
expect(result.removed).toBe(false);
|
|
85
|
+
expect(result.prompt).toContain("use bash for file operations");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("removes read guideline when read is not allowed", () => {
|
|
89
|
+
const input = prompt(
|
|
90
|
+
guidelinesSection(["use read to examine files instead of cat or sed."]),
|
|
91
|
+
);
|
|
92
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
93
|
+
expect(result.removed).toBe(true);
|
|
94
|
+
expect(result.prompt).not.toContain("use read to examine files");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("keeps read guideline when read is allowed", () => {
|
|
98
|
+
const input = prompt(
|
|
99
|
+
guidelinesSection(["use read to examine files instead of cat or sed."]),
|
|
100
|
+
);
|
|
101
|
+
const result = sanitizeAvailableToolsSection(input, ["read"]);
|
|
102
|
+
expect(result.removed).toBe(false);
|
|
103
|
+
expect(result.prompt).toContain("use read to examine files");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("removes edit guideline when edit is not allowed", () => {
|
|
107
|
+
const input = prompt(
|
|
108
|
+
guidelinesSection([
|
|
109
|
+
"use edit for precise changes (old text must match exactly)",
|
|
110
|
+
]),
|
|
111
|
+
);
|
|
112
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
113
|
+
expect(result.removed).toBe(true);
|
|
114
|
+
expect(result.prompt).not.toContain("use edit for precise changes");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("removes write guideline when write is not allowed", () => {
|
|
118
|
+
const input = prompt(
|
|
119
|
+
guidelinesSection(["use write only for new files or complete rewrites"]),
|
|
120
|
+
);
|
|
121
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
122
|
+
expect(result.removed).toBe(true);
|
|
123
|
+
expect(result.prompt).not.toContain("use write only for new files");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("removes entire Guidelines section when all bullets are filtered out", () => {
|
|
127
|
+
const input = prompt(
|
|
128
|
+
guidelinesSection([
|
|
129
|
+
"use bash for file operations like ls, rg, find",
|
|
130
|
+
"use write only for new files or complete rewrites",
|
|
131
|
+
]),
|
|
132
|
+
);
|
|
133
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
134
|
+
expect(result.removed).toBe(true);
|
|
135
|
+
expect(result.prompt).not.toContain("Guidelines:");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("preserves unrecognised guidelines regardless of allowed tools", () => {
|
|
139
|
+
const input = prompt(
|
|
140
|
+
guidelinesSection(["some custom guideline not in the rules"]),
|
|
141
|
+
);
|
|
142
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
143
|
+
expect(result.removed).toBe(false);
|
|
144
|
+
expect(result.prompt).toContain("some custom guideline not in the rules");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("handles both sections together: removes tools section and filters guidelines", () => {
|
|
148
|
+
const input = prompt(
|
|
149
|
+
availableToolsSection(["bash"]),
|
|
150
|
+
guidelinesSection([
|
|
151
|
+
"use bash for file operations like ls, rg, find",
|
|
152
|
+
"use write only for new files or complete rewrites",
|
|
153
|
+
"some custom guideline not in the rules",
|
|
154
|
+
]),
|
|
155
|
+
);
|
|
156
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
157
|
+
expect(result.removed).toBe(true);
|
|
158
|
+
expect(result.prompt).not.toContain("Available tools:");
|
|
159
|
+
expect(result.prompt).not.toContain("use bash for file operations");
|
|
160
|
+
expect(result.prompt).not.toContain("use write only for new files");
|
|
161
|
+
expect(result.prompt).toContain("some custom guideline not in the rules");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("trims whitespace from allowed tool names", () => {
|
|
165
|
+
const input = prompt(
|
|
166
|
+
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
|
167
|
+
);
|
|
168
|
+
const result = sanitizeAvailableToolsSection(input, [" bash "]);
|
|
169
|
+
expect(result.removed).toBe(false);
|
|
170
|
+
expect(result.prompt).toContain("use bash for file operations");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("sanitizeAvailableToolsSection — multi-section prompt", () => {
|
|
175
|
+
test("collapses extra blank lines after removal", () => {
|
|
176
|
+
const input = prompt(
|
|
177
|
+
"Intro",
|
|
178
|
+
availableToolsSection(["bash"]),
|
|
179
|
+
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
|
180
|
+
"Closing",
|
|
181
|
+
);
|
|
182
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
183
|
+
// No run of 3+ consecutive newlines
|
|
184
|
+
expect(result.prompt).not.toMatch(/\n{3,}/);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock logging collaborator before importing the module under test.
|
|
4
|
+
vi.mock("../src/logging.js", () => ({
|
|
5
|
+
safeJsonStringify: vi.fn((value: unknown) => JSON.stringify(value)),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
import { safeJsonStringify } from "../src/logging.js";
|
|
9
|
+
import {
|
|
10
|
+
countTextLines,
|
|
11
|
+
formatCount,
|
|
12
|
+
formatEditInputForPrompt,
|
|
13
|
+
formatGenericToolInputForLog,
|
|
14
|
+
formatReadInputForPrompt,
|
|
15
|
+
formatSearchInputForPrompt,
|
|
16
|
+
formatToolInputForPrompt,
|
|
17
|
+
formatWriteInputForPrompt,
|
|
18
|
+
getPermissionLogContext,
|
|
19
|
+
getPromptPath,
|
|
20
|
+
getToolInputPreviewForLog,
|
|
21
|
+
sanitizeInlineText,
|
|
22
|
+
serializeToolInputPreview,
|
|
23
|
+
TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
|
|
24
|
+
TOOL_INPUT_PREVIEW_MAX_LENGTH,
|
|
25
|
+
TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
|
26
|
+
truncateInlineText,
|
|
27
|
+
} from "../src/tool-input-preview.js";
|
|
28
|
+
import type { PermissionCheckResult } from "../src/types.js";
|
|
29
|
+
|
|
30
|
+
const mockedStringify = vi.mocked(safeJsonStringify);
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
mockedStringify.mockReset();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.restoreAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("constants", () => {
|
|
41
|
+
test("TOOL_INPUT_PREVIEW_MAX_LENGTH is 200", () => {
|
|
42
|
+
expect(TOOL_INPUT_PREVIEW_MAX_LENGTH).toBe(200);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH is 1000", () => {
|
|
46
|
+
expect(TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH).toBe(1000);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("TOOL_TEXT_SUMMARY_MAX_LENGTH is 80", () => {
|
|
50
|
+
expect(TOOL_TEXT_SUMMARY_MAX_LENGTH).toBe(80);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("truncateInlineText", () => {
|
|
55
|
+
test("returns text unchanged when within maxLength", () => {
|
|
56
|
+
expect(truncateInlineText("hello", 10)).toBe("hello");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("does not truncate when length equals maxLength", () => {
|
|
60
|
+
const text = "a".repeat(200);
|
|
61
|
+
expect(truncateInlineText(text, 200)).toBe(text);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("truncates and appends ellipsis when length exceeds maxLength", () => {
|
|
65
|
+
const text = "a".repeat(201);
|
|
66
|
+
const result = truncateInlineText(text, 200);
|
|
67
|
+
expect(result).toBe(`${"a".repeat(200)}…`);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("truncates long text and appends ellipsis", () => {
|
|
71
|
+
const result = truncateInlineText("abcdef", 3);
|
|
72
|
+
expect(result).toBe("abc…");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("sanitizeInlineText", () => {
|
|
77
|
+
test("collapses whitespace and trims", () => {
|
|
78
|
+
expect(sanitizeInlineText(" hello world ")).toBe("hello world");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("returns 'empty text' for blank string", () => {
|
|
82
|
+
expect(sanitizeInlineText("")).toBe("empty text");
|
|
83
|
+
expect(sanitizeInlineText(" ")).toBe("empty text");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("truncates to default TOOL_TEXT_SUMMARY_MAX_LENGTH", () => {
|
|
87
|
+
const long = "x".repeat(100);
|
|
88
|
+
const result = sanitizeInlineText(long);
|
|
89
|
+
expect(result.length).toBeLessThanOrEqual(TOOL_TEXT_SUMMARY_MAX_LENGTH + 1); // +1 for ellipsis char
|
|
90
|
+
expect(result).toContain("…");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("respects custom maxLength", () => {
|
|
94
|
+
const result = sanitizeInlineText("hello world", 5);
|
|
95
|
+
expect(result).toBe("hello…");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("countTextLines", () => {
|
|
100
|
+
test("returns 0 for empty string", () => {
|
|
101
|
+
expect(countTextLines("")).toBe(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("returns 1 for a single line with no newline", () => {
|
|
105
|
+
expect(countTextLines("hello")).toBe(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("counts LF-separated lines", () => {
|
|
109
|
+
expect(countTextLines("line1\nline2\nline3")).toBe(3);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("counts CRLF-separated lines", () => {
|
|
113
|
+
expect(countTextLines("line1\r\nline2")).toBe(2);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("counts CR-separated lines", () => {
|
|
117
|
+
expect(countTextLines("line1\rline2")).toBe(2);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("formatCount", () => {
|
|
122
|
+
test("uses singular form for 1", () => {
|
|
123
|
+
expect(formatCount(1, "line", "lines")).toBe("1 line");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("uses plural form for 0", () => {
|
|
127
|
+
expect(formatCount(0, "line", "lines")).toBe("0 lines");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("uses plural form for 2+", () => {
|
|
131
|
+
expect(formatCount(3, "line", "lines")).toBe("3 lines");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("getPromptPath", () => {
|
|
136
|
+
test("returns path from 'path' key", () => {
|
|
137
|
+
expect(getPromptPath({ path: "/foo/bar" })).toBe("/foo/bar");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("falls back to 'file_path' key", () => {
|
|
141
|
+
expect(getPromptPath({ file_path: "/baz" })).toBe("/baz");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("returns null when neither key is present", () => {
|
|
145
|
+
expect(getPromptPath({})).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("returns null when path is empty string", () => {
|
|
149
|
+
expect(getPromptPath({ path: "" })).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("formatEditInputForPrompt", () => {
|
|
154
|
+
test("returns path-only description when no edits provided", () => {
|
|
155
|
+
const result = formatEditInputForPrompt({ path: "/foo.ts" });
|
|
156
|
+
expect(result).toBe("for '/foo.ts' with edit input");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("formats single replacement with line counts", () => {
|
|
160
|
+
const result = formatEditInputForPrompt({
|
|
161
|
+
path: "/foo.ts",
|
|
162
|
+
edits: [{ oldText: "line1\nline2", newText: "replaced" }],
|
|
163
|
+
});
|
|
164
|
+
expect(result).toContain("for '/foo.ts'");
|
|
165
|
+
expect(result).toContain("1 replacement");
|
|
166
|
+
expect(result).toContain("2 lines");
|
|
167
|
+
expect(result).toContain("1 line");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("formats multiple replacements mentioning additional edits", () => {
|
|
171
|
+
const result = formatEditInputForPrompt({
|
|
172
|
+
path: "/foo.ts",
|
|
173
|
+
edits: [
|
|
174
|
+
{ oldText: "a", newText: "b" },
|
|
175
|
+
{ oldText: "c", newText: "d" },
|
|
176
|
+
{ oldText: "e", newText: "f" },
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
expect(result).toContain("3 replacements");
|
|
180
|
+
expect(result).toContain("2 additional edits");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("falls back to oldText/newText when no edits array", () => {
|
|
184
|
+
const result = formatEditInputForPrompt({
|
|
185
|
+
path: "/bar.ts",
|
|
186
|
+
oldText: "old",
|
|
187
|
+
newText: "new",
|
|
188
|
+
});
|
|
189
|
+
expect(result).toContain("for '/bar.ts'");
|
|
190
|
+
expect(result).toContain("1 replacement");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("works without a path", () => {
|
|
194
|
+
const result = formatEditInputForPrompt({
|
|
195
|
+
edits: [{ oldText: "x", newText: "y" }],
|
|
196
|
+
});
|
|
197
|
+
expect(result).not.toContain("for '");
|
|
198
|
+
expect(result).toContain("1 replacement");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("formatWriteInputForPrompt", () => {
|
|
203
|
+
test("includes path, line count, and character count", () => {
|
|
204
|
+
const result = formatWriteInputForPrompt({
|
|
205
|
+
path: "/out.ts",
|
|
206
|
+
content: "line1\nline2",
|
|
207
|
+
});
|
|
208
|
+
expect(result).toContain("for '/out.ts'");
|
|
209
|
+
expect(result).toContain("2 lines");
|
|
210
|
+
expect(result).toContain("11 characters");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("handles missing content as empty", () => {
|
|
214
|
+
const result = formatWriteInputForPrompt({ path: "/out.ts" });
|
|
215
|
+
expect(result).toContain("0 lines");
|
|
216
|
+
expect(result).toContain("0 characters");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("formatReadInputForPrompt", () => {
|
|
221
|
+
test("includes path", () => {
|
|
222
|
+
expect(formatReadInputForPrompt({ path: "/src/foo.ts" })).toBe(
|
|
223
|
+
"for path '/src/foo.ts'",
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("includes offset and limit when present", () => {
|
|
228
|
+
const result = formatReadInputForPrompt({
|
|
229
|
+
path: "/x",
|
|
230
|
+
offset: 10,
|
|
231
|
+
limit: 50,
|
|
232
|
+
});
|
|
233
|
+
expect(result).toContain("offset 10");
|
|
234
|
+
expect(result).toContain("limit 50");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("returns empty string when no path and no options", () => {
|
|
238
|
+
expect(formatReadInputForPrompt({})).toBe("");
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("formatSearchInputForPrompt", () => {
|
|
243
|
+
test("includes pattern and path", () => {
|
|
244
|
+
const result = formatSearchInputForPrompt("grep", {
|
|
245
|
+
pattern: "TODO",
|
|
246
|
+
path: "/src",
|
|
247
|
+
});
|
|
248
|
+
expect(result).toContain("pattern 'TODO'");
|
|
249
|
+
expect(result).toContain("path '/src'");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("includes glob when present", () => {
|
|
253
|
+
const result = formatSearchInputForPrompt("find", { glob: "*.ts" });
|
|
254
|
+
expect(result).toContain("glob '*.ts'");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("uses 'current working directory' for find/grep/ls without path", () => {
|
|
258
|
+
for (const toolName of ["find", "grep", "ls"]) {
|
|
259
|
+
const result = formatSearchInputForPrompt(toolName, {});
|
|
260
|
+
expect(result).toContain("current working directory");
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("returns empty string for other tools with no input", () => {
|
|
265
|
+
expect(formatSearchInputForPrompt("other", {})).toBe("");
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("serializeToolInputPreview", () => {
|
|
270
|
+
test("delegates serialization to safeJsonStringify", () => {
|
|
271
|
+
mockedStringify.mockReturnValue('{"key":"value"}');
|
|
272
|
+
const result = serializeToolInputPreview({ key: "value" });
|
|
273
|
+
expect(mockedStringify).toHaveBeenCalledWith({ key: "value" });
|
|
274
|
+
expect(result).toBe('{"key":"value"}');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("returns empty string when safeJsonStringify returns undefined", () => {
|
|
278
|
+
mockedStringify.mockReturnValue(undefined);
|
|
279
|
+
expect(serializeToolInputPreview({})).toBe("");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("returns empty string when serialized value is '{}'", () => {
|
|
283
|
+
mockedStringify.mockReturnValue("{}");
|
|
284
|
+
expect(serializeToolInputPreview({})).toBe("");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("returns empty string when serialized value is 'null'", () => {
|
|
288
|
+
mockedStringify.mockReturnValue("null");
|
|
289
|
+
expect(serializeToolInputPreview(null)).toBe("");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("collapses whitespace in serialized output", () => {
|
|
293
|
+
mockedStringify.mockReturnValue('{\n "key": "val"\n}');
|
|
294
|
+
const result = serializeToolInputPreview({});
|
|
295
|
+
expect(result).toBe('{ "key": "val" }');
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("formatToolInputForPrompt", () => {
|
|
300
|
+
test("dispatches 'edit' to formatEditInputForPrompt", () => {
|
|
301
|
+
mockedStringify.mockReturnValue(undefined);
|
|
302
|
+
const result = formatToolInputForPrompt("edit", {
|
|
303
|
+
path: "/foo.ts",
|
|
304
|
+
edits: [],
|
|
305
|
+
});
|
|
306
|
+
expect(result).toContain("for '/foo.ts'");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("dispatches 'write' to formatWriteInputForPrompt", () => {
|
|
310
|
+
const result = formatToolInputForPrompt("write", {
|
|
311
|
+
path: "/out.ts",
|
|
312
|
+
content: "hi",
|
|
313
|
+
});
|
|
314
|
+
expect(result).toContain("for '/out.ts'");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("dispatches 'read' to formatReadInputForPrompt", () => {
|
|
318
|
+
const result = formatToolInputForPrompt("read", { path: "/src/x.ts" });
|
|
319
|
+
expect(result).toContain("path '/src/x.ts'");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("dispatches 'find'/'grep'/'ls' to formatSearchInputForPrompt", () => {
|
|
323
|
+
for (const tool of ["find", "grep", "ls"]) {
|
|
324
|
+
const result = formatToolInputForPrompt(tool, {});
|
|
325
|
+
expect(result).toContain("current working directory");
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("falls back to JSON preview for unknown tools", () => {
|
|
330
|
+
mockedStringify.mockReturnValue('{"x":1}');
|
|
331
|
+
const result = formatToolInputForPrompt("unknown", { x: 1 });
|
|
332
|
+
expect(result).toContain('{"x":1}');
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe("formatGenericToolInputForLog", () => {
|
|
337
|
+
test("returns undefined when serialization yields empty string", () => {
|
|
338
|
+
mockedStringify.mockReturnValue(undefined);
|
|
339
|
+
expect(formatGenericToolInputForLog({})).toBeUndefined();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("returns prefixed input preview", () => {
|
|
343
|
+
mockedStringify.mockReturnValue('{"k":"v"}');
|
|
344
|
+
expect(formatGenericToolInputForLog({ k: "v" })).toBe('input {"k":"v"}');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("truncates to TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH", () => {
|
|
348
|
+
const longJson = `{"k":"${"x".repeat(2000)}"}`;
|
|
349
|
+
mockedStringify.mockReturnValue(longJson);
|
|
350
|
+
const result = formatGenericToolInputForLog({});
|
|
351
|
+
expect(result).toBeDefined();
|
|
352
|
+
// result is "input " + truncated, so total > TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH by "input ".length
|
|
353
|
+
const preview = result!.slice("input ".length);
|
|
354
|
+
expect(preview.length).toBeLessThanOrEqual(
|
|
355
|
+
TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH + 1,
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe("getToolInputPreviewForLog", () => {
|
|
361
|
+
const pathBearingTools = new Set(["read", "write", "edit"]);
|
|
362
|
+
|
|
363
|
+
test("returns undefined for bash tool", () => {
|
|
364
|
+
const result: PermissionCheckResult = {
|
|
365
|
+
toolName: "bash",
|
|
366
|
+
state: "allow",
|
|
367
|
+
source: "tool",
|
|
368
|
+
};
|
|
369
|
+
expect(
|
|
370
|
+
getToolInputPreviewForLog(result, { command: "ls" }, pathBearingTools),
|
|
371
|
+
).toBeUndefined();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("returns undefined for mcp tool", () => {
|
|
375
|
+
const result: PermissionCheckResult = {
|
|
376
|
+
toolName: "mcp",
|
|
377
|
+
state: "allow",
|
|
378
|
+
source: "tool",
|
|
379
|
+
};
|
|
380
|
+
expect(
|
|
381
|
+
getToolInputPreviewForLog(result, {}, pathBearingTools),
|
|
382
|
+
).toBeUndefined();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("returns undefined for mcp source", () => {
|
|
386
|
+
const result: PermissionCheckResult = {
|
|
387
|
+
toolName: "some-server:some-tool",
|
|
388
|
+
state: "allow",
|
|
389
|
+
source: "mcp",
|
|
390
|
+
};
|
|
391
|
+
expect(
|
|
392
|
+
getToolInputPreviewForLog(result, {}, pathBearingTools),
|
|
393
|
+
).toBeUndefined();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("returns path-based preview for path-bearing tools", () => {
|
|
397
|
+
const result: PermissionCheckResult = {
|
|
398
|
+
toolName: "read",
|
|
399
|
+
state: "allow",
|
|
400
|
+
source: "tool",
|
|
401
|
+
};
|
|
402
|
+
const preview = getToolInputPreviewForLog(
|
|
403
|
+
result,
|
|
404
|
+
{ path: "/src/foo.ts" },
|
|
405
|
+
pathBearingTools,
|
|
406
|
+
);
|
|
407
|
+
expect(preview).toContain("/src/foo.ts");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("returns generic JSON preview for non-path-bearing tools", () => {
|
|
411
|
+
mockedStringify.mockReturnValue('{"n":1}');
|
|
412
|
+
const result: PermissionCheckResult = {
|
|
413
|
+
toolName: "task",
|
|
414
|
+
state: "allow",
|
|
415
|
+
source: "tool",
|
|
416
|
+
};
|
|
417
|
+
const preview = getToolInputPreviewForLog(
|
|
418
|
+
result,
|
|
419
|
+
{ n: 1 },
|
|
420
|
+
pathBearingTools,
|
|
421
|
+
);
|
|
422
|
+
expect(preview).toContain('{"n":1}');
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe("getPermissionLogContext", () => {
|
|
427
|
+
const pathBearingTools = new Set(["read", "write", "edit"]);
|
|
428
|
+
|
|
429
|
+
test("returns command, target, and toolInputPreview", () => {
|
|
430
|
+
const result: PermissionCheckResult = {
|
|
431
|
+
toolName: "bash",
|
|
432
|
+
state: "allow",
|
|
433
|
+
source: "tool",
|
|
434
|
+
command: "ls -la",
|
|
435
|
+
};
|
|
436
|
+
const ctx = getPermissionLogContext(result, {}, pathBearingTools);
|
|
437
|
+
expect(ctx.command).toBe("ls -la");
|
|
438
|
+
expect(ctx.target).toBeUndefined();
|
|
439
|
+
expect(ctx.toolInputPreview).toBeUndefined();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("includes toolInputPreview for non-bash path-bearing tools", () => {
|
|
443
|
+
const result: PermissionCheckResult = {
|
|
444
|
+
toolName: "read",
|
|
445
|
+
state: "allow",
|
|
446
|
+
source: "tool",
|
|
447
|
+
};
|
|
448
|
+
const ctx = getPermissionLogContext(
|
|
449
|
+
result,
|
|
450
|
+
{ path: "/foo.ts" },
|
|
451
|
+
pathBearingTools,
|
|
452
|
+
);
|
|
453
|
+
expect(ctx.toolInputPreview).toContain("/foo.ts");
|
|
454
|
+
});
|
|
455
|
+
});
|