Security Model
Last updated
Localhost binding
The HTTP server listens on the loopback addresses only,
127.0.0.1:17989 and [::1]:17989 — LAN clients cannot reach the daemon. The web dashboard is embedded into the binary and served from / by that same daemon, so the usual browser origin is your own machine. CORS middleware accepts loopback origins only (localhost, 127.0.0.1, ::1) and answers Chrome Private Network Access with Access-Control-Allow-Private-Network. The two hosted origins it used to accept, https://web.agenvoy.com and https://agenvoy-board.pardn.workers.dev, were removed in v1.0.17 — use the dashboard the daemon serves at /.Endpoints that touch credentials, config files, or process state carry an extra
localhostOnly() guard on top of the bind. It checks the connection's remote address (127.0.0.1 / ::1), not the Origin header.Since v1.2.0 the TUI does not run agents itself; it talks to the daemon over the Unix socket ~/.config/agenvoy/daemon.sock, created with mode
0600 so only the owning user can connect.No elevated mode
There is no
/sudo command and no session-wide escalation. The /dangerous command was removed; use the per-call confirmation below or the direct TUI actions. Per-request authorization replaced both: a call that reaches outside the safe boundary raises a confirmation for that one call, and the grant is scoped to that session plus that specific path.$HOME is always writable with no setup (under auto, a write inside $HOME but outside the working directory asks once; see Permissions). These sit outside it:| Case | What happens |
|---|---|
A sensitive path inside $HOME | boundary.Restricted collects it and raises a plain confirmation (no password), in every permission mode. Approval grants that path for that session |
A path outside $HOME | boundary.Restricted collects it and raises a confirmation that also requires the operating-system password, in every permission mode (before v1.3.7 auto rejected it without a prompt). Approval grants that path for that session |
A command that has to write outside $HOME | The agent attaches write_paths to that one run_command or background_command call; those paths are bound into the sandbox only after the same password-backed prompt is approved |
A command the sandbox blocks for reasons network and write_paths cannot fix | Since v1.3.0 the call may set sandbox: false to run outside the sandbox with the user's full permissions. It always raises the password-backed prompt — allow rules, auto and allow-all Skills do not skip it — and runs only once the password is verified; the confirmation is labelled outside sandbox: <cmd> |
| A command that needs root | Since v1.3.2 the call passes argv ['sudo', '<binary>', ...]. It raises a confirmation labelled sudo: <cmd> that always demands the system password, in every permission mode including allow-all, and allow rules never skip it; after approval it runs as sudo -n <binary> ... outside the sandbox on the sudo ticket |
Password checks live in one place,
internal/sudo: sudo -n -v probes whether a sudo ticket is still cached (3 s limit), and sudo -S -v verifies a typed password (30 s limit). The sudo ticket is the only clock — there is no second TTL. While it is still cached the prompt appears without a password field, and a root daemon skips the check.The TUI and the local web dashboard can collect the password; the web confirm endpoint accepts it only from a loopback connection. Since v1.2.0 the TUI no longer runs
sudo -v itself: the password is typed into the TUI's password field, sent to the daemon with the reply, and verified there with sudo -S -v; the confirmation goes only to the TUI window that started the run. Channels that cannot collect one never elevate: since v1.3.3 Telegram and Discord restricted prompts offer no Yes and point to the TUI or Web UI on this machine, and a remote HTTP approval gets 403 while the dashboard keeps the card and shows the error (before v1.3.3 such a channel could approve and the call came back skipped). Since v1.3.3 a wrong password keeps the prompt open for another try, and restricted prompts drop don't ask again and allow this turn.Since v1.3.2
run_command and background_command accept sudo only in that argv form: no sudo flags, no nested sudo / su, and the target must be a bare binary not on denied_command. sudo inside sh -c is still rejected, with an error pointing to the argv form. sudo rm deletes for real, while a plain rm still moves to the trash. pkg_manage was dropped (Removed in v1.3.2): system packages go through run_command, with ['sudo', <package manager>, ...] on Linux, or brew with sandbox: false on macOS (never sudo with brew).The
run_command sandbox constrains writes, not reads, so a command fails on a path only when the operating system itself refuses. The file tools (read_files, find_files, open_file, file_history) still raise the password-backed confirmation before reading outside $HOME and a plain confirmation before reading a sensitive path, and denied_path rejects reads as well as writes. Approved paths are held in daemon memory per session, so a grant lasts until the daemon restarts.Permanent denials
denied_path in config.json is a hard denylist: those paths are off limits for both reads and writes, cannot be approved by any prompt, and retrying the same path fails the same way. Entries must be absolute or start with ~/; the filesystem root is refused. denied_command is its command-side equivalent — a binary on it is rejected outright, inside sh -c as well.Sensitive file guard
Files matching the sensitive set — SSH keys,
.pem, .key, .env, and credential files — are restricted for both reads and writes: read_files, edit_file, open_file, find_files, and file_history raise a confirmation before touching them — a plain one inside $HOME, the password-backed one outside it. Since v1.3.7 the set also covers the .git and .claude directories, the shell startup files (.bashrc, .bash_profile, .profile, .zshrc, .zprofile, .zshenv, .zlogin) and .mcp.json, so reading .git/config or ~/.zshrc with read_files now asks, and a read-only allowlist command whose argv touches one of them stops matching. The set is sensitive_path (dirs / files / prefixes / extensions), embedded in the binary and merged with any user entries in config.json. This guard is enforced in Go, not by prompt.