Aleph
Security

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:

  1. Default deny — any tool ScopedToolService judges non-idempotent or approval-required must go through the gate.
  2. 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.
  3. Action-aware — sees the actual call (ApprovalAction carrying the real command / operation=delete path=…), not just the word bash.
  4. 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.

TierWhat it asks aboutNotes
AskEvery mutating / side-effecting toolRead-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
FullNothing[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"] via sessions.patch (the same carrier as project_root). null = follow global. sessions.patch validates the value against ExecTier::from_id, exactly as chat.send does.
  • 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:

  • idempotentLoopTool::is_idempotent() — the builtin pure-read allowlist (tools/retry.rs::is_idempotent_builtin_name, which delegates to the single READ_ONLY_TOOLS list in tools/adapters/registry_adapter.rs — read-only ⇒ idempotent, one source for the concurrency claim, auto-retry, and this tier rule) or an MCP server's readOnlyHint / idempotentHint. Anything that declares nothing is false.
  • requires_approvalToolDefinitionMetadata (an MCP server's destructiveHint).

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_approved excludes Timeout. 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 both resolve_for_session and list_pending, so a cancelled run's zombie can no longer win the /approve FIFO 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) or exec.approval.resolve {reason} (RPC) stamps ExecApprovalRecord.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.resolve payload also accepts an optional reason; 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 -EncodedCommand payloads 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 — ApprovalAction runs 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. Both WorkspaceSandbox and WorktreeSandbox route 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.

  1. Slash-command fast path (execution_engine/slash_command.rs). /bash, /file_ops, etc. dispatched straight into BuiltinToolRegistry — no tier, no tool_permissions, no requires_confirmation, no operator gate — and the path is reachable from channels. Now slash_gate_reason() returns the existing ExecutionError::Fallthrough for any gated call, routing it into the fully-gated agent loop; ungated slash commands keep their deterministic fast path.
  2. tools.invoke RPC (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, reading CONFIRMATION_REQUIRED_TOOLS). Known limitation: argument-level asks (an Auto-tier file_ops delete) are still ungated on this direct-invoke surface — closing that needs an approval transport on the RPC.
  3. Background runs (goal / loop continuations, cron, heartbeat, a2a). A continuation used to drop both caller_role and the channel's tool_permissions layer — a chat-tier Telegram session escalated itself to local-operator authority by continuing. carry_policy_metadata now forwards exactly those two keys (and deliberately not channel_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

On this page