<!-- source: openspec/changes/close-untrusted-input-dispatch-chain/proposal.md -->

# Close the untrusted-input → unattended-dispatch chain — wrap tool results + scope the MCP-spawn env

> **Status:** proposed.
> **Owner:** Pavel Rapoport <pavel@rapoport.studio>.
> **Linear issue:** _to create_ — recommend filing under a security/hardening epic; slug-only folder name used while unassigned.
> **Opened:** 2026-06-13.
> **Authority:** `openspec/_research/2026-06-13-agent-harness-hardening-audit.md` § Section B (findings F-2, F-7, F-8) and its chained-risk note; existing methodology `openspec/_methodology/security/agents-threat-model.md` (AT-01..15) and `openspec/_methodology/agent-access-scope.md`.
> **Affects code:** `packages/muse/src/tools/linear_get_issue.ts`; `packages/muse/src/tools/fetch_url.ts`; `packages/muse/src/tools/web_search.ts`; `packages/forge/src/mcp-server/spawn.ts`; `packages/forge/src/mcp-server/tools/dispatch.ts`. Reuses `wrapUntrustedInput` (`packages/cli-core/src/prompt-utils.ts:8`).
> **Affects spec:** `openspec/_methodology/security/agents-threat-model.md` — two new rows, AT-16 (web-fetch egress-then-ingest) and AT-17 (MCP-spawn credential inheritance → unattended dispatch).
> **Blast radius:** low–medium. Tool-result wrapping is additive string-shaping on read-only tools (no schema change, no new tools, no new MCP server). The MCP-spawn env change narrows an inherited environment — the one real risk is over-scrubbing breaking dispatch, gated by a verify-then-scope step plus a live-dispatch check in `verification.md`.
> **Capability prefix:** `UID` (Untrusted-Input & Dispatch hardening). Decisions ratify as `D-UID-N`.

---

## Why

The 2026-06-13 harness audit found three live gaps that are each independently "major" but **chain into one attack path the threat model does not model**:

1. **Untrusted input enters agent prompts unwrapped.** `linear_get_issue` (description + comment bodies), `fetch_url`, and `web_search` return author- or web-controlled text concatenated into model context without the `<untrusted_input>` boundary the draft-time paths already enforce. (Findings F-7, F-8.)
2. **The Forge MCP server spawns the CLI with the full host environment.** `env: process.env` hands every secret (`ANTHROPIC_API_KEY`, `GITHUB_BOT_TOKEN`, `LINEAR_BOT_API_KEY`, the Infisical cache) to a process that, via `forge_dispatch`, can run unattended `--auto-push --auto-pr` builds. (Finding F-2.)
3. **In combination:** an injection payload embedded in a Linear ticket or a fetched web page (1) can in principle steer an agent that can reach `forge_dispatch` (2), driving a credentialed, auto-pushed build. This is a local stdio path — **not** a remote RCE — but it is exactly the chain a threat model is meant to catch, and it is currently unmodeled.

The mitigation breaks the chain at its two most leveraged points: deny injected instructions a trusted channel (wrap the three tool results), and shrink the secret blast radius of the spawned process (scope its env). Defense-in-depth on the dispatch capability itself (`--auto-*` confirmation) is acknowledged and deferred — see Out of scope and `Q-UID-1`.

## Scope

Three code edits plus one methodology edit. All on read-only tool surfaces or process-spawn config; no schema changes, no new tools, no new MCP servers.

### 1. Wrap untrusted tool results (F-7, F-8)

- `packages/muse/src/tools/linear_get_issue.ts` — wrap the issue description (`:48-49`) and each comment body (`:69`) (plus title) with `wrapUntrustedInput`; leave structural metadata (id, state, assignee, timestamps) outside the boundary. Registered foundation tool (`register_foundation_tools.ts:51`), read-only allowlisted (`allowlists.ts:44`), on the Maestro surface (`modes/maestro/tools.ts:25`).
- `packages/muse/src/tools/fetch_url.ts` — wrap the returned content (`:65-75`) as one untrusted block.
- `packages/muse/src/tools/web_search.ts` — wrap the returned results (`:140`).

This copies the pattern already used at `architect/repo-tools.ts:274` and `run_headless.ts:139`; no new primitive (`D-UID-1`).

### 2. Scope the MCP-spawn environment (F-2)

Replace `env: process.env` at `packages/forge/src/mcp-server/spawn.ts:13` (and the dispatch path at `tools/dispatch.ts:30`) with an **allowlist-derived** environment: only the vars the CLI genuinely reads on the build/dispatch path, plus the system vars any Node subprocess needs. The allowlist is derived empirically (Task 2a) by enumerating actual `process.env.*` reads — not guessed (`D-UID-3`).

### 3. Seed the two missing AT-rows

Add `AT-16` (web-fetch egress-then-ingest injection — F-8) and `AT-17` (MCP-spawn credential inheritance enabling unattended dispatch — the chain) to `agents-threat-model.md`, in the existing AT-01..15 row format (`D-UID-4`).

## Out of scope

- **`--auto-*` dispatch confirmation gating** (defense-in-depth on F-2) — changes dispatch UX, needs its own verification; recorded as `Q-UID-1`, deferred.
- **Boot-time allowlist-membership lint for Forge MCP tools** (F-12) — belongs to the follow-on detector change (Change B). This slice fixes holes; the detector prevents regressions.
- **Re-modeling the dead AT-02 PR-comment-ingestion row** (F-9) — threat-doc cleanup, not a live fix.
- **Any change to review/audit/build `Finding` types** — that is the sibling change `add-review-confidence-gate`.

## Risks

- **Over-scrubbing the spawn env breaks dispatch.** Mitigation: the allowlist is derived by enumerating actual env reads before narrowing (Task 2a); verification runs a real `forge` dispatch (without `--auto-push`/`--auto-pr`) and asserts it completes.
- **Wrapping changes a tool-output contract a downstream parser relies on.** Mitigation: the three tools are read-only and their results are model-context strings, not code-parsed; verification greps for any structured consumer and confirms none.
- **Double-wrapping** on a path that already wraps. Mitigation: the audit confirmed these three are the only unwrapped read-only untrusted-result tools; the already-wrapped paths are untouched.

## Verification

See `verification.md` — file edits + a live-dispatch check + the injection-boundary assertion (the audit's falsifiable test: exactly `linear_get_issue.ts`, `fetch_url.ts`, `web_search.ts` flagged before the fix, **zero** after, **zero** false positives on already-wrapped paths).

## Lessons consulted

- `openspec/_research/2026-06-13-agent-harness-hardening-audit.md` — source findings, chained-risk framing, the falsifiable acceptance test.
- `openspec/_methodology/security/agents-threat-model.md` — AT-row format to match for AT-16/17.
- `packages/cli-core/src/prompt-utils.ts` — the `wrapUntrustedInput` primitive being reused (no new abstraction introduced).
