From 7eb220f76e77465f740c159a5c93af04a0dbd74c Mon Sep 17 00:00:00 2001 From: priyamkarn Date: Mon, 13 Jul 2026 06:57:51 +0530 Subject: [PATCH] Fix opencode auto-memory-update leaking the full transcript via process argv runOpenCodeAgent passed the sensitive prompt (which embeds the full session transcript, including anything not yet caught by redaction) as a literal '-p' command-line argument. Process arguments are visible to other local users via ps/proc for the lifetime of the process -- and this runs automatically, unattended, on every background auto-memory-update, not just when a human runs a CLI command. codex.ts already avoids this via stdin and openhands.ts via a task file; opencode's CLI has no stdin/file option for its prompt though (confirmed against its docs), only a literal -p value, so the fix writes the prompt to a file inside the caller's private (mode 0700) run directory and passes only a short file-reference wrapper via argv instead. Adds a regression test that stubs the opencode binary, captures its real argv, and asserts the sensitive content never appears there. --- libs/agent-runner/opencode.ts | 23 ++++++-- package.json | 2 +- scripts/check-opencode-prompt-file.js | 84 +++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 scripts/check-opencode-prompt-file.js diff --git a/libs/agent-runner/opencode.ts b/libs/agent-runner/opencode.ts index ba93e4d..ebdefe8 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"], @@ -50,4 +65,4 @@ function runOpenCodeProcess( resolve({ exitCode, signal }); }); }); -} +} \ No newline at end of file diff --git a/package.json b/package.json index 6544d30..b9e007d 100644 --- a/package.json +++ b/package.json @@ -24,7 +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-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", + "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