Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions frontend/src/flow/Inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from "react";
import { Edge, Node } from "reactflow";
import { AgentNodeData, NODE_TYPES } from "./types";
import type { Gateway } from "./gateways";
import { TEMPLATES, Workflow } from "./workflows";

interface Props {
node: Node<AgentNodeData> | null;
Expand All @@ -10,9 +11,20 @@ interface Props {
gateways?: Gateway[];
onChange: (id: string, data: Partial<AgentNodeData>) => void;
onDelete: (id: string) => void;
activeWorkflowId?: string | null;
workflows?: Workflow[];
}

export function Inspector({ node, edges, nodes, gateways = [], onChange, onDelete }: Props) {
export function Inspector({
node,
edges,
nodes,
gateways = [],
onChange,
onDelete,
activeWorkflowId = null,
workflows = [],
}: Props) {
const [confirming, setConfirming] = useState(false);

if (!node) {
Expand Down Expand Up @@ -48,7 +60,26 @@ export function Inspector({ node, edges, nodes, gateways = [], onChange, onDelet
{meta.configFields.map((f) => (
<label key={f.key} className="block">
<span className="text-[10px] text-[hsl(var(--ink-faint))]">{f.label}</span>
{f.type === "textarea" ? (
{node.data.kind === "subagent" && f.key === "graph" ? (
<select
value={node.data.config?.[f.key] ?? ""}
onChange={(e) =>
onChange(node.id, {
config: { ...node.data.config, [f.key]: e.target.value },
})
}
className="mt-1 w-full bg-[hsl(var(--paper))] border border-dashed border-[hsl(var(--ink-faint))] focus:border-[hsl(var(--ink))] outline-none py-1.5 px-2 text-[hsl(var(--ink))]"
>
<option value="">-- select a subagent workflow --</option>
{[...TEMPLATES, ...workflows]
.filter((w) => w.id !== activeWorkflowId)
.map((w) => (
<option key={w.id} value={w.id}>
{w.name} ({w.isTemplate ? "template" : "custom"})
</option>
))}
</select>
) : f.type === "textarea" ? (
<textarea
value={node.data.config?.[f.key] ?? ""}
placeholder={f.placeholder}
Expand Down
45 changes: 40 additions & 5 deletions frontend/src/flow/codegen.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Edge, Node } from "reactflow";
import { AgentNodeData, AgentNodeKind } from "./types";
import { GlobalVar, SecretVar } from "./globals";
import { Workflow } from "./workflows";

// =====================================================================
// codegen.ts
Expand Down Expand Up @@ -73,6 +74,7 @@ export function generatePython(
edges: Edge[],
globals?: GlobalVar[],
secrets?: SecretVar[],
workflows?: Workflow[],
): CodegenResult {
const errors: string[] = [];
if (nodes.length === 0) {
Expand Down Expand Up @@ -357,14 +359,27 @@ export function generatePython(
`decision = bool(state.last) if state.last is not None else True`,
`return "true" if decision else "false"`,
].join("\n");
case "subagent":
case "subagent": {
const subWfId = c.graph || "";
const subWf = workflows?.find((w) => w.id === subWfId || w.name === subWfId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use deterministic workflow resolution in both generators.

The current lookup can return an earlier name match instead of an exact ID match. Duplicate names can also select an arbitrary workflow.

  • frontend/src/flow/codegen.ts#L364-L364: resolve an exact ID first, then accept only a unique name match.
  • frontend/src/flow/codegen.ts#L585-L585: apply the same resolution order and duplicate-name handling.
📍 Affects 1 file
  • frontend/src/flow/codegen.ts#L364-L364 (this comment)
  • frontend/src/flow/codegen.ts#L585-L585
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/flow/codegen.ts` at line 364, Update both workflow lookup sites
in frontend/src/flow/codegen.ts at lines 364-364 and 585-585 to resolve an exact
ID match before considering names, then use a name match only when exactly one
workflow has that name; do not select arbitrarily among duplicate names.

const docComment = subWf
? [
`# Nested Subagent Workflow Details:`,
`# Name: ${subWf.name}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Sanitize workflow names before emitting generated source comments.

Workflow.name is interpolated directly into Python and JavaScript line comments. A line terminator ends the comment, and the remaining text can become executable code inside the generated node function.

  • frontend/src/flow/codegen.ts#L368-L368: sanitize subWf.name before inserting it into the Python comment.
  • frontend/src/flow/codegen.ts#L589-L589: apply the same sanitization before inserting it into the JavaScript comment.
📍 Affects 1 file
  • frontend/src/flow/codegen.ts#L368-L368 (this comment)
  • frontend/src/flow/codegen.ts#L589-L589
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/flow/codegen.ts` at line 368, The generated Python and
JavaScript comments interpolate Workflow.name without sanitization, allowing
line terminators to escape the comment. Add or reuse a name-sanitization step
before inserting subWf.name at frontend/src/flow/codegen.ts lines 368-368 and
589-589, applying identical behavior in both generation paths.

`# Nodes: ${subWf.nodes.length}`,
`# Edges: ${subWf.edges.length}`,
].join("\n")
: `# Nested Subagent Workflow Details: Not Found`;

return [
docComment,
`input_val = interpolate(${pyStr(c.input || "input")}, state)`,
`payload = state.get(input_val, state.last) if input_val in state.data else input_val`,
`result = await run_subgraph(${pyStr(c.graph || "sub")}, payload, state)`,
`state.last = result`,
`return "on_success"`,
].join("\n");
}
case "memory":
if ((c.op || "read") === "write") {
return [
Expand Down Expand Up @@ -429,6 +444,7 @@ export function generateJavaScript(
edges: Edge[],
globals?: GlobalVar[],
secrets?: SecretVar[],
workflows?: Workflow[],
): CodegenResult {
const errors: string[] = [];
if (nodes.length === 0) return { code: "// empty graph\n", errors };
Expand Down Expand Up @@ -564,8 +580,26 @@ export function generateJavaScript(
return `const argsVal = interpolate(${JSON.stringify(c.args || "")}, state);\nstate.last = await callTool(${JSON.stringify(c.tool || "noop")}, { raw: argsVal });\nreturn "tool_result";`;
case "router":
return `// predicate: ${(c.predicate || "true").replace(/\n/g, " ")}\nreturn state.last ? "true" : "false";`;
case "subagent":
return `const inputVal = interpolate(${JSON.stringify(c.input || "input")}, state);\nconst payload = state.get(inputVal) ?? inputVal;\nstate.last = await runSubgraph(${JSON.stringify(c.graph || "sub")}, payload);\nreturn "on_success";`;
case "subagent": {
const subWfId = c.graph || "";
const subWf = workflows?.find((w) => w.id === subWfId || w.name === subWfId);
const docComment = subWf
? [
`// Nested Subagent Workflow Details:`,
`// Name: ${subWf.name}`,
`// Nodes: ${subWf.nodes.length}`,
`// Edges: ${subWf.edges.length}`,
].join("\n")
: `// Nested Subagent Workflow Details: Not Found`;

return [
docComment,
`const inputVal = interpolate(${JSON.stringify(c.input || "input")}, state);`,
`const payload = state.get(inputVal) ?? inputVal;`,
`state.last = await runSubgraph(${JSON.stringify(c.graph || "sub")}, payload);`,
`return "on_success";`,
].join("\n");
}
case "memory":
return (c.op || "read") === "write"
? `await memoryWrite(${JSON.stringify(c.key || "key")}, state.last, state);\nreturn "next";`
Expand Down Expand Up @@ -634,10 +668,11 @@ export function generateCode(
edges: Edge[],
globals?: GlobalVar[],
secrets?: SecretVar[],
workflows?: Workflow[],
): CodegenResult {
return lang === "python"
? generatePython(nodes, edges, globals, secrets)
: generateJavaScript(nodes, edges, globals, secrets);
? generatePython(nodes, edges, globals, secrets, workflows)
: generateJavaScript(nodes, edges, globals, secrets, workflows);
}

// also export the kind set for sanity
Expand Down
70 changes: 66 additions & 4 deletions frontend/src/flow/runFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { AgentNodeData } from "./types";
import type { Gateway } from "./gateways";
import { callLLM, ChatMessage } from "./adapters";
import { loadGlobals, loadSecrets } from "./globals";
import type { Workflow } from "./workflows";
import { loadWorkflows, TEMPLATES } from "./workflows";

export interface RunLog {
step: number;
Expand Down Expand Up @@ -32,6 +34,7 @@ export interface RunOptions {
}) => Promise<string>;
globals?: { key: string; value: string }[];
secrets?: { key: string; value: string }[];
workflows?: Workflow[];
}
Comment on lines 35 to 38

/**
Expand Down Expand Up @@ -310,11 +313,70 @@ export async function runNode(
return { read: key, value: memory[key] ?? null };
}
case "subagent": {
const subWfId = cfg.graph || "";
const allWfs = opts.workflows ?? [...TEMPLATES, ...loadWorkflows()];
const subWf = allWfs.find((w) => w.id === subWfId || w.name === subWfId);
if (!subWf) {
throw new Error(`Subagent workflow with ID or Name "${subWfId}" not found`);
}

let subState: Record<string, unknown> = { query: "" };
const inputVal = cfg.input || "";
if (inputVal.trim()) {
try {
// Evaluate as JS expression against current parent state
const fn = new Function("state", `return ${inputVal};`);
const evaluated = fn(state);
Comment on lines +328 to +329

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not execute cfg.input as JavaScript.

cfg.input is workflow configuration. Imported or edited workflow data can execute in the browser global scope through new Function. A malicious workflow can access browser storage and send data through network APIs.

Use interpolation, JSON input, or a restricted expression parser. Do not evaluate configuration as JavaScript.

🧰 Tools
🪛 OpenGrep (1.26.0)

[ERROR] 328-328: new Function() with dynamic input can execute arbitrary code. Avoid dynamic code evaluation entirely, or use a safe alternative.

(coderabbit.code-injection.new-function-js)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/flow/runFlow.ts` around lines 328 - 329, Replace the dynamic
execution in the `runFlow` flow around `new Function` and `fn(state)` so
`cfg.input` is never evaluated as JavaScript. Parse or interpolate the
configured value using JSON input or an existing restricted expression
mechanism, preserving the expected state-derived input behavior without exposing
browser globals or arbitrary network and storage APIs.

Source: Linters/SAST tools

Comment on lines +327 to +329
if (evaluated && typeof evaluated === "object" && !Array.isArray(evaluated)) {
subState = { ...evaluated };
} else {
subState = { query: String(evaluated) };
}
} catch {
// Fallback to interpolation
const interpolated = interpolate(inputVal, state, globalsList, secretsList);
try {
const parsed = JSON.parse(interpolated);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
subState = parsed;
} else {
subState = { query: interpolated };
}
} catch {
subState = { query: interpolated };
}
}
} else {
subState = { query: String(state.query ?? "") };
}

const subLogs = await runFlow({
nodes: subWf.nodes,
edges: subWf.edges,
gateways,
initialState: subState,
stepDelay: opts.stepDelay,
onHumanApproval: opts.onHumanApproval,
globals: globalsList,
secrets: secretsList,
workflows: allWfs,
});
Comment on lines +353 to +363

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Reject recursive workflow cycles before starting the nested run.

The nested call passes allWfs back into runFlow without an ancestry or depth guard. A self-reference, or a cycle such as A → B → A, creates unbounded nested execution. The per-flow maxSteps guard does not limit recursion across calls.

Track selected workflow IDs in the execution context. Throw a descriptive error when a workflow ID repeats. Add a maximum nesting depth as a fallback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/flow/runFlow.ts` around lines 353 - 363, Update runFlow and its
nested invocation to carry execution ancestry and nesting depth, rejecting a
workflow ID that already appears in the ancestry with a descriptive error before
starting the nested run. Also enforce a maximum nesting depth as a fallback,
while preserving normal acyclic workflow execution and propagating the guarded
context through the existing allWfs-based recursion.


const lastLog = subLogs[subLogs.length - 1];
let finalResult: unknown = undefined;
if (lastLog) {
if (lastLog.stateSnapshot && "last_output" in lastLog.stateSnapshot) {
finalResult = lastLog.stateSnapshot.last_output;
} else {
finalResult = lastLog.output;
}
}

return {
simulated: true,
subagent: cfg.graph || "unknown",
input: interpolate(cfg.input || "", state, globalsList, secretsList),
note: "subagent execution is schematic",
workflowId: subWf.id,
workflowName: subWf.name,
subLogs,
result: finalResult,
Comment on lines +365 to +379

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Propagate an unhandled nested-flow error to the parent flow.

runFlow records failures in RunLog.error. This code ignores lastLog.error and returns a normal subagent result. The parent flow then follows its success path even when the nested flow stopped on an error or the max-step guard.

If the final nested log has error, throw it from the subagent node so that the parent can select its error edge.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/flow/runFlow.ts` around lines 365 - 379, The subflow result
handling in runFlow must propagate failures from the final nested log: before
deriving finalResult or returning the normal subagent result, check
lastLog.error and throw it. Preserve the existing result selection for
successful nested flows so the parent flow can use its error path when a nested
flow fails or reaches the max-step guard.

};
}
case "human": {
Expand Down
Loading