# Security model This page describes how vincent is designed to behave. To **report** a vulnerability, use [private security advisories](https://github.com/lezli01/vincent/security/advisories/new) — see [SECURITY.md](../SECURITY.md). > [!WARNING] > **Agents run full-auto by default, which means an agent can execute arbitrary > commands as you.** That is a documented design decision, not a bug: unattended > orchestration is the point of the tool. Everything below explains exactly what > that buys and what it costs. - [The trust boundary is the OS user](#the-trust-boundary-is-the-os-user) - [Full-auto is the headline risk](#full-auto-is-the-headline-risk) - [What the worktree does and does not isolate](#what-the-worktree-does-and-does-not-isolate) - [Restricted mode](#restricted-mode) - [What an agent writes cannot drive your terminal](#what-an-agent-writes-cannot-drive-your-terminal) - [The API surface](#the-api-surface) - [Credentials](#credentials) - [What vincent writes outside its own directories](#what-vincent-writes-outside-its-own-directories) - [Tightening it](#tightening-it) - [What is not in scope](#what-is-not-in-scope) --- ## The trust boundary is the OS user Everything vincent does, it does **as you**. There are no accounts, no roles and no privilege separation inside vincent, because there is nothing meaningful to separate: an agent it launches has your privileges, your agent-CLI logins and your git identity. That is why the service registration is per-user on every platform and needs no elevation anywhere — a machine-wide service would run agents as a system account, with *more* privilege and none of your credentials, which is strictly worse in both directions. ## Full-auto is the headline risk In `full-auto`, permission prompts are bypassed at the CLI level. The adapter switches are: | Adapter | Full-auto switch | |---|---| | claude | `--dangerously-skip-permissions` | | codex | `--dangerously-bypass-approvals-and-sandbox` | | cursor | `--force` | **All three are equivalent in blast radius.** Cursor's reads mildest and is not. An agent in full-auto can read your home directory, use your credentials, and reach the network — it is not confined to the worktree. What limits the damage, and what does not: | Mitigation | Real | |---|---| | Nothing is pushed, merged or deployed unless a **workflow step does it** | ✅ — put a `manual` gate in front of any such step | | Everything is transcripted: every prompt, tool call and command | ✅ — and when it cannot be, the step **fails** rather than passing (see below) | | Any step can run `permission_mode: restricted`, and any task can be created `restricted` | ✅ — see below | | The git worktree | ⚠️ collision isolation, **not** security isolation | | Running under a service | ❌ changes nothing — it is still you | **"Everything is transcripted" is a promise about completeness, not about durability.** A transcript vincent could not finish writing does not quietly become a shorter transcript: a failed write, encode or close fails the attempt with `transcript_io_error`, and a stream an adapter could not read to the end fails it with `agent_protocol_error`. Output too long for one record is split across `partial` records rather than dropped, so length alone never costs you evidence; only `transcript_max_bytes` deliberately stops a runaway, and it says so in the file. What is *not* promised: vincent writes and closes the file and checks both, but does not fsync per line, so a host that loses power can still lose the tail of a transcript. See [spec §12.2](https://github.com/lezli01/vincent/blob/master/docs/spec.md#122-directories-platform-native). The TUI shows this warning **once**, on first run. The acknowledgment persists in `{data_dir}/tui.json` and is written when the notice is *dismissed*, never when it is shown, so quitting two seconds in does not bury it. Any failure reading or writing that file shows the notice again — a security warning that suppresses itself because a parse failed has failed in the wrong direction. ## What the worktree does and does not isolate Every task runs in its own `git worktree` on its own branch, unless you deliberately point it at a branch your own checkout already holds. **Does:** keep two tasks in the same repository from colliding; keep every task out of your own checkout, which vincent itself changes only by fast-forwarding a base branch that is behind its remote, and only when that checkout is clean ([`fetch_base_branch`](reference/configuration.md#fetch_base_branch)); leave your stash untouched; make every change reviewable as a real git diff before anything is pushed. **Does not:** confine the process. The worktree is a directory, not a sandbox. A full-auto agent's cwd is the worktree; its *reach* is your whole account. **The one exception, and you have to ask for it.** A task or chat created with [`--existing-branch`](reference/cli.md#running-a-task-on-a-branch-that-already-exists) on a branch your own checkout holds runs *in that checkout*: no worktree is made, and the agent's cwd is your working copy with whatever uncommitted work is in it. Nothing is fetched and no ref is moved there — that is why the mode is allowed at all — but the first guarantee above is the one it trades away. vincent never infers this from a branch happening to exist; it is selected per task. Command steps and checks are the same story: they execute user-authored workflow content at the same trust level as your own shell. No additional sandboxing is attempted or implied. **Workflow files are read at registration and on reload, not when you run one.** Adding a project puts every `*.yaml` in its `.vincent/workflows/` in front of the parser right away, and the daemon re-reads them whenever they change — before anybody picks a workflow. Cataloguing is therefore bounded (spec §5.2): only **regular files** are sourced, so a symlink, FIFO, socket or device named `*.yaml` is rejected without being opened or followed, and a source is capped at **1 MiB**. A rejected file is listed as an invalid entry naming the path and the reason; its valid siblings stay available. The same 1 MiB bound applies to a source posted to `POST /v1/workflows/validate`, so the API is not a way around it. What the parsed content is then allowed to *do* is unchanged — a `command` step is still a shell command. ## What the container does and does not isolate Set [`container.image`](reference/configuration.md#container) and a task's step processes run inside one container, created with the task's worktree and removed with it. It is the first thing vincent offers that confines a task's work beyond the worktree, and it is worth being exact about how far it goes. **What it covers.** The container holds every agent step, every `command` step and every `check:` of the task — the agent CLI itself runs inside it, not on your machine. [Chats](reference/cli.md#vincent-chat) are not tasks and still run on the host, with your whole home directory in reach — all but one kind: a chat [opened on a containerized task](reference/task-lifecycle.md#chatting-with-a-stopped-task) runs its turns inside that task's container, so a conversation cannot hand an unconfined agent a worktree you chose to confine. **Does:** confine the filesystem to two bind mounts — the project repository and the task's worktree, each at its own absolute path — plus your agent configuration directories (below) and whatever you list in `extra_mounts`. The rest of your home directory, your SSH keys, your other repositories and your system are not there. The shell and the tooling are the image's, not your machine's. **Does not:** - **Close the network.** Outbound traffic is on by default. `container.network: false` closes it. With the network closed the agent cannot reach vincent's per-step MCP endpoint either, so vincent refuses that setting together with `mcp.wire_steps: true` for any workflow that has an agent step. - **Withhold your agent credentials.** `mount_agent_config` is **on** by default, so `~/.claude`, `~/.codex` and `~/.cursor` are bind-mounted **read-write** under the container's `HOME`: the agent needs them, because subscription auth takes no key from the environment and cursor writes its model choice back to its own config. Anything running inside the container — the agent, every command step, anything the image runs — can read those credentials and write to those directories. Turn the knob off and they are not there; an agent CLI that then cannot authenticate is the expected result. - **Carry your macOS claude login.** On macOS, Claude Code keeps its OAuth login in the Keychain, which a container cannot reach. A containerized claude step on a Mac needs `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY` through [`environment`](reference/configuration.md#environment), and then that token is in the container's environment, readable by every step there. Codex's `auth.json` and claude's `.credentials.json` on Linux are files in the mounted directories. - **Keep the daemon unreachable.** A containerized agent reaches vincent's per-step MCP endpoint at `host.docker.internal`. On Linux with Docker Engine, vincent's API stays on loopback, but the daemon opens a second listener on the container network's gateway address while a containerized step needs it. That listener answers only `/mcp/step/{run_id}` — `/v1` and `/mcp` are `404` there — and each request needs the secret minted for that one step run. **Anything else on that bridge network**, including other containers, can reach the port; the per-run secret is what stops them using it. Docker Desktop forwards `host.docker.internal` to the daemon's loopback port instead, and no second listener is opened. - **Raise a privilege boundary.** On a Linux host every step execs as your own uid and gid, so files land owned correctly — and so an escape lands as the same user the daemon already runs as. Containerization and `permission_mode` are separate axes and compose: restricted in a container is still restricted, full-auto in a container is still full-auto with the container's reach. There is no `contained` permission mode. A Windows daemon refuses a containerized task outright rather than translating paths, and the runtime is only ever `docker`-compatible; see the configuration reference for both. ## Restricted mode `permission_mode: restricted` maps each adapter onto its own confinement: | Adapter | Restricted means | Platforms | |---|---|---| | claude | Allowlist flags with an edit/read/git/test tool set | all | | codex | `--sandbox workspace-write` — writes confined to the worktree | all | | cursor | `--sandbox enabled` | **macOS and Linux only** | A task created with [`restricted`](reference/api.md#paused-restricted-and-capped-tasks) runs every one of its agent steps this way, including a step its workflow writes `full-auto`. It only ever tightens, and the refusal below applies to it unchanged. These properties matter more than the mechanism: **An adapter that cannot restrict on your platform never runs the step.** It never downgrades. Vincent refuses to create a task whose restricted step resolves to cursor on Windows, answering `400` and naming the step and the agent — a fact about the adapter and the OS, so it holds even where the CLI is not installed. A task that reaches the engine anyway blocks with `restricted_unsupported`, under the normal retry policy. Falling back to full-auto was rejected outright: it would run wide-open a step that explicitly asked not to be, converting a safety choice into its opposite on exactly one OS — the failure mode nobody would think to check for. The same holds for a [chat opened on a task](reference/task-lifecycle.md#chatting-with-a-stopped-task) that runs `restricted`, which takes the task's permission mode: `codex exec resume` has no `--sandbox`, so codex could not keep the chat's second turn restricted. Opening a codex chat on such a task is refused with `400`, and codex refuses a resumed restricted run itself. **Denied actions can become questions.** On an input-capable adapter (claude), a denied action surfaces as a `permission` request and the task waits for you — subject to `on_input`. Set `on_input: deny` and vincent denies them automatically, keeping the run strictly unattended. **It bounds the filesystem and the shell, not what the step does to vincent.** A step wired to vincent's own [MCP server](guides/mcp.md) — the default — keeps those tools under `restricted`: claude's allow-list carries `mcp__vincent__*` in full, so a restricted step can create, cancel and archive vincent tasks. The alternative was worse in both directions: the step would have been offered the whole vincent tool list and denied every call, which is a tool list that lies. If you want a step that cannot reach vincent either, turn the wiring off with [`mcp.wire_steps: false`](reference/configuration.md#mcp) — per daemon, not per step. **A skill you invoke can widen one turn.** Claude's Agent Skills may declare `allowed-tools` in their frontmatter, and that grant applies for the turn that invokes the skill even under `restricted`: a skill asking for `Bash` gets `Bash` there, outside the allowlist in the table above. Getting there takes an act of yours — you type the skill's name into your message, which the CLI expands before the agent is asked anything, or you approve the permission request the agent raises when *it* wants such a skill. In a chat that request always reaches you; in a step left to run unattended, `on_input: deny` refuses it for you. What the skill *injects* — a shell command written inside its own text — is still checked against the allowlist, and a refusal ends the invocation before the agent runs. codex and cursor have no such field. Vincent documents this rather than switching skills off in restricted chats, which are exactly the chats a reviewer opens on a blocked task; see [Agent CLIs](guides/agents.md). **A chat's file listing does not second-guess git.** [`GET /v1/chats/{id}/files`](reference/api.md#files), the listing a composer offers behind `@`, serves only workspace-relative paths from inside the chat's own directory, and it honours `.gitignore` — so an ignored `.env` never appears. A **committed** secret does: it is in the repository, the agent can already read it, and a picker that filtered it would be inventing a rule the repository's owner did not write. Pointing an agent at a path outside the workspace is still possible by typing one, which is your own text passing through verbatim, exactly as the rest of a message does. Use `restricted` for steps that have no business running commands: a docs pass, a review, a summarization step. See [Writing workflows](guides/workflows.md#93-permission-modes). ## What an agent writes cannot drive your terminal Every record the [output pane](guides/tui.md#how-the-pane-reads) draws — assistant prose, a tool result, a command's output, an error — is stripped of ANSI escape sequences and of C0/C1 control characters before it is measured or drawn, newline and tab excepted. An agent that emits `ESC[2J` or an OSC title sequence, or that echoes a file containing one, cannot clear your screen, move the cursor, rewrite the window title or overwrite adjacent UI. Only vincent's own styles are emitted. The same holds for the Markdown rendering of assistant prose: it is a fixed subset drawn with vincent's own glyphs, and raw HTML is rendered as the characters the agent sent — never parsed, fetched or executed. The [rendered/raw toggle](guides/tui.md#seeing-the-source-and-taking-it-away) is bound by it too: raw mode shows the agent's bytes as Markdown *source*, not as terminal input. It holds on the way out as well. What the copy actions put on the clipboard is stripped on the same terms as what is drawn, so "copy the original Markdown" means the stored Markdown minus escape sequences and C0/C1 controls. A clipboard is pasted into a terminal, which is precisely the boundary this rule exists to hold. Links and images in that prose are text and nothing more. A link's label is prose and its destination is printed literally, whatever its scheme, in a numbered reference list at the end of the message; an image is its alt text plus its printed source. **By default vincent emits no OSC 8 hyperlink, and the pane never opens anything**, so a `javascript:` or `file:` destination an agent wrote is something you can read and copy but not something the terminal can be made to act on. The one way vincent itself opens a destination is when you pick it in the TUI's link picker (`ctrl+l`), which prints the whole destination first and opens only `http` and `https` — every other scheme is listed as copy only — handing the URL to the platform's opener as a single argument, never through a shell. If you turn on [`tui.hyperlinks`](reference/configuration.md#tuihyperlinks), a link becomes clickable only when its destination passes a strict check: an `http` or `https` URL with a host and no `user@` part, at most 2048 bytes, made of printable ASCII only. That refuses control characters and escape sequences, spaces, and look-alike Unicode hosts. The link carries the destination exactly as printed, and its id is generated by vincent rather than taken from the agent. Every other destination renders as plain text. The numbered reference list stays on screen either way, so a label that reads like one URL cannot hide where a click goes. No remote image is ever fetched — there is no fetch in that path to disable. Syntax highlighting in fenced code adds colour and never characters: strip the styling and the block is byte-for-byte what the agent sent. ## The API surface - **Loopback only.** `listen:` is validated to a loopback host; anything else is rejected at config load. The one thing ever served elsewhere is the per-step MCP endpoint for a [containerized agent step](#what-the-container-does-and-does-not-isolate), on the container network's gateway address, and only that path. - **Bearer token on every request**, read from `{data_dir}/token`, created `0600`. The token and `vincent.db` sit in `{data_dir}`, which vincent creates owner-only (`0700`) on POSIX. On Windows both rely on the per-user ACL that `%LOCALAPPDATA%` inherits. Compared in constant time. - **CORS is disabled**, which together with the token blocks drive-by requests from a browser tab. - **No TLS**, deliberately: the socket is loopback and the token is the authenticator. There is nothing on the wire that does not already require local account access to reach. - `GET /v1/health` is the single endpoint that requires no credential at all. - **`POST /mcp` is the same surface, not a second one.** The [MCP server](guides/mcp.md) rides the same loopback listener, the same bearer token and the same `recover → log → auth` chain, and a tool call is dispatched by replaying it against the same handler `/v1` uses — so it grants exactly what the token already granted. Some routes are deliberately not tools: an agent must not be able to stop, back up, garbage-collect or reconfigure the daemon supervising it, rewrite its workflows or triggers, inject a trigger event, open a pull request, forge a quota reading, permanently delete a project, task or chat, import a task from a backup, or drive a chat — see [the full list](guides/mcp.md). That is a design line, **not** a privilege boundary — the token still reaches those routes on `/v1`. - **`POST /mcp/step/{run_id}` is not a security boundary**, and is stated here in those words. The daemon wires each agent step to a per-step endpoint that authenticates a secret minted for that one step run instead of the daemon token. It exists so vincent knows *which* step is calling — enough to refuse a wait that would deadlock, and to record which task created which — not to confine the step. A full-auto agent can read `{data_dir}/token` off disk and reach `/mcp` directly, which is the same conclusion as everywhere else on this page: the boundary is the OS user. For a containerized agent step on Linux with Docker Engine, this endpoint is also served on a listener on the container network's gateway, which anything on that bridge network can reach. There the per-run secret is the only thing standing between another container and the step's tools — one path, one step run, for as long as the step lasts. - **`POST /v1/daemon/backup` writes a file at a path the caller names**, as the daemon's user, anywhere that user can write. That is stated here rather than left to be discovered — but it is not an *additional* grant: the same token already starts agents that run full-auto as that user (above), which is a strictly larger capability. The endpoint refuses a relative path, refuses to overwrite an existing file, and refuses a destination inside `{data_dir}/transcripts`. - **`POST /v1/tasks/import` reads an archive at a path the caller names**, as the daemon's user, and writes the task rows it holds. The same reasoning applies — the token already grants more — and the import checks every archive entry with the restore's own rules, stages it under the data directory, and never touches the live database file except through the daemon's store. Like backup, it is not an MCP tool. Anyone who can read your token file can drive your daemon — which, on a machine where they are already you, is not an additional grant. On a shared machine, it is the boundary that matters. ## Credentials **Vincent has no credential store of its own.** No vendor API keys, no OAuth flow, no keychain entries. An agent step spawns the CLI you installed, which authenticates however it already does. In a [container](#what-the-container-does-and-does-not-isolate) that CLI is the image's, and it authenticates with your `~/.claude`, `~/.codex` and `~/.cursor`, mounted in by default — except claude's macOS Keychain login, which has to be passed in as a token variable instead. That is a statement about *vincent's* credentials, not about yours. Two files vincent owns can hold sensitive data because you put it there: - **`{config_dir}/config.yaml`**, through [`environment.set`](reference/configuration.md#environment), whose values are literal — an API token, a proxy credential or a license key written there is plaintext on disk. The file is created `0600` inside a `0700` directory, and every daemon start re-tightens both on POSIX (contents untouched) and says in the log what it changed. `vincent doctor` reports a broad mode with the exact `chmod`. On Windows the per-user ACL of `%APPDATA%` applies instead. - **Transcripts**, which record what your agent and commands actually printed. They are `0600` for the same reason. `environment.set` is **not a secret store** — nothing is encrypted, and the file is one you may sync between machines. Prefer naming a variable under `environment.inherit` and letting the value come from the environment that starts the daemon. The token file gates only vincent's own API. It is not an agent credential and grants no model access. One consequence worth stating plainly: **vincent cannot reduce what your agent CLI is allowed to do at the vendor.** It can only choose which permission switch to pass. ## What vincent writes outside its own directories Four things, and they are recorded here rather than discovered: **A cursor step passes `--model`, and cursor persists that selection to `~/.cursor/cli-config.json`.** Vincent always passes one (defaulting to `auto`) because leaving it unset would mean "whatever the last invocation chose", possibly a previous vincent step — and determinism is worth more to an orchestrator than preserving an interactive preference. It is not a secret and not an escalation, but it does mean a cursor step overwrites the model you last picked in an interactive `cursor-agent` session. **The daemon view's `i` writes `statusLine.command` in `~/.claude/settings.json`**, which is the only way Claude Code's usage windows reach vincent at all — it has no endpoint to poll, and reading the OAuth token out of `~/.claude/.credentials.json` is refused as state-file parsing. Every property that makes the cursor case above tolerable is held here deliberately: - **User-initiated.** It happens when you press that key, never on daemon start, never silently, and never as a side effect of running a task. Starting a daemon leaves the file untouched, and a test asserts that rather than assuming it. - **Shown first.** The exact JSON is displayed before anything is written. - **Reversible.** The `statusLine` object vincent displaces is carried, base64 encoded, in the command it installs, so pressing the same key puts back what was there — including removing the key when there was nothing. - **No token on disk.** What is installed is the vincent binary ([`vincent statusline`](reference/cli.md#vincent-statusline)) rather than a shell script, precisely so the daemon's bearer token is discovered the way every other subcommand discovers it instead of being written into a file another tool runs. The route it posts to, `POST /v1/agents/{name}/quota`, is [not an MCP tool](guides/mcp.md): an agent must not be able to forge a daemon-level fact about the host it runs on. **[`vincent skills install`](reference/cli.md#vincent-skills) — and the daemon view's `S` — runs `npx skills add`, which writes into your global skills store (`~/.agents/skills/`) and links the result into each agent's own directory.** Vincent does not write those files itself; it shells out to the published CLI, which is why the whole install layout stays that tool's business rather than something vincent reimplements. It holds the same properties as `i` above: it happens only when you run the command or press that key, never on daemon start and never as a side effect of a task; the **exact `npx` command is printed before anything runs**; and it needs `npx` on your `PATH`, whose absence is a message rather than a silent no-op. Detecting whether a skill is installed writes nothing at all and runs no subprocess — it reads `SKILL.md` out of those directories, which are a public CLI's documented install locations holding a file this repository published. That is deliberately not the same act as reading another tool's private state for credentials — the `~/.claude/.credentials.json` read refused above. **[`vincent update`](reference/cli.md#vincent-update) replaces the vincent binary**, which is by definition outside vincent's own directories. It happens only when you run that command, only when vincent — not a package manager — owns the binary, and only after the download is verified: the cosign signature over `checksums.txt` against the project's pinned identity and issuer, then the archive's SHA-256 against that verified file. On any mismatch nothing is replaced and the old binary is left byte-identical. Without `cosign` on your `PATH` the checksum check runs alone and the command says so; pass `--require-signature` to refuse in that case. On Windows a running executable cannot be overwritten, so the old one is renamed to `.vincent-old` beside it and deleted on the daemon's next start. The **check** for a newer release is separate and writes nothing: it is one unauthenticated GET sending no token, no telemetry and no machine or install identifier, and [`update.check: false`](reference/configuration.md#update) switches it off entirely. The daemon never downloads or applies anything. Everything else vincent writes lives in its [config and data directories](reference/files.md), plus the git branches and worktrees it creates in your repositories — and the one file you name yourself when you run [`vincent daemon backup`](reference/files.md#backup-and-restore). If you turn on [scheduled backups](reference/configuration.md#backup) and point `backup.dir` outside the data directory, the daemon writes its archives there too, and deletes the older ones it wrote. It deletes only files named `vincent-backup-.tar.gz`, never anything else in that directory. Writes to GitHub leave the machine, and only when you ask for them: [opening a pull request](features.md#open-a-pull-request) pushes a task's branch to `origin` and creates the pull request, and a task's linked pull request can be merged, closed, reopened, commented on or have its failed checks re-run. All of them use the same `gh` login or `GITHUB_TOKEN`/`GH_TOKEN` the read side uses. The push never forces and sends committed work only, a merge is pinned to the head commit you named and never deletes the branch, none of them is an MCP tool so an agent cannot reach them, and `github.enabled: false` turns them off with every read. Closing or reopening an issue imported from GitHub writes its state back too. That one is an MCP tool, because closing a local issue leaves the machine for nothing, so the daemon refuses it by caller instead: an MCP call, and a request from a `vincent` command a workflow step or a chat agent runs, is refused `forge_write_needs_human`. The step and chat case is told apart by a header the CLI sends when `VINCENT_TASK_ID` or `VINCENT_CHAT_ID` is in its environment. It keeps an agent's honest `vincent issue close` off GitHub; it is **not** a boundary — an agent running as you can unset those variables, or read the token and call the API like anything else running as you. **One of those worktree files carries a token.** To give a cursor step [vincent's own tools](guides/mcp.md) — cursor has no per-run MCP flag — the adapter writes `.cursor/mcp.json` into the **task worktree** for the duration of the run, holding the step's bearer token. It is created `0600` inside a `0700` `.cursor/`, removed when the step ends, and swept on the next daemon start if a crash left it behind. Your global `~/.cursor/mcp.json` is never read or written. While the step runs, the file is an untracked file in a git worktree: it appears in `git status` and in the task diff, so do not commit it. That token authorizes what the daemon token already authorizes, from a machine where the agent could have read the daemon token anyway. **A backup archive is sensitive.** It carries every transcript, which means every rendered prompt and everything the agents did, plus your `config.yaml`. It does *not* carry the API token. Treat the file the way you would treat the data directory it came from. The same applies to **scheduled backups**, which keep writing new archives without anyone running a command. Each one holds `config.yaml`, including any literal [`environment.set`](reference/configuration.md#environment) values and the [`notify.command`](reference/configuration.md#notify) argv (a webhook URL with a secret in it, for example), and every transcript. The default `backup.dir` is `{data_dir}/backups`, created `0700`, with archives `0600`, so by default they are as private as the data directory. Pointing `backup.dir` at a synced or shared folder decides who else can read all of that, which is why the TUI's config editor asks before changing it, as it does for `notify.command` and `environment`. Pick a location only you can read: a directory that already exists keeps its own permissions, and on Windows its ACL. `backup.dir` is not a secret, so the MCP `config_get` tool shows it unmasked. No MCP tool changes it, because `PATCH /v1/config` is not an MCP tool, but that is not a boundary: a full-auto agent can edit `config.yaml` itself, or read the token off disk and call the API, like anything else running as you. ## The notify hook runs your code `notify.command` in `config.yaml` is a program the **daemon** runs, as you, whenever a task enters a state you listed. It is the same trust level as everything else on this page — an agent step already runs arbitrary commands as you — but it is worth stating rather than leaving implicit: - Nothing from a task, an agent or the API reaches its argv. It is exactly what the owner of `config.yaml` wrote, and it is **argv, never a shell string** on every platform, so a task title cannot be interpreted as a command. - **Its argv can hold a secret.** A webhook URL with a token in it is the obvious case. That is a second reason `config.yaml` and its directory are owner-only. It **is** served on `GET /v1/config`, values included, and so is `environment.set`: that endpoint is loopback-only behind a bearer token read out of an owner-only file, which is the same boundary the file itself has, so a client that can call it can already read the file. What that boundary does not cover is an [MCP](guides/mcp.md) tool result — it lands in an agent's context and in the step's transcript — so the MCP rendering of that route masks `notify.command`'s argv and `environment.set`'s values, keeping the variable names. The daemon's **log** and step transcripts still carry variable names and never values; that rule is unchanged. - **`PATCH /v1/config` can change what the daemon executes.** `notify.command`, `agents.*.path` and `environment` are all writable through the API, and so is `listen`. This is not a new privilege — anyone who can call the endpoint holds the daemon's token and can already run an agent step as you — but it is why the route is **not** an MCP tool, and why the TUI puts those four keys, and [`triggers.enabled`](#event-triggers-let-someone-else-start-an-agent), behind an explicit confirmation. - Whatever your notifier does with the envelope is outside vincent. The envelope carries the task title and the agent's question summary, so a hook that posts to a shared channel publishes both. It is off unless you configure it. See [`notify`](reference/configuration.md#notify). ## Event triggers let someone else start an agent Everything above is defensible because **you pressed the key**, or wrote the workflow that pressed it. An [event trigger](guides/triggers.md) breaks that chain: a third party labelling an issue, opening a pull request or turning a CI build red causes agents to run as you, with nobody at the keyboard. That is why a trigger's defaults differ from every other default in vincent, and the list below is the whole posture, not a set of tips. - **Off twice by default.** A file under `{config_dir}/triggers/` does nothing until its own `enabled: true` **and** [`triggers.enabled`](reference/configuration.md#triggers) in `config.yaml` are both on. Both are dangerous values: the TUI asks before turning either on, and neither can be changed over MCP. Turning either on arms the trigger with a seed that fires nothing, so an old backlog never starts work. - **Proposed, not started.** `on_fire: propose` is the default. A task a trigger creates, retries or follows up lands `paused`, and you admit each one with `resume`. `on_fire: create`, which runs unattended, is always an explicit line in the file. A `cancel` has no held form, so it is refused at load unless it writes that line. - **Agent steps are clamped `restricted`.** A triggered task is created with [restricted mode](#restricted-mode) unless the file says `permission: workflow`, which is itself a dangerous value. The clamp bounds what the agent CLI's own permission model bounds, and nothing more. `limits.max_task_cost_usd` caps the created task's spend where the adapter reports cost, and `config.yaml`'s [`max_tree_cost_usd`](reference/configuration.md#max_tree_cost_usd) caps the task and every lane it fans out into, together. `limits.max_per_hour` caps how many deliveries fire. An event over the limit is recorded and dropped, never queued. - **`overrun: cancel_previous` lets an inbound event destroy work in flight.** Every unfinished task in the event's [concurrency group](guides/triggers.md#overrun-what-happens-while-the-last-run-is-still-going) is cancelled before the new one is created. Nothing on disk is lost — each cancelled task keeps its branch and worktree — but an agent mid-run is killed by something no human pressed, and nothing un-cancels it. It is a dangerous value, so the TUI asks before writing it, and on a GitHub source `allowed_actors` is what stands between a stranger's event and a cancelled run. The other four modes create nothing a trigger without `overrun:` would not have created, and destroy nothing. - **Untrusted events need an allowlist, and the allowlist is the author.** On the GitHub sources, some events are ones an outsider can cause on a public repository: `opened`, `reopened`, `closed`, `ready_for_review` and `review_requested`. A trigger that can match any of them, including one with no `match.action`, is **refused at load** unless it names `allowed_actors`. The trusted events are issue `labeled`, `unlabeled` and `assigned`, which need triage rights, and pull-request `merged`, which needs write. vincent sees a state diff, not who made it, so `allowed_actors` matches the issue's or pull request's **author**. It stops a stranger's issue from starting work. It does **not** say who applied a label or requested a review. The `issues` source holds a change imported from GitHub (`by: sync`) to the same rule as `github_issues`, against the imported issue's author; a change made in vincent, by you or an agent, is trusted. - **Prompt injection becomes remote.** An issue body, a pull-request title or a CI log reaches the trigger's templates as `.Event`. Whatever the file renders into a title, description, field or prompt reaches an agent. `.Event` is never stored on the task, so an attacker's text reaches only the fields the trigger's author chose to render, but it does reach those. For unattended work, point the trigger at a workflow whose steps [run in a container](#what-the-container-does-and-does-not-isolate). - **No project scope.** Triggers live in `{config_dir}`, never in a repository's `.vincent/`, so merge rights on a repository cannot start agents on your machine. The cost is that a trigger is not reviewed alongside the repository it serves. - **A trigger file is code the daemon runs as you.** A `command` source's argv runs on an interval with `notify.command`'s posture: argv, never a shell string, and a whole-tree kill at the timeout. The file may carry a token, so every trigger file is written `0600`. The live poll dry run runs that command too, and it is an MCP tool because it runs only what you already configured. - **Pushed events need the daemon token.** `POST /v1/triggers/{id}/events` requires the bearer token **and** the trigger's HMAC signature, with the secret read from the daemon's environment rather than the file. Only a caller on this machine that can read `{data_dir}/token` can deliver. A GitHub.com webhook through a bare tunnel cannot reach it; a relay on this machine that adds the header can, and such a relay is already something running as you. The route is not an MCP tool: an agent that can inject events can start agents. - **An agent may write a trigger, but only you switch one on.** The `create-trigger` and `update-triggers` built-ins let an agent, in a task you started, write trigger files. They install only through [`vincent trigger apply`](reference/cli.md#vincent-trigger-apply), which refuses any change that arms a trigger compared with the file on disk: `enabled` to `true`, `on_fire` to `create` or `permission` to `workflow`. A new file counts as having none of them. A value already armed may stay, and there is no override flag. `triggers.enabled` is in `config.yaml`, which apply cannot touch. Apply also refuses a file for another project and a file that changed since the proposal read it. A rewrite of a trigger that is already armed is live once written, which is why `update-triggers` waits for your approval before applying. A proposal waits in `{data_dir}`, never in a repository, because a trigger's argv can carry a token. See [Event triggers](guides/triggers.md) for the files themselves. ## Tightening it In rough order of value: 1. **Gate anything that leaves the machine.** A `manual` step in front of every push, publish, deploy or delete. This is the single highest-leverage control. 2. **Use `restricted` where the step does not need commands.** Docs, reviews, summaries. 3. **Point vincent at repositories you would hand to a new contributor.** That is the right calibration for full-auto. 4. **Read the diff before approving.** The Diff tab exists for this; the gate exists so you have a moment to use it. 5. **Cap concurrency.** `max_parallel_tasks` bounds how much can be happening without you, and `max_parallel_chats` bounds the chats running beside it — they are independent, so the ceiling on live agent processes is their sum. 6. **Turn on `debug: true` when a run surprises you.** It records the resolved settings and full argv of every step in its transcript. 7. **Prefer per-project scoped workflows** (`.vincent/workflows/`) so a repository's automation is reviewed with the repository. ## What is not in scope Stated so you do not assume otherwise: - **Sandboxing the agent beyond what its own CLI offers.** Vincent passes the switch; it does not build a jail. - **Multi-user or multi-tenant operation.** One OS user, one daemon. - **Protecting you from your own workflow files.** A `command` step is a shell command. - **Secret redaction in transcripts or step records.** Vincent records what it ran and what came back, verbatim. A transcript records everything the agent did, and each attempt's row records the **rendered prompt or command** it was handed — the substituted text, not the workflow's template — which the task workspace's Step Details tab shows. So a secret interpolated into a prompt is on disk in the database as well as in the transcript, and `vincent daemon backup` and scheduled backups copy both. Read one before pasting it into an issue. - **Remote access.** There is no remote binding, and adding one would need a different auth story than a loopback token. --- ## See also - [SECURITY.md](../SECURITY.md) — reporting a vulnerability. - [Agent CLIs](guides/agents.md) — per-adapter behavior. - [Writing workflows](guides/workflows.md#93-permission-modes). - Spec [§16](https://github.com/lezli01/vincent/blob/master/docs/spec.md) — the normative version.