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.
How it works
Section titled “How it works”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:
- markTainted: Return a new registry with a key marked tainted.
- propagateDerivedTaint: Mark an agent’s outputs derived-tainted when any input was tainted.
- TaintRegistry: Maps each tainted memory key to its provenance.
Taint sources
Section titled “Taint sources”| 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:
- TaintMetadata: The provenance entry these sources populate.
Taint propagation flow
Section titled “Taint propagation flow”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:
- propagateDerivedTaint: The helper that extends the chain from inputs to outputs.
Taint enforcement at decision points
Section titled “Taint enforcement at decision points”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.
Conditional edge routing
Section titled “Conditional edge routing”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.
Strict taint mode
Section titled “Strict taint mode”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.
Supervisor routing
Section titled “Supervisor routing”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.
getTaintRegistry
Section titled “getTaintRegistry”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);markTainted
Section titled “markTainted”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(),});isTainted
Section titled “isTainted”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}getTaintInfo
Section titled “getTaintInfo”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}`);}propagateDerivedTaint
Section titled “propagateDerivedTaint”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',);Parameters
Section titled “Parameters”| 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. |
Interfaces
Section titled “Interfaces”TaintMetadata
Section titled “TaintMetadata”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 370Bdraft derived writer ← research_notesfinal derived editor ← draftEach 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.
TaintRegistry
Section titled “TaintRegistry”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>;Next steps
Section titled “Next steps”- 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