---
phase: 1.5
plan: 1
subsystem: governance-runtime-bugfix
tags: [governance, hook, guard, semantic-check, audit-trail, BUG-01, BUG-02]
requirements: [BUG-01, BUG-02]
wave: 1
depends_on: []
files_modified:
  - hooks/dashclaw_pretool.py
  - app/api/guard/route.js
  - app/lib/repositories/guard.repository.js
  - app/lib/repositories/actions.repository.js
  - __tests__/unit/dashclaw-pretool-block-audit.test.js
  - .env.example
autonomous: false
---

# Phase 1.5 Plan 1: Governance Runtime Bugfix

<objective>
Fix the two stacked bugs that make DashClaw's own governance runtime silently fail closed with zero audit trail — discovered during a live dogfood session on 2026-04-11 when the `dashclaw_pretool.py` hook blocked a legitimate `gsd-tools` bash call with reason `"Secret Exposure Guard: Semantic check failed (fallback: block)"`, and no corresponding action record appeared in the approvals queue or decisions ledger.

These two bugs directly contradict DashClaw's core value proposition (*"audit-ready decision trails"*) and make Plan 01-03's dogfood ritual (≥5/7 days of Claude Code approvals recorded in the ledger) structurally impossible to satisfy. They must be resolved before Phase 2's Claude Code beachhead can credibly ship.

A third bug (`BUG-03`: founder has read-only/member role on own instance) was identified in the same session and is tracked as a separate plan (Phase 1.5 Plan 2 if it stands alone, or a follow-up). This plan is scoped to **BUG-01 and BUG-02 only**, per explicit user instruction.
</objective>

<requirements>
- **BUG-01**: The "Secret Exposure Guard" policy (`gp_178772c27d0f40e69240f82f`) in `/api/guard` evaluates a semantic classification step that is erroring deterministically on the hosted DashClaw instance. When it errors, it falls back to `decision: "block"` with reason string `"Secret Exposure Guard: Semantic check failed (fallback: block)"`. Root cause must be diagnosed (likely an LLM provider misconfiguration on Vercel, possibly regression from `cd9dbaf5 chore: lazy openai, version env wiring`), fixed, and verified by re-running the blocked call and confirming it no longer deterministic-blocks.
- **BUG-02**: `hooks/dashclaw_pretool.py:344 handle_block()` never calls `create_action()` before exiting with code 2. Every other decision path (`handle_allow`, `handle_warn`, `handle_require_approval`) records the action to the server. Blocks vanish into stderr with zero persisted trail. Fix: record the blocked action via `create_action(context, status="blocked")` at the top of `handle_block`, ensure the server accepts `"blocked"` as a valid action status, and verify the block appears in the decisions ledger at `/decisions` after firing.
</requirements>

<tasks>

<task id="01.5-01-T1">
<name>Diagnose Bug 1: locate the semantic classifier call and find the failure mode</name>
<action>
Locate the server-side code that evaluates the "Secret Exposure Guard" policy's semantic check step. The entry point is the POST handler in `app/api/guard/route.js` (which the 01-01 executor partially refactored in commit `c74c3d0b`, moving the GET handler's SQL into `guard.repository.js` — the POST handler was not touched). Trace from the POST handler into whatever module evaluates individual policies. Look for a function named something like `evaluateSemanticCheck`, `classifySecretExposure`, `runGuardSemantic`, or similar. It will most likely be calling an LLM via one of the lazy-loaded OpenAI/Anthropic/Gemini clients in `app/lib/` — the `cd9dbaf5 chore: lazy openai, version env wiring` commit refactored these loaders and is the top suspect for a regression.

Once found:
1. Read the function
2. Identify which LLM provider + model it calls
3. Identify which env var holds the API key (e.g. `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`)
4. Check `process.env.<KEY>` at call time — is it present? Is the lazy init actually firing? Is there a try/catch around the provider call that swallows the real error and returns a generic failure?
5. Reproduce the failure locally by calling `POST /api/guard` with a synthetic context matching what the hook sends (copy the context structure from `_enrich_bash` in `hooks/dashclaw_pretool.py:141`)
6. Capture the actual error (stack trace, provider response, timeout, etc.) and write the diagnosis to the top of `.planning/phases/01.5-governance-bugfix/01.5-DIAGNOSIS.md`

**Do not fix anything in this task. Only diagnose.** The fix task follows.
</action>
<read_first>
- `app/api/guard/route.js`
- `app/lib/repositories/guard.repository.js`
- All files matched by `grep -rl "semantic" app/api/ app/lib/ | head -20`
- `cd9dbaf5` commit diff: `git show cd9dbaf5 --stat && git show cd9dbaf5 -- app/lib/`
- `.env.example` (to see what env vars the project expects)
- `.env` (READ-ONLY — to see what env vars are actually set locally; do NOT modify)
- `hooks/dashclaw_pretool.py` for the context shape the hook sends to `/api/guard`
</read_first>
<acceptance_criteria>
- `.planning/phases/01.5-governance-bugfix/01.5-DIAGNOSIS.md` exists and contains:
  - The exact file path and line range of the semantic classifier function
  - The LLM provider + model it calls
  - The env var(s) it depends on
  - The actual error message reproduced locally (not the fallback reason string)
  - A concrete root-cause hypothesis (one of: missing API key, rate limit, network/timeout, regression from `cd9dbaf5`, or something else with evidence)
- No source files are modified in this task
- No server restart or env changes made — pure read-and-diagnose
</acceptance_criteria>
</task>

<task id="01.5-01-T2">
<name>Fix Bug 1: repair the semantic classifier based on diagnosis</name>
<action>
Based on the diagnosis from T1, apply the minimal fix needed to make the semantic check succeed. The fix will be one of:

- **If missing env var**: add the variable to `.env.example` with a placeholder, document it in the setup flow, and instruct the user (inline in the summary, not in code) to set it in their hosted Vercel instance and redeploy
- **If regression from `cd9dbaf5`**: revert or patch the lazy-init path so the LLM client is actually constructed when the semantic check runs
- **If error is swallowed**: wrap the LLM call with a proper try/catch that logs the real error and EITHER (a) returns a neutral `decision: "allow"` instead of falling back to block (so the failure mode is fail-open not fail-closed) OR (b) returns `decision: "require_approval"` so the user gets a chance to manually review the uncertain case. **Which fallback direction to choose is a design decision and should be discussed in the summary, not picked unilaterally.** For this task, default to `require_approval` because fail-open on a guard system is usually wrong and fail-closed-without-audit-trail is also wrong — require_approval is the middle path that maintains safety while keeping the user in the loop
- **If rate limit / timeout**: add backoff + retry or raise the timeout

After the fix, re-run the exact blocked command from earlier (`node gsd-tools.cjs init progress`) via the hook and confirm it no longer deterministic-blocks. If the semantic check succeeds, the /api/guard response should contain `decision: "allow"` (assuming no other policy matches).

**Do NOT re-enable the policies the user disabled** — those are still off and will be re-enabled by the user manually after this bug is fixed. The test here is that the semantic check would work IF the policy were enabled.

Commit message: `fix(guard): repair Secret Exposure Guard semantic check fallback path (BUG-01)` with explanation of the root cause inline.
</action>
<read_first>
- `.planning/phases/01.5-governance-bugfix/01.5-DIAGNOSIS.md` (output from T1 — THIS IS THE SOURCE OF TRUTH for what to fix)
- Whatever specific file(s) the diagnosis names as the broken one
- `app/lib/` lazy-loader files if the regression is in the LLM client init path
</read_first>
<acceptance_criteria>
- The file(s) identified in the diagnosis are modified minimally — no drive-by refactors, no unrelated changes
- A failing reproduction from T1 (the synthetic `POST /api/guard` call) now returns a non-fallback response (either `allow`, `warn`, `require_approval`, or a legitimate policy-matched `block` — anything except the fallback string `"Secret Exposure Guard: Semantic check failed (fallback: block)"`)
- `npm run lint` passes
- `npm test` passes against any test files touching guard/route logic
- Commit message explicitly names BUG-01 and inlines the root cause explanation
</acceptance_criteria>
</task>

<task id="01.5-01-T3">
<name>Fix Bug 2: handle_block records the blocked action before exit</name>
<action>
Modify `hooks/dashclaw_pretool.py:344 handle_block()` so that every blocked action is recorded to the server before the hook exits. The current flow logs to stderr and calls `sys.exit(2)` without ever calling `create_action()`. Every other decision handler (`handle_allow`, `handle_warn`, `handle_require_approval`) records the action. Block must do the same.

Concrete edit:

```python
def handle_block(guard_resp, context):
    """Block in enforce mode, warn in observe mode. Always records the action."""
    reasons = guard_resp.get("reasons") or []
    policies = guard_resp.get("matched_policies") or []
    reason = reasons[0] if reasons else "Guard policy violation"
    policy = policies[0] if policies else "guard policy"

    # RECORD THE BLOCK — this was missing, causing blocks to vanish from the ledger
    # with zero audit trail (BUG-02, fixed 2026-04-11 in Phase 1.5).
    create_action(context, status="blocked")

    if HOOK_MODE == "observe":
        log("[DashClaw] [observe] Would block: " + reason)
        sys.exit(0)

    log("[DashClaw] Blocked by policy: " + reason)
    log("Policy: " + policy)
    log("Action: " + context["declared_goal"])
    log("Run 'dashclaw approvals' to review or override.")
    sys.exit(2)
```

Note: `create_action` is already imported and used by the other handlers — no new imports needed.

If `create_action` fails (network, auth), the exit-2 path still runs so the block still fires. We are recording opportunistically, not blocking on the recording — the governance decision is authoritative regardless of whether the audit trail write succeeds.

Commit message: `fix(hook): record blocked actions in dashclaw_pretool handle_block (BUG-02)` — include the `Co-Authored-By: Wes Sander` trailer and explain that blocks were previously vanishing with no audit record.
</action>
<read_first>
- `hooks/dashclaw_pretool.py` (the file being modified)
- `hooks/dashclaw_pretool.py:317-341` (handle_allow and handle_warn, so the new code matches their pattern)
- `hooks/dashclaw_pretool.py:362-413` (handle_require_approval, same reason)
</read_first>
<acceptance_criteria>
- `grep -A3 "def handle_block" hooks/dashclaw_pretool.py | grep "create_action"` returns a match (the call exists inside the function)
- `create_action` is called with `status="blocked"` specifically (not `"running"` or `"pending_approval"`)
- The observe-mode early-return still works correctly (action is recorded, THEN the function exits 0 with a log)
- The enforce-mode path still exits with code 2
- Pre-commit hooks pass
</acceptance_criteria>
</task>

<task id="01.5-01-T4">
<name>Server-side: accept "blocked" as a valid action status</name>
<action>
The hook now writes `status: "blocked"` to `/api/actions` when a block fires. The server's actions repository must accept this value. Check whether the actions table (likely `actions` or `guard_actions` in Postgres/Drizzle) has an enum constraint on the `status` column, and whether the POST handler in `app/api/actions/route.js` validates incoming statuses.

If either is constrained to `["running", "pending_approval", "completed", "failed", "cancelled"]` or similar and does not include `"blocked"`:

1. Add `"blocked"` to the enum (Drizzle schema or raw SQL migration — match the project's existing pattern for adding enum values)
2. Add a migration script under `scripts/` following the `migrate-api-keys-compat.mjs` pattern set by the 01-01 executor
3. Update the API route validation
4. Update the `actions.repository.js` if it has a status whitelist

If the status column is a free `text` field with no enum constraint, no DB changes needed — just verify the repository code doesn't reject `"blocked"` anywhere.

Also update the `/decisions` and `/mission-control` surfaces to render `"blocked"` actions in the ledger — check whether these UIs filter by status and need the new value added to their query. If so, make that change. **If the UIs show `"blocked"` actions without any code change (because they render all statuses by default), document that explicitly in the commit message.**

Commit message: `feat(actions): accept blocked as valid action status (BUG-02 server-side)` — explain why (hook now records blocks, previously they vanished).
</action>
<read_first>
- `app/api/actions/route.js`
- `app/lib/repositories/actions.repository.js` (or whatever the actions repository is named — search with `ls app/lib/repositories/ | grep -i action`)
- The Drizzle schema file (likely `app/db/schema.js` or `db/schema.ts`)
- `app/decisions/page.js` and `app/mission-control/page.js` (to see how they query/render actions)
- `scripts/migrate-api-keys-compat.mjs` (as a template for any new migration)
</read_first>
<acceptance_criteria>
- A `POST /api/actions` call with `{"status": "blocked", "declared_goal": "test", ...}` succeeds (returns a 2xx and an action_id)
- The new action appears in the decisions ledger at `/decisions` with status "blocked"
- If a migration was added, it is idempotent (safe to re-run) and uses the same pattern as `migrate-api-keys-compat.mjs`
- `npm run route-sql:check` passes
- `npm run openapi:check` passes
- `npm run api:inventory:check` passes
</acceptance_criteria>
</task>

<task id="01.5-01-T5">
<name>Add regression test for handle_block audit trail behavior</name>
<action>
Add a unit test at `__tests__/unit/dashclaw-pretool-block-audit.test.js` that verifies `handle_block` calls `create_action` with `status="blocked"` before exiting. Because the hook is Python and the rest of the test suite is Vitest/JS, either:

**Option A (preferred):** add a Python test at `hooks/__tests__/test_handle_block.py` following any existing Python test pattern (check if `hooks/__tests__/` or similar exists). Use `unittest.mock` to mock `create_action` and verify it's called with the right args. Run via `python -m unittest hooks.__tests__.test_handle_block`.

**Option B (fallback):** if there is no existing Python test infrastructure, add a shell-based integration test at `__tests__/integration/hook-block-audit.sh` that invokes the hook with a synthetic PreToolUse payload, verifies the hook exits with code 2, and checks that a corresponding `status=blocked` row appeared in the actions table via a `psql` query or `GET /api/actions?status=blocked&limit=1` check.

Include the test in the plan's verification commands so it runs as part of the standard check suite.
</action>
<read_first>
- `hooks/` directory listing to check for existing test infra: `ls hooks/__tests__ 2>/dev/null; ls hooks/test* 2>/dev/null`
- `__tests__/integration/` directory listing to check for existing integration tests
- `hooks/dashclaw_pretool.py` — the function under test
</read_first>
<acceptance_criteria>
- New test file exists at one of the two paths above
- Test case explicitly asserts `create_action` is called with `status="blocked"` when `handle_block` is invoked
- Test case explicitly asserts the exit code is 2 in enforce mode and 0 in observe mode
- Test runs clean locally (`python -m unittest ...` or `bash __tests__/integration/hook-block-audit.sh`)
- Test is added to the project's test runner if possible
</acceptance_criteria>
</task>

<task id="01.5-01-T6">
<name>End-to-end validation: re-fire the original blocked command and confirm audit trail</name>
<action>
With all prior tasks complete:

1. **Re-enable the "Secret Exposure Guard" policy** that Wes disabled during the debug session (via the Policies page in the UI, or whatever mechanism was used to disable it)
2. **Run the exact command that was blocked earlier**: `node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init progress` — this should now either succeed (if the semantic check returns `allow`), or pause for approval (if it returns `require_approval`), or block with a recorded audit entry (if it returns a legitimate `block`)
3. **Verify the action appears in the decisions ledger** at `/decisions` regardless of which decision path fired
4. **If the command gets blocked again**, verify the block is now recorded — visit `/decisions`, see a row with `status: blocked`, `declared_goal: "Bash: cd C:/Projects/DashClaw && node..."`, and the policy name inline

This is a human-action task. The executor should prepare the env and prompt Wes to re-enable the policy and re-run the command, then verify the ledger.

Write the validation result to `.planning/phases/01.5-governance-bugfix/01.5-VALIDATION.md` with:
- Command invoked
- Timestamp
- Decision returned
- Action ID in the ledger (if any)
- Screenshot or curl output proving the audit trail entry exists
</action>
<read_first>
- `.planning/phases/01.5-governance-bugfix/01.5-DIAGNOSIS.md` (so the validator knows what was originally broken)
- `hooks/dashclaw_pretool.py` (the fixed hook)
</read_first>
<acceptance_criteria>
- `.planning/phases/01.5-governance-bugfix/01.5-VALIDATION.md` exists with timestamp, command, decision, and audit trail evidence
- The original blocked command no longer deterministic-blocks (it may legitimately block or require approval, but not with the "Semantic check failed" fallback string)
- An action record corresponding to the re-fired command is visible at `/decisions`
- Wes confirms visually in the UI that the block (if any) is now recorded, not silent
</acceptance_criteria>
</task>

</tasks>

<verification>
Automated checks to run after all tasks complete:

```bash
# Linting + contracts
npm run lint
npm run route-sql:check
npm run openapi:check
npm run api:inventory:check

# Test suites (existing + new)
npm test -- --run actions
npm test -- --run guard
# Python or shell hook test, whichever option was chosen in T5

# Manual validation evidence
test -f .planning/phases/01.5-governance-bugfix/01.5-DIAGNOSIS.md
test -f .planning/phases/01.5-governance-bugfix/01.5-VALIDATION.md

# Verify handle_block was actually patched
grep -A5 "def handle_block" hooks/dashclaw_pretool.py | grep "create_action"
```
</verification>

<must_haves>
Derived from the phase goal and the two bugs:

1. **`handle_block` records the blocked action.** Before this plan, blocks vanished with zero trace. After, every block writes an action row with `status="blocked"` to the server — visible in `/decisions`, queryable via `/api/actions`, auditable.
2. **The Secret Exposure Guard's semantic check no longer deterministic-blocks on ordinary commands.** The root cause (whatever it turns out to be) is fixed, with diagnosis captured in `01.5-DIAGNOSIS.md` and the fix rationale in the commit message.
3. **The server accepts `"blocked"` as a valid action status.** Either the enum was extended, the validation whitelist was updated, or the status column was shown to be unconstrained (documented explicitly).
4. **Regression test exists.** A test — Python or integration — explicitly verifies `handle_block` calls `create_action(status="blocked")`. Future regressions to the block-audit path get caught automatically.
5. **Wes can see the fix himself.** The validation task (T6) has him re-run the originally blocked command and verify the audit trail entry. No "it works on my machine" — the founder sees it working in the UI.
6. **Nothing else got broken.** `route-sql:check`, `openapi:check`, `api-inventory:check`, and `npm test` all still pass.
</must_haves>

<notes>
**On scope:**
- BUG-03 (founder is member not admin) is **explicitly out of scope** for this plan per user direction ("the two bugs"). It is tracked as a follow-up and should be handled in a separate plan — possibly Phase 1.5 Plan 2, or deferred to a new Phase 1.6 depending on the user's call.
- Do NOT modify `README.md`, `packages/openclaw-plugin/`, `CHANGELOG.md`, `sdk/README.md`, `app/docs/page.js`, `docs/**`, `PROJECT_DETAILS.md`, `QUICK-START.md`, or any marketing/doc surface. Per multi-agent hygiene, those belong to a parallel agent working in this repo and are all uncommitted in the current tree.
- Do NOT regenerate `package-lock.json` unless a task explicitly requires adding a dep. The lockfile was just reconciled in commit `0b1bcf38` and should stay clean.

**On attribution:**
- This plan exists because Wes was dogfooding DashClaw against his own Claude Code workflow and the product bit him. That IS the closed-loop flywheel from the moat section of PROJECT.md working — the founder using the product on the product finds real bugs. Mention this in the SUMMARY.md wrap-up. This is a positive signal, not a disaster: it's the feedback loop doing its job.

**On testing the hook itself:**
- When you re-fire the blocked command in T6, the hook will intercept it again. If you're running tasks from this plan VIA Claude Code, you may need `DASHCLAW_HOOK_MODE=observe` in your shell env for the duration of the plan, or temporarily disable the pretool hook in `.claude/settings.json` / `hooks/settings.json`. Remember to re-enable it when the plan is done.

**On the two bugs being intertwined:**
- These bugs are in different layers (BUG-01 is server-side policy evaluation, BUG-02 is client-side hook + server-side schema) but they combine into a single user-visible failure (commands get silently blocked with no recourse). Fixing only one leaves the other exposed. Both must ship together for this phase to close.
</notes>

---
*Plan created: 2026-04-11 in response to live dogfood bug discovery during `/gsd-progress` session*
*Discovery context: DashClaw's own pretool hook blocked a legitimate `gsd-tools init progress` call while the founder was running the GSD workflow on DashClaw itself. This is the first real dogfood-caught bug in the project.*
