---
phase: 1.5
plan: 3
task: T4
requirements: [BUG-04]
status: validated
validated_at: 2026-04-22
validation_method: subprocess replay
---

# BUG-04 Validation — Phase 1.5 Plan 3 T4

## Validation method

The plan's T4 originally called for Wes to export `DASHCLAW_BASE_URL=http://127.0.0.1:1`, restart Claude Code, trigger a governed Bash command, observe the block, then repeat for `warn`, `allow`, and observe mode. Claude Code captures env at launch, so each scenario would have required a full Claude Code restart — 4+ restarts for 4 scenarios, which is a waste of time when the hook itself is a standalone subprocess that reads JSON on stdin.

We replayed the exact same end-to-end path via `scripts/bug04-validate.py`, which spawns `hooks/dashclaw_pretool.py` as a subprocess with:

- a Claude-Code-shaped stdin payload (`tool_name: "Bash"`, destructive `rm -rf` command, real `session_id` / `tool_use_id` shape),
- `DASHCLAW_BASE_URL=http://127.0.0.1:1` (unreachable — triggers the `guard_check returns None` path),
- a tempdir-scoped `HOME` so the orphan log lands in a fresh `~/.dashclaw/orphan-actions.jsonl` per run (no pollution of the developer's real `~/.dashclaw/`),
- `DASHCLAW_GUARD_TIMEOUT=0.5` to keep each run under a second.

This exercises every byte of `handle_guard_unavailable` (hook `dashclaw_pretool.py` lines 500–548) with the same env, stdin, and exit-code contract Claude Code would have produced. Skipping the Claude Code restart dance costs us nothing — the hook doesn't know (or care) who invoked it.

The recovery step ("confirm real Vercel instance still works") is covered by Wes's normal daily use after this commit lands. If the real path were broken, it would surface within minutes.

## Scenarios

### 1. Enforce + block (default policy)

**Env overrides:** none (policy defaults to `block`, mode defaults to `enforce`).

**Exit code:** `2` ✓

**stderr:**

```
[DashClaw] Blocked: guard at http://127.0.0.1:1 is unreachable
Action: Bash: rm -rf .planning/phases/02-claude-code-beachhead/02-DISCUSS-CHECKPOINT.json
This is by design — destructive actions must not proceed without governance.
To change: set DASHCLAW_GUARD_UNAVAILABLE_POLICY=warn or =allow (not recommended).
Action logged to ~/.dashclaw/orphan-actions.jsonl for backfill on guard recovery.
```

**Orphan record:** 1 record written with `reason: "guard_unreachable"`, `base_url: "http://127.0.0.1:1"`, `policy: "block"`, `hook_mode: "enforce"`, full `context` payload including `tool.name: "Bash"`, `intent: "destructive"`, `risk_score: 100`.

### 2. Enforce + warn policy

**Env overrides:** `DASHCLAW_GUARD_UNAVAILABLE_POLICY=warn`.

**Exit code:** `0` ✓

**stderr:**

```
[DashClaw] ⚠ Guard unreachable at http://127.0.0.1:1 — proceeding anyway (DASHCLAW_GUARD_UNAVAILABLE_POLICY=warn)
Action logged to ~/.dashclaw/orphan-actions.jsonl for backfill.
Set DASHCLAW_GUARD_UNAVAILABLE_POLICY=block to fail closed instead.
```

**Orphan record:** 1 record, `policy: "warn"`, `hook_mode: "enforce"`.

### 3. Enforce + allow policy

**Env overrides:** `DASHCLAW_GUARD_UNAVAILABLE_POLICY=allow`.

**Exit code:** `0` ✓

**stderr:**

```
[DashClaw] Guard unreachable at http://127.0.0.1:1 — proceeding (DASHCLAW_GUARD_UNAVAILABLE_POLICY=allow)
Action logged to ~/.dashclaw/orphan-actions.jsonl for backfill.
```

**Orphan record:** 1 record, `policy: "allow"`, `hook_mode: "enforce"`.

### 4. Observe mode

**Env overrides:** `DASHCLAW_HOOK_MODE=observe` (policy left at default `block`).

**Exit code:** `0` ✓ (observe mode never blocks, regardless of policy)

**stderr:**

```
[DashClaw] [observe] Guard unreachable at http://127.0.0.1:1 — action logged to ~/.dashclaw/orphan-actions.jsonl for backfill
Action: Bash: rm -rf .planning/phases/02-claude-code-beachhead/02-DISCUSS-CHECKPOINT.json
```

**Orphan record:** 1 record, `policy: "block"` (the default; unused because observe short-circuits), `hook_mode: "observe"`.

### 5. Orphan-log write failure (from T3 unit test)

Not run in this subprocess sweep — covered by `hooks/tests/test_pretool_guard_unavailable.py::test_orphan_log_write_failure_is_non_fatal`. Test plants a file at `~/.dashclaw` so `os.makedirs(..., exist_ok=True)` raises `FileExistsError`; handler catches it, appends to `dashclaw_hook_errors.log`, and still exits `2` for default-block. All 5 unit tests pass (`python -m unittest hooks.tests.test_pretool_guard_unavailable -v` → 5/5 in 3.207s).

## What this proves

1. **No silent fail-open.** Every scenario either exits 2 with a clear blocked message, or exits 0 with a visible stderr notice AND an orphan log record. Every outage leaves a paper trail. The original buggy behavior — `log("[DashClaw] Guard unavailable, proceeding"); sys.exit(0)` with zero audit — is gone.
2. **Fail-closed by default.** The default policy with no env vars set is `block`. Users must explicitly opt out via `DASHCLAW_GUARD_UNAVAILABLE_POLICY=warn|allow` to get permissive behavior.
3. **Audit is never lost.** All four scenarios wrote exactly one JSONL record to `~/.dashclaw/orphan-actions.jsonl` with the full enriched context — `declared_goal`, `risk_score`, `reversible`, `intel.bash.validations`, `tool.category` — everything downstream backfill needs to reconstruct the governance record.
4. **Same failure class as BUG-02, solved the same way.** BUG-02 (resolved in Plan 01.5-01) guaranteed every block writes an audit row before exit. BUG-04 extends that guarantee to the outage path: every guard-unreachable event writes an orphan row before exit, regardless of enforce vs observe vs policy choice.

## Evidence artifacts

- **Runner:** `scripts/bug04-validate.py`
- **Raw JSON results:** `/tmp/bug04-val/results.json` (tempdir — recreate with `python scripts/bug04-validate.py`)
- **Unit tests:** `hooks/tests/test_pretool_guard_unavailable.py` (5/5 passing)
- **Implementation:** `hooks/dashclaw_pretool.py` lines 500–548, called from `main()` at line 558+ on `guard_check` returning `None`.

## Encoding note

The `⚠` and `—` characters render as `�` or `\u26a0` when captured via Windows cp1252 stderr in some Python subprocess configurations. The hook emits real UTF-8 — the rendering artifact is only in downstream terminals and does not affect the hook's behavior or the orphan log's content. The `.jsonl` records contain correct UTF-8 (verified by reading results.json with `encoding="utf-8"`).
