Security Architecture
Digitorn's security model has two independent surfaces, each configured by its own block in the YAML and enforced at a different layer:
| Block | Source | Layer | What it controls |
|---|---|---|---|
tools.capabilities | CapabilitiesConfig | Application - runs in-process before every tool call | Which actions the agent can call, with grant / approve / deny / risk gates. |
security.behavior | BehaviorConfig | Behavioural - declarative rules + classifier injected into the agent loop | Pattern-based behaviour rules (read before edit, test after change, ...). |
Every behaviour and field on this page maps to real code in the daemon.
tools.capabilities - application security
CapabilitiesConfig. Optional -
absence means dev/test mode (no enforcement). Production apps should
declare it explicitly.
tools:
capabilities:
default_policy: auto # auto | approve | block
max_risk_level: medium # low | medium | high
grant:
- { module: filesystem, actions: [read, glob, grep] }
- { module: web, actions: [search] }
approve:
- { module: bash, actions: [run] }
- { module: filesystem, actions: [write, edit] }
deny:
- { module: workspace, actions: [delete] }
- { module: web, actions: [download] }
approval_timeout: 300 # seconds, [30, 3600]
hidden_modules: [] # ids hidden from the agent index
hidden_actions: [] # specific actions hidden
The compiler validates each (module, action) pair against the
loaded action registry - mistyped or non-existent actions raise a
compile error.
Fields
| Field | Type | Default | Description |
|---|---|---|---|
default_policy | auto | approve | block | approve | Action when no explicit grant matches. |
max_risk_level | low | medium | high | medium | Cap on the risk level an action may declare. |
grant | list[CapabilityGrant] | [] | Explicit allows. |
approve | list[CapabilityGrant] | [] | Each call pauses for user approval. |
deny | list[CapabilityGrant] | [] | Hard block. |
approval_timeout | int [30, 3600] | 300 | Seconds before an unanswered approval auto-denies. |
hidden_modules | list[string] | [] | Modules hidden from the agent index but still callable from setup steps / hooks / channels. |
hidden_actions | list[CapabilityGrant] | [] | Specific actions hidden but executable internally. |
CapabilityGrant is {module: str, actions: list[str], reason: str} (tools: also works as an alias for actions: - both are
merged if both are present). Empty actions means "all actions on
the module". reason is human-readable, surfaced on deny events.
How a tool call is gated - the gate chain
Every tool call not otherwise bypassed (see below) passes through
the same in-order gate sequence; the first deny decision stops the
chain, and a resolved approve at gate 4 raises
ApprovalRequiredError instead of denying. The audit log records
the decision with the gate name.
Real order - note 1c and 1d run between 1a and 1b, not after:
| Gate | Code label | Triggers when... |
|---|---|---|
| 0 | gate0_inactive | The app is deployed but not active (app.enabled: false). No admin bypass - Gate0Inactive checks AppActive unconditionally. |
| 1a | gate1a_module | The agent's profile can't access the module (hidden_modules or per-agent modules restriction filters it out). |
| 1c | gate1c_mcp_server | Module is mcp_<server> and the app has an allowed_servers list that doesn't include <server>. No-op if the app declares no MCP server allowlist. |
| 1d | gate1d_connector | Module is connector and the user hasn't connected + associated that connector in Settings → Connectors. No-op if the app uses no connectors. |
| 1b | gate1b_hidden | The action is in hidden_actions for this module. |
| 2 | gate2_risk | Action's declared risk exceeds max_risk_level, with no explicit grant or per-action policy. |
| 3 | gate3_permissions | Action declares required_permissions (symbolic, e.g. fs.write, net.http) and the profile lacks them. |
| 4 | gate4_policy | Resolved action policy is block (denied) or approve (paused for HITL). |
| 5 | gate5_classification | Per-tool data-classification rule rejects the params (e.g. PII detected in a non-PII-allowed channel). |
| 6 | gate6_rate_limit | Checked separately, after the gate chain passes: per-action rate limit window exceeded (PolicyContext.RateLimiter). |
Common gotcha — declaring a module is not granting it. Putting a module under
tools.modulesmakes it available; it does NOT permit its actions. A tool that declares a required permission (e.g.bash.run) is denied at gate 3 unless that permission is in the agent's granted set — either an explicitgrantentry for the module, or a matchingapprove. The error isdenied by security policy: required permission "bash.run" not in the agent's granted set. This bites even whendefault_policy: auto, because gate 3 runs before the default policy (gate 4): the default only decides actions that declare NO required permission (those pass gate 3 freely — which is whyfilesystem, with none declared, works ungranted whilebashdoes not). Fix: add the module totools.capabilities.grant. If it should run unattended (a background app, or a Build-mode agent tool), grant it outright; if a human should confirm each call, put it underapproveinstead.tools:
modules:
bash: {} # available…
capabilities:
grant:
- module: bash # …and now actually permitted (bash.run passes gate 3)
What never reaches the gate chain at all
RunGates checks three bypass categories before the gate chain
runs - none of these can be blocked via tools.capabilities.deny,
hidden_actions, or max_risk_level, because they never reach
gate 0:
| Bypass | What it covers | Why |
|---|---|---|
system_module_bypass | context_builder, llm_provider, index | Internal infrastructure, not user-facing tools. |
runtime_internal_bypass | memory, agent_spawn | Runtime-internal subsystems, intercepted by the dispatcher before capability checks apply. |
meta_tool_bypass | search_tools, get_tool, execute_tool, run_parallel, background_run, use_skill, call_app, ask_user, agent | Meta-tools dispatched by the runtime itself. The gates apply to the target tool reached via execute_tool, not to the dispatcher call. |
If you need to restrict memory or agent-spawn behavior, do it via
runtime.direct_modules / module absence, module-level constraints,
or the module's own config - not tools.capabilities.deny, which
has no effect on these two.
Resolving a policy
At gate 4, resolution order:
- Explicit deny in
tools.capabilities.denymatching this(module, action)pair →block. - Explicit approve in
tools.capabilities.approve→approve(wait for user OK). - Explicit grant in
tools.capabilities.grant→auto(allowed, no friction). - App-level
default_policy- final fallback when nothing above matched (approveby default).
There is no per-grant override of the app-level default - a
CapabilityGrant only has module, tools/actions, and reason;
matching one always resolves to auto.
When the resolved policy is approve, the gate raises
ApprovalRequiredError and the runtime enqueues an entry in the
the approval queue. The user picks approve / deny (or the app's
custom choices); the runtime resumes the turn with the choice
threaded back into the agent's context. If no answer comes within
approval_timeout seconds, the call auto-denies.
Risk levels
Every action declares a risk_level (low, medium, high)
in its decorator. max_risk_level caps what an
agent can call without an explicit grant - useful when you want to
allow most things but block destructive operations everywhere
without listing them one by one.
tools:
capabilities:
max_risk_level: low # only "low" actions auto-allowed
grant:
- { module: bash, actions: [run] } # explicit grant bypasses the cap
A high-risk action (e.g. filesystem.delete, bash.run in some
configurations) without an explicit grant is denied at gate 2.
Hidden vs denied
hidden_modules / hidden_actions | deny | |
|---|---|---|
| Visible to the agent | No (not in the tool index) | Yes (the agent can try) |
| Callable from setup steps / hooks / channels | Yes | No (gate 4 blocks) |
| Audit log entry on attempt | Filtered before reaching the audit | denied event with gate1 or gate4_policy |
Use hidden_* to declutter the agent's toolset without breaking
internal automation. Use deny when the action must NEVER fire,
internal or not.
security.behavior - runtime behavioural rules
BehaviorConfig. The behaviour
engine watches every tool call and injects corrections into the
loop. Optional - absence means no behavioural enforcement.
security:
behavior:
profile: coding # preset
classify_turns: true # semantic classifier on turn 0
classifier:
frequency: every_turn # every_turn | first_turn | manual
timeout: 15
approaches: [direct, plan_and_confirm, delegate]
brain: # cheap LLM for classification
provider: deepseek
model: deepseek-chat
backend: openai_compat
config:
api_key: "{{secret.DEEPSEEK_API_KEY}}"
rules:
read_before_edit: true
test_after_changes: true
no_bash_for_files: true
custom:
- id: protect_migrations
rule: "Never modify migration files without asking"
trigger: edit
condition:
path_matches: "alembic/versions/*"
action: block
message: "Migrations are append-only. Ask before editing."
rule_definitions: [] # fully declarative rules
state_tracking: null # uses defaults from profile when null
Fields
| Field | Type | Default | Description |
|---|---|---|---|
profile | string | null | null | Preset profile: dev, coding, research, data, creative, assistant. Or {{behavior.X}} to load from behavior/X.yaml. |
rules | dict[str, Any] | {} | Override individual rule keys (read_before_edit, test_after_changes, ...) defined by the profile. |
custom | list[BehaviorCustomRule] | [] | Legacy custom rules. Prefer rule_definitions. |
rule_definitions | list[BehaviorRuleDefinition] | [] | Fully declarative rules - work for any module/action. |
state_tracking | StateTrackingConfig | null | null | What the session state tracks (read_files, edited_files, ...). Profile defaults apply when null. |
classify_turns | bool | false | Enable semantic classification - a small LLM analyses each user message before the main agent acts. |
classifier | ClassifierConfig | default-instance | Configuration for the semantic classifier. |
brain | AgentBrain | null | null | LLM dedicated to classification. Falls back to the coordinator's brain. |
use_agent_brain | bool | true | When brain isn't set, reuse the coordinator's brain for classification. |
Built-in profiles
Each profile bundles a set of rules and sensible defaults:
| Profile | Targets | Typical rules enabled |
|---|---|---|
dev | Permissive baseline for development | Most rules off, audit-only. |
coding | Code-editing apps | read_before_edit, no_bash_for_files, test_after_changes, verify_after_edit. |
research | Read-mostly research | delegate_complex, cite_sources. |
data | Data-pipeline apps | confirm_destructive (on writes), read_before_edit for SQL, no kill on shell. |
creative | Free-form creative | Minimal restrictions, no_bash_for_files to keep it sane. |
assistant | General-purpose chat | Balanced default - modest restrictions, encouragement to plan. |
Custom profiles live in behavior/X.yaml in the bundle dir;
reference them with profile: "{{behavior.X}}". The compiler
inlines the profile content at compile time.
Three enforcement levels
Every rule declares action: block | warn | remind:
| Level | Effect |
|---|---|
block | The tool call is prevented. The runtime injects a system message back into the loop with the rule's message. |
warn | The tool call proceeds, but a warning is appended to the agent's next turn ("you violated rule X"). |
remind | A post-tool hint is added to the result (no tool blocking). |
Custom rules
BehaviorCustomRule:
security:
behavior:
custom:
- id: protect_migrations
rule: "Migrations are immutable - ask before editing."
enforce: pre_tool # pre_tool | post_tool
trigger: edit # tool name (or pattern)
condition:
path_matches: "alembic/versions/*"
action: block # block | warn | remind
message: "Cannot edit migrations without approval."
For more flexible matching (multiple triggers, complex conditions),
use rule_definitions: [BehaviorRuleDefinition] instead - same
shape but supports compositional conditions (all_of, any_of, not) and works against any action.
Full rule reference: Behavior Engine.
Semantic classifier
When classify_turns: true, before the main agent acts on turn 0
the daemon sends the user message to a small classifier brain that
emits:
- complexity (
trivial/simple/moderate/complex) - approach (one of
classifier.approaches) - risk (
low/medium/high)
These signals are injected into the main agent's prompt as behavioural directives ("This is a complex task - plan before acting. Risk: medium - confirm destructive operations.").
The classifier brain accepts the full AgentBrain shape - use a
cheap/fast model (claude-haiku-4-5, deepseek-chat, gpt-4o-mini) to keep latency under a couple of seconds.
Audit trail
Every gate decision on a tool call is appended as a
security_decision event into that call's own session event
log - the same durable log that holds the session's messages and
tool calls, not a separate global table:
| Field | Description |
|---|---|
app_id | Identifier of the deployed app. |
agent_id | Which agent the call was on. |
session_id | Active session. |
module / action | The (module, action) pair. |
risk_level | Effective risk level used during gating. |
params_redacted | Sanitised parameters (secrets redacted). |
decision | allow, deny, or needs_approval. |
gate | The gate that produced the decision. |
reason | Human-readable explanation. |
There's no admin-wide audit query API - a decision lives in the session it happened in, reviewed the same way as any other event in that session's transcript / activity view. See Security 7 - Audit trail for what that looks like from the app-author side.
Cross-references
- Block-level reference: App Configuration
tools.capabilities,security.behavior.
- Behavioural rules deep dive: Behavior Engine
- every built-in rule, classifier prompt, custom rule format.
- Credentials vault (separate from the gate engine): credentials.md.
- Per-module security knobs (filesystem path sandboxing, web egress filtering, ...): Module reference.
- Who can see and run an installed app (debug vs production visibility, install ownership): Install visibility.