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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user