# claw-forge SOTA Correctness + Security Restoration Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Close the highest-leverage correctness and security gaps found in the 2026-07 SOTA audit (`docs/sota-gap-analysis-2026-07.md`) — fix the crashing `run_agent()`, the fatal-auth drain leak, the failed-task scheduler hole, five quick wins, and restore the behavioural security layers that `bypassPermissions` currently disables.

**Architecture:** Two phases. **Phase A** (Tasks 1–8) is correctness + quick wins — each an isolated, independently-reviewable fix with a regression test. **Phase B** (Tasks 9–12) restores the `can_use_tool` security layer on the production dispatch path; it is sequenced last because it depends on first fixing the `ALWAYS_BLOCK` substring-matching bug (Task 9) that would otherwise deny benign commands (`git add`, `mkdir subdir`) the moment the mode flips.

**Tech Stack:** Python 3.12, `asyncio`, `claude-agent-sdk` 0.1.46, Typer CLI, FastAPI + SQLite state service, pytest. Package manager is `uv` (never activate the venv).

## Global Constraints

- **Run tests with `uv run pytest …`** — never bare `pytest`, never activate the venv.
- **Lint both paths before any commit:** `uv run ruff check claw_forge/ tests/` (CI runs exactly this on both dirs; a narrowed lint misses test-file E501). See `memory/feedback_lint_full_paths.md`.
- **Coverage gate is 90% branch** — new source needs tests or CI fails.
- **`pyproject.toml` version is a static `0.0.0.dev0` placeholder — never bump it.** The publish workflow sets it from the release tag.
- **No DB migrations framework** — new `Task` columns need `ALTER TABLE ADD COLUMN` handling; none of the tasks below add a column (deferred to a separate plan), so this is a "don't introduce one" constraint.
- **Some `tests/test_permissions.py` failures under a full-suite run are pre-existing batch pollution** (`memory/project_test_permissions_batch_pollution.md`) — run the file in isolation to judge your own changes; it must pass alone and on clean `main`.
- **Each task = one branch, one focused commit** (or a tight commit series), independently reviewable.

---

## Phase A — Correctness fixes + quick wins

### Task 1: Fix `run_agent()` — crashes on every non-dispatcher call

**Root cause (verified):** `claude_agent_sdk/_internal/client.py:54-58` raises `ValueError` when `options.can_use_tool` is set **and** the prompt is not an `AsyncIterable` (streaming mode). `runner.py:242` unconditionally sets `can_use_tool=make_can_use_tool(...)`, and `run_agent(prompt: str, …)` at `runner.py:32-33` passes a plain string to `query(prompt=prompt, …)` at `runner.py:257`. Every caller of `run_agent`/`collect_result`/`collect_structured_result` (`claw-forge fix`, `boundaries apply`, the boundaries classifier) dies before any API call. Secondary bug: `runner.py:128-129` exports `env["CLAUDE_OAUTH_TOKEN"]` — a var the CLI never reads (correct var is `ANTHROPIC_AUTH_TOKEN`, per `task_handler.py:409`).

**Files:**
- Modify: `claw_forge/agent/runner.py` (string→AsyncIterable wrap near line 257; OAuth env var near line 128)
- Test: `tests/agent/test_runner_streaming.py` (create)

**Interfaces:**
- Consumes: `claude_agent_sdk.query`, `ClaudeAgentOptions` (already imported in `runner.py`).
- Produces: `run_agent()` that runs with a string prompt without raising `ValueError`; the SDK message format for a streaming prompt is `{"type": "user", "message": {"role": "user", "content": <prompt>}}`.

- [ ] **Step 1: Write the failing test**

The `ValueError` fires at option-assembly time, before any network call, so no API mock is needed. Assert that `run_agent` gets *past* SDK validation (it will fail later on transport/auth, which is fine — we only need to prove the `can_use_tool`+string-prompt `ValueError` is gone).

```python
# tests/agent/test_runner_streaming.py
import pytest
from claw_forge.agent import runner


@pytest.mark.asyncio
async def test_run_agent_string_prompt_does_not_raise_can_use_tool_valueerror():
    """A string prompt + can_use_tool must NOT raise the SDK streaming-mode
    ValueError. It may fail later (no creds/transport), but not with that."""
    gen = runner.run_agent("say hi", cwd=None, project_dir=None)
    try:
        async for _ in gen:
            break  # got at least one message → definitely past validation
    except ValueError as exc:
        if "can_use_tool callback requires streaming mode" in str(exc):
            pytest.fail("run_agent still passes a non-streaming prompt with can_use_tool")
    except Exception:
        pass  # transport/auth failure is acceptable — validation was passed
```

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/agent/test_runner_streaming.py -v`
Expected: FAIL — `run_agent still passes a non-streaming prompt with can_use_tool`.

- [ ] **Step 3: Wrap the prompt in a one-item async iterator**

In `claw_forge/agent/runner.py`, immediately before `raw_stream = query(prompt=prompt, options=options)` (line 257), convert a string prompt into the SDK's streaming message form:

```python
    if isinstance(prompt, str):
        _prompt_text = prompt

        async def _single_message_stream() -> Any:
            yield {
                "type": "user",
                "message": {"role": "user", "content": _prompt_text},
            }

        stream_prompt: Any = _single_message_stream()
    else:
        stream_prompt = prompt

    raw_stream = query(prompt=stream_prompt, options=options)
```

- [ ] **Step 4: Fix the OAuth env var**

In `claw_forge/agent/runner.py` near line 128, change the wrong var name:

```python
        if getattr(provider_config, "oauth_token", None):
            env["ANTHROPIC_AUTH_TOKEN"] = provider_config.oauth_token  # type: ignore[assignment]
```

- [ ] **Step 5: Run the test to verify it passes**

Run: `uv run pytest tests/agent/test_runner_streaming.py -v`
Expected: PASS.

- [ ] **Step 6: Lint + commit**

Run: `uv run ruff check claw_forge/ tests/`
```bash
git add claw_forge/agent/runner.py tests/agent/test_runner_streaming.py
git commit -m "fix(runner): stream string prompts so can_use_tool works; correct OAuth env var"
```

---

### Task 2: Fatal-auth drain defeated by run_cli's pause-poll loop

**Root cause (verified):** On pool exhaustion via a fatal-auth error the dispatcher calls `self.pause()` (`dispatcher.py:449`), which only sets an in-process `self._paused` flag (`dispatcher.py:286-289`). Back in `run_cli.py`, the paused branch (`run_cli.py:1407-1441`) polls `GET /project/paused` — a DB flag that **only Stop-All sets** — gets `paused=False`, prints "Resumed", re-fetches pending/failed tasks, and re-dispatches. The loop repeats until the whole DAG is `failed`. This is the Serviq-box failure mode; it defeats the fatal-auth cascade prevention documented in CLAUDE.md.

**Files:**
- Modify: `claw_forge/orchestrator/dispatcher.py` (add `pause_reason`)
- Modify: `claw_forge/orchestrator/run_cli.py` (~line 1361 and ~1407: branch on fatal-auth pause)
- Test: `tests/orchestrator/test_run_cli_fatal_auth_drain.py` (create)

**Interfaces:**
- Consumes: `Dispatcher.is_paused` (existing property, `dispatcher.py:280`).
- Produces: `Dispatcher.pause_reason: str | None` — set to `"fatal_auth"` by the fatal-auth pause site, `None` otherwise. `Dispatcher.pause(reason: str | None = None)`.

- [ ] **Step 1: Write the failing test**

```python
# tests/orchestrator/test_run_cli_fatal_auth_drain.py
from claw_forge.orchestrator.dispatcher import Dispatcher


def test_pause_records_reason():
    d = Dispatcher(handler=lambda n: None, max_concurrency=1)
    d.pause(reason="fatal_auth")
    assert d.is_paused is True
    assert d.pause_reason == "fatal_auth"


def test_pause_default_reason_is_none():
    d = Dispatcher(handler=lambda n: None, max_concurrency=1)
    d.pause()
    assert d.pause_reason is None
```

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/orchestrator/test_run_cli_fatal_auth_drain.py -v`
Expected: FAIL — `AttributeError: 'Dispatcher' object has no attribute 'pause_reason'`.

- [ ] **Step 3: Add `pause_reason` to the dispatcher**

In `claw_forge/orchestrator/dispatcher.py`, in `__init__` add `self.pause_reason: str | None = None`, then update `pause` (line 286) and `resume` (line 294):

```python
    def pause(self, reason: str | None = None) -> None:
        """Signal the dispatcher to drain: finish in-flight tasks, start no new ones."""
        logger.info("Dispatcher paused (%s) — draining in-flight tasks", reason or "operator")
        self._paused = True
        self.pause_reason = reason

    def resume(self) -> None:
        """Clear the paused flag and allow new waves to start."""
        logger.info("Dispatcher resumed — normal operation")
        self._paused = False
        self.pause_reason = None
```

Update the fatal-auth pause site at `dispatcher.py:449` from `self.pause()` to `self.pause(reason="fatal_auth")`.

- [ ] **Step 4: Halt the wave loop on fatal-auth pause in run_cli**

In `claw_forge/orchestrator/run_cli.py`, the `while current_nodes:` loop starts at line 1336 and the paused branch at line 1407. Immediately after `dispatch_result = await dispatcher.run()` and the merge-retry cleanup (after line 1359, before the `if not dispatcher.is_paused:` block at line 1361), add a fatal-auth short-circuit:

```python
            if dispatcher.is_paused and dispatcher.pause_reason == "fatal_auth":
                console.print(
                    "\n[bold red]⛔ Halting: provider pool exhausted on a fatal "
                    "auth error.[/bold red]\n"
                    "[yellow]Remaining tasks left pending — fix credentials "
                    "(check `claude login` / provider oauth_token / ANTHROPIC_* "
                    "env) and re-run `claw-forge run` to resume.[/yellow]"
                )
                break
```

This exits the `while current_nodes:` loop with tasks still `pending` in the DB, instead of falling into the Stop-All resume-poll that re-dispatches them into failure.

- [ ] **Step 5: Run the tests to verify they pass**

Run: `uv run pytest tests/orchestrator/test_run_cli_fatal_auth_drain.py tests/orchestrator/test_dispatcher.py -v`
Expected: PASS (existing dispatcher tests must stay green — `pause()` keeps its zero-arg call form).

- [ ] **Step 6: Lint + commit**

Run: `uv run ruff check claw_forge/ tests/`
```bash
git add claw_forge/orchestrator/dispatcher.py claw_forge/orchestrator/run_cli.py tests/orchestrator/test_run_cli_fatal_auth_drain.py
git commit -m "fix(orchestrator): halt run loop on fatal-auth drain instead of re-dispatching to failure"
```

---

### Task 3: Failed tasks are scheduler-"completed" — dependents dispatch against missing parent work

**Root cause (verified):** `dispatcher.py:465-466` records **any** dict-returning result as completed (`result.completed[task_id] = task_result or {}`; `self._scheduler.mark_completed(task_id)`) without inspecting `task_result["success"]`. The `task_handler` returns `{"success": False, "output": …}` on agent failure (`task_handler.py:1964-1968`), yet the scheduler node is marked completed, `are_dependencies_satisfied` passes, and dependents dispatch in the same run against a target lacking the parent's work. The documented "dependents become blocked on failure" invariant does not hold.

**Note on scope:** the fatal-auth (`dispatcher.py:440-449`) and deferred (`dispatcher.py:450-464`) branches already handle their special cases *before* line 465 and must be preserved. This task only changes the final `else` outcome.

**Files:**
- Modify: `claw_forge/orchestrator/dispatcher.py:465-469`
- Test: `tests/orchestrator/test_dispatcher_failed_result.py` (create)

**Interfaces:**
- Consumes: `Scheduler.mark_failed(task_id)` (exists, `scheduler.py:57`); `DispatchResult.failed: dict[str, str]` (exists, `dispatcher.py:197`).
- Produces: unchanged public surface; behaviour change only.

- [ ] **Step 1: Write the failing test**

```python
# tests/orchestrator/test_dispatcher_failed_result.py
import pytest
from claw_forge.orchestrator.dispatcher import Dispatcher
from claw_forge.state.scheduler import TaskNode


@pytest.mark.asyncio
async def test_dict_result_with_success_false_marks_failed_not_completed():
    async def handler(node):
        return {"success": False, "output": "agent gave up"}

    d = Dispatcher(handler=handler, max_concurrency=1)
    d.add_task(TaskNode(id="t1", description="x", depends_on=[]))
    result = await d.run()

    assert "t1" in result.failed
    assert "t1" not in result.completed
    assert d._scheduler.get_status("t1") == "failed"


@pytest.mark.asyncio
async def test_dict_result_with_success_true_still_completes():
    async def handler(node):
        return {"success": True, "output": "done"}

    d = Dispatcher(handler=handler, max_concurrency=1)
    d.add_task(TaskNode(id="t1", description="x", depends_on=[]))
    result = await d.run()

    assert "t1" in result.completed
    assert "t1" not in result.failed
```

(If `TaskNode`'s constructor differs, mirror the shape used in `tests/orchestrator/test_dispatcher.py` — read that file first for the exact fixture pattern and `get_status` accessor name.)

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/orchestrator/test_dispatcher_failed_result.py -v`
Expected: FAIL — `t1` appears in `completed`, not `failed`.

- [ ] **Step 3: Branch on `success` in the wave-result handler**

In `claw_forge/orchestrator/dispatcher.py`, replace lines 465-469 (the block after the `_is_deferred` `continue`):

```python
                        if (
                            isinstance(task_result, dict)
                            and task_result.get("success") is False
                        ):
                            result.failed[task_id] = str(
                                task_result.get("output", "task failed")
                            )
                            self._scheduler.mark_failed(task_id)
                        else:
                            result.completed[task_id] = task_result or {}
                            self._scheduler.mark_completed(task_id)
                            # Notify reviewer of feature completion
                            if self._reviewer is not None:
                                self._reviewer.notify_feature_completed()
```

- [ ] **Step 4: Run the tests to verify they pass**

Run: `uv run pytest tests/orchestrator/test_dispatcher_failed_result.py tests/orchestrator/test_dispatcher.py -v`
Expected: PASS. Watch existing dispatcher tests — some may have asserted the old (buggy) "failed dict lands in completed" behaviour; if any break, they were pinning the bug — update them to expect `failed` and note it in the commit body.

- [ ] **Step 5: Verify the run_cli re-dispatch mirror**

`select_redispatch_pending` (`run_cli.py`) selects DB-`status="pending"` rows and is already status-authoritative (`run_cli.py:1388-1397`), so a now-`failed` task will not be silently re-run as satisfied. Confirm no code marks the in-memory node `merged_to_target_branch=True` on the failed path. Run: `uv run pytest tests/orchestrator/ -q`
Expected: PASS.

- [ ] **Step 6: Lint + commit**

Run: `uv run ruff check claw_forge/ tests/`
```bash
git add claw_forge/orchestrator/dispatcher.py tests/orchestrator/test_dispatcher_failed_result.py
git commit -m "fix(dispatcher): mark success=False dict results as failed, not completed (merge-gating invariant)"
```

---

### Task 4 (Quick win): Fix the stale provider-rotation guard

**Root cause (verified):** `task_handler.py:1575` gates rotation on `sdk_provider_name != "env"`, but the `"env"` literal was split into five labels in v0.8.47 (`_NON_POOL_PROVIDER_LABELS`, `task_handler.py:347-353`), and the docstring at `:396-399` mandates checking membership in that frozenset. Under env/keychain credentials the guard is stale, producing a phantom "rotating…" attempt and a misleading "All 1 configured provider(s) tripped" message that masks the real error.

**Files:**
- Modify: `claw_forge/orchestrator/task_handler.py:1575`
- Test: `tests/orchestrator/test_rotation_guard.py` (create) — or add to an existing task_handler test module if one exists.

- [ ] **Step 1: Write the failing test** — assert that each label in `_NON_POOL_PROVIDER_LABELS` is treated as non-rotatable. Since the guard is inline, test the invariant directly:

```python
# tests/orchestrator/test_rotation_guard.py
from claw_forge.orchestrator import task_handler as th


def test_all_non_pool_labels_are_non_rotatable():
    for label in th._NON_POOL_PROVIDER_LABELS:
        # A provider whose name is a non-pool label must NOT be considered
        # a rotatable pool provider.
        assert label in th._NON_POOL_PROVIDER_LABELS
    # The stale literal must be a subset of the canonical set (regression pin
    # against reintroducing a bare `!= "env"` check).
    assert "env" not in th._NON_POOL_PROVIDER_LABELS  # the old label is gone
    assert th.ENV_OAUTH_LABEL == "env-oauth"
```

- [ ] **Step 2: Run test to verify it fails or passes** — this pins the constants; the behavioural fix is the guard. Run: `uv run pytest tests/orchestrator/test_rotation_guard.py -v` (Expected: PASS on the constants; proceed to fix the guard.)

- [ ] **Step 3: Fix the guard** — in `task_handler.py:1573-1577`, change:

```python
                        _can_rotate = (
                            ctx.pool is not None
                            and sdk_provider_name not in _NON_POOL_PROVIDER_LABELS
                            and _retryable
                            and _rotation < _max_provider_rotations
                        )
```

- [ ] **Step 4: Run the suite for the module** — Run: `uv run pytest tests/orchestrator/ -q`  Expected: PASS.

- [ ] **Step 5: Lint + commit**

Run: `uv run ruff check claw_forge/ tests/`
```bash
git add claw_forge/orchestrator/task_handler.py tests/orchestrator/test_rotation_guard.py
git commit -m "fix(pool): use _NON_POOL_PROVIDER_LABELS for rotation guard (drop stale != 'env')"
```

---

### Task 5 (Quick win): Branch/worktree slug uniqueness via task_id

**Root cause (verified):** `make_branch_name` (`slug.py:43`) and `create_worktree`/`create_feature_branch` (`branching.py:95,125`) accept `task_id` but never fold it into the name — the branch is `{prefix}/{cat}-{desc}` and the worktree dir is keyed on `slug` alone (`branching.py:147`). Two same-category tasks with colliding truncated descriptions share one branch **and one worktree** (two agents in one tree, or a force-remove under a live agent). `cleanup.py:_build_slug_to_task_map` (`cleanup.py:437`) also silently overwrites on collision.

**Files:**
- Modify: `claw_forge/git/slug.py` (`make_branch_name` gains an optional `task_id`)
- Modify call sites: `claw_forge/orchestrator/task_handler.py:750`, `claw_forge/git/cleanup.py:437`
- Test: `tests/git/test_slug_uniqueness.py` (create)

**Interfaces:**
- Produces: `make_branch_name(description, category, plugin_name="feat", *, prefix="feat", max_len=55, task_id: str | None = None)` — when `task_id` is given, append `-{task_id[:6]}` to the slug (inside `max_len` budget). Backward compatible: `task_id=None` reproduces today's output.

- [ ] **Step 1: Write the failing test**

```python
# tests/git/test_slug_uniqueness.py
from claw_forge.git.slug import make_branch_name


def test_task_id_disambiguates_colliding_descriptions():
    a = make_branch_name("Add user login", "auth", task_id="abc123def")
    b = make_branch_name("Add user login flow variant", "auth", task_id="zzz999yyy")
    assert a != b
    assert a.endswith("-abc123")
    assert b.endswith("-zzz999")


def test_no_task_id_is_backward_compatible():
    assert make_branch_name("Add JWT authentication", "auth") == "feat/auth-jwt-authentication"
```

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/git/test_slug_uniqueness.py -v`
Expected: FAIL — `TypeError: unexpected keyword argument 'task_id'`.

- [ ] **Step 3: Add the `task_id` suffix**

In `claw_forge/git/slug.py`, extend `make_branch_name`:

```python
def make_branch_name(
    description: str | None,
    category: str | None,
    plugin_name: str = "feat",
    *,
    prefix: str = "feat",
    max_len: int = 55,
    task_id: str | None = None,
) -> str:
    cat_raw = category or plugin_name or "feat"
    cat_slug = _NON_ALNUM.sub("-", cat_raw.lower()).strip("-")[:15]

    suffix = f"-{task_id[:6]}" if task_id else ""
    desc_budget = max(10, max_len - len(cat_slug) - 1 - len(suffix))
    desc_slug = make_slug(description, max_len=desc_budget) if description else ""

    if desc_slug:
        return f"{prefix}/{cat_slug}-{desc_slug}{suffix}"
    return f"{prefix}/{cat_slug}{suffix}"
```

- [ ] **Step 4: Thread `task_id` through the call sites**

`task_handler.py:750` — add `task_id=task_node.id` to the `_make_branch_name(...)` call (the `.removeprefix(...)` on the result is unaffected).
`cleanup.py:437` — the replay must match, so add `task_id=<task's id column>` there too; read `cleanup.py:430-440` for the row variable name and pass its id. (If cleanup replays over tasks whose id is available in the same dict, use it; otherwise this is where the silent-overwrite map should key on the new unique slug.)

- [ ] **Step 5: Run the tests to verify they pass**

Run: `uv run pytest tests/git/test_slug_uniqueness.py tests/git/ -q`
Expected: PASS. If any existing branching/cleanup test asserted the old non-unique slug, update it.

- [ ] **Step 6: Lint + commit**

Run: `uv run ruff check claw_forge/ tests/`
```bash
git add claw_forge/git/slug.py claw_forge/orchestrator/task_handler.py claw_forge/git/cleanup.py tests/git/test_slug_uniqueness.py
git commit -m "fix(git): fold task_id into branch/worktree slug to prevent collisions"
```

---

### Task 6 (Quick win): Restore the dedicated Ollama provider

**Root cause (verified):** `registry.py:143` maps `"ollama": "openai_compat"` in `_TYPE_ALIASES`, so `type: ollama` in a config resolves to `openai_compat` and the dedicated `OllamaProvider` (`pool/providers/ollama.py:23`, wired at `registry.py:22`) is unreachable — losing its localhost default, model fallbacks, and `ollama pull` guidance. `ProviderType.OLLAMA = "ollama"` already exists (`base.py:21`).

**Files:**
- Modify: `claw_forge/pool/providers/registry.py:143`
- Test: `tests/pool/test_ollama_alias.py` (create)

- [ ] **Step 1: Write the failing test**

```python
# tests/pool/test_ollama_alias.py
from claw_forge.pool.providers.registry import load_configs_from_yaml
from claw_forge.pool.providers.base import ProviderType


def test_ollama_type_resolves_to_ollama_provider():
    cfgs = load_configs_from_yaml({"providers": {"local": {"type": "ollama"}}})
    assert len(cfgs) == 1
    assert cfgs[0].provider_type == ProviderType.OLLAMA
```

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/pool/test_ollama_alias.py -v`
Expected: FAIL — resolves to `ProviderType.OPENAI_COMPAT`.

- [ ] **Step 3: Delete the alias**

In `claw_forge/pool/providers/registry.py`, remove the line `"ollama": "openai_compat",  # Ollama speaks OpenAI API` from `_TYPE_ALIASES` (line 143). `ProviderType("ollama")` now resolves directly.

- [ ] **Step 4: Run the tests to verify they pass**

Run: `uv run pytest tests/pool/test_ollama_alias.py tests/pool/ -q`
Expected: PASS.

- [ ] **Step 5: Lint + commit**

Run: `uv run ruff check claw_forge/ tests/`
```bash
git add claw_forge/pool/providers/registry.py tests/pool/test_ollama_alias.py
git commit -m "fix(pool): route type: ollama to the dedicated OllamaProvider (drop openai_compat alias)"
```

---

### Task 7 (Quick win): `claw-forge ui --host` defaulting to loopback

**Root cause (verified):** `ui_cli.py:324` hardcodes `uvicorn.run(static_app, host="0.0.0.0", …)` with no option. The UI proxy injects the bearer token server-side (`ui_cli.py:257-261`), so a bound-to-all-interfaces UI hands any LAN peer authenticated access to mutating routes. CLAUDE.md says "keep it localhost-bound"; the code offers no way. (The *state service* default stays `0.0.0.0` — the shared-box workflow depends on it; this changes only the token-injecting UI proxy.)

**Files:**
- Modify: `claw_forge/ui_cli.py` (add `--host` option to `ui`, thread to `uvicorn.run`)
- Test: `tests/test_ui_cli_host.py` (create) — assert the Typer option exists and defaults to `127.0.0.1`.

- [ ] **Step 1: Write the failing test**

```python
# tests/test_ui_cli_host.py
import inspect
from claw_forge import ui_cli


def test_ui_has_host_option_defaulting_to_loopback():
    sig = inspect.signature(ui_cli.ui)
    assert "host" in sig.parameters
    default = sig.parameters["host"].default
    # Typer OptionInfo carries the default value
    assert getattr(default, "default", default) == "127.0.0.1"
```

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/test_ui_cli_host.py -v`
Expected: FAIL — no `host` parameter.

- [ ] **Step 3: Add the option and thread it through**

In `claw_forge/ui_cli.py`, add to the `ui(...)` signature (after `state_port`, ~line 51):

```python
    host: str = typer.Option(
        "127.0.0.1",
        "--host",
        help="Interface to bind the UI server to (default loopback; the UI "
             "proxy holds the state token, so avoid 0.0.0.0 on shared hosts)",
    ),
```

Change the prod-serve line (currently `ui_cli.py:324`) to:

```python
        uvicorn.run(static_app, host=host, port=port, log_level="warning")
```

Keep the `# noqa: S104` only if `host` can still be `0.0.0.0`; since the default is now loopback and `0.0.0.0` is an explicit opt-in, replace the blanket noqa with a targeted one or drop it — run ruff to see which it wants.

- [ ] **Step 4: Run the tests to verify they pass**

Run: `uv run pytest tests/test_ui_cli_host.py -v`
Expected: PASS.

- [ ] **Step 5: Lint + commit**

Run: `uv run ruff check claw_forge/ tests/`
```bash
git add claw_forge/ui_cli.py tests/test_ui_cli_host.py
git commit -m "fix(ui): add --host defaulting to 127.0.0.1 (token-injecting proxy shouldn't bind 0.0.0.0)"
```

---

### Task 8 (Quick win): Acceptance-gate process-tree kill

**Root cause (verified):** `acceptance.py:102-116` spawns the test command via `create_subprocess_shell` (a `/bin/sh -c` wrapper) and on timeout calls `proc.kill()` — which signals only the shell, orphaning a hung `pytest` that keeps holding the worktree's files. The docstring at `acceptance.py:96-99` already *claims* it "kills the process tree"; the code doesn't.

**Files:**
- Modify: `claw_forge/orchestrator/acceptance.py:91-120`
- Test: `tests/orchestrator/test_acceptance_killpg.py` (create)

- [ ] **Step 1: Write the failing test**

```python
# tests/orchestrator/test_acceptance_killpg.py
import time
import pytest
from pathlib import Path
from claw_forge.orchestrator.acceptance import run_test_command


@pytest.mark.asyncio
async def test_timeout_kills_child_process_tree(tmp_path: Path):
    # A shell that spawns a long-lived child; on timeout the whole group
    # must die. We assert the call returns the timeout sentinel quickly.
    start = time.monotonic()
    exit_code, out = await run_test_command(
        tmp_path, "sleep 30 & wait", timeout=1.0,
    )
    elapsed = time.monotonic() - start
    assert elapsed < 5.0  # did not wait for the 30s child
    assert "timed out" in out
```

- [ ] **Step 2: Run test to verify it fails or is flaky**

Run: `uv run pytest tests/orchestrator/test_acceptance_killpg.py -v`
Expected: FAIL/hang — the orphaned `sleep` isn't reaped via the shell-only `kill`.

- [ ] **Step 3: Use a new session + `killpg`**

In `claw_forge/orchestrator/acceptance.py`, add `import os` and `import signal` at top if absent, then:

```python
        proc = await asyncio.create_subprocess_shell(
            command,
            cwd=str(cwd),
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.STDOUT,
            start_new_session=True,  # own process group → killpg reaches children
        )
```

and in the timeout handler:

```python
    except TimeoutError:
        with contextlib.suppress(ProcessLookupError, OSError):
            os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
        with contextlib.suppress(Exception):
            await proc.wait()  # reap
        return (_TIMEOUT_EXIT, f"acceptance tests timed out after {timeout}s")
```

- [ ] **Step 4: Run the tests to verify they pass**

Run: `uv run pytest tests/orchestrator/test_acceptance_killpg.py tests/orchestrator/ -q`
Expected: PASS.

- [ ] **Step 5: Lint + commit**

Run: `uv run ruff check claw_forge/ tests/`
```bash
git add claw_forge/orchestrator/acceptance.py tests/orchestrator/test_acceptance_killpg.py
git commit -m "fix(acceptance): kill the whole process group on gate timeout (reap orphaned pytest)"
```

---

## Phase B — Security-layer restoration

> **Sequencing:** Task 9 MUST land before Task 10. Flipping `permission_mode` off `bypassPermissions` while `ALWAYS_BLOCK` still substring-matches would deny `git add` (contains `dd`? no — but `su` matches `subdir`, `mkdir subdir`, `sudo`-free `su`-containing words) on nearly every task.

### Task 9: Tokenize `ALWAYS_BLOCK` matching (prerequisite)

**Root cause (verified):** Both permission callbacks match blocked commands as raw substrings: `smart_can_use_tool` at `permissions.py:358-359` (`if blocked in cmd`) and the `make_can_use_tool` inner callback at `permissions.py:455-456` (`if pattern in cmd`). Single-token entries like `su`, `dd` therefore match inside benign words (`mkdir subdir` contains `su`; `git add` is safe but `add` isn't blocked — the real hazards are `su`→`subdir`/`sudo`-messages, `dd`→`add`/`address`). The module comment at `permissions.py:31-34` even acknowledges the false-positive risk. The existing `shlex`/`_SHELL_SPLIT_RE` machinery (`permissions.py:82`) already tokenizes for the jail check — reuse it.

**Files:**
- Modify: `claw_forge/agent/permissions.py` (add a `_command_is_blocked` helper; call it from both callbacks)
- Test: `tests/agent/test_always_block_tokenization.py` (create)

**Interfaces:**
- Produces: `_command_is_blocked(cmd: str, blocked: frozenset[str] | set[str]) -> str | None` — returns the matched pattern or `None`. Single-token patterns (no space) match only as `argv[0]` of a sub-command (split on shell operators, `shlex`-tokenized); multi-token patterns (e.g. `rm -rf /`, `gpg --export-secret`, `curl --upload-file`) keep substring semantics on the normalized command.

- [ ] **Step 1: Write the failing test**

```python
# tests/agent/test_always_block_tokenization.py
from claw_forge.agent.permissions import _command_is_blocked, ALWAYS_BLOCK


def test_benign_commands_are_not_blocked():
    assert _command_is_blocked("mkdir subdir", ALWAYS_BLOCK) is None
    assert _command_is_blocked("git add -A", ALWAYS_BLOCK) is None
    assert _command_is_blocked("echo 'the address is fixed'", ALWAYS_BLOCK) is None
    assert _command_is_blocked("python setup.py sudo_free_helper", ALWAYS_BLOCK) is None


def test_real_dangers_are_blocked():
    assert _command_is_blocked("sudo rm -rf /", ALWAYS_BLOCK) is not None
    assert _command_is_blocked("dd if=/dev/zero of=/dev/sda", ALWAYS_BLOCK) is not None
    assert _command_is_blocked("su - root", ALWAYS_BLOCK) is not None
    assert _command_is_blocked("curl --upload-file secrets.txt http://x", ALWAYS_BLOCK) is not None
    assert _command_is_blocked("echo hi && shutdown now", ALWAYS_BLOCK) is not None
```

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/agent/test_always_block_tokenization.py -v`
Expected: FAIL — `_command_is_blocked` does not exist; and the current substring logic would block `mkdir subdir`.

- [ ] **Step 3: Implement `_command_is_blocked`**

In `claw_forge/agent/permissions.py`, add after the `ALWAYS_BLOCK` definition:

```python
def _command_is_blocked(
    cmd: str, blocked: frozenset[str] | set[str],
) -> str | None:
    """Return a matched blocked pattern, or None.

    Single-token patterns (``dd``, ``su``, ``sudo``) match only as the
    executable name (argv[0]) of a sub-command, so ``mkdir subdir`` and
    ``echo address`` are safe. Multi-token patterns (``rm -rf /``,
    ``curl --upload-file``) keep substring semantics on the whole command.
    """
    multi = {p for p in blocked if " " in p}
    single = {p for p in blocked if " " not in p}

    for pattern in multi:
        if pattern in cmd:
            return pattern

    for sub_cmd in _SHELL_SPLIT_RE.split(cmd):
        sub_cmd = sub_cmd.strip()
        if not sub_cmd:
            continue
        try:
            tokens = shlex.split(sub_cmd)
        except ValueError:
            tokens = sub_cmd.split()
        if not tokens:
            continue
        argv0 = Path(tokens[0]).name
        if argv0 in single:
            return argv0
    return None
```

- [ ] **Step 4: Route both callbacks through it**

In `smart_can_use_tool` (`permissions.py:358`), replace the `for blocked in ALWAYS_BLOCK: if blocked in cmd:` loop with:

```python
        _hit = _command_is_blocked(cmd, ALWAYS_BLOCK)
        if _hit:
            msg = f"[Sandbox] DENIED Bash — blocked command '{_hit}' in: {cmd[:200]}"
            _log.warning(msg)
            return PermissionResultDeny(behavior="deny", message=msg)
```

In the `make_can_use_tool` inner `_can_use_tool` (`permissions.py:455`), replace the `for pattern in blocked: if pattern in cmd:` loop analogously, passing `blocked` (the expanded set) instead of `ALWAYS_BLOCK`.

- [ ] **Step 5: Run the tests to verify they pass**

Run: `uv run pytest tests/agent/test_always_block_tokenization.py tests/test_permissions.py -v`
Expected: PASS in isolation. If a legacy `test_permissions.py` case asserted a substring false-positive (e.g. expected `mkdir subdir` denied), it was pinning the bug — fix it and note in the commit body.

- [ ] **Step 6: Lint + commit**

Run: `uv run ruff check claw_forge/ tests/`
```bash
git add claw_forge/agent/permissions.py tests/agent/test_always_block_tokenization.py
git commit -m "fix(permissions): tokenize ALWAYS_BLOCK matching (single-token = argv0, multi-token = substring)"
```

---

### Task 10: Restore `can_use_tool` enforcement on the dispatch path

**Root cause (verified):** `task_handler.py:1379` (and the rotation-retry copy at `:1663`) set `permission_mode="bypassPermissions"`. Under that mode the SDK never invokes `can_use_tool` (it's still passed at `:1398`, but dead). Layers 1–2 of the six-layer defense — file-tool path sandbox, Bash path checks, exempt-command jail check, and the now-fixed dangerous-command blocklist — never fire in production. OS sandbox (Layer 4) still blocks *writes*, but dangerous non-write commands (`sudo`, `curl --upload-file` exfil) are unblocked.

**Prerequisite:** Task 9 merged (otherwise this denies benign commands).

**Files:**
- Modify: `claw_forge/orchestrator/task_handler.py:1379` and `:1663` (`bypassPermissions` → `"default"`)
- Test: `tests/orchestrator/test_dispatch_permission_mode.py` (create) + a callback round-trip smoke test.

**Interfaces:**
- Consumes: the `make_can_use_tool(...)` callback already constructed at `:1398`. In `"default"` mode the SDK calls it for every tool; the callback returns `PermissionResultAllow` for everything it does not explicitly deny, so non-dangerous work is unaffected.

- [ ] **Step 1: Write the failing test**

The mode is a literal in option assembly. Assert both option-construction sites use `"default"`, and add a callback round-trip test proving a dangerous command is denied and a benign one allowed:

```python
# tests/orchestrator/test_dispatch_permission_mode.py
import inspect
from claw_forge.orchestrator import task_handler as th
from claw_forge.agent.permissions import make_can_use_tool


def test_dispatch_does_not_use_bypass_permissions():
    src = inspect.getsource(th)
    assert 'permission_mode="bypassPermissions"' not in src
    assert src.count('permission_mode="default"') >= 2


import pytest
from pathlib import Path


@pytest.mark.asyncio
async def test_callback_denies_sudo_allows_ls(tmp_path: Path):
    cb = make_can_use_tool(project_dir=tmp_path)

    class _Ctx:  # minimal ToolPermissionContext stand-in
        pass

    deny = await cb("Bash", {"command": "sudo rm -rf /"}, _Ctx())
    assert deny.behavior == "deny"
    allow = await cb("Bash", {"command": "ls -la"}, _Ctx())
    assert allow.behavior == "allow"
```

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/orchestrator/test_dispatch_permission_mode.py -v`
Expected: FAIL — source still contains `bypassPermissions`.

- [ ] **Step 3: Flip the mode at both sites**

In `claw_forge/orchestrator/task_handler.py`, change line 1379 and line 1663 from `permission_mode="bypassPermissions",` to `permission_mode="default",`.

**Verify the nested-sandbox matrix is untouched:** this change does not alter `_make_sdk_sandbox_settings(isolation_active=…)` (`:1370`), so Layer-4/5 nesting rules (`memory/project_nested_sandbox_landmine.md`) are unaffected — `"default"` governs the SDK's *permission callback*, not its `SandboxSettings`.

- [ ] **Step 4: Run the tests to verify they pass**

Run: `uv run pytest tests/orchestrator/test_dispatch_permission_mode.py tests/orchestrator/ -q`
Expected: PASS.

- [ ] **Step 5: Manual smoke sanity (optional but recommended)**

Because `"default"` mode can prompt for tools the callback neither allows nor denies, confirm the callback's final `return PermissionResultAllow(...)` (`permissions.py:401` and the `_can_use_tool` tail) is the catch-all so no task stalls awaiting an interactive prompt. Grep: `uv run pytest tests/agent/test_permissions.py -q` in isolation.
Expected: PASS.

- [ ] **Step 6: Lint + commit**

Run: `uv run ruff check claw_forge/ tests/`
```bash
git add claw_forge/orchestrator/task_handler.py tests/orchestrator/test_dispatch_permission_mode.py
git commit -m "fix(security): enforce can_use_tool on dispatch path (default mode, not bypassPermissions)"
```

---

### Task 11: Regression guard against silent bypass reintroduction

**Rationale:** The bypass regression was invisible for many releases. Add a cheap static guard so it can't silently return.

**Files:**
- Test: `tests/orchestrator/test_no_bypass_permissions_regression.py` (create)

- [ ] **Step 1: Write the guard test**

```python
# tests/orchestrator/test_no_bypass_permissions_regression.py
from pathlib import Path
import claw_forge


def test_no_bypass_permissions_in_dispatch_source():
    root = Path(claw_forge.__file__).parent
    th = (root / "orchestrator" / "task_handler.py").read_text()
    assert "bypassPermissions" not in th, (
        "dispatch path must not use bypassPermissions — it disables can_use_tool"
    )
```

- [ ] **Step 2: Run to verify it passes** (Task 10 already removed the string).

Run: `uv run pytest tests/orchestrator/test_no_bypass_permissions_regression.py -v`
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add tests/orchestrator/test_no_bypass_permissions_regression.py
git commit -m "test(security): guard against bypassPermissions reintroduction on dispatch path"
```

---

### Task 12: Documentation reconciliation

**Rationale:** CLAUDE.md's "six-layer defense" section presents Layers 1–2 as active; they were dead. After Tasks 9–11 they are live again — but the docs and any memory note should reflect the fix's provenance (per `memory/` conventions and `feedback_plans_must_include_docs.md`, doc updates ship *with* the change, not deferred).

**Files:**
- Modify: `CLAUDE.md` (the "Worktree-Leakage Defenses" / permission-mode note, if it references the mode)
- Create: `memory/project_dispatch_permission_mode_restored.md` + index line in `MEMORY.md`

- [ ] **Step 1: Update CLAUDE.md** — if any line states or implies the dispatch path uses `bypassPermissions`, correct it to `"default"` and note that `can_use_tool` (Layers 1–2) is enforced. Do not restate git-history forensics in CLAUDE.md (that belongs in memory/).

- [ ] **Step 2: Write the memory note**

```markdown
---
name: project_dispatch_permission_mode_restored
description: Dispatch path enforces can_use_tool via permission_mode=default; bypassPermissions disabled Layers 1-2 for many releases
metadata:
  type: project
---

The dispatcher (`task_handler.py`) used `permission_mode="bypassPermissions"` at
the two ClaudeAgentOptions sites, under which the SDK never invokes
`can_use_tool` — silently disabling Layers 1–2 of the six-layer worktree-leak
defense (file-tool path sandbox, Bash path checks, exempt-command jail check,
ALWAYS_BLOCK dangerous-command blocklist). OS sandbox (Layer 4) still blocked
writes, so the gap was write-safe but allowed dangerous non-write commands
(`sudo`, `curl --upload-file` exfil).

**Fix (2026-07):** tokenized `ALWAYS_BLOCK` matching first ([[...]]) so single-token
patterns match only as argv0 (else `mkdir subdir` → blocked on `su`), then flipped
both sites to `permission_mode="default"`. A source-level regression test now
guards against reintroducing `bypassPermissions`.

**How to apply:** any new dispatch-path ClaudeAgentOptions must use `"default"`,
not `"bypassPermissions"`. See CLAUDE.md "Cross-layer rule — nested sandbox";
this change does not touch SandboxSettings, so the nesting matrix is unaffected.
```

Add to `MEMORY.md`: `- [Dispatch path enforces can_use_tool (not bypassPermissions)](project_dispatch_permission_mode_restored.md) — Layers 1-2 were dead for releases; default mode restored 2026-07`

- [ ] **Step 3: Commit**

```bash
git add CLAUDE.md memory/project_dispatch_permission_mode_restored.md memory/MEMORY.md
git commit -m "docs: reconcile six-layer defense docs with restored can_use_tool enforcement"
```

---

## Self-Review

**Spec coverage:** Phase A covers the audit's top-3 correctness fixes (Tasks 1–3) + all five quick wins (Tasks 4–8: rotation guard, slug uniqueness, Ollama alias, UI host, gate killpg). Phase B covers security restoration (Tasks 9–12: ALWAYS_BLOCK tokenization → default mode → regression guard → docs). The cost/token/circuit wiring (audit #5) and per-task ceilings (#7), SDK session resume (#8), migration reconciler (#9), and egress default-deny (#10) are **intentionally deferred** — they are medium/large and warrant their own plan; this plan is the "stop the bleeding" batch. Flag to the human: schedule a follow-up plan for those.

**Placeholder scan:** every code step shows concrete code; test steps show full test bodies; commands are exact `uv run …` invocations. One soft spot: Task 5 Step 4 and Task 3 Step 1 tell the implementer to *read* the neighbouring file (`cleanup.py:430-440`, `tests/orchestrator/test_dispatcher.py`) for the exact row-variable / fixture names — this is deliberate (those names weren't captured at plan time) and bounded to a single read.

**Type consistency:** `make_branch_name` gains `task_id: str | None = None` (Task 5) consistently across `slug.py`, `task_handler.py:750`, `cleanup.py:437`. `Dispatcher.pause(reason=None)` and `pause_reason` (Task 2) are used consistently in `dispatcher.py` and read in `run_cli.py`. `_command_is_blocked` (Task 9) has one signature, called from both permission callbacks. `permission_mode="default"` is the single literal both dispatch sites (Task 10) and the regression guard (Task 11) reference.
