# Minions Security Model

This document records the threat model for Minions today. It is intentionally
narrow: it describes what the engine and dashboard are designed to protect
against, what they are **not** designed to protect against, and the residual
risks an operator should know about. It is the source of truth for "is X a
vulnerability or a documented assumption?" questions.

If you are implementing a change that touches authentication, the dashboard
HTTP surface, secret handling, or the agent prompt boundary, read this first
and update it in the same PR if the model changes.

## 1. Deployment model

Minions is designed as a **single-user, localhost-only, single-tenant**
developer tool:

- One human operator runs `minions start` on a workstation (or a remote DevBox
  they treat as their own workstation). Agents dispatched by the engine run as
  the same OS user as the engine and dashboard.
- The dashboard binds `127.0.0.1` only (see
  [`dashboard.js`](../dashboard.js) — `server.listen(PORT, '127.0.0.1', ...)`)
  and is **not** intended to be reachable from any other host.
- Configuration, runtime state, secrets, project worktrees, and agent output
  all live on the same machine under `MINIONS_DIR` and the operator's git
  worktrees.

**Multi-tenant deployment is explicitly out of scope.** Minions is not a
hosted service. The engine, dashboard, agents, MCP helpers, and any tools the
operator invokes from the same shell session form one trust domain. Anything
that could allow a second human to share that trust domain — exposing the
dashboard port, mirroring `MINIONS_DIR` to another user, running the engine as
a service account read by multiple operators — is unsupported and not
defended against here.

## 2. Dashboard threat model

The dashboard (`dashboard.js`, port 7331) is the only HTTP surface in the
system. Its threat model:

### In scope (intentional, not vulnerabilities)

- **Loopback bind.** The dashboard binds `127.0.0.1` only; no LAN, container,
  or VPN client can reach it. Operators who tunnel the port elsewhere (SSH
  port forward, `ngrok`, etc.) opt out of this assumption and inherit
  responsibility for whatever auth gate they place in front.
- **Same-user process access.** Any process running as the same OS user as
  the engine (other agent runtimes, MCP helpers, `curl` from a terminal,
  `minions` CLI subcommands) can call `/api/*`. This is intentional — it is
  how `minions dispatch`, the Copilot/Claude runtimes, and operator scripts
  drive the engine. We do not attempt to authenticate same-user callers.
- **No authentication gate.** There is no login, no session cookie, no
  per-user ACL. The single-user assumption above is the entire authn story.
  Adding authn would not increase security in the single-user model; it would
  only break local CLI/MCP tooling.

### Residual risks defended today

- **Cross-origin browser requests / CSRF / DNS rebinding.** A browser tab the
  operator visits could in principle issue requests to `http://127.0.0.1:7331`.
  The dashboard defends against this with:
  - An **Origin gate** on mutating methods (`POST`/`PUT`/`PATCH`/`DELETE`)
    and CORS preflights — see `dashboard.js` ~4565–4609 (and additional
    `isAllowedOrigin` enforcement points in the SSE/CC handlers) and
    `shared.isAllowedOrigin` / `shared.buildSecurityHeaders` in
    [`engine/shared.js`](../engine/shared.js). Requests whose `Origin` (or
    `Referer`, if `Origin` is absent) is not in the local allowlist are
    rejected with HTTP 403. Callers without an `Origin` header at all
    (Node `http.request`, `curl` without `-H Origin`, CLI tooling) are
    allowed through to preserve local automation.
  - Baseline **security headers** (CSP, `X-Content-Type-Options`,
    `Referrer-Policy`, clickjacking protections) applied to every response
    via `shared.buildSecurityHeaders()`.
  - **Narrowed `Access-Control-Allow-Origin` on GET/HEAD reads**
    (P-bfa2c-cors-wildcard). Before this change the dashboard echoed
    `Access-Control-Allow-Origin: *` on every read response, which let
    any cross-origin browser page (e.g. `https://attacker.com`) issue
    `fetch('http://localhost:7331/api/*')` and **read** the JSON body —
    exposing operator-private state (config, PR data, work items, agent
    transcripts). The GET/HEAD prelude now echoes ACAO **only** when the
    request's `Origin` header matches the dashboard's own served origin
    (`http://localhost:7331`) or an entry in
    `config.engine.allowedDashboardOrigins` (default `[]`). Requests
    without an `Origin` header (curl, uptime monitors, Node
    `http.request`) receive **no** ACAO header — that path was already
    cross-origin-unreadable and is preserved verbatim. To opt a
    reverse-proxy origin in, set in `config.json`:

    ```json
    { "engine": { "allowedDashboardOrigins": ["https://minions.example.com"] } }
    ```

    Entries are matched verbatim against the request `Origin` header
    (scheme + host + port; no path, no wildcards). See
    `shared.isAllowedDashboardOrigin()` in
    [`engine/shared.js`](../engine/shared.js).

### Residual risks tracked elsewhere

- **CSRF hardening sweep.** A broader hardening pass — deny-by-default CORS,
  `Sec-Fetch-Site: same-origin` enforcement on mutating endpoints, and an
  optional bearer-token gate as a secondary defense — is **deferred to a
  separate plan** (`D-f8-csrf` in
  `prd/security-fix-plan-from-weekly-review-2026-05-18.json`, open question
  `Q-csrf-followup`). This document does not gate that work; if and when the
  CSRF follow-up plan ships, update §2 to reflect the new posture.

### Recommended hardening (if F8 ever moves from docs to code)

If we revisit this assumption — e.g. the dashboard ever serves more than one
operator, or we want defense-in-depth beyond Origin checks — the recommended
shape is:

1. Reject mutating requests whose `Origin` / `Sec-Fetch-Site` is not
   `same-origin`, instead of the current allowlist + missing-header pass-through.
2. Switch CORS to deny-by-default and explicitly opt specific endpoints in.
3. Add an optional bearer token (operator-supplied via env) as a secondary
   gate; require it on mutating endpoints when set.
4. Document the resulting break in CLI/MCP tooling and provide a token
   injection path for it.

## 3. Data flow trust boundaries

Minions reads from several sources with very different trust levels. The
engine and dashboard treat them differently on purpose:

| Source | Trust | Examples | Handling |
|---|---|---|---|
| **Operator config** | Trusted | `config.json`, `projects/`, `notes.md`, `notes/inbox/*` authored by the human, `pinned.md` | Read as-is. The operator is assumed to control these files. |
| **Agent output** | Semi-trusted | Completion reports, fenced `completion` blocks, learnings notes, PR comments authored by the shared `gh` identity | Schema-validated; completion JSON is gated by the per-spawn nonce (`MINIONS_COMPLETION_NONCE`, see [`completion-reports.md`](completion-reports.md) → "Trust boundary"). Reports without a valid nonce are rejected with `failure_class: 'completion-nonce-mismatch'`. |
| **External APIs** | Untrusted | GitHub REST/GraphQL responses, Azure DevOps REST responses, GitHub/ADO PR comment bodies, CI/run logs | Validated and shape-checked before persistence. Strings sourced from these responses are never passed as raw arguments to shells or `git`; see F2 (gh shell-injection fix) and F7 (`git log` execFile conversion) in the same security plan. |
| **Agent-controlled paths** | Untrusted | Paths supplied to dashboard endpoints by agents (e.g. `/api/agent-output`) | Normalized through `shared.sanitizePath` to constrain to the expected root; see F4 in the same plan. |

The agent-output trust boundary deserves emphasis: completion reports are the
single most powerful signal an agent can emit (they advance work-item status,
mark PRs reviewed, trigger merges). The nonce gate exists specifically so a
report written by an unrelated process — or by a stale dispatch from a
previous tick — cannot be silently consumed. Anything in the report body
itself remains agent-controlled and is treated as such (no `eval`, no shell
interpolation, schema-validated fields only).

## 4. Secret management

- **PATs and API tokens live in environment variables only.** GitHub tokens
  (`GH_TOKEN`, `COPILOT_GITHUB_TOKEN`), Azure DevOps PATs, and any
  runtime-specific credentials are read from the engine's process
  environment. They are not persisted to `config.json`, work-item state,
  PR metadata, or any other on-disk JSON Minions owns.
- **Tokens are never intentionally logged.** Engine code that shells out to
  `gh` or the ADO CLI threads the token via per-call `GH_TOKEN=...`
  environment injection (see `engine/gh-token.js`), so the value never
  appears on a command line or in `live-output.log`.
- **The log redactor is best-effort, not authoritative.** A best-effort
  redactor scrubs token-shaped strings from logs and agent output, but its
  coverage has **not** been formally audited (deferred as `D-f9`, open
  question `Q-f9-log-redactor`). Treat redaction as a defense-in-depth nicety,
  not a guarantee — do not rely on it to keep a leaked token out of an
  uploaded log bundle. If a token may have appeared in output, rotate it.

## 5. Known limitations

These are accepted limitations of the current model. They are documented
rather than fixed because (a) they are out of scope for the single-user
threat model, (b) they are tracked under other items, or (c) a fix would
break operator workflows we want to preserve.

- **No authentication gate on the dashboard.** Intentional — see §2. The
  single-user UX (and `minions` CLI, MCP integrations, and operator scripts
  that POST to `/api/*` without juggling a token) depends on this. Revisit
  only if the deployment model in §1 changes.
- **Prompt-injection surface from PR comments and inbox notes.** **Mitigated
  in F5 (W-mpeklod3000we69c).** Agent prompts now splice human-authored
  content (pinned notes, `notes/inbox/*`, PR comment bodies,
  `pendingHumanFeedback`, agent-memory, dashboard doc-chat document/selection
  blocks) inside `<UNTRUSTED-INPUT source="…">…</UNTRUSTED-INPUT>` fences via
  the helpers in `engine/untrusted-fence.js`. The sysprompt directive in
  `playbooks/shared-rules.md` (and `prompts/cc-system.md` for CC/doc-chat)
  teaches agents to treat fenced content as a quoted artifact and raise
  `securityFlags.injectionAttempt: true` in the completion report when they
  spot redirection attempts. Engine response: non-retryable failure with
  `FAILURE_CLASS.INJECTION_FLAGGED` plus a `notes/inbox/security-injection-*`
  alert and `_securityFlag` stamp on the work item. The `task_description`
  field is intentionally NOT fenced — it IS the task instruction, and
  fencing it would tell the agent to ignore its own work. New splice sites
  must use `wrapUntrusted(content, buildSource(...))`; see CLAUDE.md for the
  routing convention.
- **Temp-file predictability.** Per-dispatch temp paths can be predictable
  in some shells, opening a narrow TOCTOU window for a same-user process to
  race the engine. Tracked as **F6** in this same security plan
  (`P-f6-tmp-toctou`); the fix moves dispatch temp dirs to per-spawn unique
  paths with restrictive permissions.
- **Log redactor coverage is unaudited.** See §4 and `D-f9` /
  `Q-f9-log-redactor`. Until the audit lands, treat any log bundle that
  might contain agent output, CI logs, or `live-output.log` excerpts as
  potentially containing tokens, and rotate accordingly.
- **CSRF hardening sweep is deferred.** See §2. Origin gate + security
  headers are in place today; the broader sweep (deny-by-default CORS,
  `Sec-Fetch-Site` enforcement, optional bearer token) is `D-f8-csrf` /
  `Q-csrf-followup`.

---

**Updating this doc:** If you change the dashboard's bind address, add or
remove an authn/authz mechanism, change how completion reports are trusted,
change how secrets are read, or land any of F6 / F9 / the CSRF
follow-up, update the relevant section here in the same PR. F5 (untrusted
content fencing) landed in W-mpeklod3000we69c — extend the splice-site list
above when you wrap a new untrusted source. Keep the "in-scope vs residual
vs deferred" split — it is the part reviewers come back to.
