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.CORS only stops a page from reading a response, not from sending a request, so since v1.3.11 the daemon also refuses cross-site writes: a
POST, PUT, PATCH or DELETE whose Origin header names a host other than localhost, 127.0.0.1 or ::1 gets 403 [Denied] origin: ... before any handler runs (a request without Origin, such as curl, is unaffected). POST /v1/send and POST /v1/chat/completions also require Content-Type: application/json and answer anything else with 415, which closes the text/plain simple request that needs no preflight. enable_sandbox:false on /v1/send (agen -p --skip-sandbox) is accepted only from a loopback connection; any other caller gets 403.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. Since v1.3.10 read_files and find_files skip the prompt for paths on the readable list (built-in system and package locations plus readable_path) |
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 (since v1.3.10 read_files and find_files skip it for paths on the readable list) 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, remove_file, restore_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), built into go-pkg filesystem since v1.3.10 (previously configs/jsons/sensitive_path.json) and extended by any user entries in config.json. This guard is enforced in Go, not by prompt.Secret masking
Since v1.3.11 secrets in tool output are masked before they reach the model, logs, action history or a channel (
internal/utils/secret, patterns in configs/jsons/secret_patterns.json). Detection is format matching only — private key blocks, credentials in URLs, Bearer / Basic / token values, netrc passwords, and provider token formats such as AWS access keys, GitHub, GitLab, Slack, Stripe, npm and Hugging Face tokens, and LLM API keys — and no LLM decides what to mask.| Item | Detail |
|---|---|
| Format | First 3 characters + ******** + last 3; a value shorter than 9 characters keeps only its first 3, and one of 3 characters or fewer becomes ******** alone. A PEM private key block is masked as one value, not line by line |
| Tool results and errors | Masked in toolregister.Dispatch, shared by the agent loop and the external MCP server |
| Streamed progress | Masked chunk by chunk through secret.Stream (in runToolExec and run_command's progress writer); a private key block split across chunks is dropped whole |
| Background reports | Masked in FinishBackground / UpdateBackground before the report is stored |
| Writing a mask back | edit_file content / new_string and a run_command argv that contain a masked value this daemon produced are refused with [Invalid], so a mask is never written over the real secret. Only the masked strings are remembered, never the originals |
A command that needs a key reads and uses it inside the same
run_command, for example ['sh', '-c', 'set -a; . ./.env; set +a; curl ...'] or security find-generic-password, so the value never passes through a tool result.