Skip to content

Commit 5e0323d

Browse files
hobostayTest Userclaude
authored
fix(daemon): value-redact secrets in Gemini/NanoBot/OpenCode provider output (#10)
The daemon runs provider CLIs in full-access mode with the daemon's env (via buildProviderEnv, which spreads process.env), so secret-named vars such as provider API keys are present in the spawned process. Task prompts are workspace-member-controlled, so a prompted provider can echo a secret into its stdout/stderr, which then flows back via task output / error diagnostics / streamed callbacks. The agent-router execution path (claude, codex, openclaw, hermes) already scrubs secret values from stdout/stderr with buildRedactions/redactText (agent-router/subprocess.ts). The direct provider-runtime paths did not: - runGeminiProviderTask (gemini) - execProviderCommand, the shared helper used by runNanoBotProviderTask (nanobot) and runOpenCodeProviderTask (opencode) These accumulated raw stdout/stderr and, for execProviderCommand, forwarded raw chunks to the external onStdout/onStderr callbacks. The legacy sanitizeDiagnosticText only catches recognizable shapes (KEY=value, sk-…, Bearer …, URL params), so a bare secret value leaked. Apply the same value-based redaction to these paths via a buildProviderRedactions helper, and forward the redacted chunks to external callbacks. This brings gemini/nanobot/opencode to parity with the agent-router providers. runCodexProviderTaskAttempt / runClaudeProviderTask are left untouched: runProviderTask routes claude and codex through the agent-router, so those direct functions are currently unreached. Adds a Gemini regression test that echoes a bare secret value to stdout and asserts the returned output is redacted. Verified it fails without the fix and passes with it. No new test regressions (3 pre-existing resume-session test failures also fail on main). Co-authored-by: Test User <test@example.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7f14a1a commit 5e0323d

2 files changed

Lines changed: 61 additions & 8 deletions

File tree

packages/daemon/src/provider-runtime.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1295,6 +1295,37 @@ test("runProviderTask includes sanitized Claude stderr tail on empty stdout", as
12951295
}
12961296
});
12971297

1298+
test("runProviderTask value-redacts bare secret values leaked into Gemini output", async () => {
1299+
// Gemini runs through runGeminiProviderTask (LocalSandbox.exec), not the
1300+
// agent-router, so its stdout/stderr were not value-redacted. The legacy
1301+
// diagnostic sanitizer only catches recognizable shapes (KEY=value, sk-…,
1302+
// Bearer …); a bare secret value echoed by the provider is only scrubbed by
1303+
// the value-based redaction added here, mirroring the agent-router path.
1304+
const workDir = mkdtempSync(join(tmpdir(), "agent-space-gemini-redact-"));
1305+
const binPath = join(workDir, "gemini");
1306+
writeFileSync(binPath, "#!/bin/sh\nprintf '%s\\n' \"leaked: $CUSTOM_API_TOKEN\"\n", "utf8");
1307+
chmodSync(binPath, 0o755);
1308+
const runtime: ProviderRuntimeRecord = {
1309+
id: "runtime-gemini-redact-test",
1310+
workspaceId: "default",
1311+
provider: "gemini",
1312+
name: "Gemini",
1313+
status: "online",
1314+
metadata: { executablePath: binPath, mode: "remote" },
1315+
};
1316+
1317+
try {
1318+
const result = await runProviderTask(runtime, "summarize", workDir, {
1319+
taskTimeoutMs: 1_000,
1320+
contextEnv: { CUSTOM_API_TOKEN: "gamma-delta-9988-echo" },
1321+
});
1322+
assert.equal(result.output.includes("gamma-delta-9988-echo"), false);
1323+
assert.equal(result.output.includes("[redacted:CUSTOM_API_TOKEN]"), true);
1324+
} finally {
1325+
rmSync(workDir, { recursive: true, force: true });
1326+
}
1327+
});
1328+
12981329
test("runProviderTask reports Claude timeouts with execution diagnostics", async () => {
12991330
const fixture = createClaudeRuntimeFixture([
13001331
"#!/bin/sh",

packages/daemon/src/provider-runtime.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type AgentRouterEvent,
1414
type AgentRouterHarness,
1515
} from "./agent-router/index.ts";
16+
import { buildRedactions, redactText } from "./agent-router/utils.ts";
1617
import { clearTaskOutputArtifacts } from "./bundle.ts";
1718
import { readGoogleWorkspaceReadiness } from "./google-workspace-readiness.ts";
1819
import { buildOpenClawProviderHealthSnapshot, inspectOpenClawDaemonAuthHealth } from "./openclaw-health.ts";
@@ -1624,13 +1625,16 @@ async function runGeminiProviderTask(
16241625
let finalOutput = "";
16251626
let stderr = "";
16261627
let stdoutBuffer = "";
1628+
const providerEnv = buildProviderEnv(runtime, options.contextEnv);
1629+
const redactions = buildProviderRedactions(providerEnv);
16271630
const result = await sandbox.exec({
16281631
command: runtime.metadata.executablePath,
16291632
args: providerArgs,
16301633
timeoutMs: taskTimeoutMs,
1631-
env: buildProviderEnv(runtime, options.contextEnv),
1634+
env: providerEnv,
16321635
onStdout: (chunk) => {
1633-
stdoutBuffer += chunk;
1636+
const value = redactText(chunk, redactions);
1637+
stdoutBuffer += value;
16341638
const lines = stdoutBuffer.split(/\r?\n/);
16351639
stdoutBuffer = lines.pop() ?? "";
16361640

@@ -1656,7 +1660,7 @@ async function runGeminiProviderTask(
16561660
}
16571661
},
16581662
onStderr: (chunk) => {
1659-
stderr += chunk;
1663+
stderr += redactText(chunk, redactions);
16601664
},
16611665
});
16621666

@@ -1871,6 +1875,20 @@ function buildProviderEnv(runtime: ProviderRuntimeRecord, extra?: NodeJS.Process
18711875
return env;
18721876
}
18731877

1878+
// Builds value-based redaction patterns for every secret-named entry in the
1879+
// provider env, mirroring the agent-router path (see buildRedactions). Used to
1880+
// scrub secret values from provider stdout/stderr before they are stored,
1881+
// streamed to clients, or surfaced in error diagnostics.
1882+
function buildProviderRedactions(env: NodeJS.ProcessEnv) {
1883+
const stringEnv: Record<string, string> = {};
1884+
for (const [key, value] of Object.entries(env)) {
1885+
if (typeof value === "string") {
1886+
stringEnv[key] = value;
1887+
}
1888+
}
1889+
return buildRedactions(stringEnv);
1890+
}
1891+
18741892
function buildClaudeEnv(runtime: ProviderRuntimeRecord, extra?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
18751893
const env = buildProviderEnv(runtime, extra);
18761894
for (const [key, value] of Object.entries(process.env)) {
@@ -1913,20 +1931,24 @@ async function execProviderCommand(
19131931
workDir,
19141932
});
19151933

1934+
const providerEnv = buildProviderEnv(runtime, env);
1935+
const redactions = buildProviderRedactions(providerEnv);
19161936
let stdout = "";
19171937
let stderr = "";
19181938
const result = await sandbox.exec({
19191939
command: runtime.metadata.executablePath,
19201940
args,
19211941
timeoutMs,
1922-
env: buildProviderEnv(runtime, env),
1942+
env: providerEnv,
19231943
onStdout: (chunk) => {
1924-
stdout += chunk;
1925-
callbacks?.onStdout?.(chunk);
1944+
const value = redactText(chunk, redactions);
1945+
stdout += value;
1946+
callbacks?.onStdout?.(value);
19261947
},
19271948
onStderr: (chunk) => {
1928-
stderr += chunk;
1929-
callbacks?.onStderr?.(chunk);
1949+
const value = redactText(chunk, redactions);
1950+
stderr += value;
1951+
callbacks?.onStderr?.(value);
19301952
},
19311953
});
19321954

0 commit comments

Comments
 (0)