Aleph
Concepts

Approval

Capability-domain approval engine for browser, desktop, desktop automation, PIM, and media capture actions.

The approval module provides an action-typed capability-domain approval engine covering browser, desktop, desktop automation, PIM write, and media capture. It is a layer distinct from the action-aware exec approval gate at src/sandbox/exec_approval/, which covers shell / tool calls and is enforced at src/tools/scoped/.

Design Philosophy

  1. Default deny — Without a matching rule or default, every request falls back to Ask
  2. Fail closed — A missing or unparseable policy file yields safe_default (everything Ask); Default::default() and safe_default never disagree
  3. Configuration-driven — Rules load from ~/.aleph/approval-policy.json, not from source

Architecture

ActionRequest ──▶ ApprovalPolicy::check() ──▶ ApprovalDecision

                         │  (blocklist → allowlist → defaults → ask)

                  ConfigApprovalPolicy

Core Types

ActionRequest

Describes the action an agent wants to perform:

pub struct ActionRequest {
    pub action_type: ActionType,
    pub target: String,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub display_target: String,
    pub agent_id: String,
    pub context: String,
    pub timestamp: DateTime<Utc>,
}

ActionType

The legitimate request categories by capability domain:

pub enum ActionType {
    BrowserNavigate,
    BrowserClick,
    BrowserType,
    BrowserFill,
    BrowserEvaluate,
    DesktopClick,
    DesktopType,
    DesktopKeyCombo,
    DesktopLaunchApp,
    DesktopAutomation,
    PimWrite,
    MediaCapture,
}

ApprovalDecision

The outcome of a policy check:

pub enum ApprovalDecision {
    Allow,
    Deny { reason: String },
    Ask { prompt: String },
}

Policy Implementation

ConfigApprovalPolicy

The primary policy implementation that loads rules from JSON configuration:

pub struct ConfigApprovalPolicy { /* ... */ }

impl ConfigApprovalPolicy {
    pub fn new(config: PolicyConfig) -> Self;
    pub fn load() -> Self;
    pub fn load_from(path: PathBuf) -> Self;
}

#[async_trait]
impl ApprovalPolicy for ConfigApprovalPolicy {
    async fn check(&self, request: &ActionRequest) -> ApprovalDecision;
    async fn record(&self, request: &ActionRequest, decision: &ApprovalDecision);
}

Decision flow (in order):

  1. Blocklist — if the target matches any blocklist entry for this ActionTypeDeny
  2. Allowlist — if the target matches any allowlist entry for this ActionTypeAllow
  3. Defaults — look up the per-ActionType DefaultDecision in defaults
  4. Fallback — no match ⇒ Ask

Fail closed: when ~/.aleph/approval-policy.json is missing, unreadable, or fails to parse, load_from returns safe_default (empty allowlist / empty blocklist / empty defaults) so every action becomes Ask. Default::default() and safe_default no longer disagree.

PolicyRule

pub struct PolicyRule {
    #[serde(rename = "type")]
    pub action_type: ActionType,
    pub pattern: String,
}

PolicyConfig top-level shape:

pub struct PolicyConfig {
    pub defaults: HashMap<ActionType, DefaultDecision>,
    #[serde(default)] pub allowlist: Vec<PolicyRule>,
    #[serde(default)] pub blocklist: Vec<PolicyRule>,
}

Glob Matching

Patterns use glob syntax with path-safe semantics:

pub fn matches_glob(value: &str, pattern: &str) -> bool;
  • * — matches any characters except / (including newline)
  • ** — matches any path segment, including across / and across newlines
  • ? — matches any single character except /

The ? pattern compiles to [^/] internally so a ** block rule cannot be evaded by a multi-line target. Compiled regular expressions are cached in a process-wide bounded cache so the hot path never re-compiles.

Both check and record route target through redact_target before writing a tracing event — the field becomes <redacted len=… sha=…> so clipboard text, PIM bodies, and script bodies cannot leak into logs.


Configuration File

Policies load from a JSON file whose path ConfigApprovalPolicy::config_path() resolves:

{
  "defaults": {
    "browser_navigate": "allow",
    "browser_evaluate": "ask",
    "desktop_automation": "ask",
    "pim_write": "ask"
  },
  "allowlist": [
    { "type": "browser_navigate", "pattern": "https://*.github.com/**" }
  ],
  "blocklist": [
    { "type": "browser_navigate", "pattern": "**/secrets/**" }
  ]
}

DefaultDecision serializes in lowercase ("allow" / "deny" / "ask"); invalid values are rejected at parse time.


Guardian Judge

src/approval/guardian_requester.rs::GuardianApprovalRequester is an LLM risk reviewer that wraps the human approval requester. Its verdict semantics:

  • risk == "low" and allow == true — auto-approved, human is skipped
  • Anything else — escalates to the wrapped human requester

The Guardian never denies on its own authority and never widens what a human would have been asked about. Code parse failures, provider errors, the 30-second timeout, and the provider circuit breaker (three consecutive failures trigger a five-minute cooldown) all escalate to the human. The reviewer consumes src/exec/masker.rs::SecretMasker and masks both the summary and per-segment raw text, so secrets are never serialized into the verdict payload and never reach the audit trail.

The verdict payload carries the stable GUARDIAN_SYSTEM rubric as a cache: true SystemPromptPart, letting Anthropic-compatible providers reuse the prompt cache across approvals (the per-action payload is the only dynamic tail).

The wiring lives at bin/aleph-server/commands/start/mod.rs::set_requester and is enabled only when [policies] guardian_review = true and a default provider is present.


Discord Interactive Approval

src/approval/callback_sink.rs::ApprovalCallbackSink provides a two-way, cross-channel stream — mcp.respond_approval / mcp.cancel_approval lets Discord buttons share a single pending-approval seat pool with TUI and Panel forms.


Relationship to the Exec Approval Gate

src/approval/ and the exec approval gate are two separate layers:

  • src/approval/ — capability domain (older): browser / desktop / PIM / automation / media capture decisions
  • src/sandbox/exec_approval/ — action-aware (newer): shell commands / tool-call fingerprint, denial ledger, and the Ask / Auto / Full tier

The exec gate forces every tool through a single chokepoint at src/tools/scoped/. The Ask / Auto / Full tiers live at src/config/types/policies/exec_tier.rs, read declared ToolFacts metadata, and fail closed for unknown tools in the ask tier. No execution bypasses src/tools/scoped/.


Code Location

  • src/approval/mod.rs — module entry and public exports
  • src/approval/types.rsActionRequest / ActionType / ApprovalDecision / DefaultDecision
  • src/approval/policy.rsApprovalPolicy trait
  • src/approval/config.rsConfigApprovalPolicy / PolicyConfig / glob matching / cache / safe_default
  • src/approval/guardian_requester.rs — Guardian Judge triage
  • src/approval/callback_sink.rs — cross-channel approval callback stream
  • src/approval/adapters.rs — builtin tool adapters

See Also

On this page