Security
cycgraph operates under a Zero Trust security model built on three assumptions:
- Input is malicious — users and external data may contain injection attacks
- Agents are fallible — LLMs can be jailbroken or manipulated
- State is leaky — agents should only see what they need to see
Every layer of the engine enforces these assumptions through concrete mechanisms described below.
State slicing (least privilege)
Section titled “State slicing (least privilege)”Agents never see the full WorkflowState. Each agent and node declares explicit permissions:
const WRITER_ID = registry.register({ name: 'Writer', model: 'claude-sonnet-4-20250514', provider: 'anthropic', system_prompt: '...', tools: [], permissions: { read_keys: ['goal', 'research_notes'], // can only read these write_keys: ['draft'], // can only write this },});At runtime, the engine creates a state view — a filtered projection of WorkflowState.memory containing only the keys listed in read_keys. An agent configured with read_keys: ['goal', 'research_notes'] receives undefined for every other key, including db_credentials, api_keys, or any other sensitive data in state.
Secure by default: read_keys and write_keys both default to []. A node that omits read_keys sees only goal and constraints — no memory keys — so state slicing protects you without opt-in. A node consuming an upstream output must declare it explicitly (read_keys: ['research_notes']).
The wildcard read_keys: ['*'] grants access to all non-internal memory keys; validateGraph warns on it because it defeats slicing. Internal keys (prefixed with _, such as _taint_registry) are always excluded from state views, and _taint_registry is additionally append-only through reducers — a node cannot clear or weaken taint via a crafted memory write.
Dot-notation nested key filtering
Section titled “Dot-notation nested key filtering”State slicing supports dot-notation paths for fine-grained access to nested objects. Instead of granting access to an entire top-level key, you can restrict an agent to specific nested paths:
const WRITER_ID = registry.register({ name: 'Writer', model: 'claude-sonnet-4-20250514', provider: 'anthropic', system_prompt: '...', tools: [], permissions: { read_keys: ['user.name', 'user.email'], // only these nested paths write_keys: ['draft'], },});An agent with read_keys: ['user.name', 'user.email'] receives a filtered user object containing only { name, email } — all other fields (e.g. user.ssn, user.api_key) are excluded from its state view.
Write permission enforcement
Section titled “Write permission enforcement”Write permissions are enforced at two levels:
-
Agent executor — After the LLM call completes,
validateMemoryUpdatePermissions()checks every key the agent wrote against itswrite_keys. Unauthorized writes throwPermissionDeniedError. -
Graph runner — Before any action is applied to state,
validateAction()re-validates the action against the node’swrite_keys. This second check catches edge cases where actions are constructed outside the agent executor.
Agent LLM call → validateMemoryUpdatePermissions() → validateAction() → Reducer → State ↑ PermissionDeniedError ↑ PermissionDeniedErrorInternal keys (prefixed with _) are reserved for the engine. Agents are blocked from writing _-prefixed keys by the agent executor’s validation layer (extractMemoryUpdates rejects them). The GraphRunner’s validateAction() skips _-prefixed keys during permission checks — they are treated as trusted system metadata injected by the executor (e.g. _taint_registry), not as agent-authored writes.
Prompt injection sanitization
Section titled “Prompt injection sanitization”All agent inputs pass through a sanitization pipeline before reaching the LLM. These sanitizers defend against known prompt injection techniques:
- NFKC Unicode normalization — Converts lookalike characters (e.g. Cyrillic homographs like
а→a) to their canonical forms, preventing visual spoofing attacks. - Carriage return stripping — Removes
\rcharacters that can be used to hide injected instructions in terminal-style overwrite attacks. - Consecutive newline normalization — Collapses runs of 3+ newlines to 2, preventing whitespace-based prompt boundary confusion.
- Directional override stripping — Removes Unicode bidirectional override characters (U+202A–U+202E, U+2066–U+2069) that can reverse visible text direction to disguise injected content.
- Base64-encoded injection detection — Detects and rejects inputs containing base64-encoded strings that decode to known injection patterns (e.g.
ignore previous instructions).
These sanitizers run on all agent system prompts and user messages before LLM invocation.
Taint tracking
Section titled “Taint tracking”External data is the most dangerous attack vector. cycgraph automatically tracks the provenance of data entering the system from external tools.
How it works:
- Flagging — All MCP tool results are automatically wrapped with taint metadata (source type, tool name, server ID, timestamp) via
wrapToolWithTaint(). Bothagentnodes and standalonetoolnodes taint their MCP output. - Propagation — When an agent reads tainted input keys and writes output,
propagateDerivedTaint()marks the outputs asderived-tainted, preserving the chain of custody. - Inspection — Downstream nodes can call
isTainted(memory, key)orgetTaintInfo(memory, key)to check provenance before trusting inputs.
Taint metadata is stored in memory._taint_registry — an internal key that is invisible to agents (stripped from every state view), append-only through reducers (a crafted memory write cannot clear or weaken taint), and accumulated per-execution so concurrent voting/evolution/map sub-runs never cross-attribute provenance.
Strict taint mode
Section titled “Strict taint mode”By default, tainted data in routing decisions produces a warning. Set strict_taint: true at the graph level to reject tainted data in routing decisions entirely:
const graph = createGraph({ name: 'High Security Workflow', strict_taint: true, // reject tainted data in routing decisions nodes: [ /* ... */ ], edges: [ /* ... */ ], start_node: 'start', end_nodes: ['end'],});When strict_taint is enabled, conditional edge expressions that reference tainted keys evaluate to false, and supervisor nodes that receive tainted routing inputs will refuse to route. See Taint Tracking for details on taint enforcement at decision points.
See Taint Tracking for the full API reference.
Economic guardrails
Section titled “Economic guardrails”Prevent runaway costs, infinite loops, and denial-of-wallet attacks with layered limits:
Token budget
Section titled “Token budget”Set max_token_budget on the workflow state. The engine tracks total_tokens_used across all LLM calls and throws BudgetExceededError when the limit is hit.
const state = createWorkflowState({ workflow_id: graph.id, goal: '...', max_token_budget: 50_000,});Cost budget (USD)
Section titled “Cost budget (USD)”Set budget_usd for dollar-denominated limits. The engine calculates costs using a per-model pricing table and fires threshold alerts at 50%, 75%, 90%, and 100% of the budget:
const state = createWorkflowState({ workflow_id: graph.id, goal: '...', budget_usd: 1.00,});At 100%, execution halts with BudgetExceededError. Listen for threshold events to add monitoring:
runner.on('budget:threshold_reached', ({ percentage, total_cost_usd }) => { console.warn(`Budget ${percentage}% reached: $${total_cost_usd}`);});See Cost & Budget Tracking for model pricing details and the UsageRecorder interface.
Iteration limit
Section titled “Iteration limit”max_iterations (default: 50) caps the total number of graph loop iterations. This prevents cyclic graphs from running forever:
const state = createWorkflowState({ workflow_id: graph.id, goal: '...', max_iterations: 20,});Execution timeout
Section titled “Execution timeout”max_execution_time_ms (default: 1 hour) sets a wall-clock deadline. The engine checks elapsed time before each node execution and throws WorkflowTimeoutError if exceeded:
const state = createWorkflowState({ workflow_id: graph.id, goal: '...', max_execution_time_ms: 120_000, // 2 minutes});Agent step limit
Section titled “Agent step limit”Each agent has a max_steps setting (default: 10, maximum: 50) that limits the number of tool-call iterations within a single LLM invocation. This prevents agents from entering infinite tool-call loops.
Agent timeout
Section titled “Agent timeout”Each agent invocation has a 2-minute timeout (configurable via timeout_ms). If the LLM call doesn’t complete within the limit, an AgentTimeoutError is thrown and the node fails (subject to its retry policy).
MCP tool security
Section titled “MCP tool security”Agents never see MCP server transport configurations or secrets.
Trusted MCP Server Registry
Section titled “Trusted MCP Server Registry”Server connection configs (URLs, commands, auth headers) live in the MCP Server Registry — an admin-only data store. Agent configs reference servers by ID only:
// Agent config — no transport details, no secretstools: [ { type: 'mcp', server_id: 'web-search' },]Access control
Section titled “Access control”Each server entry can restrict which agents may use it via allowed_agents:
await registry.saveServer({ id: 'admin-tools', name: 'Admin Tools', transport: { type: 'http', url: 'https://internal.example.com/admin' }, allowed_agents: ['admin-agent-001'],});When allowed_agents is set, the MCPConnectionManager validates the requesting agent’s ID before resolving tools. Unauthorized access throws MCPAccessDeniedError.
Transport restrictions
Section titled “Transport restrictions”- stdio — Only allowlisted commands (
npx,node,python3,python,uvx). No arbitrary shell execution. - http/sse — URLs stored in the registry, never in agent configs. Secrets stay server-side.
- SSRF guard — http/sse URLs are blocked from resolving to private, loopback, link-local, or cloud-metadata addresses (
169.254.169.254,127.0.0.1, RFC1918,[::1],fc00::/7, …). SetCYCGRAPH_ALLOW_PRIVATE_MCP_URLS=trueto allow them for local development.
Registry validation at the trust boundary
Section titled “Registry validation at the trust boundary”saveServer and loadServer re-validate every entry through MCPServerEntrySchema on both write and read — not just at compile time. The stdio allowlist and SSRF guard are therefore enforced even against a JS caller, an any cast, a direct SQL write, or a migration. An entry with a disallowed command or a private-IP URL is rejected before it can ever be spawned or connected.
Automatic taint wrapping
Section titled “Automatic taint wrapping”All MCP tool results are wrapped with taint metadata before being returned to agents, ensuring every piece of external data is tracked from the moment it enters the system.
See Tools & MCP for the full MCP integration guide.
Workflow Architect publish gate
Section titled “Workflow Architect publish gate”The Architect can generate executable graphs from natural language, but architect_publish_workflow never persists an unvalidated graph: it runs GraphSchema.parse + validateGraph first, so a prompt-injected or buggy agent cannot publish a graph with wildcard reads, unbounded fan-out, or arbitrary tool wiring. Wire ArchitectToolDeps.canPublish to additionally require human approval or a privileged credential before any publish reaches the registry:
initArchitectTools({ saveGraph, loadGraph, canPublish: async (graph) => { // return true to allow, or a string reason to deny return (await isApprovedByHuman(graph.id)) || 'human approval required'; },});Resource bounds (DoS guards)
Section titled “Resource bounds (DoS guards)”Every fan-out and iteration knob is capped at the schema level so a hand-written or generated graph can’t trigger a fan-out bomb: population_size ≤ 100, max_generations ≤ 100, max_concurrency ≤ 50, voter_agent_ids ≤ 50, supervisor/annealing max_iterations ≤ 1000. Subgraph nesting is capped at depth 32, and subgraphs inherit the parent’s guardrails (tool resolver, fact sanitizer, memory writer, model resolver) rather than running with reduced guarantees.
Supervisor routing validation
Section titled “Supervisor routing validation”Supervisor nodes validate every LLM routing decision against their managed_nodes allowlist. If the LLM attempts to route to a node not in the list, a SupervisorRoutingError is thrown. This prevents prompt injection attacks from hijacking workflow control flow.
Human-in-the-loop as security
Section titled “Human-in-the-loop as security”For high-stakes actions, use approval nodes to pause execution for human review:
- A preceding node proposes an action and saves it to state
- The workflow pauses at the approval gate (status becomes
waiting) - A human reviews and approves or rejects
- Execution resumes only after approval
See Human-in-the-Loop for the implementation pattern.
Immutable audit trail
Section titled “Immutable audit trail”Every state transition is logged as an action with:
- Which node produced it
- When it was applied
- What the previous state was
- An idempotency key (
{node_id}:{iteration_count}) to prevent duplicate execution on retries
This enables full audit trails and time-travel debugging via the event log.
Error classes
Section titled “Error classes”All security-related errors are typed and exported from @cycgraph/orchestrator:
| Error | Thrown when |
|---|---|
PermissionDeniedError | Agent writes to unauthorized memory key |
BudgetExceededError | Token or cost budget exceeded |
WorkflowTimeoutError | Execution time exceeds max_execution_time_ms |
AgentTimeoutError | Single agent call exceeds timeout |
MCPAccessDeniedError | Agent not in server’s allowed_agents list |
MCPServerNotFoundError | Server ID not found in registry |
SupervisorRoutingError | Supervisor routes to unauthorized node |
Next steps
Section titled “Next steps”- Taint Tracking — full taint API reference
- Cost & Budget Tracking — pricing tables and usage recording
- Tools & MCP — MCP server registry and access control
- Persistence — state versioning and event log