fix(coding-agent): obfuscate guided-goal transcript before provider calls

runGuidedGoalTurn sent the rendered interview transcript to the plan/slow
provider as raw text, so a secret typed into the rough goal or an answer
bypassed the session's redaction contract. Route the transcript through the
session obfuscator before the request and deobfuscate the echoed question /
objective before it is displayed or the goal starts (no-op when no secrets
are configured).
This commit is contained in:
metaphorics
2026-06-14 08:51:59 +09:00
parent 06528f9d0d
commit 219fa0765c
2 changed files with 50 additions and 6 deletions
@@ -79,11 +79,16 @@ export async function runGuidedGoalTurn(
const userPrompt = prompt.render(guidedGoalInterviewPrompt, {
messages: options.messages.map(message => ({ label: message.role.toUpperCase(), content: message.content })),
});
// Secret obfuscation: route the user-authored transcript through the session obfuscator the
// same way normal turns do, so an API key / secret typed into the rough goal or an answer is
// never sent verbatim to the plan/slow provider. Deobfuscated again below before display/use.
const obfuscator = session.obfuscator;
const promptText = obfuscator?.hasSecrets() ? obfuscator.obfuscate(userPrompt) : userPrompt;
const response = await instrumentedCompleteSimple(
resolved.model,
{
systemPrompt: [prompt.render(guidedGoalSystemPrompt)],
messages: [{ role: "user", content: [{ type: "text", text: userPrompt }], timestamp: Date.now() }],
messages: [{ role: "user", content: [{ type: "text", text: promptText }], timestamp: Date.now() }],
tools: [RESPOND_TOOL],
},
{
@@ -103,13 +108,26 @@ export async function runGuidedGoalTurn(
}
const call = extractToolCall(response, RESPOND_TOOL_NAME);
let result: GuidedGoalTurnResult;
if (call) {
return parseGuidedGoalPayload(parseToolArguments(call.arguments));
result = parseGuidedGoalPayload(parseToolArguments(call.arguments));
} else {
const text = extractTextContent(response);
if (!text) {
throw new Error("guided goal returned an invalid response");
}
result = parseGuidedGoalPayload(parseJsonPayload(text));
}
const text = extractTextContent(response);
if (!text) {
throw new Error("guided goal returned an invalid response");
// Reverse the obfuscation: restore any secret placeholders the model echoed back before the
// question/objective is shown or the goal is started.
if (!obfuscator?.hasSecrets()) return result;
if (result.kind === "question") {
return {
kind: "question",
question: obfuscator.deobfuscate(result.question),
objective: result.objective !== undefined ? obfuscator.deobfuscate(result.objective) : undefined,
};
}
return parseGuidedGoalPayload(parseJsonPayload(text));
return { kind: "ready", objective: obfuscator.deobfuscate(result.objective) };
}
@@ -171,6 +171,32 @@ describe("guided goal setup", () => {
expect(result).toEqual({ kind: "question", question: "What is done?", objective: "Ship the feature." });
});
it("obfuscates secrets in the transcript before the request and deobfuscates the echoed objective", async () => {
const obfuscator = {
hasSecrets: () => true,
obfuscate: (text: string) => text.replaceAll("SECRET123", "#S0#"),
deobfuscate: (text: string) => text.replaceAll("#S0#", "SECRET123"),
};
const session = { ...createSession(), obfuscator } as unknown as AgentSession;
const complete = spyOn(core, "instrumentedCompleteSimple").mockResolvedValue(
// The model echoes the obfuscated placeholder back inside its objective.
mockResponse({ kind: "ready", objective: "Rotate the key #S0# and redeploy." }) as never,
);
const result = await runGuidedGoalTurn(session, {
messages: [{ role: "user", content: "my api key is SECRET123, automate rotation" }],
});
// The provider never sees the raw secret — only the placeholder.
const sentContext = complete.mock.calls[0]?.[1] as { messages: Array<{ content: Array<{ text: string }> }> };
const sentText = sentContext.messages[0]!.content[0]!.text;
expect(sentText).not.toContain("SECRET123");
expect(sentText).toContain("#S0#");
// The objective is restored to the real secret before the goal starts.
expect(result).toEqual({ kind: "ready", objective: "Rotate the key SECRET123 and redeploy." });
});
it("salvages the latest guided objective when the turn cap ends on a question without one", async () => {
const harness = await createInteractiveGoalHarness();
try {