---
phase: 1.5
plan: 3
subsystem: governance-runtime-bugfix
tags: [hook, audit, fail-closed, observability, orphan-log, BUG-04]
requirements: [BUG-04]
wave: 3
depends_on: ["01.5-02"]
files_modified:
  - hooks/dashclaw_pretool.py
  - .claude/hooks/dashclaw_pretool.py
  - hooks/tests/test_pretool_guard_unavailable.py
  - .env.example
  - CHANGELOG.md
autonomous: true
---

# Phase 1.5 Plan 3: Hook Fail-Open Bugfix — BUG-04

<objective>
Fix BUG-04: `hooks/dashclaw_pretool.py` silently exits 0 when `/api/guard` is unreachable, producing a single `[DashClaw] Guard unavailable, proceeding` stderr line and no audit record. This is **structurally the same failure class as BUG-02** — a governance decision made without an audit trail — and directly contradicts DashClaw's core value prop from `PROJECT.md`: *"Your coding agent can never surprise you with a destructive action, and you can always prove what it did."*

Surfaced 2026-04-22 during Phase 2 CONTEXT-gathering. A diagnosis session revealed that Wes's Claude Code hook had been silently routed to a local `dashclaw-demo` Docker container for ~30 minutes due to a stale `DASHCLAW_BASE_URL=http://localhost:3000` env var overriding the `.env` value. That specific misroute happened to produce a `"Demo Production Guard"` block (demo-mode fixtures), but the same stale env var could have easily pointed at a dead or unreachable instance — in which case every governed tool call would have proceeded **unaudited** for the entire session.

BUG-02 (fixed 2026-04-11 in Plan 01.5-01) solved the same class of bug on the block path: governance decided to block, but no audit record was written, so blocks disappeared into stderr. BUG-04 is the **outage path** version of the same bug: governance was unreachable, so no audit record was written, and the tool proceeded anyway. Both failures are silent, both are permissive, and both break the "always prove what it did" promise.

BUG-04 is therefore a blocker for Phase 2 launch on the same rationale BUG-02 was: you cannot credibly ship "the control plane for coding agents" on a control plane that silently lets destructive actions through during any network hiccup, with no audit record of what was allowed. This is Wave 3 of Phase 1.5 (after BUG-03 in Wave 2) because BUG-03 affects the founder's ability to test the fix — a promoted admin is needed to verify the orphan-log backfill flow in the /decisions UI.
</objective>

<requirements>
- **BUG-04**: `hooks/dashclaw_pretool.py:557-560` must not silently exit 0 when `/api/guard` is unreachable. In enforce mode (default), the hook must fail closed (block the tool, exit 2). In observe mode, the hook must write the action to a local orphan log so the audit record is recoverable on the next successful guard call. A new env var `DASHCLAW_GUARD_UNAVAILABLE_POLICY=block|warn|allow` (default `block`) governs the enforce-mode behavior so advanced users can opt out explicitly. A regression test must cover the unreachable-guard scenario for both enforce and observe modes.
</requirements>

<tasks>

<task id="01.5-03-T1">
<name>Replace silent fail-open with enforce/observe-aware handler in pretool.py</name>
<action>
Modify `hooks/dashclaw_pretool.py` to replace the silent fail-open at lines 557-560:

```python
# CURRENT (buggy):
guard_resp = guard_check(context)
if guard_resp is None:
    log("[DashClaw] Guard unavailable, proceeding")
    sys.exit(0)
```

with a mode-aware handler that reads a new env var `DASHCLAW_GUARD_UNAVAILABLE_POLICY` (values: `block`, `warn`, `allow`; default `block`):

**Required code changes:**

1. **Add a new constant at the top-level config block (near `HOOK_MODE`, around line 76):**

```python
GUARD_UNAVAILABLE_POLICY = (os.environ.get("DASHCLAW_GUARD_UNAVAILABLE_POLICY") or "block").lower()
```

2. **Add a new helper function `handle_guard_unavailable(context, tool_use_id)` in the "Decision handlers" section (after `handle_require_approval`, around line 497):**

```python
def handle_guard_unavailable(context, tool_use_id):
    """Guard could not be reached. Behavior governed by DASHCLAW_GUARD_UNAVAILABLE_POLICY."""
    policy = GUARD_UNAVAILABLE_POLICY
    mode = HOOK_MODE

    # Write orphan log record for backfill regardless of policy — never lose audit
    orphan_path = os.path.join(os.path.expanduser("~"), ".dashclaw", "orphan-actions.jsonl")
    try:
        os.makedirs(os.path.dirname(orphan_path), exist_ok=True)
        from datetime import datetime, timezone
        record = {
            "ts": datetime.now(timezone.utc).isoformat(),
            "reason": "guard_unreachable",
            "base_url": BASE_URL,
            "agent_id": AGENT_ID,
            "context": context,
            "hook_mode": mode,
            "policy": policy,
        }
        with open(orphan_path, "a", encoding="utf-8") as f:
            f.write(json.dumps(record) + "\n")
    except Exception as e:
        _log_hook_error("handle_guard_unavailable: orphan log write failed: " + type(e).__name__ + ": " + str(e))

    # Observe mode always proceeds (by definition — observe is "warn loudly, don't block")
    if mode == "observe":
        log("[DashClaw] [observe] Guard unreachable at " + BASE_URL + " — action logged to ~/.dashclaw/orphan-actions.jsonl for backfill")
        log("Action: " + context.get("declared_goal", "unknown"))
        sys.exit(0)

    # Enforce mode: behavior governed by DASHCLAW_GUARD_UNAVAILABLE_POLICY
    if policy == "allow":
        log("[DashClaw] Guard unreachable at " + BASE_URL + " — proceeding (DASHCLAW_GUARD_UNAVAILABLE_POLICY=allow)")
        log("Action logged to ~/.dashclaw/orphan-actions.jsonl for backfill.")
        sys.exit(0)

    if policy == "warn":
        log("[DashClaw] ⚠ Guard unreachable at " + BASE_URL + " — proceeding anyway (DASHCLAW_GUARD_UNAVAILABLE_POLICY=warn)")
        log("Action logged to ~/.dashclaw/orphan-actions.jsonl for backfill.")
        log("Set DASHCLAW_GUARD_UNAVAILABLE_POLICY=block to fail closed instead.")
        sys.exit(0)

    # Default: block (fail closed)
    log("[DashClaw] Blocked: guard at " + BASE_URL + " is unreachable")
    log("Action: " + context.get("declared_goal", "unknown"))
    log("This is by design — destructive actions must not proceed without governance.")
    log("To change: set DASHCLAW_GUARD_UNAVAILABLE_POLICY=warn or =allow (not recommended).")
    log("Action logged to ~/.dashclaw/orphan-actions.jsonl for backfill on guard recovery.")
    sys.exit(2)
```

3. **Modify `main()` at the existing fail-open site (currently lines 557-560) to call the new handler:**

Replace:

```python
guard_resp = guard_check(context)
if guard_resp is None:
    log("[DashClaw] Guard unavailable, proceeding")
    sys.exit(0)
```

with:

```python
guard_resp = guard_check(context)
if guard_resp is None:
    handle_guard_unavailable(context, tool_use_id)
```

**Do NOT modify any other decision handler** (`handle_allow`, `handle_warn`, `handle_block`, `handle_require_approval`) — those are unrelated and already audit correctly per BUG-02.

**Keep the `.claude/hooks/` copy in sync:** After editing `hooks/dashclaw_pretool.py`, copy the file to `.claude/hooks/dashclaw_pretool.py` so the installed hook matches. If the project has a hook sync script (check `scripts/install-hooks.mjs` or similar), use that; otherwise copy manually.

```bash
cp hooks/dashclaw_pretool.py .claude/hooks/dashclaw_pretool.py
```
</action>
<read_first>
- `hooks/dashclaw_pretool.py` (the canonical file — lines 73-80 for config block, 497-503 for handler section, 557-560 for the fail-open site)
- `.claude/hooks/dashclaw_pretool.py` (installed copy — keep in sync)
- `hooks/dashclaw_pretool.py` §`handle_block` (lines 420-439) — the audit-write pattern that BUG-02 established; the orphan log is the same spirit applied to the outage path
- `.planning/phases/01.5-governance-bugfix/01.5-01-PLAN.md` — context on how BUG-02 was resolved, same failure class
- `.planning/todos/pending/todo-003-guard-unavailable-fail-open.md` — full supporting context captured during diagnosis
</read_first>
<acceptance_criteria>
- `hooks/dashclaw_pretool.py` contains the string `GUARD_UNAVAILABLE_POLICY = (os.environ.get("DASHCLAW_GUARD_UNAVAILABLE_POLICY")`
- `hooks/dashclaw_pretool.py` contains the string `def handle_guard_unavailable(context, tool_use_id):`
- `hooks/dashclaw_pretool.py` contains the string `orphan-actions.jsonl`
- `hooks/dashclaw_pretool.py` no longer contains the literal line `log("[DashClaw] Guard unavailable, proceeding")` followed directly by `sys.exit(0)` outside the `handle_guard_unavailable` function
- `grep -c "handle_guard_unavailable" hooks/dashclaw_pretool.py` returns at least 2 (one definition + at least one call site)
- `.claude/hooks/dashclaw_pretool.py` byte-for-byte matches `hooks/dashclaw_pretool.py` (`diff hooks/dashclaw_pretool.py .claude/hooks/dashclaw_pretool.py` produces no output)
- `python -c "import ast; ast.parse(open('hooks/dashclaw_pretool.py').read())"` exits 0 (syntax is valid Python)
</acceptance_criteria>
</task>

<task id="01.5-03-T2">
<name>Document the new env var in .env.example and CHANGELOG.md</name>
<action>
**Update `.env.example`:** Add the new env var in the `# --- DashClaw governance / hook ---` section (or wherever the hook-related env vars are grouped — check the file for `DASHCLAW_HOOK_MODE` and add alongside it):

```
# Controls hook behavior when /api/guard is unreachable (network failure, instance down).
# Values: block (default, fail closed — destructive actions blocked during outage),
#         warn  (proceed with a loud stderr warning — actions logged for backfill),
#         allow (proceed silently — not recommended, defeats the audit promise).
# All outages write to ~/.dashclaw/orphan-actions.jsonl for backfill on recovery.
DASHCLAW_GUARD_UNAVAILABLE_POLICY=block
```

**Update `CHANGELOG.md`:** Add a new unreleased entry at the top (or under the most recent unreleased version section) noting BUG-04:

```markdown
### Fixed

- **BUG-04 (Hook audit-trail gap on guard outage)**: `dashclaw_pretool.py` no longer silently exits 0 when `/api/guard` is unreachable. In enforce mode (default), the hook now blocks the tool (exit 2). In observe mode, it proceeds but logs the action to `~/.dashclaw/orphan-actions.jsonl` so the audit record is recoverable on guard recovery. New env var `DASHCLAW_GUARD_UNAVAILABLE_POLICY=block|warn|allow` (default `block`) governs enforce-mode behavior. Structurally same failure class as BUG-02 — both are silent governance without audit.
```

Do not bump the platform version in this task — version bumps happen at release time, which is a separate commit. This task only records the fix in the unreleased section.
</action>
<read_first>
- `.env.example` — find the hook env var section to match formatting
- `CHANGELOG.md` — find the unreleased section and the most recent version's changelog entry format
- `.planning/phases/01.5-governance-bugfix/01.5-01-SUMMARY.md` (if it exists) — see how BUG-02's changelog entry was formatted for consistency
</read_first>
<acceptance_criteria>
- `.env.example` contains the string `DASHCLAW_GUARD_UNAVAILABLE_POLICY=block`
- `.env.example` contains a comment explaining the `block|warn|allow` values
- `CHANGELOG.md` contains a new entry mentioning `BUG-04` and `DASHCLAW_GUARD_UNAVAILABLE_POLICY`
- No platform version bump introduced in this task
</acceptance_criteria>
</task>

<task id="01.5-03-T3">
<name>Add regression test covering the guard-unreachable scenario</name>
<action>
Create `hooks/tests/test_pretool_guard_unavailable.py` covering the BUG-04 scenarios. Follow the existing test pattern in `hooks/tests/` (check for existing `test_pretool_*.py` files; if none exist, use unittest or pytest matching the project's Python testing convention — look at how `dashclaw_agent_intel/` tests are structured).

**Required test cases:**

1. **Enforce + block policy (default)**: mock `api_request` to return `None` (unreachable). Invoke the hook's guard-unavailable path. Assert:
   - Hook exits with code 2
   - stderr contains the string `"Blocked"` and `"unreachable"`
   - Orphan log file contains a new JSONL record with `reason: "guard_unreachable"`

2. **Enforce + warn policy**: same as above but with `DASHCLAW_GUARD_UNAVAILABLE_POLICY=warn`. Assert:
   - Hook exits with code 0
   - stderr contains `"⚠"` or `"warn"` and `"proceeding"`
   - Orphan log record written

3. **Enforce + allow policy**: same with `DASHCLAW_GUARD_UNAVAILABLE_POLICY=allow`. Assert:
   - Hook exits with code 0
   - stderr mentions `"proceeding"` and `"DASHCLAW_GUARD_UNAVAILABLE_POLICY=allow"`
   - Orphan log record written

4. **Observe mode** (regardless of policy): `DASHCLAW_HOOK_MODE=observe`. Assert:
   - Hook exits with code 0
   - stderr contains `"[observe]"` and `"orphan"`
   - Orphan log record written

5. **Orphan log write failure is non-fatal**: simulate write failure by pointing `HOME` at a read-only path. Assert:
   - Hook still proceeds with the policy decision (block/warn/allow)
   - An entry is appended to `dashclaw_hook_errors.log` (per `_log_hook_error` pattern)

**Test isolation:** Use a `tempfile.TemporaryDirectory()` as the fake `HOME` for each test so the orphan log doesn't pollute the developer's real `~/.dashclaw/`. Set the env var inside the test's setup:

```python
import os, tempfile
os.environ["HOME"] = tmpdir  # then invoke hook
```

Do not hit a real network. Mock `urllib.request.urlopen` to raise an exception that makes `api_request` return `None`.
</action>
<read_first>
- `hooks/tests/` directory listing — find the existing test pattern
- `hooks/dashclaw_pretool.py` §`handle_guard_unavailable` (just written in T1)
- `hooks/dashclaw_agent_intel/__init__.py` or any existing test file in the project — for the unittest/pytest flavor
</read_first>
<acceptance_criteria>
- File `hooks/tests/test_pretool_guard_unavailable.py` exists
- File contains at least 5 test cases (one per scenario above)
- `python -m pytest hooks/tests/test_pretool_guard_unavailable.py -v` exits 0 (or `python -m unittest hooks/tests/test_pretool_guard_unavailable.py -v` if unittest is the project standard)
- No real network calls made during the test run (all `urllib.request.urlopen` mocked)
- Tests use `tempfile.TemporaryDirectory()` so no writes to the developer's real `~/.dashclaw/`
</acceptance_criteria>
</task>

<task id="01.5-03-T4">
<name>End-to-end validation: stop guard, confirm block + orphan log + recovery</name>
<action>
Human-action validation task. After T1, T2, T3 all pass, Wes performs:

1. **Ensure the demo Docker container is stopped** so nothing catches the localhost:3000 traffic:
   ```
   docker stop cranky_perlman 2>/dev/null || true
   ```

2. **Ensure `DASHCLAW_BASE_URL` is unset in the shell** (so .env's real Vercel URL wins), then **temporarily point it at a dead URL** to simulate an outage:
   ```
   export DASHCLAW_BASE_URL=http://127.0.0.1:1  # intentionally unreachable port
   ```

3. **Trigger a governed Bash command** from Claude Code. Expected behavior:
   - Hook blocks with exit 2
   - stderr shows: `"Blocked: guard at http://127.0.0.1:1 is unreachable"` and the orphan log path
   - Claude Code's tool call fails

4. **Check the orphan log:**
   ```
   cat ~/.dashclaw/orphan-actions.jsonl
   ```
   Confirm the new record exists with `reason: "guard_unreachable"`, the correct `base_url`, and the full `context` payload.

5. **Switch policy to warn and repeat:**
   ```
   export DASHCLAW_GUARD_UNAVAILABLE_POLICY=warn
   ```
   Trigger another governed command. Confirm the command proceeds with a stderr warning, and a new orphan record is appended.

6. **Restore normal operation:**
   ```
   unset DASHCLAW_BASE_URL
   unset DASHCLAW_GUARD_UNAVAILABLE_POLICY
   ```
   Trigger a governed command. Confirm it hits the real Vercel instance (per .env) and proceeds normally.

7. **Write the validation evidence** to `.planning/phases/01.5-governance-bugfix/01.5-BUG04-VALIDATION.md`:
   - Command sequence + observed stderr output for each scenario
   - Orphan log contents (redact any sensitive paths)
   - Confirmation that real operation was restored
</action>
<read_first>
- `hooks/dashclaw_pretool.py` (for reference on the exact expected stderr strings)
- `.env` (to confirm real `DASHCLAW_BASE_URL` is set for recovery step)
</read_first>
<acceptance_criteria>
- `.planning/phases/01.5-governance-bugfix/01.5-BUG04-VALIDATION.md` exists with evidence
- Block scenario shows exit 2 and correct stderr message (captured verbatim)
- Orphan log contains valid JSONL records for each scenario
- Recovery step confirms the hook resumes normal operation against the real Vercel instance
</acceptance_criteria>
</task>

</tasks>

<verification>
After all tasks complete:

```bash
# Syntax + lint
python -c "import ast; ast.parse(open('hooks/dashclaw_pretool.py').read())"
python -c "import ast; ast.parse(open('.claude/hooks/dashclaw_pretool.py').read())"

# Canonical vs installed copy must match
diff hooks/dashclaw_pretool.py .claude/hooks/dashclaw_pretool.py

# Regression test
python -m pytest hooks/tests/test_pretool_guard_unavailable.py -v

# No fail-open silent exit remaining
! grep -q 'log("\[DashClaw\] Guard unavailable, proceeding")' hooks/dashclaw_pretool.py || echo "FAIL: silent fail-open still present"

# Env var documented
grep -q "DASHCLAW_GUARD_UNAVAILABLE_POLICY" .env.example
grep -q "BUG-04" CHANGELOG.md

# Validation artifact exists
test -f .planning/phases/01.5-governance-bugfix/01.5-BUG04-VALIDATION.md
```
</verification>

<must_haves>
1. **No silent fail-open on guard unreachable.** The hook never exits 0 with no audit record when `/api/guard` is unreachable. This is the core BUG-04 promise and the primary test case.
2. **Orphan log captures every outage action.** Every guard-unreachable scenario — block, warn, allow, observe-mode — writes a JSONL record to `~/.dashclaw/orphan-actions.jsonl`. Audit is never lost.
3. **Default is fail-closed.** `DASHCLAW_GUARD_UNAVAILABLE_POLICY` defaults to `block`. Users must explicitly opt out of fail-closed to get permissive behavior. This matches the product promise — governance must be present, not optional.
4. **Observe mode proceeds loudly.** Observe mode (per `HOOK_MODE`) continues to allow actions but writes an orphan log record and prints a visible stderr notice. Observe is "warn don't block," not "silent allow."
5. **Canonical + installed hook stay in sync.** Both `hooks/dashclaw_pretool.py` and `.claude/hooks/dashclaw_pretool.py` get the fix. No drift between them.
6. **Regression test covers all 5 scenarios.** Block, warn, allow, observe, orphan-write-failure. Runs without hitting a real network.
7. **Validation artifact captures Wes's manual confirmation.** Not executor testimony — Wes's own terminal session output showing the block, the orphan log contents, and the recovery step working against the real Vercel instance.
</must_haves>

<notes>
**On scope:**
- This plan is BUG-04 only. Do NOT fold in the other two insights from the 2026-04-22 diagnosis session (todo-001 "hook warns on demo-mode BASE_URL" and todo-002 "rename Demo Production Guard") — those are separate concerns and belong in Phase 2 or later.
- Do NOT modify `dashclaw_posttool.py` or `dashclaw_stop.py` in this plan even though they may have similar patterns. A separate review of post-tool and stop hooks for the same failure class is backlog work.
- Do NOT modify the `api_request` function itself — the fix is at the call-site in `main()`, not inside the HTTP helper. Keep `api_request` as a pure HTTP layer that returns `None` on failure; the decision about what to do with `None` lives in `main()` and the new handler.

**On the dependency on 01.5-02:**
- Wave 3, depends on 01.5-02 (BUG-03 admin-role fix). Rationale: T4 validation requires Wes to observe the block behavior in his own instance. If he's still stuck as `role: member` (BUG-03), he may not see the correct `/decisions` UI state when the backfilled orphan record surfaces. Sequencing 01.5-03 after 01.5-02 means validation lands on a fully-working admin instance.
- If 01.5-02 is blocked or delayed, this plan can run independently — its validation works from terminal output alone and doesn't strictly require admin UI access. But prefer the dependent order.

**On the "warn" policy value:**
- `warn` is the middle ground for users who want to avoid fail-closed in development but still want the warning signal. It was not in the original BUG-04 proposal (which just had `block`/`allow`) — adding it gives users a non-destructive escape hatch without making silent-allow easy. The test covers it explicitly so the behavior doesn't rot.

**On orphan log file path:**
- `~/.dashclaw/orphan-actions.jsonl` is a new convention introduced by this plan. Use `os.path.expanduser("~")` + `.dashclaw/orphan-actions.jsonl` — don't hardcode platform-specific paths. The directory is created on first write via `os.makedirs(..., exist_ok=True)`.
- The file is append-only JSONL (one record per line). This matches common log-shipping conventions and makes backfill-on-recovery straightforward in a future plan.

**On backfill-on-recovery:**
- This plan does NOT implement the backfill step. It only guarantees the orphan records exist so a future tool can ingest them. Backfill is a backlog item — when the hook successfully reaches `/api/guard` on a later invocation, it could read the orphan log, POST each record to a new `/api/actions/backfill` endpoint, and truncate on success. That's Phase 2 or later work.

**On relation to todo-003:**
- This plan formalizes the fix described in `.planning/todos/pending/todo-003-guard-unavailable-fail-open.md`. After Plan 01.5-03 commits, the todo file can be moved to `.planning/todos/resolved/` or deleted — either is fine.
</notes>

---
*Plan created: 2026-04-22 in response to BUG-04 surfaced during Phase 2 CONTEXT-gathering diagnosis session*
*BUG-04 source evidence: `.planning/todos/pending/todo-003-guard-unavailable-fail-open.md`*
*Same failure class as BUG-02 (resolved 2026-04-11 in Plan 01.5-01) — silent governance without audit*
