# Security Model

## 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](/docs/security-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.
