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

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

> Companion to proposal.md. Records `D-UID-N` and the contracts the
> implementation must honour.

## D-UID-1 — Reuse `wrapUntrustedInput`; introduce no new boundary primitive

The codebase already standardises on `wrapUntrustedInput` (`packages/cli-core/src/prompt-utils.ts:8`), wired on the draft-time paths: plan-builder Linear body (`plan-builder.ts:287`), audit openspec-change resolver (`audit/input-resolvers/openspec-change.ts:59`), Muse draft (`bin/muse/commands/draft.ts:313`), architect repo file reads (`architect/repo-tools.ts:274`), Telegram headless (`run_headless.ts:139`). The three fixes call the **same** function with no per-tool variant.

**Why not a new wrapper?** A second boundary primitive becomes its own drift surface, and the audit's acceptance test asserts *uniform* use of this exact function. One primitive, audited once.

## D-UID-2 — Wrap author-controlled fields, not the whole tool envelope

- **`linear_get_issue`:** wrap the issue **description** (`:48-49`), each **comment body** (`:69`), and the **title** — the fields a ticket author controls — while leaving structural metadata (issue id, state, assignee, labels, timestamps) **outside** the boundary so the model can still reason over trustworthy routing fields.
- **`fetch_url` / `web_search`:** the entire returned content/result body is untrusted; wrap it as one block. There is no trustworthy sub-structure to preserve.

**Why granular for Linear, whole for web?** Linear has trustworthy server-set metadata worth keeping usable; web responses do not. Granularity is chosen per the trust profile of each field, not applied uniformly.

## D-UID-3 — Env scoping is allowlist-derived, fail-closed (not a denylist)

Replace `env: process.env` with `env: buildSpawnEnv(process.env)`, where `buildSpawnEnv` returns only:

- **(a)** an explicit allowlist of vars the CLI genuinely reads on the build/dispatch path — derived **empirically** in Task 2a by enumerating `process.env.*` reads, then pinned here; and
- **(b)** the system vars any Node subprocess needs: `PATH`, `HOME`, `SHELL`, `TMPDIR`, `LANG`, and `NODE_*`.

```ts
// shape only — fill the allowlist from Task 2a's enumeration
const SPAWN_ENV_ALLOWLIST = [/* e.g. ANTHROPIC_API_KEY, GITHUB_BOT_TOKEN, LINEAR_BOT_API_KEY, … */] as const;
const SYSTEM_ENV = ['PATH', 'HOME', 'SHELL', 'TMPDIR', 'LANG'] as const;
function buildSpawnEnv(src: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
  const out: NodeJS.ProcessEnv = {};
  for (const k of [...SPAWN_ENV_ALLOWLIST, ...SYSTEM_ENV]) if (src[k] != null) out[k] = src[k];
  for (const k of Object.keys(src)) if (k.startsWith('NODE_')) out[k] = src[k];
  return out;
}
```

**Why allowlist not denylist?** A denylist leaks every newly-added secret by default; an allowlist fails closed — a secret not on the list never reaches the child process. The empirical-derivation step (Task 2a) is what prevents over-scrubbing.

## D-UID-4 — AT-16 / AT-17 match the existing Shostack row format

The two new rows reuse the column schema of `AT-01..15` in `agents-threat-model.md` — do **not** invent a new table shape.

- **AT-16** — web-fetch egress-then-ingest injection: `fetch_url` / `web_search` results re-enter an agent prompt as trusted text (F-8). Post-mitigation control: `wrapUntrustedInput` (this change).
- **AT-17** — MCP-spawn credential inheritance → unattended dispatch: the Forge MCP server passes full `process.env` to a CLI that can `--auto-push`/`--auto-pr` (F-2 / the chain). Post-mitigation controls: env allowlist (this change) + input wrapping (AT-16); residual addressed by `Q-UID-1`.

Each carries a post-mitigation RPN consistent with the register's ≤ 10 target after this change lands.

**Why match the format?** The threat model is one coherent register; new rows must be diffable against the old, not a parallel structure.

## Q-UID-1 — `--auto-*` dispatch confirmation (deferred, defense-in-depth)

**Open:** should `forge_dispatch` (`tools/dispatch.ts:18-25`) require a human confirmation token before `--auto-push` / `--auto-pr`, so that even a fully-injected caller cannot ship unattended?

Deferred from this slice: it changes dispatch UX and needs its own verification. Recorded here so it is not lost; resolve in a follow-on hardening change if the rule-of-three (or a real incident) triggers. Wrapping (D-UID-2) + env scoping (D-UID-3) already break the chain at its two most leveraged points; this is the third, heavier layer.
