Skip to main content

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:

BlockSourceLayerWhat it controls
tools.capabilitiesCapabilitiesConfigApplication - runs in-process before every tool callWhich actions the agent can call, with grant / approve / deny / risk gates.
security.behaviorBehaviorConfigBehavioural - declarative rules + classifier injected into the agent loopPattern-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.

yaml
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​

FieldTypeDefaultDescription
default_policyauto | approve | blockapproveAction when no explicit grant matches.
max_risk_levellow | medium | highmediumCap on the risk level an action may declare.
grantlist[CapabilityGrant][]Explicit allows.
approvelist[CapabilityGrant][]Each call pauses for user approval.
denylist[CapabilityGrant][]Hard block.
approval_timeoutint [30, 3600]300Seconds before an unanswered approval auto-denies.
hidden_moduleslist[string][]Modules hidden from the agent index but still callable from setup steps / hooks / channels.
hidden_actionslist[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:

GateCode labelTriggers when...
0gate0_inactiveThe app is deployed but not active (app.enabled: false). No admin bypass - Gate0Inactive checks AppActive unconditionally.
1agate1a_moduleThe agent's profile can't access the module (hidden_modules or per-agent modules restriction filters it out).
1cgate1c_mcp_serverModule 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.
1dgate1d_connectorModule is connector and the user hasn't connected + associated that connector in Settings → Connectors. No-op if the app uses no connectors.
1bgate1b_hiddenThe action is in hidden_actions for this module.
2gate2_riskAction's declared risk exceeds max_risk_level, with no explicit grant or per-action policy.
3gate3_permissionsAction declares required_permissions (symbolic, e.g. fs.write, net.http) and the profile lacks them.
4gate4_policyResolved action policy is block (denied) or approve (paused for HITL).
5gate5_classificationPer-tool data-classification rule rejects the params (e.g. PII detected in a non-PII-allowed channel).
6gate6_rate_limitChecked 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.modules makes 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 explicit grant entry for the module, or a matching approve. The error is denied by security policy: required permission "bash.run" not in the agent's granted set. This bites even when default_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 why filesystem, with none declared, works ungranted while bash does not). Fix: add the module to tools.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 under approve instead.

yaml
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:

BypassWhat it coversWhy
system_module_bypasscontext_builder, llm_provider, indexInternal infrastructure, not user-facing tools.
runtime_internal_bypassmemory, agent_spawnRuntime-internal subsystems, intercepted by the dispatcher before capability checks apply.
meta_tool_bypasssearch_tools, get_tool, execute_tool, run_parallel, background_run, use_skill, call_app, ask_user, agentMeta-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:

  1. Explicit deny in tools.capabilities.deny matching this (module, action) pair → block.
  2. Explicit approve in tools.capabilities.approve → approve (wait for user OK).
  3. Explicit grant in tools.capabilities.grant → auto (allowed, no friction).
  4. App-level default_policy - final fallback when nothing above matched (approve by 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.

yaml
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_actionsdeny
Visible to the agentNo (not in the tool index)Yes (the agent can try)
Callable from setup steps / hooks / channelsYesNo (gate 4 blocks)
Audit log entry on attemptFiltered before reaching the auditdenied 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.

yaml
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​

FieldTypeDefaultDescription
profilestring | nullnullPreset profile: dev, coding, research, data, creative, assistant. Or {{behavior.X}} to load from behavior/X.yaml.
rulesdict[str, Any]{}Override individual rule keys (read_before_edit, test_after_changes, ...) defined by the profile.
customlist[BehaviorCustomRule][]Legacy custom rules. Prefer rule_definitions.
rule_definitionslist[BehaviorRuleDefinition][]Fully declarative rules - work for any module/action.
state_trackingStateTrackingConfig | nullnullWhat the session state tracks (read_files, edited_files, ...). Profile defaults apply when null.
classify_turnsboolfalseEnable semantic classification - a small LLM analyses each user message before the main agent acts.
classifierClassifierConfigdefault-instanceConfiguration for the semantic classifier.
brainAgentBrain | nullnullLLM dedicated to classification. Falls back to the coordinator's brain.
use_agent_brainbooltrueWhen brain isn't set, reuse the coordinator's brain for classification.

Built-in profiles​

Each profile bundles a set of rules and sensible defaults:

ProfileTargetsTypical rules enabled
devPermissive baseline for developmentMost rules off, audit-only.
codingCode-editing appsread_before_edit, no_bash_for_files, test_after_changes, verify_after_edit.
researchRead-mostly researchdelegate_complex, cite_sources.
dataData-pipeline appsconfirm_destructive (on writes), read_before_edit for SQL, no kill on shell.
creativeFree-form creativeMinimal restrictions, no_bash_for_files to keep it sane.
assistantGeneral-purpose chatBalanced 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:

LevelEffect
blockThe tool call is prevented. The runtime injects a system message back into the loop with the rule's message.
warnThe tool call proceeds, but a warning is appended to the agent's next turn ("you violated rule X").
remindA post-tool hint is added to the result (no tool blocking).

Custom rules​

BehaviorCustomRule:

yaml
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:

FieldDescription
app_idIdentifier of the deployed app.
agent_idWhich agent the call was on.
session_idActive session.
module / actionThe (module, action) pair.
risk_levelEffective risk level used during gating.
params_redactedSanitised parameters (secrets redacted).
decisionallow, deny, or needs_approval.
gateThe gate that produced the decision.
reasonHuman-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.