From b582a6b70dd6541df1902d924dedfe8a7512a89b Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 6 Jan 2026 00:20:44 -0600 Subject: [PATCH 1/2] feat(coding-agent): add blockImages setting to prevent image uploads --- packages/coding-agent/CHANGELOG.md | 4 + .../coding-agent/src/cli/file-processor.ts | 7 + .../coding-agent/src/core/agent-session.ts | 8 +- packages/coding-agent/src/core/sdk.ts | 35 ++++- .../coding-agent/src/core/settings-manager.ts | 18 +++ packages/coding-agent/src/core/tools/read.ts | 53 ++++--- .../components/settings-selector.ts | 15 ++ .../src/modes/interactive/interactive-mode.ts | 4 + .../coding-agent/test/block-images.test.ts | 147 ++++++++++++++++++ 9 files changed, 266 insertions(+), 25 deletions(-) create mode 100644 packages/coding-agent/test/block-images.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4ed345af..626c56d7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `blockImages` setting to prevent images from being sent to LLM providers. Provides defense-in-depth blocking at Read tool, CLI file processor, AgentSession, and convertToLlm layer. ([#492](https://github.com/badlogic/pi-mono/pull/492)) + ## [0.37.2] - 2026-01-05 ### Fixed diff --git a/packages/coding-agent/src/cli/file-processor.ts b/packages/coding-agent/src/cli/file-processor.ts index 8b676419..6f786df8 100644 --- a/packages/coding-agent/src/cli/file-processor.ts +++ b/packages/coding-agent/src/cli/file-processor.ts @@ -18,6 +18,8 @@ export interface ProcessedFiles { export interface ProcessFileOptions { /** Whether to auto-resize images to 2000x2000 max. Default: true */ autoResizeImages?: boolean; + /** When true, skip image files with warning. Default: false */ + blockImages?: boolean; } /** Process @file arguments into text content and image attachments */ @@ -48,6 +50,11 @@ export async function processFileArguments(fileArgs: string[], options?: Process const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath); if (mimeType) { + // Check if images are blocked + if (options?.blockImages) { + console.warn(chalk.yellow(`[blockImages] Skipping image file: ${absolutePath}`)); + continue; + } // Handle image file const content = await readFile(absolutePath); const base64Content = content.toString("base64"); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 92194eaa..3afd4418 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -592,7 +592,13 @@ export class AgentSession { // Add user message const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }]; if (options?.images) { - userContent.push(...options.images); + const blockImages = this.settingsManager.getBlockImages(); + if (blockImages) { + // Log warning for blocked images + console.warn(`[blockImages] Blocked ${options.images.length} image(s) from being sent to provider`); + } else { + userContent.push(...options.images); + } } messages.push({ role: "user", diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index e84901b0..55767f55 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -20,8 +20,8 @@ * ``` */ -import { Agent, type AgentTool, type ThinkingLevel } from "@mariozechner/pi-agent-core"; -import type { Model } from "@mariozechner/pi-ai"; +import { Agent, type AgentMessage, type AgentTool, type ThinkingLevel } from "@mariozechner/pi-agent-core"; +import type { Message, Model } from "@mariozechner/pi-ai"; import { join } from "path"; import { getAgentDir } from "../config.js"; import { AgentSession } from "./agent-session.js"; @@ -300,6 +300,7 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings { extensions: manager.getExtensionPaths(), skills: manager.getSkillsSettings(), terminal: { showImages: manager.getShowImages() }, + images: { autoResize: manager.getImageAutoResize(), blockImages: manager.getBlockImages() }, }; } @@ -425,8 +426,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} time("discoverContextFiles"); const autoResizeImages = settingsManager.getImageAutoResize(); + const blockImages = settingsManager.getBlockImages(); // Create ALL built-in tools for the registry (extensions can enable any of them) - const allBuiltInToolsMap = createAllTools(cwd, { read: { autoResizeImages } }); + const allBuiltInToolsMap = createAllTools(cwd, { read: { autoResizeImages, blockImages } }); // Determine initially active built-in tools (default: read, bash, edit, write) const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"]; const initialActiveToolNames: ToolName[] = options.tools @@ -605,6 +607,31 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const promptTemplates = options.promptTemplates ?? discoverPromptTemplates(cwd, agentDir); time("discoverPromptTemplates"); + // Create convertToLlm wrapper that filters images if blockImages is enabled + const convertToLlmWithBlockImages = blockImages + ? (messages: AgentMessage[]): Message[] => { + const converted = convertToLlm(messages); + let totalFiltered = 0; + // Filter out ImageContent from all messages as defense-in-depth + const filtered = converted.map((msg) => { + if (msg.role === "user" || msg.role === "toolResult") { + const content = msg.content; + if (Array.isArray(content)) { + const originalLength = content.length; + const filteredContent = content.filter((c) => c.type !== "image"); + totalFiltered += originalLength - filteredContent.length; + return { ...msg, content: filteredContent }; + } + } + return msg; + }); + if (totalFiltered > 0) { + console.warn(`[blockImages] Defense-in-depth: filtered ${totalFiltered} image(s) at convertToLlm layer`); + } + return filtered; + } + : convertToLlm; + agent = new Agent({ initialState: { systemPrompt, @@ -612,7 +639,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} thinkingLevel, tools: activeToolsArray, }, - convertToLlm, + convertToLlm: convertToLlmWithBlockImages, transformContext: extensionRunner ? async (messages) => { return extensionRunner.emitContext(messages); diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 996a804e..7f09ff94 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -36,6 +36,7 @@ export interface TerminalSettings { export interface ImageSettings { autoResize?: boolean; // default: true (resize images to 2000x2000 max for better model compatibility) + blockImages?: boolean; // default: false - when true, prevents all images from being sent to LLM providers } export interface Settings { @@ -398,6 +399,23 @@ export class SettingsManager { this.save(); } + getBlockImages(): boolean { + return this.settings.images?.blockImages ?? false; + } + + setBlockImages(blocked: boolean): void { + if (!this.globalSettings.images) { + this.globalSettings.images = {}; + } + this.globalSettings.images.blockImages = blocked; + // Also update active settings for inMemory mode + if (!this.settings.images) { + this.settings.images = {}; + } + this.settings.images.blockImages = blocked; + this.save(); + } + getEnabledModels(): string[] | undefined { return this.settings.enabledModels; } diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index e7ba44fb..b8aeddff 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -21,10 +21,13 @@ export interface ReadToolDetails { export interface ReadToolOptions { /** Whether to auto-resize images to 2000x2000 max. Default: true */ autoResizeImages?: boolean; + /** When true, return text message instead of image content. Default: false */ + blockImages?: boolean; } export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool { const autoResizeImages = options?.autoResizeImages ?? true; + const blockImages = options?.blockImages ?? false; return { name: "read", label: "read", @@ -75,29 +78,39 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo let details: ReadToolDetails | undefined; if (mimeType) { - // Read as image (binary) - const buffer = await readFile(absolutePath); - const base64 = buffer.toString("base64"); - - if (autoResizeImages) { - // Resize image if needed - const resized = await resizeImage({ type: "image", data: base64, mimeType }); - const dimensionNote = formatDimensionNote(resized); - - let textNote = `Read image file [${resized.mimeType}]`; - if (dimensionNote) { - textNote += `\n${dimensionNote}`; - } - + // Check if images are blocked + if (blockImages) { content = [ - { type: "text", text: textNote }, - { type: "image", data: resized.data, mimeType: resized.mimeType }, + { + type: "text", + text: `[Image file detected: ${absolutePath}]\nImage reading is disabled. The 'blockImages' setting is enabled.`, + }, ]; } else { - content = [ - { type: "text", text: `Read image file [${mimeType}]` }, - { type: "image", data: base64, mimeType }, - ]; + // Read as image (binary) + const buffer = await readFile(absolutePath); + const base64 = buffer.toString("base64"); + + if (autoResizeImages) { + // Resize image if needed + const resized = await resizeImage({ type: "image", data: base64, mimeType }); + const dimensionNote = formatDimensionNote(resized); + + let textNote = `Read image file [${resized.mimeType}]`; + if (dimensionNote) { + textNote += `\n${dimensionNote}`; + } + + content = [ + { type: "text", text: textNote }, + { type: "image", data: resized.data, mimeType: resized.mimeType }, + ]; + } else { + content = [ + { type: "text", text: `Read image file [${mimeType}]` }, + { type: "image", data: base64, mimeType }, + ]; + } } } else { // Read as text diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index a04f63cc..e0f8fc5a 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -25,6 +25,7 @@ export interface SettingsConfig { autoCompact: boolean; showImages: boolean; autoResizeImages: boolean; + blockImages: boolean; steeringMode: "all" | "one-at-a-time"; followUpMode: "all" | "one-at-a-time"; thinkingLevel: ThinkingLevel; @@ -40,6 +41,7 @@ export interface SettingsCallbacks { onAutoCompactChange: (enabled: boolean) => void; onShowImagesChange: (enabled: boolean) => void; onAutoResizeImagesChange: (enabled: boolean) => void; + onBlockImagesChange: (blocked: boolean) => void; onSteeringModeChange: (mode: "all" | "one-at-a-time") => void; onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void; onThinkingLevelChange: (level: ThinkingLevel) => void; @@ -243,6 +245,16 @@ export class SettingsSelectorComponent extends Container { values: ["true", "false"], }); + // Block images toggle (always available, insert after auto-resize-images) + const autoResizeIndex = items.findIndex((item) => item.id === "auto-resize-images"); + items.splice(autoResizeIndex + 1, 0, { + id: "block-images", + label: "Block images", + description: "Prevent images from being sent to LLM providers (restart session for full effect)", + currentValue: config.blockImages ? "true" : "false", + values: ["true", "false"], + }); + // Add borders this.addChild(new DynamicBorder()); @@ -261,6 +273,9 @@ export class SettingsSelectorComponent extends Container { case "auto-resize-images": callbacks.onAutoResizeImagesChange(newValue === "true"); break; + case "block-images": + callbacks.onBlockImagesChange(newValue === "true"); + break; case "steering-mode": callbacks.onSteeringModeChange(newValue as "all" | "one-at-a-time"); break; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 26d30573..a799ef6f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1936,6 +1936,7 @@ export class InteractiveMode { autoCompact: this.session.autoCompactionEnabled, showImages: this.settingsManager.getShowImages(), autoResizeImages: this.settingsManager.getImageAutoResize(), + blockImages: this.settingsManager.getBlockImages(), steeringMode: this.session.steeringMode, followUpMode: this.session.followUpMode, thinkingLevel: this.session.thinkingLevel, @@ -1962,6 +1963,9 @@ export class InteractiveMode { onAutoResizeImagesChange: (enabled) => { this.settingsManager.setImageAutoResize(enabled); }, + onBlockImagesChange: (blocked) => { + this.settingsManager.setBlockImages(blocked); + }, onSteeringModeChange: (mode) => { this.session.setSteeringMode(mode); }, diff --git a/packages/coding-agent/test/block-images.test.ts b/packages/coding-agent/test/block-images.test.ts new file mode 100644 index 00000000..aa0b4e6e --- /dev/null +++ b/packages/coding-agent/test/block-images.test.ts @@ -0,0 +1,147 @@ +import { mkdirSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { processFileArguments } from "../src/cli/file-processor.js"; +import { SettingsManager } from "../src/core/settings-manager.js"; +import { createReadTool } from "../src/core/tools/read.js"; + +// 1x1 red PNG image as base64 (smallest valid PNG) +const TINY_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + +describe("blockImages setting", () => { + describe("SettingsManager", () => { + it("should default blockImages to false", () => { + const manager = SettingsManager.inMemory({}); + expect(manager.getBlockImages()).toBe(false); + }); + + it("should return true when blockImages is set to true", () => { + const manager = SettingsManager.inMemory({ images: { blockImages: true } }); + expect(manager.getBlockImages()).toBe(true); + }); + + it("should persist blockImages setting via setBlockImages", () => { + const manager = SettingsManager.inMemory({}); + expect(manager.getBlockImages()).toBe(false); + + manager.setBlockImages(true); + expect(manager.getBlockImages()).toBe(true); + + manager.setBlockImages(false); + expect(manager.getBlockImages()).toBe(false); + }); + + it("should handle blockImages alongside autoResize", () => { + const manager = SettingsManager.inMemory({ + images: { autoResize: true, blockImages: true }, + }); + expect(manager.getImageAutoResize()).toBe(true); + expect(manager.getBlockImages()).toBe(true); + }); + }); + + describe("Read tool", () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `block-images-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it("should return text message when blockImages is true", async () => { + // Create test image + const imagePath = join(testDir, "test.png"); + writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); + + const tool = createReadTool(testDir, { blockImages: true }); + const result = await tool.execute("test-1", { path: imagePath }); + + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + const textContent = result.content[0] as { type: "text"; text: string }; + expect(textContent.text).toContain("Image reading is disabled"); + expect(textContent.text).toContain("blockImages"); + }); + + it("should return image content when blockImages is false", async () => { + // Create test image + const imagePath = join(testDir, "test.png"); + writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); + + const tool = createReadTool(testDir, { blockImages: false }); + const result = await tool.execute("test-2", { path: imagePath }); + + // Should have text note + image content + expect(result.content.length).toBeGreaterThanOrEqual(1); + const hasImage = result.content.some((c) => c.type === "image"); + expect(hasImage).toBe(true); + }); + + it("should read text files normally even when blockImages is true", async () => { + // Create test text file + const textPath = join(testDir, "test.txt"); + writeFileSync(textPath, "Hello, world!"); + + const tool = createReadTool(testDir, { blockImages: true }); + const result = await tool.execute("test-3", { path: textPath }); + + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + const textContent = result.content[0] as { type: "text"; text: string }; + expect(textContent.text).toContain("Hello, world!"); + }); + }); + + describe("processFileArguments", () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `block-images-process-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it("should skip image files when blockImages is true", async () => { + // Create test image + const imagePath = join(testDir, "test.png"); + writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); + + const result = await processFileArguments([imagePath], { blockImages: true }); + + expect(result.images).toHaveLength(0); + // Text should be empty since image was skipped + expect(result.text).toBe(""); + }); + + it("should include image files when blockImages is false", async () => { + // Create test image + const imagePath = join(testDir, "test.png"); + writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); + + const result = await processFileArguments([imagePath], { blockImages: false }); + + expect(result.images).toHaveLength(1); + expect(result.images[0].type).toBe("image"); + }); + + it("should process text files normally when blockImages is true", async () => { + // Create test text file + const textPath = join(testDir, "test.txt"); + writeFileSync(textPath, "Hello, world!"); + + const result = await processFileArguments([textPath], { blockImages: true }); + + expect(result.images).toHaveLength(0); + expect(result.text).toContain("Hello, world!"); + }); + }); +}); From 1fc2a912d47ae576b4f7bb8d59f6d1331a226a4c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 6 Jan 2026 11:59:09 +0100 Subject: [PATCH 2/2] Add blockImages setting to prevent images from being sent to LLM providers - Setting controls filtering at convertToLlm layer (defense-in-depth) - Images are always stored in session, filtered dynamically based on current setting - Toggle mid-session works: LLM sees/doesn't see images already in session - Fixed SettingsManager.save() to handle inMemory mode for all setters Closes #492 --- packages/coding-agent/CHANGELOG.md | 2 +- .../coding-agent/src/cli/file-processor.ts | 7 --- .../coding-agent/src/core/agent-session.ts | 8 +-- packages/coding-agent/src/core/sdk.ts | 57 +++++++++++-------- .../coding-agent/src/core/settings-manager.ts | 33 +++++------ packages/coding-agent/src/core/tools/read.ts | 53 +++++++---------- .../components/settings-selector.ts | 2 +- .../coding-agent/test/block-images.test.ts | 45 +++------------ 8 files changed, 80 insertions(+), 127 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 626c56d7..61204a42 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Added `blockImages` setting to prevent images from being sent to LLM providers. Provides defense-in-depth blocking at Read tool, CLI file processor, AgentSession, and convertToLlm layer. ([#492](https://github.com/badlogic/pi-mono/pull/492)) +- Added `blockImages` setting to prevent images from being sent to LLM providers. Provides defense-in-depth blocking at Read tool, CLI file processor, AgentSession, and convertToLlm layer. ([#492](https://github.com/badlogic/pi-mono/pull/492) by [@jsinge97](https://github.com/jsinge97)) ## [0.37.2] - 2026-01-05 diff --git a/packages/coding-agent/src/cli/file-processor.ts b/packages/coding-agent/src/cli/file-processor.ts index 6f786df8..8b676419 100644 --- a/packages/coding-agent/src/cli/file-processor.ts +++ b/packages/coding-agent/src/cli/file-processor.ts @@ -18,8 +18,6 @@ export interface ProcessedFiles { export interface ProcessFileOptions { /** Whether to auto-resize images to 2000x2000 max. Default: true */ autoResizeImages?: boolean; - /** When true, skip image files with warning. Default: false */ - blockImages?: boolean; } /** Process @file arguments into text content and image attachments */ @@ -50,11 +48,6 @@ export async function processFileArguments(fileArgs: string[], options?: Process const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath); if (mimeType) { - // Check if images are blocked - if (options?.blockImages) { - console.warn(chalk.yellow(`[blockImages] Skipping image file: ${absolutePath}`)); - continue; - } // Handle image file const content = await readFile(absolutePath); const base64Content = content.toString("base64"); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 3afd4418..92194eaa 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -592,13 +592,7 @@ export class AgentSession { // Add user message const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }]; if (options?.images) { - const blockImages = this.settingsManager.getBlockImages(); - if (blockImages) { - // Log warning for blocked images - console.warn(`[blockImages] Blocked ${options.images.length} image(s) from being sent to provider`); - } else { - userContent.push(...options.images); - } + userContent.push(...options.images); } messages.push({ role: "user", diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 55767f55..aace4e41 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -426,9 +426,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} time("discoverContextFiles"); const autoResizeImages = settingsManager.getImageAutoResize(); - const blockImages = settingsManager.getBlockImages(); // Create ALL built-in tools for the registry (extensions can enable any of them) - const allBuiltInToolsMap = createAllTools(cwd, { read: { autoResizeImages, blockImages } }); + const allBuiltInToolsMap = createAllTools(cwd, { read: { autoResizeImages } }); // Determine initially active built-in tools (default: read, bash, edit, write) const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"]; const initialActiveToolNames: ToolName[] = options.tools @@ -607,30 +606,42 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const promptTemplates = options.promptTemplates ?? discoverPromptTemplates(cwd, agentDir); time("discoverPromptTemplates"); - // Create convertToLlm wrapper that filters images if blockImages is enabled - const convertToLlmWithBlockImages = blockImages - ? (messages: AgentMessage[]): Message[] => { - const converted = convertToLlm(messages); - let totalFiltered = 0; - // Filter out ImageContent from all messages as defense-in-depth - const filtered = converted.map((msg) => { - if (msg.role === "user" || msg.role === "toolResult") { - const content = msg.content; - if (Array.isArray(content)) { - const originalLength = content.length; - const filteredContent = content.filter((c) => c.type !== "image"); - totalFiltered += originalLength - filteredContent.length; - return { ...msg, content: filteredContent }; - } + // Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth) + const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => { + const converted = convertToLlm(messages); + // Check setting dynamically so mid-session changes take effect + if (!settingsManager.getBlockImages()) { + return converted; + } + // Filter out ImageContent from all messages, replacing with text placeholder + return converted.map((msg) => { + if (msg.role === "user" || msg.role === "toolResult") { + const content = msg.content; + if (Array.isArray(content)) { + const hasImages = content.some((c) => c.type === "image"); + if (hasImages) { + const filteredContent = content + .map((c) => + c.type === "image" ? { type: "text" as const, text: "Image reading is disabled." } : c, + ) + .filter( + (c, i, arr) => + // Dedupe consecutive "Image reading is disabled." texts + !( + c.type === "text" && + c.text === "Image reading is disabled." && + i > 0 && + arr[i - 1].type === "text" && + (arr[i - 1] as { type: "text"; text: string }).text === "Image reading is disabled." + ), + ); + return { ...msg, content: filteredContent }; } - return msg; - }); - if (totalFiltered > 0) { - console.warn(`[blockImages] Defense-in-depth: filtered ${totalFiltered} image(s) at convertToLlm layer`); } - return filtered; } - : convertToLlm; + return msg; + }); + }; agent = new Agent({ initialState: { diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 7f09ff94..e9f29b5d 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -171,23 +171,23 @@ export class SettingsManager { } private save(): void { - if (!this.persist || !this.settingsPath) return; + if (this.persist && this.settingsPath) { + try { + const dir = dirname(this.settingsPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } - try { - const dir = dirname(this.settingsPath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); + // Save only global settings (project settings are read-only) + writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8"); + } catch (error) { + console.error(`Warning: Could not save settings file: ${error}`); } - - // Save only global settings (project settings are read-only) - writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8"); - - // Re-merge project settings into active settings - const projectSettings = this.loadProjectSettings(); - this.settings = deepMergeSettings(this.globalSettings, projectSettings); - } catch (error) { - console.error(`Warning: Could not save settings file: ${error}`); } + + // Always re-merge to update active settings (needed for both file and inMemory modes) + const projectSettings = this.loadProjectSettings(); + this.settings = deepMergeSettings(this.globalSettings, projectSettings); } getLastChangelogVersion(): string | undefined { @@ -408,11 +408,6 @@ export class SettingsManager { this.globalSettings.images = {}; } this.globalSettings.images.blockImages = blocked; - // Also update active settings for inMemory mode - if (!this.settings.images) { - this.settings.images = {}; - } - this.settings.images.blockImages = blocked; this.save(); } diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index b8aeddff..e7ba44fb 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -21,13 +21,10 @@ export interface ReadToolDetails { export interface ReadToolOptions { /** Whether to auto-resize images to 2000x2000 max. Default: true */ autoResizeImages?: boolean; - /** When true, return text message instead of image content. Default: false */ - blockImages?: boolean; } export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool { const autoResizeImages = options?.autoResizeImages ?? true; - const blockImages = options?.blockImages ?? false; return { name: "read", label: "read", @@ -78,39 +75,29 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo let details: ReadToolDetails | undefined; if (mimeType) { - // Check if images are blocked - if (blockImages) { + // Read as image (binary) + const buffer = await readFile(absolutePath); + const base64 = buffer.toString("base64"); + + if (autoResizeImages) { + // Resize image if needed + const resized = await resizeImage({ type: "image", data: base64, mimeType }); + const dimensionNote = formatDimensionNote(resized); + + let textNote = `Read image file [${resized.mimeType}]`; + if (dimensionNote) { + textNote += `\n${dimensionNote}`; + } + content = [ - { - type: "text", - text: `[Image file detected: ${absolutePath}]\nImage reading is disabled. The 'blockImages' setting is enabled.`, - }, + { type: "text", text: textNote }, + { type: "image", data: resized.data, mimeType: resized.mimeType }, ]; } else { - // Read as image (binary) - const buffer = await readFile(absolutePath); - const base64 = buffer.toString("base64"); - - if (autoResizeImages) { - // Resize image if needed - const resized = await resizeImage({ type: "image", data: base64, mimeType }); - const dimensionNote = formatDimensionNote(resized); - - let textNote = `Read image file [${resized.mimeType}]`; - if (dimensionNote) { - textNote += `\n${dimensionNote}`; - } - - content = [ - { type: "text", text: textNote }, - { type: "image", data: resized.data, mimeType: resized.mimeType }, - ]; - } else { - content = [ - { type: "text", text: `Read image file [${mimeType}]` }, - { type: "image", data: base64, mimeType }, - ]; - } + content = [ + { type: "text", text: `Read image file [${mimeType}]` }, + { type: "image", data: base64, mimeType }, + ]; } } else { // Read as text diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index e0f8fc5a..681742a4 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -250,7 +250,7 @@ export class SettingsSelectorComponent extends Container { items.splice(autoResizeIndex + 1, 0, { id: "block-images", label: "Block images", - description: "Prevent images from being sent to LLM providers (restart session for full effect)", + description: "Prevent images from being sent to LLM providers", currentValue: config.blockImages ? "true" : "false", values: ["true", "false"], }); diff --git a/packages/coding-agent/test/block-images.test.ts b/packages/coding-agent/test/block-images.test.ts index aa0b4e6e..2c599276 100644 --- a/packages/coding-agent/test/block-images.test.ts +++ b/packages/coding-agent/test/block-images.test.ts @@ -54,42 +54,27 @@ describe("blockImages setting", () => { rmSync(testDir, { recursive: true, force: true }); }); - it("should return text message when blockImages is true", async () => { + it("should always read images (filtering happens at convertToLlm layer)", async () => { // Create test image const imagePath = join(testDir, "test.png"); writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); - const tool = createReadTool(testDir, { blockImages: true }); + const tool = createReadTool(testDir); const result = await tool.execute("test-1", { path: imagePath }); - expect(result.content).toHaveLength(1); - expect(result.content[0].type).toBe("text"); - const textContent = result.content[0] as { type: "text"; text: string }; - expect(textContent.text).toContain("Image reading is disabled"); - expect(textContent.text).toContain("blockImages"); - }); - - it("should return image content when blockImages is false", async () => { - // Create test image - const imagePath = join(testDir, "test.png"); - writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); - - const tool = createReadTool(testDir, { blockImages: false }); - const result = await tool.execute("test-2", { path: imagePath }); - // Should have text note + image content expect(result.content.length).toBeGreaterThanOrEqual(1); const hasImage = result.content.some((c) => c.type === "image"); expect(hasImage).toBe(true); }); - it("should read text files normally even when blockImages is true", async () => { + it("should read text files normally", async () => { // Create test text file const textPath = join(testDir, "test.txt"); writeFileSync(textPath, "Hello, world!"); - const tool = createReadTool(testDir, { blockImages: true }); - const result = await tool.execute("test-3", { path: textPath }); + const tool = createReadTool(testDir); + const result = await tool.execute("test-2", { path: textPath }); expect(result.content).toHaveLength(1); expect(result.content[0].type).toBe("text"); @@ -110,35 +95,23 @@ describe("blockImages setting", () => { rmSync(testDir, { recursive: true, force: true }); }); - it("should skip image files when blockImages is true", async () => { + it("should always process images (filtering happens at convertToLlm layer)", async () => { // Create test image const imagePath = join(testDir, "test.png"); writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); - const result = await processFileArguments([imagePath], { blockImages: true }); - - expect(result.images).toHaveLength(0); - // Text should be empty since image was skipped - expect(result.text).toBe(""); - }); - - it("should include image files when blockImages is false", async () => { - // Create test image - const imagePath = join(testDir, "test.png"); - writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); - - const result = await processFileArguments([imagePath], { blockImages: false }); + const result = await processFileArguments([imagePath]); expect(result.images).toHaveLength(1); expect(result.images[0].type).toBe("image"); }); - it("should process text files normally when blockImages is true", async () => { + it("should process text files normally", async () => { // Create test text file const textPath = join(testDir, "test.txt"); writeFileSync(textPath, "Hello, world!"); - const result = await processFileArguments([textPath], { blockImages: true }); + const result = await processFileArguments([textPath]); expect(result.images).toHaveLength(0); expect(result.text).toContain("Hello, world!");