Execution Approval
Action-aware human-in-the-loop approval gate — ToolFacts metadata, grant fingerprint, 120 s timeout
Overview
Aleph's execution approval system is an action-aware human-in-the-loop
gate: every shell command, every mutating operation, every confirm-gated
tool call passes through the same enforcement point
src/sandbox/exec_approval/. The human sees the actual call, not the
tool name; the grant fingerprint keys on raw canonical arguments.
Core design points:
- Default deny — any tool
ScopedToolServicejudges non-idempotent or approval-required must go through the gate. - Metadata-driven — the gate reads
ToolFacts.name/idempotent/requires_approval, never the tool name — MCP tools register as{server}__{tool}, and a name-glob table silently lets whole families through the gate it claims to hold. - Action-aware — sees the actual call (
ApprovalActioncarrying the real command /operation=delete path=…), not just the wordbash. - Grant fingerprint keys on canonical args — uses the raw canonical args, not the redacted summary (redaction would collapse distinct secrets to one placeholder, letting one grant cover another).
Source locations:
- Enforcement chokepoint:
src/tools/scoped/(dispatch.rs,builder.rs,traits.rs) - Action-aware gate:
src/sandbox/exec_approval/(gate.rs,action.rs,session_memory.rs,denial_ledger.rs) - Exec tier:
src/config/types/policies/exec_tier.rs - Pending / resolve pairing:
src/exec/manager.rs(ExecApprovalManager) - Rendering helper:
src/exec/parser.rs::analyze_shell_command(render only)
Architecture
Tool call
│
▼
ScopedToolService::execute_inner
│
├── [policies.tool_permissions] ── explicit entries win
│
├── exec tier ──────────────────► Ask / Auto / Full
│ │
│ ▼
│ ToolFacts read
│ (idempotent, requires_approval)
│
├── [sandbox.command_policy] ─── hardline command floor
│ (no tier can lower)
│
▼
ApprovalGate (action-aware)
│
├── grant_fingerprint(tool, canonical args)
│ │
│ ├── hit in session_memory? ─► Allow (cached)
│ │
│ └── otherwise:
│ Render ApprovalAction (command / op=delete path=…)
│ Route through ApprovalRequester to a human
│ │
│ ├── AllowOnce ─► this call only
│ ├── AllowSession ─► this + same fingerprint later
│ │ (cached by fingerprint)
│ ├── AllowAlways ─► clamped to AllowSession by clamp_decision
│ │ (no persistent allowlist — button
│ │ is never rendered)
│ ├── Deny <reason> ─► refuse; reason rendered back to model
│ └── Timeout ─► refuse (not ledgered)Exec tier (Ask / Auto / Full)
[policies] exec_tier is the one user-facing dial over tool
permissions. It is not a second policy engine and not a second
enforcement mechanism: it is a rule consulted at the chokepoint every
tool call already funnels through, whenever no explicit
[policies.tool_permissions] entry names the tool.
| Tier | What it asks about | Notes |
|---|---|---|
Ask | Every mutating / side-effecting tool | Read-only tools stay allowed, so the model can still investigate |
Auto (default) | The irreversible tail only | *_delete, vault_*, team_disband, an MCP server's destructiveHint, and file_ops argument-level delete / move |
Full | Nothing | [sandbox.command_policy] floor still applies |
The lattice (who wins)
explicit [policies.tool_permissions] entry (exact name > glob)
↓ (nothing named this tool)
configured `default` TIGHTENED BY the tier's verdict
↓ (restrictive_min — tier can only raise, never widen)
[sandbox.command_policy] hardline floor (no tier can lower it — Full included)effective_permission(permissions, tier, facts) is the only place this
precedence exists. Both consumers (ScopedToolService::permission_for and
the gateway slash-command fast path) call it, so the two surfaces cannot
drift.
Where the tier comes from, per turn
resolve_turn_permissions resolves it once per run: request > session >
global.
- request — the Panel composer pill sends the tier with the first
message (
chat.send), so it governs the very turn it was armed for. - session — persisted to
identity_meta.custom["exec_tier"]viasessions.patch(the same carrier asproject_root).null= follow global.sessions.patchvalidates the value againstExecTier::from_id, exactly aschat.senddoes. - global —
[policies] exec_tier, read live per turn (no restart). - A non-operator caller (a chat-tier channel) is clamped after resolution: it can tighten the tier but never raise it.
Unknown-tool default: fail closed
ToolFacts { name, idempotent, requires_approval } is filled from the
tool's own ToolDefinition:
idempotent←LoopTool::is_idempotent()— the builtin pure-read allowlist (tools/retry.rs::is_idempotent_builtin_name, which delegates to the singleREAD_ONLY_TOOLSlist intools/adapters/registry_adapter.rs— read-only ⇒ idempotent, one source for the concurrency claim, auto-retry, and this tier rule) or an MCP server'sreadOnlyHint/idempotentHint. Anything that declares nothing isfalse.requires_approval←ToolDefinitionMetadata(an MCP server'sdestructiveHint).
Hence: not idempotent = mutating, an unknown tool is
non-idempotent, so Ask tier is fail-closed for every tool Aleph has
never heard of. A table of name globs cannot do this — MCP tools register
as {server}__{tool} (github__delete_repo), and any glob table
silently lets whole families through the gate it claims to hold.
The one argument-level exception: file_ops multiplexes list and
delete behind a single name, so ExecTier::asks_for_arguments reads
the operation field. That is a deterministic safety hard-filter
(explicitly permitted by R7), not a judgement about intent.
Action-aware approval gate
ApprovalAction
The gate is asked to approve this call, not this tool:
// src/sandbox/exec_approval/action.rs
pub struct ApprovalAction {
pub tool_name: String,
pub summary: String, // the ACTUAL call, redacted + capped
pub cwd: Option<String>,
pub analysis: Option<CommandAnalysis>,
pub reason: String,
}
// src/sandbox/exec_approval/gate.rs
trait ApprovalRequester {
async fn request_approval(&self, action: &ApprovalAction) -> ApprovalResponse;
}
pub struct ApprovalResponse {
pub outcome: ApprovalOutcome,
pub deny_reason: Option<String>, // the human's own words on a /deny <reason>
}summary renders what will actually happen — the command for bash /
code_exec (fed through the real exec::parser::analyze_shell_command),
operation=delete path=… for file_ops, k=v otherwise — then passes
through SecretMasker, is newline-flattened, and is capped at 200 chars
on a char_indices() boundary. A confirmation gate that hides what it
is gating trains the user to click Approve, which converts the whole
tier system into theater; every surface (Panel card, plain-text channel
prompt, cluster node over reverse RPC) now shows the action.
Grant fingerprint keys on the action, not the tool
confirm_with_memory computes grant_fingerprint(tool, &args) once —
over the raw canonical arguments, not the redacted summary (redaction
collapses distinct secrets to one placeholder, so a grant on one
credential would cover another) — and uses it for both the session
memory and the denial ledger.
Consequence: "Allow for this session" authorizes that exact call.
Under Ask, bash re-prompts per distinct command instead of
whitelisting arbitrary argv after one approval (codex's
ApprovalStore semantics). Noisier, deliberately.
Decisions
// src/exec/socket.rs
pub enum ApprovalDecisionType {
AllowOnce, // this call
AllowSession, // this call, for the rest of the session (by fingerprint)
AllowAlways, // clamped to AllowSession by clamp_decision
// (no persistent allowlist — never rendered)
Deny,
}- Timeout ⇒ refusal.
DEFAULT_APPROVAL_TIMEOUT_MS = 120s;ApprovalOutcome::is_approvedexcludesTimeout. A timeout is deliberately not written to the denial ledger — an expired card is not a decision. There is no fail-open path. - Orphans cannot hijack a card.
PendingEntry::is_live()(not expired ∧ receiver not closed) filters bothresolve_for_sessionandlist_pending, so a cancelled run's zombie can no longer win the/approveFIFO or render. - Denial is terminal for that call, returned to the model as an
in-context instruction not to retry it, rewrite it, or achieve the
same result by other means. Three denials trip the sticky pause in
denial_ledger.rs. - A denial can carry the human's reason.
/deny wrong directory, use /tmp(channels) orexec.approval.resolve {reason}(RPC) stampsExecApprovalRecord.deny_reason; the gate renders it verbatim in the model-facing error (The user said: "…") so the model re-plans on the actual objection. Display-layer only — the ledger still keys on the fingerprint.
Decision transport
All approvals flow over WebSocket JSON-RPC:
- Panel →
exec.approval.list/exec.approval.resolve {outcome, reason}RPC. - Channels (Telegram / Discord / iMessage) → the same RPC, rendered as inline buttons.
- Cluster nodes → reverse RPC (the
exec.approval.resolvepayload also accepts an optionalreason; older nodes ignore it).
There used to be a Unix-socket IPC transport for approvals — it was
never wired into the server and has been removed (see src/exec/socket.rs
docs). The only wire vocabulary is WebSocket + JSON-RPC.
Command analysis (approval-summary rendering only)
src/exec/parser.rs::analyze_shell_command splits a shell string into
its executables and segments. Its output feeds the approval card
summary (so a human approving a bash call sees the real command, not
just the word bash) — it is a rendering aid, not an enforcement
gate. The catastrophic floor that actually refuses commands is
sandbox::command_policy, whose real hardline rules
(command_policy/rules.rs::hardline_rules) cover:
- fork bomb;
- bare-root
rm -rf //rm -rf ////.(cycle 7 tightened the multi-slash / dot bypass); dd/mkfs/ redirect to raw block device;- Windows: drive / hive-root recursive delete,
format, the shadow-copy destruction chain; powershell -EncodedCommandpayloads decoded before matching, so encoding a script does not remove it from the floor's view.
Output masking
Two distinct paths, by consumer:
SecretMasker(src/exec/masker.rs) — redacts secrets in strings shown to a human or written to logs (e.g., the approval-card summary —ApprovalActionruns its command summary through it).sandbox::scrub::scrub_and_gate_output(src/sandbox/scrub.rs) — the single source of truth for a finished command's stdout / stderr: redacts secrets at the byte level, strips invisible / bidi control characters, and returns a block-class verdict. A block-class hit (a PEM private key —leak_detector::BLOCK_CLASS_SECRETS) makes the sandbox fail closed rather than return the surrounding context. BothWorkspaceSandboxandWorktreeSandboxroute their output through it, so the floor cannot diverge between execution paths.
The two secret-pattern catalogs (src/secrets/leak_detector.rs and
src/exec/secret_patterns.rs) are kept in sync — the private-key regex
is -----BEGIN[A-Z ]*PRIVATE KEY----- in both, so a bare PKCS#8 header
cannot slip one catalog but not the other.
Three closed bypasses (do not re-open them)
Each of these was a surface that could execute a tool without passing
through ScopedToolService. When you add a new such surface, this is the
question to ask first.
- Slash-command fast path (
execution_engine/slash_command.rs)./bash,/file_ops, etc. dispatched straight intoBuiltinToolRegistry— no tier, notool_permissions, norequires_confirmation, no operator gate — and the path is reachable from channels. Nowslash_gate_reason()returns the existingExecutionError::Fallthroughfor any gated call, routing it into the fully-gated agent loop; ungated slash commands keep their deterministic fast path. tools.invokeRPC (gateway/handlers/tools_invoke.rs). Its denylist (security/dangerous_tools.rs) once named 7 tools that did not exist in this repo — it had been inert its entire life. It now names the real ones and also refuses confirmation-gated tools (is_confirmation_gated, readingCONFIRMATION_REQUIRED_TOOLS). Known limitation: argument-level asks (anAuto-tierfile_ops delete) are still ungated on this direct-invoke surface — closing that needs an approval transport on the RPC.- Background runs (goal / loop continuations, cron, heartbeat,
a2a). A continuation used to drop both
caller_roleand the channel'stool_permissionslayer — a chat-tier Telegram session escalated itself to local-operator authority by continuing.carry_policy_metadatanow forwards exactly those two keys (and deliberately notchannel_id/conversation_id, which would make an unattended run's approvals look deliverable).
Unattended = fail closed
A run with no human attached is stamped UNATTENDED_KEY
(execution_engine/mod.rs) at every headless producer: cron when its
approval is not routable (a job carrying both a source channel and a
conversation has a real /approve path — stamping it would auto-deny a
working HITL flow), heartbeat and a2a always. continuation_metadata
inserts the marker last, so an inherited key can never demote a
continuation to attended. The flag also rides
TurnContext.unattended, so session_send stamps wait-mode children
of a headless parent too (fire_and_forget || parent.unattended) —
previously only fire-and-forget children were stamped and a headless
parent's wait-mode child hung the full 120 s per gated tool before
refusing.
ScopedToolService then immediately denies confirm-gated tools
instead of publishing an approval card into the void and blocking for
the 120 s timeout. The model is told the run is unattended.
Teams (dispatcher / broadcast) are deliberately not stamped: a member run's approvals resolve to a Panel card, and the user who dispatched the team is the operator watching it.
Audit
The session event log is the live trail.
tools/scoped/dispatch.rs::record_approval_decision writes
ToolCallApproved / ToolCallDenied session events. Query it through
the session service, alongside every other event of the run that
produced it.
Do not look for src/exec/approval/storage.rs or audit.rs — they
were deleted together with the aleph-server audit CLI on 2026-07-14.
Their three SQLite tables (~/.aleph/approval_audit.db) had only test
helpers as writers, so an operator running audit got zeros and
concluded nothing had happened — while the real trail sat in the session
event log. That is worse than dead code. Also removed in the same sweep,
all zero-consumer: src/exec/approval/{escalation,binding,path_canonicalize}.rs,
benches/approval_performance.rs,
exec/allowed_decisions.rs::{decisions_for_risk, assess_command_decisions, risk_segments}.
See also
- Security Overview — tier and floor design
- Sandboxing — OS isolation + the command floor
- Pairing — permission system the paired device interacts with