SECURITY

Two-layer security model. Approval system, SSRF, prompt-injection, sensitive paths. Sandbox.

10 / 16·reference·v0.9.26

alpi is published by Satoshi Ltd., whose three load-bearing principles for this document are:

Security First — threat-modeled from initial development; no surveillance disguised as telemetry. Privacy by Design — privacy is the foundation, not a feature. Zero Knowledge — what we don't know can't be subpoenaed, leaked, or sold.

Those are the frame every decision below lives inside: the guard is mandatory and local, the sandbox is an opt-in second wall, the LLM is treated as an adversary with user credentials, and we keep as little state off your machine as we can.

alpi runs LLM-decided tool calls on your machine. The security posture is layered — application-level guards that always run, plus an optional OS-level sandbox for shell commands.

Layer 1 — application guards (always on)

Live inside the Python process, can't be disabled without editing source. Cover the attack vectors that an OS sandbox around terminal doesn't reach:

Replaces the previous hard denylist. See docs/CONFIG.md for the allowlist format and surface-specific behaviour.

Defense in depth: the network layer (Tailscale / WPA2) cipheres the wire so the token doesn't leak; the token layer authenticates the device. Public IPs would break the first invariant — that's why the bind validator refuses them.

Paired devices carry a role. From v0.6.10, each entry in ~/.alpi/host/devices.yaml has a role field — admin or member (legacy entries without the field read back as member, least privilege). The dispatcher gates sensitive verbs against the role:

What member does NOT restrict. The role limits the host control plane (config, devices, gateways, MCP, profile lifecycle, schedules, daemon restart). It does not sandbox the agent itself: a member device can still send chat turns via host.chat.send, which means anything the agent's tools can do — write to the workspace, edit memories, hit external HTTP — is reachable. If you need a sandbox boundary on agent capabilities, use the OS sandbox flag and / or a separate profile, not the device role.

Three host.network.* verbs (status, set_advertised, restart_host_server) stay in _LOCAL_ONLY_METHODS — no remote role unlocks them. The admin allowlist lives in _ADMIN_METHODS in alpi/host/server.py.

host.profile.read_file carries an independent deny list, applied on every caller regardless of role. Checks happen by path components, not just top-level prefixes:

Secrets surface only through dedicated, audited methods.

Layer 2 — OS sandbox (opt-in, per profile)

Wraps terminal subprocess calls in a native OS sandbox so the kernel refuses the syscalls, not just the detector above. Persistent writes are confined to workspace + ~/.alpi/ + the system temporary trees (/tmp, plus macOS-specific /private/tmp and /private/var/folders); a small set of character devices that well-behaved CLI tools reopen (/dev/null, /dev/{u,}random, /dev/tty, std streams) is also writable but they are not persistent storage. Read posture is platform-specific: Linux/bubblewrap only makes explicitly-mounted paths readable — workspace and profile bind-mounted writable, runtime system paths (/usr, /etc, /bin, the loader and libraries the process needs) mounted read-only, /tmp as an in-sandbox tmpfs — so anything not mounted is invisible. macOS/sandbox-exec runs default-allow for reads with a small explicit deny list (~/.ssh, ~/.aws, ~/.gnupg, profile .env, skill secrets/), so anything outside those denies stays readable. Network is denied by default.

Status: stable, opt-in. Defaults to off because real-world dev workflows vary too much to pick a profile that never breaks: git push over SSH relies on ~/.ssh, Apple Silicon Homebrew lives in /opt/homebrew, docker needs /var/run/docker.sock, npm wants ~/.npm. For interactive chat where you approve every command, the Layer 1 denylist is already sufficient.

Where it really earns its keep: unattended profiles. The the alpi daemon (Telegram gateway + scheduler subsystems), research / delegate sub-agents — these run without a human approving each command. A prompt-injected email or a hallucinating sub-agent can issue rm -rf ~/anything with no veto. Layer 2 is the kernel-level veto you want there.

alpi's multi-profile CLI makes this ergonomic:

Each profile has its own ~/.alpi/profiles/<name>/config.yaml, so the sandbox flag is set independently.

Enabling

Interactive: alpi setup → Sandbox → toggle on/off + network.

YAML (direct): set in ~/.alpi/profiles/<name>/config.yaml:

tools:
  terminal:
    sandbox: true
    allow_network: false   # flip to true if the profile needs git push / npm install

TUI feedback

The top bar shows the current profile's sandbox state next to the workspace: sandbox on in green when active, sandbox off in muted grey when not. Quick visual confirmation you're in the posture you think you're in.

Platform support

macOS — uses native sandbox-exec (ships with the OS at /usr/bin/sandbox-exec). No install step.

Linux — uses bubblewrap. Install once:

Requires user namespaces enabled in the kernel (default on modern distros; some hardened configs disable them).

Windows — no native sandbox path. Two options:

  1. WSL2 (recommended): wsl --install, then run alpi inside Ubuntu as if it were Linux native. bubblewrap works there.
  2. Native Windows: leave tools.terminal.sandbox: false. Layer 1 stays active; you lose the kernel-level guarantee for shell commands.

What happens when the sandbox is on

Testing the Linux path from macOS

A minimal Docker image covers the Linux code path. See docs/sandbox-linux-test.md.

Threat model

alpi's realistic attacker:

Layer 1 covers the common-case attacks (known patterns, known sensitive paths, known SSRF targets). Layer 2 adds defense-in-depth so a creative prompt that bypasses the regex still can't touch the FS or the network.

Closed system prompt (by construction)

alpi's system prompt is assembled from three narrow, controlled sources — nothing else. There is no auto-load of workspace files like AGENTS.md, .alpi.md, CLAUDE.md, or similar "bring your own context" conventions. The build in engine.py::_build_system_prompt concatenates, in order:

  1. alpi/prompts/system_prompt.md — shipped in the package; authored by us, updated with each release.
  2. Memory (USER.md, MEMORY.md, AGENT.md) from ~/.alpi/profiles/<name>/memories/ — written by the LLM itself through the memory tool, with dedup + char limits + cross-file duplicate detection.
  3. The skills index from ~/.alpi/skills/**/SKILL.md — every mutation passes through skills_guard.py, which scans for dangerous patterns (rm -rf, curl|sh, eval(), hardcoded keys).

Workspace files — anything the user has on disk — are data, not context. The LLM reads them through the read_file tool, which labels the result as a tool response (the model is trained to treat tool output as untrusted). The usual prompt-injection warnings in system_prompt.md cover this path.

This is a deliberate departure from agents that honour convention-over-configuration context files. Those files are raw Markdown loaded before the turn starts — a documented attack vector (an attacker who can write a .agent.md to a repo you clone can steer your next turn). alpi trades the ergonomic convention for a smaller trusted-input surface. If a project needs its conventions taught to the agent, put them in a skill or in USER.md; both paths pass through explicit user approval.

Third-party code

Every runtime dependency is an attack surface. We keep the list tight (see ARCHITECTURE.md → Dependencies for why each one earns its place) and audit it before each release. The CVE pass is a single command:

uv run --with pip-audit pip-audit

Risk profile of the runtime set:

DepRiskNotes
litellmMediumLarge surface (100+ providers). Ships with telemetry=True by default — alpi flips it off in llm.py::_silence_litellm() so no request phones home. Regression test: tests/test_llm_privacy.py.
playwrightMedium-highRuns a full Chromium (~230 MB) that loads arbitrary web content. Chromium's own sandbox is the line of defence at that layer; alpi adds nothing on top. Used only by the browser tool.
playwright-stealthLowSmall patch set on navigator.webdriver and friends. Reverse-engineered detection bypass; breaks occasionally when detection vendors tighten.
pillowMediumImage parsers have a long history of CVEs. Keep on the latest minor; pip-audit catches known issues.
faster-whisperLowBundles CTranslate2 native code. Models are downloaded from HuggingFace on first use — inspect the model hash if paranoia calls for it.
edge-ttsLowReverse-engineered unofficial Microsoft Edge TTS endpoint. Small code, but the endpoint can change; have a plan B (say on macOS, espeak on Linux) ready.
textualLowPure Python, active, stable API surface we pin to.
litellm's transitive tree (openai SDK, anthropic SDK, etc.)Low-mediumFlows through. pip-audit covers.
httpx, rich, click, pyyaml, python-dotenv, prompt_toolkit, croniter, html2text, ddgsLowSmall or stable or both. Rarely updated, rarely break.

Policy

Security posture audit

alpi audit is the read-only posture scan for an installed machine. It is different from alpi doctor: doctor asks "is the active profile healthy and reachable right now?", while audit asks "is this whole install hardened enough to leave unattended?".

The command scans the entire ~/.alpi install, not just the selected profile:

alpi audit           # includes OSV CVE lookup when network is available
alpi audit --offline # local-only: permissions, binds, hardening

Checks today:

Exit code is 1 only when a fail is present. Warnings are visible but do not break cron or release scripts. The command never changes permissions, writes config, upgrades packages, or phones home unless the user explicitly runs the online CVE check by omitting --offline.

Inline image reads (host plane)

Agent-made images render inline in chat across clients. The image bytes are read by path, scoped to a fixed root set: the active profile's workspace, its home (~/.alpi/...), and temp dirs. Same roots on every client:

Implication: a client authorised for a profile can fetch any image under those roots by path — broader than "an image that appeared in this chat". This is intentional (it's what inline rendering needs and the device is already trusted for the profile), but it is a real read surface. A future tightening would restrict reads to paths that appear in the session transcript or an output manifest; not implemented today.

Audit trail & accountability

alpi records what the agent and its operators do across several local surfaces. The posture is personal-grade: rich per-session detail and useful operational logs, but no single tamper-evident audit log and no actor attribution on the local control plane. What exists today:

What is NOT covered today (and why it matters for a fleet, not a single user):

Closing these is an explicit roadmap item — see AUDIT.2 in ROADMAP.md. It is deliberately not built into the personal product until a real fleet deployment pulls for it.

Known gaps

theme