diff --git a/libs/agent-runner/opencode.ts b/libs/agent-runner/opencode.ts index afd3404..d653475 100644 --- a/libs/agent-runner/opencode.ts +++ b/libs/agent-runner/opencode.ts @@ -1,5 +1,6 @@ import { createWriteStream, writeFileSync } from "node:fs"; import { spawn } from "node:child_process"; +import { dirname, join } from "node:path"; import { collectAgentMetrics } from "./metrics.js"; import type { AgentRunInput, AgentRunResult } from "./types.js"; @@ -26,14 +27,28 @@ export async function runOpenCodeAgent(input: AgentRunInput): Promise { return new Promise((resolve, reject) => { - const args = ["-p", input.prompt, "-f", "json", "-q"]; + // The opencode CLI has no stdin/file option for its prompt argument -- only + // a literal `-p ` value -- and process arguments are visible to other + // local users via `ps`/`/proc//cmdline` for as long as the process runs. + // input.prompt embeds the full session transcript (which may contain secrets + // or other sensitive content), so it must never be passed directly as argv. + // Instead, write it to a file inside the caller's private run directory + // (created via mkdtempSync, mode 0700 per POSIX) and pass only a short, + // non-sensitive reference on the command line; the agent reads the real + // instructions itself via its own file tool. + const promptPath = join(dirname(input.transcriptPath), "opencode-task-prompt.md"); + writeFileSync(promptPath, input.prompt, "utf8"); + const wrapperPrompt = `Your full task instructions are in the file at ${promptPath}. Read that file now and follow its instructions exactly.`; - const child = spawn("opencode", args, { + const args = ["-p", wrapperPrompt, "-f", "json", "-q"]; + + const child = spawnImpl("opencode", args, { cwd: input.cwd, env: input.env, stdio: ["ignore", "pipe", "inherit"], @@ -56,4 +71,4 @@ function runOpenCodeProcess( resolve({ exitCode, signal }); }); }); -} +} \ No newline at end of file diff --git a/package.json b/package.json index d6dd889..9c8fdc3 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,7 @@ "smoke:copilot": "npm run build && node scripts/smoke-copilot-install.mjs", "smoke:opencode": "npm run build && node scripts/smoke-opencode-install.mjs", "smoke:cursor": "npm run build && node scripts/smoke-cursor-install.mjs", - "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-agent-runner-spawn-error.js", - "test:managed-collaboration": "npm run build && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js", - "test:reconciliation-code-evidence": "npm run build && node scripts/check-reconciliation-code-evidence.js", - "test:repo-installations": "npm run build && node scripts/check-repo-installations.js", + "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-opencode-prompt-file.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js", "test:transcript-bundle": "npm run build && node scripts/check-transcript-bundle.js", "test:repo-context": "npm run build && node scripts/check-repo-context.js", "test:source-memberships": "npm run build && node scripts/check-source-memberships.js", diff --git a/scripts/check-opencode-prompt-file.js b/scripts/check-opencode-prompt-file.js new file mode 100644 index 0000000..60c4ca0 --- /dev/null +++ b/scripts/check-opencode-prompt-file.js @@ -0,0 +1,84 @@ +// Security regression: the opencode agent runner must never pass the sensitive +// prompt (which embeds the full session transcript) as a literal command-line +// argument, since process arguments are visible to other local users via +// `ps`/`/proc//cmdline` for as long as the process runs. It must instead +// write the prompt to a file inside the caller's private run directory and +// pass only a short, non-sensitive file reference on the command line. +// +// This verifies the argv actually built by injecting a fake spawn implementation +// instead of launching a real subprocess. That's deliberate: a real fake-binary +// stand-in hits unrelated, OS-specific subprocess quirks (e.g. Windows cannot +// spawn .cmd/.bat files via CreateProcess without going through cmd.exe, which +// has nothing to do with the property under test), so this checks the exact +// argument list handed to spawn() directly and platform-independently. + +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = new URL("..", import.meta.url); +const { runOpenCodeProcess } = await import(new URL("dist/libs/agent-runner/opencode.js", root)); + +const secretMarker = "SECRET_TRANSCRIPT_MARKER_do_not_leak_via_argv_12345"; + +function fakeSpawn(capturedCalls) { + return (command, args, options) => { + capturedCalls.push({ command, args, options }); + const child = new EventEmitter(); + child.stdout = new PassThrough(); + queueMicrotask(() => { + child.stdout.end(); + child.emit("close", 0, null); + }); + return child; + }; +} + +const runDir = mkdtempSync(join(tmpdir(), "greplica-opencode-runner-test-")); + +try { + const input = { + cwd: runDir, + env: process.env, + prompt: `Some task instructions.\n\nHere is sensitive session context: ${secretMarker}\n`, + transcriptPath: join(runDir, "agent-events.jsonl"), + finalMessagePath: join(runDir, "final-message.md"), + }; + + const capturedCalls = []; + const transcript = new PassThrough(); + transcript.resume(); + + await runOpenCodeProcess(input, transcript, fakeSpawn(capturedCalls)); + + assert.equal(capturedCalls.length, 1, `expected exactly one spawn() call, got ${capturedCalls.length}`); + const { command, args } = capturedCalls[0]; + assert.equal(command, "opencode"); + + const argvJoined = args.join(" "); + assert.ok( + !argvJoined.includes(secretMarker), + `sensitive prompt content must not appear in argv, got: ${argvJoined}`, + ); + + const promptFlagIndex = args.indexOf("-p"); + assert.ok(promptFlagIndex !== -1, `expected -p flag in argv, got: ${argvJoined}`); + const wrapperPrompt = args[promptFlagIndex + 1]; + assert.ok( + wrapperPrompt.includes("opencode-task-prompt.md"), + `expected the -p value to reference the prompt file, got: ${wrapperPrompt}`, + ); + + // The real prompt (including the sensitive marker) must actually have been + // written to the file the wrapper prompt points at. + const promptFilePath = join(runDir, "opencode-task-prompt.md"); + const writtenPrompt = readFileSync(promptFilePath, "utf8"); + assert.ok(writtenPrompt.includes(secretMarker), "expected the full prompt to be written to the task-prompt file"); + + console.log("OpenCode prompt-file (argv exposure) regression check passed."); +} finally { + rmSync(runDir, { recursive: true, force: true }); +} \ No newline at end of file