Skip to content

Taint Tracking

Any data that enters a workflow from an external source (MCP tools, web searches, APIs) is automatically marked as tainted. Taint metadata records where the data came from, when it arrived, and whether downstream agents have processed it. This allows supervisors and security-sensitive nodes to distinguish trusted internal state from untrusted external inputs.

Taint metadata lives in the first-class taint registry, structurally separate from the memory blackboard. That separation is the protection: the registry is never part of any node’s state view, so agents cannot read or overwrite it through memory at all.

For example, when an MCP tool returns a result, the MCPConnectionManager accumulates taint metadata in a per-resolution collector. After execution completes, the executor drains that specific collector and marks tainted on any memory keys that received MCP tool results. The per-resolution collector means concurrent executions (voting, evolution, map) never cross-attribute taint. Both agent nodes and standalone tool nodes drain and apply taint, so external data is never written to memory untainted. The raw tool result is returned directly to the LLM, so no taint wrapper is visible to the model. When an agent reads tainted inputs and produces outputs, the outputs are marked as derived-tainted.

Refs:

Source When it’s applied
mcp_tool Result returned from an MCP server tool
custom_tool Result from a custom tool declared taints: true
tool_node Result from a tool-type node execution
agent_response Agent output when explicitly marked
derived Agent output when any of its inputs were tainted
retrieval Fact injected into a prompt by memory retrieval

Refs:

MCP Tool "search"
→ memory.search_results [mcp_tool, server_id: "web-search", node_id: "search"]
Agent "researcher" reads search_results, writes summary
→ memory.summary [derived, node_id: "research", derived_from: ["search_results"]]
Agent "writer" reads summary, writes draft
→ memory.draft [derived, node_id: "write", derived_from: ["summary"]]

Once data is tainted, the taint follows it through every agent that processes it. derived_from names the tainted keys each value came from, so the chain walks backward one hop at a time from any tainted key to the external call that introduced it.

Refs:

Tainted data is tracked not only for auditing, but also enforced at routing decision points to prevent untrusted external data from controlling workflow control flow.

When a conditional edge expression references a tainted memory key, the engine logs a warning by default. This alerts operators that an external data source is influencing which path a workflow takes.

Setting strict_taint: true on the graph upgrades warnings to hard rejections. When enabled, evaluateCondition() returns false for any condition that references a tainted key, forcing the workflow to take the fallback path instead of trusting external data:

import { node, runTool, graph } from '@cycgraph/orchestrator';
const fetch = runTool('web_search', { id: 'fetch' });
const analyze = node({ id: 'analyze', agent: analyst, reads: [fetch.result], writes: 'analysis' });
const fallback = node({ id: 'fallback', agent: fallbackAgent, writes: 'analysis' });
const strictGraph = graph({
name: 'Strict Taint Example',
description: 'Routes to a fallback agent when external (tainted) data would otherwise drive the decision.',
strictTaint: true,
nodes: [fetch, analyze, fallback],
edges: [
{ from: fetch, to: analyze, when: `length(memory.${fetch.result}) > 0` },
{ from: fetch, to: fallback },
],
startNode: fetch,
endNodes: [analyze, fallback],
});

In this example, fetch.result is tainted, because it came from a tool. With strictTaint: true, the condition evaluates to false regardless of the actual value, and the workflow routes to fallback.

When a supervisor node receives input containing tainted keys, the engine injects an explicit warning into the supervisor’s prompt: the supervisor is told which keys are tainted and that routing decisions should not rely on their content. This gives the LLM the context to make safer routing choices, even without strict_taint enabled.

All functions are pure. They read from or return a TaintRegistry value and never mutate their input, because state changes happen through reducers.

Read the taint registry from workflow state. Returns an empty registry when the field is absent, such as a hand-built state that skipped schema defaults.

import { getTaintRegistry } from '@cycgraph/orchestrator';
function getTaintRegistry(
state: Pick<WorkflowState, 'taint_registry'>,
): TaintRegistry;
const registry = getTaintRegistry(state);

Return a new registry with key marked tainted using the provided provenance metadata. The input registry is not mutated.

import { markTainted } from '@cycgraph/orchestrator';
function markTainted(
registry: TaintRegistry,
key: string,
meta: TaintMetadata,
): TaintRegistry;
const next = markTainted(getTaintRegistry(state), 'search_results', {
source: 'mcp_tool',
tool_name: 'search',
server_id: 'web-search',
created_at: new Date().toISOString(),
});

Check whether a key has an entry in the taint registry. Uses Object.hasOwn, so a key named constructor or toString does not read as tainted through the prototype chain.

import { isTainted } from '@cycgraph/orchestrator';
function isTainted(registry: TaintRegistry, key: string): boolean;
if (isTainted(getTaintRegistry(state), 'search_results')) {
// Do not use this data for routing decisions
}

Get the full taint metadata for a specific key. Returns undefined if the key is not tainted.

import { getTaintInfo } from '@cycgraph/orchestrator';
function getTaintInfo(
registry: TaintRegistry,
key: string,
): TaintMetadata | undefined;
const info = getTaintInfo(getTaintRegistry(state), 'search_results');
if (info?.source === 'mcp_tool') {
console.log(`Data from MCP server: ${info.server_id}`);
}

Propagate taint from an agent’s readable inputs to its outputs. If any key in readableMemory is tainted in registry, every entry in outputKeys is marked derived-tainted. Returns only the new entries, empty when no propagation occurred.

import { propagateDerivedTaint } from '@cycgraph/orchestrator';
function propagateDerivedTaint(
readableMemory: Record<string, unknown>,
registry: TaintRegistry,
outputKeys: string[],
agentId: string,
nodeId?: string,
): TaintRegistry;
const newEntries = propagateDerivedTaint(
readableMemory,
getTaintRegistry(state),
['summary', 'draft'],
'writer-agent',
);
Parameter Type Description
readableMemory Record<string, unknown> The memory slice the agent could read, its state view.
registry TaintRegistry Taint registry scoped to those readable keys.
outputKeys string[] Memory keys written by the agent.
agentId string ID of the agent that produced the outputs.

Provenance of the untrusted data behind one memory key. Keyed by memory key inside TaintRegistry. Defined by TaintMetadataSchema so the type and the runtime schema cannot drift. This is the same shape documented on Workflow State.

Field Type Description
source 'mcp_tool' | 'custom_tool' | 'tool_node' | 'agent_response' | 'derived' | 'retrieval' | 'a2a' Origin of the data.
tool_name string? Tool that produced the data, for tool sources.
server_id string? MCP server, or registered A2A server, that provided it.
agent_id string? Agent that produced the data, for 'agent_response' or 'derived'.
node_id string? Node that introduced the data, whatever produced it.
derived_from string[]? Tainted keys this value was derived from.
bytes number? Serialized size of the value.
created_at string ISO 8601 timestamp.

derived_from is what makes the registry a lineage rather than a set of flags: a derived entry names the keys it came from, so a value can be followed back to the tool or remote agent that first introduced it.

research_notes custom_tool lookup_briefing 370B
draft derived writer ← research_notes
final derived editor ← draft

Each first tainting also emits a taint:applied stream event and a taint_applied log line carrying the same fields, so untrusted data entering a run is visible live rather than only by comparing state snapshots.

Maps each tainted memory key to its TaintMetadata provenance. Stored on the first-class state.taint_registry field and merged append-only by the reducers.

type TaintRegistry = Record<string, TaintMetadata>;
  • Tools & MCP: how MCP tool results are automatically tainted
  • Workflow State: the state fields taint lives alongside
  • Security: access control and the zero-trust security model
  • Nodes: state slicing and the principle of least privilege