Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions libs/agent-runner/opencode.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -26,14 +27,28 @@ export async function runOpenCodeAgent(input: AgentRunInput): Promise<AgentRunRe
};
}

function runOpenCodeProcess(
export function runOpenCodeProcess(
input: AgentRunInput,
transcript: NodeJS.WritableStream,
spawnImpl: typeof spawn = spawn,
): Promise<{ exitCode: number | null; signal: string | null }> {
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 <text>` value -- and process arguments are visible to other
// local users via `ps`/`/proc/<pid>/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"],
Expand All @@ -56,4 +71,4 @@ function runOpenCodeProcess(
resolve({ exitCode, signal });
});
});
}
}
5 changes: 1 addition & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
84 changes: 84 additions & 0 deletions scripts/check-opencode-prompt-file.js
Original file line number Diff line number Diff line change
@@ -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/<pid>/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 });
}
Loading