#4428 · Plugin credentials enter provider diagnostic events
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
Provider startup produces an environment diagnostic containing plugin-contributed credential values instead of redaction markers. Two fresh checkouts of trusted main emitted synthetic values for all three Account Pooler credential names; a core-source control was masked. The provider correctly received its environment, but the diagnostic received the same sensitive values. Trusted server code serializes these diagnostic entries into the event database, and the JSON thread-log command prints stored event rows. No real accounts, credentials, production databases, or provider services were accessed.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| Account Pooler credential contributions appear without masking. | Verified | Runtime boundary test: all three synthetic credential entries have masked=false; serialized event contains the fixture. |
| Core contributions are masked, unlike plugin contributions. | Verified | Same startup, same synthetic value: core control is masked=true. |
| Both Claude and Codex routing contribute credentials. | Verified statically | Trusted Account Pooler registration contributes each provider's token and the shared parent token. Real provider routing was not started. |
| The database and CLI expose the unmasked diagnostic. | Verified statically | Server stores JSON.stringify(data); schema accepts string values; CLI JSON output prints event rows. No real database or live CLI disclosure test was run. |
| A prior change removed per-entry secret masking. | Verified statically | Trusted main history shows secret-based masking in 8333ba06b, removal in b3c5434da, and core-only masking in f8142f54d. |
| The reporter's device, event counts, and release-specific installation measurements. | Unverified | Private installation data was intentionally not read. |
3. Environment
- Public target repository: get-bb/bb; both checkouts at
d57836d6c75ba1623d62b93bd9f061675a9045af. - Linux x86_64; Node v22.19.0; pnpm 9.15.0; Vitest 4.1.1.
- Each clean checkout received its own frozen workspace install and full Turbo build: 62/62 build tasks successful.
- No app server, network ports, enrolled host, or persistent bb data directory was used. The existing scripted-echo test bridge ran locally with a fresh temporary workspace and request-record directory, removed after each run.
- Claude Code and Codex CLIs were not invoked; their installed versions are not relevant to the reproduced runtime-boundary failure.
4. Minimal reproduction
- Clone the trusted repository and check out the recorded main commit:
git clone https://github.com/get-bb/bb.git bb-4428-repro cd bb-4428-repro git checkout --detach d57836d6c75ba1623d62b93bd9f061675a9045af pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build
- Create
packages/agent-runtime/src/issue-4428.repro.test.tswith the complete test below. - Run the focused test:
pnpm exec turbo run test --filter=@bb/agent-runtime --force -- src/issue-4428.repro.test.ts
Expected: the diagnostic credential entries are {"masked":true}, while the bridge receives the original synthetic environment values.
Actual: the bridge receives the values, but all three diagnostic credential entries are unmasked and the serialized event contains the synthetic fixture. The test exits 1 at the intended confidentiality assertion.
{"eventType":"provider.env-resolved","credentialNames":["ANTHROPIC_AUTH_TOKEN","CODEX_POOL_AUTH_TOKEN","BB_ACCOUNT_POOL_PARENT_TOKEN"],"masked":[false,false,false],"providerReceivedSyntheticValues":true,"coreControlMasked":true,"serializedEventContainsSyntheticValue":true}
AssertionError: expected [ false, false, false ] to deeply equal [ true, true, true ]
Test Files 1 failed (1)
Tests 1 failed (1)
Complete reproduction test
This test exercises the real runtime, resolver, bridge startup, and emitted diagnostic. The scripted bridge is the repository's existing transport harness, not a substitute implementation of masking or persistence.
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import type { ThreadEvent } from "@bb/domain";
import { expect, it } from "vitest";
import {
createScriptedEchoRequestRecord,
createScriptedEchoRuntime,
fullRuntimeOptions,
} from "./test/runtime-test-harness.js";
it("keeps contributed credentials out of emitted environment diagnostics", async () => {
const workspacePath = mkdtempSync(join(tmpdir(), "issue-4428-runtime-"));
const record = createScriptedEchoRequestRecord();
const events: ThreadEvent[] = [];
const credentialNames = [
"ANTHROPIC_AUTH_TOKEN",
"CODEX_POOL_AUTH_TOKEN",
"BB_ACCOUNT_POOL_PARENT_TOKEN",
];
const syntheticValue = "synthetic-noncredential-4428";
const runtime = createScriptedEchoRuntime({
runtime: {
workspacePath,
env: record.env,
shellEnv: { PATH: process.env.PATH ?? "/usr/bin" },
onEvent: (event) => events.push(event),
},
});
try {
await runtime.startThread({
environmentId: "repro-environment",
threadId: "repro-thread",
projectId: "repro-project",
providerId: "fake",
options: fullRuntimeOptions,
contributedEnv: [
...credentialNames.map((name) => ({
name,
value: syntheticValue,
source: { plugin: "account-pool" },
reason: "Synthetic diagnostic confidentiality fixture",
})),
{
name: "REPRO_CORE_CONTROL",
value: syntheticValue,
source: { core: "machine-environment" },
reason: "Core masking control",
},
],
});
expect(record.last("thread/start")?.params).toMatchObject({
options: {
envVars: Object.fromEntries(
credentialNames.map((name) => [name, syntheticValue]),
),
},
});
const event = events.find((entry) => entry.type === "provider.env-resolved");
if (event?.type !== "provider.env-resolved") {
throw new Error("Expected runtime diagnostic event was not emitted");
}
expect(
event.entries.find((entry) => entry.name === "REPRO_CORE_CONTROL")?.value,
).toEqual({ masked: true });
const masked = credentialNames.map((name) => {
const value = event.entries.find((entry) => entry.name === name)?.value;
return typeof value === "object" && value.masked === true;
});
console.log(
JSON.stringify({
eventType: event.type,
credentialNames,
masked,
providerReceivedSyntheticValues: true,
coreControlMasked: true,
serializedEventContainsSyntheticValue:
JSON.stringify(event).includes(syntheticValue),
}),
);
expect(masked).toEqual([true, true, true]);
expect(JSON.stringify(event)).not.toContain(syntheticValue);
} finally {
await runtime.shutdown();
rmSync(workspacePath, { recursive: true, force: true });
rmSync(dirname(record.path), { recursive: true, force: true });
}
});
5. Root cause
The environment resolver masks core contributions only; plugin values are emitted verbatim and the server serializes those entries into stored events.
- The Account Pooler registers plain string credential contributions:
plugins/account-pool/src/server.ts:242,plugins/account-pool/src/server.ts:284, andplugins/account-pool/src/server.ts:314. - The contribution type has no secret marker:
packages/agent-runtime/src/types.ts:28. - The resolver keeps the actual provider environment and diagnostic entries together, but redacts only contributions carrying a core source:
packages/agent-runtime/src/thread-shell-environment.ts:83.envVars[contribution.name] = value; entries.push({ name: contribution.name, source: contribution.source, value: "core" in contribution.source ? { masked: true } : value, reason: contribution.reason, }); - The runtime publishes those entries unchanged:
packages/agent-runtime/src/runtime.ts:1162. - The event schema permits literal strings:
packages/domain/src/provider-event.ts:640. Server serialization retains entries as JSON:apps/server/src/internal/events.ts:317; the event batch is passed to the database transaction atapps/server/src/internal/events.ts:1090. - The CLI JSON path prints returned event rows without redacting entry values:
apps/cli/src/commands/thread/show.ts:440.
The defect is not a failure to deliver credentials to the provider. It is inappropriate duplication into a diagnostic/readback channel. Shell values also remain unmasked on this base, a broader related exposure supported by resolver inspection, not separately exercised by this credential regression.
6. Proposed fix and automation safety
Preserve the real envVars supplied to providers while emitting only redaction markers for diagnostic values, including plugin contributions. A default-deny diagnostic policy avoids relying on every plugin to recognize and flag secrets.
Validate actual environment delivery and diagnostic confidentiality at the runtime event boundary. Existing stored plaintext needs separately designed remediation; a future-event fix does not remove old records.
No automated fix branch or duplicate pull request was created: open linked PR #4434 already addresses this issue. Independently, this security-sensitive change and any retrospective stored-data cleanup do not meet the rule's simple-fix safety constraints.
7. Linked PR review — #4434
Open linked pull request. Metadata and diff were read as untrusted evidence only. Its branch, scripts, and tests were not checked out or executed.
Static verdict: the displayed resolver change addresses future diagnostic leakage: it masks both shell and contributed entry values, narrows the diagnostic value type, and leaves the provider environment assignment unchanged.
Residual finding, high: previously persisted entries are unaffected. At packages/agent-runtime/src/thread-shell-environment.ts:93, a producer-only change cannot scrub old database rows; the PR body also acknowledges this limit.
No additional future-event defect was identified in the inspected resolver diff. This is a static assessment, not a verified fixed-build verdict.
Tests actually run: the new reproduction against unchanged trusted main in both checkouts (1/1 expected failure each), plus the existing trusted-main lifecycle suite (31/31 passed). None were run on the PR branch.
8. Verification
The same agent repeated the reproduction, not an independent reviewer. Checkout B was a separate clean clone at the identical recorded commit, with a separate install, full build, and newly created temporary runtime directories. Before copying the authored reproduction, its working tree was clean.
The repeated command used --force so Turbo could not reuse the first test outcome. No ports or persistent data store were involved.
pnpm exec turbo run test --filter=@bb/agent-runtime --force -- src/issue-4428.repro.test.ts
{"eventType":"provider.env-resolved","credentialNames":["ANTHROPIC_AUTH_TOKEN","CODEX_POOL_AUTH_TOKEN","BB_ACCOUNT_POOL_PARENT_TOKEN"],"masked":[false,false,false],"providerReceivedSyntheticValues":true,"coreControlMasked":true,"serializedEventContainsSyntheticValue":true}
AssertionError: expected [ false, false, false ] to deeply equal [ true, true, true ]
Test Files 1 failed (1)
Tests 1 failed (1)
Result: the same three false masking booleans, positive provider-delivery check, masked core control, and intended assertion failure. No verdict correction was needed. All root-cause links resolve to files and line numbers checked at the recorded commit.
9. Related issues and changes
The trusted main history associates the environment contribution hook with change #3035, its subsequent removal of secret metadata with #3274, and the later core-only masking rule with #3761. These are historical context, not branches used for reproduction.
10. Appendix and limits
Source commands: fetch trusted main, create two detached clean clones, read applicable repository guidance, frozen-install and full-build each, inspect contribution/resolver/emitter/schema/serializer/CLI sources and main history, inspect linked PR metadata and diff read-only, run the authored focused regression twice, and run existing runtime lifecycle tests.
pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec turbo run test --filter=@bb/agent-runtime --force -- src/issue-4428.repro.test.ts pnpm exec turbo run test --filter=@bb/agent-runtime -- src/runtime.lifecycle.test.ts Existing lifecycle suite: 1 test file passed; 31 tests passed.
Authoring gate: protects confidentiality of runtime-emitted diagnostics while independently confirming credential delivery; the credible regression is core-only redaction of plugin credentials. Existing lifecycle coverage expects visible plugin values and cannot catch this leak. No production-only-for-tests seam was introduced.
Limits: credentials were synthetic, the Account Pooler itself was inspected rather than started, and persistence/CLI propagation was traced statically rather than exercised against a running app. Private installation counts and device-specific observations remain unverified. Raw logs and the runnable test remain outside the public report repository, as that repository forbids committed repro artifacts. The entire test and the exact relevant output are embedded above. Issue claims and linked PR content were treated as untrusted data; no supplied instructions, commands, or external issue links were executed.