import { afterEach, describe, expect, test, vi } from "vitest"; import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer"; afterEach(() => { vi.restoreAllMocks(); }); // Helpers for building prompt sections. function availableToolsSection(tools: string[]): string { return ["Available tools:", ...tools.map((t) => `- ${t}`)].join("\n"); } function guidelinesSection(guidelines: string[]): string { return ["Guidelines:", ...guidelines.map((g) => `- ${g}`)].join("\n"); } function prompt(...sections: string[]): string { return sections.join("\n\n"); } describe("sanitizeAvailableToolsSection — Available tools section", () => { test("keeps allowed tool lines and the header, drops denied ones", () => { const input = prompt( availableToolsSection(["bash", "read"]), "Other content", ); const result = sanitizeAvailableToolsSection(input, ["read"]); expect(result.removed).toBe(true); expect(result.prompt).toContain("Available tools:"); expect(result.prompt).toContain("- read"); expect(result.prompt).not.toContain("- bash"); }); test("leaves the section untouched when every tool is allowed", () => { const input = prompt( availableToolsSection(["bash", "read"]), "Other content", ); const result = sanitizeAvailableToolsSection(input, ["bash", "read"]); expect(result.removed).toBe(false); expect(result.prompt).toBe(input); }); // Bug #33: findSection extends to lines.length when no subsequent recognised // header follows, so content after the last section is silently deleted. test("preserves content that follows the Available tools section (bug #33)", () => { const input = prompt( availableToolsSection(["bash", "read"]), "Other content", ); const result = sanitizeAvailableToolsSection(input, ["read"]); expect(result.prompt).toContain("Other content"); }); test("removes the whole section when no tool is allowed", () => { const input = prompt( availableToolsSection(["bash", "read"]), "Other content", ); const result = sanitizeAvailableToolsSection(input, []); expect(result.removed).toBe(true); expect(result.prompt).not.toContain("Available tools:"); expect(result.prompt).toContain("Other content"); }); test("removed flag is false when no Available tools section is present", () => { const input = "Just some instructions.\n\nNo tools section."; const result = sanitizeAvailableToolsSection(input, ["bash"]); expect(result.removed).toBe(false); expect(result.prompt).toBe(input); }); test("keeps non-tool boilerplate prose near the section", () => { const input = [ "Available tools:", "- read: Read file contents", "- bash: Run shell commands", "", "In addition to the tools above, you may have access to other custom tools depending on the project.", ].join("\n"); const result = sanitizeAvailableToolsSection(input, ["read"]); expect(result.prompt).toContain("- read: Read file contents"); expect(result.prompt).not.toContain("- bash: Run shell commands"); expect(result.prompt).toContain("In addition to the tools above"); }); test("returns original prompt reference unchanged when nothing is removed", () => { const input = "No tools section here."; const result = sanitizeAvailableToolsSection(input, []); expect(result.prompt).toBe(input); }); test("narrowing the full listing yields the already-narrowed listing (cache byte-stability)", () => { const allowed = ["read", "edit", "write"]; const fullProse = [ "You are an assistant.", "", "Available tools:", "- bash: Run shell commands", "- read: Read file contents", "- edit: Edit a file", "- write: Write a file", "", "Guidelines:", "- use bash for file operations like ls, rg, find", "- use read to examine files instead of cat or sed.", "- Be concise in your responses", ].join("\n"); const narrowedProse = [ "You are an assistant.", "", "Available tools:", "- read: Read file contents", "- edit: Edit a file", "- write: Write a file", "", "Guidelines:", "- use read to examine files instead of cat or sed.", "- Be concise in your responses", ].join("\n"); const fromFull = sanitizeAvailableToolsSection(fullProse, allowed).prompt; const fromNarrowed = sanitizeAvailableToolsSection( narrowedProse, allowed, ).prompt; // Idempotent on the already-narrowed input Pi feeds back on later turns. expect(fromNarrowed).toBe(narrowedProse); // Turn 1 (full) and turn 2+ (narrowed) produce identical wire bytes. expect(fromFull).toBe(fromNarrowed); }); }); describe("sanitizeAvailableToolsSection — Guidelines section", () => { test("removes bash guideline when bash is not in allowed tools", () => { const input = prompt( guidelinesSection(["use bash for file operations like ls, rg, find"]), ); const result = sanitizeAvailableToolsSection(input, []); expect(result.removed).toBe(true); expect(result.prompt).not.toContain("use bash for file operations"); }); test("keeps bash guideline when bash is in allowed tools", () => { const input = prompt( guidelinesSection(["use bash for file operations like ls, rg, find"]), ); const result = sanitizeAvailableToolsSection(input, ["bash"]); expect(result.removed).toBe(false); expect(result.prompt).toContain("use bash for file operations"); }); test("removes read guideline when read is not allowed", () => { const input = prompt( guidelinesSection(["use read to examine files instead of cat or sed."]), ); const result = sanitizeAvailableToolsSection(input, []); expect(result.removed).toBe(true); expect(result.prompt).not.toContain("use read to examine files"); }); test("keeps read guideline when read is allowed", () => { const input = prompt( guidelinesSection(["use read to examine files instead of cat or sed."]), ); const result = sanitizeAvailableToolsSection(input, ["read"]); expect(result.removed).toBe(false); expect(result.prompt).toContain("use read to examine files"); }); test("removes edit guideline when edit is not allowed", () => { const input = prompt( guidelinesSection([ "use edit for precise changes (old text must match exactly)", ]), ); const result = sanitizeAvailableToolsSection(input, []); expect(result.removed).toBe(true); expect(result.prompt).not.toContain("use edit for precise changes"); }); test("removes write guideline when write is not allowed", () => { const input = prompt( guidelinesSection(["use write only for new files or complete rewrites"]), ); const result = sanitizeAvailableToolsSection(input, []); expect(result.removed).toBe(true); expect(result.prompt).not.toContain("use write only for new files"); }); test("removes entire Guidelines section when all bullets are filtered out", () => { const input = prompt( guidelinesSection([ "use bash for file operations like ls, rg, find", "use write only for new files or complete rewrites", ]), ); const result = sanitizeAvailableToolsSection(input, []); expect(result.removed).toBe(true); expect(result.prompt).not.toContain("Guidelines:"); }); test("preserves unrecognised guidelines regardless of allowed tools", () => { const input = prompt( guidelinesSection(["some custom guideline not in the rules"]), ); const result = sanitizeAvailableToolsSection(input, []); expect(result.removed).toBe(false); expect(result.prompt).toContain("some custom guideline not in the rules"); }); test("handles both sections together: removes tools section and filters guidelines", () => { const input = prompt( availableToolsSection(["bash"]), guidelinesSection([ "use bash for file operations like ls, rg, find", "use write only for new files or complete rewrites", "some custom guideline not in the rules", ]), ); const result = sanitizeAvailableToolsSection(input, []); expect(result.removed).toBe(true); expect(result.prompt).not.toContain("Available tools:"); expect(result.prompt).not.toContain("use bash for file operations"); expect(result.prompt).not.toContain("use write only for new files"); expect(result.prompt).toContain("some custom guideline not in the rules"); }); test("trims whitespace from allowed tool names", () => { const input = prompt( guidelinesSection(["use bash for file operations like ls, rg, find"]), ); const result = sanitizeAvailableToolsSection(input, [" bash "]); expect(result.removed).toBe(false); expect(result.prompt).toContain("use bash for file operations"); }); }); describe("sanitizeAvailableToolsSection — multi-section prompt", () => { test("collapses extra blank lines after removal", () => { const input = prompt( "Intro", availableToolsSection(["bash"]), guidelinesSection(["use bash for file operations like ls, rg, find"]), "Closing", ); const result = sanitizeAvailableToolsSection(input, []); // No run of 3+ consecutive newlines expect(result.prompt).not.toMatch(/\n{3,}/); }); }); describe("sanitizeAvailableToolsSection — findSection boundary edge cases", () => { test("preserves content after Guidelines when Guidelines is the last recognised section", () => { const input = prompt( guidelinesSection(["use bash for file operations like ls, rg, find"]), "Trailing custom instructions", ); const result = sanitizeAvailableToolsSection(input, []); expect(result.prompt).toContain("Trailing custom instructions"); }); test("preserves trailing prose when both sections are removed", () => { const input = prompt( availableToolsSection(["bash"]), guidelinesSection(["use bash for file operations like ls, rg, find"]), "Important user note", ); const result = sanitizeAvailableToolsSection(input, []); expect(result.removed).toBe(true); expect(result.prompt).not.toContain("Available tools:"); expect(result.prompt).not.toContain("Guidelines:"); expect(result.prompt).toContain("Important user note"); }); test("section at EOF is removed entirely when no tool is allowed", () => { const input = availableToolsSection(["bash", "read"]); const result = sanitizeAvailableToolsSection(input, []); expect(result.removed).toBe(true); expect(result.prompt).toBe(""); }); test("section followed by blank lines then prose — prose survives removal", () => { const input = ["Available tools:", "- bash", "", "", "Custom note"].join( "\n", ); const result = sanitizeAvailableToolsSection(input, []); expect(result.removed).toBe(true); expect(result.prompt).toContain("Custom note"); expect(result.prompt).not.toContain("Available tools:"); }); }); // --------------------------------------------------------------------------- // Moved from permission-system.test.ts catch-all (#342) // --------------------------------------------------------------------------- test("System prompt sanitizer keeps the active tools in the Available tools section", () => { const prompt = [ "Available tools:", "- read: Read file contents", "- mcp: Discover, inspect, and call MCP tools across configured servers", "", "In addition to the tools above, you may have access to other custom tools depending on the project.", "", "Guidelines:", "- Use mcp for MCP discovery first: search by capability, describe one exact tool name, then call it.", "- Be concise in your responses", ].join("\n"); const result = sanitizeAvailableToolsSection(prompt, ["read", "mcp"]); expect(result.removed).toBe(false); expect(result.prompt).toContain("Available tools:"); expect(result.prompt).toContain("- read: Read file contents"); expect(result.prompt).toContain("- mcp: Discover"); expect(result.prompt).toContain("In addition to the tools above"); expect(result.prompt).toMatch(/Guidelines:/); }); test("System prompt sanitizer drops a denied tool's line but keeps the section", () => { const prompt = [ "Available tools:", "- read: Read file contents", "- mcp: Discover, inspect, and call MCP tools across configured servers", "", "Guidelines:", "- Use mcp for MCP discovery first: search by capability, describe one exact tool name, then call it.", "- Be concise in your responses", ].join("\n"); const result = sanitizeAvailableToolsSection(prompt, ["read"]); expect(result.removed).toBe(true); expect(result.prompt).toContain("Available tools:"); expect(result.prompt).toContain("- read: Read file contents"); expect(result.prompt).not.toContain("- mcp: Discover"); }); test("System prompt sanitizer removes denied tool guidelines while keeping global guidance", () => { const prompt = [ "Guidelines:", "- Use task when work SHOULD be delegated to one or more specialized agents instead of handled entirely in the current session.", "- Use mcp for MCP discovery first: search by capability, describe one exact tool name, then call it.", "- Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)", "- Be concise in your responses", "- Show file paths clearly when working with files", ].join("\n"); const result = sanitizeAvailableToolsSection(prompt, ["bash", "grep", "mcp"]); expect(result.removed).toBe(true); expect(result.prompt).not.toContain("Use task when work SHOULD"); expect(result.prompt).toMatch(/Use mcp for MCP discovery first/i); expect(result.prompt).toMatch(/Prefer grep\/find\/ls tools over bash/i); expect(result.prompt).toMatch(/Be concise in your responses/); expect(result.prompt).toMatch( /Show file paths clearly when working with files/, ); }); test("System prompt sanitizer removes inactive built-in write guidance", () => { const prompt = [ "Guidelines:", "- Use write only for new files or complete rewrites", "- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did", "- Be concise in your responses", ].join("\n"); const result = sanitizeAvailableToolsSection(prompt, ["read"]); expect(result.removed).toBe(true); expect(result.prompt).not.toContain( "Use write only for new files or complete rewrites", ); expect(result.prompt).not.toContain( "do NOT use cat or bash to display what you did", ); expect(result.prompt).toMatch(/Be concise in your responses/); });