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

# Tasks — close-untrusted-input-dispatch-chain

> Companion to proposal.md + design.md.

## Wave 1 — Scaffold

- [x] Author `proposal.md`.
- [x] Author `design.md` with `D-UID-1..4` + `Q-UID-1`.
- [x] Author `tasks.md` (this file).
- [x] Author `verification.md`.

## Wave 2 — Wrap untrusted tool results (F-7, F-8)

- [x] **Primitive (`packages/cli-core/src/prompt-utils.ts`):** extend `UntrustedSource` with `url:`/`web-search:` and XML-escape the `source` attribute (`escapeXmlAttr`), per `D-UID-1`. No-op for existing safe-charset sources.
- [x] **Consolidate the duplicate primitive (`D-UID-1`):** delete `packages/forge/src/core/prompt-utils.ts` and re-point its two importers (`plan-builder.ts:33`, `audit/input-resolvers/openspec-change.ts:5`) at `@rapoport-studio/cli-core`, so the hardened primitive is the only one. Barrel `core/index.ts:94` already re-exported cli-core.
- [x] `packages/muse/src/tools/linear_get_issue.ts` — wrap description (`:48-49`), comment bodies (`:69`), and title via `wrapUntrustedInput`, per `D-UID-2`; leave structural metadata outside the boundary.
- [x] `packages/muse/src/tools/fetch_url.ts` — wrap returned content (`:65-75`) as one untrusted block, per `D-UID-2`.
- [x] `packages/muse/src/tools/web_search.ts` — wrap returned results (`:140`), per `D-UID-2`.
- [x] Unit tests: each tool's output contains the `<untrusted_input>` boundary around author/web content; Linear structural metadata stays outside it. Plus `prompt-utils.test.ts` for the attribute-escape.

## Wave 3 — Scope the MCP-spawn env (F-2)

- [x] **2a (verify-first):** enumerate every `process.env.*` read on the `forge` build/dispatch path; pin the required set into `D-UID-3`'s `SPAWN_ENV_ALLOWLIST`.
- [x] `packages/forge/src/mcp-server/spawn.ts` (`:13`) + `tools/dispatch.ts` (`:30`) — replace `env: process.env` with `buildSpawnEnv(process.env)`, per `D-UID-3`.
- [x] Unit test: `buildSpawnEnv` returns only allowlisted + system vars; asserts an off-allowlist token is absent; names build-only secrets (`SUPABASE_SERVICE_ROLE_KEY`, `GITHUB_BOT_TOKEN`) as must-be-present.
- [x] Live check: a real `forge` subprocess (boots through `spawnForge` → `buildSpawnEnv`) completes against the scoped env; off-allowlist token absent. Secret-*value* live-run `[to-verify]` (Infisical not authed in sandbox) — see `_results.md` §3.

## Wave 4 — Threat-model rows

- [ ] `openspec/_methodology/security/agents-threat-model.md` — add `AT-16` (web-fetch egress-then-ingest) and `AT-17` (MCP-spawn credential inheritance → unattended dispatch) in the existing AT-01..15 format, per `D-UID-4`.

## Wave 5 — Gates

- [ ] `node tools/check-openspec-refs.mjs` exits 0.
- [ ] Touched-package tests green (`pnpm --filter @rapoport-studio/muse test`, `pnpm --filter @rapoport-studio/forge test`).
- [ ] Injection-boundary spot-check passes (`verification.md` acceptance).

> The implementing agent does **not** push, open a PR, or run an `--auto-*` dispatch. Human sign-off (Pavel) gates merge — agents prepare, humans sign.
