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 node declares explicit grants:
const research = node({ id: 'research', agent: researcher, writes: 'research_notes' });
const write = node({ id: 'write', agent: writer, reads: [research.writes], // can only read this writes: 'draft', // can only write this});reads and writes are what you author; they reach the stored graph as read_keys and write_keys, which is the spelling you will see in a serialized definition or an event log.
At runtime, the engine creates a state view: a filtered projection of WorkflowState.memory containing only the keys a node declared. An agent granted reads: ['research_notes'] receives undefined for every other key, including db_credentials, api_keys, or any other sensitive data in state.
goal and constraints sit outside that filter. They are state fields rather than memory keys and reach every node whatever its grants say, so listing them grants nothing — reads governs the memory a node sees on top of them.
Secure by default: reads and writes both default to empty. A node that omits reads 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, naming the producing node’s key: reads: [research.writes].
The wildcard reads: ['*'] grants access to all memory keys; validateGraph warns on it because it defeats slicing. Engine-owned data (the taint registry, lesson provenance, HITL state) lives in first-class state fields rather than in memory, so it is structurally absent from every state view. The taint registry is additionally append-only through reducers, so 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 a node to specific nested paths:
const write = node({ id: 'write', agent: writer, reads: ['user.name', 'user.email'], writes: 'draft',});An agent with reads: ['user.name', 'user.email'] receives a filtered user object containing only { name, email }. All other fields such as user.ssn and 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 the effective write permission: the node’s grant intersected with the agent registry’s optional ceiling. Unauthorized writes throwPermissionDeniedError. -
Graph runner. Before any action is applied to state,
validateAction()re-validates the action against the node’s effective write keys. This second check catches edge cases where actions are constructed outside the agent executor.
Implied grants
Section titled “Implied grants”Declared writes govern what a node’s agent may write. Two families of grants are derived automatically, because the node’s type or config already declares the intent:
- Control-flow permissions by node type. A supervisor may emit
handoffand completion actions; approval and subgraph nodes may pause the run; a swarm-config agent may hand off to peers. None of these need awritesentry. - Executor-owned result keys by node config. A verifier’s
${result_key}/${result_key}_passedpair, a reflection node’s envelope, a tool node’s${id}_result, and the fan-out nodes’ aggregate keys are written by the executor itself, so the config that names them is the grant.
The derivation lives in effectiveWriteKeys() (exported for tooling). Declaring an implied key explicitly is harmless, because the two sets are unioned.
Ceiling and grant
Section titled “Ceiling and grant”Node keys and agent-registry permissions play different roles. The node’s readKeys/writeKeys are the authoritative grant, because need-to-know is a property of the graph position. The agent registry’s permissions block is an optional ceiling: a hard cap intersected with the grant, for agents that must never touch certain keys anywhere they appear, such as a shared summarizer capped away from credential keys or a tenant-level cap in a hosted deployment. A registry entry without a permissions block is uncapped, so the node’s grant alone governs. An explicit empty permissions block still means deny-all, so a deliberately locked-down agent stays locked down. The intersection logic is exported as intersectWriteGrant().
Agent LLM call → validateMemoryUpdatePermissions() → validateAction() → Reducer → State ↑ PermissionDeniedError ↑ PermissionDeniedErrorThe _-prefixed key namespace is reserved for the engine’s wire format inside action payloads. Agents are blocked from writing _-prefixed keys by the agent executor’s validation layer (extractMemoryUpdates rejects them), and validateAction() skips them during permission checks because they are executor-injected system metadata, not agent-authored writes. At the reducer boundary, known wire keys (such as _taint_registry) are routed to their first-class state fields; unknown _-prefixed keys are dropped fail-closed and recorded in memory_drops.
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 such as Cyrillic homographs (
а→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 such as
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 the first-class state.taint_registry field. It is structurally invisible to agents (never part of a state view’s memory), 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 workflow = graph({ name: 'High Security Workflow', strictTaint: true, // reject tainted data in routing decisions nodes: [ /* ... */ ], edges: [ /* ... */ ],});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({ workflowId: graph.id, goal: '...', maxTokenBudget: 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({ workflowId: graph.id, goal: '...', budgetUsd: 1.00,});At 100%, execution halts with BudgetExceededError. Listen for threshold events to add monitoring:
runner.on('budget:threshold_reached', ({ threshold_pct, cost_usd, budget_usd }) => { console.warn(`Budget ${threshold_pct}% reached: $${cost_usd.toFixed(4)} of $${budget_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({ workflowId: graph.id, goal: '...', maxIterations: 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({ workflowId: graph.id, goal: '...', maxExecutionTimeMs: 120_000,});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:
tools: [ { type: 'mcp', serverId: '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' }, allowedAgents: ['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 (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