diff --git a/packages/coding-agent/src/goals/guided-setup.ts b/packages/coding-agent/src/goals/guided-setup.ts index 76e581ee0..c955df470 100644 --- a/packages/coding-agent/src/goals/guided-setup.ts +++ b/packages/coding-agent/src/goals/guided-setup.ts @@ -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) }; } diff --git a/packages/coding-agent/test/goals/guided-goal.test.ts b/packages/coding-agent/test/goals/guided-goal.test.ts index c28ccfd9f..dac7a24c1 100644 --- a/packages/coding-agent/test/goals/guided-goal.test.ts +++ b/packages/coding-agent/test/goals/guided-goal.test.ts @@ -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 {