Status: Active plan for issue #122

# Plan: Harden `/deep-research` tool policy and result bounds

## Goal

Close the focused follow-up gaps split out of #121: research agents must not be
able to mutate the workspace, and question/angle/fan-in/result sizes must be
modestly bounded with a clear failure message. Normal `/deep-research <question>`
must keep returning a usable report.

## Verified current behavior

| Symptom | Evidence |
|---------|----------|
| Research agents get the full coding tool pool | `src/builtin-commands.ts:228` — `tools: [...createCodingTools(cwd), ...createWebTools()]`. `createCodingTools` returns `read, bash, edit, write`, so a research agent can run `bash`/`edit`/`write` against the host workspace. |
| No read-only fence on the run | The `runWorkflow(...)` call passes no `readOnly` flag, so an `agentType` allowlist or harness config could re-grant a write tool (the `applyToolPolicy` read-only fence is never engaged). |
| Question length unbounded | `src/deep-research.ts` script: `const question = (args && args.question) || ''` — no length cap; a huge prompt overflows the agent-result channel / provider context. |
| Angles / minSupport unbounded | `const angles = (args && args.angles) || 4` / `minSupport` — any value accepted, fan-in can explode. |
| Fan-in unbounded | `gathered.filter(Boolean).flatMap(...)` collects every source from every angle with no cap; `JSON.stringify(allSources)` is passed verbatim to the verify/report agents. |
| Final report unbounded | `report` agent output is returned verbatim with no truncation note. |
| No private-network policy for `web_fetch` | `src/web-tools.ts` `createWebFetchTool` fetches any absolute URL the model selects, including loopback/private ranges; the intended policy is undocumented. |

## Allowed paths

- `src/deep-research.ts` — bounds + clear failure messages in the generated
  script; export a `createDeepResearchTools(cwd)` helper and a
  `deepResearchSafetyOptions(cwd)` seam for testability.
- `src/builtin-commands.ts` — switch the deep-research handler to the read-only
  tool pool + `readOnly: true` fence.
- `src/web-tools.ts` — document the deferred private-network policy decision
  (comment only; no blocklist yet, per the issue's "before adding network
  hardening" wording).
- `src/index.ts` — export the new `createDeepResearchTools` /
  `deepResearchSafetyOptions` helpers.
- `docs/workflows/deep-research.md` — new short doc recording the tool policy,
  bounds, and the private-network policy decision.
- `docs/workflows/workflow-lock.json` — update the `deep-research` `sha256`
  after `src/deep-research.ts` changes.
- `tests/builtin-workflows.test.ts` and `tests/deep-research.test.ts` (new) —
  targeted tests for bounds, failure messages, and the read-only tool pool.

## Denied / out-of-scope paths

- **Network hardening / SSRF blocklist** — the issue explicitly defers this
  ("Decide and document … before adding network hardening"). We document the
  decision only.
- **Artifact/citation subsystem** — guardrail forbids introducing one without
  an accepted design.
- **Issue Delivery / prompt-guidance code** — guardrail keeps this release
  independent.
- **`src/workflow.ts` / `src/agent-registry.ts` tool-policy core** — read-only
  fence and `applyToolPolicy` already exist; we only consume them.

## Bounds (modest, documented, tested)

| Bound | Value | On overflow |
|-------|-------|-------------|
| Question length | 1..2000 chars | throw clear error |
| Angles | integer 1..8 | throw clear error |
| minSupport | integer 1..angles | throw clear error |
| Sources fan-in | cap 32 | truncate + note in result |
| Claims per source | 12; claim/url char caps | applied before verify |
| Verify payload | 60k chars | truncate + note (agent reads as text) |
| Final report | 8k chars | truncate + `[report truncated …]` note |

Errors thrown by the script surface to the user through the existing
`catch` in the deep-research handler (`deep-research failed: <message>`), so
each bound violation produces a clear failure message.

## Implementation steps

### Step 1: Bounds + read-only tool helper (`src/deep-research.ts`)

- Add exported bounds object and `createDeepResearchTools(cwd)` returning
  `[...createReadOnlyTools(cwd), ...createWebTools()]`.
- Add `deepResearchSafetyOptions(cwd)` returning `{ tools, readOnly: true }`
  (the testable safety seam; the handler spreads the rest of its run options).
- Rewrite the generated script: validate question/angles/minSupport up front
  (throw clear errors), clamp fan-in / claims, bound the verify payload, cap the
  report with a truncation note. Preserve the four phases and the
  `args && args.question` / `args.angles` / `args.minSupport` reads (existing
  tests assert these substrings).

### Step 2: Read-only fence in the handler (`src/builtin-commands.ts`)

- Replace `tools: [...createCodingTools(cwd), ...createWebTools()]` with
  `...deepResearchSafetyOptions(cwd)` so the run is read-only + web-enabled.

### Step 3: Document the private-network policy (`src/web-tools.ts` + docs)

- Add a doc comment on `createWebFetchTool` stating: today any absolute URL the
  model selects is fetched on the host network; the intended future policy is to
  reject loopback / link-local / private ranges, but that network hardening is
  deferred pending an accepted design (issue #122). Behavior unchanged.
- Add `docs/workflows/deep-research.md` summarizing tool policy, bounds, and the
  deferred network policy.

### Step 4: Exports + lock (`src/index.ts`, `docs/workflows/workflow-lock.json`)

- Re-export `createDeepResearchTools` and `deepResearchSafetyOptions`.
- Recompute and update the `deep-research` entry `sha256` in the lock; keep
  notes consistent.

### Step 5: Tests

- `tests/deep-research.test.ts`: `createDeepResearchTools(cwd)` excludes
  `WRITE_TOOL_NAMES` (`edit`/`bash`/`write`) and includes `web_search`/`web_fetch`;
  `deepResearchSafetyOptions(cwd).readOnly === true`.
- `tests/builtin-workflows.test.ts`: the generated script references the bounds,
  throws on empty/oversized question, throws on out-of-range angles, caps the
  source fan-in, and truncates the report with a clear note.

## Acceptance mapping

- **Research agents cannot mutate the workspace** → read-only tool pool +
  `readOnly: true` fence; tested by name exclusion of write tools.
- **Inputs, fan-in, and final response have modest tested bounds with a clear
  failure message** → bounds table above; targeted tests.
- **Normal research still returns a usable report** → bounds are generous
  (8k report, 32 sources); no behavior change for ordinary questions.
- **Targeted tests + `npm test` pass** → new tests + full gate green.
