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.
The wildcard read_keys: ['*'] grants access to all non-internal memory keys. Internal keys (prefixed with _, such as _taint_registry) are always excluded from state views.
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(). - 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 (a protected internal key invisible to agents).
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.
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.
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