Security Architecture
Digitorn provides enterprise-grade security for AI agent applications. Every action an agent attempts passes through multiple enforcement layers before execution. Nothing executes without explicit authorization.
Design Principles
The security system follows four principles:
- Deny by default -- actions without explicit policy require approval
- Least privilege -- agents see only what they need
- Defense in depth -- 7 independent gates, each can block
- Full auditability -- every decision is logged with context
Architecture Overview
YAML Configuration
All security is declared in the capabilities: block:
capabilities:
# Default policy for actions not explicitly mentioned
default_policy: approve # auto | approve | block
# Maximum risk level the app can handle without explicit grants
max_risk_level: medium # low | medium | high
# Actions the agent can execute freely
grant:
- module: filesystem
actions: [read, ls, find, grep, edit, write]
- module: git
actions: [status, diff, log, add, commit]
- module: shell
actions: [run, bash, which]
# Actions that require user confirmation before execution
approve:
- module: filesystem
actions: [rm]
- module: git
actions: [push, reset, merge]
- module: shell
actions: [task_kill]
# Actions that are permanently blocked
deny:
- module: database
actions: [execute_query, batch_execute]
reason: "Read-only mode"
# Modules invisible to the agent (still usable by system)
hidden_modules: [llm_provider, index]
# Specific actions hidden from tool index
hidden_actions:
- module: database
actions: [set_policy]
The Seven Security Gates
Gate 0: App Active
If the application is marked inactive (via API or admin), all actions are denied. This allows instant kill-switch capability without redeployment.
if not profile.is_active:
raise PermissionDeniedError # logged as gate0_inactive
Gate 1: Module Visibility
The agent can only use modules that are visible in its security profile.
Hidden modules are completely invisible -- the agent cannot discover them
via search_tools, list_categories, or browse_category.
# These modules exist but the agent cannot see them
hidden_modules: [llm_provider, index]
Gate 2: Risk Level Cap
Every action has a risk level declared in its @action decorator:
| Risk | Examples | Description |
|---|---|---|
low | read, ls, grep, status, search | Read-only, no side effects |
medium | write, edit, commit, post | Modifies state but recoverable |
high | rm, push, reset, execute_query | Destructive or affects shared state |
The max_risk_level in capabilities sets the ceiling. Actions above this level
are denied unless they have an explicit grant or approve override.
max_risk_level: medium
grant:
- module: shell
actions: [run] # run is high-risk, but this explicit grant overrides the cap
Gate 3: Symbolic Permissions
Actions can declare required permissions in their @action decorator:
@action(
description="Edit a file",
permissions=["fs.read", "fs.write"], # required
risk_level="medium",
)
The security gate checks these against granted_permissions. However,
explicit grants override symbolic permissions. If filesystem:edit
is in the grant list, the symbolic fs.read and fs.write checks are skipped.
This prevents the common scenario where a developer explicitly grants an action but it fails because of a symbolic permission mismatch.
Gate 4: Action Policy Resolution
The final policy for each action is resolved through a priority chain:
Priority order (first match wins):
- Explicit action override --
grant:,approve:, ordeny:in YAML - Risk-based rule -- per-risk-level policy mapping
- Module default --
default_action_policyon the module grant - App default --
default_policyin capabilities
The golden rule: deny always wins. Even if an action is in both grant and
deny, the deny takes effect.
Gate 5: Data Classification
Actions can declare a data classification level:
@action(
description="Read sensitive config",
data_classification="confidential",
)
Classification levels, from least to most sensitive:
| Level | Description | Examples |
|---|---|---|
public | No sensitivity | help text, tool lists |
internal | Internal use | source code, configs |
confidential | Business sensitive | customer data, credentials |
restricted | Highest sensitivity | encryption keys, PII |
Configure the maximum allowed level:
capabilities:
max_data_classification: internal # blocks confidential and restricted
Gate 6: Per-Action Rate Limiting
Prevent abuse by limiting how often specific actions can be called:
capabilities:
rate_limits:
"shell.run": 30 # max 30 calls per minute
"filesystem.write": 60 # max 60 writes per minute
"*": 120 # default for all actions
The rate limiter uses a sliding window (60-second window). When the limit is reached, the action is denied with a message telling the agent how long to wait before retrying.
Approval Workflow
When an action has the approve policy, execution is paused and the user
is prompted for confirmation.
Approval in CLI Mode
In standalone mode (digitorn run --standalone), the CLI prompts the user
directly in the terminal with a Rich prompt.
Approval in Daemon Mode
In daemon mode, the approval request is emitted as an SSE event:
{
"event": "approval_request",
"data": {
"request_id": "apr_abc123",
"tool_name": "filesystem.rm",
"tool_params": {"path": "/tmp/data"},
"risk_level": "high",
"description": "Delete a file or directory permanently."
}
}
The client (web app, CLI, extension) resolves it via the API:
POST /api/apps/{app_id}/approve
{
"request_id": "apr_abc123",
"approved": true
}
Approval Timeout
Approvals timeout after 5 minutes by default. If the user does not respond, the action is automatically denied.
Temporal Scopes
Grants can be time-limited or session-scoped:
capabilities:
temporal_grants:
- module: shell
action: run
scope: session # valid only for current session
- module: git
action: push
scope: timed
duration: 3600 # valid for 1 hour after session start
| Scope | Behavior |
|---|---|
session | Grant is valid only for the current session. New session = new approval needed. |
timed | Grant expires after the specified duration (seconds). |
Temporal grants are managed by the TemporalGrantStore and cleaned up
automatically when they expire.
Security Audit Log
Every security decision is logged to a persistent audit trail. The audit log records what was attempted, what decision was made, which gate made the decision, and why.
Audit Event Structure
{
"timestamp": 1710547200.0,
"app_id": "claude-code",
"agent_id": "main",
"session_id": "sess_abc123",
"module_id": "filesystem",
"action": "rm",
"risk_level": "high",
"params_summary": {"path": "/tmp/data"},
"decision": "approval_required",
"gate": "gate4_policy",
"reason": "Action requires user approval",
"policy_resolved": "approve",
"approved_by": "",
"approval_duration_ms": 0
}
Decision Types
| Decision | Meaning |
|---|---|
allowed | Action passed all gates and executed |
denied | Action was blocked by a security gate |
approval_required | Action is waiting for user confirmation |
approved | User approved the action, it executed |
denied_by_user | User denied the approval request |
Querying the Audit Log
Via the API:
# Get recent security events
GET /api/apps/{app_id}/audit?limit=50
# Filter by decision
GET /api/apps/{app_id}/audit?decision=denied&limit=20
# Filter by module
GET /api/apps/{app_id}/audit?module_id=shell
# Get statistics
GET /api/apps/{app_id}/audit/stats
Parameter Sanitization
Audit log entries never contain sensitive data. Parameters are automatically sanitized before logging:
- Keys containing
password,secret,token,api_key,credential,auth,private_key,access_keyare replaced with***REDACTED*** - Strings longer than 200 characters are truncated
- Large collections are summarized as
<list len=100> - Internal keys (starting with
_) are excluded
Complete YAML Reference
capabilities:
# Default policy for unlisted actions
default_policy: approve # auto | approve | block
# Risk ceiling (actions above this need explicit grant/approve)
max_risk_level: medium # low | medium | high
# Data sensitivity ceiling
max_data_classification: internal # public | internal | confidential | restricted
# Per-action rate limits (calls per minute)
rate_limits:
"shell.run": 30
"filesystem.write": 60
"*": 120 # default for all
# Free-pass actions (execute without confirmation)
grant:
- module: filesystem
actions: [read, ls, find, grep, edit, write, insert, mkdir]
- module: git
actions: [status, diff, log, blame, show, add, commit]
- module: shell
actions: [run, bash, which, env]
# Actions requiring user confirmation
approve:
- module: filesystem
actions: [rm]
- module: git
actions: [push, reset, merge]
- module: shell
actions: [task_kill]
# Permanently blocked actions
deny:
- module: database
actions: [execute_query, batch_execute, set_policy]
reason: "Read-only access only"
# Invisible modules (system use only)
hidden_modules: [llm_provider, index]
# Invisible actions (still executable by system)
hidden_actions:
- module: database
actions: [set_policy]
# Time-limited grants
temporal_grants:
- module: git
action: push
scope: timed
duration: 3600
Module-Level Security
Beyond the security gate, individual modules enforce their own security controls. All are configurable via YAML and enabled by default.
Filesystem: Path Sandboxing
The filesystem module restricts all operations to declared paths. Every action (read, write, edit, grep, find, ls, rm, undo, diff_checkpoint) checks against the constraint before execution.
modules:
filesystem:
constraints:
paths:
- "{{workspace}}"
- "/tmp/digitorn"
max_file_size: "50MB"
allowed_extensions: [".py", ".js", ".ts", ".yaml", ".md", ".json"]
Any access outside these paths returns a permission error.
The paths constraint is resolved at compile time, not at runtime, preventing
the agent from modifying it.
Shell: Output Sanitization
The shell module automatically redacts values of sensitive environment variables
from command output. This prevents secret exfiltration through commands like
env, printenv, or echo $API_KEY.
modules:
shell:
config:
security:
sanitize_output: true # default: true
sensitive_patterns: # extra patterns (in addition to built-in)
- "database_url"
- "internal_token"
Built-in patterns: key, secret, password, token, auth, credential,
private, cert, jwt, signing, encryption, ssh, pgp, gpg.
Any environment variable whose name matches one of these patterns has its
value replaced with ***REDACTED*** in stdout and stderr.
Web: Egress Filtering
The web module supports domain allowlists and blocklists. This prevents the agent from fetching content from internal services, cloud metadata endpoints, or attacker-controlled domains.
modules:
web:
config:
egress:
allowed_domains: # null = all allowed (default)
- "docs.python.org"
- "github.com"
- "stackoverflow.com"
blocked_domains: # always blocked, even if allowed_domains is null
- "localhost"
- "127.0.0.1"
- "169.254.169.254" # AWS/GCP metadata endpoint
- "metadata.google.internal"
When allowed_domains is set, only those domains can be fetched.
blocked_domains applies regardless of the allowlist.
Web: Prompt Injection Detection
Fetched web content is scanned for common prompt injection patterns.
When detected, the result includes a security_warning field and a log
entry is emitted. The agent still receives the content, but the warning
signals that the data may be adversarial.
modules:
web:
config:
security:
detect_injection: true # default: true
injection_patterns: # extra patterns (in addition to built-in)
- "you are a helpful assistant"
Built-in detection patterns include: ignore previous instructions,
disregard your instructions, you are now, system prompt:,
forget everything, and common LLM prompt delimiters.
HTTP: Egress Protection
The HTTP module blocks outbound write requests (POST, PUT, PATCH, DELETE) to external hosts by default. Only GET, HEAD, and OPTIONS are allowed to external hosts without explicit authorization. This prevents data exfiltration via HTTP.
To allow write requests to specific hosts, declare them in the YAML:
modules:
http:
constraints:
allowed_hosts:
- "api.github.com"
- "httpbin.org"
- "hooks.slack.com"
When allowed_hosts is set, all HTTP methods are allowed to those hosts.
Requests to localhost and 127.0.0.1 are always allowed regardless of the list.
Database: Host Restriction
Database connections to remote hosts are blocked by default. Only localhost,
127.0.0.1, and ::1 are allowed. SQLite connections to :memory: and local
files are always allowed.
To connect to a remote database server, declare the host explicitly:
modules:
database:
constraints:
allowed_hosts:
- "db.company.com"
- "analytics.internal"
MCP: Server Trust Model
MCP servers are external processes with their own system access. Digitorn applies several layers of protection, but cannot fully sandbox the server process itself.
What Digitorn controls:
- Tool risk classification (auto-inferred from tool name)
- Result normalization (structured output, no raw system data)
- Approval workflows (high-risk MCP tools require human approval)
- Audit logging (all MCP tool calls are logged)
- Credential isolation (each server sees only its own env vars)
What Digitorn cannot control:
- What the server process does internally (file access, network calls)
- Whether the server has vulnerabilities or backdoors
Recommendations:
- Only install MCP servers from trusted sources
- Review the server source code before installing
- Use
capabilities.approvefor all MCP tool calls in production - Run the daemon in a container for full isolation
Memory: Secret Redaction
The memory module redacts sensitive environment variable values before storing facts. This prevents secrets from persisting in the memory store and surviving across sessions.
modules:
memory:
config:
security:
redact_secrets: true # default: true
sensitive_patterns: # extra patterns (in addition to built-in)
- "internal_key"
When an agent calls add_fact(content="Found API key: sk-abc123..."),
the value is replaced with [REDACTED] before storage.
Defense in Depth Summary
Every action passes through the security gate first, then through the module's own controls. Both layers are independently configurable. A failure at either level blocks execution.
Security Model Comparison
How Digitorn security compares to other agent frameworks:
| Feature | Digitorn | Claude Code | LangChain | CrewAI |
|---|---|---|---|---|
| Per-action policy | grant/approve/block | allow/deny | None | None |
| Risk-level enforcement | 3 levels with cap | Basic | None | None |
| Approval workflow | Async queue + SSE | Interactive prompt | None | None |
| Audit log | Persistent, queryable | None | None | None |
| Data classification | 4 levels | None | None | None |
| Rate limiting per action | Sliding window | None | None | None |
| Temporal scopes | Session + timed | None | None | None |
| Module visibility | Hidden + visible | N/A | N/A | N/A |
| Output sanitization | Auto-redact secrets in stdout | None | None | None |
| Path sandboxing | Allowlist per module | Workspace only | None | None |
| Egress filtering | Domain allow/blocklist | None | None | None |
| Prompt injection detection | Pattern scan on fetch | Built-in | None | None |
| Memory secret redaction | Auto-redact before storage | N/A | None | None |
Implementation Details
File Structure
packages/digitorn/core/
security.py # SecurityProfile, ModuleGrant, security_gate (7 gates)
security_audit.py # SecurityAuditLog, SecurityEvent, param sanitization
security_enforcer.py # ActionRateLimiter, DataClassification, TemporalGrantStore
runtime/
approval.py # ApprovalQueue, ApprovalRequest (async Future-based)
Test Coverage
The security system has 60 dedicated tests across 3 test files:
| Test file | Tests | Coverage |
|---|---|---|
test_security_gate.py | 28 | All 7 gates, policy resolution, Claude Code pattern |
test_security_advanced.py | 32 | Audit log, rate limiting, classification, temporal scopes |
| Total | 60 | All security features |
Complete Security Reference
Every security feature is configurable in the application YAML.
Full Example
modules:
filesystem:
config:
checkpoint: true
max_checkpoints: 20
constraints:
paths: ["{{workspace}}"]
max_file_size: "50MB"
allowed_extensions: [".py", ".js", ".ts", ".yaml", ".md", ".json", ".txt"]
shell:
config:
security:
sanitize_output: true
sensitive_patterns:
- "COMPANY_INTERNAL"
- "PROD_"
constraints:
allowed_actions: [run, bash, which, env, background_run, task_status, task_output, task_list, task_wait]
web:
config:
egress:
allowed_domains:
- "docs.python.org"
- "github.com"
- "stackoverflow.com"
blocked_domains:
- "169.254.169.254"
- "metadata.google.internal"
security:
detect_injection: true
injection_patterns:
- "you are a helpful assistant"
http:
constraints:
allowed_hosts:
- "api.github.com"
- "httpbin.org"
- "hooks.slack.com"
database:
constraints:
allowed_hosts:
- "db.company.com"
- "analytics.internal"
memory:
config:
security:
redact_secrets: true
sensitive_patterns:
- "COMPANY_"
- "INTERNAL_"
mcp:
servers:
github:
env:
GITHUB_TOKEN: "{{secret.GITHUB_TOKEN}}"
config:
cache:
scope: auto
ttl: 300
capabilities:
default_policy: auto
max_risk_level: medium
grant:
- module: filesystem
actions: [read, ls, find, grep, edit, write, insert, mkdir]
- module: git
actions: [status, diff, log, blame, show, add, commit]
- module: shell
actions: [run, which, env]
approve:
- module: git
actions: [push, reset, merge]
- module: filesystem
actions: [rm, mv, cp]
- module: shell
actions: [bash]
deny:
- module: database
actions: [execute_query, batch_execute]
reason: "Read-only database access"
Configuration Summary
| Feature | Location in YAML | Default | Effect |
|---|---|---|---|
| Path confinement | modules.filesystem.constraints.paths | None (open) | Agent can only access listed paths |
| File size limit | modules.filesystem.constraints.max_file_size | None (open) | Blocks read/write of files above limit |
| Shell actions | modules.shell.constraints.allowed_actions | All | Only listed actions are available |
| Shell output sanitization | modules.shell.config.security.sanitize_output | true | Redacts secrets from command output |
| Shell sensitive patterns | modules.shell.config.security.sensitive_patterns | Built-in | Additional env var name patterns to redact |
| Web allowed domains | modules.web.config.egress.allowed_domains | None (open) | Only listed domains can be fetched |
| Web blocked domains | modules.web.config.egress.blocked_domains | None | Always blocked, even if in allowed list |
| Web injection detection | modules.web.config.security.detect_injection | true | Warns when fetched content has injection patterns |
| HTTP allowed hosts | modules.http.constraints.allowed_hosts | None (localhost only for writes) | POST/PUT/DELETE allowed to listed hosts |
| DB allowed hosts | modules.database.constraints.allowed_hosts | None (localhost only) | Remote DB connections allowed to listed hosts |
| Memory secret redaction | modules.memory.config.security.redact_secrets | true | Redacts env var values before storing as facts |
| Memory sensitive patterns | modules.memory.config.security.sensitive_patterns | Built-in | Additional patterns to redact in memory |
| MCP env filtering | Automatic | Always on | MCP servers only see safe env vars + declared vars |
| Grant actions | capabilities.grant | None | Allowed without approval |
| Approve actions | capabilities.approve | None | Requires human approval |
| Deny actions | capabilities.deny | None | Always blocked |
| Max risk level | capabilities.max_risk_level | medium | Actions above this risk require approval |
| Default policy | capabilities.default_policy | auto | Policy for actions not in grant/approve/deny |