From fefc4c344e31e60535dceb2f4c4e1512fb030364 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:21:45 +0900 Subject: [PATCH 1/2] fix(adapters): stage qoder/codebuddy system prompts in private files instead of argv The folded system+developer prompt was embedded in the child process argument list (--append-system-prompt ), which is visible to other local users via process listing. Stage it in a private temp file (mkdtemp, mode 0600) and pass the path: --append-system-prompt-file for Qoder, --system-prompt-file for CodeBuddy. Staging failure fails closed before spawn; the temp directory is removed after the turn. --- src/adapters/codebuddy/adapter.ts | 61 ++++++++++++++++++----- src/adapters/qoder/adapter.ts | 53 +++++++++++++++----- tests/providers/codebuddy-adapter.test.ts | 36 +++++++++++-- tests/providers/qoder-adapter.test.ts | 32 ++++++++++++ 4 files changed, 153 insertions(+), 29 deletions(-) diff --git a/src/adapters/codebuddy/adapter.ts b/src/adapters/codebuddy/adapter.ts index a769ac37dae..c7e1b00e52d 100644 --- a/src/adapters/codebuddy/adapter.ts +++ b/src/adapters/codebuddy/adapter.ts @@ -1,4 +1,7 @@ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { AdapterRequest, ProviderAdapter } from "../base"; import { mapReasoningEffort } from "../../reasoning-effort"; import { buildSystemPrompt } from "../coding-agent/protocol"; @@ -35,7 +38,12 @@ export function buildChildEnv(profile: CodeBuddyProfile, apiKey: string): Record * would require authorization is blocked. The turn is a single text/reasoning pass over stream-json; * Codex's tool catalog is not advertised in v1 (the control-protocol tool bridge is a fast-follow). */ -export function buildArgs(profile: CodeBuddyProfile, parsed: OcxParsedRequest, provider: OcxProviderConfig): string[] { +export function buildArgs( + profile: CodeBuddyProfile, + parsed: OcxParsedRequest, + provider: OcxProviderConfig, + systemPromptFile?: string, +): string[] { const args: string[] = [ "-p", "--output-format", "stream-json", @@ -50,8 +58,7 @@ export function buildArgs(profile: CodeBuddyProfile, parsed: OcxParsedRequest, p ]; const effort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); if (effort) args.push("--effort", effort); - const system = buildSystemPrompt(parsed); - if (system) args.push("--append-system-prompt", system); + if (systemPromptFile) args.push("--system-prompt-file", systemPromptFile); // profile is retained for symmetry with the region-isolated design and future per-region flags. void profile; return args; @@ -71,16 +78,44 @@ export function createCodeBuddyAdapter(provider: OcxProviderConfig, deps: CodeBu }, async runTurn(parsed, incoming, emit): Promise { - await runCodingAgentTurn({ - profiles: CODEBUDDY_PROFILES, - provider, - parsed, - incoming, - emit: guardCodeBuddyScaffolding(emit), - buildArgs: (resolved, req, prov) => buildArgs(resolved as CodeBuddyProfile, req, prov), - buildEnv: (resolved, apiKey) => buildChildEnv(resolved as CodeBuddyProfile, apiKey), - deps, - }); + // argv is world-readable via process listing, so the folded system+developer prompt is + // staged in a private temp file and passed by path instead of embedded in the arguments. + const system = buildSystemPrompt(parsed); + let promptDir: string | undefined; + let promptFile: string | undefined; + if (system) { + try { + promptDir = await mkdtemp(join(tmpdir(), "ocx-codebuddy-prompt-")); + promptFile = join(promptDir, "system-prompt.txt"); + await writeFile(promptFile, system, { encoding: "utf8", mode: 0o600, flag: "wx" }); + } catch { + if (promptDir) await rm(promptDir, { recursive: true, force: true }).catch(() => {}); + emit({ + type: "error", + message: "CodeBuddy system prompt could not be staged securely.", + status: 500, + errorType: "upstream_error", + code: "system_prompt_staging_failed", + retryable: false, + }); + return; + } + } + + try { + await runCodingAgentTurn({ + profiles: CODEBUDDY_PROFILES, + provider, + parsed, + incoming, + emit: guardCodeBuddyScaffolding(emit), + buildArgs: (resolved, req, prov) => buildArgs(resolved as CodeBuddyProfile, req, prov, promptFile), + buildEnv: (resolved, apiKey) => buildChildEnv(resolved as CodeBuddyProfile, apiKey), + deps, + }); + } finally { + if (promptDir) await rm(promptDir, { recursive: true, force: true }).catch(() => {}); + } }, }; } diff --git a/src/adapters/qoder/adapter.ts b/src/adapters/qoder/adapter.ts index 1bb8821b0b4..7236ce0ccae 100644 --- a/src/adapters/qoder/adapter.ts +++ b/src/adapters/qoder/adapter.ts @@ -1,4 +1,7 @@ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { AdapterRequest, ProviderAdapter } from "../base"; import { mapReasoningEffort } from "../../reasoning-effort"; import { buildSystemPrompt } from "../coding-agent/protocol"; @@ -13,7 +16,7 @@ export function buildQoderChildEnv(profile: QoderProfile, apiKey: string): Recor } /** Single-shot, tools-disabled Qoder CLI invocation; Codex remains the tool owner. */ -export function buildQoderArgs(parsed: OcxParsedRequest, provider: OcxProviderConfig): string[] { +export function buildQoderArgs(parsed: OcxParsedRequest, provider: OcxProviderConfig, systemPromptFile?: string): string[] { const args = [ "-p", "--output-format", "stream-json", @@ -27,8 +30,7 @@ export function buildQoderArgs(parsed: OcxParsedRequest, provider: OcxProviderCo ]; const effort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); if (effort) args.push("--reasoning-effort", effort); - const system = buildSystemPrompt(parsed); - if (system) args.push("--append-system-prompt", system); + if (systemPromptFile) args.push("--append-system-prompt-file", systemPromptFile); return args; } @@ -123,16 +125,41 @@ export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapt }); return; } - await runCodingAgentTurn({ - profiles: QODER_PROFILES, - provider, - parsed, - incoming, - emit: guardQoderScaffolding(emit), - buildArgs: (_profile, req, prov) => buildQoderArgs(req, prov), - buildEnv: (profile, apiKey) => buildQoderChildEnv(profile as QoderProfile, apiKey), - deps, - }); + // argv is world-readable via process listing, so the folded system+developer prompt is + // staged in a private temp file and passed by path instead of embedded in the arguments. + const system = buildSystemPrompt(parsed); + let promptDir: string | undefined; + let promptFile: string | undefined; + try { + promptDir = system ? await mkdtemp(join(tmpdir(), "ocx-qoder-prompt-")) : undefined; + promptFile = promptDir ? join(promptDir, "system-prompt.txt") : undefined; + if (promptFile) await writeFile(promptFile, system!, { encoding: "utf8", mode: 0o600 }); + } catch { + if (promptDir) await rm(promptDir, { recursive: true, force: true }).catch(() => {}); + emit({ + type: "error", + message: "Qoder system prompt could not be prepared securely.", + status: 500, + errorType: "upstream_error", + code: "prompt_file_failed", + retryable: false, + }); + return; + } + try { + await runCodingAgentTurn({ + profiles: QODER_PROFILES, + provider, + parsed, + incoming, + emit: guardQoderScaffolding(emit), + buildArgs: (_profile, req, prov) => buildQoderArgs(req, prov, promptFile), + buildEnv: (profile, apiKey) => buildQoderChildEnv(profile as QoderProfile, apiKey), + deps, + }); + } finally { + if (promptDir) await rm(promptDir, { recursive: true, force: true }).catch(() => {}); + } }, }; } diff --git a/tests/providers/codebuddy-adapter.test.ts b/tests/providers/codebuddy-adapter.test.ts index f68322752c5..0f96797ceaa 100644 --- a/tests/providers/codebuddy-adapter.test.ts +++ b/tests/providers/codebuddy-adapter.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, readFileSync, statSync } from "node:fs"; import { EventEmitter } from "node:events"; import { Readable, Writable } from "node:stream"; import type { ChildProcess } from "node:child_process"; @@ -115,14 +116,16 @@ describe("codebuddy headless arguments keep tool ownership with Codex", () => { expect(args[args.indexOf("--model") + 1]).toBe("glm-5.3"); }); - test("maps Codex reasoning effort onto --effort and folds the system prompt", () => { + test("maps Codex reasoning effort and references a private system-prompt file", () => { const args = buildArgs( CODEBUDDY_GLOBAL_PROFILE, parsed({ options: { reasoning: "high" }, context: { systemPrompt: ["Be terse."], messages: [] } }), provider(), + "/private/system-prompt.txt", ); expect(args[args.indexOf("--effort") + 1]).toBe("high"); - expect(args[args.indexOf("--append-system-prompt") + 1]).toBe("Be terse."); + expect(args[args.indexOf("--system-prompt-file") + 1]).toBe("/private/system-prompt.txt"); + expect(args).not.toContain("Be terse."); }); }); @@ -186,6 +189,7 @@ describe("codebuddy runTurn fails closed before any spawn", () => { let command = ""; let args: readonly string[] = []; let options: import("node:child_process").SpawnOptions | undefined; + let promptFile = ""; const adapter = createCodeBuddyAdapter(provider(), { platform: "win32", which: () => "C:\\npm\\codebuddy.cmd", @@ -193,6 +197,10 @@ describe("codebuddy runTurn fails closed before any spawn", () => { command = seenCommand; args = seenArgs; options = seenOptions; + const commandLine = seenArgs[3] ?? ""; + const match = commandLine.match(/--system-prompt-file\s+"([^"]+)"/); + promptFile = match?.[1] ?? ""; + expect(readFileSync(promptFile, "utf8")).toBe('Say "hello" & stop'); return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; }, killGraceMs: 20, @@ -202,8 +210,30 @@ describe("codebuddy runTurn fails closed before any spawn", () => { expect(command.toLowerCase()).toContain("cmd.exe"); expect(args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); expect(args[3]).toContain("codebuddy.cmd"); - expect(args[3]).toContain("Say"); + expect(args[3]).not.toContain("Say"); expect(options?.windowsVerbatimArguments).toBe(true); + expect(existsSync(promptFile)).toBe(false); + }); + + test("keeps request-derived prompts out of argv and removes the private staging file", async () => { + let promptFile = ""; + const secret = "private-system-instruction"; + const adapter = createCodeBuddyAdapter(provider(), { + which: () => "/usr/bin/codebuddy", + spawn: (_command, args) => { + expect(args).not.toContain(secret); + const index = args.indexOf("--system-prompt-file"); + expect(index).toBeGreaterThanOrEqual(0); + promptFile = args[index + 1] ?? ""; + expect(readFileSync(promptFile, "utf8")).toBe(secret); + if (process.platform !== "win32") expect(statSync(promptFile).mode & 0o777).toBe(0o600); + return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; + }, + killGraceMs: 20, + }); + + await run(adapter, parsed({ context: { systemPrompt: [secret], messages: [] } })); + expect(existsSync(promptFile)).toBe(false); }); }); diff --git a/tests/providers/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts index 54115043543..4066fc5e995 100644 --- a/tests/providers/qoder-adapter.test.ts +++ b/tests/providers/qoder-adapter.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { EventEmitter } from "node:events"; import { Readable, Writable } from "node:stream"; +import { readFile } from "node:fs/promises"; import type { ChildProcess } from "node:child_process"; import { buildQoderArgs, buildQoderChildEnv, createQoderAdapter } from "../../src/adapters/qoder/adapter"; import { clearQoderBinaryCache, QODER_CN_PROFILE, QODER_GLOBAL_PROFILE, resolveQoderProfile } from "../../src/adapters/qoder/profiles"; @@ -44,6 +45,37 @@ describe("qoder adapter", () => { expect(args).not.toContain("--dangerously-skip-permissions"); }); + test("keeps system and developer prompts out of child-process arguments", async () => { + const secretSystem = "private system instructions"; + const secretDeveloper = "private developer context"; + let args: readonly string[] = []; + let promptFromFile: Promise | undefined; + const adapter = createQoderAdapter(provider(), { + which: () => "/bin/qoder", + spawn: (_command, childArgs) => { + args = childArgs; + const flag = childArgs.indexOf("--append-system-prompt-file"); + promptFromFile = readFile(childArgs[flag + 1]!, "utf8"); + return fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + }, + }); + await adapter.runTurn!(parsed({ + context: { + systemPrompt: [secretSystem], + messages: [ + { role: "developer", content: secretDeveloper, timestamp: 0 }, + { role: "user", content: "hello", timestamp: 0 }, + ], + }, + }), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, () => {}); + + const promptPath = args[args.indexOf("--append-system-prompt-file") + 1]!; + expect(args.join(" ")).not.toContain(secretSystem); + expect(args.join(" ")).not.toContain(secretDeveloper); + expect(await readFile(promptPath, "utf8").catch(() => "removed")).toBe("removed"); + expect(await promptFromFile).toBe(`${secretSystem}\n\n${secretDeveloper}`); + }); + test("keeps Global and CN profiles, executables, destinations, and PAT variables isolated", async () => { expect(resolveQoderProfile("https://qoder.com/")).toBe(QODER_GLOBAL_PROFILE); expect(resolveQoderProfile("https://qoder.cn/")).toBe(QODER_CN_PROFILE); From 9ff15d338365f70dab4c8e086ac4dc2dada0f94d Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:23:31 +0900 Subject: [PATCH 2/2] docs(codebuddy): note deliberate system-prompt replace semantics --- src/adapters/codebuddy/adapter.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/adapters/codebuddy/adapter.ts b/src/adapters/codebuddy/adapter.ts index c7e1b00e52d..67d6c0762a9 100644 --- a/src/adapters/codebuddy/adapter.ts +++ b/src/adapters/codebuddy/adapter.ts @@ -58,6 +58,8 @@ export function buildArgs( ]; const effort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); if (effort) args.push("--effort", effort); + // The vendor CLI documents no file-backed append flag, so the staged prompt replaces the default. + // That default targets interactive tool use, which this adapter disables end to end. if (systemPromptFile) args.push("--system-prompt-file", systemPromptFile); // profile is retained for symmetry with the region-isolated design and future per-region flags. void profile;