diff --git a/apps/worker/src/run-task/__tests__/opencode-tool-safety-plugin-script.test.ts b/apps/worker/src/run-task/__tests__/opencode-tool-safety-plugin-script.test.ts new file mode 100644 index 000000000..4a400081c --- /dev/null +++ b/apps/worker/src/run-task/__tests__/opencode-tool-safety-plugin-script.test.ts @@ -0,0 +1,132 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { OPENCODE_TOOL_SAFETY_PLUGIN_SCRIPT } from '../opencode-tool-safety-plugin-script'; + +interface ToolHookInput { + tool: string; + args?: unknown; +} + +type ToolHooks = { + 'tool.execute.before': ( + input: ToolHookInput, + output: { args?: unknown }, + ) => Promise; +}; + +describe('OPENCODE_TOOL_SAFETY_PLUGIN_SCRIPT', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'roomote-opencode-tool-safety-plugin-'), + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + async function loadHooks(): Promise { + const pluginPath = path.join(tempDir, 'roomote-tool-safety.mjs'); + fs.writeFileSync(pluginPath, OPENCODE_TOOL_SAFETY_PLUGIN_SCRIPT, 'utf8'); + + const module = (await import( + /* @vite-ignore */ pathToFileURL(pluginPath).href + )) as { + RoomoteOpenCodeToolSafety: () => Promise; + }; + + return await module.RoomoteOpenCodeToolSafety(); + } + + it.each([ + '/tmp/site-icon.ico', + '/tmp/site-icon.CUR', + String.raw`C:\tmp\site-icon.ICO`, + '/tmp/site-icon.ico?cache=1', + ])('rejects unsupported icon reads for %s', async (filePath) => { + const hooks = await loadHooks(); + + await expect( + hooks['tool.execute.before']({ tool: 'read' }, { args: { filePath } }), + ).rejects.toThrow('cannot safely attach ICO or CUR image files'); + }); + + it('checks read arguments supplied on the hook input', async () => { + const hooks = await loadHooks(); + + await expect( + hooks['tool.execute.before']( + { tool: 'read', args: { file_path: '/tmp/site-icon.ico' } }, + {}, + ), + ).rejects.toThrow('cannot safely attach ICO or CUR image files'); + }); + + it('accepts the generic path argument shape', async () => { + const hooks = await loadHooks(); + + await expect( + hooks['tool.execute.before']( + { tool: 'read' }, + { args: { path: '/tmp/site-icon.ico' } }, + ), + ).rejects.toThrow('cannot safely attach ICO or CUR image files'); + }); + + it('rejects a safe-looking symlink whose target is an unsupported icon', async () => { + const hooks = await loadHooks(); + const targetPath = path.join(tempDir, 'target.ico'); + const symlinkPath = path.join(tempDir, 'preview.png'); + fs.writeFileSync(targetPath, 'not inspected by the plugin', 'utf8'); + fs.symlinkSync(targetPath, symlinkPath); + + await expect( + hooks['tool.execute.before']( + { tool: 'read' }, + { args: { filePath: symlinkPath } }, + ), + ).rejects.toThrow('cannot safely attach ICO or CUR image files'); + }); + + it('allows a symlink to a supported image path', async () => { + const hooks = await loadHooks(); + const targetPath = path.join(tempDir, 'target.png'); + const symlinkPath = path.join(tempDir, 'preview.png'); + fs.writeFileSync(targetPath, 'not inspected by the plugin', 'utf8'); + fs.symlinkSync(targetPath, symlinkPath); + + await expect( + hooks['tool.execute.before']( + { tool: 'read' }, + { args: { filePath: symlinkPath } }, + ), + ).resolves.toBeUndefined(); + }); + + it.each(['/tmp/screenshot.png', '/tmp/component.ts'])( + 'allows safe reads for %s', + async (filePath) => { + const hooks = await loadHooks(); + + await expect( + hooks['tool.execute.before']({ tool: 'read' }, { args: { filePath } }), + ).resolves.toBeUndefined(); + }, + ); + + it('does not inspect arguments for other tools', async () => { + const hooks = await loadHooks(); + + await expect( + hooks['tool.execute.before']( + { tool: 'bash' }, + { args: { filePath: '/tmp/site-icon.ico' } }, + ), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/worker/src/run-task/agent-home.ts b/apps/worker/src/run-task/agent-home.ts index 8a7a0855d..2f2c92265 100644 --- a/apps/worker/src/run-task/agent-home.ts +++ b/apps/worker/src/run-task/agent-home.ts @@ -50,6 +50,7 @@ import { SLACK_POSTING_TOOL_EXCLUSIONS } from './slack-posting-tools'; import { SLACK_STOP_HOOK_SCRIPT } from './slack-stop-hook-script'; import { OPENCODE_SLACK_HOOKS_PLUGIN_SCRIPT } from './opencode-slack-hooks-plugin-script'; import { OPENCODE_CHATGPT_GATEWAY_PLUGIN_SCRIPT } from './opencode-chatgpt-gateway-plugin-script'; +import { OPENCODE_TOOL_SAFETY_PLUGIN_SCRIPT } from './opencode-tool-safety-plugin-script'; import { resolveOpenCodeModelSelection } from './opencode-model'; import { createProofRunnerAgentPrompt, @@ -192,6 +193,8 @@ const ROOMOTE_OPENCODE_SLACK_HOOKS_PLUGIN_FILE_NAME = 'roomote-slack-hooks.js'; const ROOMOTE_OPENCODE_CHATGPT_GATEWAY_PLUGIN_FILE_NAME = 'roomote-chatgpt-gateway.js'; +const ROOMOTE_OPENCODE_TOOL_SAFETY_PLUGIN_FILE_NAME = 'roomote-tool-safety.js'; + const OPENCODE_ALLOW_ALL_PERMISSION = { read: 'allow', edit: 'allow', @@ -620,6 +623,10 @@ function writeOpenCodeManagedFiles(openCodeConfigDir: string): void { pluginsDir, ROOMOTE_OPENCODE_CHATGPT_GATEWAY_PLUGIN_FILE_NAME, ); + const toolSafetyPluginPath = path.join( + pluginsDir, + ROOMOTE_OPENCODE_TOOL_SAFETY_PLUGIN_FILE_NAME, + ); const silenceHookPath = path.join( openCodeConfigDir, ROOMOTE_OPENCODE_SLACK_SILENCE_HOOK_FILE_NAME, @@ -636,6 +643,11 @@ function writeOpenCodeManagedFiles(openCodeConfigDir: string): void { OPENCODE_CHATGPT_GATEWAY_PLUGIN_SCRIPT, 'utf8', ); + fs.writeFileSync( + toolSafetyPluginPath, + OPENCODE_TOOL_SAFETY_PLUGIN_SCRIPT, + 'utf8', + ); fs.writeFileSync(silenceHookPath, SLACK_SILENCE_HOOK_SCRIPT, 'utf8'); fs.writeFileSync(stopHookPath, SLACK_STOP_HOOK_SCRIPT, 'utf8'); fs.chmodSync(silenceHookPath, 0o755); diff --git a/apps/worker/src/run-task/opencode-tool-safety-plugin-script.ts b/apps/worker/src/run-task/opencode-tool-safety-plugin-script.ts new file mode 100644 index 000000000..aa81cc3e6 --- /dev/null +++ b/apps/worker/src/run-task/opencode-tool-safety-plugin-script.ts @@ -0,0 +1,61 @@ +export const OPENCODE_TOOL_SAFETY_PLUGIN_SCRIPT = `import { realpath } from 'node:fs/promises'; + +const UNSUPPORTED_READ_IMAGE_EXTENSIONS = new Set(['.cur', '.ico']); + +function getReadPath(input, context) { + const args = context?.args ?? input?.args; + + if (!args || typeof args !== 'object') { + return undefined; + } + + return typeof args.filePath === 'string' + ? args.filePath + : typeof args.file_path === 'string' + ? args.file_path + : typeof args.path === 'string' + ? args.path + : undefined; +} + +function getExtension(filePath) { + const normalized = filePath.split(/[?#]/u, 1)[0]?.toLowerCase() ?? ''; + const basename = normalized.split(/[\\/]/u).pop() ?? ''; + const extensionIndex = basename.lastIndexOf('.'); + + return extensionIndex >= 0 ? basename.slice(extensionIndex) : ''; +} + +async function resolvesToUnsupportedImage(filePath) { + if (UNSUPPORTED_READ_IMAGE_EXTENSIONS.has(getExtension(filePath))) { + return true; + } + + try { + const resolvedPath = await realpath(filePath.split(/[?#]/u, 1)[0]); + return UNSUPPORTED_READ_IMAGE_EXTENSIONS.has(getExtension(resolvedPath)); + } catch { + // Let the read tool report missing or inaccessible paths itself. + return false; + } +} + +export const RoomoteOpenCodeToolSafety = async () => ({ + 'tool.execute.before': async (input, context) => { + if (input?.tool !== 'read') { + return; + } + + const filePath = getReadPath(input, context); + + if (!filePath || !(await resolvesToUnsupportedImage(filePath))) { + return; + } + + throw new Error( + 'The read tool cannot safely attach ICO or CUR image files to the model conversation. ' + + 'Inspect metadata with a text-only command or convert the image to PNG in a temporary directory first.', + ); + }, +}); +`; diff --git a/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server-bootstrap.test.ts b/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server-bootstrap.test.ts index d2b1d76e5..f51669f0d 100644 --- a/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server-bootstrap.test.ts +++ b/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server-bootstrap.test.ts @@ -78,6 +78,19 @@ describe('opencode-server bootstrap', () => { ); } + function readOpenCodeToolSafetyPlugin(homeDir: string): string { + return fs.readFileSync( + path.join( + homeDir, + '.config', + 'opencode', + 'plugins', + 'roomote-tool-safety.js', + ), + 'utf8', + ); + } + // The plugin seed gate probes `opencode --version` through OPENCODE_COMMAND. // Pin the probe to a stub so the resolved version always matches the seed // fixtures regardless of whatever opencode CLI the host machine has on PATH @@ -1742,6 +1755,24 @@ describe('opencode-server bootstrap', () => { expect(pluginContent).toContain('R_INFERENCE_GATEWAY_CHATGPT'); }); + it('installs the OpenCode tool safety plugin', async () => { + const { prepareOpenCodeCommandEnv } = + await import('../opencode-server/bootstrap'); + + const homeDir = createTempHome(); + + await prepareOpenCodeCommandEnv({ + runtimeEnv: createDirectHarnessRuntimeEnv(homeDir), + workspacePath: '/tmp/workspace', + logger: createLogger(), + }); + + const pluginContent = readOpenCodeToolSafetyPlugin(homeDir); + + expect(pluginContent).toContain('RoomoteOpenCodeToolSafety'); + expect(pluginContent).toContain("'.ico'"); + }); + it('enables Slack hook debug logs only when Slack reply satisfaction is configured', async () => { const { prepareOpenCodeCommandEnv } = await import('../opencode-server/bootstrap');