From c1a28d54c2c3cb0041fd9d7016d5ce372b0c2e76 Mon Sep 17 00:00:00 2001 From: majdyz Date: Wed, 8 Apr 2026 18:41:58 +0700 Subject: [PATCH] fix(frontend/builder): require manual action confirmation and prevent prompt injection - Replace auto-apply with per-action Apply buttons; users must explicitly confirm each AI suggestion before the graph is mutated - Accumulate parsedActions across all assistant messages so multi-turn suggestions remain visible rather than disappearing after the next turn - Escape < and > in node names/descriptions before embedding in XML prompt context to prevent AI prompt injection via crafted node labels - Add MAX_EDGES cap (200) in serializeGraphForChat to mirror the MAX_NODES limit and prevent token overruns on dense graphs - Add Escape key handler in the hook to close the chat panel - Add helpers.test.ts with unit tests for buildSeedPrompt, extractTextFromParts, and XML sanitization --- .../BuilderChatPanel/BuilderChatPanel.tsx | 34 +++++- .../__tests__/BuilderChatPanel.test.tsx | 31 ++++- .../__tests__/helpers.test.ts | 111 ++++++++++++++++++ .../components/BuilderChatPanel/helpers.ts | 43 +++++-- .../BuilderChatPanel/useBuilderChatPanel.ts | 67 +++++------ 5 files changed, 233 insertions(+), 53 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/helpers.test.ts diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/BuilderChatPanel.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/BuilderChatPanel.tsx index cd763c5756..c38772a145 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/BuilderChatPanel.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/BuilderChatPanel.tsx @@ -34,6 +34,8 @@ export function BuilderChatPanel({ className, isGraphLoaded }: Props) { sessionId, nodes, parsedActions, + appliedActionKeys, + handleApplyAction, seedMessageId, } = useBuilderChatPanel({ isGraphLoaded }); @@ -91,6 +93,8 @@ export function BuilderChatPanel({ className, isGraphLoaded }: Props) { streamError={error} nodes={nodes} parsedActions={parsedActions} + appliedActionKeys={appliedActionKeys} + onApplyAction={handleApplyAction} seedMessageId={seedMessageId} messagesEndRef={messagesEndRef} /> @@ -147,6 +151,8 @@ interface MessageListProps { streamError: Error | undefined; nodes: CustomNode[]; parsedActions: GraphAction[]; + appliedActionKeys: Set; + onApplyAction: (action: GraphAction) => void; seedMessageId: string | null; messagesEndRef: React.RefObject; } @@ -158,6 +164,8 @@ function MessageList({ streamError, nodes, parsedActions, + appliedActionKeys, + onApplyAction, seedMessageId, messagesEndRef, }: MessageListProps) { @@ -246,14 +254,17 @@ function MessageList({ {parsedActions.length > 0 && (

- AI applied these changes + Suggested changes

{parsedActions.map((action) => { + const key = getActionKey(action); return ( ); })} @@ -268,9 +279,13 @@ function MessageList({ function ActionItem({ action, nodes, + isApplied, + onApply, }: { action: GraphAction; nodes: CustomNode[]; + isApplied: boolean; + onApply: (action: GraphAction) => void; }) { const nodeName = (id: string) => nodes.find((n) => n.id === id)?.data.metadata?.customized_name || @@ -285,9 +300,18 @@ function ActionItem({ return (
{label} - - Applied - + {isApplied ? ( + + Applied + + ) : ( + + )}
); } diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/BuilderChatPanel.test.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/BuilderChatPanel.test.tsx index 5c0401c410..64f01fb8e7 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/BuilderChatPanel.test.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/BuilderChatPanel.test.tsx @@ -39,6 +39,7 @@ function makeMockHook( sessionId: null, nodes: [], parsedActions: [], + appliedActionKeys: new Set(), handleApplyAction: vi.fn(), seedMessageId: null, ...overrides, @@ -119,7 +120,7 @@ describe("BuilderChatPanel", () => { expect(screen.getByText("This agent searches the web.")).toBeDefined(); }); - it("renders applied actions section when parsedActions are present", () => { + it("renders suggested changes section when parsedActions are present", () => { mockUseBuilderChatPanel.mockReturnValue( makeMockHook({ isOpen: true, @@ -134,11 +135,11 @@ describe("BuilderChatPanel", () => { }), ); render(); - expect(screen.getByText("AI applied these changes")).toBeDefined(); - expect(screen.getByText("Applied")).toBeDefined(); + expect(screen.getByText("Suggested changes")).toBeDefined(); + expect(screen.getByText("Apply")).toBeDefined(); }); - it("shows applied badge for actions", () => { + it("shows Apply button for unapplied actions and Applied badge for applied actions", () => { const action = { type: "update_node_input" as const, nodeId: "1", @@ -149,10 +150,32 @@ describe("BuilderChatPanel", () => { makeMockHook({ isOpen: true, parsedActions: [action], + appliedActionKeys: new Set(["1:query"]), }), ); render(); expect(screen.getByText("Applied")).toBeDefined(); + expect(screen.queryByText("Apply")).toBeNull(); + }); + + it("calls handleApplyAction when Apply button is clicked", () => { + const handleApplyAction = vi.fn(); + const action = { + type: "update_node_input" as const, + nodeId: "1", + key: "query", + value: "AI news", + }; + mockUseBuilderChatPanel.mockReturnValue( + makeMockHook({ + isOpen: true, + parsedActions: [action], + handleApplyAction, + }), + ); + render(); + fireEvent.click(screen.getByText("Apply")); + expect(handleApplyAction).toHaveBeenCalledWith(action); }); it("calls sendMessage when the user submits a message", () => { diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/helpers.test.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/helpers.test.ts new file mode 100644 index 0000000000..373971cd5b --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/helpers.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + buildSeedPrompt, + extractTextFromParts, + serializeGraphForChat, +} from "../helpers"; +import type { CustomNode } from "../../FlowEditor/nodes/CustomNode/CustomNode"; + +describe("extractTextFromParts", () => { + it("returns empty string for empty array", () => { + expect(extractTextFromParts([])).toBe(""); + }); + + it("concatenates text parts in order", () => { + const parts = [ + { type: "text", text: "Hello, " }, + { type: "text", text: "world!" }, + ]; + expect(extractTextFromParts(parts)).toBe("Hello, world!"); + }); + + it("ignores non-text parts", () => { + const parts = [ + { type: "text", text: "visible" }, + { type: "tool-call", text: "ignored" }, + { type: "text", text: " text" }, + ]; + expect(extractTextFromParts(parts)).toBe("visible text"); + }); + + it("returns empty string when all parts are non-text", () => { + const parts = [{ type: "tool-result" }, { type: "image" }]; + expect(extractTextFromParts(parts)).toBe(""); + }); +}); + +describe("buildSeedPrompt", () => { + it("wraps the summary in tags", () => { + const result = buildSeedPrompt("some graph summary"); + expect(result).toContain( + "\nsome graph summary\n", + ); + }); + + it("includes instructions for update_node_input format", () => { + const result = buildSeedPrompt(""); + expect(result).toContain('"action": "update_node_input"'); + }); + + it("includes instructions for connect_nodes format", () => { + const result = buildSeedPrompt(""); + expect(result).toContain('"action": "connect_nodes"'); + }); + + it("ends with a question to prompt AI response", () => { + const result = buildSeedPrompt(""); + expect(result.trim().endsWith("What does this agent do?")).toBe(true); + }); +}); + +describe("serializeGraphForChat – XML injection prevention", () => { + it("escapes < and > in node names before embedding in prompt", () => { + const nodes = [ + { + id: "1", + data: { + title: "", + description: "", + hardcodedValues: {}, + inputSchema: {}, + outputSchema: {}, + uiType: 1, + block_id: "b1", + costs: [], + categories: [], + }, + type: "custom" as const, + position: { x: 0, y: 0 }, + }, + ] as unknown as CustomNode[]; + + const result = serializeGraphForChat(nodes, []); + expect(result).not.toContain("