fix(coding-agent): hid secrets from advisor prompts

Stopped restoring placeholders inside opaque assistant thinking blocks and threaded the configured secret obfuscator into advisor session-update prompts.

Added regression coverage for thinking preservation and advisor prompt redaction.

Fixes #3237
This commit is contained in:
roboomp
2026-06-22 07:05:23 +00:00
parent 320261fca9
commit 7dadeab331
6 changed files with 45 additions and 13 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed hide-secrets handling so advisor session updates are redacted before the advisor model sees them and opaque assistant thinking blocks are no longer deobfuscated.
## [16.1.14] - 2026-06-22
### Added
@@ -5,6 +5,7 @@ import { createAdvisorMessageCard } from "../../modes/components/advisor-message
import { getThemeByName } from "../../modes/theme/theme";
import { formatSessionHistoryMarkdown } from "../../session/session-history-format";
import { YieldQueue } from "../../session/yield-queue";
import { SecretObfuscator } from "../../secrets/obfuscator";
import {
ADVISOR_READONLY_TOOL_NAMES,
AdviseTool,
@@ -447,6 +448,28 @@ describe("advisor", () => {
expect(promptInputs[0]).not.toContain("note");
});
it("obfuscates session updates before prompting the advisor", async () => {
const secret = "ADVISOR_SECRET_TOKEN_123";
const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]);
const placeholder = obfuscator.obfuscate(secret);
const promptInputs: string[] = [];
const agent = makeAgent(promptInputs);
const messages: AgentMessage[] = [{ role: "user", content: `token ${secret}`, timestamp: 1 } as AgentMessage];
const host: AdvisorRuntimeHost = {
snapshotMessages: () => messages,
enqueueAdvice: () => {},
obfuscator,
};
const runtime = new AdvisorRuntime(agent, host);
runtime.onTurnEnd();
await Promise.resolve();
expect(promptInputs).toHaveLength(1);
expect(promptInputs[0]).toContain(placeholder);
expect(promptInputs[0]).not.toContain(secret);
});
it("expands plan-mode context once, then collapses an unchanged re-injection", async () => {
const promptInputs: string[] = [];
const agent = makeAgent(promptInputs);
+6 -1
View File
@@ -2,6 +2,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction";
import { logger } from "@oh-my-pi/pi-utils";
import { formatSessionHistoryMarkdown, PRIMARY_CONTEXT_CUSTOM_TYPES } from "../session/session-history-format";
import type { SecretObfuscator } from "../secrets/obfuscator";
/** Minimal slice of `Agent` the runtime drives — satisfied by pi-agent-core `Agent`. */
export interface AdvisorAgent {
@@ -16,6 +17,8 @@ export interface AdvisorRuntimeHost {
snapshotMessages(): AgentMessage[];
/** Surface one advice note to the primary (enqueues into the session YieldQueue). */
enqueueAdvice(note: string, severity?: "nit" | "concern" | "blocker"): void;
/** Redact primary transcript bytes before they reach the advisor model. */
obfuscator?: SecretObfuscator;
/**
* Pre-prompt context maintenance for the advisor's own append-only context.
* Promotes the advisor model to a larger sibling when its context nears the
@@ -181,7 +184,9 @@ export class AdvisorRuntime {
expandPrimaryContext: true,
});
if (!md.trim()) return null;
return `### Session update\n\n${md}`;
const text = `### Session update\n\n${md}`;
const obfuscator = this.host.obfuscator;
return obfuscator?.hasSecrets() ? obfuscator.obfuscate(text) : text;
}
/**
@@ -259,9 +259,9 @@ export function deobfuscateAgentMessages(obfuscator: SecretObfuscator, messages:
}
/**
* Restore placeholders in assistant content: visible text, thinking text, and
* tool-call arguments/intent/rawBlock. Signatures and redacted-thinking bytes
* are opaque provider-replay data and pass through byte-identical.
* Restore placeholders in assistant content: visible text and tool-call
* arguments/intent/rawBlock. Thinking and signatures are opaque
* provider-replay/hidden-reasoning data and pass through byte-identical.
*/
export function deobfuscateAssistantContent(
obfuscator: SecretObfuscator,
@@ -276,12 +276,6 @@ export function deobfuscateAssistantContent(
changed = true;
return { ...block, text };
}
if (block.type === "thinking") {
const thinking = obfuscator.deobfuscate(block.thinking);
if (thinking === block.thinking) return block;
changed = true;
return { ...block, thinking };
}
if (block.type === "toolCall") {
const args = deobfuscateToolArguments(obfuscator, block.arguments);
const intent = block.intent === undefined ? undefined : obfuscator.deobfuscate(block.intent);
@@ -1936,6 +1936,7 @@ export class AgentSession {
snapshotMessages: () => this.agent.state.messages,
enqueueAdvice,
maintainContext: incomingTokens => this.#maintainAdvisorContext(incomingTokens),
obfuscator: this.#obfuscator,
});
if (seedToCurrent) {
this.#advisorRuntime.seedTo(this.agent.state.messages.length);
@@ -252,7 +252,7 @@ describe("SecretObfuscator cross-turn cache stability", () => {
});
describe("deobfuscateAgentMessages (display restore)", () => {
it("restores assistant content and model-generated summaries, leaving raw user text untouched", () => {
it("restores assistant text and tool calls while leaving raw user text and thinking untouched", () => {
const secret = "DISPLAY_SECRET_TOKEN_123";
const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]);
const placeholder = obfuscator.obfuscate(secret);
@@ -302,11 +302,16 @@ describe("deobfuscateAgentMessages (display restore)", () => {
const restored = deobfuscateAgentMessages(obfuscator, [userMsg, assistantMsg, branchSummary, compactionSummary]);
// Assistant text, thinking, and tool-call args/intent are restored to the real secret.
// Assistant text and tool-call args/intent are restored to the real secret.
const restoredAssistant = restored[1] as AssistantMessage;
const assistantJson = JSON.stringify(restoredAssistant.content);
expect(assistantJson).toContain(secret);
expect(assistantJson).not.toContain(placeholder);
expect(assistantJson).not.toContain(`answer ${placeholder}`);
expect(assistantJson).not.toContain(`path ${placeholder}`);
expect(assistantJson).not.toContain(`intent ${placeholder}`);
// Opaque thinking is never walked: placeholder-shaped bytes survive unchanged.
expect(assistantJson).toContain(`reason ${placeholder}`);
expect(assistantJson).not.toContain(`reason ${secret}`);
// Model-generated summaries are restored.
expect((restored[2] as { summary: string }).summary).toBe(`branch ${secret}`);
expect((restored[3] as { summary: string; shortSummary?: string }).summary).toBe(`compact ${secret}`);