Security Overview
Defense in depth — network boundary, TLS, execution-approval gate, sandboxing, signed ledger
Security Architecture
Aleph's security model is built on several layers that operate independently. A failure in one layer does not collapse the others.
Layer 1 — Network boundary + Gateway credential
├─ Default loopback bind, zero-config operator
├─ Remote connection must present one of:
│ • device token (aleph-dt-*, long-lived)
│ • bootstrap ticket (aleph-bt-*, 5-min single-use)
│ • shared Gateway token (aleph-<uuid>, HMAC-hashed)
├─ Fail closed (per-connect gate)
└─ Native TLS + TOFU client trust
Layer 2 — Tool permissions
├─ ScopedToolService merge (global → agent → channel)
├─ Exec tier (Ask / Auto / Full) — keyed on ToolFacts metadata
├─ Explicit [policies.tool_permissions] entries win
└─ [sandbox.command_policy] hardline floor
Layer 3 — Execution approval
├─ action-aware approval gate (ApprovalAction)
├─ grant fingerprint keys on canonical args (not the redacted summary)
├─ 120s timeout ⇒ refusal (not ledgered)
└─ /deny <reason> routes the reason back to the model
Layer 4 — Sandbox
├─ WorkspaceSandbox (six-step pipeline)
├─ Platform-native enforcement:
│ • macOS — sandbox-exec + Seatbelt
│ • Linux — bwrap + Landlock + seccomp-bpf + cgroup v2
│ • Windows — AppContainer + RestrictedToken + JobObject
├─ [sandbox.command_policy] hardline command filter
└─ SSRF engine + parse normalisation
Layer 5 — Audit
├─ session event log (ToolCallApproved / ToolCallDenied)
└─ per-agent signed ledger (Ed25519 hash chain)Network boundary + Gateway token
The trust boundary is the network boundary, gated by a single shared Gateway token.
| Configuration | Meaning |
|---|---|
host = "127.0.0.1" (default) | Only same-machine processes; zero-config operator |
host = "0.0.0.0" | Remote devices can reach the socket but must present a valid credential |
Loopback is always credential-free (is_loopback ⇒ operator). A remote
connection must present one of, in priority order:
- Device token (
aleph-dt-*) — long-lived, bound to one paired device, SHA-256-hashed at rest. - Bootstrap ticket (
aleph-bt-*) — 5-minute single-use, exchanged during onboarding for a fresh device token. - Shared Gateway token (
aleph-<uuid>) — generated at boot bySharedTokenManager, HMAC-hashed, constant-time verified.
A valid credential = full operator authority (identical to local); a
missing/invalid one is walled (the WS dispatch refuses every method but
connect).
Remote transport encryption (TLS)
Loopback stays plaintext ws:// — the zero-config desktop / CLI /
same-host-proxy hop is unchanged. For a non-loopback bind Aleph is now
fail-closed:
- Boot gate (
check_network_exposure): a config that binds a non-loopbackhostwith no native TLS, no trusted proxy, andallow_insecure_remote = falserefuses to start with an actionable error. - Per-connect gate (
refuse_insecure_remote): a remote client whose leg is unencrypted is rejected at the WS upgrade with426 Upgrade Required.
Three ways to satisfy it:
| Tier | How |
|---|---|
| ① | Same-host Caddy / nginx reverse proxy (recommended; needs a domain) |
| ② | Native self-signed TLS (no domain; clients go through TOFU) |
| ③ | Native TLS with operator-provided PEM (domain, no proxy) |
allow_insecure_remote = true is the escape hatch — boot gate and
per-connect gate both stand down, restoring pre-hardening behavior. Only
for a trusted, isolated LAN.
Client trust for self-signed TLS (TOFU)
Tier ② client trust for self-signed TLS uses Trust-on-First-Use (TOFU)
(src/gateway/cert_trust.rs):
- Shared decision core + pinned TOFU trust store
(
~/.aleph/data/security.db). - SHA-256 fingerprint + SAN / Subject parsing.
- Approval splash page (fingerprint + SAN + TOFU / change warning).
- Pending-cert state + approve / reject Tauri commands.
- macOS WKWebView
respondsToSelectorcache invalidation; absolute-URL navigation to the trust-approval page. - iOS Keychain trust store + decision mirror (shares decision state with macOS).
A newly-appearing interface IP triggers cert regen (new fingerprint ⇒
re-trust once); a sans.txt sidecar tracks SAN coverage so churn stays
minimal.
Pairing
Aleph now distinguishes two pairing flows:
Device pairing (remote Panel / phone / CLI)
- The remote Panel, in Settings → Security → "Pair new device", calls
gateway.ticket.createto mint a bootstrap ticket (aleph-bt-*). - The URL is resolved server-side (
gateway.ticket.createreturnsurls, fromtls::discover_interface_ips) — a Panel building it from its ownwindow.locationwould emithttp://127.0.0.1:<port>/…whenever the operator generates the ticket from the local desktop App. - The Panel submits the ticket as
connect.bootstrap_ticket; the handshake exchanges it for a freshdevice_token(aleph-dt-*) that the Panel persists for subsequent reconnects. - Revocation:
gateway.token.rotate(regenerates the shared token, revokes every paired device, closes every remote socket) orgateway.devices.revoke {device_id}(drops just that device's live sessions back to the login wall, then closes their sockets with WS 4001device_revoked).
Headless cores use aleph-server pair to mint a ticket directly (opens
the 0600 security.db, WAL — the daemon need not be running).
aleph-server bootstrap-token prints the shared Gateway token for
recovery.
Channel sender pairing (Telegram / Discord / iMessage unknown sender)
A lighter flow: a 6-character alphanumeric code in the single inbox
pairing store (src/gateway/pairing_store.rs), approved via the RPC
pairing.list / pairing.approve (not the aleph pairing approve CLI).
[routing] pairing_code_expiry_secs controls expiry (default 24 h,
0 = never).
How the two flows differ
- Device: long-lived credential (
device_token), individually revocable, never placed in a URL / QR (only the single-use bootstrap ticket travels through those channels). - Channel sender: short-lived authorization (inbound router pairing store), managed through RPC.
Tool permissions
Limiting what an agent may do (as opposed to who may connect) is the
job of ScopedToolService (src/tools/scoped/). It does not read
IdentityContext; it merges a ToolPermissionsConfig across three tiers
(global → agent → channel, most-restrictive wins). This is orthogonal
to connection trust.
Three compositions happen at the same chokepoint, in this order:
[policies.tool_permissions]— per-toolallow/ask/deny, exact name or glob. An explicit entry beats the tier.- Exec tier (
Ask/Auto/Full) — readsToolFacts{idempotent, requires_approval}, not tool names. Default is fail-closed: a tool Aleph has never heard of is non-idempotent, henceAsktier holds. [sandbox.command_policy]— the hardline command floor, which no tier and no permission entry can lower.
Deny hides the tool from the model and refuses the call; Ask routes
to the approval gate.
Execution approval gate
The action-aware approval gate (src/sandbox/exec_approval/):
- Sees the actual call (
ApprovalAction), not the tool name. - Grant fingerprint =
grant_fingerprint(tool, canonical args)— uses the raw canonical args, not the redacted summary (redaction would collapse distinct secrets to one placeholder, letting one grant cover another). - Decisions:
AllowOnce/AllowSession/AllowAlways(AllowAlwaysis clamped toAllowSessionbyExecApprovalManager::clamp_decision— no persistent allowlist exists). - 120 s timeout ⇒ refusal (
ApprovalOutcome::is_approvedexcludesTimeout), not written to the denial ledger. /deny <reason>(channels) orexec.approval.resolve {reason}(RPC) carries the human's reason viaExecApprovalRecord.deny_reason→ResolvedDecision→ApprovalResponse; the dispatch gate renders it verbatim asThe user said: "…"(display layer; the ledger still keys on the fingerprint).
Sandbox
WorkspaceSandbox (src/sandbox/workspace.rs) is the one seam between
exec-class tools and the OS, with a six-step pipeline:
- Session resolve — lazy create
~/.aleph/workspaces/<session_hash>/. - cwd validate —
canonicalize+starts_with(&ws.cwd); a symlink cannot escape (BUG-3 hardening). - Capability check —
SandboxCapabilities::is_within; out-of-baseline requests go throughApprovalGate, with per-session grant caching. - Profile generate — platform-native (SBPL / bwrap args / AppContainer profile).
- Run — through
OsSandboxDriver; default 60 s wall-clock + 1 MiB output budget. - Audit —
tracing::info!to thecapability_ledgertarget.
The command-policy layer (src/sandbox/command_policy/) runs before
the OS sandbox: a deterministic hard-filter covering fork bombs, rm -rf / / rm -rf // / /., dd of=/dev/..., mkfs, shadow-copy deletion,
powershell -EncodedCommand (decoded before matching), Windows raw-disk
writes, and more.
Platform support:
| OS | Enforcement |
|---|---|
| macOS | sandbox-exec + Seatbelt profile + AllowHosts via managed proxy |
| Linux | bwrap + Landlock (≥ 5.13) + seccomp-bpf + cgroup v2 resource limits |
| Windows | AppContainer (preferred) → RestrictedToken + Low IL → baseline JobObject (three-tier soft-degrade) |
Linux/Windows NetworkPolicy::AllowHosts flows through a netns→UDS→
loopback bridge (Linux, Phase B) or AppContainer capability SIDs
(Windows) — a raw AllowHosts on those OSes still hard-fails with a
readable rejection message.
Audit
The session event log is the live trail for approval decisions
(tools/scoped/dispatch.rs::record_approval_decision writes
ToolCallApproved / ToolCallDenied). Query it through the session
service alongside every other event of the run that produced it.
The signed agent ledger (src/identity/, landed 2026-07-25,
hardened 2026-07-26) answers who, provably:
- Each agent holds an Ed25519 keypair (public half + fingerprint in
security.db, private half in the encrypted vault). - Every mutating tool call, every refusal, every approval decision is appended to that agent's own hash-chained, signed chain.
agent_identity(tool, operator-gated) andaleph-server identity(CLI, read-only, daemon-independent) read it.
A delegated sub-agent is its own principal in the signed-ledger model —
not a line on its parent's chain. It holds its own Ed25519 key and signs
its own work. The acting role is injected by AllowlistToolService, which
the spawner builds from the child's AgentDef.
Cross-process safety guarantees (Spec C)
The original .shared_token corruption incident had a structural cause:
nothing prevented two aleph-server processes from writing the same
~/.aleph/data/ files. Spec C closes that gap with four layered
protections:
- Singleton lock:
aleph-server startacquires~/.aleph/data/aleph.lockviaflock()inmain()before any other state. A secondstartexits cleanly with code 64 + a stderr diagnostic naming the holder PID. - Vault writes: every write to
secrets.vaultgoes throughvault_io::VaultIo, combining an fcntl exclusive lock withtempfile + persistfor atomicity. - JSON state writes:
acp_sessions.jsonand equivalent files useatomic_io::write_atomic. - SQLite connections: every connection under
~/.aleph/data/is opened viaopen_sqlite_safe, settingjournal_mode=WAL,busy_timeout=5000,synchronous=NORMAL. - CLI dispatch: every CLI subcommand declares a
CommandPolicy(NoLock / LockOnly / LockOrIpc) and routes throughwith_policyorrun_no_lock. When the server holds the singleton lock, write subcommands forward to/v1/admin/*IPC endpoints (token rotation self-heals via a single 401 retry).
See also
- Execution Approval —
exec_tier+ the action-aware approval gate - Pairing — device and channel-sender trust establishment
- Sandboxing — OS-level isolation
- IPC — WebSocket control plane + JSON-RPC