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

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

> Implementation run output. Branch only, no push/PR/merge — prepared for Pavel's sign-off.

**Branch:** `harden/close-untrusted-input-dispatch-chain` · **Ready for review.**

---

## 1. Edit summary

### Primitive (cli-core)
- `packages/cli-core/src/prompt-utils.ts:1` — extend `UntrustedSource` union with `url:${string}` + `web-search:${string}`.
- `packages/cli-core/src/prompt-utils.ts:8` — add `escapeXmlAttr` and XML-escape the `source` value inside `wrapUntrustedInput` (`&` first, then `<`,`>`,`"`). Must-fix: the new free-text web sources would otherwise break/inject the `source="…"` attribute. No-op for existing safe-charset sources.

### Wrapping (muse tools — F-7, F-8, D-UID-2)
- `packages/muse/src/tools/linear_get_issue.ts:1` — import `wrapUntrustedInput`.
- `packages/muse/src/tools/linear_get_issue.ts:36` — identifier stays a structural `# RAP-NN` heading; **title** wrapped (`source: linear:<id>`).
- `packages/muse/src/tools/linear_get_issue.ts:48` — **description** wrapped (`source: linear:<id>`).
- `packages/muse/src/tools/linear_get_issue.ts:62` — each **comment body** wrapped (`source: linear-comment:<id>-<index>`); author/timestamp header kept outside.
- `packages/muse/src/tools/fetch_url.ts:1,65` — import + wrap the fetched body (`source: url:<url>`) as one block.
- `packages/muse/src/tools/web_search.ts:2,140` — import + wrap synthesis+sources (`source: web-search:<query>`) as one block.

### Env scoping (forge MCP — F-2, D-UID-3)
- `packages/forge/src/mcp-server/spawn.ts:8` — add `SPAWN_ENV_ALLOWLIST`, `SYSTEM_ENV`, and exported `buildSpawnEnv`; `spawnForge` now uses `env: buildSpawnEnv(process.env)`.
- `packages/forge/src/mcp-server/tools/dispatch.ts:4,28` — import `buildSpawnEnv`; background dispatch spawn now uses it instead of `env: process.env`.

### Primitive consolidation (D-UID-1 — "one audited primitive")
- `packages/forge/src/core/prompt-utils.ts` — **deleted**. It was a byte-identical second copy of `wrapUntrustedInput` / `UntrustedSource`, a separate boundary primitive that would not have received this change's `source`-attribute escape.
- `packages/forge/src/commands/plan/plan-builder.ts:33` + `…/audit/input-resolvers/openspec-change.ts:5` — re-pointed from `../core/prompt-utils.js` to `@rapoport-studio/cli-core` (the only two importers of the local copy). The barrel `packages/forge/src/core/index.ts:94` already re-exported the cli-core version, so the public surface is unchanged.
- Rationale: the consolidation operationalises `D-UID-1` literally — every Forge call-site (`plan-builder.ts:287`, `openspec-change.ts:59`) now shares the single hardened primitive, so the attribute-escape fix covers them too. Forge build (`tsc -b`) + the full forge suite re-verified green after the change.

### Threat model (D-UID-4)
- `openspec/_methodology/security/agents-threat-model.md:117` — add `AT-16` (web-fetch egress-then-ingest, RPN 4) + `AT-17` (MCP-spawn credential inheritance → unattended dispatch, RPN 5), matching the AT-01..15 7-column format.

### Tests (new / extended)
- `packages/cli-core/src/__tests__/prompt-utils.test.ts` (new) — safe sources unchanged + free-text attribute-escape + `&`-first ordering.
- `packages/muse/src/tools/__tests__/linear-tools.test.ts` — updated heading assertion + new "wraps author fields, metadata stays outside" boundary test.
- `packages/muse/src/tools/__tests__/fetch_url.test.ts` (new), `…/web_search.test.ts` (new).
- `packages/forge/src/mcp-server/__tests__/spawn.test.ts` (new) — fail-closed scoping; names `SUPABASE_SERVICE_ROLE_KEY` + `GITHUB_BOT_TOKEN` as must-be-present.

### OpenSpec artifacts
- `openspec/changes/close-untrusted-input-dispatch-chain/{proposal,design,tasks,verification}.md` (new; `<!-- source -->` stripped to match neighbour folders).
- `openspec/_research/2026-06-13-agent-harness-hardening-audit.md` (new) — imported verbatim from sibling worktree `suspicious-burnell-ff29c7` (verify-not-overwrite), `<!-- source -->` stripped to match `_research/` neighbours.

---

## 2. Injection-boundary scan (Wave 2 acceptance — PASS)

Scan over `packages/muse/src/tools/*.ts`:

- **Now wrapped (exactly the 3 audited tools):** `linear_get_issue.ts`, `fetch_url.ts`, `web_search.ts` — all call `wrapUntrustedInput`.
- **Zero false positives on already-wrapped reference paths:** `modes/architect/repo-tools.ts` and `orchestration/run_headless.ts` still call `wrapUntrustedInput` and are **unmodified** (confirmed via working-tree diff — neither appears in my change set).

This is the audit's exact falsifiable acceptance test (`openspec/_research/2026-06-13-…audit.md` line 129), now passing.

---

## 3. Env allowlist + live-check

### Derived `SPAWN_ENV_ALLOWLIST` (empirical — where each var is read)

| Var | Read at | Purpose |
|---|---|---|
| `ANTHROPIC_API_KEY` | `forge/cli.ts`, plan/build orchestrators, `muse/shared/client.ts` | Claude API |
| `GITHUB_BOT_TOKEN` | `forge/core/auth.ts:91`, `muse/shared/github-client.ts` | GitHub push/identity (**build-only**) |
| `GITHUB_TOKEN` | `forge/core/auth.ts` (resolve fallback) | operator-token fallback |
| `LINEAR_BOT_API_KEY` | `forge/core/auth.ts:92`, `muse/shared/linear-client.ts`, `muse/commands/draft.ts` | Linear API |
| `LINEAR_API_KEY` | `forge/cli.ts:228/427`, `auth.ts` fallback | Linear `--create-issues` |
| `LINEAR_TEAM_ID` | `forge/cli.ts:229` | audit `--create-issues` |
| `SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` | `forge/core/forge-events.ts:192-193` | `orchestra_journal` `build_complete`/`handoff_ack` (**build-only**) |
| `NODE_ENV` | `forge/core/auth.ts:89`, `muse/runtime/specialist-tools.ts` | prod gate / flags |
| `FORGE_PROJECT` / `FORGE_PROJECT_CONTEXT_PATH` / `FORGE_ISSUE_PREFIX` | `forge/config.ts:513/516/813` | project/config overrides |
| `FORGE_{ARCHITECT,PLANNER,BUILDER}_COMPACTION`, `FORGE_BUILDER_CTX_{EDIT,KEEP}` | `forge/core/flags.ts:70-84` | per-role compaction/context flags |
| `MAESTRO_HANDOFF_SESSION_ID` | `forge/commands/build/orchestrator.ts:179/1265` | handoff_ack / build_complete emits |
| `MUSE_OPENSPEC_PATH` / `MUSE_METHODOLOGY_PATH` | `muse` read_openspec/mirror/templates, `forge/commands/render/orchestrator.ts` | OpenSpec/methodology paths |
| `STUDIO_HOME` | `muse/modes/maestro/journal.ts:147` | studio home override |
| `USERPROFILE` | `forge/core/acceptance.ts:423` | Windows `HOME` fallback |

Plus system vars `PATH, HOME, SHELL, TMPDIR, LANG` and every `NODE_*` (`D-UID-3`).

**`SUPABASE_SERVICE_ROLE_KEY` justification (crown-jewel / RLS bypass):** genuinely read on the build path (`forge-events.ts:193` for `orchestra_journal` writes the build orchestrator fires) — `forge_dispatch` runs `build`, so kept. If a future trace shows the build path no longer writes Supabase, drop it.

### Live-check (PASS — boot through the real spawn path)

Ran a harness that calls `spawnForge(forgeBin, ['status', '--json'])` (which now routes through `buildSpawnEnv`):
- Off-allowlist secret `OFF_ALLOWLIST_LEAK` planted on the parent env → **absent** from the scoped child env (fail-closed confirmed).
- `PATH` + `HOME` present; a real `forge` subprocess **booted, loaded config, and exited 0** with valid JSON — proves the scoped env does not over-scrub the system vars a real subprocess needs (process startup + tsx/muse-mirror resolution + `~/.forge` access).

**Secret-value pass-through `[to-verify, low-risk]`:** Infisical is not authenticated in this sandbox (`infisical run` could not fetch secrets), so a live run with real secret *values* was not executed. This residual is deterministically covered: `buildSpawnEnv` is a pure pick and `spawn.test.ts` asserts the exact build-only secret **names** (`SUPABASE_SERVICE_ROLE_KEY`, `GITHUB_BOT_TOKEN`) pass through — a live run could only catch a name typo, already guarded. Optional belt-and-suspenders for sign-off:
```
infisical run --env=prod --path=/ -- node <harness calling spawnForge(forgeBin, ['plan','<RAP>','--json'])>
```

---

## 4. Verification checklist (`verification.md`)

| Acceptance line | Status |
|---|---|
| Wave 2 — injection scan flags 3 tools wrapped, 0 false positives on reference paths | ✅ PASS |
| Wave 2 — unit test: `<untrusted_input>` wraps Linear description+comments+title and web content; metadata stays outside | ✅ PASS |
| Wave 3 — `buildSpawnEnv` ⊆ (allowlist ∪ system); off-allowlist token absent (unit test) | ✅ PASS |
| Wave 3 — real `forge` dispatch w/o `--auto-*` completes against scoped env | ✅ PASS (boot-level via `forge status`; secret-value live-run `[to-verify]`, see §3) |
| Wave 4 — `AT-16` + `AT-17` present in AT-01..15 format, RPN ≤ 10 | ✅ PASS (RPN 4 / 5) |
| Wave 5 — `node tools/check-openspec-refs.mjs` exits 0 | ✅ PASS |
| Wave 5 — touched-package tests green | ✅ PASS (cli-core 128 · muse 934 · forge 1527 — re-run after consolidation; the orphaned forge-local `prompt-utils.test.ts` was migrated into cli-core and deleted) |
| MUST NOT: no new primitive / no Finding-type change / no new MCP server or lint / no `--auto-*` gating / no `openspec/current/` edits / no push/PR | ✅ confirmed (diff scope below) |

**Diff scope (working tree):** 10 tracked edits (incl. the 2 consolidation re-points) + 2 tracked deletions (`forge/src/core/prompt-utils.ts` and its now-orphaned `__tests__/prompt-utils.test.ts`, whose unique cases were migrated into `cli-core/src/__tests__/prompt-utils.test.ts`) + 6 untracked new paths — exactly the planned files plus the consolidation. Reference paths `repo-tools.ts` / `run_headless.ts` untouched.

---

## 5. Notes for reviewer

- **Duplicate primitive — consolidated (resolved in this change):** `packages/forge/src/core/prompt-utils.ts` was a **separate copy** of `wrapUntrustedInput` (not a re-export of cli-core). Per Pavel's explicit decision it has been **deleted** and its two importers (`plan-builder.ts`, `audit/input-resolvers/openspec-change.ts`) re-pointed at `@rapoport-studio/cli-core`, so `D-UID-1`'s "one audited primitive" now holds literally — the source-attribute escape covers the Forge call-sites too. (Both call-sites only ever passed safe-charset sources, so there was no live injection gap; this closes the drift surface, not an active hole.) Forge build + full forge suite re-verified green post-consolidation.
- **`UntrustedSource` exhaustive-matcher:** the union extension caused no build breakage — cli-core + muse + forge all typecheck/build clean; no `switch` over the union elsewhere.
- **Linear issue:** none created — slug-only folder per the proposal header (`_to create_`).
- Branch is local only. **No push, no PR, no `--auto-*` dispatch run.** Agents prepare, humans sign.
