diff --git a/.gitignore b/.gitignore
index c96fcfe..2fbc15d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
node_modules/
.code-warden-report.json
+.code-warden/
.claude/
code-warden-v*.zip
code-warden-*.tgz
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6660a39..c93bedd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,27 +5,56 @@ Versions follow [Semantic Versioning](https://semver.org/).
---
-## Unreleased
-
-- Added Codex config handling to `--hooks=codex`: the installer now enables
- `[features].hooks = true` in `~/.codex/config.toml` and removes deprecated
- `[features].codex_hooks` entries when present.
-- Updated Codex hook health checks so registered hooks verify both hook script
- paths and feature-flag enablement without making optional hooks mandatory for
- base installs.
-- Added tests for Codex config migration and hook feature enablement.
-- Improved first-run onboarding in CLI help, install output, and quickstart docs
- so users see `init -> doctor -> report -> optional hooks`.
-- Added Codex hook repair guidance so doctor/verify can point partial hook setup
- back to `code-warden hooks codex`.
-- Added `code-warden verify ` as a public CLI wrapper for strict
- per-runtime install and hook health checks.
-- Clarified target-specific onboarding examples for CLI and direct installer
- users.
-- Added `code-warden smoke-npx` as a public CLI wrapper for clean-temp npm
- package smoke testing.
-- Hardened release preflight so tags fail before publish when the npm version
- already exists, and documented trusted-publisher setup.
+## v4.0.0 - 2026-06-10
+
+**Enforcement layers: scope lock, command risk gate, audit ledger, corroborated receipts, baseline ratchet, git backstop, and lifecycle hooks.**
+
+Major version: the report's `session.scopeGate` field becomes an object when a
+scope lock exists (was always a string), everything under `.code-warden/` is
+agent-write-protected unconditionally, and hook registration expands to new
+events. **Upgrade note: re-run `code-warden hooks claude` (and `hooks codex`)
+after updating — old registrations keep working but miss NotebookEdit/command
+coverage and all new events and gates.**
+
+### Scanner and hook hardening
+
+- Added six secret patterns: Anthropic (`sk-ant-`), Google (`AIza`), GitLab (`glpat-`), npm (`npm_`), Hugging Face (`hf_`), and JWT.
+- All secret matches are now reported per file (`scanForAllSecrets`), not first-match-only.
+- Hooks discover the governed project's own `codewarden.json` (walking up from the working directory, stopping at the `.git` boundary), so `lint.exclude_paths`, `secrets.allowlist`, and thresholds behave identically in hooks and CI.
+- Claude write hooks now cover `Write|Edit|NotebookEdit`; new `warden-command-hook.js` scans `Bash|PowerShell` command strings for credentials.
+- `pre_flight_trigger_lines` is wired as an "ask" gate on single changes over 150 lines; `human_checkpoint_files` and `exempt_from_blast_radius` are honestly documented as prompt-layer-only.
+
+### Git backstop and baseline ratchet
+
+- Added `code-warden hooks git`: a marker-managed per-repo pre-commit hook that runs staged-content lint and secrets scans (`git show :path`) with exclude/allowlist parity. `git commit --no-verify` bypasses it — documented, not hidden. `code-warden verify git` checks the installation.
+- Added `report --write-baseline[=path]` and `report --baseline[=path]` ratchet mode: only NEW or WORSENED violations fail; legacy findings are counted separately ("N new / M legacy"). Secrets are fingerprinted by sha256 of the trimmed matched line — no raw secrets in baselines. SARIF carries fresh findings only. A missing baseline file is a hard error.
+- Added a `baseline` input to the GitHub Action and brownfield adoption guidance to the CI template.
+
+### Scope Lock and Command Risk Gate
+
+- Added `code-warden scope set|add|remove|clear|status` managing `/.code-warden/scope.json` (`goal`, `enforce`, `filesIn`, `expansions[]` audit trail); `scope set --no-enforce` records scope without blocking.
+- Write hooks (Claude `Write`/`Edit`/`NotebookEdit`, Codex `apply_patch`) deny out-of-scope edits while a scope is locked; the deny message tells the agent to ask the user to run `code-warden scope add `. Strictly opt-in — no scope file means no enforcement.
+- Added a Command Risk Gate to both command hooks. Default blocked (deny): `rm_rf_root`, `rd_root`, `remove_item_root`, `git_reset_hard`, `git_push_force` (`--force-with-lease` exempt), `git_clean_force`, `git_history_rewrite`, `curl_pipe_shell`, `ps_web_pipe_iex`, `chmod_777_root`. Default high (ask on Claude; allow on Codex, which has no ask equivalent): `package_install` (bare `npm install`/`ci` allowed), `npm_publish`, `git_push`, `recursive_delete`, `remove_item_recurse`, `git_discard_changes`. Configurable via `risk_policy.command_rules` (id-based override/disable; user rules merge with defaults).
+- Governance report: `session.scopeGate` reports a `{status: 'locked', goal, filesIn, enforce}` object when a scope exists (previously always the string `'session_only'`) — JSON consumers should handle both shapes.
+
+### Audit ledger, lifecycle hooks, and corroborated receipts
+
+- Added a PostToolUse audit ledger: `.code-warden/audit.jsonl` with a sha256 hash chain (GENESIS-anchored). Command targets are secret-redacted and truncated to 300 chars. Auto-on while a scope lock exists, else `audit.enabled` config (explicit `false` wins). Claude sessions only.
+- `.code-warden/` governance artifacts are unconditionally write-protected from agents (both runtimes), even with no scope lock.
+- Added a SessionStart hook that injects architecture context (shared `lib/context-discovery.js`, also used by `get-context.js`) and scope status.
+- Added an opt-in Stop hook (`session.verify_on_stop`, default `false`) that blocks completion on FRESH lint/secret violations; loop-guarded via `stop_hook_active` and baseline-aware.
+- Added `receipt --from-audit[=path] --out=`: prefills draft receipts from `scope.json`, architecture context, git branch/commit (`lib/git-info.js`), and ledger evidence with chain verification. A `complete` receipt with `audit.chainValid: false` fails validation. Receipt schema is additive — v1 receipts still validate.
+- Generalized hook management across PreToolUse/PostToolUse/SessionStart/Stop (`lib/hook-events.js`); behavioral-test timeout raised to 60s.
+
+### Previously unreleased changes (since v3.4.0)
+
+- `--hooks=codex` now enables `[features].hooks = true` in `~/.codex/config.toml` and removes deprecated `[features].codex_hooks` entries; health checks verify both hook script paths and feature-flag enablement without making optional hooks mandatory.
+- Added `code-warden verify ` and `code-warden smoke-npx` as public CLI wrappers; improved first-run onboarding (`init -> doctor -> report -> optional hooks`) and Codex hook repair guidance.
+- Hardened release preflight so tags fail before publish when the npm version already exists; documented trusted-publisher setup.
+
+### New config keys
+
+- `lint.exclude_paths` and `secrets.allowlist` (now hook-honored), `risk_policy.command_rules`, `audit.enabled`, `session.verify_on_stop`.
---
diff --git a/README.md b/README.md
index c4b7ea1..41e587d 100644
--- a/README.md
+++ b/README.md
@@ -4,9 +4,9 @@
-
+
-
+
@@ -29,16 +29,17 @@ npx code-warden verify codex # or your target runtime
npx code-warden report
```
-Optional hard hooks where supported:
+Optional hard enforcement:
```bash
-npx code-warden hooks claude
-npx code-warden hooks codex
+npx code-warden hooks claude # per-user lifecycle hooks
+npx code-warden hooks codex # per-user, partial surfaces
+npx code-warden hooks git # per-repo pre-commit backstop (run from the repo)
```
Use `doctor` after install or hook setup. It verifies installed skill manifests,
-hook script paths, and runtime-specific hook config when hooks are registered.
-When it finds partial hook setup, it prints the repair command to rerun.
+hook script paths, and runtime-specific hook config, and prints the repair
+command when it finds partial setup.
## Who This Is For
@@ -50,42 +51,27 @@ If you run long Claude Code, Codex, or Cursor sessions — multi-file refactors,
At its core, Code-Warden is a governance contract:
- The agent states architecture context before acting.
-- The agent declares scope and patch order before edits.
+- The agent declares scope and patch order before edits — and the scope lock can mechanically enforce it.
- The repo verifies file size, secrets, tests, install health, risk policy, runtime hooks, and receipt artifacts.
-- The workflow keeps receipts, JSON, Markdown, SARIF, and release evidence outside chat memory.
-
-**Built for developers who:**
-- Run long, high-autonomy AI coding sessions
-- Let agents touch multiple files or whole modules
-- Work across several projects at once
-- Need CI-friendly verification without relying on chat memory
-- Need an audit trail for what the agent was allowed to change
-- Want hard blocking where the runtime supports it
-
-**Probably overkill if:**
-- You only use AI for short snippets
-- You manually review every one-file edit before it lands
-- You do not need CI checks
-- You are comfortable relying entirely on prompt instructions
+- The workflow keeps receipts, audit ledgers, JSON, Markdown, SARIF, and release evidence outside chat memory.
## Prevents / Allows
**Prevents**
- Code before scope is declared
-- Multi-file edits without a patch plan
-- Files touched outside approved scope
+- Writes outside a locked scope (denied at the hook layer)
+- Destructive shell commands — force push, hard reset, recursive root deletes, pipe-to-shell (denied)
- Oversized monolithic files
-- Hardcoded API keys and credentials
+- Hardcoded API keys and credentials — in files, commands, and staged commits
- Completion claims without verification evidence
+- Agent tampering with governance artifacts (`.code-warden/` is always write-protected)
- Stale or broken agent installs
-- Claude Code writes that violate hook policy
**Allows**
-- Normal development work
-- Fast solo-founder iteration
-- Existing agent workflows
+- Normal development work and fast solo-founder iteration
+- Existing agent workflows — every enforcement layer is opt-in
+- Brownfield adoption — baseline ratchet gates only new violations
- CI enforcement without chat memory
-- Optional hard blocking only where supported
## Four Layers
@@ -93,19 +79,35 @@ At its core, Code-Warden is a governance contract:
+| Layer | What it does | Bypass honesty |
+|-------|--------------|----------------|
+| **1. Prompt governance** | SKILL.md gates: Scope Gate, Plan Gate, blast radius, drift signals | Instructions only — the agent is told, nothing blocks |
+| **2. Runtime hooks** | Claude lifecycle hooks (full) and Codex PreToolUse hooks (partial) deny bad writes, out-of-scope edits, and risky commands before execution | Only where the runtime exposes hook surfaces |
+| **3. Git backstop** | Per-repo pre-commit scans staged content for lint/secrets — any agent, any editor, any human | `git commit --no-verify` skips it |
+| **4. CI** | `governance-report.js` / GitHub Action: deterministic gate plus JSON/Markdown/SARIF evidence | Catches everything that reaches a PR |
+
+Each layer narrows what the previous one can miss. None of them is a sandbox
+or a security boundary against a malicious user — they govern the agent inside
+the workflow you already use.
+
## Compatibility
| Runtime | Install | Skill Rules | Local Tools | CI | Hard Hooks |
|---|---:|---:|---:|---:|---:|
-| Claude Code | ✅ | ✅ | ✅ | ✅ | ✅ PreToolUse |
+| Claude Code | ✅ | ✅ | ✅ | ✅ | ✅ Full lifecycle |
| OpenAI Codex | ✅ | ✅ | ✅ | ✅ | ⚡ Partial |
-| Cursor | ✅ | ✅ | ✅ | ✅ | — |
-| Warp | ✅ | ✅ | ✅ | ✅ | — |
-| Windsurf | ✅ flat rules | ✅ adapted | ✅ | ✅ | — |
-| Generic Agents | ✅ | ✅ | ✅ | ✅ | — |
+| Cursor | ✅ | ✅ | ✅ | ✅ | git backstop |
+| Warp | ✅ | ✅ | ✅ | ✅ | git backstop |
+| Windsurf | ✅ flat rules | ✅ adapted | ✅ | ✅ | git backstop |
+| Generic Agents | ✅ | ✅ | ✅ | ✅ | git backstop |
| GitHub Actions | — | — | ✅ | ✅ | — |
-Claude Code gets full hard enforcement (blocks `Write`/`Edit` before the file system is touched). Codex gets partial enforcement: `apply_patch` and `Bash` calls are intercepted for secrets and estimated file size — the tool surfaces Codex exposes at `PreToolUse`. CI enforcement closes the remaining gap for both runtimes.
+Claude Code gets full enforcement: PreToolUse gates on `Write`/`Edit`/`NotebookEdit`
+and `Bash`/`PowerShell`, a PostToolUse audit ledger, SessionStart context
+injection, and opt-in Stop verification. Codex gets partial enforcement:
+`apply_patch` and `Bash` are the only hookable surfaces — no ask-tier
+confirmations, no PostToolUse ledger. The git backstop and CI close the
+remaining gap for every runtime.
## Why Not Just Prompt Better?
@@ -115,76 +117,50 @@ You should prompt well. Code-Warden does not replace that.
| Rule | Prompt-only | Code-Warden |
|---|---|---|
-| Keep files modular | Agent remembers | `warden-lint` checks files and directories |
-| No hardcoded secrets | Agent remembers | `verify-secrets` scans locally and in CI |
-| Stay inside scope | Agent declares scope | Scope Gate creates an explicit file contract |
-| Verify before done | Agent claims it checked | `npm run ci` produces a deterministic result |
-| Block unsafe writes | Not possible everywhere | Claude `PreToolUse` hooks deny `Write`/`Edit` before execution |
-
-Code-Warden is portable at the governance, installer, local-tooling, and CI layers. Hard pre-write blocking is currently Claude Code-specific because Claude exposes `PreToolUse` hooks. Other runtimes get all other layers.
-
-## What Code-Warden Is / Is Not
-
-**Code-Warden is:**
-- A governance layer for AI coding agents
-- A local verification toolkit
-- A cross-runtime installer and health checker
-- A CI-friendly policy gate
-- An optional hard-enforcement layer for Claude Code (full) and Codex (partial)
-
-**Code-Warden is not:**
-- A replacement for your coding agent
-- A full development methodology like Superpowers
-- A sandbox or security boundary against malicious users
-- A guarantee that unsupported runtimes can block tool calls before execution
-
-> Code-Warden governs the agent inside the workflow you already use.
+| Keep files modular | Agent remembers | `warden-lint` checks files; hooks block oversized writes |
+| No hardcoded secrets | Agent remembers | Scanned in writes, commands, staged commits, and CI |
+| Stay inside scope | Agent declares scope | Scope lock denies out-of-scope writes at the hook layer |
+| Don't run destructive commands | Agent is careful | Command Risk Gate denies blocked-tier, asks on high-tier |
+| Verify before done | Agent claims it checked | `npm run ci`, Stop verification, and receipts corroborated by a hash-chained audit ledger |
## Adoption Path
-You do not need to install everything at once. Each layer adds value independently.
+Each layer adds value independently. Start where the pain is.
-1. **CI only** — add `warden-lint` and `verify-secrets` to GitHub Actions. No skill install required.
+1. **CI only** — add the GitHub Action or `governance-report.js`. Brownfield? `report --write-baseline` first, gate on `--baseline`.
2. **Skill governance** — install Code-Warden into your AI runtime. Scope Gates, Plan Gates, and drift signals activate immediately.
-3. **Hard enforcement** — enable hooks for pre-tool-use blocking. Claude Code: full (`Write`/`Edit`). Codex: partial (`apply_patch`/`Bash`). Requires step 2 first.
-
-Start where you have the most immediate pain.
+3. **Git backstop** — `code-warden hooks git` for a per-repo pre-commit scan. Works for every runtime and human commits too.
+4. **Runtime hooks** — `hooks claude` / `hooks codex` for pre-execution blocking. Requires step 2 first.
+5. **Scope lock + audit** — `code-warden scope set` per governed session; close with a corroborated receipt.
## Install
```bash
-npx code-warden init
+npx code-warden init # or: npm install -g code-warden && code-warden init
```
-Or install globally:
-
-```bash
-npm install -g code-warden
-code-warden init
-```
-
-The installer scans for AI runtimes and deploys to all of them in one step.
-Supports Claude Code, Cursor, Warp, OpenAI Codex, Windsurf, and generic agent runtimes.
+The installer scans for AI runtimes and deploys to all of them in one step:
+Claude Code, Cursor, Warp, OpenAI Codex, Windsurf, and generic agent runtimes.
### CLI commands
```bash
-code-warden init # install to detected AI runtimes
-code-warden report # generate governance report
-code-warden report --format=md # Markdown output (pipe to PR summary)
-code-warden report --format=sarif # SARIF output for Code Scanning
-code-warden report --format=sarif --out=code-warden.sarif
-code-warden receipt --template --out=code-warden-receipt.json
-code-warden receipt --validate=code-warden-receipt.json
-code-warden references README.md code-warden/tools/
+code-warden init # install to detected AI runtimes
+code-warden report # governance report (--format=md|sarif, --out=)
+code-warden report --write-baseline # record current violations as the ratchet floor
+code-warden report --baseline # fail only NEW or WORSENED violations
+code-warden scope set --goal="..." # lock session scope (--no-enforce to record only)
+code-warden scope add|remove|clear|status # manage the scope lock
+code-warden receipt --template --out= # draft governance receipt
+code-warden receipt --from-audit --out= # receipt prefilled from the audit ledger
+code-warden receipt --validate=
+code-warden references # recommend governance references
+code-warden doctor # verify source + install health
+code-warden verify # strict health check (claude, codex, git, ...)
+code-warden list # show detected runtimes
+code-warden hooks claude|codex|git # install enforcement hooks
+code-warden uninstall-hooks claude|codex|git
code-warden smoke-npx --package=code-warden@latest
-code-warden doctor # verify source + install health
-code-warden verify codex # strict health check for one runtime
-code-warden list # show detected runtimes
-code-warden hooks claude # install Claude Code PreToolUse hooks
-code-warden hooks codex # install Codex PreToolUse hooks (partial)
-code-warden uninstall-hooks claude
-code-warden uninstall-hooks codex
```
## Invoke
@@ -195,203 +171,172 @@ code-warden uninstall-hooks codex
Or: `"load code-warden"`, `"new session"`, `"begin coding"`, `"governance check"`.
-### Session Start Sequence
-
-## Optional Hard Enforcement (Hooks)
+## Hard Enforcement (Hooks)
-### Claude Code — Full enforcement
+### Claude Code — full lifecycle
-```bash
-node install.js --hooks=claude # install (requires Claude target installed first)
-node install.js --uninstall-hooks=claude # remove
-```
+`code-warden hooks claude` registers (per-user, `~/.claude/settings.json`):
-Blocks `Write` and `Edit` before the file system is touched — if the resulting file would exceed the line limit or contain a hardcoded credential.
+| Event | Hook | Policy |
+|-------|------|--------|
+| PreToolUse `Write\|Edit\|NotebookEdit` | lint, secrets, scope | Deny oversized files, hardcoded credentials, out-of-scope writes; ask past `pre_flight_trigger_lines` |
+| PreToolUse `Bash\|PowerShell` | command | Deny credentials in commands; Command Risk Gate (deny blocked-tier, ask high-tier) |
+| PostToolUse | audit | Append to the hash-chained audit ledger (never blocks) |
+| SessionStart | session | Inject architecture context + scope status |
+| Stop | stop | Opt-in (`session.verify_on_stop`): block completion on fresh lint/secret violations |
-### OpenAI Codex — Partial enforcement
+### OpenAI Codex — partial
-```bash
-node install.js --hooks=codex # install (requires Codex target installed first)
-node install.js --uninstall-hooks=codex # remove
-```
+`code-warden hooks codex` registers `apply_patch` (secrets, estimated size,
+scope lock) and `Bash` (secrets, Command Risk Gate — blocked-tier denies only;
+Codex has no ask equivalent, so high-tier allows silently). The installer
+enables `[features].hooks = true` in `~/.codex/config.toml` and removes the
+deprecated `codex_hooks` key. No PostToolUse surface exists, so there is no
+Codex audit ledger.
+
+### Git backstop — any runtime
+
+`code-warden hooks git` installs a marker-managed pre-commit hook in the repo
+at cwd (per-repo, unlike the per-user hooks above). It scans **staged content**
+(`git show :path`) for file-length and secret violations with the same
+exclude/allowlist config as CI. `git commit --no-verify` bypasses it — that is
+git's escape hatch and Code-Warden documents it rather than pretending
+otherwise. Check it with `code-warden verify git`.
-The Codex hook installer writes `~/.codex/hooks.json` and enables the current
-Codex feature flag in `~/.codex/config.toml`:
+### Scope Lock
-```toml
-[features]
-hooks = true
+```bash
+code-warden scope set --goal="Fix auth bug" src/ lib/utils.js
+code-warden scope status
+code-warden scope add src/middleware.js # user-approved expansion (audited)
+code-warden scope clear
```
-If an older config contains `[features].codex_hooks`, the installer removes that
-deprecated key while enabling `hooks`. Current Codex docs list `hooks` as the
-stable lifecycle-hook feature flag.
+Writes `/.code-warden/scope.json`. While locked, agent writes outside
+the declared paths are denied and the agent is told to ask you to run
+`code-warden scope add `. Expansions are recorded in `expansions[]`.
+Strictly opt-in — no scope file, no enforcement. `.code-warden/` itself is
+always write-protected from agents, lock or not. If the agent runs `scope add`
+via the shell, the command is visible in your session and the expansion is
+recorded — auditable, not impossible.
-| Hook | Trigger | Policy |
-|------|---------|--------|
-| `warden-apply-patch-hook.js` | `apply_patch` | Blocks if added lines contain a credential or estimated result exceeds line limit |
-| `warden-bash-hook.js` | `Bash` | Blocks if command contains a hardcoded credential |
+### Command Risk Gate
-Codex exposes `apply_patch` and `Bash` at `PreToolUse` — not `Write`/`Edit`. These are the available surfaces. CI enforcement closes the remaining gap.
+Conservative defaults, two enforced tiers:
-Doctor and `--verify-target=` validate hook script paths and Codex hook
-feature enablement when hooks are registered, with repair guidance for partial
-hook setup.
+- **blocked (deny)**: `rm_rf_root`, `rd_root`, `remove_item_root`, `git_reset_hard`, `git_push_force` (`--force-with-lease` exempt), `git_clean_force`, `git_history_rewrite`, `curl_pipe_shell`, `ps_web_pipe_iex`, `chmod_777_root`
+- **high (ask on Claude, allow on Codex)**: `package_install` (bare `npm install`/`ci` allowed), `npm_publish`, `git_push`, `recursive_delete`, `remove_item_recurse`, `git_discard_changes`
-## Governance Evidence
+Override per rule id via `risk_policy.command_rules` in `codewarden.json` —
+replace a default, disable it with `"tier": "off"`, or add your own patterns.
-Code-Warden produces a machine-readable governance report — verifiable evidence that checks ran and passed:
+## Governance Evidence
```bash
-node tools/governance-report.js . # writes .code-warden-report.json
-node tools/governance-report.js . --format=md # Markdown table for PR summaries
-node tools/governance-report.js . --format=sarif # SARIF for source-located findings
-node tools/governance-report.js . --format=sarif --out=code-warden.sarif
+code-warden report # .code-warden-report.json + summary
+code-warden report --format=md # Markdown for $GITHUB_STEP_SUMMARY
+code-warden report --format=sarif --out=code-warden.sarif
```
-The report covers file length, hardcoded credentials, behavioral tests, source integrity, and runtime hook status in a single pass. In CI, it pipes directly into `$GITHUB_STEP_SUMMARY` so every PR shows what was checked.
+One pass covers file length, credentials, behavioral tests, source integrity,
+risk policy, runtime hook status, and session governance (the report's
+`scopeGate` is a `{status, goal, filesIn, enforce}` object when a scope lock
+exists; the string `"session_only"` otherwise — handle both shapes). SARIF is
+intentionally narrower: only source-located findings (`CW001`/`CW002`).
-SARIF output is intentionally narrower than the JSON report: it includes only
-findings with source locations (`CW001/max-file-length` and
-`CW002/hardcoded-credential`). Behavioral tests, install health, runtime hook
-state, and session governance remain in JSON/Markdown because they are
-workflow evidence, not source-code findings.
+### Audit ledger and corroborated receipts
-Governance receipts cover the part reports cannot know by themselves: the
-confirmed Scope Gate and Plan Gate. Generate a draft receipt before or during a
-session, fill in the gate and final command evidence, then validate it:
+While a scope lock exists (or `audit.enabled` is `true`), Claude sessions
+append every governed tool call to `.code-warden/audit.jsonl` — sha256
+hash-chained from GENESIS, so any edit breaks every later line. Commands are
+logged secret-redacted and truncated. Gitignore the ledger; receipts are the
+durable artifact:
```bash
-code-warden receipt --template --out=code-warden-receipt.json
+code-warden receipt --from-audit --out=code-warden-receipt.json
code-warden receipt --validate=code-warden-receipt.json
```
-Receipts deliberately start as drafts with `canProveCompliance: false`; they
-become valid only when the required evidence is filled in.
-
-Reports also include risk policy evidence from `codewarden.json`. The default
-policy marks read-only work as `low`, file edits as `medium`,
-dependency/network/release operations as `high`, and destructive or
-secret-bearing actions as `blocked`.
+`--from-audit` prefills the draft from the scope lock, architecture context,
+git branch/commit, and ledger evidence with chain verification. Receipts still
+start as drafts — a human completes them — and a `complete` receipt over a
+broken chain fails validation.
-External evidence providers are recorded with scope and trust limits. Code
-Scanning SARIF, secret scanning, dependency scans, artifact attestations, npm
-provenance, and CI run links can support a governance claim, but they do not
-replace Scope Gate, Plan Gate, or receipts.
+### Baseline ratchet (brownfield adoption)
-MCP servers are governed as tool-bearing integrations, not harmless context
-sources. Before enabling one, Code-Warden expects an approval record covering
-server source, version, transport, toolsets, credential scope, data egress, and
-rollback. See [`code-warden/references/mcp-governance.md`](code-warden/references/mcp-governance.md).
+```bash
+npx code-warden report --write-baseline # freeze current debt as the floor
+git add .code-warden-baseline.json && git commit -m "chore: code-warden baseline"
+npx code-warden report --baseline # N new / M legacy; fails on new only
+```
-Use `code-warden references ` to recommend the focused governance
-references for touched paths. This is advisory loading, not hidden enforcement.
+Baselined files fail again the moment they grow. Secrets are fingerprinted by
+content hash — no raw secrets in the baseline. A missing baseline file is a
+hard error, never a silent skip.
## CI Integration
-Use Code-Warden as a GitHub Action:
-
```yaml
- name: Code-Warden Governance Gate
- uses: Kodaxadev/Code-Warden@v3
+ uses: Kodaxadev/Code-Warden@v4
with:
path: .
+ baseline: .code-warden-baseline.json # optional ratchet mode
+ sarif: 'true' # optional Code Scanning upload
```
-The action writes `.code-warden-report.json`, appends a Markdown summary to the
-workflow run, and uploads the report as an artifact by default.
+SARIF upload needs `security-events: write` permission and goes through
+`github/codeql-action/upload-sarif@v4`. The action writes
+`.code-warden-report.json`, appends a Markdown summary, uploads the report
+artifact, and fails the job when the gate fails.
-Enable GitHub Code Scanning annotations with SARIF:
+Prefer a pinned download? Fetch
+`https://github.com/Kodaxadev/Code-Warden/releases/download/v4.0.0/code-warden-v4.0.0.zip`
+and run `node /tools/governance-report.js .` — full template with both
+options: [`code-warden/templates/ci/github-actions.yml`](code-warden/templates/ci/github-actions.yml)
-```yaml
-permissions:
- contents: read
- security-events: write
-
-steps:
- - uses: actions/checkout@v6
- - name: Code-Warden Governance Gate
- uses: Kodaxadev/Code-Warden@v3
- with:
- path: .
- sarif: 'true'
-```
+## Upgrading to v4
-The action uploads SARIF through `github/codeql-action/upload-sarif@v4` and
-still fails the job when the governance report fails.
+**Re-run `code-warden hooks claude` (and `hooks codex`) after updating.** Old
+registrations keep working but lack NotebookEdit/command coverage and all new
+events and gates. Breaking changes for consumers:
-Or download a pinned release directly:
+- Report `session.scopeGate` is an object when a scope lock exists (was always a string).
+- `.code-warden/` is agent-write-protected unconditionally.
+- New config keys: `lint.exclude_paths`, `secrets.allowlist` (both hook-honored), `risk_policy.command_rules`, `audit.enabled`, `session.verify_on_stop`.
-```yaml
-- name: Install Code-Warden
- run: |
- curl -fsSL -o cw.zip \
- https://github.com/Kodaxadev/Code-Warden/releases/download/v3.4.0/code-warden-v3.4.0.zip
- unzip -q cw.zip -d .code-warden-ci
-
-- name: Governance report
- run: node .code-warden-ci/tools/governance-report.js .
-
-- name: Publish governance summary
- if: always()
- run: node .code-warden-ci/tools/governance-report.js . --format=md >> $GITHUB_STEP_SUMMARY
-
-- name: Upload governance artifact
- if: always()
- uses: actions/upload-artifact@v7
- with:
- name: code-warden-report
- path: .code-warden-report.json
- retention-days: 90
-```
-
-The pinned `v3.4.0` release-download path includes SARIF and `--out` support.
-
-Full template: [`code-warden/templates/ci/github-actions.yml`](code-warden/templates/ci/github-actions.yml)
+See [`RELEASE_NOTES_v4.0.0.md`](RELEASE_NOTES_v4.0.0.md).
## Release Trust
-Code-Warden releases are tag-driven. The release workflow verifies the package
-version matches the pushed tag, runs the governance gate, performs an npm
-publish dry run, publishes to npm through trusted publishing, creates a GitHub
-release, and uploads the versioned zip asset.
-
-Trusted publishing uses GitHub Actions OIDC instead of a long-lived npm token
-and lets npm attach provenance to public package publishes from public
-repositories.
+Releases are tag-driven: the workflow verifies the package version matches the
+tag, runs the governance gate, dry-runs the publish, publishes to npm through
+trusted publishing (GitHub Actions OIDC, npm provenance — no long-lived token),
+creates the GitHub release, and uploads the versioned zip.
## File Structure
| File | Purpose |
|------|---------|
| `SKILL.md` | Session gates, quick rules, drift signals, reference index |
-| `CONFIGURE.md` | Tunable thresholds and team-size profiles |
+| `CONFIGURE.md` | Tunable thresholds, scope lock, audit ledger, command rules |
| `DECISIONS.md` | Architecture decision log |
-| `references/planning-gates.md` | Scope Gate and Plan Gate contracts |
-| `references/architecture.md` | Blueprint Rule, Re-injection, State Update |
-| `references/safety.md` | Blast Radius, Patch-First, Zero-Trust, Dependency Freeze |
-| `references/cognition.md` | Think Before Coding, Don't Guess Syntax, Human Checkpoint |
-| `references/cleanup.md` | Tech Debt format, Test Contract, Decision Log |
-| `references/anti-drift.md` | Anchor Check, Session Scoping, Drift Trigger |
-| `references/operations.md` | Verification evidence, git hygiene, dependency control |
-| `references/evidence-providers.md` | External scanners, provenance, attestations, CI evidence, trust limits |
-| `references/research-and-fit.md` | Live research gate, stack fit, product-shape guardrails |
-| `references/mcp-governance.md` | MCP approval, toolset scope, credentials, consent, audit evidence |
-| `tools/lib/reference-selector.js` | Path-based governance reference recommendations |
-| `tools/lib/risk-policy.js` | Risk tier defaults, config merge, and validation |
-| `tools/receipt.js` | Governance receipt template and validation CLI |
+| `references/` | Planning gates, architecture, safety, cognition, cleanup, anti-drift, operations, evidence providers, research-and-fit, MCP governance |
+| `tools/` | Scanners, governance report, receipt/scope CLIs, hooks, shared libs |
+| `tools/hooks/claude/`, `tools/hooks/codex/` | Runtime hook scripts |
+| `tools/lib/` | Shared policy modules (config, baseline, command-risk, scope-store, audit-ledger, ...) |
## Version
-v3.4.0 — See [`CHANGELOG.md`](CHANGELOG.md) for full changelog.
+v4.0.0 — See [`CHANGELOG.md`](CHANGELOG.md) for full changelog.
## Author
diff --git a/RELEASE_NOTES_v4.0.0.md b/RELEASE_NOTES_v4.0.0.md
new file mode 100644
index 0000000..99440b0
--- /dev/null
+++ b/RELEASE_NOTES_v4.0.0.md
@@ -0,0 +1,129 @@
+# code-warden v4.0.0
+
+Enforcement layers — scope lock, command risk gate, audit ledger,
+corroborated receipts, baseline ratchet, and a git pre-commit backstop.
+
+## Upgrade note (read first)
+
+**Existing hook users MUST re-run `code-warden hooks claude` (and
+`code-warden hooks codex`).** Old registrations keep working, but they lack
+NotebookEdit and Bash/PowerShell command coverage and none of the new
+events or gates (scope lock, command risk gate, audit ledger, SessionStart
+context, Stop verification).
+
+Why this is a major version:
+
+- The governance report's `session.scopeGate` field becomes an object
+ (`{status, goal, filesIn, enforce}`) when a scope lock exists; it was
+ always a string before. JSON consumers should handle both shapes.
+- Everything under `.code-warden/` is write-protected from agents
+ unconditionally — even with no scope lock.
+- Hook registration expands from PreToolUse-only to
+ PreToolUse/PostToolUse/SessionStart/Stop.
+
+## What's new
+
+### Scope Lock (`code-warden scope`)
+
+`code-warden scope set --goal="..." ` writes
+`/.code-warden/scope.json`. While it exists with `enforce: true`,
+write hooks (Claude `Write`/`Edit`/`NotebookEdit`, Codex `apply_patch`) deny
+edits outside the declared paths and tell the agent to ask the user to run
+`code-warden scope add `. Expansions are appended to `expansions[]`
+as an audit trail. Strictly opt-in: no scope file, no enforcement.
+`--no-enforce` records scope without blocking.
+
+### Command Risk Gate
+
+Both command hooks classify shell commands against conservative defaults:
+
+- **blocked (denied)**: `rm_rf_root`, `rd_root`, `remove_item_root`,
+ `git_reset_hard`, `git_push_force` (`--force-with-lease` exempt),
+ `git_clean_force`, `git_history_rewrite`, `curl_pipe_shell`,
+ `ps_web_pipe_iex`, `chmod_777_root`
+- **high (ask on Claude; allow on Codex — no ask equivalent there)**:
+ `package_install` (bare `npm install`/`ci` allowed), `npm_publish`,
+ `git_push`, `recursive_delete`, `remove_item_recurse`,
+ `git_discard_changes`
+
+Tune via `risk_policy.command_rules`: reuse a default `id` to replace it,
+set `"tier": "off"` to disable it, or add new rules with your own patterns.
+
+### Audit ledger + corroborated receipts
+
+A PostToolUse hook appends one line per governed tool call to
+`.code-warden/audit.jsonl`, sha256 hash-chained from GENESIS — editing,
+reordering, or deleting any line breaks every later hash. Commands are
+logged secret-redacted and truncated to 300 chars. Auto-on while a scope
+lock exists; otherwise controlled by `audit.enabled` (explicit `false`
+wins). Claude sessions only — Codex has no PostToolUse surface.
+
+`code-warden receipt --from-audit --out=` prefills a draft receipt
+from the scope lock, architecture context, git branch/commit, and ledger
+evidence with chain verification. A `complete` receipt with a broken chain
+(`audit.chainValid: false`) fails validation. Schema is additive — v1
+receipts still validate.
+
+### Baseline ratchet for brownfield repos
+
+```bash
+npx code-warden report --write-baseline # record current debt as the floor
+git add .code-warden-baseline.json
+npx code-warden report --baseline # fail only NEW or WORSENED violations
+```
+
+Legacy findings are counted separately ("N new / M legacy"). Secrets are
+fingerprinted by sha256 of the trimmed matched line — baselines never store
+raw secrets. SARIF carries fresh findings only. A missing baseline file is
+a hard error. The GitHub Action gained a `baseline` input.
+
+### Git pre-commit backstop
+
+`code-warden hooks git` installs a marker-managed pre-commit hook (per-repo,
+run from the repo — unlike the per-user claude/codex hooks) that scans
+staged content (`git show :path`) for lint and secrets with full
+exclude/allowlist parity. `git commit --no-verify` bypasses it — that is
+documented honestly, not hidden. Verify with `code-warden verify git`.
+
+### Lifecycle hooks
+
+- **SessionStart** injects architecture context and scope status, so
+ sessions start governed instead of discovering rules mid-task.
+- **Stop** (opt-in via `session.verify_on_stop`, default `false`) blocks
+ session completion while FRESH lint/secret violations exist. Loop-guarded
+ and baseline-aware — legacy debt never traps a session.
+
+### Scanner and hook hardening
+
+- Six new secret patterns: Anthropic (`sk-ant-`), Google (`AIza`), GitLab
+ (`glpat-`), npm (`npm_`), Hugging Face (`hf_`), JWT. All matches per file
+ are reported, not just the first.
+- Hooks read the governed project's own `codewarden.json` (walking up from
+ cwd, stopping at the `.git` boundary), so `lint.exclude_paths`,
+ `secrets.allowlist`, and thresholds match CI behavior exactly.
+- `pre_flight_trigger_lines` now asks for confirmation on single changes
+ over 150 lines.
+
+### New config keys
+
+`lint.exclude_paths`, `secrets.allowlist` (both now hook-honored),
+`risk_policy.command_rules`, `audit.enabled`, `session.verify_on_stop`.
+
+## Honest limits
+
+- The agent can run `code-warden scope add` itself via the shell — but the
+ command is visible in the session and recorded in `expansions[]`. The
+ lock makes scope creep auditable, not impossible.
+- Codex has no "ask" permission decision and no PostToolUse hook: high-tier
+ commands allow silently there, and the audit ledger is Claude-only. CI
+ and the git backstop close part of that gap.
+- `git commit --no-verify` skips the pre-commit backstop by design.
+
+## Verification
+
+```bash
+node tools/tests/run-all-tests.js
+node tools/warden-lint.js tools
+node tools/governance-report.js .
+npx code-warden --version # 4.0.0
+```
diff --git a/action.yml b/action.yml
index d7a05db..9c283e1 100644
--- a/action.yml
+++ b/action.yml
@@ -34,6 +34,14 @@ inputs:
description: Path to a codewarden.json config file in the repo. Overrides built-in defaults.
required: false
default: ''
+ baseline:
+ description: >-
+ Path to a committed code-warden baseline file (write one locally with
+ `npx code-warden report --write-baseline`). When set and the file
+ exists, only NEW or WORSENED violations fail the gate; baselined legacy
+ findings are reported but excluded from SARIF and the result.
+ required: false
+ default: ''
outputs:
report-path:
@@ -69,7 +77,11 @@ runs:
if [ -n "${{ inputs.config }}" ] && [ -f "${{ inputs.config }}" ]; then
CONFIG_FLAG="--config=${{ inputs.config }}"
fi
- node "${{ github.action_path }}/code-warden/tools/governance-report.js" "${{ inputs.path }}" $CONFIG_FLAG
+ BASELINE_FLAG=""
+ if [ -n "${{ inputs.baseline }}" ] && [ -f "${{ inputs.baseline }}" ]; then
+ BASELINE_FLAG="--baseline=${{ inputs.baseline }}"
+ fi
+ node "${{ github.action_path }}/code-warden/tools/governance-report.js" "${{ inputs.path }}" $CONFIG_FLAG $BASELINE_FLAG
- name: Publish governance summary
if: ${{ always() && inputs.summary == 'true' }}
@@ -79,7 +91,11 @@ runs:
if [ -n "${{ inputs.config }}" ] && [ -f "${{ inputs.config }}" ]; then
CONFIG_FLAG="--config=${{ inputs.config }}"
fi
- node "${{ github.action_path }}/code-warden/tools/governance-report.js" "${{ inputs.path }}" --format=md $CONFIG_FLAG >> "$GITHUB_STEP_SUMMARY" || true
+ BASELINE_FLAG=""
+ if [ -n "${{ inputs.baseline }}" ] && [ -f "${{ inputs.baseline }}" ]; then
+ BASELINE_FLAG="--baseline=${{ inputs.baseline }}"
+ fi
+ node "${{ github.action_path }}/code-warden/tools/governance-report.js" "${{ inputs.path }}" --format=md $CONFIG_FLAG $BASELINE_FLAG >> "$GITHUB_STEP_SUMMARY" || true
- name: Generate SARIF report
if: ${{ always() && inputs.sarif == 'true' }}
@@ -89,7 +105,11 @@ runs:
if [ -n "${{ inputs.config }}" ] && [ -f "${{ inputs.config }}" ]; then
CONFIG_FLAG="--config=${{ inputs.config }}"
fi
- node "${{ github.action_path }}/code-warden/tools/governance-report.js" "${{ inputs.path }}" --format=sarif $CONFIG_FLAG > "${{ steps.sarif-path.outputs.path }}" || true
+ BASELINE_FLAG=""
+ if [ -n "${{ inputs.baseline }}" ] && [ -f "${{ inputs.baseline }}" ]; then
+ BASELINE_FLAG="--baseline=${{ inputs.baseline }}"
+ fi
+ node "${{ github.action_path }}/code-warden/tools/governance-report.js" "${{ inputs.path }}" --format=sarif $CONFIG_FLAG $BASELINE_FLAG > "${{ steps.sarif-path.outputs.path }}" || true
test -s "${{ steps.sarif-path.outputs.path }}"
- name: Upload SARIF report
diff --git a/code-warden/CONFIGURE.md b/code-warden/CONFIGURE.md
index fea81ef..27045b0 100644
--- a/code-warden/CONFIGURE.md
+++ b/code-warden/CONFIGURE.md
@@ -28,14 +28,20 @@ Located at the root of the skill folder. Default configuration:
}
```
-| Setting | Default | Rationale |
-|---------|---------|-----------|
-| `max_file_length` | 400 | Keeps files reviewable in a single pass. The `warden-lint.js` script enforces this. |
-| `pre_flight_trigger_lines` | 150 | Forces a JSON manifest before large outputs. |
-| `human_checkpoint_files` | 2 | Requires human `[AWAITING CONFIRMATION]` before modifying this many files simultaneously. |
-| `exempt_from_blast_radius` | (list) | Skips strict rewriting rollback plans on these file directories. |
-| `lint.exclude_paths` | `[]` | Path prefixes excluded from file-length checks. Use for docs, generated files, or vendored code (e.g. `["Documents/", "generated/"]`). |
-| `secrets.allowlist` | `[]` | Path prefixes excluded from hardcoded-credential scanning. Use for files with known-safe localhost dev URLs or test fixtures (e.g. `["scripts/indexer.config.toml"]`). |
+| Setting | Default | Enforced by | Rationale |
+|---------|---------|-------------|-----------|
+| `max_file_length` | 400 | hook + CI | Keeps files reviewable in a single pass. Enforced by `warden-lint.js`, the governance report, and the runtime hooks. |
+| `pre_flight_trigger_lines` | 150 | hook | The Claude lint hook asks for confirmation (`permissionDecision: "ask"`) when a single Write/Edit change exceeds this many lines without breaching `max_file_length`. |
+| `human_checkpoint_files` | 2 | prompt | Requires human `[AWAITING CONFIRMATION]` before modifying this many files simultaneously. Protocol rule only — no runtime hook or CI check enforces it. |
+| `exempt_from_blast_radius` | (list) | prompt | Skips strict rewriting rollback plans on these file directories. Protocol rule only — no runtime hook or CI check enforces it. |
+| `lint.exclude_paths` | `[]` | hook + CI | Path prefixes excluded from file-length checks. Use for docs, generated files, or vendored code (e.g. `["Documents/", "generated/"]`). |
+| `secrets.allowlist` | `[]` | hook + CI | Path prefixes excluded from hardcoded-credential scanning. Use for files with known-safe localhost dev URLs or test fixtures (e.g. `["scripts/indexer.config.toml"]`). |
+| `risk_policy.command_rules` | `[]` | hook | Per-rule overrides for the Command Risk Gate. Entries `{ "id", "pattern", "tier", "message" }` merge with the built-in defaults: reusing a default `id` replaces that rule, and `"tier": "off"` (or `"allow"`) disables it. `"blocked"` denies the shell command; `"high"` asks for confirmation (Claude) or allows silently (Codex has no ask equivalent). |
+| `audit.enabled` | not set | hook | Audit ledger (Claude PostToolUse). Unset: the ledger is on only while a scope lock exists. `true`: always on for the project. `false`: always off, even with a scope lock. |
+| `session.verify_on_stop` | `false` | hook | Opt-in Stop verification (Claude). When `true`, the Stop hook re-scans the project for fresh file-length/secret violations before the session may finish. Off by default - the hook is registered but inert. |
+| `.code-warden/scope.json` (managed via `code-warden scope`) | not set | hook | Opt-in Scope Lock. When present with `"enforce": true`, write hooks (Claude Write/Edit/NotebookEdit, Codex apply_patch) deny edits outside the declared `filesIn` paths. No file means no enforcement. Everything under `.code-warden/` is write-protected from agents unconditionally - scope lock or not. |
+
+"Enforced by" legend: **hook** = runtime lifecycle hooks (PreToolUse gates, PostToolUse audit, SessionStart context, Stop verification), **CI** = `warden-lint.js` / `verify-secrets.js` / `governance-report.js`, **prompt** = governance protocol text only (the agent is instructed to comply, but nothing blocks it at runtime).
---
@@ -45,3 +51,117 @@ Located at the root of the skill folder. Default configuration:
2. **Modify** the specific rule or threshold inside the JSON structural fields.
3. The executable tools (`tools/warden-lint.js`, etc.) read these limits dynamically so no Markdown files need to be edited to enforce limits.
4. **Log the change** in `DECISIONS.md` so your team knows why the default was overridden.
+
+## Scope Lock
+
+The Scope Lock mechanically enforces the Scope Gate's files-in contract.
+Declare the goal and the paths the session may touch:
+
+```
+code-warden scope set --goal="Fix auth bug" src/ lib/utils.js
+```
+
+This writes `/.code-warden/scope.json`. While it exists, any agent
+write outside the declared paths is denied at the hook layer. The agent sees:
+
+```
+[CodeWarden] Scope lock: docs/notes.md is outside the declared scope
+(goal: Fix auth bug). Ask the user to approve expansion via:
+code-warden scope add docs/notes.md
+```
+
+Expansions are appended to `expansions[]` in the scope file as an audit trail.
+The scope file itself is self-protected: hooks deny agent edits to anything
+under `.code-warden/`, even when `enforce` is `false`.
+
+Honest limits: if the agent runs `code-warden scope add` itself via the shell,
+that command is visible in your session and the expansion is recorded in
+`expansions[]` - the lock makes scope creep auditable, not impossible.
+Analogously, `git commit --no-verify` bypasses the git pre-commit backstop.
+
+## Command Risk Gate Defaults
+
+The command hooks (Claude `Bash`/`PowerShell`, Codex `Bash`) classify commands
+against these built-in rules. `blocked` denies; `high` asks for confirmation
+on Claude and allows silently on Codex (no ask equivalent there).
+
+| Rule id | Tier | Matches |
+|---------|------|---------|
+| `rm_rf_root` | blocked | `rm -rf` of `/`, `~`, `.`, `..`, `*`, `.git`, or a drive root |
+| `rd_root` | blocked | `rd`/`rmdir /s` of a drive root |
+| `remove_item_root` | blocked | `Remove-Item -Recurse -Force` of a critical root path |
+| `git_reset_hard` | blocked | `git reset --hard` |
+| `git_push_force` | blocked | `git push --force`/`-f` (`--force-with-lease` exempt) |
+| `git_clean_force` | blocked | `git clean -f` |
+| `git_history_rewrite` | blocked | `git filter-branch` / `filter-repo` |
+| `curl_pipe_shell` | blocked | `curl`/`wget` piped into a shell |
+| `ps_web_pipe_iex` | blocked | `iwr`/`irm` piped into `Invoke-Expression` |
+| `chmod_777_root` | blocked | `chmod -R 777 /` |
+| `package_install` | high | Dependency add/remove/update (bare `npm install`/`ci` allowed) |
+| `npm_publish` | high | `npm`/`pnpm`/`yarn`/`bun publish` |
+| `git_push` | high | Any `git push` |
+| `recursive_delete` | high | Any recursive delete (`rm -r`, `rd /s`) |
+| `remove_item_recurse` | high | Any `Remove-Item -Recurse -Force` |
+| `git_discard_changes` | high | `git checkout --` / `git restore` (working-tree discard) |
+
+Override in `risk_policy.command_rules`: reuse a default `id` to replace its
+pattern/tier/message, set `"tier": "off"` (or `"allow"`) to disable it, or add
+new rules with your own `pattern`. Invalid user patterns are skipped with a
+warning — a broken regex never widens or traps the gate.
+
+## Baseline Ratchet (brownfield repos)
+
+```
+npx code-warden report --write-baseline
+git add .code-warden-baseline.json
+npx code-warden report --baseline
+```
+
+With `--baseline`, only NEW or WORSENED violations fail; legacy findings are
+counted separately. A baselined file fails again when it grows past its
+recorded line count. Secrets are fingerprinted by sha256 of the trimmed
+matched line - the baseline never stores raw secret text. A missing baseline
+file is a hard error. The git pre-commit backstop and the Stop hook are also
+baseline-aware.
+
+## Audit Ledger
+
+Claude sessions append one line per governed tool call (Write/Edit/
+NotebookEdit/Bash/PowerShell) to `/.code-warden/audit.jsonl` via a
+PostToolUse hook. Each entry is chained to the previous one:
+`hash = sha256(prev + canonical entry JSON)`, with the first entry chained to
+`GENESIS` - editing, reordering, or deleting any line breaks every later
+hash, and `code-warden receipt --from-audit` reports the first broken line.
+
+Enablement: on automatically while a scope lock exists; force it with
+`"audit": { "enabled": true }` or disable it entirely with
+`"audit": { "enabled": false }` (explicit `false` wins over a scope lock).
+
+Shell commands are logged secret-redacted (`[REDACTED:]`) and
+truncated to 300 characters; file targets are logged as repo-relative paths.
+The ledger never stores file contents or credentials.
+
+Honest limits: the ledger applies to Claude sessions only - the Codex hook
+surface here has no PostToolUse equivalent. Do not commit `audit.jsonl`:
+gitignore it in governed repos. It is per-session evidence; receipts built
+from it (`receipt --from-audit`) are the durable, reviewable artifact.
+
+## Stop Verification
+
+With `"session": { "verify_on_stop": true }`, the Claude Stop hook runs the
+same in-process file-length and secrets scans as the governance report
+(no behavioral tests, no git) when the agent tries to finish, and blocks
+completion while FRESH violations exist. It is loop-guarded
+(`stop_hook_active` exits immediately; Claude Code also caps consecutive
+blocks) and baseline-aware: with a `.code-warden-baseline.json` at the
+project root, only new-or-worsened violations block - legacy debt never
+traps a session.
+
+## Project-Level Configuration
+
+Runtime hooks discover a governed project's own `codewarden.json` by walking
+up from the working directory, checking each level for `codewarden.json` or
+`code-warden/codewarden.json` and stopping at the repository root (the first
+directory containing `.git`). When found, that project config takes precedence
+over the skill-dir default, so `lint.exclude_paths`, `secrets.allowlist`, and
+thresholds behave the same in hooks as they do in CI (`--config=`).
diff --git a/code-warden/DECISIONS.md b/code-warden/DECISIONS.md
index 7f96029..8486a16 100644
--- a/code-warden/DECISIONS.md
+++ b/code-warden/DECISIONS.md
@@ -17,6 +17,78 @@ Each entry:
---
+## 2026-06-10 - Scope lock is opt-in and CLI-owned
+
+- **Decision**: The scope lock lives in `/.code-warden/scope.json`, is created and expanded only through the user-run `code-warden scope` CLI, and is strictly opt-in (no file means no enforcement). Agent writes to anything under `.code-warden/` are denied unconditionally by the write hooks, scope lock or not.
+- **Alternatives considered**: Auto-locking scope from the chat-declared Scope Gate — rejected because the CLI cannot verify what was confirmed in chat, and silent auto-locks would surprise users. Letting agents edit the scope file with logging — rejected because a lock the locked party can rewrite is not a lock.
+- **Reasoning**: Enforcement must be anchored in an artifact the agent cannot modify. Keeping it opt-in preserves the existing zero-friction default; keeping it CLI-owned means every expansion is a visible user action recorded in `expansions[]`. Honest limit: an agent can run `scope add` via the shell, but the command is visible in session and the expansion is audited — the lock makes scope creep auditable, not impossible.
+- **Files affected**: `tools/scope.js`, `tools/lib/scope-store.js`, `tools/hooks/claude/warden-scope-hook.js`, `tools/hooks/codex/warden-apply-patch-hook.js`, `bin/code-warden.js`, `tools/tests/scope-tests.js`
+
+---
+
+## 2026-06-10 - Command risk gate defaults conservative with id-based overrides
+
+- **Decision**: Ship a Command Risk Gate in both command hooks with two enforced tiers: `blocked` denies outright (root-targeting recursive deletes, `git reset --hard`, force push, `git clean -f`, history rewrites, pipe-to-shell, `chmod -R 777 /`); `high` asks for confirmation on Claude and allows silently on Codex, which exposes no "ask" decision (dependency changes, publish, push, recursive deletes, working-tree discards). Rules merge by `id`: reusing a default id replaces it, `"tier": "off"`/`"allow"` disables it, and invalid user patterns are skipped with warnings.
+- **Alternatives considered**: Block the `high` tier too — rejected because a false deny on a routine `git push` erodes trust faster than a missed exotic command. Pattern-list config without ids — rejected because users could not surgically disable or replace one default rule.
+- **Reasoning**: The defaults target irreversible or remote-code-execution commands only; everything else is a question, not a wall. The Codex asymmetry is documented rather than papered over: where the runtime cannot ask, Code-Warden does not pretend it asked.
+- **Files affected**: `tools/lib/command-risk.js`, `tools/hooks/claude/warden-command-hook.js`, `tools/hooks/codex/warden-bash-hook.js`, `codewarden.json`, `tools/tests/command-risk-tests.js`
+
+---
+
+## 2026-06-10 - Baselines ratchet: growth is a fresh violation, fingerprints carry no secrets
+
+- **Decision**: `report --baseline` fails only NEW or WORSENED violations; legacy findings are reported separately ("N new / M legacy"). A baselined oversized file fails again the moment it grows past its recorded count. Baselined secrets are fingerprinted by sha256 of the trimmed matched line — raw secret text never enters the baseline. A missing baseline file is a hard error, and SARIF carries fresh findings only.
+- **Alternatives considered**: Grandfather files at any size until refactored — rejected because that lets legacy files absorb unlimited new code. Store line numbers for secrets — rejected because line drift would silently break the baseline; content hashes survive moves. Treat a missing baseline as "no baseline" — rejected because a typo'd path must not silently disable the gate.
+- **Reasoning**: Brownfield repos cannot adopt a hard gate that fails on day one for pre-existing debt. Ratcheting freezes the debt floor while keeping full enforcement for everything new.
+- **Files affected**: `tools/lib/baseline.js`, `tools/governance-report.js`, `action.yml`, `templates/ci/github-actions.yml`, `tools/tests/baseline-tests.js`
+
+---
+
+## 2026-06-10 - Audit ledger is hash-chained and auto-enabled by scope locks
+
+- **Decision**: A PostToolUse hook appends one entry per governed tool call to `.code-warden/audit.jsonl`, each entry hashed as `sha256(prev + canonical entry JSON)` anchored at `GENESIS`. Commands are secret-redacted and truncated to 300 chars; no file contents are stored. Enablement: on while a scope lock exists, else `audit.enabled` config, with explicit `false` winning over a scope lock. Claude sessions only.
+- **Alternatives considered**: Plain append-only JSONL without chaining — rejected because tamper-evidence is the point of an audit artifact. Always-on ledger — rejected because ungoverned casual sessions should not accumulate per-call logs by default.
+- **Reasoning**: A scope lock is an explicit signal that the session is governed, so evidence collection should follow automatically. The hash chain makes edits detectable rather than preventable — consistent with Code-Warden's honesty-over-theater posture. The ledger is per-session evidence and should be gitignored; receipts are the durable artifact.
+- **Files affected**: `tools/lib/audit-ledger.js`, `tools/hooks/claude/warden-audit-hook.js`, `tools/lib/hook-events.js`, `tools/tests/audit-ledger-tests.js`
+
+---
+
+## 2026-06-10 - Receipts graduate from honest to corroborated
+
+- **Decision**: `receipt --from-audit[=path] --out=` prefills a draft receipt from the scope lock (confirmed/goal/filesIn), discovered architecture context, git branch/commit, and audit-ledger evidence including chain verification. A `complete` receipt whose `audit.chainValid` is `false` fails validation. The audit block is additive — schemaVersion 1 receipts without it still validate.
+- **Alternatives considered**: Mark from-audit receipts complete automatically — rejected because the CLI still cannot verify the human confirmations; drafts stay drafts until a person finishes them. A new schema version — rejected because the change is purely additive and a version bump would orphan existing receipts.
+- **Reasoning**: v3.4.0 receipts were honest but unsupported claims. Corroboration ties the claimed scope to a tamper-evident record of what actually happened, without overclaiming what the tooling can know.
+- **Files affected**: `tools/receipt.js`, `tools/lib/receipt-audit.js`, `tools/lib/git-info.js`, `tools/lib/context-discovery.js`, `tools/tests/receipt-audit-tests.js`
+
+---
+
+## 2026-06-10 - Git pre-commit is the runtime-agnostic backstop
+
+- **Decision**: `code-warden hooks git` installs a marker-managed pre-commit hook in the repository at cwd (per-repo, unlike the per-user claude/codex hooks) that scans staged content via `git show :path` with the same exclude/allowlist behavior as CI. `git commit --no-verify` bypasses it, and the docs say so plainly.
+- **Alternatives considered**: Husky or a hook framework — rejected to preserve zero dependencies. Scanning the working tree instead of the index — rejected because the commit gate must judge what is being committed, not what happens to be on disk.
+- **Reasoning**: Runtime hooks only cover agents whose runtimes expose hook surfaces. The pre-commit hook catches anything that reaches `git commit` — any agent, any editor, any human — making it the broadest local layer between the prompt and CI. Marker management keeps installs idempotent and uninstalls surgical alongside user-owned hook content.
+- **Files affected**: `tools/lib/hook-dispatch.js`, `install.js`, `bin/code-warden.js`, `tools/tests/git-hook-tests.js`
+
+---
+
+## 2026-06-10 - Stop verification is opt-in to avoid hostile UX
+
+- **Decision**: The Stop hook is registered for all hook installs but inert until `session.verify_on_stop` is `true`. When enabled, it re-runs fast in-process lint/secret scans and blocks completion only on FRESH violations (baseline-aware), with a loop guard honoring `stop_hook_active`.
+- **Alternatives considered**: On by default — rejected because a session that cannot end while legacy debt exists is hostile, especially in brownfield repos. Running the full governance report on Stop — rejected because behavioral tests and git subprocesses are too slow for an end-of-turn gate.
+- **Reasoning**: Blocking an agent's completion is the most intrusive enforcement point available; it must be deliberate, fast, loop-safe, and unable to trap users on pre-existing problems.
+- **Files affected**: `tools/hooks/claude/warden-stop-hook.js`, `tools/lib/scan-core.js`, `codewarden.json`, `tools/tests/lifecycle-hook-tests.js`
+
+---
+
+## 2026-06-10 - Hooks read the governed project's config for CI/hook parity
+
+- **Decision**: All hooks discover the governed project's own `codewarden.json` by walking up from the working directory (checking `codewarden.json` and `code-warden/codewarden.json` at each level, stopping at the first `.git` boundary), falling back to the installed skill's config.
+- **Alternatives considered**: Skill-dir config only — rejected because hooks and CI then enforce different thresholds in the same repo, which users experience as nondeterminism. Per-project hook registration — rejected because Claude/Codex hooks are user-level and should not require re-registration per repo.
+- **Reasoning**: A governance gate that gives different answers locally and in CI trains users to ignore it. The `.git` boundary stops a session from inheriting config from outside the repository.
+- **Files affected**: `tools/lib/config.js`, all hooks under `tools/hooks/`, `tools/tests/config-discovery-tests.js`
+
+---
+
## 2026-05-19 - Codex hook install owns feature-flag enablement
- **Decision**: `--hooks=codex` now enables `[features].hooks = true` in `~/.codex/config.toml` and removes deprecated `[features].codex_hooks` entries when found. Doctor and `--verify-target=codex` validate feature enablement when Code-Warden Codex hooks are registered.
diff --git a/code-warden/README.md b/code-warden/README.md
index 3003b5b..e828c80 100644
--- a/code-warden/README.md
+++ b/code-warden/README.md
@@ -4,9 +4,11 @@
Code-Warden provides verifiable governance for AI-assisted development.
It does not just ask agents to follow rules. It makes them declare scope,
-patch order, blast radius, and verification before code is accepted. Local
-checks, CI enforcement, runtime hooks, and report artifacts keep that contract
-auditable after the chat scrolls away.
+patch order, blast radius, and verification before code is accepted — and
+where the runtime allows it, denies the violation before it happens. Local
+checks, runtime hooks, a git pre-commit backstop, CI enforcement, audit
+ledgers, and receipt artifacts keep that contract auditable after the chat
+scrolls away.
## Four Layers
@@ -14,35 +16,36 @@ auditable after the chat scrolls away.
-| Layer | What it does |
-|-------|-------------|
-| **Skill governance** | Scope Gate, Plan Gate, blast-radius checks, patch-first editing, research gates, drift signals, verification evidence |
-| **Local verification** | `warden-lint`, `verify-secrets`, `get-context` — directory-aware, no external deps |
-| **Installer and health** | Cross-app auto-installer, manifest-backed installs, `--doctor`, `--verify-target`, Windsurf adapter |
-| **Hard enforcement** | Claude Code `PreToolUse` hooks — block oversized writes and hardcoded secrets before the file system is touched |
+| Layer | What it does | Honest bypass |
+|-------|--------------|---------------|
+| **Prompt governance** | Scope Gate, Plan Gate, blast-radius checks, drift signals, verification evidence | Protocol text only — nothing blocks |
+| **Runtime hooks** | Claude lifecycle hooks (PreToolUse gates, PostToolUse audit, SessionStart context, Stop verification); Codex PreToolUse (partial) | Only where the runtime exposes surfaces |
+| **Git backstop** | Per-repo pre-commit scanning staged content for lint/secrets | `git commit --no-verify` |
+| **CI** | `governance-report.js` / GitHub Action — deterministic gate + JSON/Markdown/SARIF evidence | Last line before merge |
## Governance Evidence
-Generate a machine-readable governance report that can be stored in CI, attached to PRs, or used as audit evidence:
+Generate a machine-readable governance report for CI, PRs, or audit:
```bash
-node tools/governance-report.js . # write .code-warden-report.json + summary
-node tools/governance-report.js . --format=json # JSON to stdout
+node tools/governance-report.js . # write .code-warden-report.json + summary
node tools/governance-report.js . --format=md # Markdown to stdout
-node tools/governance-report.js . --format=sarif # SARIF to stdout
node tools/governance-report.js . --format=sarif --out=code-warden.sarif
+node tools/governance-report.js . --write-baseline # record current debt as the ratchet floor
+node tools/governance-report.js . --baseline # fail only NEW or WORSENED violations
```
-The report runs all checks in a single pass (file length, secrets, behavioral tests, source integrity) and produces a structured artifact:
+The report runs file length, secrets, behavioral tests, source integrity, and
+risk policy in one pass:
```json
{
"tool": "code-warden",
- "version": "3.4.0",
+ "version": "4.0.0",
"checks": {
- "fileLength": { "status": "pass", "filesScanned": 44, "violations": 0 },
- "secrets": { "status": "pass", "filesScanned": 44, "violations": 0 },
- "behavioralTests": { "status": "pass", "tests": 21, "failures": 0 },
+ "fileLength": { "status": "pass", "filesScanned": 60, "violations": 0 },
+ "secrets": { "status": "pass", "filesScanned": 60, "violations": 0 },
+ "behavioralTests": { "status": "pass" },
"installHealth": { "status": "pass" },
"riskPolicy": { "status": "pass" }
},
@@ -50,69 +53,66 @@ The report runs all checks in a single pass (file length, secrets, behavioral te
}
```
-In CI, the Markdown format pipes directly into `$GITHUB_STEP_SUMMARY` for PR-visible evidence:
+The report's `session.scopeGate` is the string `"session_only"` normally, but
+becomes `{ "status": "locked", "goal", "filesIn", "enforce" }` when a scope
+lock exists — JSON consumers should handle both shapes (changed in v4.0.0).
-| Check | Result | Details |
-|-------|--------|---------|
-| File length | PASS | 44 files scanned, 0 violations |
-| Hardcoded credentials | PASS | 44 files scanned, 0 violations |
-| Behavioral tests | PASS | 24 tests, 0 failures |
-| Install health | PASS | All source files present |
-| Risk policy | PASS | 7 governed actions |
+SARIF output is intentionally limited to source-located findings
+(`CW001/max-file-length`, `CW002/hardcoded-credential`); with a baseline it
+carries fresh findings only. JSON remains the canonical governance artifact.
-See [`templates/ci/github-actions.yml`](templates/ci/github-actions.yml) for the full CI template with artifact upload.
+### Baseline ratchet (brownfield adoption)
-SARIF output is intentionally limited to source-located findings:
-`CW001/max-file-length` and `CW002/hardcoded-credential`. The JSON report
-remains the canonical governance artifact for behavioral tests, install health,
-runtime hook registration, and session gate evidence.
+```bash
+npx code-warden report --write-baseline
+git add .code-warden-baseline.json
+npx code-warden report --baseline # reports "N new / M legacy", fails on new only
+```
+
+Baselined oversized files fail again the moment they grow. Baselined secrets
+are fingerprinted by sha256 of the trimmed matched line — the baseline never
+contains raw secret text. A missing baseline file is a hard error.
### Governance Receipts
-Reports prove repository checks ran. Receipts record the human-confirmed session
-contract that happened before edits:
+Reports prove repository checks ran. Receipts record the session contract —
+and since v4.0.0 they can be corroborated by the audit ledger:
```bash
-code-warden receipt --template --out=code-warden-receipt.json
+code-warden receipt --template --out=code-warden-receipt.json # blank draft
+code-warden receipt --from-audit --out=code-warden-receipt.json # prefilled draft
code-warden receipt --validate=code-warden-receipt.json
```
-Receipt templates start as `draft` and `canProveCompliance: false`. Validation
-only passes after Scope Gate, Plan Gate, and final command evidence fields are
-filled. Code-Warden will not claim chat compliance that was not recorded.
+`--from-audit` prefills the scope gate from `.code-warden/scope.json`, the
+architecture context, git branch/commit, and ledger evidence including hash
+chain verification. Receipts still start as drafts (`canProveCompliance:
+false`) — a human completes them — and a `complete` receipt whose
+`audit.chainValid` is `false` fails validation. Code-Warden will not claim
+chat compliance that was not recorded.
### GitHub Action
-Use the repository action when you want the shortest CI setup:
-
-```yaml
-- name: Code-Warden Governance Gate
- uses: Kodaxadev/Code-Warden@v3
- with:
- path: .
-```
-
-The action runs `tools/governance-report.js`, writes
-`.code-warden-report.json`, appends a Markdown summary, and uploads the report
-artifact by default.
-
-Enable GitHub Code Scanning annotations by adding `sarif: 'true'` and granting
-the workflow `security-events: write` permission:
-
```yaml
permissions:
contents: read
- security-events: write
+ security-events: write # only needed for sarif: 'true'
steps:
- uses: actions/checkout@v6
- name: Code-Warden Governance Gate
- uses: Kodaxadev/Code-Warden@v3
+ uses: Kodaxadev/Code-Warden@v4
with:
path: .
- sarif: 'true'
+ baseline: .code-warden-baseline.json # optional ratchet mode
+ sarif: 'true' # optional Code Scanning upload
```
+The action writes `.code-warden-report.json`, appends a Markdown summary,
+uploads the report artifact, and fails the job when the gate fails. See
+[`templates/ci/github-actions.yml`](templates/ci/github-actions.yml) for the
+release-download alternative.
+
## Install
```bash
@@ -122,214 +122,154 @@ npx code-warden verify codex # or your target runtime
npx code-warden report
```
-Or install globally:
-
-```bash
-npm install -g code-warden
-code-warden init
-code-warden doctor
-code-warden verify codex # or your target runtime
-code-warden report
-```
-
-Optional hard hooks:
+Optional hard enforcement:
```bash
-code-warden hooks claude
-code-warden hooks codex
+code-warden hooks claude # per-user lifecycle hooks
+code-warden hooks codex # per-user, partial surfaces
+code-warden hooks git # per-repo pre-commit backstop (run from the repo)
code-warden doctor
```
`doctor` verifies installed manifests, hook script paths, and runtime hook
-config when hooks are registered. If hook setup is partial, it prints the repair
-command to rerun.
-
-Target one runtime when troubleshooting:
-
-```bash
-code-warden init --target=codex
-code-warden verify codex
-code-warden hooks codex
-```
+config, with repair guidance for partial setup. Target one runtime when
+troubleshooting: `code-warden init --target=codex && code-warden verify codex`.
### CLI commands
| Command | Purpose |
|---------|---------|
| `code-warden init` | Install to all detected AI runtimes |
-| `code-warden report` | Generate governance report |
-| `code-warden report --format=md` | Markdown output for PR summaries |
-| `code-warden report --format=sarif` | SARIF output for Code Scanning |
-| `code-warden report --format=sarif --out=code-warden.sarif` | Write SARIF to a file |
-| `code-warden receipt --template --out=code-warden-receipt.json` | Write a draft Scope Gate / Plan Gate receipt |
-| `code-warden receipt --validate=code-warden-receipt.json` | Validate completed receipt evidence |
-| `code-warden references ` | Recommend focused governance references for touched paths |
-| `code-warden smoke-npx --package=code-warden@latest` | Smoke-test npm package from a clean temp directory |
+| `code-warden report` | Governance report (`--format=md\|sarif`, `--out=`) |
+| `code-warden report --write-baseline[=path]` | Record current violations as the ratchet floor |
+| `code-warden report --baseline[=path]` | Ratchet mode: fail only new/worsened violations |
+| `code-warden scope set --goal="..." ` | Lock session scope (`--no-enforce` records without blocking) |
+| `code-warden scope add\|remove\|clear\|status` | Manage the scope lock (expansions are audited) |
+| `code-warden receipt --template --out=` | Blank draft receipt |
+| `code-warden receipt --from-audit[=path] --out=` | Draft receipt corroborated by the audit ledger |
+| `code-warden receipt --validate=` | Validate completed receipt evidence |
+| `code-warden references ` | Recommend governance references for touched paths |
| `code-warden doctor` | Verify source integrity + install health |
-| `code-warden verify ` | Strict health check for one runtime |
+| `code-warden verify ` | Strict health check (`claude`, `codex`, `git`, ...) |
| `code-warden list` | Show detected runtimes |
-| `code-warden hooks claude` | Install Claude Code PreToolUse hooks |
-| `code-warden hooks codex` | Install Codex PreToolUse hooks (partial) |
-| `code-warden uninstall-hooks claude` | Remove Claude Code hooks |
-| `code-warden uninstall-hooks codex` | Remove Codex hooks |
+| `code-warden hooks claude\|codex\|git` | Install enforcement hooks |
+| `code-warden uninstall-hooks claude\|codex\|git` | Remove enforcement hooks |
+| `code-warden smoke-npx --package=code-warden@latest` | Smoke-test the npm package from a clean temp dir |
-### Direct installer commands
-
-| Command | Purpose |
-|---------|---------|
-| `node install.js` | Scan, prompt, install to detected apps |
-| `node install.js --all` | Install without prompt |
-| `node install.js --dry-run` | Preview installs, write nothing |
-| `node install.js --list` | Show detected apps and detection method |
-| `node install.js --doctor` | Verify source integrity + per-target install health |
-| `node install.js --target=claude,cursor` | Force specific targets (warns if not detected) |
-| `node install.js --verify-target=claude` | Strict health check — exits nonzero if not installed |
-| `node install.js --hooks=claude` | Install PreToolUse hooks into `~/.claude/settings.json` |
-| `node install.js --uninstall-hooks=claude` | Remove code-warden hook entries from settings |
-| `node install.js --target=codex --all` | Install only the Codex target |
-| `node install.js --verify-target=codex` | Strict Codex health check |
-| `node install.js --hooks=codex` | Install Codex PreToolUse hooks and enable `[features].hooks` |
-| `node install.js --uninstall-hooks=codex` | Remove Codex hook entries |
-
-Supported targets: **Claude Code**, **Cursor**, **Warp**, **OpenAI Codex**, **Windsurf**, **Generic Agents**.
-
-Each install writes a `.code-warden-install.json` manifest (version, target, format, timestamp).
+Direct installer equivalents: `node install.js [--all | --dry-run | --list |
+--doctor | --target= | --verify-target= | --hooks= |
+--uninstall-hooks=]`. Supported targets: **Claude Code**, **Cursor**,
+**Warp**, **OpenAI Codex**, **Windsurf**, **Generic Agents**. Each install
+writes a `.code-warden-install.json` manifest.
### npm scripts
```bash
npm run lint # warden-lint on full project tree
npm run check-secrets # verify-secrets on full project tree
-npm run report # governance report, writes .code-warden-report.json
-npm run report:json # governance report as JSON to stdout
-npm run report:md # governance report as Markdown to stdout
-npm run install-auto # node install.js
-npm run install-dry-run # node install.js --dry-run
-npm run install-list # node install.js --list
-npm run install-doctor # node install.js --doctor
-npm run smoke:npx # verify published package from a clean temp directory
-npm run test # behavioral tests (24 scanner/report/receipt/risk/reference/hook cases)
+npm run report # governance report (also report:json, report:md)
+npm run test # behavioral tests
npm run ci # lint + secrets + test + doctor
+npm run smoke:npx # verify published package from a clean temp directory
```
-The public CLI also exposes the package smoke helper:
-
-```bash
-code-warden smoke-npx --package=code-warden@latest
-```
-
-### Release Publishing
-
-The release workflow uses npm trusted publishing. Configure the trusted
-publisher on npm before tagging a release:
-
-- Provider: GitHub Actions
-- Owner: `Kodaxadev`
-- Repository: `Code-Warden`
-- Workflow filename: `release.yml`
-- Environment: blank unless the workflow adds one
-
-The workflow fails before publish if `code-warden/package.json` matches an npm
-version that already exists. Bump the package version before creating a release
-tag.
-
## Usage
-Load at the start of any coding session. Trigger phrases:
-
-- `"load code-warden"` / `"load protocol"`
-- `"begin coding"` / `"new session"` / `"governance check"`
-- `"start a new module"` / `"review this before we write"`
+Load at the start of any coding session: `"load code-warden"`,
+`"begin coding"`, `"new session"`, `"governance check"`.
The session sequence is enforced before any implementation:
-
-
-
-
1. Architecture State (Re-injection Rule)
2. Session Scope (Session Scoping Rule)
3. Reference Files (Blueprint Rule)
4. **Scope Gate** — goal, non-goals, files in/out, verify commands, rollback
5. **Plan Gate** — patch order, blast radius class, post-patch checks
-See [`examples/governed-session.md`](examples/governed-session.md) for an annotated example.
+After the Scope Gate is confirmed, lock it mechanically (optional):
-## Optional Runtime Hooks
+```bash
+code-warden scope set --goal="Fix auth bug" src/ lib/utils.js
+```
+
+See [`examples/governed-session.md`](examples/governed-session.md) for an
+annotated example including the v4 scope lock and receipt flow.
+
+## Runtime Hooks
-Install hard enforcement that runs at the `PreToolUse` level where the runtime exposes usable surfaces.
+### Claude Code — full lifecycle
-```bash
-node install.js --hooks=claude # full Write/Edit coverage
-node install.js --hooks=codex # partial apply_patch/Bash coverage
-```
+| Event | Hook | Policy |
+|-------|------|--------|
+| PreToolUse `Write\|Edit\|NotebookEdit` | `warden-lint-hook.js` | Deny past line limit; ask past `pre_flight_trigger_lines` (NotebookEdit exempt from length — cells are not files) |
+| PreToolUse `Write\|Edit\|NotebookEdit` | `warden-secrets-hook.js` | Deny hardcoded credentials in content |
+| PreToolUse `Write\|Edit\|NotebookEdit` | `warden-scope-hook.js` | Deny out-of-scope writes while a scope lock exists; always deny writes to `.code-warden/` |
+| PreToolUse `Bash\|PowerShell` | `warden-command-hook.js` | Deny credentials in commands; Command Risk Gate (blocked = deny, high = ask) |
+| PostToolUse (all governed tools) | `warden-audit-hook.js` | Append to the hash-chained audit ledger — never blocks |
+| SessionStart | `warden-session-hook.js` | Inject architecture context + scope status |
+| Stop | `warden-stop-hook.js` | Opt-in (`session.verify_on_stop`): block completion on fresh violations |
-### Claude Code
+### OpenAI Codex — partial
| Hook | Trigger | Policy |
|------|---------|--------|
-| `warden-lint-hook.js` | `Write` or `Edit` | Blocks if resulting file exceeds line limit |
-| `warden-secrets-hook.js` | `Write` or `Edit` | Hardcoded credential scanner — blocks if content matches any secret pattern |
+| `warden-apply-patch-hook.js` | `apply_patch` | Deny added credentials, estimated oversize, out-of-scope targets, and `.code-warden/` writes |
+| `warden-bash-hook.js` | `Bash` | Deny credentials and blocked-tier commands; high-tier allows silently (Codex has no ask equivalent) |
-### OpenAI Codex
-
-| Hook | Trigger | Policy |
-|------|---------|--------|
-| `warden-apply-patch-hook.js` | `apply_patch` | Blocks added credentials and estimates resulting file size where a path is extractable |
-| `warden-bash-hook.js` | `Bash` | Blocks command strings that contain hardcoded credentials |
-
-Codex cannot hook `Write`/`Edit` directly. CI enforcement closes the remaining gap.
-All hooks use exec form (`node /path/to/hook.js`) — no shell differences across platforms.
-The Codex installer also enables the current lifecycle-hook feature flag in
-`~/.codex/config.toml` and removes the deprecated `[features].codex_hooks`
-setting when present:
-
-```toml
-[features]
-hooks = true
-```
+Codex cannot hook `Write`/`Edit` and has no PostToolUse surface — no Codex
+audit ledger. The git backstop and CI close the remaining gap. The Codex
+installer also enables `[features].hooks = true` in `~/.codex/config.toml`
+and removes the deprecated `codex_hooks` key.
-Thresholds are read from `codewarden.json` in the installed skill directory.
+### Git backstop
-```bash
-node install.js --uninstall-hooks=claude
-node install.js --uninstall-hooks=codex
-```
+`code-warden hooks git` installs a marker-managed pre-commit hook in the
+repository at cwd. It scans **staged content** (`git show :path`) for
+file-length and secret violations, honoring `lint.exclude_paths` and
+`secrets.allowlist` exactly like CI. `git commit --no-verify` bypasses it —
+documented, not hidden. Verify with `code-warden verify git`.
-Doctor and `--verify-target=` validate hook script paths and Codex hook
-feature enablement when hooks are registered, with repair guidance for partial
-hook setup.
+All hooks use exec form (`node /path/to/hook.js`) — no shell differences
+across platforms. Hooks read the governed project's own `codewarden.json`
+(walking up from the working directory, stopping at the `.git` boundary),
+falling back to the installed skill's config — hook and CI behavior match.
## Configuration
-All thresholds in [`codewarden.json`](codewarden.json):
-
-| Setting | Default | What it controls |
-|---------|---------|-----------------|
-| `thresholds.max_file_length` | 400 | Lines before `warden-lint.js` flags a file |
-| `thresholds.pre_flight_trigger_lines` | 150 | Lines before a pre-flight manifest is required |
-| `thresholds.human_checkpoint_files` | 2 | Files touched before `[AWAITING CONFIRMATION]` is required |
-| `safety.exempt_from_blast_radius` | `tests/`, `docs/`, `scripts/` | Paths excluded from rollback-plan rule |
-| `reference_selection.rules` | 4 path rules | Maps touched paths to focused reference files |
-| `external_evidence.providers` | 4 providers | Describes approved external evidence sources and trust limits |
-| `risk_policy.actions` | 7 governed actions | Maps action classes to `low`, `medium`, `high`, or `blocked` |
-
-See [`CONFIGURE.md`](CONFIGURE.md) for team-size profiles and tuning rationale.
-
-Default risk policy treats read-only context gathering as `low`, file edits as
-`medium`, dependency/network/release operations as `high`, and destructive or
-secret-bearing actions as `blocked` until explicitly scoped.
-
-Reference selection is advisory. It helps agents load the right governance
-references for touched paths without pretending irrelevant rules disappeared.
-
-External evidence providers are descriptive in this release line. SARIF,
-attestations, provenance, and scanner output should be recorded with scope and
-trust limits before being treated as governance evidence.
+All settings in [`codewarden.json`](codewarden.json):
+
+| Setting | Default | Enforced by | What it controls |
+|---------|---------|-------------|-----------------|
+| `thresholds.max_file_length` | 400 | hook + git + CI | Line limit for `warden-lint.js` and all hooks |
+| `thresholds.pre_flight_trigger_lines` | 150 | hook | Single-change size before the Claude lint hook asks for confirmation |
+| `thresholds.human_checkpoint_files` | 2 | prompt | Files touched before `[AWAITING CONFIRMATION]` (protocol rule only) |
+| `safety.exempt_from_blast_radius` | `tests/`, `docs/`, `scripts/` | prompt | Rollback-plan exemptions (protocol rule only) |
+| `lint.exclude_paths` | `[]` | hook + git + CI | Path prefixes excluded from file-length checks |
+| `secrets.allowlist` | `[]` | hook + git + CI | Path prefixes excluded from credential scanning |
+| `risk_policy.command_rules` | `[]` | hook | Command Risk Gate overrides — id-based replace/disable, merged with defaults |
+| `risk_policy.actions` | 7 actions | CI report | Action-class to tier map (`low`/`medium`/`high`/`blocked`) |
+| `audit.enabled` | unset | hook | Audit ledger: unset = on while a scope lock exists; `true` = always; `false` = never |
+| `session.verify_on_stop` | `false` | hook | Opt-in Stop verification (Claude) |
+| `reference_selection.rules` | 4 rules | CLI (advisory) | Path-to-reference recommendations |
+| `external_evidence.providers` | 4 providers | prompt | Approved evidence sources and trust limits |
+
+"Enforced by" legend: **hook** = runtime hooks, **git** = pre-commit backstop,
+**CI** = CLI scanners and `governance-report.js`, **prompt** = protocol text
+only — the agent is instructed to comply, but no runtime check blocks it.
+
+See [`CONFIGURE.md`](CONFIGURE.md) for the scope lock, audit ledger, stop
+verification, default command-rule list, and team tuning rationale.
+
+## Upgrading to v4.0.0
+
+**Re-run `code-warden hooks claude` (and `hooks codex`).** Existing
+registrations keep working but lack NotebookEdit/command coverage and all new
+events and gates. Report consumers: handle both `scopeGate` shapes. Agents can
+no longer write anywhere under `.code-warden/`.
## Reference Files
@@ -342,40 +282,25 @@ trust limits before being treated as governance evidence.
| `references/cleanup.md` | Tech Debt format, Test Contract, Decision Log |
| `references/anti-drift.md` | Anchor Check, Session Scoping, Drift Trigger Protocol |
| `references/operations.md` | Verification, source-control hygiene, dependency control |
-| `references/evidence-providers.md` | External scanners, provenance, attestations, CI evidence, trust limits |
+| `references/evidence-providers.md` | External scanners, provenance, attestations, trust limits |
| `references/research-and-fit.md` | Live research gate, stack fit, product-shape guardrails |
-| `references/mcp-governance.md` | MCP server approval, toolset scope, credentials, consent, audit evidence |
+| `references/mcp-governance.md` | MCP server approval, toolset scope, credentials, audit evidence |
## Note for contributors
> If testing `npx code-warden` from inside the Code-Warden source checkout,
-> npm may prefer the local package context. Test from a separate directory for
-> the same behavior users will see.
-
-Run the external smoke test to exercise the published package from a clean temp
-directory:
-
-```bash
-npm run smoke:npx
-```
-
-The smoke test runs `npx code-warden@latest --version`, then
-`npx code-warden@latest report --format=json`, and verifies the report parses
-as a passing Code-Warden result.
+> npm may prefer the local package context. Test from a separate directory.
+> `npm run smoke:npx` exercises the published package from a clean temp dir.
## Release Process
-Code-Warden releases are tag-driven from GitHub Actions:
-
-1. The workflow checks that `package.json` matches the pushed `vX.Y.Z` tag.
-2. `npm run ci` verifies lint, secrets, behavioral tests, and install health.
-3. `npm publish --dry-run --access public` verifies the package contents.
-4. npm trusted publishing publishes the package without a long-lived npm token.
-5. The workflow creates a GitHub release and uploads `code-warden-vX.Y.Z.zip`.
-
-Configure npm trusted publishing for the repository before relying on the
-release workflow. Manual publishing remains a fallback, but it should be the
-exception because it does not provide the same CI-linked provenance story.
+Tag-driven from GitHub Actions: the workflow checks `package.json` against the
+pushed `vX.Y.Z` tag, runs `npm run ci`, dry-runs the publish, publishes via
+npm trusted publishing (no long-lived token), then creates the GitHub release
+with `code-warden-vX.Y.Z.zip`. The workflow fails before publish if the npm
+version already exists — bump the package version before tagging. Configure
+the npm trusted publisher (GitHub Actions / `Kodaxadev` / `Code-Warden` /
+`release.yml`) before relying on it.
## Author
diff --git a/code-warden/SKILL.md b/code-warden/SKILL.md
index 47fa915..d807d25 100644
--- a/code-warden/SKILL.md
+++ b/code-warden/SKILL.md
@@ -12,9 +12,20 @@ description: >
or any request to begin writing code.
metadata:
author: Justin Davis
- version: 3.4.0
+ version: 4.0.0
category: development-governance
changelog: |
+ v4.0.0 (2026-06-10): Enforcement layers. Scope Lock CLI (`code-warden scope`)
+ mechanically denies out-of-scope writes; Command Risk Gate denies destructive
+ shell commands and asks on high-risk ones; hash-chained audit ledger
+ (.code-warden/audit.jsonl) corroborates receipts via `receipt --from-audit`;
+ baseline ratchet (`report --write-baseline` / `--baseline`) gates brownfield
+ repos on new violations only; `hooks git` installs a per-repo pre-commit
+ backstop; SessionStart context injection and opt-in Stop verification;
+ hooks read the governed project's codewarden.json; six new secret patterns.
+ Breaking: report scopeGate is an object when a scope lock exists;
+ .code-warden/ is agent-write-protected unconditionally; re-run
+ `code-warden hooks claude` / `hooks codex` to register the new gates.
v3.4.0 (2026-05-19): Governance receipts, evidence providers, reference
selection, risk policy, MCP governance, SARIF output, and Code Scanning
integration. Added receipt --template, receipt --validate, references ,
@@ -71,7 +82,7 @@ metadata:
v2.0.0: Initial production release.
---
-# code-warden v3.4.0
+# code-warden v4.0.0
Production-grade AI development governance skill.
Load at the start of every session involving code generation, refactoring,
@@ -136,7 +147,11 @@ information above.
## Quick Rules
- **Scope Gate**: Required before every session. Declare goal, non-goals, files in/out, verify commands, rollback plan. See `references/planning-gates.md`.
+- **Scope Lock**: Ask the user to run `code-warden scope set --goal="..." ` after the Scope Gate is confirmed. While locked, hooks deny writes outside the declared paths; expansion goes through the user-run `code-warden scope add `. Never edit `.code-warden/` directly — hooks deny it unconditionally.
- **Plan Gate**: Required before any multi-file or >30-line change. Declare patch order, blast radius class, post-patch checks. See `references/planning-gates.md`.
+- **Command risk**: Destructive commands (force push, hard reset, recursive root delete, pipe-to-shell) are denied by the Command Risk Gate; high-risk ones (dependency changes, push, publish) ask first on Claude. Do not work around a denial — surface it.
+- **Audit + receipts**: Governed sessions append to a hash-chained `.code-warden/audit.jsonl`. Close sessions with `code-warden receipt --from-audit --out=` so the receipt is corroborated by ledger evidence, then validate it.
+- **Baseline ratchet**: In brownfield repos, `code-warden report --baseline` fails only NEW or WORSENED violations; never grow a baselined file or add a fresh secret.
- **Max file size**: Enforced by `warden-lint.js` (default 400 lines). Split into modules at the limit.
- **Editing mode**: Patch/diff first. No full rewrites without blast radius check.
- **Feedback mode**: Adversarial. Correctness over comfort; push back on weak logic.
@@ -179,6 +194,7 @@ Stop and re-anchor immediately if any of these appear:
| Began implementing without a confirmed Scope Gate | Stop, produce Scope Gate, await confirmation |
| Began implementing without a confirmed Plan Gate | Stop, produce Plan Gate, await confirmation |
| Touched a file not declared in Scope Gate | Stop, declare scope expansion, await approval |
+| Write denied by the Scope Lock or Command Risk Gate | Stop, report the denial verbatim, ask the user to expand scope or approve — never bypass |
| Guessed library syntax without searching docs | Search live docs, correct output |
| Used stale training data for current facts | Run live research or mark unverified |
| Chose a default stack/product shape without fit check | Compare alternatives against project constraints |
diff --git a/code-warden/bin/code-warden.js b/code-warden/bin/code-warden.js
index 79f7e86..8679c58 100644
--- a/code-warden/bin/code-warden.js
+++ b/code-warden/bin/code-warden.js
@@ -11,12 +11,13 @@ const COMMANDS = {
doctor: { desc: 'Verify source integrity and install health', run: ['install.js', '--doctor'] },
report: { desc: 'Generate governance report (.code-warden-report.json)', run: ['tools/governance-report.js', '.'] },
receipt: { desc: 'Create or validate governance receipt artifacts', run: ['tools/receipt.js'] },
+ scope: { desc: 'Manage the scope lock (.code-warden/scope.json)', run: ['tools/scope.js'] },
references: { desc: 'Recommend governance references for paths', run: ['tools/select-references.js'] },
'smoke-npx': { desc: 'Smoke-test npm package from a clean temp directory', run: ['tools/smoke-npx.js'] },
list: { desc: 'Show detected AI runtimes', run: ['install.js', '--list'] },
};
-const HOOK_TARGETS = ['claude', 'codex'];
+const HOOK_TARGETS = ['claude', 'codex', 'git'];
function usage() {
console.log('Usage: code-warden [options]\n');
@@ -24,8 +25,9 @@ function usage() {
for (const [name, { desc }] of Object.entries(COMMANDS)) {
console.log(` ${name.padEnd(22)} ${desc}`);
}
- console.log(` ${'hooks '.padEnd(22)} Install PreToolUse hooks (${HOOK_TARGETS.join(', ')})`);
- console.log(` ${'uninstall-hooks '.padEnd(22)} Remove PreToolUse hooks`);
+ console.log(` ${'hooks '.padEnd(22)} Install enforcement hooks (${HOOK_TARGETS.join(', ')})`);
+ console.log(` ${''.padEnd(22)} claude/codex are per-user; git is per-repo (run from the repo)`);
+ console.log(` ${'uninstall-hooks '.padEnd(22)} Remove enforcement hooks`);
console.log(` ${'verify '.padEnd(22)} Strict health check for one runtime`);
console.log(`\nExamples:`);
console.log(` npx code-warden init`);
@@ -35,11 +37,18 @@ function usage() {
console.log(` npx code-warden report --format=md`);
console.log(` npx code-warden report --format=sarif --out=code-warden.sarif`);
console.log(` npx code-warden receipt --template --out=code-warden-receipt.json`);
+ console.log(` npx code-warden receipt --from-audit --out=code-warden-receipt.json`);
console.log(` npx code-warden receipt --validate=code-warden-receipt.json`);
+ console.log(` npx code-warden scope set --goal="Fix auth bug" src/ lib/utils.js`);
+ console.log(` npx code-warden scope add src/middleware.js`);
+ console.log(` npx code-warden scope status`);
console.log(` npx code-warden references README.md code-warden/tools/`);
console.log(` npx code-warden smoke-npx --package=code-warden@latest`);
console.log(` npx code-warden hooks claude`);
console.log(` npx code-warden hooks codex`);
+ console.log(` npx code-warden hooks git # pre-commit backstop for the repo at cwd`);
+ console.log(` npx code-warden report --write-baseline`);
+ console.log(` npx code-warden report --baseline`);
}
function run(scriptPath, args) {
diff --git a/code-warden/codewarden.json b/code-warden/codewarden.json
index 2940ea3..dbc5804 100644
--- a/code-warden/codewarden.json
+++ b/code-warden/codewarden.json
@@ -17,6 +17,9 @@
"secrets": {
"allowlist": []
},
+ "session": {
+ "verify_on_stop": false
+ },
"reference_selection": {
"rules": [
{
@@ -66,6 +69,7 @@
}
},
"risk_policy": {
+ "command_rules": [],
"actions": {
"read_only": {
"tier": "low",
diff --git a/code-warden/examples/governed-session.md b/code-warden/examples/governed-session.md
index 188da27..a67e8a5 100644
--- a/code-warden/examples/governed-session.md
+++ b/code-warden/examples/governed-session.md
@@ -114,6 +114,38 @@ export interface TokenPayload {
---
+## v4 addendum: locking the scope and corroborating the receipt
+
+Since v4.0.0 the confirmed scope above can be mechanically enforced instead of
+relying on the agent's memory. After the `[AWAITING CONFIRMATION]` exchange,
+the user (not the agent — agent writes to `.code-warden/` are always denied)
+locks the scope:
+
+```bash
+code-warden scope set --goal="Add JWT auth middleware" \
+ src/middleware/ src/types/auth.types.ts src/routes/api.ts
+```
+
+From here, an agent write to any other path is denied at the hook layer with
+a message telling it to ask the user to run `code-warden scope add `.
+While the lock exists, every governed tool call is also appended to the
+hash-chained audit ledger (`.code-warden/audit.jsonl`).
+
+At the end of the session, the receipt is generated from that evidence
+instead of typed from memory:
+
+```bash
+code-warden receipt --from-audit --out=code-warden-receipt.json
+# prefills scopeGate (goal/filesIn/confirmed), git branch/commit,
+# architecture context, and ledger evidence with chain verification
+code-warden receipt --validate=code-warden-receipt.json
+```
+
+The receipt stays a draft until a human fills the remaining gate fields — and
+a "complete" receipt over a broken ledger chain fails validation.
+
+---
+
## What this example demonstrates
| Rule | Where it fired |
diff --git a/code-warden/install.js b/code-warden/install.js
index 0ddde2e..d9d3789 100644
--- a/code-warden/install.js
+++ b/code-warden/install.js
@@ -13,7 +13,8 @@
* node install.js --verify-target=claude # strict health check for one target; exits nonzero if unknown or not installed
* node install.js --verify-target=claude,warp # check multiple targets
* node install.js --target=claude,cursor # force specific targets (warns if not detected)
- * node install.js --hooks=claude # install PreToolUse hooks into ~/.claude/settings.json
+ * node install.js --hooks=claude # install lifecycle hooks (PreToolUse/PostToolUse/SessionStart/Stop) into ~/.claude/settings.json
+ * node install.js --hooks=git # install per-repo pre-commit backstop (repo at cwd)
* node install.js --uninstall-hooks=claude # remove code-warden hook entries from ~/.claude/settings.json
*/
@@ -25,6 +26,8 @@ const { TARGETS } = require('./tools/auto-targets');
const { scanTargets } = require('./tools/auto-detect');
const { installWindsurf } = require('./tools/auto-windsurf-adapter');
const { getCodexHookRepairHint, inspectHookEntries, inspectHooksFeature } = require('./tools/lib/codex-config');
+const { dispatchHooks, verifyGitHooks } = require('./tools/lib/hook-dispatch');
+const { collectMarkedEntries } = require('./tools/lib/hook-events');
// ---------------------------------------------------------------------------
// Config
@@ -157,10 +160,6 @@ function parseArgs(argv) {
};
}
-// ---------------------------------------------------------------------------
-// Main
-// ---------------------------------------------------------------------------
-
// ---------------------------------------------------------------------------
// Doctor helpers — shared by --doctor and --verify-target
// ---------------------------------------------------------------------------
@@ -209,7 +208,7 @@ function checkTarget(t, issues) {
if (t.id === 'claude') {
const sp = path.join(t.skillsDir, '..', 'settings.json');
if (fs.existsSync(sp)) { try {
- const cw=(JSON.parse(fs.readFileSync(sp,'utf8'))?.hooks?.PreToolUse||[]).flatMap(m=>m.hooks||[]).filter(h=>String(h.description||'').startsWith('code-warden:'));
+ const cw=collectMarkedEntries(JSON.parse(fs.readFileSync(sp,'utf8'))?.hooks); // all managed events
if(cw.length>0){check(` Hooks registered (${cw.length})`,true);cw.forEach(h=>{const p=h.args&&h.args[0];check(` Hook script: ${path.basename(p||'?')}`,!!(p&&fs.existsSync(p)));});}
} catch { fail(' settings.json parse error'); issues.push('claude: settings.json'); } }
}
@@ -246,16 +245,17 @@ function runDoctor(scanned) {
// ---------------------------------------------------------------------------
function runVerifyTarget(ids) {
- const knownIds = TARGETS.map(t => t.id);
- const issues = [];
+ const knownIds = TARGETS.map(t => t.id);
+ const issues = [];
+ const runtimeIds = ids.filter(id => id !== 'git'); // 'git' is per-repo, not a runtime target
// Validate all requested IDs before doing any checks
- const unknown = ids.filter(id => !knownIds.includes(id));
+ const unknown = runtimeIds.filter(id => !knownIds.includes(id));
if (unknown.length > 0) {
for (const id of unknown) {
console.error(`[CodeWarden] [FAIL] Unknown target ID: "${id}"`);
}
- console.error(`[CodeWarden] Known IDs: ${knownIds.join(', ')}`);
+ console.error(`[CodeWarden] Known IDs: ${knownIds.join(', ')}, git`);
process.exit(1);
}
@@ -263,10 +263,11 @@ function runVerifyTarget(ids) {
console.log('');
log(`Checking target(s): ${ids.join(', ')}\n`);
- for (const id of ids) {
+ for (const id of runtimeIds) {
const t = TARGETS.find(t => t.id === id);
checkTarget(t, issues);
}
+ if (ids.includes('git')) issues.push(...verifyGitHooks({ ok, fail }));
const count = issues.length;
log(`Verify-target complete. ${count === 0 ? 'No issues found.' : `${count} issue(s) found.`}`);
@@ -294,27 +295,8 @@ async function main() {
}
if (hooksTarget || uninstallHooksTarget) { // --hooks / --uninstall-hooks dispatch
- const HOOK_TARGETS = { claude: 'Claude Code', codex: 'OpenAI Codex' };
- const ids = hooksTarget || uninstallHooksTarget;
- const bad = ids.filter(id => !HOOK_TARGETS[id]);
- if (bad.length > 0) {
- console.error(`[CodeWarden] hooks support: ${Object.keys(HOOK_TARGETS).join(', ')}. Unknown: ${bad.join(', ')}`);
- process.exit(1);
- }
- for (const id of ids) {
- const skillDir = path.join(TARGETS.find(t => t.id === id).skillsDir, SKILL_NAME);
- const mod = require(`./tools/hooks/${id}/${hooksTarget ? 'install' : 'uninstall'}-hooks`);
- if (hooksTarget) {
- log(`Installing hooks for ${HOOK_TARGETS[id]}...`);
- mod.installHooks(skillDir);
- ok('Hook entries written');
- log(`Restart ${HOOK_TARGETS[id]} for hooks to take effect.`);
- } else {
- log(`Removing hooks for ${HOOK_TARGETS[id]}...`);
- mod.uninstallHooks();
- log(`Restart ${HOOK_TARGETS[id]} for changes to take effect.`);
- }
- }
+ dispatchHooks({ ids: hooksTarget || uninstallHooksTarget, uninstall: !hooksTarget,
+ targets: TARGETS, skillName: SKILL_NAME, log, ok });
return;
}
@@ -389,7 +371,7 @@ async function main() {
console.log('');
log(`Done. ${success} ${dryRun ? 'planned' : 'installed'}, ${failure} failed.`);
- if (!dryRun) log('Next: run `code-warden doctor`, then `code-warden report`.\n[CodeWarden] Optional hard hooks: `code-warden hooks claude` or `code-warden hooks codex`.\n[CodeWarden] Restart or refresh your agent session to load the updated skill.');
+ if (!dryRun) log('Next: run `code-warden doctor`, then `code-warden report`.\n[CodeWarden] Optional hard hooks: `code-warden hooks claude`, `code-warden hooks codex`, or per-repo `code-warden hooks git`.\n[CodeWarden] Restart or refresh your agent session to load the updated skill.');
if (failure > 0) process.exit(1);
}
diff --git a/code-warden/package.json b/code-warden/package.json
index 23a5d28..2e7c7b1 100644
--- a/code-warden/package.json
+++ b/code-warden/package.json
@@ -1,6 +1,6 @@
{
"name": "code-warden",
- "version": "3.4.0",
+ "version": "4.0.0",
"description": "Verifiable governance for AI-assisted development — checks, hooks, and evidence.",
"main": "SKILL.md",
"bin": {
diff --git a/code-warden/templates/ci/github-actions.yml b/code-warden/templates/ci/github-actions.yml
index bbe1808..f097044 100644
--- a/code-warden/templates/ci/github-actions.yml
+++ b/code-warden/templates/ci/github-actions.yml
@@ -14,6 +14,8 @@
# - Markdown summary on the workflow run / PR (via GITHUB_STEP_SUMMARY)
# - Uploaded artifact for audit trail (90-day retention)
# - SARIF output is available in Code-Warden v3.4.0 and newer.
+# - Baseline ratchet mode (--write-baseline / --baseline) is available in
+# Code-Warden v4.0.0 and newer.
#
# How code-warden is made available in CI (choose one):
#
@@ -31,6 +33,16 @@
# max_file_length (default 400 lines)
# pre_flight_trigger_lines (default 150 lines)
# human_checkpoint_files (default 2 files)
+#
+# Brownfield adoption (baseline / ratchet mode):
+# Existing repos can gate on NEW or WORSENED violations only. Write a
+# baseline locally, commit it, then pass --baseline to the report steps:
+# npx code-warden report --write-baseline
+# git add .code-warden-baseline.json
+# Then append `--baseline=.code-warden-baseline.json` to each
+# governance-report.js invocation below. Baselined files are allowed only
+# until they GROW; baselined secrets are fingerprinted by content hash
+# (never the raw value) so line moves do not break the baseline.
name: Code-Warden Quality Gate
@@ -41,7 +53,7 @@ on:
branches: [main, master]
env:
- CODE_WARDEN_VERSION: v3.4.0
+ CODE_WARDEN_VERSION: v4.0.0
CODE_WARDEN_PATH: .code-warden-ci
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
diff --git a/code-warden/tools/get-context.js b/code-warden/tools/get-context.js
index 7c01b08..2c348c4 100644
--- a/code-warden/tools/get-context.js
+++ b/code-warden/tools/get-context.js
@@ -1,38 +1,6 @@
#!/usr/bin/env node
const fs = require('fs');
-const path = require('path');
-
-const candidates = [
- 'AGENTS.md',
- '.codex/AGENTS.md',
- 'ARCHITECTURE.md',
- 'docs/ARCHITECTURE.md',
- '.agents/AGENTS.md',
- '.claude/CLAUDE.md',
- 'CLAUDE.md',
- 'README.md',
- 'docs/README.md',
- 'PRD.md',
-];
-
-function findContextFile(startDir) {
- let currDir = path.resolve(startDir);
-
- while (true) {
- for (const candidate of candidates) {
- const fullPath = path.join(currDir, candidate);
- if (fs.existsSync(fullPath)) {
- return fullPath;
- }
- }
-
- const parentDir = path.dirname(currDir);
- if (parentDir === currDir) {
- return null;
- }
- currDir = parentDir;
- }
-}
+const { findContextFile } = require('./lib/context-discovery');
const contextFile = findContextFile(process.cwd());
diff --git a/code-warden/tools/governance-report.js b/code-warden/tools/governance-report.js
index b46beb5..9d549fc 100644
--- a/code-warden/tools/governance-report.js
+++ b/code-warden/tools/governance-report.js
@@ -5,12 +5,15 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawnSync } = require('child_process');
-const { countLines } = require('./lib/line-count');
-const { collectFiles } = require('./lib/file-collection');
-const { scanForSecrets } = require('./lib/secret-patterns');
-const { loadConfig } = require('./lib/config');
-const { formatSarif } = require('./lib/sarif');
-const { loadRiskPolicy } = require('./lib/risk-policy');
+const { formatSarif } = require('./lib/sarif');
+const { loadRiskPolicy } = require('./lib/risk-policy');
+const { getScopeSummary } = require('./lib/scope-store');
+const { gitInfo } = require('./lib/git-info');
+const { runScans: runScanCore } = require('./lib/scan-core');
+const { collectMarkedEntries } = require('./lib/hook-events');
+const { formatMarkdown, formatSummary } = require('./lib/report-format');
+const { createBaseline, loadBaseline, applyBaselineToChecks,
+ DEFAULT_BASELINE } = require('./lib/baseline');
const ROOT = path.join(__dirname, '..');
const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
@@ -29,86 +32,32 @@ function parseArgs(argv) {
const out = outArg ? outArg.slice('--out='.length) : null;
const configPath = configArg ? configArg.slice('--config='.length) : null;
const scanPath = args.find(a => !a.startsWith('--')) || '.';
- return { format, out, scanPath, configPath };
-}
-
-// ---------------------------------------------------------------------------
-// Git metadata
-// ---------------------------------------------------------------------------
-
-function gitInfo() {
- const run = (gitArgs) => {
- const r = spawnSync('git', gitArgs, { encoding: 'utf8', timeout: 5000 });
- return r.status === 0 ? r.stdout.trim() : null;
+ // --write-baseline[=path] / --baseline[=path]; bare flags use the default
+ // baseline filename in the current working directory.
+ const pathFlag = (name) => {
+ const withValue = args.find(a => a.startsWith(`--${name}=`));
+ if (withValue) return withValue.slice(name.length + 3);
+ return args.includes(`--${name}`) ? DEFAULT_BASELINE : null;
};
return {
- branch: run(['rev-parse', '--abbrev-ref', 'HEAD']),
- commit: run(['rev-parse', '--short', 'HEAD']),
+ format, out, scanPath, configPath,
+ writeBaselinePath: pathFlag('write-baseline'),
+ baselinePath: pathFlag('baseline'),
};
}
// ---------------------------------------------------------------------------
-// File length + secrets (single pass over all files)
+// File length + secrets (single pass; core shared with the Stop hook via
+// lib/scan-core.js)
// ---------------------------------------------------------------------------
-function matchesAnyPrefix(filePath, prefixes) {
- const normalized = filePath.replace(/\\/g, '/');
- return prefixes.some(p => normalized.startsWith(p) || normalized === p.replace(/\/$/, ''));
-}
-
function runScans(scanPath, configPath) {
- const { maxFileLength, lintExcludePaths, secretsAllowlist } = loadConfig(configPath);
- const resolved = path.resolve(scanPath);
-
- if (!fs.existsSync(resolved)) {
- console.error(`[CodeWarden] Error: scan path not found: ${scanPath}`);
+ try {
+ return runScanCore(scanPath, configPath);
+ } catch (err) {
+ console.error(`[CodeWarden] Error: ${err.message}`);
process.exit(1);
}
-
- const files = [];
- const scanRootIsDirectory = fs.statSync(resolved).isDirectory();
- if (scanRootIsDirectory) {
- collectFiles(resolved, files);
- } else {
- files.push(resolved);
- }
-
- const lengthViolations = [];
- const secretViolations = [];
-
- for (const f of files) {
- let content;
- try { content = fs.readFileSync(f, 'utf8'); } catch { continue; }
-
- const rel = scanRootIsDirectory ? path.relative(resolved, f) : path.basename(f);
-
- if (!matchesAnyPrefix(rel, lintExcludePaths)) {
- const lineCount = countLines(content);
- if (lineCount > maxFileLength) {
- lengthViolations.push({ file: rel, lines: lineCount, limit: maxFileLength });
- }
- }
-
- const hit = scanForSecrets(content);
- if (hit && !matchesAnyPrefix(rel, secretsAllowlist)) {
- secretViolations.push({ file: rel, pattern: hit.label, line: hit.line, column: hit.column });
- }
- }
-
- return {
- fileLength: {
- status: lengthViolations.length === 0 ? 'pass' : 'fail',
- filesScanned: files.length,
- violations: lengthViolations.length,
- details: lengthViolations.length > 0 ? lengthViolations : undefined,
- },
- secrets: {
- status: secretViolations.length === 0 ? 'pass' : 'fail',
- filesScanned: files.length,
- violations: secretViolations.length,
- details: secretViolations.length > 0 ? secretViolations : undefined,
- },
- };
}
// ---------------------------------------------------------------------------
@@ -128,7 +77,7 @@ function checkTests() {
const r = spawnSync(process.execPath, [testScript], {
encoding: 'utf8',
- timeout: 30000,
+ timeout: 60000, // raised from 30000: suite growth (audit/lifecycle/receipt-audit tests)
cwd: ROOT,
});
@@ -163,6 +112,9 @@ function checkInstallHealth() {
'tools/warden-lint.js',
'tools/verify-secrets.js',
'tools/get-context.js',
+ 'tools/scope.js',
+ 'tools/hooks/claude/warden-scope-hook.js',
+ 'tools/hooks/claude/warden-audit-hook.js',
];
const missing = required.filter(f => !fs.existsSync(path.join(ROOT, f)));
return {
@@ -183,9 +135,8 @@ function checkRuntimeHooks() {
if (fs.existsSync(claudeSettings)) {
try {
const s = JSON.parse(fs.readFileSync(claudeSettings, 'utf8'));
- const hooks = (s?.hooks?.PreToolUse || [])
- .flatMap(m => m.hooks || [])
- .filter(h => String(h.description || '').startsWith('code-warden:'));
+ // All managed event arrays (PreToolUse/PostToolUse/SessionStart/Stop).
+ const hooks = collectMarkedEntries(s?.hooks);
if (hooks.length > 0) {
const valid = hooks.every(h => h.args?.[0] && fs.existsSync(h.args[0]));
result.claude = valid ? 'registered' : 'registered_broken';
@@ -221,13 +172,26 @@ function checkRuntimeHooks() {
// Report assembly
// ---------------------------------------------------------------------------
-function generateReport(scanPath, configPath) {
+function generateReport(scanPath, configPath, baseline, baselinePath) {
const repo = gitInfo();
- const { fileLength, secrets } = runScans(scanPath, configPath);
+ const scans = runScans(scanPath, configPath);
+ let { fileLength, secrets } = scans;
+ let baselineInfo = null;
+
+ if (baseline) {
+ const applied = applyBaselineToChecks(scans, baseline);
+ fileLength = applied.fileLength;
+ secrets = applied.secrets;
+ baselineInfo = { path: baselinePath, applied: true, legacy: applied.legacy };
+ }
+
const behavioralTests = checkTests();
const installHealth = checkInstallHealth();
const runtimeHooks = checkRuntimeHooks();
const riskPolicy = loadRiskPolicy();
+ // Scope Lock is per-repo and opt-in: report 'locked' only when the scanned
+ // repository actually has a .code-warden/scope.json.
+ const scope = getScopeSummary(path.resolve(scanPath));
const checks = { fileLength, secrets, behavioralTests, installHealth, riskPolicy };
const result = Object.values(checks).every(c => c.status === 'pass' || c.status === 'skip')
@@ -239,8 +203,12 @@ function generateReport(scanPath, configPath) {
timestamp: new Date().toISOString(),
repository: { branch: repo.branch, commit: repo.commit },
checks,
+ ...(baselineInfo ? { baseline: baselineInfo } : {}),
governance: {
- scopeGate: 'session_only',
+ scopeGate: scope
+ ? { status: 'locked', goal: scope.goal, filesIn: scope.filesIn.length,
+ enforce: scope.enforce }
+ : 'session_only',
planGate: 'session_only',
runtimeHooks,
riskPolicy: {
@@ -253,59 +221,9 @@ function generateReport(scanPath, configPath) {
}
// ---------------------------------------------------------------------------
-// Markdown formatter
+// Output (formatters live in lib/report-format.js)
// ---------------------------------------------------------------------------
-function formatMarkdown(report) {
- const badge = s => s === 'pass' ? 'PASS' : s === 'skip' ? 'SKIP' : 'FAIL';
- const hookLabel = (id) => {
- const s = report.governance.runtimeHooks[id];
- if (s === 'registered') return 'verified';
- if (s === 'registered_broken') return 'broken';
- if (s === 'not_registered') return 'none';
- return 'n/a';
- };
-
- const healthDetail = report.checks.installHealth.missing
- ? 'Missing: ' + report.checks.installHealth.missing.join(', ')
- : 'All source files present';
-
- const lines = [
- '## Code-Warden Governance Report',
- '',
- '| Check | Result | Details |',
- '|-------|--------|---------|',
- `| File length | ${badge(report.checks.fileLength.status)} | ${report.checks.fileLength.filesScanned} files scanned, ${report.checks.fileLength.violations} violations |`,
- `| Hardcoded credentials | ${badge(report.checks.secrets.status)} | ${report.checks.secrets.filesScanned} files scanned, ${report.checks.secrets.violations} violations |`,
- `| Behavioral tests | ${badge(report.checks.behavioralTests.status)} | ${report.checks.behavioralTests.tests} tests, ${report.checks.behavioralTests.failures} failures |`,
- `| Install health | ${badge(report.checks.installHealth.status)} | ${healthDetail} |`,
- `| Risk policy | ${badge(report.checks.riskPolicy.status)} | ${Object.keys(report.checks.riskPolicy.actions).length} governed actions |`,
- `| Runtime hooks | — | Claude: ${hookLabel('claude')} / Codex: ${hookLabel('codex')} |`,
- '',
- `**Result:** ${report.result === 'pass' ? 'All governed checks passed.' : 'One or more checks failed.'}`,
- '',
- `> Generated by Code-Warden v${report.version} at ${report.timestamp}`,
- ];
-
- return lines.join('\n');
-}
-
-// ---------------------------------------------------------------------------
-// One-line summary (default mode stdout)
-// ---------------------------------------------------------------------------
-
-function formatSummary(report) {
- const c = report.checks;
- const parts = [
- `lint:${c.fileLength.status}`,
- `secrets:${c.secrets.status}`,
- `tests:${c.behavioralTests.status}`,
- `health:${c.installHealth.status}`,
- `risk:${c.riskPolicy.status}`,
- ];
- return `[CodeWarden] Governance report: ${report.result.toUpperCase()} (${parts.join(', ')})`;
-}
-
function formatReport(report, format) {
if (format === 'md') return formatMarkdown(report);
if (format === 'json') return JSON.stringify(report, null, 2);
@@ -324,8 +242,34 @@ function writeReport(outPath, content) {
// Main
// ---------------------------------------------------------------------------
-const { format, out, scanPath, configPath } = parseArgs(process.argv);
-const report = generateReport(scanPath, configPath);
+const { format, out, scanPath, configPath,
+ writeBaselinePath, baselinePath } = parseArgs(process.argv);
+
+// --write-baseline: record current violations as the ratchet floor and exit.
+if (writeBaselinePath) {
+ const scans = runScans(scanPath, configPath);
+ const baseline = createBaseline(scans);
+ const dest = path.resolve(writeBaselinePath);
+ fs.writeFileSync(dest, JSON.stringify(baseline, null, 2) + '\n', 'utf8');
+ console.log(`[CodeWarden] Baseline written to ${dest}`);
+ console.log(`[CodeWarden] Recorded ${baseline.fileLength.length} file-length and ${baseline.secrets.length} secret finding(s).`);
+ console.log('[CodeWarden] Commit this file; future runs with --baseline fail only on new or worsened violations.');
+ process.exit(0);
+}
+
+// --baseline: a missing file is a hard error - silently ignoring it would
+// fake a gate.
+let baselineData = null;
+if (baselinePath) {
+ try {
+ baselineData = loadBaseline(path.resolve(baselinePath));
+ } catch (err) {
+ console.error(`[CodeWarden] Error: ${err.message}`);
+ process.exit(1);
+ }
+}
+
+const report = generateReport(scanPath, configPath, baselineData, baselinePath);
if (out) {
writeReport(out, formatReport(report, format));
diff --git a/code-warden/tools/hooks/claude/install-hooks.js b/code-warden/tools/hooks/claude/install-hooks.js
index bf4602e..6c9abe5 100644
--- a/code-warden/tools/hooks/claude/install-hooks.js
+++ b/code-warden/tools/hooks/claude/install-hooks.js
@@ -1,14 +1,17 @@
#!/usr/bin/env node
/**
* install-hooks.js
- * Merges code-warden PreToolUse hook entries into ~/.claude/settings.json.
+ * Merges code-warden hook entries into ~/.claude/settings.json across every
+ * managed event array: PreToolUse (gates), PostToolUse (audit ledger),
+ * SessionStart (context injection), and Stop (opt-in verification).
*
* Idempotent: removes any existing code-warden entries by description marker,
- * then inserts current entries. Replace, not skip — ensures paths stay current
- * after reinstalls or version moves.
+ * then inserts current entries. Replace, not skip - ensures paths stay current
+ * after reinstalls or version moves. Non-code-warden entries are preserved
+ * in every event array.
*
* Requires the skill to already be installed at skillDir before writing
- * settings — avoids dangling settings pointing at a missing skill directory.
+ * settings - avoids dangling settings pointing at a missing skill directory.
*/
'use strict';
@@ -17,8 +20,9 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
-const MARKER_PREFIX = 'code-warden:';
-const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
+const { stripEventGroups, applyEventGroups } = require('../../lib/hook-events');
+
+const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
// ---------------------------------------------------------------------------
// Settings I/O
@@ -44,46 +48,87 @@ function writeSettings(settings) {
// Hook entry helpers
// ---------------------------------------------------------------------------
-function stripCodeWardenHooks(preToolUse) {
- return preToolUse
- .map(matcher => ({
- ...matcher,
- hooks: (matcher.hooks || []).filter(
- h => !String(h.description || '').startsWith(MARKER_PREFIX)
- ),
- }))
- .filter(matcher => (matcher.hooks || []).length > 0);
+// Back-compat alias: PreToolUse-era name, now shared multi-event logic.
+const stripCodeWardenHooks = stripEventGroups;
+
+function buildHookEntry(skillDir, file, description) {
+ return {
+ type: 'command',
+ command: 'node',
+ args: [path.join(skillDir, 'tools', 'hooks', 'claude', file)],
+ description,
+ timeout: 30,
+ };
}
-function buildEntries(skillDir) {
+/** PreToolUse matcher groups (kept as its own export for older callers). */
+function buildMatcherGroups(skillDir) {
return [
{
- type: 'command',
- command: 'node',
- args: [path.join(skillDir, 'tools', 'hooks', 'claude', 'warden-lint-hook.js')],
- description: 'code-warden: file length gate',
- timeout: 30,
+ matcher: 'Write|Edit|NotebookEdit',
+ hooks: [
+ buildHookEntry(skillDir, 'warden-lint-hook.js', 'code-warden: file length gate'),
+ buildHookEntry(skillDir, 'warden-secrets-hook.js', 'code-warden: zero-trust secrets gate'),
+ // Always registered; silently no-ops until a scope file exists.
+ buildHookEntry(skillDir, 'warden-scope-hook.js', 'code-warden: scope lock gate'),
+ ],
},
{
- type: 'command',
- command: 'node',
- args: [path.join(skillDir, 'tools', 'hooks', 'claude', 'warden-secrets-hook.js')],
- description: 'code-warden: zero-trust secrets gate',
- timeout: 30,
+ matcher: 'Bash|PowerShell',
+ hooks: [
+ buildHookEntry(skillDir, 'warden-command-hook.js', 'code-warden: command secrets gate'),
+ ],
},
];
}
+/** All managed events with their code-warden matcher groups. */
+function buildEventGroups(skillDir) {
+ return {
+ PreToolUse: buildMatcherGroups(skillDir),
+ PostToolUse: [
+ {
+ matcher: 'Write|Edit|NotebookEdit|Bash|PowerShell',
+ hooks: [
+ // Advisory: appends to the tamper-evident ledger, never blocks.
+ buildHookEntry(skillDir, 'warden-audit-hook.js', 'code-warden: audit ledger'),
+ ],
+ },
+ ],
+ SessionStart: [
+ {
+ matcher: 'startup|resume|clear',
+ hooks: [
+ buildHookEntry(skillDir, 'warden-session-hook.js', 'code-warden: session context'),
+ ],
+ },
+ ],
+ Stop: [
+ {
+ // No matcher: Stop fires once per stop event. Inert until
+ // codewarden.json sets session.verify_on_stop true.
+ hooks: [
+ buildHookEntry(skillDir, 'warden-stop-hook.js', 'code-warden: stop verification'),
+ ],
+ },
+ ],
+ };
+}
+
// ---------------------------------------------------------------------------
// Install
// ---------------------------------------------------------------------------
function installHooks(skillDir) {
// Guard: skill must be installed before settings are written
+ const hookScripts = [
+ 'warden-lint-hook.js', 'warden-secrets-hook.js', 'warden-command-hook.js',
+ 'warden-scope-hook.js', 'warden-audit-hook.js', 'warden-session-hook.js',
+ 'warden-stop-hook.js',
+ ];
const required = [
path.join(skillDir, 'SKILL.md'),
- path.join(skillDir, 'tools', 'hooks', 'claude', 'warden-lint-hook.js'),
- path.join(skillDir, 'tools', 'hooks', 'claude', 'warden-secrets-hook.js'),
+ ...hookScripts.map(f => path.join(skillDir, 'tools', 'hooks', 'claude', f)),
];
for (const p of required) {
if (!fs.existsSync(p)) {
@@ -94,19 +139,12 @@ function installHooks(skillDir) {
}
}
- const settings = readSettings();
- settings.hooks = settings.hooks || {};
- const existing = settings.hooks.PreToolUse || [];
-
- // Remove stale code-warden entries, then append fresh block
- const cleaned = stripCodeWardenHooks(existing);
- settings.hooks.PreToolUse = [
- ...cleaned,
- { matcher: 'Write|Edit', hooks: buildEntries(skillDir) },
- ];
-
+ const settings = readSettings();
+ applyEventGroups(settings, buildEventGroups(skillDir));
writeSettings(settings);
return SETTINGS_PATH;
}
-module.exports = { installHooks };
+module.exports = {
+ installHooks, stripCodeWardenHooks, buildMatcherGroups, buildEventGroups,
+};
diff --git a/code-warden/tools/hooks/claude/uninstall-hooks.js b/code-warden/tools/hooks/claude/uninstall-hooks.js
index 56df4f8..16006bf 100644
--- a/code-warden/tools/hooks/claude/uninstall-hooks.js
+++ b/code-warden/tools/hooks/claude/uninstall-hooks.js
@@ -1,9 +1,10 @@
#!/usr/bin/env node
/**
* uninstall-hooks.js
- * Removes all code-warden hook entries from ~/.claude/settings.json.
- * Identified by description prefix "code-warden:".
- * Cleans up empty PreToolUse arrays and empty hooks objects after removal.
+ * Removes all code-warden hook entries from ~/.claude/settings.json across
+ * every managed event array (PreToolUse, PostToolUse, SessionStart, Stop).
+ * Identified by description prefix "code-warden:". Non-code-warden entries
+ * are preserved; empty event arrays and an empty hooks object are cleaned up.
*/
'use strict';
@@ -12,7 +13,8 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
-const MARKER_PREFIX = 'code-warden:';
+const { removeMarkedEntries } = require('../../lib/hook-events');
+
const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
function readSettings() {
@@ -34,39 +36,22 @@ function uninstallHooks() {
const settings = readSettings();
if (!settings) {
- console.log('[CodeWarden] No settings.json found — nothing to remove.');
+ console.log('[CodeWarden] No settings.json found - nothing to remove.');
return false;
}
- if (!settings.hooks || !settings.hooks.PreToolUse) {
- console.log('[CodeWarden] No PreToolUse hooks in settings.json — nothing to remove.');
+ if (!settings.hooks) {
+ console.log('[CodeWarden] No hooks in settings.json - nothing to remove.');
return false;
}
- const before = settings.hooks.PreToolUse;
- const cleaned = before
- .map(matcher => ({
- ...matcher,
- hooks: (matcher.hooks || []).filter(
- h => !String(h.description || '').startsWith(MARKER_PREFIX)
- ),
- }))
- .filter(matcher => (matcher.hooks || []).length > 0);
-
- const removedMatchers = before.length - cleaned.length;
- const removedHooks = before.flatMap(m => m.hooks || []).filter(
- h => String(h.description || '').startsWith(MARKER_PREFIX)
- ).length;
+ const removedHooks = removeMarkedEntries(settings);
if (removedHooks === 0) {
- console.log('[CodeWarden] No code-warden hook entries found — nothing to remove.');
+ console.log('[CodeWarden] No code-warden hook entries found - nothing to remove.');
return false;
}
- settings.hooks.PreToolUse = cleaned;
- if (cleaned.length === 0) delete settings.hooks.PreToolUse;
- if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
-
writeSettings(settings);
console.log(`[CodeWarden] Removed ${removedHooks} hook entry(ies) from settings.json.`);
return true;
diff --git a/code-warden/tools/hooks/claude/warden-audit-hook.js b/code-warden/tools/hooks/claude/warden-audit-hook.js
new file mode 100644
index 0000000..6294a01
--- /dev/null
+++ b/code-warden/tools/hooks/claude/warden-audit-hook.js
@@ -0,0 +1,38 @@
+#!/usr/bin/env node
+/**
+ * warden-audit-hook.js
+ * PostToolUse Claude Code hook: appends one chained entry to the audit
+ * ledger (/.code-warden/audit.jsonl) for
+ * Write/Edit/NotebookEdit/Bash/PowerShell calls.
+ *
+ * PostToolUse is purely advisory - the tool already ran and nothing can be
+ * blocked - so this hook ALWAYS exits 0 and never writes to stdout. All
+ * logic (root discovery, enablement, redaction, hash chain) lives in
+ * lib/audit-ledger.js.
+ *
+ * Payload (stdin JSON): { session_id, cwd, tool_name, tool_input, tool_response, ... }
+ */
+
+'use strict';
+
+const { recordPostToolUse } = require('../../lib/audit-ledger');
+
+async function main() {
+ let payload;
+ try {
+ const chunks = [];
+ for await (const chunk of process.stdin) chunks.push(chunk);
+ payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
+ } catch {
+ process.exit(0); // unreadable payload - nothing to audit
+ }
+
+ try {
+ recordPostToolUse(payload);
+ } catch {
+ // Ledger trouble must never disturb the session - audit is best-effort.
+ }
+ process.exit(0);
+}
+
+main().catch(() => process.exit(0));
diff --git a/code-warden/tools/hooks/claude/warden-command-hook.js b/code-warden/tools/hooks/claude/warden-command-hook.js
new file mode 100644
index 0000000..2263dd0
--- /dev/null
+++ b/code-warden/tools/hooks/claude/warden-command-hook.js
@@ -0,0 +1,86 @@
+#!/usr/bin/env node
+/**
+ * warden-command-hook.js
+ * PreToolUse Claude Code hook for Bash/PowerShell commands. Two gates run in
+ * order (secrets deny wins):
+ * 1. Command secrets gate - blocks commands containing hardcoded
+ * credentials (e.g. echo/export with secret values, curl with tokens).
+ * 2. Command risk gate - classifies the command against
+ * risk_policy.command_rules: "blocked" denies, "high" asks for
+ * confirmation (permissionDecision "ask").
+ *
+ * This is a best-effort surface: shell commands are intentionally wide. The
+ * hook catches the most common dangerous patterns; it does not attempt to
+ * sandbox arbitrary shell execution.
+ *
+ * Payload (stdin JSON): { tool_name: "Bash"|"PowerShell", tool_input: { command }, cwd }
+ * On violation: exit 2 + JSON deny response to stdout.
+ * On high risk: exit 0 + JSON ask response to stdout.
+ * On pass: exit 0 (no output).
+ */
+
+'use strict';
+
+const { scanForSecrets } = require('../../lib/secret-patterns');
+const { classifyCommand, loadCommandRules } = require('../../lib/command-risk');
+
+// ---------------------------------------------------------------------------
+// Response helpers
+// ---------------------------------------------------------------------------
+
+function respond(decision, reason, exitCode) {
+ process.stdout.write(JSON.stringify({
+ hookSpecificOutput: {
+ hookEventName: 'PreToolUse',
+ permissionDecision: decision,
+ permissionDecisionReason: reason,
+ },
+ }));
+ process.exit(exitCode);
+}
+
+const deny = (reason) => respond('deny', reason, 2);
+const ask = (reason) => respond('ask', reason, 0);
+const allow = () => process.exit(0);
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+
+async function main() {
+ let payload;
+ try {
+ const chunks = [];
+ for await (const chunk of process.stdin) chunks.push(chunk);
+ payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
+ } catch {
+ allow(); // parse failure is non-blocking
+ }
+
+ const { tool_name, tool_input = {} } = payload;
+
+ if (tool_name === 'Bash' || tool_name === 'PowerShell') {
+ const command = String(tool_input.command || '');
+ if (!command) allow();
+
+ // Gate 1: secrets (deny wins over any risk classification)
+ const hit = scanForSecrets(command);
+ if (hit) {
+ deny(`[CodeWarden] Command secrets gate: ${hit.label} detected in ${tool_name} command. Use environment variables or a secrets manager - no hardcoded credentials.`);
+ }
+
+ // Gate 2: risk policy tiers (rules discovered from the governed project)
+ const { rules } = loadCommandRules(null, payload.cwd || process.cwd());
+ const risk = classifyCommand(command, rules);
+ if (risk && risk.tier === 'blocked') {
+ deny(`[CodeWarden] Command risk gate: ${risk.rule.message} [rule: ${risk.rule.id}] Override: adjust risk_policy.command_rules in codewarden.json or run the command yourself.`);
+ }
+ if (risk && risk.tier === 'high') {
+ ask(`[CodeWarden] Command risk gate: ${risk.rule.message} [rule: ${risk.rule.id}] Confirm to proceed.`);
+ }
+ }
+
+ allow(); // unrecognised tool — pass through
+}
+
+main().catch(() => allow());
diff --git a/code-warden/tools/hooks/claude/warden-lint-hook.js b/code-warden/tools/hooks/claude/warden-lint-hook.js
index 80f4f5c..6271067 100644
--- a/code-warden/tools/hooks/claude/warden-lint-hook.js
+++ b/code-warden/tools/hooks/claude/warden-lint-hook.js
@@ -2,10 +2,18 @@
/**
* warden-lint-hook.js
* PreToolUse Claude Code hook: blocks Write/Edit if the resulting file would
- * exceed the configured line limit.
+ * exceed the configured line limit. Asks for confirmation (permissionDecision
+ * "ask") when a single change exceeds pre_flight_trigger_lines without
+ * breaching the hard limit. NotebookEdit is explicitly allowed — cells are
+ * not files, so the length gate does not apply.
*
- * Payload (stdin JSON): { tool_name, tool_input: { file_path, content|new_string, ... } }
+ * Config: discovered from the governed project (payload.cwd, walking up for
+ * codewarden.json) with fallback to the skill-dir default. Honors
+ * lint.exclude_paths relative to the discovered project root.
+ *
+ * Payload (stdin JSON): { tool_name, tool_input: { file_path, content|new_string, ... }, cwd }
* On violation: exit 2 + JSON deny response to stdout.
+ * On pre-flight trigger: exit 0 + JSON ask response to stdout.
* On pass: exit 0 (no output).
*/
@@ -13,14 +21,9 @@
const fs = require('fs');
const path = require('path');
-const { countLines } = require('../../lib/line-count');
-const { loadConfig } = require('../../lib/config');
-
-// ---------------------------------------------------------------------------
-// Config — loaded via shared module; falls back to 400 if missing
-// ---------------------------------------------------------------------------
-
-const { maxFileLength: MAX_LINES } = loadConfig();
+const { countLines } = require('../../lib/line-count');
+const { loadConfig } = require('../../lib/config');
+const { matchesProjectPath } = require('../../lib/path-match');
// ---------------------------------------------------------------------------
// Skip list — file types where line counting is meaningless
@@ -47,19 +50,27 @@ function shouldSkip(filePath) {
// Response helpers
// ---------------------------------------------------------------------------
-function deny(reason) {
+function respond(decision, reason, exitCode) {
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
- permissionDecision: 'deny',
+ permissionDecision: decision,
permissionDecisionReason: reason,
},
}));
- process.exit(2);
+ process.exit(exitCode);
}
+const deny = (reason) => respond('deny', reason, 2);
+const ask = (reason) => respond('ask', reason, 0);
const allow = () => process.exit(0);
+function preFlightGate(changeLines, trigger) {
+ if (changeLines > trigger) {
+ ask(`[CodeWarden] Pre-flight gate: single change of ${changeLines} lines exceeds ${trigger}. Confirm this large block is intentional.`);
+ }
+}
+
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
@@ -77,12 +88,24 @@ async function main() {
const { tool_name, tool_input = {} } = payload;
const { file_path, content, old_string, new_string, replace_all } = tool_input;
+ if (tool_name === 'NotebookEdit') allow(); // cells are not files — no length gate
+
+ const baseDir = payload.cwd || process.cwd();
+ const config = loadConfig(null, baseDir);
+ const MAX_LINES = config.maxFileLength;
+ const TRIGGER = config.preFlightTriggerLines;
+
+ if (matchesProjectPath(file_path, config.projectRoot, config.lintExcludePaths, baseDir)) {
+ allow(); // lint.exclude_paths — project opted this path out of length checks
+ }
+
if (tool_name === 'Write') {
if (shouldSkip(file_path)) allow();
const lines = countLines(content || '');
if (lines > MAX_LINES) {
deny(`[CodeWarden] File length gate: ${path.basename(file_path)} would be ${lines} lines (limit ${MAX_LINES}). Split into modules before writing.`);
}
+ preFlightGate(lines, TRIGGER);
allow();
}
@@ -97,6 +120,7 @@ async function main() {
if (lines > MAX_LINES) {
deny(`[CodeWarden] File length gate: ${path.basename(file_path)} would be ${lines} lines after edit (limit ${MAX_LINES}). Split into modules before editing.`);
}
+ preFlightGate(countLines(new_string || ''), TRIGGER);
allow();
}
diff --git a/code-warden/tools/hooks/claude/warden-scope-hook.js b/code-warden/tools/hooks/claude/warden-scope-hook.js
new file mode 100644
index 0000000..93c9f45
--- /dev/null
+++ b/code-warden/tools/hooks/claude/warden-scope-hook.js
@@ -0,0 +1,70 @@
+#!/usr/bin/env node
+/**
+ * warden-scope-hook.js
+ * PreToolUse Claude Code hook for Write/Edit/NotebookEdit. Two layers
+ * (both via lib/scope-store checkScopeDenial):
+ *
+ * 1. UNCONDITIONAL: any target containing a '.code-warden' path segment
+ * is denied even when no scope.json exists - governance artifacts
+ * (scope.json, audit.jsonl) are CLI/user-managed, never agent-edited.
+ * 2. Opt-in Scope Lock: walks up from payload.cwd looking for
+ * .code-warden/scope.json (stopping at the .git boundary, same rule
+ * as config discovery). No scope file, an unparseable file, or
+ * enforce:false means allow - the lock is strictly opt-in.
+ *
+ * Scope expansion goes through the user-run CLI: code-warden scope add.
+ *
+ * Payload (stdin JSON): { tool_name, tool_input: { file_path, ... }, cwd }
+ * On violation: exit 2 + JSON deny response to stdout.
+ * On pass: exit 0 (no output).
+ */
+
+'use strict';
+
+const { checkScopeDenial } = require('../../lib/scope-store');
+
+// ---------------------------------------------------------------------------
+// Response helpers
+// ---------------------------------------------------------------------------
+
+function deny(reason) {
+ process.stdout.write(JSON.stringify({
+ hookSpecificOutput: {
+ hookEventName: 'PreToolUse',
+ permissionDecision: 'deny',
+ permissionDecisionReason: reason,
+ },
+ }));
+ process.exit(2);
+}
+
+const allow = () => process.exit(0);
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+
+async function main() {
+ let payload;
+ try {
+ const chunks = [];
+ for await (const chunk of process.stdin) chunks.push(chunk);
+ payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
+ } catch {
+ allow(); // parse failure is non-blocking
+ }
+
+ const { tool_name, tool_input = {} } = payload;
+
+ if (tool_name === 'Write' || tool_name === 'Edit' || tool_name === 'NotebookEdit') {
+ const filePath = tool_input.file_path;
+ if (!filePath) allow();
+ const baseDir = payload.cwd || process.cwd();
+ const message = checkScopeDenial(filePath, baseDir);
+ if (message) deny(`[CodeWarden] ${message}`);
+ }
+
+ allow(); // unrecognised tool or in-scope write - pass through
+}
+
+main().catch(() => allow());
diff --git a/code-warden/tools/hooks/claude/warden-secrets-hook.js b/code-warden/tools/hooks/claude/warden-secrets-hook.js
index 6166268..857cf42 100644
--- a/code-warden/tools/hooks/claude/warden-secrets-hook.js
+++ b/code-warden/tools/hooks/claude/warden-secrets-hook.js
@@ -1,11 +1,16 @@
#!/usr/bin/env node
/**
* warden-secrets-hook.js
- * PreToolUse Claude Code hook: blocks Write/Edit if content contains
- * hardcoded credentials matching zero-trust secret patterns.
+ * PreToolUse Claude Code hook: blocks Write/Edit/NotebookEdit if content
+ * contains hardcoded credentials matching zero-trust secret patterns.
*
- * Write: scans full content.
- * Edit: scans new_string only (old content was already committed).
+ * Write: scans full content.
+ * Edit: scans new_string only (old content was already committed).
+ * NotebookEdit: scans new_source.
+ *
+ * Config: discovered from the governed project (payload.cwd, walking up for
+ * codewarden.json) with fallback to the skill-dir default. Files matching
+ * secrets.allowlist (relative to the discovered project root) skip the scan.
*
* On violation: exit 2 + JSON deny response to stdout.
* On pass: exit 0 (no output).
@@ -14,7 +19,9 @@
'use strict';
const path = require('path');
-const { scanForSecrets } = require('../../lib/secret-patterns');
+const { scanForSecrets } = require('../../lib/secret-patterns');
+const { loadConfig } = require('../../lib/config');
+const { matchesProjectPath } = require('../../lib/path-match');
// ---------------------------------------------------------------------------
// Response helpers
@@ -33,6 +40,13 @@ function deny(reason) {
const allow = () => process.exit(0);
+function scanOrDeny(text, fileLabel, where) {
+ const hit = scanForSecrets(text || '');
+ if (hit) {
+ deny(`[CodeWarden] Hardcoded credential scanner: ${hit.label} detected in ${where}${fileLabel}. Use environment variables - no hardcoded credentials.`);
+ }
+}
+
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
@@ -48,24 +62,17 @@ async function main() {
}
const { tool_name, tool_input = {} } = payload;
+ const baseDir = payload.cwd || process.cwd();
+ const config = loadConfig(null, baseDir);
+ const file = path.basename(tool_input.file_path || 'file');
- if (tool_name === 'Write') {
- const hit = scanForSecrets(tool_input.content || '');
- if (hit) {
- const file = path.basename(tool_input.file_path || 'file');
- deny(`[CodeWarden] Hardcoded credential scanner: ${hit.label} detected in ${file}. Use environment variables — no hardcoded credentials.`);
- }
- allow();
+ if (matchesProjectPath(tool_input.file_path, config.projectRoot, config.secretsAllowlist, baseDir)) {
+ allow(); // secrets.allowlist — project opted this path out of scanning
}
- if (tool_name === 'Edit') {
- const hit = scanForSecrets(tool_input.new_string || '');
- if (hit) {
- const file = path.basename(tool_input.file_path || 'file');
- deny(`[CodeWarden] Hardcoded credential scanner: ${hit.label} detected in replacement for ${file}. Use environment variables — no hardcoded credentials.`);
- }
- allow();
- }
+ if (tool_name === 'Write') scanOrDeny(tool_input.content, file, '');
+ if (tool_name === 'Edit') scanOrDeny(tool_input.new_string, file, 'replacement for ');
+ if (tool_name === 'NotebookEdit') scanOrDeny(tool_input.new_source, file, 'notebook cell for ');
allow();
}
diff --git a/code-warden/tools/hooks/claude/warden-session-hook.js b/code-warden/tools/hooks/claude/warden-session-hook.js
new file mode 100644
index 0000000..b54ddc7
--- /dev/null
+++ b/code-warden/tools/hooks/claude/warden-session-hook.js
@@ -0,0 +1,81 @@
+#!/usr/bin/env node
+/**
+ * warden-session-hook.js
+ * SessionStart Claude Code hook (matchers: startup|resume|clear): injects
+ * architecture context and scope status at the start of a session so the
+ * agent begins governed instead of discovering the rules mid-task.
+ *
+ * Discovery is repo-bounded (stopAtGitBoundary) - a session never inherits
+ * context found above the repository root. Nothing found and no scope lock
+ * means exit 0 with no output. SessionStart cannot block; output goes
+ * through the hookSpecificOutput.additionalContext JSON form.
+ *
+ * Payload (stdin JSON): { session_id, cwd, hook_event_name, source, ... }
+ */
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+const { findContextFile } = require('../../lib/context-discovery');
+const { getScopeSummary } = require('../../lib/scope-store');
+
+const MAX_CONTEXT_CHARS = 2000;
+const MAX_CONTEXT_LINES = 30;
+
+/** Console/context output stays ASCII: replace anything else with '?'. */
+function toAscii(text) {
+ return String(text).replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '?');
+}
+
+function buildContext(cwd) {
+ const contextFile = findContextFile(cwd, { stopAtGitBoundary: true });
+ const scope = getScopeSummary(cwd);
+ if (!contextFile && !scope) return null;
+
+ const parts = [];
+ if (contextFile) {
+ let head = '';
+ try {
+ head = fs.readFileSync(contextFile, 'utf8')
+ .split(/\r?\n/).slice(0, MAX_CONTEXT_LINES).join('\n').trim();
+ } catch { /* unreadable file - skip the excerpt */ }
+ const name = path.basename(contextFile);
+ parts.push(`[CodeWarden] Architecture context from ${name} (${contextFile}):\n${head}`);
+ }
+ if (scope) {
+ parts.push(`[CodeWarden] Scope locked: goal=${scope.goal || '(not set)'}, ` +
+ `${scope.filesIn.length} files in scope.`);
+ }
+ parts.push('[CodeWarden] Scope Gate and Plan Gate apply before code changes.');
+
+ return toAscii(parts.join('\n')).slice(0, MAX_CONTEXT_CHARS);
+}
+
+async function main() {
+ let payload;
+ try {
+ const chunks = [];
+ for await (const chunk of process.stdin) chunks.push(chunk);
+ payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
+ } catch {
+ process.exit(0); // unreadable payload - inject nothing
+ }
+
+ let context = null;
+ try {
+ context = buildContext(payload.cwd || process.cwd());
+ } catch { /* discovery trouble must never break session start */ }
+ if (!context) process.exit(0);
+
+ process.stdout.write(JSON.stringify({
+ hookSpecificOutput: {
+ hookEventName: 'SessionStart',
+ additionalContext: context,
+ },
+ }));
+ process.exit(0);
+}
+
+main().catch(() => process.exit(0));
diff --git a/code-warden/tools/hooks/claude/warden-stop-hook.js b/code-warden/tools/hooks/claude/warden-stop-hook.js
new file mode 100644
index 0000000..6b13828
--- /dev/null
+++ b/code-warden/tools/hooks/claude/warden-stop-hook.js
@@ -0,0 +1,88 @@
+#!/usr/bin/env node
+/**
+ * warden-stop-hook.js
+ * Opt-in Stop Claude Code hook: when codewarden.json sets
+ * session.verify_on_stop true, runs FAST in-process scans (file length +
+ * secrets via lib/scan-core - no behavioral tests, no git subprocesses)
+ * before the session is allowed to finish. Registered by default but inert:
+ * verify_on_stop defaults to false.
+ *
+ * Loop guard: payload.stop_hook_active is true when this hook already
+ * blocked once this stop attempt - exit 0 immediately (Claude Code also
+ * hard-caps consecutive blocks).
+ *
+ * Baseline-aware: when /.code-warden-baseline.json exists,
+ * only FRESH violations block - legacy debt never traps a session.
+ *
+ * On violations: {"decision":"block","reason":"..."} on stdout, exit 0
+ * (the reason is fed back to Claude as its next instruction).
+ * Clean (or off): exit 0, no output.
+ */
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+const { loadConfig } = require('../../lib/config');
+const { runScans } = require('../../lib/scan-core');
+const { loadBaseline, applyBaseline, DEFAULT_BASELINE } = require('../../lib/baseline');
+
+const MAX_LISTED = 3;
+
+function block(reason) {
+ process.stdout.write(JSON.stringify({ decision: 'block', reason }));
+ process.exit(0);
+}
+
+function verify(cwd) {
+ const cfg = loadConfig(null, cwd);
+ if (!cfg.verifyOnStop) return; // off by default - absent means inert
+
+ const root = cfg.projectRoot || cwd;
+ const scans = runScans(root, null, root);
+
+ let lengthFresh = scans.fileLength.details || [];
+ let secretFresh = scans.secrets.details || [];
+
+ const baselinePath = path.join(root, DEFAULT_BASELINE);
+ if (fs.existsSync(baselinePath)) {
+ try {
+ const parts = applyBaseline(scans, loadBaseline(baselinePath));
+ lengthFresh = parts.fileLength.fresh;
+ secretFresh = parts.secrets.fresh;
+ } catch { /* unusable baseline - everything counts as fresh */ }
+ }
+
+ const total = lengthFresh.length + secretFresh.length;
+ if (total === 0) return;
+
+ const lines = [
+ ...lengthFresh.map(d => `${d.file}: ${d.lines} lines (limit ${d.limit})`),
+ ...secretFresh.map(d => `${d.file}:${d.line}: ${d.pattern}`),
+ ].slice(0, MAX_LISTED);
+
+ block(`[CodeWarden] Stop verification: ${total} fresh violations ` +
+ `(${lengthFresh.length} length, ${secretFresh.length} secrets). ` +
+ `Fix or report them before finishing:\n${lines.join('\n')}`);
+}
+
+async function main() {
+ let payload;
+ try {
+ const chunks = [];
+ for await (const chunk of process.stdin) chunks.push(chunk);
+ payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
+ } catch {
+ process.exit(0); // unreadable payload - never trap the session
+ }
+
+ if (payload.stop_hook_active) process.exit(0); // loop guard - MUST come first
+
+ try {
+ verify(payload.cwd || process.cwd());
+ } catch { /* scan trouble must never trap the session */ }
+ process.exit(0);
+}
+
+main().catch(() => process.exit(0));
diff --git a/code-warden/tools/hooks/codex/warden-apply-patch-hook.js b/code-warden/tools/hooks/codex/warden-apply-patch-hook.js
index 764b09e..c58eb2b 100644
--- a/code-warden/tools/hooks/codex/warden-apply-patch-hook.js
+++ b/code-warden/tools/hooks/codex/warden-apply-patch-hook.js
@@ -17,11 +17,17 @@
const fs = require('fs');
const path = require('path');
-const { scanForSecrets } = require('../../lib/secret-patterns');
-const { countLines } = require('../../lib/line-count');
-const { loadConfig } = require('../../lib/config');
+const { scanForSecrets } = require('../../lib/secret-patterns');
+const { countLines } = require('../../lib/line-count');
+const { loadConfig } = require('../../lib/config');
+const { matchesProjectPath } = require('../../lib/path-match');
+const { checkScopeDenial } = require('../../lib/scope-store');
-const { maxFileLength: maxLines } = loadConfig();
+// Discover the governed project's codewarden.json from the working directory
+// (Codex payloads do not carry cwd); falls back to the skill-dir default.
+const BASE_DIR = process.cwd();
+const { maxFileLength: maxLines, lintExcludePaths, secretsAllowlist, projectRoot } =
+ loadConfig(null, BASE_DIR);
function deny(reason) {
@@ -96,18 +102,30 @@ process.stdin.on('end', () => {
const patch = String(input.patch || '');
if (!patch) process.exit(0);
- // --- Secrets check on added lines ---
- const added = extractAddedLines(patch);
- const addedText = added.join('\n');
- const hit = scanForSecrets(addedText);
- if (hit) deny(`Blocked apply_patch — hardcoded credential detected (${hit.label}). Remove before patching.`);
-
- // --- File length check ---
const targetPath = extractTargetPath(patch);
- const estimated = estimateResultLines(patch, targetPath);
- if (estimated !== null && estimated > maxLines) {
- deny(`Blocked apply_patch — resulting file would be ~${estimated} lines (limit ${maxLines}). Break it up first.`);
+
+ // --- Secrets check on added lines (skipped for secrets.allowlist paths) ---
+ if (!matchesProjectPath(targetPath, projectRoot, secretsAllowlist, BASE_DIR)) {
+ const added = extractAddedLines(patch);
+ const addedText = added.join('\n');
+ const hit = scanForSecrets(addedText);
+ if (hit) deny(`Blocked apply_patch — hardcoded credential detected (${hit.label}). Remove before patching.`);
+ }
+
+ // --- File length check (skipped for lint.exclude_paths paths) ---
+ if (!matchesProjectPath(targetPath, projectRoot, lintExcludePaths, BASE_DIR)) {
+ const estimated = estimateResultLines(patch, targetPath);
+ if (estimated !== null && estimated > maxLines) {
+ deny(`Blocked apply_patch — resulting file would be ~${estimated} lines (limit ${maxLines}). Break it up first.`);
+ }
}
+ // --- Governance artifact protection + Scope Lock (shared with the Claude
+ // write hooks through lib/scope-store so semantics never drift): targets
+ // under a .code-warden/ segment are ALWAYS denied, even with no scope.json;
+ // the scope lock itself stays opt-in. ---
+ const scopeDenial = checkScopeDenial(targetPath, BASE_DIR);
+ if (scopeDenial) deny(scopeDenial);
+
process.exit(0);
});
diff --git a/code-warden/tools/hooks/codex/warden-bash-hook.js b/code-warden/tools/hooks/codex/warden-bash-hook.js
index 8dfbfa0..614efa2 100644
--- a/code-warden/tools/hooks/codex/warden-bash-hook.js
+++ b/code-warden/tools/hooks/codex/warden-bash-hook.js
@@ -2,13 +2,20 @@
/**
* warden-bash-hook.js — Codex PreToolUse hook
*
- * Fires on Bash tool calls. Scans the command string for patterns that
- * would embed hardcoded credentials into files (e.g. echo/printf/cat with
- * secret values, curl with Authorization headers, env assignments, etc.).
+ * Fires on Bash tool calls. Two gates run in order:
+ * 1. Secrets gate - scans the command string for patterns that would embed
+ * hardcoded credentials (echo/printf with secret values, curl with
+ * Authorization headers, env assignments, etc.).
+ * 2. Command risk gate - classifies the command against
+ * risk_policy.command_rules. ASYMMETRY vs the Claude runtime: Codex
+ * hooks have no "ask" equivalent (only deny + exit 2, or silent allow),
+ * so the "high" tier ALLOWS silently here and nothing is printed to
+ * stdout. Only "blocked" denies. Claude surfaces "high" as an
+ * interactive permission prompt instead.
*
* This is a best-effort surface: Bash is intentionally wide. The hook catches
- * the most common accidental secret exposure patterns; it does not attempt to
- * sandbox arbitrary shell execution.
+ * the most common dangerous patterns; it does not attempt to sandbox
+ * arbitrary shell execution.
*
* Codex hook payload (stdin, JSON):
* { tool: "Bash", toolInput: { command: "" } }
@@ -21,6 +28,7 @@
'use strict';
const { scanForSecrets } = require('../../lib/secret-patterns');
+const { classifyCommand, loadCommandRules } = require('../../lib/command-risk');
function deny(reason) {
process.stdout.write(JSON.stringify({ deny: true, message: `[CodeWarden] ${reason}` }) + '\n');
@@ -47,5 +55,13 @@ process.stdin.on('end', () => {
deny(`Blocked Bash command — hardcoded credential detected (${hit.label}). Use environment variables or a secrets manager instead.`);
}
+ // Command risk gate. "high" allows silently (no Codex "ask" - see header);
+ // only "blocked" denies.
+ const { rules } = loadCommandRules(null, process.cwd());
+ const risk = classifyCommand(command, rules);
+ if (risk && risk.tier === 'blocked') {
+ deny(`Blocked Bash command - ${risk.rule.message} [rule: ${risk.rule.id}] Override: adjust risk_policy.command_rules in codewarden.json or run the command yourself.`);
+ }
+
process.exit(0);
});
diff --git a/code-warden/tools/hooks/git/install-hooks.js b/code-warden/tools/hooks/git/install-hooks.js
new file mode 100644
index 0000000..829225c
--- /dev/null
+++ b/code-warden/tools/hooks/git/install-hooks.js
@@ -0,0 +1,206 @@
+#!/usr/bin/env node
+/**
+ * install-hooks.js
+ * Installs a marker-managed code-warden block into the pre-commit hook of
+ * the git repository at the working directory.
+ *
+ * Unlike the claude/codex hook installers (per-user, written under the home
+ * directory), git hooks are PER-REPO: the block is written into
+ * /hooks/pre-commit, or the core.hooksPath directory when set.
+ *
+ * Merge semantics:
+ * - No pre-commit file: create one (sh shebang + marker block)
+ * - Existing without markers: append the block, preserving content
+ * - Existing with markers: replace the block in place (idempotent
+ * re-install keeps the embedded path current)
+ *
+ * The embedded command points at warden-pre-commit.js of the CURRENTLY
+ * RUNNING install (resolved via __dirname), converted to forward slashes so
+ * the sh interpreter git uses on Windows can execute it.
+ */
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const MARKER_START = '# >>> code-warden >>>';
+const MARKER_END = '# <<< code-warden <<<';
+const PRE_COMMIT_SCRIPT = path.join(__dirname, 'warden-pre-commit.js');
+
+// ---------------------------------------------------------------------------
+// Git plumbing
+// ---------------------------------------------------------------------------
+
+function gitOutput(args, cwd) {
+ const r = spawnSync('git', args, { encoding: 'utf8', cwd, timeout: 10000 });
+ return r.status === 0 ? r.stdout.trim() : null;
+}
+
+/**
+ * Resolve the hooks directory for the repository containing cwd.
+ * Respects `git config core.hooksPath` (resolved against the working-tree
+ * top level, matching git's own behavior); falls back to /hooks.
+ *
+ * @param {string} [cwd] - Directory inside the target repo (default process.cwd())
+ * @returns {string|null} Absolute hooks directory, or null when not a git repo
+ */
+function resolveHooksDir(cwd) {
+ const base = cwd || process.cwd();
+ const gitDir = gitOutput(['rev-parse', '--git-dir'], base);
+ if (gitDir === null) return null;
+ const hooksPath = gitOutput(['config', 'core.hooksPath'], base);
+ if (hooksPath) {
+ const top = gitOutput(['rev-parse', '--show-toplevel'], base) || base;
+ return path.resolve(top, hooksPath);
+ }
+ return path.join(path.resolve(base, gitDir), 'hooks');
+}
+
+// ---------------------------------------------------------------------------
+// Marker block construction and merge
+// ---------------------------------------------------------------------------
+
+/**
+ * Build the marker-delimited block invoking the staged-content scanner.
+ *
+ * @param {string} [scriptPath] - Override for tests (default: this install)
+ * @returns {string} Block text without trailing newline
+ */
+function buildBlock(scriptPath) {
+ const forwardSlash = (scriptPath || PRE_COMMIT_SCRIPT).replace(/\\/g, '/');
+ return [
+ MARKER_START,
+ `node "${forwardSlash}" || exit $?`,
+ MARKER_END,
+ ].join('\n');
+}
+
+/**
+ * Merge the marker block into existing pre-commit content.
+ *
+ * @param {string|null} existing - Current file content, or null when absent
+ * @param {string} block - Marker-delimited block from buildBlock()
+ * @returns {string} New file content
+ */
+function mergePreCommitContent(existing, block) {
+ if (existing === null || existing.trim() === '') {
+ return `#!/bin/sh\n${block}\n`;
+ }
+ const startIdx = existing.indexOf(MARKER_START);
+ const endIdx = existing.indexOf(MARKER_END);
+ if (startIdx !== -1 && endIdx !== -1 && endIdx >= startIdx) {
+ // Replace in place; everything around the block is preserved untouched.
+ const before = existing.slice(0, startIdx);
+ const after = existing.slice(endIdx + MARKER_END.length);
+ return before + block + after;
+ }
+ // No markers: append at the end, ensuring a trailing newline first.
+ const terminated = existing.endsWith('\n') ? existing : existing + '\n';
+ return `${terminated}${block}\n`;
+}
+
+/**
+ * Strip the marker block from pre-commit content (uninstall support).
+ *
+ * @param {string} existing
+ * @returns {{ content: string, removed: boolean, emptyApartFromShebang: boolean }}
+ */
+function stripPreCommitContent(existing) {
+ const startIdx = existing.indexOf(MARKER_START);
+ const endIdx = existing.indexOf(MARKER_END);
+ if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) {
+ return { content: existing, removed: false, emptyApartFromShebang: false };
+ }
+ const before = existing.slice(0, startIdx);
+ let after = existing.slice(endIdx + MARKER_END.length);
+ if (after.startsWith('\n')) after = after.slice(1);
+ const content = before + after;
+ const meaningful = content
+ .split('\n')
+ .filter(l => l.trim() !== '' && !l.trim().startsWith('#!'));
+ return { content, removed: true, emptyApartFromShebang: meaningful.length === 0 };
+}
+
+// ---------------------------------------------------------------------------
+// Inspection (doctor / verify support)
+// ---------------------------------------------------------------------------
+
+/**
+ * Report the pre-commit hook state for the repository at cwd.
+ *
+ * @param {string} [cwd]
+ * @returns {{ insideRepo: boolean, hooksDir: string|null,
+ * preCommitPath: string|null, exists: boolean, hasBlock: boolean,
+ * scriptPath: string|null, scriptExists: boolean }}
+ */
+function inspectPreCommit(cwd) {
+ const hooksDir = resolveHooksDir(cwd);
+ if (!hooksDir) {
+ return { insideRepo: false, hooksDir: null, preCommitPath: null,
+ exists: false, hasBlock: false, scriptPath: null, scriptExists: false };
+ }
+ const preCommitPath = path.join(hooksDir, 'pre-commit');
+ let content = null;
+ try { content = fs.readFileSync(preCommitPath, 'utf8'); } catch { /* absent */ }
+ const hasBlock = content !== null &&
+ content.includes(MARKER_START) && content.includes(MARKER_END);
+ let scriptPath = null;
+ if (hasBlock) {
+ const block = content.slice(content.indexOf(MARKER_START), content.indexOf(MARKER_END));
+ const m = block.match(/node "([^"]+)"/);
+ if (m) scriptPath = m[1];
+ }
+ return {
+ insideRepo: true, hooksDir, preCommitPath,
+ exists: content !== null, hasBlock, scriptPath,
+ scriptExists: Boolean(scriptPath && fs.existsSync(scriptPath)),
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Install
+// ---------------------------------------------------------------------------
+
+/**
+ * Install (or refresh) the code-warden block in the repo's pre-commit hook.
+ * Signature matches the claude/codex installers so install.js can dispatch
+ * uniformly; skillDir is unused because the embedded path resolves to the
+ * currently running package via __dirname.
+ *
+ * @param {string|null} _skillDir - Ignored (per-repo target, not per-user)
+ * @param {string} [cwd] - Directory inside the target repo (tests pass temp repos)
+ * @returns {string} Path of the written pre-commit file
+ */
+function installHooks(_skillDir, cwd) {
+ const hooksDir = resolveHooksDir(cwd);
+ if (!hooksDir) {
+ console.error('[CodeWarden] Not inside a git repository - git hooks are per-repo.');
+ console.error('[CodeWarden] Run this from the repository you want governed.');
+ process.exit(1);
+ }
+ if (!fs.existsSync(PRE_COMMIT_SCRIPT)) {
+ console.error(`[CodeWarden] Missing hook script: ${PRE_COMMIT_SCRIPT}`);
+ process.exit(1);
+ }
+
+ fs.mkdirSync(hooksDir, { recursive: true });
+ const preCommitPath = path.join(hooksDir, 'pre-commit');
+ let existing = null;
+ try { existing = fs.readFileSync(preCommitPath, 'utf8'); } catch { /* new file */ }
+
+ fs.writeFileSync(preCommitPath, mergePreCommitContent(existing, buildBlock()), 'utf8');
+ try { fs.chmodSync(preCommitPath, 0o755); } catch { /* harmless on Windows */ }
+
+ console.log(`[CodeWarden] Pre-commit hook installed -> ${preCommitPath}`);
+ console.log('[CodeWarden] Scans STAGED content for file length + secrets at commit time.');
+ console.log('[CodeWarden] Honest note: `git commit --no-verify` bypasses this backstop.');
+ return preCommitPath;
+}
+
+module.exports = {
+ installHooks, inspectPreCommit, resolveHooksDir,
+ buildBlock, mergePreCommitContent, stripPreCommitContent,
+ MARKER_START, MARKER_END, PRE_COMMIT_SCRIPT,
+};
diff --git a/code-warden/tools/hooks/git/uninstall-hooks.js b/code-warden/tools/hooks/git/uninstall-hooks.js
new file mode 100644
index 0000000..3d01c04
--- /dev/null
+++ b/code-warden/tools/hooks/git/uninstall-hooks.js
@@ -0,0 +1,54 @@
+#!/usr/bin/env node
+/**
+ * uninstall-hooks.js
+ * Removes the code-warden marker block from the pre-commit hook of the git
+ * repository at the working directory.
+ *
+ * If the file becomes empty apart from the shebang, the file is deleted
+ * entirely. If the hook never had our markers, says so and leaves it alone.
+ */
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+const { resolveHooksDir, stripPreCommitContent } = require('./install-hooks');
+
+/**
+ * @param {string} [cwd] - Directory inside the target repo (default process.cwd())
+ * @returns {boolean} true when a block was removed
+ */
+function uninstallHooks(cwd) {
+ const hooksDir = resolveHooksDir(cwd);
+ if (!hooksDir) {
+ console.log('[CodeWarden] Not inside a git repository - nothing to remove.');
+ return false;
+ }
+
+ const preCommitPath = path.join(hooksDir, 'pre-commit');
+ let existing;
+ try {
+ existing = fs.readFileSync(preCommitPath, 'utf8');
+ } catch {
+ console.log('[CodeWarden] No pre-commit hook found - nothing to remove.');
+ return false;
+ }
+
+ const { content, removed, emptyApartFromShebang } = stripPreCommitContent(existing);
+ if (!removed) {
+ console.log('[CodeWarden] No code-warden block in pre-commit - nothing to remove.');
+ return false;
+ }
+
+ if (emptyApartFromShebang) {
+ fs.unlinkSync(preCommitPath);
+ console.log(`[CodeWarden] Removed code-warden block; deleted now-empty hook: ${preCommitPath}`);
+ } else {
+ fs.writeFileSync(preCommitPath, content, 'utf8');
+ console.log(`[CodeWarden] Removed code-warden block from ${preCommitPath}`);
+ }
+ return true;
+}
+
+module.exports = { uninstallHooks };
diff --git a/code-warden/tools/hooks/git/warden-pre-commit.js b/code-warden/tools/hooks/git/warden-pre-commit.js
new file mode 100644
index 0000000..bcdfd58
--- /dev/null
+++ b/code-warden/tools/hooks/git/warden-pre-commit.js
@@ -0,0 +1,115 @@
+#!/usr/bin/env node
+/**
+ * warden-pre-commit.js
+ * Git pre-commit backstop: scans STAGED content for file-length and
+ * hardcoded-credential violations before a commit lands.
+ *
+ * Installed into the governed repo by install-hooks.js as a marker-managed
+ * block in pre-commit; runs at commit time with the repo as cwd.
+ *
+ * Staged content is read with `git show :` - NOT the working tree -
+ * because the index is what actually gets committed. Honors the project's
+ * codewarden.json: lint.exclude_paths skips the length check and
+ * secrets.allowlist skips the secrets check. Git emits repo-root-relative
+ * paths and config discovery stops at the .git boundary, so prefix matching
+ * uses the project root (falling back to the repo root when no project
+ * config is discovered - the two roots coincide either way).
+ *
+ * Exit 0 when staged files are clean; exit 1 with [FAIL] lines otherwise.
+ * Deliberate bypass (visible in review): git commit --no-verify.
+ */
+
+'use strict';
+
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const { countLines } = require('../../lib/line-count');
+const { scanForAllSecrets } = require('../../lib/secret-patterns');
+const { loadConfig } = require('../../lib/config');
+const { matchesAnyPrefix } = require('../../lib/path-match');
+const { SKIP_NAMES, SKIP_EXTS } = require('../../lib/file-collection');
+
+// ---------------------------------------------------------------------------
+// Git plumbing
+// ---------------------------------------------------------------------------
+
+function git(args) {
+ return spawnSync('git', args, {
+ encoding: 'utf8',
+ timeout: 15000,
+ maxBuffer: 64 * 1024 * 1024,
+ });
+}
+
+/** @returns {string[]|null} Staged (added/copied/modified) paths, NUL-parsed */
+function stagedFiles() {
+ const r = git(['diff', '--cached', '--name-only', '--diff-filter=ACM', '-z']);
+ if (r.status !== 0) return null;
+ return (r.stdout || '').split('\0').filter(Boolean);
+}
+
+/** @returns {string|null} Staged blob content, or null when unreadable */
+function stagedContent(file) {
+ const r = git(['show', `:${file}`]);
+ return r.status === 0 ? r.stdout : null;
+}
+
+// ---------------------------------------------------------------------------
+// Staged scan
+// ---------------------------------------------------------------------------
+
+function scanStaged() {
+ const top = git(['rev-parse', '--show-toplevel']);
+ if (top.status !== 0) {
+ console.error('[CodeWarden] pre-commit: not inside a git repository - skipping.');
+ return 0;
+ }
+ const repoRoot = top.stdout.trim();
+
+ const files = stagedFiles();
+ if (files === null) {
+ console.error('[CodeWarden] pre-commit: could not list staged files - skipping.');
+ return 0;
+ }
+
+ const { maxFileLength, lintExcludePaths, secretsAllowlist } = loadConfig(null, repoRoot);
+
+ const failures = [];
+ let scanned = 0;
+
+ for (const file of files) {
+ const name = path.basename(file);
+ if (SKIP_NAMES.has(name) || SKIP_EXTS.has(path.extname(name).toLowerCase())) continue;
+
+ const content = stagedContent(file);
+ if (content === null) continue; // e.g. submodule entry or unreadable blob
+
+ scanned++;
+
+ if (!matchesAnyPrefix(file, lintExcludePaths)) {
+ const lines = countLines(content);
+ if (lines > maxFileLength) {
+ failures.push(`${file}: ${lines} lines exceeds the ${maxFileLength}-line limit`);
+ }
+ }
+
+ if (!matchesAnyPrefix(file, secretsAllowlist)) {
+ for (const hit of scanForAllSecrets(content)) {
+ failures.push(`${file}:${hit.line}: ${hit.label} detected in staged content`);
+ }
+ }
+ }
+
+ if (failures.length > 0) {
+ for (const f of failures) console.error(`[FAIL] [CodeWarden] ${f}`);
+ console.error(`[FAIL] [CodeWarden] Pre-commit gate: ${failures.length} violation(s) across ${scanned} staged file(s). Commit blocked.`);
+ console.error('[CodeWarden] Fix the staged content, or adjust lint.exclude_paths / secrets.allowlist in codewarden.json.');
+ return 1;
+ }
+
+ console.log(`[PASS] [CodeWarden] Pre-commit gate: ${scanned} staged file(s) clean.`);
+ return 0;
+}
+
+process.exit(scanStaged());
diff --git a/code-warden/tools/lib/audit-ledger.js b/code-warden/tools/lib/audit-ledger.js
new file mode 100644
index 0000000..37a7655
--- /dev/null
+++ b/code-warden/tools/lib/audit-ledger.js
@@ -0,0 +1,200 @@
+'use strict';
+
+/**
+ * audit-ledger.js
+ * Tamper-evident PostToolUse audit ledger: /.code-warden/audit.jsonl
+ *
+ * Each line is one JSON entry chained to the previous one:
+ * hash = sha256(prev + canonical JSON of the entry core)
+ * where prev is the previous entry's hash ('GENESIS' for the first line).
+ * Editing, reordering, or deleting any line breaks every later hash, so
+ * verifyLedger() pinpoints the first tampered line.
+ *
+ * Enablement (resolveLedger):
+ * - project root = scope-store walk (scope root wins), else discovered
+ * codewarden.json root; no root -> disabled.
+ * - explicit audit.enabled === false -> always disabled.
+ * - else enabled when an active (parseable) scope.json exists, or when
+ * audit.enabled === true. Governed sessions get a ledger by default;
+ * everyone else opts in.
+ *
+ * Command targets are secret-redacted and truncated BEFORE logging - the
+ * ledger must never become a credential store.
+ */
+
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+
+const { findScopeFile, loadScope } = require('./scope-store');
+const { loadConfig } = require('./config');
+const { SECRET_PATTERNS } = require('./secret-patterns');
+
+const GENESIS = 'GENESIS';
+const LEDGER_REL = path.join('.code-warden', 'audit.jsonl');
+const COMMAND_TARGET_MAX = 300;
+const CORE_FIELDS = ['ts', 'session_id', 'event', 'tool', 'target', 'ok'];
+const FILE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
+const COMMAND_TOOLS = new Set(['Bash', 'PowerShell']);
+
+/** Canonical JSON for a flat entry core: fixed field order, no hash fields. */
+function canonicalCore(core) {
+ const ordered = {};
+ for (const k of CORE_FIELDS) ordered[k] = core[k];
+ return JSON.stringify(ordered);
+}
+
+/** Chain hash for one entry: sha256(prev + canonical core). */
+function entryHash(prev, core) {
+ return crypto.createHash('sha256')
+ .update(String(prev) + canonicalCore(core), 'utf8')
+ .digest('hex');
+}
+
+/** Replace every secret-pattern match region with '[REDACTED:]'. */
+function redactSecrets(text) {
+ let out = String(text);
+ for (const { label, re } of SECRET_PATTERNS) {
+ const flags = re.flags.includes('g') ? re.flags : re.flags + 'g';
+ out = out.replace(new RegExp(re.source, flags), `[REDACTED:${label}]`);
+ }
+ return out;
+}
+
+/**
+ * Decide whether the ledger is active for startDir and where it lives.
+ *
+ * @param {string} startDir - Hook working directory (payload.cwd)
+ * @returns {{ root: string, ledgerPath: string } | null} null when disabled
+ */
+function resolveLedger(startDir) {
+ const dir = startDir || process.cwd();
+ const scope = findScopeFile(dir);
+ const cfg = loadConfig(null, dir);
+ const root = (scope && scope.scopeRoot) || cfg.projectRoot;
+ if (!root) return null; // no discoverable project root
+ if (cfg.auditEnabled === false) return null; // explicit off always wins
+ const scopeActive = Boolean(scope && loadScope(scope.scopePath));
+ if (cfg.auditEnabled !== true && !scopeActive) return null;
+ return { root, ledgerPath: path.join(root, LEDGER_REL) };
+}
+
+/**
+ * Best-effort success heuristic over tool_response. PostToolUse responses
+ * are tool-shaped and undocumented, so: absence of an obvious error
+ * field/flag means success. Explicit failure signals checked:
+ * success:false, ok:false, is_error:true, interrupted:true,
+ * a non-zero numeric exitCode/exit_code/code, or a truthy error field.
+ * stderr is deliberately ignored - successful commands write there too.
+ */
+function okFromResponse(resp) {
+ if (!resp || typeof resp !== 'object') return true;
+ if (resp.success === false || resp.ok === false) return false;
+ if (resp.is_error === true || resp.interrupted === true) return false;
+ const exit = [resp.exitCode, resp.exit_code, resp.code]
+ .find(v => typeof v === 'number');
+ if (typeof exit === 'number' && exit !== 0) return false;
+ if (resp.error) return false;
+ return true;
+}
+
+/** Ledger target string for a tool call (relative path or redacted command). */
+function targetFor(tool, toolInput, root, baseDir) {
+ if (COMMAND_TOOLS.has(tool)) {
+ // Redact on the FULL command first, then truncate - truncating first
+ // could split a token so the pattern no longer matches.
+ return redactSecrets(toolInput.command || '').slice(0, COMMAND_TARGET_MAX);
+ }
+ const fp = toolInput.file_path || toolInput.notebook_path || '';
+ if (!fp) return '';
+ const abs = path.resolve(baseDir || root, fp);
+ const rel = path.relative(root, abs);
+ const escaped = !rel || rel.startsWith('..') || path.isAbsolute(rel);
+ return (escaped ? abs : rel).replace(/\\/g, '/');
+}
+
+/** Hash of the last parseable line, or GENESIS. A broken tail still gets a
+ * fresh GENESIS link; verifyLedger flags the breakage either way. */
+function lastHash(ledgerPath) {
+ if (!fs.existsSync(ledgerPath)) return GENESIS;
+ const lines = fs.readFileSync(ledgerPath, 'utf8')
+ .split(/\r?\n/).filter(l => l.trim().length > 0);
+ if (lines.length === 0) return GENESIS;
+ try {
+ const last = JSON.parse(lines[lines.length - 1]);
+ if (last && typeof last.hash === 'string') return last.hash;
+ } catch { /* tampered tail */ }
+ return GENESIS;
+}
+
+/** Append one chained entry (single atomic appendFileSync line). */
+function appendEntry(ledgerPath, core) {
+ const prev = lastHash(ledgerPath);
+ const entry = { ...core, prev, hash: entryHash(prev, core) };
+ fs.mkdirSync(path.dirname(ledgerPath), { recursive: true });
+ fs.appendFileSync(ledgerPath, JSON.stringify(entry) + '\n', 'utf8');
+ return entry;
+}
+
+/**
+ * Record one PostToolUse payload. Returns the appended entry, or null when
+ * the tool is not audited or the ledger is disabled for this directory.
+ */
+function recordPostToolUse(payload) {
+ const tool = payload && payload.tool_name;
+ if (!FILE_TOOLS.has(tool) && !COMMAND_TOOLS.has(tool)) return null;
+ const cwd = payload.cwd || process.cwd();
+ const resolved = resolveLedger(cwd);
+ if (!resolved) return null;
+ const core = {
+ ts: new Date().toISOString(),
+ session_id: payload.session_id || '',
+ event: 'PostToolUse',
+ tool,
+ target: targetFor(tool, payload.tool_input || {}, resolved.root, cwd),
+ ok: okFromResponse(payload.tool_response),
+ };
+ return appendEntry(resolved.ledgerPath, core);
+}
+
+/**
+ * Verify the hash chain. brokenAt is the 1-based line number of the first
+ * unparseable or mis-chained entry (null when valid).
+ *
+ * @param {string} ledgerPath
+ * @returns {{ valid: boolean, entries: number, brokenAt: number|null }}
+ */
+function verifyLedger(ledgerPath) {
+ if (!fs.existsSync(ledgerPath)) return { valid: false, entries: 0, brokenAt: null };
+ const lines = fs.readFileSync(ledgerPath, 'utf8')
+ .split(/\r?\n/).filter(l => l.trim().length > 0);
+ let prev = GENESIS;
+ for (let i = 0; i < lines.length; i++) {
+ let e;
+ try { e = JSON.parse(lines[i]); } catch {
+ return { valid: false, entries: lines.length, brokenAt: i + 1 };
+ }
+ if (!e || e.prev !== prev || e.hash !== entryHash(e.prev, e)) {
+ return { valid: false, entries: lines.length, brokenAt: i + 1 };
+ }
+ prev = e.hash;
+ }
+ return { valid: true, entries: lines.length, brokenAt: null };
+}
+
+/** Parsed entries for receipt prefill (unparseable lines are skipped). */
+function readLedgerEntries(ledgerPath) {
+ if (!fs.existsSync(ledgerPath)) return [];
+ const entries = [];
+ for (const line of fs.readFileSync(ledgerPath, 'utf8').split(/\r?\n/)) {
+ if (!line.trim()) continue;
+ try { entries.push(JSON.parse(line)); } catch { /* skip */ }
+ }
+ return entries;
+}
+
+module.exports = {
+ GENESIS, LEDGER_REL, COMMAND_TARGET_MAX,
+ resolveLedger, recordPostToolUse, appendEntry, verifyLedger,
+ readLedgerEntries, redactSecrets, okFromResponse, entryHash,
+};
diff --git a/code-warden/tools/lib/baseline.js b/code-warden/tools/lib/baseline.js
new file mode 100644
index 0000000..e3cc531
--- /dev/null
+++ b/code-warden/tools/lib/baseline.js
@@ -0,0 +1,156 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * baseline.js
+ * Baseline / ratchet support for the governance report.
+ *
+ * Brownfield repos adopt CI enforcement by recording current violations in a
+ * committed baseline file, then failing only on NEW or WORSENED violations:
+ *
+ * - fileLength (ratchet): a baselined file stays legacy-allowed only while
+ * its current line count is <= the count recorded at baseline time;
+ * growth makes it a fresh violation again.
+ * - secrets: a hit is legacy only when (file, label, contextHash) matches.
+ * contextHash is a sha256 of the TRIMMED matched line - line numbers
+ * drift, content fingerprints survive moves. Raw secret text is NEVER
+ * stored in the baseline (hash + pattern label + file only).
+ */
+
+const crypto = require('crypto');
+const fs = require('fs');
+
+const BASELINE_KIND = 'code-warden/baseline';
+const SCHEMA_VERSION = 1;
+const DEFAULT_BASELINE = '.code-warden-baseline.json';
+
+const slash = f => String(f).replace(/\\/g, '/');
+
+/**
+ * Fingerprint a matched source line. Trimmed so indentation changes and
+ * CRLF/LF differences do not invalidate the baseline entry.
+ *
+ * @param {string} lineText
+ * @returns {string} hex sha256 digest
+ */
+function hashLine(lineText) {
+ return crypto.createHash('sha256').update(String(lineText).trim(), 'utf8').digest('hex');
+}
+
+/**
+ * Build a baseline document from runScans() output.
+ *
+ * @param {{ fileLength: { details?: object[] }, secrets: { details?: object[] } }} scans
+ * @returns {object} Baseline document (schemaVersion 1)
+ */
+function createBaseline(scans) {
+ return {
+ schemaVersion: SCHEMA_VERSION,
+ kind: BASELINE_KIND,
+ generatedAt: new Date().toISOString(),
+ fileLength: (scans.fileLength.details || []).map(d => ({
+ file: slash(d.file),
+ lines: d.lines,
+ })),
+ secrets: (scans.secrets.details || []).map(d => ({
+ file: slash(d.file),
+ label: d.pattern,
+ contextHash: d.contextHash,
+ })),
+ };
+}
+
+/**
+ * Load and validate a baseline file. Throws with a clear message when the
+ * file is missing or not a code-warden baseline - silently ignoring a bad
+ * baseline would fake a gate.
+ *
+ * @param {string} baselinePath
+ * @returns {object} Parsed baseline document
+ */
+function loadBaseline(baselinePath) {
+ if (!fs.existsSync(baselinePath)) {
+ throw new Error(`baseline file not found: ${baselinePath} (generate one with --write-baseline)`);
+ }
+ let parsed;
+ try {
+ parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
+ } catch (err) {
+ throw new Error(`baseline file could not be parsed: ${baselinePath} (${err.message})`);
+ }
+ if (!parsed || parsed.kind !== BASELINE_KIND || parsed.schemaVersion !== SCHEMA_VERSION) {
+ throw new Error(`not a code-warden baseline (kind=${parsed && parsed.kind}, ` +
+ `schemaVersion=${parsed && parsed.schemaVersion}): ${baselinePath}`);
+ }
+ return parsed;
+}
+
+/**
+ * Partition scan violations into legacy (covered by the baseline) and fresh.
+ *
+ * @param {{ fileLength: { details?: object[] }, secrets: { details?: object[] } }} scans
+ * @param {object} baseline - Document from loadBaseline()/createBaseline()
+ * @returns {{ fileLength: { fresh: object[], legacy: object[] },
+ * secrets: { fresh: object[], legacy: object[] } }}
+ */
+function applyBaseline(scans, baseline) {
+ const allowedLines = new Map((baseline.fileLength || []).map(e => [slash(e.file), e.lines]));
+ const legacySecrets = new Set((baseline.secrets || []).map(e => secretKey(e.file, e.label, e.contextHash)));
+
+ const fileLength = { fresh: [], legacy: [] };
+ for (const d of scans.fileLength.details || []) {
+ const allowed = allowedLines.get(slash(d.file));
+ const isLegacy = typeof allowed === 'number' && d.lines <= allowed; // ratchet
+ (isLegacy ? fileLength.legacy : fileLength.fresh).push(d);
+ }
+
+ const secrets = { fresh: [], legacy: [] };
+ for (const d of scans.secrets.details || []) {
+ const isLegacy = legacySecrets.has(secretKey(d.file, d.pattern, d.contextHash));
+ (isLegacy ? secrets.legacy : secrets.fresh).push(d);
+ }
+
+ return { fileLength, secrets };
+}
+
+function secretKey(file, label, contextHash) {
+ // NUL separators cannot occur in paths or pattern labels, so keys stay
+ // collision-free even though labels contain spaces.
+ return `${slash(file)}\u0000${label}\u0000${contextHash}`;
+}
+
+/**
+ * Rebuild the fileLength/secrets check objects so only FRESH violations gate
+ * the result. `details` keeps fresh-only entries - the SARIF formatter
+ * consumes `details`, so legacy noise never reaches Code Scanning.
+ * `legacyDetails` carries the baselined remainder for transparency.
+ *
+ * @param {object} scans - runScans() output
+ * @param {object} baseline - Loaded baseline document
+ * @returns {{ fileLength: object, secrets: object,
+ * legacy: { fileLength: number, secrets: number } }}
+ */
+function applyBaselineToChecks(scans, baseline) {
+ const parts = applyBaseline(scans, baseline);
+ const rebuild = (check, { fresh, legacy }) => ({
+ status: fresh.length === 0 ? 'pass' : 'fail',
+ filesScanned: check.filesScanned,
+ violations: fresh.length,
+ legacyViolations: legacy.length,
+ details: fresh.length > 0 ? fresh : undefined,
+ legacyDetails: legacy.length > 0 ? legacy : undefined,
+ });
+ return {
+ fileLength: rebuild(scans.fileLength, parts.fileLength),
+ secrets: rebuild(scans.secrets, parts.secrets),
+ legacy: {
+ fileLength: parts.fileLength.legacy.length,
+ secrets: parts.secrets.legacy.length,
+ },
+ };
+}
+
+module.exports = {
+ createBaseline, loadBaseline, applyBaseline, applyBaselineToChecks,
+ hashLine, BASELINE_KIND, SCHEMA_VERSION, DEFAULT_BASELINE,
+};
diff --git a/code-warden/tools/lib/command-risk.js b/code-warden/tools/lib/command-risk.js
new file mode 100644
index 0000000..4aa64f0
--- /dev/null
+++ b/code-warden/tools/lib/command-risk.js
@@ -0,0 +1,222 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * command-risk.js
+ * Command Risk Gate: classifies shell commands against risk_policy tiers.
+ *
+ * Two tiers are enforced at runtime:
+ * blocked - deny outright (destructive/irreversible or remote-code-exec)
+ * high - ask for confirmation (Claude); Codex hooks have no "ask"
+ * equivalent, so high allows silently there (see the bash hook)
+ *
+ * Defaults are deliberately conservative: a false deny on a routine command
+ * erodes trust faster than a missed exotic one. Users tune behavior via
+ * risk_policy.command_rules in codewarden.json - rules merge with defaults
+ * by id (tier "off"/"allow" disables a default; a reused id replaces it).
+ */
+
+// ---------------------------------------------------------------------------
+// Default rules
+// ---------------------------------------------------------------------------
+
+// Dangerous deletion roots: /, /*, ~, ., .., *, .git, or a bare drive (C:\).
+const NIX_ROOTS = String.raw`(?:\/\*?|~\/?|\.git\/?|\.{1,2}\/?|\*|[A-Za-z]:[\\\/]?\*?)`;
+
+const DEFAULT_COMMAND_RULES = [
+ // --- blocked -------------------------------------------------------------
+ {
+ id: 'rm_rf_root',
+ tier: 'blocked',
+ pattern: new RegExp(String.raw`(?:^|[\s;&|(])rm\s+(?:-{1,2}[\w=-]+\s+)*(?:-[a-zA-Z]*r[a-zA-Z]*|--recursive)\s+(?:-{1,2}[\w=-]+\s+)*(?:--\s+)?["']?` + NIX_ROOTS + String.raw`["']?\s*(?:$|[;&|)])`, 'i'),
+ message: 'Recursive delete targets a critical root path (/, ~, ., .git, *, or a drive root).',
+ },
+ {
+ id: 'rd_root',
+ tier: 'blocked',
+ pattern: /(?:^|[\s;&|(])(?:rd|rmdir)\s+\/s\b[^\n;&|]*\s["']?(?:[A-Za-z]:[\\/]?|\\)["']?\s*(?:$|[;&|)])/i,
+ message: 'Recursive directory removal targets a drive root.',
+ },
+ {
+ id: 'remove_item_root',
+ tier: 'blocked',
+ pattern: /(?:^|[\s;&|(])remove-item\b(?=[^\n;|]*\s-recurse\b)(?=[^\n;|]*\s-force\b)(?=[^\n;|]*\s["']?(?:[A-Za-z]:[\\/]?\*?|\\|\/|~|\$env:USERPROFILE|\$HOME|\.{1,2})["']?\s*(?:$|[\s;|&]))/i,
+ message: 'Remove-Item -Recurse -Force targets a critical root path.',
+ },
+ {
+ id: 'git_reset_hard',
+ tier: 'blocked',
+ pattern: /(?:^|[\s;&|(])git\s+reset\b[^\n;&|]*--hard\b/i,
+ message: 'git reset --hard discards uncommitted work irreversibly.',
+ },
+ {
+ id: 'git_push_force',
+ tier: 'blocked',
+ pattern: /(?:^|[\s;&|(])git\s+push\b(?=[^\n;&|]*\s(?:--force(?!-with-lease|-if-includes)\b|-f\b))/i,
+ message: 'git push --force can destroy remote history. Use --force-with-lease if a force push is truly needed.',
+ },
+ {
+ id: 'git_clean_force',
+ tier: 'blocked',
+ pattern: /(?:^|[\s;&|(])git\s+clean\b(?=[^\n;&|]*\s(?:-[a-zA-Z]*f|--force\b))/i,
+ message: 'git clean -f deletes untracked files irreversibly.',
+ },
+ {
+ id: 'git_history_rewrite',
+ tier: 'blocked',
+ pattern: /(?:^|[\s;&|(])git(?:\s+|-)filter-(?:branch|repo)\b/i,
+ message: 'History rewriting (filter-branch/filter-repo) is destructive and repo-wide.',
+ },
+ {
+ id: 'curl_pipe_shell',
+ tier: 'blocked',
+ pattern: /\b(?:curl|wget)\b[^\n;&]*\|\s*(?:sudo\s+)?(?:ba|da|z)?sh\b/i,
+ message: 'Piping a download straight into a shell executes unreviewed remote code.',
+ },
+ {
+ id: 'ps_web_pipe_iex',
+ tier: 'blocked',
+ pattern: /\b(?:iwr|irm|invoke-webrequest|invoke-restmethod)\b[^\n;]*\|\s*(?:iex|invoke-expression)\b|\b(?:iex|invoke-expression)\b\s*\(?\s*&?\s*\(?\s*(?:iwr|irm|invoke-webrequest|invoke-restmethod)\b/i,
+ message: 'Piping a download into Invoke-Expression executes unreviewed remote code.',
+ },
+ {
+ id: 'chmod_777_root',
+ tier: 'blocked',
+ pattern: /(?:^|[\s;&|(])chmod\b(?=[^\n;&|]*\s-[a-zA-Z]*R)(?=[^\n;&|]*\s0?777\b)[^\n;&|]*\s\/\s*(?:$|[;&|])/i,
+ message: 'chmod -R 777 / makes the entire filesystem world-writable.',
+ },
+ // --- high (ask) ----------------------------------------------------------
+ {
+ id: 'package_install',
+ tier: 'high',
+ pattern: /(?:^|[\s;&|(])(?:npm|pnpm|yarn|bun)\s+(?:-[^\s]+\s+)*(?:install|uninstall|add|remove|update|upgrade|i|rm|un|up)\b(?=(?:\s+-{1,2}[\w:=./@-]+)*\s+["']?[^-\s"'])/i,
+ message: 'Dependency change (risk_policy: dependency_change is a high-tier action).',
+ },
+ {
+ id: 'npm_publish',
+ tier: 'high',
+ pattern: /(?:^|[\s;&|(])(?:npm|pnpm|yarn|bun)\s+publish\b/i,
+ message: 'Publishing a package (risk_policy: release_publish is a high-tier action).',
+ },
+ {
+ id: 'git_push',
+ tier: 'high',
+ pattern: /(?:^|[\s;&|(])git\s+push\b/i,
+ message: 'Pushing to a remote (risk_policy: release_publish is a high-tier action).',
+ },
+ {
+ id: 'recursive_delete',
+ tier: 'high',
+ pattern: /(?:^|[\s;&|(])rm\s+(?:-{1,2}[\w=-]+\s+)*(?:-[a-zA-Z]*r[a-zA-Z]*|--recursive)\b|(?:^|[\s;&|(])(?:rd|rmdir)\s+\/s\b/i,
+ message: 'Recursive delete - confirm the target path is intended.',
+ },
+ {
+ id: 'remove_item_recurse',
+ tier: 'high',
+ pattern: /(?:^|[\s;&|(])remove-item\b(?=[^\n;|]*\s-recurse\b)(?=[^\n;|]*\s-force\b)/i,
+ message: 'Remove-Item -Recurse -Force - confirm the target path is intended.',
+ },
+ {
+ id: 'git_discard_changes',
+ tier: 'high',
+ pattern: /(?:^|[\s;&|(])git\s+checkout\b[^\n;&|]*\s--(?:\s|$)|(?:^|[\s;&|(])git\s+restore\s+(?!--staged\b(?![^\n;&|]*--worktree\b))/i,
+ message: 'Discards uncommitted working-tree changes (git checkout -- / git restore).',
+ },
+];
+
+// ---------------------------------------------------------------------------
+// Classification
+// ---------------------------------------------------------------------------
+
+/**
+ * Classify a command against a rule set. Returns the highest-severity match
+ * (blocked > high) or null when no rule matches.
+ *
+ * @param {string} command
+ * @param {Array<{id, pattern: RegExp, tier, message}>} [rules]
+ * @returns {{ tier: 'blocked'|'high', rule: object } | null}
+ */
+function classifyCommand(command, rules) {
+ if (!command || typeof command !== 'string') return null;
+ const list = Array.isArray(rules) ? rules : DEFAULT_COMMAND_RULES;
+ let high = null;
+ for (const rule of list) {
+ if (!rule || !(rule.pattern instanceof RegExp)) continue;
+ if (!rule.pattern.test(command)) continue;
+ if (rule.tier === 'blocked') return { tier: 'blocked', rule };
+ if (rule.tier === 'high' && !high) high = { tier: 'high', rule };
+ }
+ return high;
+}
+
+// ---------------------------------------------------------------------------
+// Config merge
+// ---------------------------------------------------------------------------
+
+/**
+ * Merge user rules (risk_policy.command_rules, raw objects with string
+ * patterns) over the defaults. A user rule reusing a default id REPLACES it;
+ * tier "off" or "allow" disables that rule. Invalid patterns are skipped
+ * defensively with a warning entry - a broken user regex must never turn
+ * the gate into a trap or silently widen it.
+ *
+ * @param {object[]} [userRules]
+ * @returns {{ rules: object[], warnings: string[] }}
+ */
+function mergeCommandRules(userRules) {
+ const byId = new Map(DEFAULT_COMMAND_RULES.map(r => [r.id, r]));
+ const warnings = [];
+
+ for (const raw of Array.isArray(userRules) ? userRules : []) {
+ if (!raw || typeof raw !== 'object' || typeof raw.id !== 'string' || !raw.id) {
+ warnings.push('command_rules entry without an id was skipped');
+ continue;
+ }
+ const tier = String(raw.tier || '').toLowerCase();
+ if (tier === 'off' || tier === 'allow') { byId.delete(raw.id); continue; }
+ if (tier !== 'blocked' && tier !== 'high') {
+ warnings.push(`command_rules.${raw.id}: tier must be "blocked", "high", "off", or "allow" - skipped`);
+ continue;
+ }
+
+ const base = byId.get(raw.id);
+ let pattern = base ? base.pattern : null;
+ if (typeof raw.pattern === 'string' && raw.pattern) {
+ try {
+ pattern = new RegExp(raw.pattern, 'i');
+ } catch (err) {
+ warnings.push(`command_rules.${raw.id}: invalid pattern (${err.message}) - rule skipped`);
+ continue;
+ }
+ }
+ if (!pattern) {
+ warnings.push(`command_rules.${raw.id}: new rules require a pattern - skipped`);
+ continue;
+ }
+ byId.set(raw.id, {
+ id: raw.id,
+ pattern,
+ tier,
+ message: typeof raw.message === 'string' && raw.message
+ ? raw.message
+ : (base ? base.message : `Command matched rule ${raw.id}.`),
+ });
+ }
+
+ return { rules: [...byId.values()], warnings };
+}
+
+/**
+ * Load merged command rules using the shared codewarden.json discovery
+ * (lib/config.js loadConfig) - the single loading path for hooks and tools.
+ *
+ * @param {string} [configPath] - Explicit config override
+ * @param {string} [startDir] - Enables project config discovery
+ * @returns {{ rules: object[], warnings: string[] }}
+ */
+function loadCommandRules(configPath, startDir) {
+ const { commandRules } = require('./config').loadConfig(configPath, startDir);
+ return mergeCommandRules(commandRules);
+}
+
+module.exports = { DEFAULT_COMMAND_RULES, classifyCommand, mergeCommandRules, loadCommandRules };
diff --git a/code-warden/tools/lib/config.js b/code-warden/tools/lib/config.js
index 5236357..f5635b1 100644
--- a/code-warden/tools/lib/config.js
+++ b/code-warden/tools/lib/config.js
@@ -11,26 +11,105 @@
*
* Resolution order for config file:
* 1. Explicit path passed to loadConfig(configPath)
- * 2. <__dirname>/../../codewarden.json (relative to tools/lib/ → skill root)
+ * 2. Discovered project config when startDir is given — walks up from
+ * startDir looking for codewarden.json or code-warden/codewarden.json,
+ * stopping at the first .git boundary (repo root is the last level
+ * checked) or the filesystem root
+ * 3. <__dirname>/../../codewarden.json (relative to tools/lib/ → skill root)
*/
const fs = require('fs');
const path = require('path');
const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', '..', 'codewarden.json');
+const CONFIG_FILENAME = 'codewarden.json';
+
+/**
+ * Generic upward walk shared by config discovery and the scope store.
+ * Starting at startDir, each level is checked for the given relative
+ * candidate paths. The ascent stops after the first directory containing
+ * .git (the repo root is the last level checked) or at the filesystem root.
+ *
+ * @param {string} startDir - Directory to start the upward walk from
+ * @param {string[]} relCandidates - Candidate paths relative to each level
+ * @returns {{ foundPath: string, rootDir: string } | null}
+ */
+function findUpward(startDir, relCandidates) {
+ if (!startDir || typeof startDir !== 'string') return null;
+ let dir = path.resolve(startDir);
+
+ for (;;) {
+ for (const rel of relCandidates) {
+ const candidate = path.join(dir, rel);
+ try {
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
+ return { foundPath: candidate, rootDir: dir };
+ }
+ } catch { /* unreadable candidate — keep walking */ }
+ }
+ const atGitBoundary = fs.existsSync(path.join(dir, '.git'));
+ const parent = path.dirname(dir);
+ if (atGitBoundary || parent === dir) return null;
+ dir = parent;
+ }
+}
+
+/**
+ * Walk up from startDir looking for a project-level codewarden.json.
+ * At each level both /codewarden.json and /code-warden/codewarden.json
+ * are checked (boundary rules: see findUpward).
+ *
+ * @param {string} startDir - Directory to start the upward walk from
+ * @returns {{ configPath: string, projectRoot: string } | null}
+ */
+function findProjectConfig(startDir) {
+ const found = findUpward(startDir, [
+ CONFIG_FILENAME,
+ path.join('code-warden', CONFIG_FILENAME),
+ ]);
+ return found ? { configPath: found.foundPath, projectRoot: found.rootDir } : null;
+}
/**
* Load and parse codewarden.json, returning a merged config object.
* Falls back to defaults silently if the file is missing or unparseable.
*
+ * Precedence: explicit configPath > discovered project config (when startDir
+ * is given) > skill-dir default.
+ *
* @param {string} [configPath] - Override the config file location
- * @returns {{ maxFileLength: number, lintExcludePaths: string[], secretsAllowlist: string[] }}
+ * @param {string} [startDir] - Enables project config discovery from this dir
+ * @returns {{ maxFileLength: number, preFlightTriggerLines: number,
+ * lintExcludePaths: string[], secretsAllowlist: string[],
+ * commandRules: object[], auditEnabled: boolean|null,
+ * verifyOnStop: boolean, projectRoot: string|null }}
+ * commandRules is the raw risk_policy.command_rules array (uncompiled);
+ * auditEnabled is tri-state: true/false when audit.enabled is explicitly
+ * set, null when absent (the audit ledger then follows the scope lock);
+ * verifyOnStop reflects session.verify_on_stop (default false);
+ * projectRoot is non-null only when a project config was discovered via
+ * startDir.
*/
-function loadConfig(configPath) {
- const target = configPath || DEFAULT_CONFIG_PATH;
- let maxFileLength = 400;
- let lintExcludePaths = [];
- let secretsAllowlist = [];
+function loadConfig(configPath, startDir) {
+ let target = configPath || null;
+ let projectRoot = null;
+
+ if (!target && startDir) {
+ const found = findProjectConfig(startDir);
+ if (found) {
+ target = found.configPath;
+ projectRoot = found.projectRoot;
+ }
+ }
+ if (!target) target = DEFAULT_CONFIG_PATH;
+
+ let maxFileLength = 400;
+ let preFlightTriggerLines = 150;
+ let lintExcludePaths = [];
+ let secretsAllowlist = [];
+ let commandRules = [];
+ let auditEnabled = null;
+ let verifyOnStop = false;
try {
const raw = fs.readFileSync(target, 'utf8');
@@ -41,17 +120,34 @@ function loadConfig(configPath) {
if (typeof configured === 'number' && configured > 0) {
maxFileLength = configured;
}
+ const preFlight = cfg?.thresholds?.pre_flight_trigger_lines;
+ if (typeof preFlight === 'number' && preFlight > 0) {
+ preFlightTriggerLines = preFlight;
+ }
if (Array.isArray(cfg?.lint?.exclude_paths)) {
lintExcludePaths = cfg.lint.exclude_paths.filter(p => typeof p === 'string');
}
if (Array.isArray(cfg?.secrets?.allowlist)) {
secretsAllowlist = cfg.secrets.allowlist.filter(p => typeof p === 'string');
}
+ if (Array.isArray(cfg?.risk_policy?.command_rules)) {
+ commandRules = cfg.risk_policy.command_rules.filter(
+ r => r && typeof r === 'object' && !Array.isArray(r)
+ );
+ }
+ if (typeof cfg?.audit?.enabled === 'boolean') {
+ auditEnabled = cfg.audit.enabled;
+ }
+ if (cfg?.session?.verify_on_stop === true) {
+ verifyOnStop = true;
+ }
} catch {
// Missing or invalid config — use defaults
}
- return { maxFileLength, lintExcludePaths, secretsAllowlist };
+ return { maxFileLength, preFlightTriggerLines, lintExcludePaths,
+ secretsAllowlist, commandRules, auditEnabled, verifyOnStop,
+ projectRoot };
}
-module.exports = { loadConfig, DEFAULT_CONFIG_PATH };
+module.exports = { loadConfig, findProjectConfig, findUpward, DEFAULT_CONFIG_PATH };
diff --git a/code-warden/tools/lib/context-discovery.js b/code-warden/tools/lib/context-discovery.js
new file mode 100644
index 0000000..d3630fb
--- /dev/null
+++ b/code-warden/tools/lib/context-discovery.js
@@ -0,0 +1,54 @@
+'use strict';
+
+/**
+ * context-discovery.js
+ * Shared architecture-context file discovery, extracted from get-context.js
+ * so the SessionStart hook and receipt --from-audit reuse the exact same
+ * candidate list and walk order instead of copy-pasting it.
+ *
+ * get-context.js keeps its historical unbounded walk (all the way to the
+ * filesystem root). Hooks pass stopAtGitBoundary:true so a session never
+ * injects context found OUTSIDE the governed repository.
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+/** Candidate files, in priority order (first hit at each level wins). */
+const CONTEXT_CANDIDATES = [
+ 'AGENTS.md',
+ '.codex/AGENTS.md',
+ 'ARCHITECTURE.md',
+ 'docs/ARCHITECTURE.md',
+ '.agents/AGENTS.md',
+ '.claude/CLAUDE.md',
+ 'CLAUDE.md',
+ 'README.md',
+ 'docs/README.md',
+ 'PRD.md',
+];
+
+/**
+ * Walk up from startDir looking for the first architecture context file.
+ *
+ * @param {string} startDir
+ * @param {{ stopAtGitBoundary?: boolean }} [opts] - When true, the first
+ * directory containing .git is the LAST level checked (repo-bounded).
+ * @returns {string|null} Absolute path of the found file, or null
+ */
+function findContextFile(startDir, { stopAtGitBoundary = false } = {}) {
+ let dir = path.resolve(startDir);
+
+ for (;;) {
+ for (const candidate of CONTEXT_CANDIDATES) {
+ const fullPath = path.join(dir, candidate);
+ if (fs.existsSync(fullPath)) return fullPath;
+ }
+ if (stopAtGitBoundary && fs.existsSync(path.join(dir, '.git'))) return null;
+ const parentDir = path.dirname(dir);
+ if (parentDir === dir) return null;
+ dir = parentDir;
+ }
+}
+
+module.exports = { CONTEXT_CANDIDATES, findContextFile };
diff --git a/code-warden/tools/lib/git-info.js b/code-warden/tools/lib/git-info.js
new file mode 100644
index 0000000..92cfd60
--- /dev/null
+++ b/code-warden/tools/lib/git-info.js
@@ -0,0 +1,44 @@
+'use strict';
+
+/**
+ * git-info.js
+ * Best-effort git metadata shared by governance-report.js and
+ * receipt --from-audit. Every helper returns null instead of throwing when
+ * git is unavailable or the directory is not a repository.
+ */
+
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+function runGit(args, cwd) {
+ const r = spawnSync('git', args, {
+ encoding: 'utf8', timeout: 5000, cwd: cwd || process.cwd(),
+ });
+ return r.status === 0 && r.stdout ? r.stdout.trim() : null;
+}
+
+/**
+ * Current branch and short commit for the repository at cwd.
+ *
+ * @param {string} [cwd]
+ * @returns {{ branch: string|null, commit: string|null }}
+ */
+function gitInfo(cwd) {
+ return {
+ branch: runGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd),
+ commit: runGit(['rev-parse', '--short', 'HEAD'], cwd),
+ };
+}
+
+/**
+ * Repository top-level directory for cwd, or null outside a repo.
+ *
+ * @param {string} [cwd]
+ * @returns {string|null}
+ */
+function gitTopLevel(cwd) {
+ const out = runGit(['rev-parse', '--show-toplevel'], cwd);
+ return out ? path.resolve(out) : null;
+}
+
+module.exports = { gitInfo, gitTopLevel };
diff --git a/code-warden/tools/lib/hook-dispatch.js b/code-warden/tools/lib/hook-dispatch.js
new file mode 100644
index 0000000..94c31d8
--- /dev/null
+++ b/code-warden/tools/lib/hook-dispatch.js
@@ -0,0 +1,93 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * hook-dispatch.js
+ * Shared dispatch for install.js --hooks= / --uninstall-hooks= targets and
+ * the `verify git` per-repo check.
+ *
+ * claude/codex hooks are per-user (written under the home directory);
+ * git hooks are PER-REPO (written into the repository at process.cwd()).
+ * Extracted from install.js so adding hook targets does not push the
+ * installer past the file length limit.
+ */
+
+const path = require('path');
+
+const HOOK_TARGET_LABELS = {
+ claude: 'Claude Code',
+ codex: 'OpenAI Codex',
+ git: 'Git pre-commit',
+};
+
+/**
+ * Run install or uninstall for each requested hook target.
+ *
+ * @param {object} opts
+ * @param {string[]} opts.ids - Requested target ids (claude|codex|git)
+ * @param {boolean} opts.uninstall - true for --uninstall-hooks
+ * @param {Array<{id: string, skillsDir: string}>} opts.targets - TARGETS table
+ * @param {string} opts.skillName - Installed skill folder name
+ * @param {(msg: string) => void} opts.log - Prefixed logger
+ * @param {(msg: string) => void} opts.ok - PASS-line logger
+ */
+function dispatchHooks({ ids, uninstall, targets, skillName, log, ok }) {
+ const bad = ids.filter(id => !HOOK_TARGET_LABELS[id]);
+ if (bad.length > 0) {
+ console.error(`[CodeWarden] hooks support: ${Object.keys(HOOK_TARGET_LABELS).join(', ')}. Unknown: ${bad.join(', ')}`);
+ process.exit(1);
+ }
+
+ for (const id of ids) {
+ const target = targets.find(t => t.id === id);
+ const skillDir = target ? path.join(target.skillsDir, skillName) : null;
+ const mod = require(path.join(__dirname, '..', 'hooks', id,
+ `${uninstall ? 'uninstall' : 'install'}-hooks`));
+ const label = HOOK_TARGET_LABELS[id];
+
+ if (uninstall) {
+ log(`Removing hooks for ${label}...`);
+ mod.uninstallHooks();
+ log(id === 'git'
+ ? 'Git hooks are per-repo: this affected only the repository at the current directory.'
+ : `Restart ${label} for changes to take effect.`);
+ } else {
+ log(`Installing hooks for ${label}...`);
+ mod.installHooks(skillDir);
+ ok('Hook entries written');
+ log(id === 'git'
+ ? 'Git hooks are per-repo: installed into the repository at the current directory (not per-user).'
+ : `Restart ${label} for hooks to take effect.`);
+ }
+ }
+}
+
+/**
+ * Verify the git pre-commit backstop for the repository at cwd:
+ * marker block present and the embedded script path exists.
+ *
+ * @param {{ ok: (msg: string) => void, fail: (msg: string) => void }} loggers
+ * @param {string} [cwd]
+ * @returns {string[]} Issue labels (empty when healthy)
+ */
+function verifyGitHooks({ ok, fail }, cwd) {
+ const { inspectPreCommit } = require(path.join(__dirname, '..', 'hooks', 'git', 'install-hooks'));
+ const s = inspectPreCommit(cwd || process.cwd());
+ const issues = [];
+ const check = (label, pass) => {
+ if (pass) ok(label); else { fail(label); issues.push(label); }
+ };
+
+ console.log(' Git pre-commit (repository at current directory)');
+ check(' Inside a git repository', s.insideRepo);
+ if (s.insideRepo) {
+ check(` Marker block present (${s.preCommitPath})`, s.hasBlock);
+ if (s.hasBlock) {
+ check(` Embedded hook script exists (${s.scriptPath || '?'})`, s.scriptExists);
+ }
+ }
+ console.log('');
+ return issues;
+}
+
+module.exports = { dispatchHooks, verifyGitHooks, HOOK_TARGET_LABELS };
diff --git a/code-warden/tools/lib/hook-events.js b/code-warden/tools/lib/hook-events.js
new file mode 100644
index 0000000..00b0266
--- /dev/null
+++ b/code-warden/tools/lib/hook-events.js
@@ -0,0 +1,100 @@
+'use strict';
+
+/**
+ * hook-events.js
+ * Shared helpers for managing code-warden entries across the Claude Code
+ * settings.json hook event arrays (PreToolUse, PostToolUse, SessionStart,
+ * Stop). Entries are identified by the description marker prefix
+ * "code-warden:"; everything else is preserved untouched.
+ *
+ * Extracted so install-hooks.js, uninstall-hooks.js, install.js (doctor),
+ * and governance-report.js share one definition of "a code-warden entry"
+ * and one list of managed events.
+ */
+
+const MARKER_PREFIX = 'code-warden:';
+
+/** Every settings.json hook event array that code-warden manages. */
+const CLAUDE_HOOK_EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'Stop'];
+
+/** True when a hook entry carries the code-warden description marker. */
+function isMarked(entry) {
+ return String((entry && entry.description) || '').startsWith(MARKER_PREFIX);
+}
+
+/**
+ * Remove code-warden entries from one event's matcher-group array.
+ * Non-code-warden hooks inside mixed groups are preserved; groups left
+ * empty are dropped.
+ *
+ * @param {Array<{matcher?: string, hooks?: object[]}>} groups
+ * @returns {Array}
+ */
+function stripEventGroups(groups) {
+ return (groups || [])
+ .map(group => ({
+ ...group,
+ hooks: (group.hooks || []).filter(h => !isMarked(h)),
+ }))
+ .filter(group => (group.hooks || []).length > 0);
+}
+
+/**
+ * Flatten all code-warden entries across every managed event array.
+ *
+ * @param {object|undefined} hooks - settings.hooks object
+ * @returns {object[]} Marked hook entries (possibly empty)
+ */
+function collectMarkedEntries(hooks) {
+ if (!hooks || typeof hooks !== 'object') return [];
+ return CLAUDE_HOOK_EVENTS
+ .flatMap(event => (Array.isArray(hooks[event]) ? hooks[event] : []))
+ .flatMap(group => group.hooks || [])
+ .filter(isMarked);
+}
+
+/**
+ * Merge fresh code-warden groups into a settings object: per event, stale
+ * marked entries are stripped first, then the new groups are appended after
+ * any user-defined groups. Mutates and returns settings.
+ *
+ * @param {object} settings - Parsed settings.json object
+ * @param {Object} eventGroups - event name -> matcher groups
+ * @returns {object} The same settings object
+ */
+function applyEventGroups(settings, eventGroups) {
+ settings.hooks = settings.hooks || {};
+ for (const [event, groups] of Object.entries(eventGroups)) {
+ const cleaned = stripEventGroups(settings.hooks[event]);
+ settings.hooks[event] = [...cleaned, ...groups];
+ }
+ return settings;
+}
+
+/**
+ * Remove every code-warden entry from all managed event arrays. Empty event
+ * arrays and an empty hooks object are deleted afterwards. Mutates settings.
+ *
+ * @param {object} settings - Parsed settings.json object
+ * @returns {number} Count of removed hook entries
+ */
+function removeMarkedEntries(settings) {
+ if (!settings || !settings.hooks) return 0;
+ let removed = 0;
+ for (const event of CLAUDE_HOOK_EVENTS) {
+ const groups = settings.hooks[event];
+ if (!Array.isArray(groups)) continue;
+ removed += groups.flatMap(g => g.hooks || []).filter(isMarked).length;
+ const cleaned = stripEventGroups(groups);
+ if (cleaned.length === 0) delete settings.hooks[event];
+ else settings.hooks[event] = cleaned;
+ }
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
+ return removed;
+}
+
+module.exports = {
+ MARKER_PREFIX, CLAUDE_HOOK_EVENTS,
+ isMarked, stripEventGroups, collectMarkedEntries,
+ applyEventGroups, removeMarkedEntries,
+};
diff --git a/code-warden/tools/lib/path-match.js b/code-warden/tools/lib/path-match.js
new file mode 100644
index 0000000..091fbd6
--- /dev/null
+++ b/code-warden/tools/lib/path-match.js
@@ -0,0 +1,50 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * path-match.js
+ * Shared path-prefix matching for lint.exclude_paths and secrets.allowlist.
+ *
+ * Extracted from governance-report.js so runtime hooks and CI apply the
+ * exact same semantics: forward-slash normalisation plus prefix match, with
+ * an exact-match fallback for entries listed without a trailing slash.
+ */
+
+const path = require('path');
+
+/**
+ * Test whether a (project-relative) file path matches any configured prefix.
+ *
+ * @param {string} filePath - Relative path; backslashes are normalised
+ * @param {string[]} prefixes - Configured prefixes (e.g. ["vendor/", "docs/"])
+ * @returns {boolean}
+ */
+function matchesAnyPrefix(filePath, prefixes) {
+ if (!Array.isArray(prefixes) || prefixes.length === 0) return false;
+ const normalized = String(filePath).replace(/\\/g, '/');
+ return prefixes.some(p => normalized.startsWith(p) || normalized === p.replace(/\/$/, ''));
+}
+
+/**
+ * Test whether a file path (absolute or relative to baseDir) falls under a
+ * configured prefix when expressed relative to the project root.
+ *
+ * Returns false when there is no project root, the path resolves outside the
+ * project, or no prefixes are configured — i.e. "do not skip the check".
+ *
+ * @param {string} filePath - Target file path from a tool payload
+ * @param {string|null} projectRoot - Discovered project root (or null)
+ * @param {string[]} prefixes - Configured prefixes
+ * @param {string} [baseDir] - Base for resolving relative paths (defaults to projectRoot)
+ * @returns {boolean}
+ */
+function matchesProjectPath(filePath, projectRoot, prefixes, baseDir) {
+ if (!projectRoot || !filePath) return false;
+ if (!Array.isArray(prefixes) || prefixes.length === 0) return false;
+ const abs = path.resolve(baseDir || projectRoot, filePath);
+ const rel = path.relative(projectRoot, abs);
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
+ return matchesAnyPrefix(rel, prefixes);
+}
+
+module.exports = { matchesAnyPrefix, matchesProjectPath };
diff --git a/code-warden/tools/lib/receipt-audit.js b/code-warden/tools/lib/receipt-audit.js
new file mode 100644
index 0000000..a0dfcff
--- /dev/null
+++ b/code-warden/tools/lib/receipt-audit.js
@@ -0,0 +1,108 @@
+'use strict';
+
+/**
+ * receipt-audit.js
+ * Builds a governance receipt prefilled from session evidence:
+ * the audit ledger (hash-chain verified), the scope lock, architecture
+ * context discovery, and git metadata. Split out of receipt.js so the CLI
+ * stays under the file length limit.
+ *
+ * The result is still a DRAFT: nonGoals/verifyAfter/rollback and the whole
+ * Plan Gate are judgement calls only the human can attest to. Prefill
+ * corroborates; it never auto-completes.
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const { findScopeFile, loadScope } = require('./scope-store');
+const { findProjectConfig } = require('./config');
+const { findContextFile } = require('./context-discovery');
+const { verifyLedger, readLedgerEntries, LEDGER_REL } = require('./audit-ledger');
+const { gitInfo, gitTopLevel } = require('./git-info');
+
+const slash = p => String(p).replace(/\\/g, '/');
+
+/**
+ * Project root for receipt evidence: scope root wins (a governed session),
+ * then discovered codewarden.json root, then git top-level, then cwd.
+ */
+function discoverRoot(cwd) {
+ const scope = findScopeFile(cwd);
+ if (scope) return scope.scopeRoot;
+ const cfg = findProjectConfig(cwd);
+ if (cfg) return cfg.projectRoot;
+ return gitTopLevel(cwd) || path.resolve(cwd);
+}
+
+/**
+ * Prefill a receipt template from the audit ledger and project evidence.
+ *
+ * @param {object} opts
+ * @param {object} opts.template - Fresh receipt template (createTemplate())
+ * @param {string} opts.cwd - Directory to discover the project root from
+ * @param {string|null} [opts.ledgerPath] - Explicit ledger path override
+ * @returns {{ receipt: object, summary: string }}
+ * @throws {Error} When the ledger file does not exist
+ */
+function buildAuditReceipt({ template, cwd, ledgerPath }) {
+ const root = discoverRoot(cwd);
+ const ledger = ledgerPath
+ ? path.resolve(cwd, ledgerPath)
+ : path.join(root, LEDGER_REL);
+
+ if (!fs.existsSync(ledger)) {
+ throw new Error(`audit ledger not found: ${ledger} ` +
+ '(run a governed Claude session first, or pass --from-audit=)');
+ }
+
+ const chain = verifyLedger(ledger);
+ const entries = readLedgerEntries(ledger);
+ const receipt = template; // status stays 'draft' - the human completes it
+
+ // Architecture context (repo-bounded, same discovery as the session hook)
+ const contextFile = findContextFile(root, { stopAtGitBoundary: true });
+ if (contextFile) {
+ let summaryLine = '';
+ try {
+ summaryLine = (fs.readFileSync(contextFile, 'utf8')
+ .split(/\r?\n/).find(l => l.trim()) || '').trim().slice(0, 200);
+ } catch { /* unreadable - source path is still evidence */ }
+ receipt.architectureState = { source: slash(contextFile), summary: summaryLine };
+ }
+
+ // Scope Gate: the scope file only exists if the user ran the CLI, so the
+ // files-in contract IS confirmed. nonGoals/verifyAfter/rollback stay empty
+ // for the human.
+ const found = findScopeFile(root);
+ const scope = found ? loadScope(found.scopePath) : null;
+ if (scope) {
+ receipt.scopeGate.confirmed = true;
+ receipt.scopeGate.goal = typeof scope.goal === 'string' ? scope.goal : '';
+ receipt.scopeGate.filesIn = [...scope.filesIn];
+ }
+
+ receipt.repository = gitInfo(root);
+
+ receipt.audit = {
+ path: slash(ledger),
+ entries: chain.entries,
+ chainValid: chain.valid,
+ ...(chain.valid ? {} : { brokenAt: chain.brokenAt }),
+ };
+
+ receipt.finalEvidence.commands = entries
+ .filter(e => e.tool === 'Bash' || e.tool === 'PowerShell')
+ .map(e => e.target)
+ .filter(t => typeof t === 'string' && t.length > 0);
+ receipt.finalEvidence.notes = [
+ `prefilled from audit ledger ${chain.entries} entries; ` +
+ `chain ${chain.valid ? 'valid' : 'BROKEN'}`,
+ ];
+
+ const summary = `${chain.entries} ledger entr${chain.entries === 1 ? 'y' : 'ies'}, ` +
+ `chain ${chain.valid ? 'valid' : `BROKEN at line ${chain.brokenAt}`}`;
+ return { receipt, summary };
+}
+
+module.exports = { buildAuditReceipt, discoverRoot };
diff --git a/code-warden/tools/lib/report-format.js b/code-warden/tools/lib/report-format.js
new file mode 100644
index 0000000..2c6dff9
--- /dev/null
+++ b/code-warden/tools/lib/report-format.js
@@ -0,0 +1,94 @@
+'use strict';
+
+/**
+ * report-format.js
+ * Markdown and one-line summary formatters for the governance report.
+ *
+ * Extracted from governance-report.js so baseline-aware output does not push
+ * the report generator past the file length limit. When a baseline is
+ * applied (report.baseline.applied), the scan rows show 'N new / M legacy'
+ * instead of a flat violation count.
+ */
+
+/**
+ * Violation cell text for a scan check, baseline-aware.
+ *
+ * @param {object} check - checks.fileLength or checks.secrets
+ * @param {boolean} baselineApplied
+ * @returns {string}
+ */
+function violationCell(check, baselineApplied) {
+ if (!baselineApplied) return `${check.violations} violations`;
+ return `${check.violations} new / ${check.legacyViolations || 0} legacy violations`;
+}
+
+/**
+ * Render the full Markdown governance report.
+ *
+ * @param {object} report - generateReport() output
+ * @returns {string}
+ */
+function formatMarkdown(report) {
+ const badge = s => s === 'pass' ? 'PASS' : s === 'skip' ? 'SKIP' : 'FAIL';
+ const baselined = Boolean(report.baseline && report.baseline.applied);
+ const hookLabel = (id) => {
+ const s = report.governance.runtimeHooks[id];
+ if (s === 'registered') return 'verified';
+ if (s === 'registered_broken') return 'broken';
+ if (s === 'not_registered') return 'none';
+ return 'n/a';
+ };
+
+ const healthDetail = report.checks.installHealth.missing
+ ? 'Missing: ' + report.checks.installHealth.missing.join(', ')
+ : 'All source files present';
+
+ const lines = [
+ '## Code-Warden Governance Report',
+ '',
+ '| Check | Result | Details |',
+ '|-------|--------|---------|',
+ `| File length | ${badge(report.checks.fileLength.status)} | ${report.checks.fileLength.filesScanned} files scanned, ${violationCell(report.checks.fileLength, baselined)} |`,
+ `| Hardcoded credentials | ${badge(report.checks.secrets.status)} | ${report.checks.secrets.filesScanned} files scanned, ${violationCell(report.checks.secrets, baselined)} |`,
+ `| Behavioral tests | ${badge(report.checks.behavioralTests.status)} | ${report.checks.behavioralTests.tests} tests, ${report.checks.behavioralTests.failures} failures |`,
+ `| Install health | ${badge(report.checks.installHealth.status)} | ${healthDetail} |`,
+ `| Risk policy | ${badge(report.checks.riskPolicy.status)} | ${Object.keys(report.checks.riskPolicy.actions).length} governed actions |`,
+ `| Runtime hooks | — | Claude: ${hookLabel('claude')} / Codex: ${hookLabel('codex')} |`,
+ '',
+ `**Result:** ${report.result === 'pass' ? 'All governed checks passed.' : 'One or more checks failed.'}`,
+ ];
+
+ if (baselined) {
+ lines.push('', `> Baseline applied (${report.baseline.path}): ` +
+ `${report.baseline.legacy.fileLength} legacy file-length and ` +
+ `${report.baseline.legacy.secrets} legacy secret finding(s) excluded from the gate.`);
+ }
+
+ lines.push('', `> Generated by Code-Warden v${report.version} at ${report.timestamp}`);
+ return lines.join('\n');
+}
+
+/**
+ * Render the one-line summary printed in default mode.
+ *
+ * @param {object} report - generateReport() output
+ * @returns {string}
+ */
+function formatSummary(report) {
+ const c = report.checks;
+ const parts = [
+ `lint:${c.fileLength.status}`,
+ `secrets:${c.secrets.status}`,
+ `tests:${c.behavioralTests.status}`,
+ `health:${c.installHealth.status}`,
+ `risk:${c.riskPolicy.status}`,
+ ];
+ let line = `[CodeWarden] Governance report: ${report.result.toUpperCase()} (${parts.join(', ')})`;
+ if (report.baseline && report.baseline.applied) {
+ line += ` [baseline: lint ${c.fileLength.violations} new / ${c.fileLength.legacyViolations || 0} legacy,` +
+ ` secrets ${c.secrets.violations} new / ${c.secrets.legacyViolations || 0} legacy]`;
+ }
+ return line;
+}
+
+module.exports = { formatMarkdown, formatSummary, violationCell };
diff --git a/code-warden/tools/lib/scan-core.js b/code-warden/tools/lib/scan-core.js
new file mode 100644
index 0000000..aa2faa1
--- /dev/null
+++ b/code-warden/tools/lib/scan-core.js
@@ -0,0 +1,101 @@
+'use strict';
+
+/**
+ * scan-core.js
+ * Single-pass file length + secrets scan shared by governance-report.js (CI)
+ * and warden-stop-hook.js (Stop verification). Extracted so the Stop hook can
+ * run the exact same checks in-process - no child process, no behavioral
+ * tests, no git - and stay fast enough for an end-of-turn gate.
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const { countLines } = require('./line-count');
+const { collectFiles } = require('./file-collection');
+const { scanForAllSecrets } = require('./secret-patterns');
+const { loadConfig } = require('./config');
+const { matchesAnyPrefix } = require('./path-match');
+const { hashLine } = require('./baseline');
+
+/**
+ * Scan a file or directory for file-length and hardcoded-credential
+ * violations, honouring lint.exclude_paths and secrets.allowlist.
+ *
+ * @param {string} scanPath - File or directory to scan
+ * @param {string} [configPath] - Explicit codewarden.json override
+ * @param {string} [startDir] - Enables project config discovery from this
+ * dir (hooks pass the governed root; the CI report passes nothing so its
+ * --config= / skill-default precedence is unchanged)
+ * @returns {{ fileLength: object, secrets: object }} Check objects in the
+ * governance-report shape ({ status, filesScanned, violations, details? })
+ * @throws {Error} When scanPath does not exist
+ */
+function runScans(scanPath, configPath, startDir) {
+ const { maxFileLength, lintExcludePaths, secretsAllowlist } =
+ loadConfig(configPath, startDir);
+ const resolved = path.resolve(scanPath);
+
+ if (!fs.existsSync(resolved)) {
+ throw new Error(`scan path not found: ${scanPath}`);
+ }
+
+ const files = [];
+ const scanRootIsDirectory = fs.statSync(resolved).isDirectory();
+ if (scanRootIsDirectory) {
+ collectFiles(resolved, files);
+ } else {
+ files.push(resolved);
+ }
+
+ const lengthViolations = [];
+ const secretViolations = [];
+
+ for (const f of files) {
+ let content;
+ try { content = fs.readFileSync(f, 'utf8'); } catch { continue; }
+
+ const rel = scanRootIsDirectory ? path.relative(resolved, f) : path.basename(f);
+
+ if (!matchesAnyPrefix(rel, lintExcludePaths)) {
+ const lineCount = countLines(content);
+ if (lineCount > maxFileLength) {
+ lengthViolations.push({ file: rel, lines: lineCount, limit: maxFileLength });
+ }
+ }
+
+ if (!matchesAnyPrefix(rel, secretsAllowlist)) {
+ const hits = scanForAllSecrets(content);
+ if (hits.length > 0) {
+ // Split matches locationForIndex() in secret-patterns ('\n' only);
+ // hashLine() trims, so stray '\r' never affects the fingerprint.
+ const sourceLines = content.split('\n');
+ for (const hit of hits) {
+ secretViolations.push({
+ file: rel, pattern: hit.label, line: hit.line, column: hit.column,
+ // Content fingerprint of the matched line for baseline matching.
+ // Never the raw text - line numbers drift, hashes survive moves.
+ contextHash: hashLine(sourceLines[hit.line - 1] || ''),
+ });
+ }
+ }
+ }
+ }
+
+ return {
+ fileLength: {
+ status: lengthViolations.length === 0 ? 'pass' : 'fail',
+ filesScanned: files.length,
+ violations: lengthViolations.length,
+ details: lengthViolations.length > 0 ? lengthViolations : undefined,
+ },
+ secrets: {
+ status: secretViolations.length === 0 ? 'pass' : 'fail',
+ filesScanned: files.length,
+ violations: secretViolations.length,
+ details: secretViolations.length > 0 ? secretViolations : undefined,
+ },
+ };
+}
+
+module.exports = { runScans };
diff --git a/code-warden/tools/lib/scope-store.js b/code-warden/tools/lib/scope-store.js
new file mode 100644
index 0000000..df7e83d
--- /dev/null
+++ b/code-warden/tools/lib/scope-store.js
@@ -0,0 +1,257 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * scope-store.js
+ * Storage + evaluation for the Scope Lock (.code-warden/scope.json).
+ *
+ * The scope file mechanically enforces the Scope Gate's files-in contract:
+ * once a scope is set, write hooks (Claude Write/Edit/NotebookEdit and Codex
+ * apply_patch) deny edits outside the declared paths. Strictly opt-in - no
+ * scope file means no enforcement. Shared by tools/scope.js (CLI), both
+ * runtime hook surfaces, and governance-report.js so semantics never drift.
+ *
+ * Schema (rhymes with the receipt's scopeGate block):
+ * { schemaVersion: 1, kind: 'code-warden/scope', goal, enforce,
+ * createdAt, updatedAt, filesIn: [...], expansions: [{path, addedAt}] }
+ *
+ * filesIn entries are repo-root-relative, forward-slash normalized: exact
+ * file paths, or directory prefixes ending '/' (lib/path-match semantics).
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { matchesAnyPrefix } = require('./path-match');
+const { findUpward } = require('./config');
+
+const SCOPE_DIR = '.code-warden';
+const SCOPE_FILE = 'scope.json';
+
+/** Absolute scope file path for a given repo root. */
+function scopePathFor(rootDir) {
+ return path.join(rootDir, SCOPE_DIR, SCOPE_FILE);
+}
+
+/**
+ * Walk up from startDir looking for .code-warden/scope.json, stopping after
+ * the first directory containing .git (same boundary rule as config
+ * discovery - see findUpward in lib/config.js).
+ *
+ * @param {string} startDir
+ * @returns {{ scopePath: string, scopeRoot: string } | null}
+ */
+function findScopeFile(startDir) {
+ const found = findUpward(startDir, [path.join(SCOPE_DIR, SCOPE_FILE)]);
+ return found ? { scopePath: found.foundPath, scopeRoot: found.rootDir } : null;
+}
+
+/**
+ * Load and minimally validate a scope file.
+ * Returns null when missing, unparseable, or structurally invalid - the
+ * hooks treat null as "no scope lock" (opt-in enforcement, never a trap).
+ *
+ * @param {string} scopePath
+ * @returns {object|null}
+ */
+function loadScope(scopePath) {
+ try {
+ const scope = JSON.parse(fs.readFileSync(scopePath, 'utf8').replace(/^\uFEFF/, ''));
+ if (!scope || typeof scope !== 'object' || Array.isArray(scope)) return null;
+ if (!Array.isArray(scope.filesIn)) return null;
+ return scope;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Write a scope object to /.code-warden/scope.json, refreshing
+ * updatedAt. Creates the .code-warden directory if needed.
+ *
+ * @param {string} rootDir
+ * @param {object} scope
+ * @returns {string} The written file path
+ */
+function saveScope(rootDir, scope) {
+ const dest = scopePathFor(rootDir);
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ scope.updatedAt = new Date().toISOString();
+ fs.writeFileSync(dest, JSON.stringify(scope, null, 2) + '\n', 'utf8');
+ return dest;
+}
+
+/**
+ * Create a fresh scope object.
+ *
+ * @param {{ goal?: string, filesIn?: string[], enforce?: boolean }} [opts]
+ * @returns {object}
+ */
+function createScope({ goal = '', filesIn = [], enforce = true } = {}) {
+ const now = new Date().toISOString();
+ return {
+ schemaVersion: 1,
+ kind: 'code-warden/scope',
+ goal,
+ enforce,
+ createdAt: now,
+ updatedAt: now,
+ filesIn: [...filesIn],
+ expansions: [],
+ };
+}
+
+/**
+ * Normalize a CLI path argument into a repo-root-relative scope entry:
+ * forward slashes, no leading './', absolute paths re-expressed relative to
+ * rootDir, and a trailing '/' appended when the path is an existing
+ * directory (directory-prefix semantics).
+ *
+ * @param {string} entry - Raw path argument
+ * @param {string} rootDir - Repo root the entry is relative to
+ * @returns {string|null} Normalized entry, or null if it escapes rootDir
+ */
+function normalizeScopeEntry(entry, rootDir) {
+ let p = String(entry).trim().replace(/\\/g, '/');
+ if (!p) return null;
+ if (path.isAbsolute(p) || /^[A-Za-z]:/.test(p)) {
+ const rel = path.relative(rootDir, p);
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
+ p = rel.replace(/\\/g, '/');
+ }
+ p = p.replace(/^(\.\/)+/, '');
+ if (!p || p === '.' || p.startsWith('../')) return null;
+ if (!p.endsWith('/')) {
+ try {
+ if (fs.statSync(path.join(rootDir, p)).isDirectory()) p += '/';
+ } catch { /* not on disk yet - keep as given */ }
+ }
+ return p;
+}
+
+/**
+ * Evaluate a write target against a loaded scope.
+ * Order matters: self-protection of .code-warden/ applies even when
+ * enforce is false; the enforce switch is checked next; then root
+ * containment; then filesIn prefix matching.
+ *
+ * @param {string} filePath - Target path from a tool payload
+ * @param {object} scope - Parsed scope (loadScope output)
+ * @param {string} scopeRoot - Directory containing .code-warden/
+ * @param {string} [baseDir] - Base for resolving relative paths
+ * @returns {{ code: 'self_protect'|'enforce_off'|'outside_root'|
+ * 'in_scope'|'out_of_scope', relPath: string }}
+ */
+function evaluateScope(filePath, scope, scopeRoot, baseDir) {
+ const abs = path.resolve(baseDir || scopeRoot, filePath);
+ const rel = path.relative(scopeRoot, abs);
+ const outside = !rel || rel.startsWith('..') || path.isAbsolute(rel);
+ const relNorm = rel.replace(/\\/g, '/');
+
+ if (!outside && (relNorm === SCOPE_DIR || relNorm.startsWith(SCOPE_DIR + '/'))) {
+ return { code: 'self_protect', relPath: relNorm };
+ }
+ if (scope.enforce === false) return { code: 'enforce_off', relPath: relNorm };
+ if (outside) return { code: 'outside_root', relPath: abs.replace(/\\/g, '/') };
+ if (matchesAnyPrefix(relNorm, scope.filesIn)) {
+ return { code: 'in_scope', relPath: relNorm };
+ }
+ return { code: 'out_of_scope', relPath: relNorm };
+}
+
+/**
+ * Deny message for an evaluateScope verdict, or null when the write is
+ * allowed. Messages omit the '[CodeWarden] ' prefix - each hook surface
+ * adds its own.
+ *
+ * @param {{ code: string, relPath: string }} verdict
+ * @param {object} scope
+ * @returns {string|null}
+ */
+function scopeDenialMessage(verdict, scope) {
+ if (verdict.code === 'self_protect') {
+ return 'Scope lock: the scope file is user-controlled. ' +
+ 'Ask the user to run: code-warden scope add ';
+ }
+ if (verdict.code === 'outside_root') {
+ return `Scope lock: ${verdict.relPath} is outside the governed repository ` +
+ 'while a scope is locked. Ask the user to make this change manually.';
+ }
+ if (verdict.code === 'out_of_scope') {
+ return `Scope lock: ${verdict.relPath} is outside the declared scope ` +
+ `(goal: ${scope.goal || 'not set'}). Ask the user to approve expansion ` +
+ `via: code-warden scope add ${verdict.relPath}`;
+ }
+ return null; // in_scope / enforce_off
+}
+
+/**
+ * True when the resolved target path contains a '.code-warden' path SEGMENT.
+ * Segment equality means this repo's 'code-warden' directory (no leading
+ * dot) never matches. Used for the UNCONDITIONAL write protection of
+ * governance artifacts (scope.json, audit.jsonl) - they are CLI/user
+ * managed, never agent-edited, even when no scope lock exists.
+ *
+ * @param {string} filePath - Target path from a tool payload
+ * @param {string} [baseDir] - Base for resolving relative paths
+ * @returns {boolean}
+ */
+function isGovernanceArtifactPath(filePath, baseDir) {
+ if (!filePath) return false;
+ const abs = path.resolve(baseDir || process.cwd(), filePath);
+ return abs.split(/[\\/]+/).some(segment => segment === SCOPE_DIR);
+}
+
+/**
+ * One-call check used by the write hooks. Two layers:
+ * 1. UNCONDITIONAL: any target under a .code-warden/ segment is denied,
+ * scope lock or not - agents must not hand-edit governance artifacts.
+ * 2. Opt-in scope lock: discovers the scope from startDir, evaluates
+ * filePath, and returns a deny message or null. Missing or unparseable
+ * scope files always allow.
+ *
+ * @param {string} filePath - Target path from a tool payload
+ * @param {string} startDir - Hook working directory (payload.cwd or cwd)
+ * @returns {string|null}
+ */
+function checkScopeDenial(filePath, startDir) {
+ if (!filePath) return null;
+ if (isGovernanceArtifactPath(filePath, startDir)) {
+ return 'Governance artifacts under .code-warden/ are user-managed ' +
+ '(scope.json, audit.jsonl). Use the code-warden CLI instead of ' +
+ 'editing these files.';
+ }
+ const found = findScopeFile(startDir);
+ if (!found) return null;
+ const scope = loadScope(found.scopePath);
+ if (!scope) return null;
+ const verdict = evaluateScope(filePath, scope, found.scopeRoot, startDir);
+ return scopeDenialMessage(verdict, scope);
+}
+
+/**
+ * Read-only summary for status displays and the governance report.
+ *
+ * @param {string} startDir
+ * @returns {{ scopePath, scopeRoot, goal, enforce, filesIn, expansions } | null}
+ */
+function getScopeSummary(startDir) {
+ const found = findScopeFile(startDir);
+ if (!found) return null;
+ const scope = loadScope(found.scopePath);
+ if (!scope) return null;
+ return {
+ scopePath: found.scopePath,
+ scopeRoot: found.scopeRoot,
+ goal: typeof scope.goal === 'string' ? scope.goal : '',
+ enforce: scope.enforce !== false,
+ filesIn: scope.filesIn,
+ expansions: Array.isArray(scope.expansions) ? scope.expansions : [],
+ };
+}
+
+module.exports = {
+ SCOPE_DIR, SCOPE_FILE,
+ scopePathFor, findScopeFile, loadScope, saveScope, createScope,
+ normalizeScopeEntry, evaluateScope, scopeDenialMessage,
+ isGovernanceArtifactPath, checkScopeDenial, getScopeSummary,
+};
diff --git a/code-warden/tools/lib/secret-patterns.js b/code-warden/tools/lib/secret-patterns.js
index 45eefa2..2ea652c 100644
--- a/code-warden/tools/lib/secret-patterns.js
+++ b/code-warden/tools/lib/secret-patterns.js
@@ -26,12 +26,21 @@
/** @type {SecretPattern[]} */
const SECRET_PATTERNS = [
{ label: 'AWS access key', re: /\bAKIA[0-9A-Z]{16}\b/ },
+ // Anthropic must precede OpenAI: keys share the sk- prefix but the OpenAI
+ // pattern's [A-Za-z0-9] run cannot cross the hyphen in sk-ant-.
+ { label: 'Anthropic API key', re: /\bsk-ant-[A-Za-z0-9_\-]{32,}\b/ },
{ label: 'OpenAI key', re: /\bsk-[A-Za-z0-9]{32,}\b/ },
+ { label: 'Google API key', re: /\bAIza[0-9A-Za-z_\-]{35}\b/ },
{ label: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/i },
+ { label: 'GitLab PAT', re: /\bglpat-[0-9A-Za-z_\-]{20,}\b/ },
+ { label: 'npm token', re: /\bnpm_[A-Za-z0-9]{36}\b/ },
+ { label: 'Hugging Face token', re: /\bhf_[A-Za-z0-9]{30,}\b/ },
{ label: 'Stripe secret key', re: /\bsk_(live|test)_[A-Za-z0-9]{24,}\b/ },
{ label: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9\-]{10,}\b/ },
{ label: 'SendGrid key', re: /\bSG\.[A-Za-z0-9_\-.]{20,}\b/ },
{ label: 'Twilio SID', re: /\bAC[a-f0-9]{32}\b/ },
+ // Three-segment form only (header.payload.signature) to limit false positives.
+ { label: 'JWT', re: /\beyJ[A-Za-z0-9_\-]{10,}\.eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b/ },
{ label: 'generic API key', re: /api[_-]?key\s*[:=]\s*['"]?[A-Za-z0-9_\-]{20,}/i },
{ label: 'generic secret key', re: /secret[_-]?key\s*[:=]\s*['"]?[A-Za-z0-9_\-]{20,}/i },
{ label: 'password assignment', re: /password\s*[:=]\s*['"][^'"]{8,}['"]/i },
@@ -58,6 +67,38 @@ function scanForSecrets(content) {
return null;
}
+/**
+ * Scan a content string and return every match across every pattern.
+ *
+ * Patterns are cloned with the global flag internally so the shared
+ * SECRET_PATTERNS regexes are never mutated. Exact duplicate findings
+ * (same label, line, and column) are deduped.
+ *
+ * @param {string} content
+ * @returns {{ label: string, line: number, column: number }[]} All matches,
+ * ordered by position in the content (pattern-list order breaks ties).
+ */
+function scanForAllSecrets(content) {
+ if (typeof content !== 'string' || content.length === 0) return [];
+ const hits = [];
+ const seen = new Set();
+ for (const { label, re } of SECRET_PATTERNS) {
+ const flags = re.flags.includes('g') ? re.flags : re.flags + 'g';
+ const global = new RegExp(re.source, flags);
+ let match;
+ while ((match = global.exec(content)) !== null) {
+ if (match[0].length === 0) { global.lastIndex++; continue; }
+ const { line, column } = locationForIndex(content, match.index);
+ const key = `${label}:${line}:${column}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ hits.push({ label, line, column, index: match.index });
+ }
+ }
+ hits.sort((a, b) => a.index - b.index); // stable: ties keep pattern order
+ return hits.map(({ label, line, column }) => ({ label, line, column }));
+}
+
function locationForIndex(content, index) {
const before = content.slice(0, index);
const line = before.split('\n').length;
@@ -66,4 +107,4 @@ function locationForIndex(content, index) {
return { line, column };
}
-module.exports = { SECRET_PATTERNS, scanForSecrets };
+module.exports = { SECRET_PATTERNS, scanForSecrets, scanForAllSecrets };
diff --git a/code-warden/tools/receipt.js b/code-warden/tools/receipt.js
index c0db39c..523dc0a 100644
--- a/code-warden/tools/receipt.js
+++ b/code-warden/tools/receipt.js
@@ -113,13 +113,29 @@ function validateReceipt(receipt) {
errors.push('validation.canProveCompliance must be true for complete receipts');
}
+ // Optional corroboration block (receipt --from-audit). Additive: absent on
+ // older receipts and never required. But a receipt claiming completion on
+ // top of a broken evidence chain is a contradiction - fail it loudly.
+ const audit = receipt.audit;
+ if (audit !== undefined) {
+ if (!isObject(audit)) {
+ errors.push('audit must be an object when present');
+ } else if (audit.chainValid === false) {
+ errors.push('audit.chainValid is false - a complete receipt cannot rest on a broken audit ledger chain' +
+ (audit.brokenAt ? ` (broken at line ${audit.brokenAt})` : ''));
+ }
+ }
+
return errors;
}
function parseArgs(argv) {
- const options = { template: false, out: null, validate: null };
+ // fromAudit: false = off, true = default ledger path, string = explicit path
+ const options = { template: false, out: null, validate: null, fromAudit: false };
for (const arg of argv) {
if (arg === '--template') options.template = true;
+ else if (arg === '--from-audit') options.fromAudit = true;
+ else if (arg.startsWith('--from-audit=')) options.fromAudit = arg.slice('--from-audit='.length);
else if (arg.startsWith('--out=')) options.out = arg.slice('--out='.length);
else if (arg.startsWith('--validate=')) options.validate = arg.slice('--validate='.length);
else throw new Error(`Unknown option: ${arg}`);
@@ -129,6 +145,7 @@ function parseArgs(argv) {
function usage() {
console.log('Usage: code-warden receipt --template --out=');
+ console.log(' code-warden receipt --from-audit[=] --out=');
console.log(' code-warden receipt --validate=');
}
@@ -144,7 +161,7 @@ function readJson(file) {
return JSON.parse(raw.replace(/^\uFEFF/, ''));
}
-function main(argv = process.argv.slice(2)) {
+function main(argv = process.argv.slice(2), cwd = process.cwd()) {
let options;
try {
options = parseArgs(argv);
@@ -164,6 +181,28 @@ function main(argv = process.argv.slice(2)) {
return 0;
}
+ if (options.fromAudit !== false) {
+ if (!options.out) {
+ console.error('Missing required --out= for --from-audit');
+ return 1;
+ }
+ const { buildAuditReceipt } = require('./lib/receipt-audit');
+ try {
+ const { receipt, summary } = buildAuditReceipt({
+ template: createTemplate(),
+ cwd,
+ ledgerPath: typeof options.fromAudit === 'string' ? options.fromAudit : null,
+ });
+ const resolved = writeJson(options.out, receipt);
+ console.log(`[CodeWarden] Receipt prefilled from audit ledger: ${summary}`);
+ console.log(`[CodeWarden] Draft written to ${resolved} - complete the remaining gate fields, then validate.`);
+ return 0;
+ } catch (error) {
+ console.error(`[CodeWarden] ${error.message}`);
+ return 1;
+ }
+ }
+
if (options.validate) {
let receipt;
try {
diff --git a/code-warden/tools/scope.js b/code-warden/tools/scope.js
new file mode 100644
index 0000000..07d901c
--- /dev/null
+++ b/code-warden/tools/scope.js
@@ -0,0 +1,219 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * scope.js
+ * CLI for the Scope Lock: manages /.code-warden/scope.json.
+ *
+ * code-warden scope set --goal="..." create/overwrite the scope
+ * code-warden scope add expand scope (audited)
+ * code-warden scope remove shrink scope
+ * code-warden scope clear delete the scope file
+ * code-warden scope status show goal/paths/expansions
+ *
+ * The scope file is user-controlled: write hooks deny agent edits to
+ * .code-warden/, so expansions only happen through this CLI - and `scope add`
+ * records every expansion in expansions[] as an audit trail.
+ */
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { spawnSync } = require('node:child_process');
+const store = require('./lib/scope-store');
+
+// ---------------------------------------------------------------------------
+// Repo root resolution
+// ---------------------------------------------------------------------------
+
+/** git rev-parse --show-toplevel, falling back to cwd outside a repo. */
+function findRepoRoot(cwd) {
+ const r = spawnSync('git', ['rev-parse', '--show-toplevel'],
+ { cwd, encoding: 'utf8', timeout: 5000 });
+ if (r.status === 0 && r.stdout && r.stdout.trim()) {
+ return path.resolve(r.stdout.trim());
+ }
+ return path.resolve(cwd);
+}
+
+// ---------------------------------------------------------------------------
+// CLI parsing + helpers
+// ---------------------------------------------------------------------------
+
+function parseArgs(argv) {
+ const [command, ...rest] = argv;
+ const options = { command, goal: '', enforce: true, paths: [] };
+ for (const arg of rest) {
+ if (arg.startsWith('--goal=')) options.goal = arg.slice('--goal='.length);
+ else if (arg === '--no-enforce') options.enforce = false;
+ else if (arg.startsWith('--')) throw new Error(`Unknown option: ${arg}`);
+ else options.paths.push(arg);
+ }
+ return options;
+}
+
+function usage() {
+ console.log('Usage: code-warden scope ');
+ console.log(' scope set --goal="..." Create/overwrite the scope lock');
+ console.log(' (--no-enforce records scope without blocking)');
+ console.log(' scope add Expand the scope (recorded in expansions[])');
+ console.log(' scope remove Remove paths from the scope');
+ console.log(' scope clear Delete the scope file');
+ console.log(' scope status Show the current scope lock');
+ console.log('Paths are repo-root-relative; directories use a trailing slash (src/).');
+}
+
+const log = msg => console.log(`[CodeWarden] ${msg}`);
+
+/** Normalize CLI paths against rootDir; logs and skips entries that escape. */
+function normalizeAll(paths, rootDir) {
+ const out = [];
+ for (const p of paths) {
+ const norm = store.normalizeScopeEntry(p, rootDir);
+ if (norm === null) log(`Skipped (outside the repository root): ${p}`);
+ else out.push(norm);
+ }
+ return out;
+}
+
+/** Load the current scope by walking up from cwd; null when none is set. */
+function currentScope(cwd) {
+ const found = store.findScopeFile(cwd);
+ if (!found) return null;
+ const scope = store.loadScope(found.scopePath);
+ return scope ? { ...found, scope } : null;
+}
+
+// ---------------------------------------------------------------------------
+// Commands
+// ---------------------------------------------------------------------------
+
+function cmdSet(options, cwd) {
+ const rootDir = findRepoRoot(cwd);
+ const filesIn = normalizeAll(options.paths, rootDir);
+ if (filesIn.length === 0) {
+ console.error('[CodeWarden] scope set requires at least one in-scope path.');
+ console.error('[CodeWarden] Example: code-warden scope set --goal="Fix auth bug" src/ lib/utils.js');
+ return 1;
+ }
+ const scope = store.createScope({ goal: options.goal, filesIn, enforce: options.enforce });
+ const dest = store.saveScope(rootDir, scope);
+ log(`Scope lock written to ${dest}`);
+ return printStatus({ scopePath: dest, scopeRoot: rootDir, scope });
+}
+
+function cmdAdd(options, cwd) {
+ const current = currentScope(cwd);
+ if (!current) {
+ console.error('[CodeWarden] No scope is set. Run: code-warden scope set --goal="..." ');
+ return 1;
+ }
+ const additions = normalizeAll(options.paths, current.scopeRoot);
+ if (additions.length === 0) {
+ console.error('[CodeWarden] scope add requires at least one path.');
+ return 1;
+ }
+ const { scope } = current;
+ scope.expansions = Array.isArray(scope.expansions) ? scope.expansions : [];
+ for (const p of additions) {
+ if (!scope.filesIn.includes(p)) scope.filesIn.push(p);
+ scope.expansions.push({ path: p, addedAt: new Date().toISOString() });
+ log(`Added to scope: ${p}`);
+ }
+ store.saveScope(current.scopeRoot, scope);
+ return 0;
+}
+
+function cmdRemove(options, cwd) {
+ const current = currentScope(cwd);
+ if (!current) {
+ console.error('[CodeWarden] No scope is set - nothing to remove.');
+ return 1;
+ }
+ const removals = normalizeAll(options.paths, current.scopeRoot);
+ if (removals.length === 0) {
+ console.error('[CodeWarden] scope remove requires at least one path.');
+ return 1;
+ }
+ const { scope } = current;
+ for (const p of removals) {
+ const idx = scope.filesIn.indexOf(p);
+ if (idx === -1) log(`Not in scope (no change): ${p}`);
+ else { scope.filesIn.splice(idx, 1); log(`Removed from scope: ${p}`); }
+ }
+ store.saveScope(current.scopeRoot, scope);
+ return 0;
+}
+
+function cmdClear(cwd) {
+ const found = store.findScopeFile(cwd);
+ if (!found) {
+ log('No scope file found - nothing to clear.');
+ return 0;
+ }
+ fs.rmSync(found.scopePath, { force: true });
+ try { fs.rmdirSync(path.dirname(found.scopePath)); } catch { /* dir not empty - keep it */ }
+ log(`Scope lock cleared (${found.scopePath}).`);
+ return 0;
+}
+
+function printStatus({ scopePath, scopeRoot, scope }) {
+ const enforce = scope.enforce !== false;
+ log(`Scope lock: ${enforce ? 'ACTIVE' : 'recorded only (enforce: false)'}`);
+ log(` File: ${scopePath}`);
+ log(` Root: ${scopeRoot}`);
+ log(` Goal: ${scope.goal || '(not set)'}`);
+ log(` Files in scope (${scope.filesIn.length}):`);
+ for (const p of scope.filesIn) log(` - ${p}`);
+ const expansions = Array.isArray(scope.expansions) ? scope.expansions : [];
+ if (expansions.length > 0) {
+ log(` Expansions (${expansions.length}):`);
+ for (const e of expansions) log(` - ${e.path} (added ${e.addedAt})`);
+ }
+ if (enforce) {
+ log(' Enforced on Write/Edit/NotebookEdit (Claude) and apply_patch (Codex) while hooks are installed.');
+ }
+ return 0;
+}
+
+function cmdStatus(cwd) {
+ const current = currentScope(cwd);
+ if (!current) {
+ log('No scope lock is set for this repository.');
+ log('Set one with: code-warden scope set --goal="..." ');
+ return 0;
+ }
+ return printStatus(current);
+}
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+
+function main(argv = process.argv.slice(2), cwd = process.cwd()) {
+ let options;
+ try {
+ options = parseArgs(argv);
+ } catch (error) {
+ console.error(`[CodeWarden] ${error.message}`);
+ usage();
+ return 1;
+ }
+
+ switch (options.command) {
+ case 'set': return cmdSet(options, cwd);
+ case 'add': return cmdAdd(options, cwd);
+ case 'remove': return cmdRemove(options, cwd);
+ case 'clear': return cmdClear(cwd);
+ case 'status': return cmdStatus(cwd);
+ default:
+ if (options.command) console.error(`[CodeWarden] Unknown scope command: ${options.command}`);
+ usage();
+ return 1;
+ }
+}
+
+if (require.main === module) {
+ process.exit(main());
+}
+
+module.exports = { main, findRepoRoot };
diff --git a/code-warden/tools/tests/audit-ledger-tests.js b/code-warden/tools/tests/audit-ledger-tests.js
new file mode 100644
index 0000000..0fb1be9
--- /dev/null
+++ b/code-warden/tools/tests/audit-ledger-tests.js
@@ -0,0 +1,198 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * audit-ledger-tests.js
+ * Behavioral tests for lib/audit-ledger.js and warden-audit-hook.js:
+ * append + chain verification, tamper detection, secret redaction,
+ * truncation, enablement (scope-gated / audit.enabled / no root), and the
+ * ok heuristic. All fixtures live in temp dirs under os.tmpdir() with a
+ * .git boundary; no .code-warden/ directory is ever created in this repo.
+ */
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { spawnSync } = require('node:child_process');
+const path = require('node:path');
+const fs = require('node:fs');
+const os = require('node:os');
+
+const ledgerLib = require('../lib/audit-ledger');
+const store = require('../lib/scope-store');
+
+const AUDIT_HOOK = path.join(__dirname, '..', 'hooks', 'claude', 'warden-audit-hook.js');
+
+// ---------------------------------------------------------------------------
+// Fixtures - fake secrets built from parts, never contiguous literals
+// ---------------------------------------------------------------------------
+
+const makeFakeSecret = () => ['sk', '-', 'a'.repeat(48)].join('');
+
+function makeRepo({ scope, config } = {}) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `cw-audit-${process.pid}-`));
+ fs.mkdirSync(path.join(root, '.git'), { recursive: true });
+ if (config) {
+ fs.writeFileSync(path.join(root, 'codewarden.json'), JSON.stringify(config, null, 2) + '\n');
+ }
+ if (scope) store.saveScope(root, { ...store.createScope(), ...scope });
+ return root;
+}
+
+const cleanup = (root) => fs.rmSync(root, { recursive: true, force: true });
+const ledgerPathFor = (root) => path.join(root, '.code-warden', 'audit.jsonl');
+
+function payloadFor(root, tool, tool_input, tool_response) {
+ return { session_id: 'sess-1', cwd: root, hook_event_name: 'PostToolUse',
+ tool_name: tool, tool_input, tool_response };
+}
+
+// ---------------------------------------------------------------------------
+// Append + chain verify
+// ---------------------------------------------------------------------------
+
+test('audit: appends chained entries and verifyLedger accepts them', () => {
+ const root = makeRepo({ scope: { goal: 'Audit', filesIn: ['src/'] } });
+ try {
+ const e1 = ledgerLib.recordPostToolUse(
+ payloadFor(root, 'Write', { file_path: path.join(root, 'src', 'a.js') }, {}));
+ const e2 = ledgerLib.recordPostToolUse(
+ payloadFor(root, 'Bash', { command: 'npm test' }, { exitCode: 0 }));
+
+ assert.equal(e1.prev, 'GENESIS', 'first entry chains to GENESIS');
+ assert.equal(e2.prev, e1.hash, 'second entry chains to the first');
+ assert.equal(e1.event, 'PostToolUse');
+ assert.equal(e1.target, 'src/a.js', 'file targets are root-relative, forward-slashed');
+ assert.equal(e1.session_id, 'sess-1');
+
+ const v = ledgerLib.verifyLedger(ledgerPathFor(root));
+ assert.deepEqual(v, { valid: true, entries: 2, brokenAt: null });
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('audit: tampering with any line is detected at that line', () => {
+ const root = makeRepo({ scope: { goal: 'Audit', filesIn: ['src/'] } });
+ try {
+ for (const f of ['a.js', 'b.js', 'c.js']) {
+ ledgerLib.recordPostToolUse(payloadFor(root, 'Write', { file_path: path.join(root, 'src', f) }, {}));
+ }
+ const lp = ledgerPathFor(root);
+ const lines = fs.readFileSync(lp, 'utf8').trim().split('\n');
+ const doctored = JSON.parse(lines[1]);
+ doctored.target = 'src/evil.js'; // rewrite history
+ lines[1] = JSON.stringify(doctored);
+ fs.writeFileSync(lp, lines.join('\n') + '\n');
+
+ const v = ledgerLib.verifyLedger(lp);
+ assert.equal(v.valid, false);
+ assert.equal(v.brokenAt, 2, '1-based line number of the doctored entry');
+ assert.equal(v.entries, 3);
+
+ // Deleting a line breaks the chain at the splice point too
+ fs.writeFileSync(lp, [lines[0], lines[2]].join('\n') + '\n');
+ const v2 = ledgerLib.verifyLedger(lp);
+ assert.equal(v2.valid, false);
+ assert.equal(v2.brokenAt, 2);
+ } finally {
+ cleanup(root);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Redaction + truncation
+// ---------------------------------------------------------------------------
+
+test('audit: command targets are secret-redacted before logging', () => {
+ const root = makeRepo({ scope: { goal: 'Audit', filesIn: ['src/'] } });
+ try {
+ const fake = makeFakeSecret();
+ const entry = ledgerLib.recordPostToolUse(
+ payloadFor(root, 'Bash', { command: `curl -H "X-Key: ${fake}" https://api.example.com` }, {}));
+ assert.ok(!entry.target.includes(fake), 'raw token must never reach the ledger');
+ assert.match(entry.target, /\[REDACTED:OpenAI key\]/);
+ assert.ok(!fs.readFileSync(ledgerPathFor(root), 'utf8').includes(fake));
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('audit: command targets are truncated to 300 chars (after redaction)', () => {
+ const root = makeRepo({ scope: { goal: 'Audit', filesIn: ['src/'] } });
+ try {
+ const fake = makeFakeSecret();
+ const long = `echo start ${'x'.repeat(400)} ${fake} end`;
+ const entry = ledgerLib.recordPostToolUse(payloadFor(root, 'PowerShell', { command: long }, {}));
+ assert.ok(entry.target.length <= ledgerLib.COMMAND_TARGET_MAX);
+ assert.ok(!entry.target.includes(fake), 'redaction runs on the FULL command before truncation');
+ } finally {
+ cleanup(root);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Enablement
+// ---------------------------------------------------------------------------
+
+test('audit: disabled without scope or audit.enabled; explicit false always wins', () => {
+ const noScope = makeRepo({ config: {} });
+ const optIn = makeRepo({ config: { audit: { enabled: true } } });
+ const forced = makeRepo({ config: { audit: { enabled: false } },
+ scope: { goal: 'X', filesIn: ['src/'] } });
+ const bare = makeRepo(); // .git only: no config, no scope -> no root
+ try {
+ assert.equal(ledgerLib.resolveLedger(noScope), null, 'config without scope stays off');
+ assert.equal(ledgerLib.recordPostToolUse(payloadFor(noScope, 'Bash', { command: 'ls' }, {})), null);
+ assert.equal(fs.existsSync(path.join(noScope, '.code-warden')), false, 'no dir side effects');
+
+ assert.ok(ledgerLib.resolveLedger(optIn), 'audit.enabled true opts in without a scope');
+ assert.equal(ledgerLib.resolveLedger(forced), null, 'enabled:false beats an active scope');
+ assert.equal(ledgerLib.resolveLedger(bare), null, 'no discoverable project root means off');
+ } finally {
+ [noScope, optIn, forced, bare].forEach(cleanup);
+ }
+});
+
+test('audit: unaudited tools are ignored even when enabled', () => {
+ const root = makeRepo({ config: { audit: { enabled: true } } });
+ try {
+ assert.equal(ledgerLib.recordPostToolUse(payloadFor(root, 'Read', { file_path: 'x' }, {})), null);
+ assert.equal(fs.existsSync(ledgerPathFor(root)), false);
+ } finally {
+ cleanup(root);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// ok heuristic + hook end-to-end
+// ---------------------------------------------------------------------------
+
+test('audit: ok heuristic - absence of error signals means success', () => {
+ const ok = ledgerLib.okFromResponse;
+ assert.equal(ok(undefined), true);
+ assert.equal(ok({}), true);
+ assert.equal(ok({ stdout: 'done', stderr: 'warning: x' }), true, 'stderr alone is not failure');
+ assert.equal(ok({ exitCode: 0 }), true);
+ assert.equal(ok({ exitCode: 1 }), false);
+ assert.equal(ok({ exit_code: 2 }), false);
+ assert.equal(ok({ success: false }), false);
+ assert.equal(ok({ is_error: true }), false);
+ assert.equal(ok({ interrupted: true }), false);
+ assert.equal(ok({ error: 'boom' }), false);
+});
+
+test('audit hook: exits 0 silently and appends through stdin interface', () => {
+ const root = makeRepo({ scope: { goal: 'Hook', filesIn: ['src/'] } });
+ try {
+ const r = spawnSync(process.execPath, [AUDIT_HOOK], {
+ input: JSON.stringify(payloadFor(root, 'Edit', { file_path: path.join(root, 'src', 'a.js') }, { success: true })),
+ encoding: 'utf8',
+ });
+ assert.equal(r.status, 0, 'PostToolUse is advisory - always exit 0');
+ assert.equal(r.stdout, '', 'no stdout output');
+ const v = ledgerLib.verifyLedger(ledgerPathFor(root));
+ assert.deepEqual(v, { valid: true, entries: 1, brokenAt: null });
+ } finally {
+ cleanup(root);
+ }
+});
diff --git a/code-warden/tools/tests/baseline-tests.js b/code-warden/tools/tests/baseline-tests.js
new file mode 100644
index 0000000..110dcc1
--- /dev/null
+++ b/code-warden/tools/tests/baseline-tests.js
@@ -0,0 +1,213 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * baseline-tests.js
+ * Behavioral tests for baseline / ratchet mode (lib/baseline.js and the
+ * governance-report --write-baseline / --baseline flags).
+ *
+ * Unit tests cover create/apply partitioning; the CLI test exercises the
+ * full flag path end-to-end against a temp project: legacy unchanged passes,
+ * grown files fail, new secrets fail, contextHash survives line moves, and
+ * legacy findings never reach SARIF. Spawned reports set
+ * CODE_WARDEN_SKIP_BEHAVIORAL_TESTS so the suite never recurses into itself.
+ */
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { spawnSync } = require('node:child_process');
+const path = require('node:path');
+const fs = require('node:fs');
+const os = require('node:os');
+
+const { createBaseline, loadBaseline, applyBaseline, applyBaselineToChecks,
+ hashLine, BASELINE_KIND, SCHEMA_VERSION } = require('../lib/baseline');
+
+const REPORT_SCRIPT = path.join(__dirname, '..', 'governance-report.js');
+
+// ---------------------------------------------------------------------------
+// Fixture helpers — fake secrets built from parts, never contiguous literals
+// ---------------------------------------------------------------------------
+
+const makeFakeSecret = (c = 'b') => ['sk', '-', c.repeat(48)].join('');
+const makeLines = n => Array.from({ length: n }, (_, i) => `const l${i + 1} = ${i + 1};`).join('\n') + '\n';
+const cleanup = dir => fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
+
+function scansFixture() {
+ const secretLine = `const key = '${makeFakeSecret()}';`;
+ return {
+ secretLine,
+ scans: {
+ fileLength: { status: 'fail', filesScanned: 3, violations: 1,
+ details: [{ file: 'src\\big.js', lines: 500, limit: 400 }] },
+ secrets: { status: 'fail', filesScanned: 3, violations: 1,
+ details: [{ file: 'src/creds.js', pattern: 'OpenAI key', line: 7, column: 14,
+ contextHash: hashLine(secretLine) }] },
+ },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// createBaseline / hashLine
+// ---------------------------------------------------------------------------
+
+test('createBaseline: schema, slash paths, and hash-only secrets', () => {
+ const { scans, secretLine } = scansFixture();
+ const b = createBaseline(scans);
+ assert.equal(b.schemaVersion, SCHEMA_VERSION);
+ assert.equal(b.kind, BASELINE_KIND);
+ assert.ok(b.generatedAt);
+ assert.deepEqual(b.fileLength, [{ file: 'src/big.js', lines: 500 }], 'backslashes normalised');
+ assert.equal(b.secrets.length, 1);
+ assert.deepEqual(Object.keys(b.secrets[0]).sort(), ['contextHash', 'file', 'label']);
+ assert.ok(!JSON.stringify(b).includes(makeFakeSecret()), 'raw secret text must never enter the baseline');
+ assert.equal(b.secrets[0].contextHash, hashLine(secretLine));
+});
+
+test('hashLine: trims whitespace so indentation and CR do not matter', () => {
+ assert.equal(hashLine(' const x = 1; '), hashLine('const x = 1;'));
+ assert.equal(hashLine('const x = 1;\r'), hashLine('const x = 1;'));
+ assert.match(hashLine('x'), /^[0-9a-f]{64}$/);
+ assert.notEqual(hashLine('a'), hashLine('b'));
+});
+
+// ---------------------------------------------------------------------------
+// applyBaseline partitioning
+// ---------------------------------------------------------------------------
+
+test('applyBaseline: unchanged legacy, grown file, moved secret, new secret', () => {
+ const { scans, secretLine } = scansFixture();
+ const baseline = createBaseline(scans);
+
+ // Unchanged: same lines, secret moved to a different line (hash matches)
+ const unchanged = {
+ fileLength: { details: [{ file: 'src/big.js', lines: 500, limit: 400 }] },
+ secrets: { details: [{ file: 'src/creds.js', pattern: 'OpenAI key', line: 42, column: 14,
+ contextHash: hashLine(` ${secretLine} `) }] },
+ };
+ const p1 = applyBaseline(unchanged, baseline);
+ assert.equal(p1.fileLength.fresh.length, 0);
+ assert.equal(p1.fileLength.legacy.length, 1);
+ assert.equal(p1.secrets.fresh.length, 0, 'contextHash must survive line moves and re-indentation');
+ assert.equal(p1.secrets.legacy.length, 1);
+
+ // Ratchet: shrinking stays legacy, growing becomes fresh
+ const shrunk = applyBaseline({ fileLength: { details: [{ file: 'src/big.js', lines: 450, limit: 400 }] },
+ secrets: { details: [] } }, baseline);
+ assert.equal(shrunk.fileLength.legacy.length, 1, 'shrinking below the floor stays legacy');
+ const grown = applyBaseline({ fileLength: { details: [{ file: 'src/big.js', lines: 501, limit: 400 }] },
+ secrets: { details: [] } }, baseline);
+ assert.equal(grown.fileLength.fresh.length, 1, 'growth past the baselined count is fresh');
+
+ // New secret in a baselined file is still fresh (different content hash)
+ const newSecret = applyBaseline({ fileLength: { details: [] },
+ secrets: { details: [{ file: 'src/creds.js', pattern: 'OpenAI key', line: 7, column: 1,
+ contextHash: hashLine('const other = "changed";') }] } }, baseline);
+ assert.equal(newSecret.secrets.fresh.length, 1);
+});
+
+test('applyBaselineToChecks: fresh-only details, legacyDetails preserved', () => {
+ const { scans } = scansFixture();
+ const baseline = createBaseline(scans);
+ const applied = applyBaselineToChecks(scans, baseline);
+ assert.equal(applied.fileLength.status, 'pass');
+ assert.equal(applied.secrets.status, 'pass');
+ assert.equal(applied.fileLength.violations, 0);
+ assert.equal(applied.fileLength.legacyViolations, 1);
+ assert.equal(applied.fileLength.details, undefined, 'SARIF consumes details: legacy must not appear');
+ assert.equal(applied.fileLength.legacyDetails.length, 1);
+ assert.deepEqual(applied.legacy, { fileLength: 1, secrets: 1 });
+});
+
+test('loadBaseline: missing or foreign files throw clear errors', () => {
+ assert.throws(() => loadBaseline(path.join(os.tmpdir(), 'cw-does-not-exist.json')), /baseline file not found/);
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `cw-blv-${process.pid}-`));
+ try {
+ const bogus = path.join(dir, 'bogus.json');
+ fs.writeFileSync(bogus, JSON.stringify({ kind: 'something-else' }), 'utf8');
+ assert.throws(() => loadBaseline(bogus), /not a code-warden baseline/);
+ } finally {
+ cleanup(dir);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// CLI: --write-baseline / --baseline end-to-end against a temp project
+// ---------------------------------------------------------------------------
+
+function runReport(proj, args) {
+ return spawnSync(process.execPath, [REPORT_SCRIPT, ...args], {
+ cwd: proj,
+ encoding: 'utf8',
+ env: { ...process.env, CODE_WARDEN_SKIP_BEHAVIORAL_TESTS: '1' },
+ });
+}
+
+test('CLI: --baseline with a missing file is a hard error', () => {
+ const proj = fs.mkdtempSync(path.join(os.tmpdir(), `cw-bcli-${process.pid}-`));
+ try {
+ fs.writeFileSync(path.join(proj, 'ok.js'), "'use strict';\n", 'utf8');
+ const r = runReport(proj, ['.', '--baseline=missing-baseline.json']);
+ assert.equal(r.status, 1, 'missing baseline must not silently pass');
+ assert.match(r.stderr, /baseline file not found/);
+ } finally {
+ cleanup(proj);
+ }
+});
+
+test('CLI: write-baseline then ratchet end-to-end', () => {
+ const proj = fs.mkdtempSync(path.join(os.tmpdir(), `cw-bend-${process.pid}-`));
+ try {
+ fs.mkdirSync(path.join(proj, 'src'));
+ fs.writeFileSync(path.join(proj, 'codewarden.json'),
+ JSON.stringify({ thresholds: { max_file_length: 10 } }) + '\n', 'utf8');
+ fs.writeFileSync(path.join(proj, 'src', 'long.js'), makeLines(20), 'utf8');
+ fs.writeFileSync(path.join(proj, 'src', 'creds.js'), `const key = '${makeFakeSecret('d')}';\n`, 'utf8');
+ const reportArgs = ['src', '--config=codewarden.json'];
+
+ // Write the baseline: exits 0 despite live violations, never stores raw text
+ const w = runReport(proj, [...reportArgs, '--write-baseline']);
+ assert.equal(w.status, 0, w.stdout + w.stderr);
+ assert.match(w.stdout, /Baseline written to/);
+ const baselineFile = path.join(proj, '.code-warden-baseline.json');
+ const raw = fs.readFileSync(baselineFile, 'utf8');
+ assert.ok(!raw.includes(makeFakeSecret('d')), 'baseline must contain hashes, not secrets');
+ assert.equal(JSON.parse(raw).fileLength[0].lines, 20);
+
+ // Unchanged project: legacy-only, gate passes, summary shows new/legacy
+ const pass = runReport(proj, [...reportArgs, '--baseline']);
+ assert.equal(pass.status, 0, pass.stdout + pass.stderr);
+ assert.match(pass.stdout, /lint 0 new \/ 1 legacy/);
+ assert.match(pass.stdout, /secrets 0 new \/ 1 legacy/);
+
+ // Line move: contextHash keeps the secret legacy
+ fs.writeFileSync(path.join(proj, 'src', 'creds.js'),
+ `// moved\n// down\n const key = '${makeFakeSecret('d')}';\n`, 'utf8');
+ const moved = runReport(proj, [...reportArgs, '--baseline']);
+ assert.equal(moved.status, 0, 'moved/re-indented secret line must stay legacy');
+
+ // Legacy-only findings never reach SARIF
+ const sarif = runReport(proj, [...reportArgs, '--baseline', '--format=sarif']);
+ assert.equal(sarif.status, 0);
+ assert.equal(JSON.parse(sarif.stdout).runs[0].results.length, 0);
+
+ // Ratchet: a grown baselined file fails again
+ fs.writeFileSync(path.join(proj, 'src', 'long.js'), makeLines(25), 'utf8');
+ const grown = runReport(proj, [...reportArgs, '--baseline']);
+ assert.equal(grown.status, 1, 'grown file must be a fresh violation');
+ assert.match(grown.stdout, /lint 1 new \/ 0 legacy/);
+ fs.writeFileSync(path.join(proj, 'src', 'long.js'), makeLines(20), 'utf8'); // restore
+
+ // New secret fails and is the only SARIF result
+ fs.writeFileSync(path.join(proj, 'src', 'fresh.js'), `const t = '${makeFakeSecret('e')}';\n`, 'utf8');
+ const fresh = runReport(proj, [...reportArgs, '--baseline']);
+ assert.equal(fresh.status, 1, 'new secret must fail the gate');
+ assert.match(fresh.stdout, /secrets 1 new \/ 1 legacy/);
+ const sarif2 = runReport(proj, [...reportArgs, '--baseline', '--format=sarif']);
+ const results = JSON.parse(sarif2.stdout).runs[0].results;
+ assert.equal(results.length, 1, 'only the FRESH violation reaches SARIF');
+ assert.match(results[0].message.text, /fresh\.js/);
+ } finally {
+ cleanup(proj);
+ }
+});
diff --git a/code-warden/tools/tests/command-risk-tests.js b/code-warden/tools/tests/command-risk-tests.js
new file mode 100644
index 0000000..8aa3ea2
--- /dev/null
+++ b/code-warden/tools/tests/command-risk-tests.js
@@ -0,0 +1,241 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * command-risk-tests.js
+ * Behavioral tests for the Command Risk Gate:
+ * - lib/command-risk.js classification table (every default rule gets a
+ * positive and a near-miss negative)
+ * - user override merge semantics (replace by id, tier off, invalid regex)
+ * - hook integration through the real stdin interfaces:
+ * warden-command-hook.js (Claude: deny/ask/allow) and
+ * warden-bash-hook.js (Codex: deny only - high allows silently)
+ */
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { spawnSync } = require('node:child_process');
+const path = require('node:path');
+const fs = require('node:fs');
+const os = require('node:os');
+
+const { DEFAULT_COMMAND_RULES, classifyCommand, mergeCommandRules } =
+ require('../lib/command-risk');
+
+const CLAUDE_HOOK = path.join(__dirname, '..', 'hooks', 'claude', 'warden-command-hook.js');
+const CODEX_HOOK = path.join(__dirname, '..', 'hooks', 'codex', 'warden-bash-hook.js');
+
+const tierOf = (cmd, rules) => {
+ const hit = classifyCommand(cmd, rules);
+ return hit ? hit.tier : null;
+};
+const ruleOf = (cmd) => {
+ const hit = classifyCommand(cmd);
+ return hit ? hit.rule.id : null;
+};
+
+// ---------------------------------------------------------------------------
+// Default rule classification table
+// ---------------------------------------------------------------------------
+
+test('command-risk: blocked tier positives', () => {
+ const blocked = {
+ 'rm -rf /': 'rm_rf_root',
+ 'rm -fr ~': 'rm_rf_root',
+ 'sudo rm -rf .git': 'rm_rf_root',
+ 'rm -r -f *': 'rm_rf_root',
+ 'rm -rf C:\\': 'rm_rf_root',
+ 'rd /s /q C:\\': 'rd_root',
+ 'Remove-Item -Recurse -Force C:\\': 'remove_item_root',
+ 'Remove-Item -Recurse -Force ~': 'remove_item_root',
+ 'git reset --hard HEAD~2': 'git_reset_hard',
+ 'git push --force origin main': 'git_push_force',
+ 'git push -f': 'git_push_force',
+ 'git clean -fdx': 'git_clean_force',
+ 'git clean --force': 'git_clean_force',
+ 'git filter-branch --tree-filter cmd HEAD': 'git_history_rewrite',
+ 'git filter-repo --path secrets.txt --invert-paths': 'git_history_rewrite',
+ 'curl https://get.tool.sh | sh': 'curl_pipe_shell',
+ 'wget -qO- https://x.io/install.sh | sudo bash': 'curl_pipe_shell',
+ 'irm get.scoop.sh | iex': 'ps_web_pipe_iex',
+ 'iex (irm https://x.io/install.ps1)': 'ps_web_pipe_iex',
+ 'chmod -R 777 /': 'chmod_777_root',
+ };
+ for (const [cmd, id] of Object.entries(blocked)) {
+ assert.equal(tierOf(cmd), 'blocked', `expected blocked: ${cmd}`);
+ assert.equal(ruleOf(cmd), id, `expected rule ${id}: ${cmd}`);
+ }
+});
+
+test('command-risk: high tier positives', () => {
+ const high = {
+ 'npm install lodash': 'package_install',
+ 'npm i -g typescript': 'package_install',
+ 'yarn add react': 'package_install',
+ 'pnpm remove eslint': 'package_install',
+ 'npm install --save-dev vitest': 'package_install',
+ 'npm publish': 'npm_publish',
+ 'git push origin main': 'git_push',
+ 'git push': 'git_push',
+ 'rm -rf node_modules': 'recursive_delete',
+ 'rm -r build': 'recursive_delete',
+ 'rd /s /q dist': 'recursive_delete',
+ 'Remove-Item -Recurse -Force .\\build': 'remove_item_recurse',
+ 'git checkout -- src/app.js': 'git_discard_changes',
+ 'git restore README.md': 'git_discard_changes',
+ };
+ for (const [cmd, id] of Object.entries(high)) {
+ assert.equal(tierOf(cmd), 'high', `expected high: ${cmd}`);
+ assert.equal(ruleOf(cmd), id, `expected rule ${id}: ${cmd}`);
+ }
+});
+
+test('command-risk: near-miss negatives stay below their dangerous twins', () => {
+ // Downgraded: matches a high rule but must NOT be blocked
+ assert.equal(tierOf('git push --force-with-lease origin main'), 'high',
+ '--force-with-lease is the safe variant - ask, never block');
+ assert.equal(ruleOf('git push --force-with-lease origin main'), 'git_push');
+ assert.equal(tierOf('rm -rf node_modules'), 'high', 'ordinary recursive delete asks');
+ assert.equal(tierOf('rm -rf /tmp/build-cache'), 'high', 'subpath of / is not a root');
+
+ // Fully allowed
+ const allowed = [
+ 'git status && npm test',
+ 'curl https://example.com/data.json -o data.json',
+ 'curl -s https://api.github.com | jq .',
+ 'Invoke-WebRequest -Uri https://x.io/f.zip -OutFile f.zip',
+ 'git reset HEAD~1',
+ 'git reset --soft HEAD~1',
+ 'git clean -n',
+ 'git checkout feature-branch',
+ 'git checkout -b new-feature',
+ 'git restore --staged README.md',
+ 'npm install',
+ 'npm ci',
+ 'npm run update-snapshots',
+ 'rm notes.txt',
+ 'chmod 755 deploy.sh',
+ 'mkdir -p src/lib && echo done',
+ ];
+ for (const cmd of allowed) {
+ assert.equal(tierOf(cmd), null, `expected allow: ${cmd}`);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Merge semantics
+// ---------------------------------------------------------------------------
+
+test('command-risk: tier off/allow disables a default rule by id', () => {
+ const { rules, warnings } = mergeCommandRules([{ id: 'git_push', tier: 'off' }]);
+ assert.equal(warnings.length, 0);
+ assert.equal(rules.length, DEFAULT_COMMAND_RULES.length - 1);
+ assert.equal(tierOf('git push origin main', rules), null, 'plain push now allowed');
+ assert.equal(tierOf('git push --force', rules), 'blocked', 'force push rule unaffected');
+
+ const viaAllow = mergeCommandRules([{ id: 'git_push', tier: 'allow' }]).rules;
+ assert.equal(tierOf('git push origin main', viaAllow), null);
+});
+
+test('command-risk: user rule reusing a default id replaces it', () => {
+ const { rules } = mergeCommandRules([
+ { id: 'npm_publish', pattern: 'docker\\s+push', tier: 'blocked', message: 'No publishing.' },
+ ]);
+ assert.equal(rules.length, DEFAULT_COMMAND_RULES.length, 'replace, not append');
+ assert.equal(tierOf('docker push registry/img', rules), 'blocked');
+ assert.equal(tierOf('npm publish', rules), null, 'old pattern is gone');
+
+ // Tier-only override keeps the default pattern
+ const escalated = mergeCommandRules([{ id: 'git_push', tier: 'blocked' }]).rules;
+ assert.equal(tierOf('git push origin main', escalated), 'blocked');
+});
+
+test('command-risk: invalid user regex is skipped with a warning', () => {
+ const { rules, warnings } = mergeCommandRules([
+ { id: 'broken', pattern: '[unclosed', tier: 'blocked' },
+ ]);
+ assert.equal(warnings.length, 1);
+ assert.match(warnings[0], /broken.*invalid pattern/);
+ assert.equal(rules.length, DEFAULT_COMMAND_RULES.length, 'defaults untouched');
+});
+
+test('command-risk: new user rules and malformed entries', () => {
+ const { rules, warnings } = mergeCommandRules([
+ { id: 'no_docker_rm', pattern: 'docker\\s+rm\\b', tier: 'high' },
+ { id: 'missing_pattern', tier: 'high' },
+ { tier: 'blocked', pattern: 'x' },
+ ]);
+ assert.equal(tierOf('docker rm my-container', rules), 'high');
+ assert.equal(warnings.length, 2, 'no-pattern new rule and id-less entry both warn');
+});
+
+// ---------------------------------------------------------------------------
+// Hook integration (stdin)
+// ---------------------------------------------------------------------------
+
+function runHook(scriptPath, payload) {
+ const result = spawnSync(process.execPath, [scriptPath], {
+ input: JSON.stringify(payload),
+ encoding: 'utf8',
+ });
+ return { code: result.status ?? result.signal, stdout: result.stdout || '' };
+}
+
+const claudePayload = (command, cwd) =>
+ ({ tool_name: 'Bash', tool_input: { command }, ...(cwd ? { cwd } : {}) });
+
+test('claude command hook: blocked tier denies with rule id and override hint', () => {
+ const { code, stdout } = runHook(CLAUDE_HOOK, claudePayload('git push --force origin main'));
+ assert.equal(code, 2);
+ const out = JSON.parse(stdout).hookSpecificOutput;
+ assert.equal(out.permissionDecision, 'deny');
+ assert.match(out.permissionDecisionReason, /\[rule: git_push_force\]/);
+ assert.match(out.permissionDecisionReason, /Override: adjust risk_policy\.command_rules/);
+});
+
+test('claude command hook: high tier asks; clean command allows silently', () => {
+ const asked = runHook(CLAUDE_HOOK, claudePayload('npm install lodash'));
+ assert.equal(asked.code, 0, 'ask responses exit 0');
+ const out = JSON.parse(asked.stdout).hookSpecificOutput;
+ assert.equal(out.permissionDecision, 'ask');
+ assert.match(out.permissionDecisionReason, /\[rule: package_install\]/);
+
+ const clean = runHook(CLAUDE_HOOK, claudePayload('git status'));
+ assert.equal(clean.code, 0);
+ assert.equal(clean.stdout, '');
+});
+
+test('claude command hook: project command_rules override is honored via cwd', () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `cw-risk-${process.pid}-`));
+ try {
+ fs.mkdirSync(path.join(root, '.git'), { recursive: true });
+ fs.writeFileSync(path.join(root, 'codewarden.json'), JSON.stringify({
+ risk_policy: { command_rules: [{ id: 'git_push', tier: 'off' }] },
+ }, null, 2) + '\n');
+
+ const { code, stdout } = runHook(CLAUDE_HOOK, claudePayload('git push origin main', root));
+ assert.equal(code, 0, 'git_push disabled by the project config');
+ assert.equal(stdout, '');
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test('codex bash hook: blocked denies; high allows with NO output (no ask)', () => {
+ const denied = spawnSync(process.execPath, [CODEX_HOOK], {
+ input: JSON.stringify({ tool: 'Bash', toolInput: { command: 'git reset --hard HEAD~3' } }),
+ encoding: 'utf8',
+ });
+ assert.equal(denied.status, 2);
+ const body = JSON.parse(denied.stdout);
+ assert.equal(body.deny, true);
+ assert.match(body.message, /\[rule: git_reset_hard\]/);
+
+ // Codex has no "ask" equivalent: high tier must allow silently
+ const high = spawnSync(process.execPath, [CODEX_HOOK], {
+ input: JSON.stringify({ tool: 'Bash', toolInput: { command: 'npm install lodash' } }),
+ encoding: 'utf8',
+ });
+ assert.equal(high.status, 0);
+ assert.equal(high.stdout, '', 'high tier must print nothing on the Codex surface');
+});
diff --git a/code-warden/tools/tests/config-discovery-tests.js b/code-warden/tools/tests/config-discovery-tests.js
new file mode 100644
index 0000000..259df83
--- /dev/null
+++ b/code-warden/tools/tests/config-discovery-tests.js
@@ -0,0 +1,198 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * config-discovery-tests.js
+ * Behavioral tests for project-level codewarden.json discovery
+ * (lib/config.js findProjectConfig + loadConfig precedence) and the shared
+ * path-prefix matcher (lib/path-match.js).
+ *
+ * All fixtures live in temp dirs under os.tmpdir(); each project root gets a
+ * .git directory so the upward walk never escapes into the host filesystem.
+ */
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const path = require('node:path');
+const fs = require('node:fs');
+const os = require('node:os');
+
+const { loadConfig, findProjectConfig, DEFAULT_CONFIG_PATH } = require('../lib/config');
+const { matchesAnyPrefix, matchesProjectPath } = require('../lib/path-match');
+
+// ---------------------------------------------------------------------------
+// Fixture helpers
+// ---------------------------------------------------------------------------
+
+/** Create a temp project root with a .git boundary; returns the root path. */
+function makeRoot() {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `cw-cfg-${process.pid}-`));
+ fs.mkdirSync(path.join(root, '.git'), { recursive: true });
+ return root;
+}
+
+function writeConfig(dir, config) {
+ fs.mkdirSync(dir, { recursive: true });
+ const p = path.join(dir, 'codewarden.json');
+ fs.writeFileSync(p, JSON.stringify(config, null, 2) + '\n', 'utf8');
+ return p;
+}
+
+const cleanup = (root) => fs.rmSync(root, { recursive: true, force: true });
+
+// ---------------------------------------------------------------------------
+// findProjectConfig
+// ---------------------------------------------------------------------------
+
+test('findProjectConfig: walks up to a root-level codewarden.json', () => {
+ const root = makeRoot();
+ try {
+ const configPath = writeConfig(root, {});
+ const deep = path.join(root, 'src', 'nested', 'deep');
+ fs.mkdirSync(deep, { recursive: true });
+
+ const found = findProjectConfig(deep);
+ assert.ok(found, 'expected discovery to succeed');
+ assert.equal(found.configPath, configPath);
+ assert.equal(found.projectRoot, root);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('findProjectConfig: finds code-warden/codewarden.json variant', () => {
+ const root = makeRoot();
+ try {
+ const configPath = writeConfig(path.join(root, 'code-warden'), {});
+ const deep = path.join(root, 'src');
+ fs.mkdirSync(deep, { recursive: true });
+
+ const found = findProjectConfig(deep);
+ assert.ok(found, 'expected discovery to succeed');
+ assert.equal(found.configPath, configPath);
+ assert.equal(found.projectRoot, root, 'projectRoot is the walk level, not the code-warden subdir');
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('findProjectConfig: stops ascending past a .git boundary', () => {
+ const outer = fs.mkdtempSync(path.join(os.tmpdir(), `cw-cfg-outer-${process.pid}-`));
+ try {
+ writeConfig(outer, {}); // config ABOVE the repo root must not be found
+ const repo = path.join(outer, 'repo');
+ fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
+ const src = path.join(repo, 'src');
+ fs.mkdirSync(src, { recursive: true });
+
+ assert.equal(findProjectConfig(src), null, 'walk must stop at the repo root');
+ } finally {
+ cleanup(outer);
+ }
+});
+
+test('findProjectConfig: repo root itself is the last level checked', () => {
+ const root = makeRoot();
+ try {
+ const configPath = writeConfig(root, {}); // config AT the .git level
+ const found = findProjectConfig(path.join(root));
+ assert.ok(found, 'config at the repo root must be found');
+ assert.equal(found.configPath, configPath);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('findProjectConfig: invalid input returns null', () => {
+ assert.equal(findProjectConfig(null), null);
+ assert.equal(findProjectConfig(''), null);
+ assert.equal(findProjectConfig(undefined), null);
+});
+
+// ---------------------------------------------------------------------------
+// loadConfig precedence + values
+// ---------------------------------------------------------------------------
+
+test('loadConfig: explicit configPath wins over discovery', () => {
+ const root = makeRoot();
+ try {
+ writeConfig(root, { thresholds: { max_file_length: 111 } });
+ const explicitDir = path.join(root, 'explicit');
+ const explicitPath = writeConfig(explicitDir, { thresholds: { max_file_length: 222 } });
+
+ const cfg = loadConfig(explicitPath, root);
+ assert.equal(cfg.maxFileLength, 222, 'explicit path must take precedence');
+ assert.equal(cfg.projectRoot, null, 'explicit configs do not set projectRoot');
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('loadConfig: discovers project config and returns projectRoot', () => {
+ const root = makeRoot();
+ try {
+ writeConfig(root, {
+ thresholds: { max_file_length: 123, pre_flight_trigger_lines: 45 },
+ lint: { exclude_paths: ['vendor/'] },
+ secrets: { allowlist: ['fixtures/'] },
+ });
+ const deep = path.join(root, 'src');
+ fs.mkdirSync(deep, { recursive: true });
+
+ const cfg = loadConfig(null, deep);
+ assert.equal(cfg.maxFileLength, 123);
+ assert.equal(cfg.preFlightTriggerLines, 45);
+ assert.deepEqual(cfg.lintExcludePaths, ['vendor/']);
+ assert.deepEqual(cfg.secretsAllowlist, ['fixtures/']);
+ assert.equal(cfg.projectRoot, root);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('loadConfig: no args falls back to skill default with null projectRoot', () => {
+ const cfg = loadConfig();
+ assert.equal(cfg.projectRoot, null);
+ assert.equal(typeof cfg.maxFileLength, 'number');
+ assert.equal(typeof cfg.preFlightTriggerLines, 'number');
+ assert.ok(fs.existsSync(DEFAULT_CONFIG_PATH), 'skill default config must exist');
+});
+
+test('loadConfig: defaults survive a missing discovered config', () => {
+ const root = makeRoot(); // .git but no codewarden.json anywhere
+ try {
+ const cfg = loadConfig(null, root);
+ // Falls through to the skill default (this repo: 400 / 150)
+ assert.equal(cfg.maxFileLength, 400);
+ assert.equal(cfg.preFlightTriggerLines, 150);
+ } finally {
+ cleanup(root);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// path-match helpers
+// ---------------------------------------------------------------------------
+
+test('matchesAnyPrefix: prefix, exact, and backslash normalisation', () => {
+ assert.equal(matchesAnyPrefix('src/app.js', ['src/']), true);
+ assert.equal(matchesAnyPrefix('src2/app.js', ['src/']), false);
+ assert.equal(matchesAnyPrefix('vendor', ['vendor/']), true, 'exact match without trailing slash');
+ assert.equal(matchesAnyPrefix('src\\app.js', ['src/']), true, 'backslashes normalised');
+ assert.equal(matchesAnyPrefix('src/app.js', []), false);
+});
+
+test('matchesProjectPath: resolves against project root and rejects escapes', () => {
+ const root = makeRoot();
+ try {
+ const inside = path.join(root, 'vendor', 'lib.js');
+ const outside = path.join(os.tmpdir(), 'elsewhere.js');
+ assert.equal(matchesProjectPath(inside, root, ['vendor/']), true);
+ assert.equal(matchesProjectPath(inside, root, ['src/']), false);
+ assert.equal(matchesProjectPath(outside, root, ['vendor/']), false, 'paths outside the root never match');
+ assert.equal(matchesProjectPath(inside, null, ['vendor/']), false, 'no project root means no exclusion');
+ assert.equal(matchesProjectPath('vendor/lib.js', root, ['vendor/'], root), true, 'relative paths resolve against baseDir');
+ } finally {
+ cleanup(root);
+ }
+});
diff --git a/code-warden/tools/tests/git-hook-tests.js b/code-warden/tools/tests/git-hook-tests.js
new file mode 100644
index 0000000..14a40f5
--- /dev/null
+++ b/code-warden/tools/tests/git-hook-tests.js
@@ -0,0 +1,253 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * git-hook-tests.js
+ * Behavioral tests for the git backstop hooks:
+ * - install-hooks.js (marker create/append/replace, core.hooksPath)
+ * - uninstall-hooks.js (block removal, empty-file deletion, no-op)
+ * - warden-pre-commit.js (staged-content scan with exclude/allowlist parity)
+ *
+ * Every repo is a throwaway `git init` under os.tmpdir(); this project's own
+ * .git/hooks is NEVER touched. Merge/strip semantics are covered by pure
+ * functions (no git needed); repo-backed cases skip with a clear message
+ * when git is unavailable on the test machine.
+ */
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { spawnSync } = require('node:child_process');
+const path = require('node:path');
+const fs = require('node:fs');
+const os = require('node:os');
+
+const installer = require('../hooks/git/install-hooks');
+const { uninstallHooks } = require('../hooks/git/uninstall-hooks');
+const { MARKER_START, MARKER_END } = installer;
+
+const PRE_COMMIT_SCRIPT = path.join(__dirname, '..', 'hooks', 'git', 'warden-pre-commit.js');
+
+const HAS_GIT = spawnSync('git', ['--version'], { encoding: 'utf8' }).status === 0;
+const SKIP_GIT = HAS_GIT ? false : 'SKIP: git not available on this machine';
+
+// ---------------------------------------------------------------------------
+// Fixture helpers — fake secrets built from parts, never contiguous literals
+// ---------------------------------------------------------------------------
+
+const makeFakeSecret = (c = 'g') => ['sk', '-', c.repeat(48)].join('');
+const makeLines = n => Array.from({ length: n }, (_, i) => `const l${i + 1} = ${i + 1};`).join('\n') + '\n';
+
+function git(args, cwd) {
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
+ assert.equal(r.status, 0, `git ${args.join(' ')} failed: ${r.stderr}`);
+ return r.stdout.trim();
+}
+
+function makeRepo() {
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), `cw-git-${process.pid}-`));
+ git(['init', '-q'], repo);
+ git(['config', 'user.email', 'warden-tests@example.com'], repo);
+ git(['config', 'user.name', 'Warden Tests'], repo);
+ git(['config', 'commit.gpgsign', 'false'], repo);
+ return repo;
+}
+
+const cleanup = repo => fs.rmSync(repo, { recursive: true, force: true, maxRetries: 3 });
+
+function stage(repo, rel, content) {
+ const abs = path.join(repo, rel);
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
+ fs.writeFileSync(abs, content, 'utf8');
+ git(['add', rel], repo);
+}
+
+function runPreCommit(repo) {
+ return spawnSync(process.execPath, [PRE_COMMIT_SCRIPT], { cwd: repo, encoding: 'utf8' });
+}
+
+// ---------------------------------------------------------------------------
+// Merge / strip semantics (pure functions, no git required)
+// ---------------------------------------------------------------------------
+
+test('merge: no existing hook creates shebang + marker block', () => {
+ const merged = installer.mergePreCommitContent(null, installer.buildBlock('/x/warden-pre-commit.js'));
+ assert.ok(merged.startsWith('#!/bin/sh\n'));
+ assert.ok(merged.includes(MARKER_START) && merged.includes(MARKER_END));
+ assert.ok(merged.includes('node "/x/warden-pre-commit.js" || exit $?'));
+ assert.ok(merged.endsWith('\n'));
+});
+
+test('merge: existing hook without markers is appended, content preserved', () => {
+ const existing = '#!/bin/sh\necho custom-check'; // note: no trailing newline
+ const merged = installer.mergePreCommitContent(existing, installer.buildBlock('/x/w.js'));
+ assert.ok(merged.startsWith('#!/bin/sh\necho custom-check\n'), 'trailing newline ensured before append');
+ assert.ok(merged.indexOf(MARKER_START) > merged.indexOf('echo custom-check'));
+});
+
+test('merge: existing markers are replaced in place, surroundings kept', () => {
+ const before = `#!/bin/sh\necho before\n${installer.buildBlock('/old/path.js')}\necho after\n`;
+ const merged = installer.mergePreCommitContent(before, installer.buildBlock('/new/path.js'));
+ assert.ok(merged.includes('echo before') && merged.includes('echo after'));
+ assert.ok(merged.includes('/new/path.js') && !merged.includes('/old/path.js'));
+ assert.equal(merged.split(MARKER_START).length - 1, 1, 'exactly one block after re-install');
+});
+
+test('strip: removes block; detects empty-apart-from-shebang', () => {
+ const fresh = installer.mergePreCommitContent(null, installer.buildBlock('/x/w.js'));
+ const s1 = installer.stripPreCommitContent(fresh);
+ assert.equal(s1.removed, true);
+ assert.equal(s1.emptyApartFromShebang, true, 'only the shebang remains');
+
+ const mixed = installer.mergePreCommitContent('#!/bin/sh\necho keep\n', installer.buildBlock('/x/w.js'));
+ const s2 = installer.stripPreCommitContent(mixed);
+ assert.equal(s2.removed, true);
+ assert.equal(s2.emptyApartFromShebang, false);
+ assert.ok(s2.content.includes('echo keep') && !s2.content.includes(MARKER_START));
+
+ const s3 = installer.stripPreCommitContent('#!/bin/sh\necho none\n');
+ assert.equal(s3.removed, false, 'no markers means nothing to strip');
+});
+
+// ---------------------------------------------------------------------------
+// Installer round-trip in a throwaway repo
+// ---------------------------------------------------------------------------
+
+test('install/uninstall: fresh repo round-trip', { skip: SKIP_GIT }, () => {
+ const repo = makeRepo();
+ try {
+ const pc = installer.installHooks(null, repo);
+ assert.equal(pc, path.join(repo, '.git', 'hooks', 'pre-commit'));
+ const content = fs.readFileSync(pc, 'utf8');
+ assert.ok(content.startsWith('#!/bin/sh\n'));
+ assert.ok(!/node "[^"]*\\/.test(content), 'embedded path must use forward slashes');
+
+ const state = installer.inspectPreCommit(repo);
+ assert.equal(state.hasBlock, true);
+ assert.equal(state.scriptExists, true, `embedded script must exist: ${state.scriptPath}`);
+
+ // Re-install is idempotent: still exactly one block
+ installer.installHooks(null, repo);
+ const again = fs.readFileSync(pc, 'utf8');
+ assert.equal(again.split(MARKER_START).length - 1, 1);
+
+ // Uninstall deletes the file when only the shebang would remain
+ assert.equal(uninstallHooks(repo), true);
+ assert.equal(fs.existsSync(pc), false);
+ assert.equal(uninstallHooks(repo), false, 'second uninstall is a no-op');
+ } finally {
+ cleanup(repo);
+ }
+});
+
+test('install/uninstall: preserves a pre-existing user hook', { skip: SKIP_GIT }, () => {
+ const repo = makeRepo();
+ try {
+ const pc = path.join(repo, '.git', 'hooks', 'pre-commit');
+ fs.mkdirSync(path.dirname(pc), { recursive: true });
+ fs.writeFileSync(pc, '#!/bin/sh\necho user-hook\n', 'utf8');
+
+ installer.installHooks(null, repo);
+ const merged = fs.readFileSync(pc, 'utf8');
+ assert.ok(merged.includes('echo user-hook') && merged.includes(MARKER_START));
+
+ assert.equal(uninstallHooks(repo), true);
+ assert.ok(fs.existsSync(pc), 'user hook file must survive uninstall');
+ const rest = fs.readFileSync(pc, 'utf8');
+ assert.ok(rest.includes('echo user-hook') && !rest.includes(MARKER_START));
+ } finally {
+ cleanup(repo);
+ }
+});
+
+test('install: respects git config core.hooksPath', { skip: SKIP_GIT }, () => {
+ const repo = makeRepo();
+ try {
+ git(['config', 'core.hooksPath', '.githooks'], repo);
+ const pc = installer.installHooks(null, repo);
+ assert.equal(pc, path.join(repo, '.githooks', 'pre-commit'));
+ assert.ok(fs.existsSync(pc));
+ } finally {
+ cleanup(repo);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Staged-content scan (warden-pre-commit.js run directly)
+// ---------------------------------------------------------------------------
+
+test('pre-commit scan: clean, secret, and staged-vs-working-tree', { skip: SKIP_GIT }, () => {
+ const repo = makeRepo();
+ try {
+ stage(repo, 'src/app.js', "'use strict';\nconst ok = 1;\n");
+ const clean = runPreCommit(repo);
+ assert.equal(clean.status, 0, clean.stderr);
+ assert.match(clean.stdout, /\[PASS\] \[CodeWarden\] Pre-commit gate: 1 staged file\(s\) clean\./);
+
+ // Staged secret blocks
+ stage(repo, 'src/creds.js', `const key = '${makeFakeSecret()}';\n`);
+ const blocked = runPreCommit(repo);
+ assert.equal(blocked.status, 1, 'staged secret must block');
+ assert.match(blocked.stderr, /\[FAIL\] \[CodeWarden\] src\/creds\.js:1: OpenAI key detected in staged content/);
+ assert.match(blocked.stderr, /Commit blocked/);
+
+ // Fix the STAGED copy; dirty working tree must not matter
+ stage(repo, 'src/creds.js', 'const key = process.env.API_KEY;\n');
+ fs.writeFileSync(path.join(repo, 'src', 'creds.js'),
+ `const key = '${makeFakeSecret()}';\n`, 'utf8'); // working tree only, NOT staged
+ const stagedOnly = runPreCommit(repo);
+ assert.equal(stagedOnly.status, 0, 'scan must read git show :path, not the working tree');
+ } finally {
+ cleanup(repo);
+ }
+});
+
+test('pre-commit scan: config parity (length, exclude_paths, allowlist, skips)', { skip: SKIP_GIT }, () => {
+ const repo = makeRepo();
+ try {
+ fs.writeFileSync(path.join(repo, 'codewarden.json'), JSON.stringify({
+ thresholds: { max_file_length: 10 },
+ lint: { exclude_paths: ['vendor/'] },
+ secrets: { allowlist: ['fixtures/'] },
+ }, null, 2) + '\n', 'utf8');
+
+ stage(repo, 'vendor/bundle.js', makeLines(40)); // excluded from length
+ stage(repo, 'fixtures/sample.js', `const k = '${makeFakeSecret()}';\n`); // allowlisted secret
+ stage(repo, 'package-lock.json', `{"x":"${makeFakeSecret()}"}\n`); // SKIP_NAMES
+ stage(repo, 'logo.png', makeFakeSecret()); // SKIP_EXTS
+ const pass = runPreCommit(repo);
+ assert.equal(pass.status, 0, pass.stderr);
+
+ stage(repo, 'src/big.js', makeLines(40)); // not excluded -> length violation
+ const blocked = runPreCommit(repo);
+ assert.equal(blocked.status, 1);
+ assert.match(blocked.stderr, /src\/big\.js: 40 lines exceeds the 10-line limit/);
+ } finally {
+ cleanup(repo);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// End-to-end: real `git commit` through the installed hook
+// ---------------------------------------------------------------------------
+
+test('end-to-end: installed hook blocks a real commit; --no-verify bypasses', { skip: SKIP_GIT }, () => {
+ const repo = makeRepo();
+ try {
+ installer.installHooks(null, repo);
+
+ stage(repo, 'clean.js', "'use strict';\n");
+ const ok = spawnSync('git', ['commit', '-q', '-m', 'clean'], { cwd: repo, encoding: 'utf8' });
+ assert.equal(ok.status, 0, `clean commit must pass the hook: ${ok.stderr}`);
+
+ stage(repo, 'creds.js', `const key = '${makeFakeSecret()}';\n`);
+ const blocked = spawnSync('git', ['commit', '-q', '-m', 'creds'], { cwd: repo, encoding: 'utf8' });
+ assert.notEqual(blocked.status, 0, 'commit with staged secret must be blocked');
+ assert.match(blocked.stderr + blocked.stdout, /\[FAIL\] \[CodeWarden\]/);
+
+ // Documented escape hatch: --no-verify skips pre-commit entirely
+ const bypass = spawnSync('git', ['commit', '-q', '--no-verify', '-m', 'bypass'], { cwd: repo, encoding: 'utf8' });
+ assert.equal(bypass.status, 0, `--no-verify must bypass the backstop: ${bypass.stderr}`);
+ } finally {
+ cleanup(repo);
+ }
+});
diff --git a/code-warden/tools/tests/hook-coverage-tests.js b/code-warden/tools/tests/hook-coverage-tests.js
new file mode 100644
index 0000000..9cebe32
--- /dev/null
+++ b/code-warden/tools/tests/hook-coverage-tests.js
@@ -0,0 +1,304 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * hook-coverage-tests.js
+ * Behavioral tests for the expanded Claude hook coverage:
+ * - warden-command-hook.js (Bash/PowerShell command secrets gate)
+ * - warden-secrets-hook.js (NotebookEdit scanning + secrets.allowlist)
+ * - warden-lint-hook.js (NotebookEdit exemption, lint.exclude_paths,
+ * pre_flight_trigger_lines ask gate)
+ * - install-hooks.js (pure functions: buildMatcherGroups + strip)
+ *
+ * Hooks are exercised through their real stdin interface. Project configs
+ * live in temp dirs under os.tmpdir() with a .git boundary so discovery never
+ * escapes into the host filesystem. The user's home dir is never touched.
+ */
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { spawnSync } = require('node:child_process');
+const path = require('node:path');
+const fs = require('node:fs');
+const os = require('node:os');
+
+const HOOKS = path.join(__dirname, '..', 'hooks', 'claude');
+const LINT_HOOK = path.join(HOOKS, 'warden-lint-hook.js');
+const SECRETS_HOOK = path.join(HOOKS, 'warden-secrets-hook.js');
+const COMMAND_HOOK = path.join(HOOKS, 'warden-command-hook.js');
+
+// ---------------------------------------------------------------------------
+// Fixture helpers — fake secrets built from parts, never contiguous literals
+// ---------------------------------------------------------------------------
+
+function makeFakeSecret() {
+ return ['sk', '-', 'a'.repeat(48)].join('');
+}
+
+function makeLines(count) {
+ const lines = [];
+ for (let i = 1; i <= count; i++) lines.push(`const line${i} = ${i};`);
+ return lines.join('\n') + '\n';
+}
+
+/** Create a temp project with a .git boundary and a codewarden.json. */
+function makeProject(config) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `cw-hook-${process.pid}-`));
+ fs.mkdirSync(path.join(root, '.git'), { recursive: true });
+ fs.writeFileSync(path.join(root, 'codewarden.json'), JSON.stringify(config, null, 2) + '\n');
+ return root;
+}
+
+const cleanup = (root) => fs.rmSync(root, { recursive: true, force: true });
+
+function runHook(scriptPath, payload) {
+ const result = spawnSync(process.execPath, [scriptPath], {
+ input: JSON.stringify(payload),
+ encoding: 'utf8',
+ });
+ return { code: result.status ?? result.signal, stdout: result.stdout || '', stderr: result.stderr || '' };
+}
+
+function decisionOf(stdout) {
+ return JSON.parse(stdout).hookSpecificOutput.permissionDecision;
+}
+
+// ---------------------------------------------------------------------------
+// Command hook: Bash/PowerShell secrets gate
+// ---------------------------------------------------------------------------
+
+test('command hook: Bash command with secret exits 2 with deny', () => {
+ const { code, stdout } = runHook(COMMAND_HOOK, {
+ tool_name: 'Bash',
+ tool_input: { command: `echo ${makeFakeSecret()} > creds.txt` },
+ });
+ assert.equal(code, 2, 'expected exit 2 (deny)');
+ assert.equal(decisionOf(stdout), 'deny');
+});
+
+test('command hook: PowerShell command with secret exits 2 with deny', () => {
+ const { code, stdout } = runHook(COMMAND_HOOK, {
+ tool_name: 'PowerShell',
+ tool_input: { command: `$env:X = '${makeFakeSecret()}'; Invoke-Thing` },
+ });
+ assert.equal(code, 2, 'expected exit 2 (deny)');
+ assert.equal(decisionOf(stdout), 'deny');
+});
+
+test('command hook: clean command exits 0 with no output', () => {
+ const { code, stdout } = runHook(COMMAND_HOOK, {
+ tool_name: 'Bash',
+ tool_input: { command: 'git status && npm test' },
+ });
+ assert.equal(code, 0);
+ assert.equal(stdout, '');
+});
+
+test('command hook: unrelated tool passes through', () => {
+ const { code } = runHook(COMMAND_HOOK, {
+ tool_name: 'Write',
+ tool_input: { file_path: 'x.js', content: makeFakeSecret() },
+ });
+ assert.equal(code, 0, 'command hook must ignore non-command tools');
+});
+
+// ---------------------------------------------------------------------------
+// Secrets hook: NotebookEdit + allowlist
+// ---------------------------------------------------------------------------
+
+test('secrets hook: NotebookEdit with secret in new_source exits 2', () => {
+ const root = makeProject({});
+ try {
+ const { code, stdout } = runHook(SECRETS_HOOK, {
+ tool_name: 'NotebookEdit',
+ cwd: root,
+ tool_input: {
+ file_path: path.join(root, 'analysis.ipynb'),
+ cell_id: 'cell-1',
+ new_source: `key = '${makeFakeSecret()}'`,
+ edit_mode: 'replace',
+ },
+ });
+ assert.equal(code, 2, 'expected exit 2 (deny) for notebook cell secret');
+ assert.equal(decisionOf(stdout), 'deny');
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('secrets hook: allowlisted path skips the scan', () => {
+ const root = makeProject({ secrets: { allowlist: ['fixtures/'] } });
+ try {
+ const payload = (file) => ({
+ tool_name: 'Write',
+ cwd: root,
+ tool_input: { file_path: path.join(root, file), content: `const K = '${makeFakeSecret()}';` },
+ });
+ const allowed = runHook(SECRETS_HOOK, payload('fixtures/sample.js'));
+ assert.equal(allowed.code, 0, 'allowlisted path must pass');
+
+ const denied = runHook(SECRETS_HOOK, payload('src/app.js'));
+ assert.equal(denied.code, 2, 'non-allowlisted path must still deny');
+ } finally {
+ cleanup(root);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Lint hook: NotebookEdit exemption + exclude_paths + project thresholds
+// ---------------------------------------------------------------------------
+
+test('lint hook: NotebookEdit is exempt from the length gate', () => {
+ const root = makeProject({ thresholds: { max_file_length: 50 } });
+ try {
+ const { code, stdout } = runHook(LINT_HOOK, {
+ tool_name: 'NotebookEdit',
+ cwd: root,
+ tool_input: { file_path: path.join(root, 'big.ipynb'), new_source: makeLines(200) },
+ });
+ assert.equal(code, 0, 'notebook cells are not files - no length gate');
+ assert.equal(stdout, '');
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('lint hook: lint.exclude_paths allows an oversized write', () => {
+ const root = makeProject({
+ thresholds: { max_file_length: 100, pre_flight_trigger_lines: 500 },
+ lint: { exclude_paths: ['vendor/'] },
+ });
+ try {
+ const payload = (file) => ({
+ tool_name: 'Write',
+ cwd: root,
+ tool_input: { file_path: path.join(root, file), content: makeLines(120) },
+ });
+ const excluded = runHook(LINT_HOOK, payload('vendor/bundle.js'));
+ assert.equal(excluded.code, 0, 'excluded path must pass the length gate');
+
+ const denied = runHook(LINT_HOOK, payload('src/app.js'));
+ assert.equal(denied.code, 2, 'non-excluded path must still deny');
+ assert.equal(decisionOf(denied.stdout), 'deny');
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('lint hook: discovered project max_file_length overrides skill default', () => {
+ const root = makeProject({ thresholds: { max_file_length: 50, pre_flight_trigger_lines: 500 } });
+ try {
+ const { code, stdout } = runHook(LINT_HOOK, {
+ tool_name: 'Write',
+ cwd: root,
+ tool_input: { file_path: path.join(root, 'src', 'app.js'), content: makeLines(60) },
+ });
+ assert.equal(code, 2, '60 lines must deny under a project limit of 50');
+ assert.equal(decisionOf(stdout), 'deny');
+ } finally {
+ cleanup(root);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Lint hook: pre_flight_trigger_lines ask gate
+// ---------------------------------------------------------------------------
+
+test('lint hook: Write past pre-flight trigger asks for confirmation', () => {
+ const root = makeProject({ thresholds: { max_file_length: 200, pre_flight_trigger_lines: 50 } });
+ try {
+ const { code, stdout } = runHook(LINT_HOOK, {
+ tool_name: 'Write',
+ cwd: root,
+ tool_input: { file_path: path.join(root, 'src', 'big.js'), content: makeLines(80) },
+ });
+ assert.equal(code, 0, 'ask responses exit 0');
+ assert.equal(decisionOf(stdout), 'ask');
+ assert.match(JSON.parse(stdout).hookSpecificOutput.permissionDecisionReason,
+ /Pre-flight gate: single change of 80 lines exceeds 50/);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('lint hook: Edit new_string past pre-flight trigger asks', () => {
+ const root = makeProject({ thresholds: { max_file_length: 200, pre_flight_trigger_lines: 50 } });
+ try {
+ const target = path.join(root, 'src', 'mod.js');
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.writeFileSync(target, "'use strict';\n// PLACEHOLDER\n");
+ const { code, stdout } = runHook(LINT_HOOK, {
+ tool_name: 'Edit',
+ cwd: root,
+ tool_input: { file_path: target, old_string: '// PLACEHOLDER', new_string: makeLines(80) },
+ });
+ assert.equal(code, 0, 'ask responses exit 0');
+ assert.equal(decisionOf(stdout), 'ask');
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('lint hook: below trigger passes silently; above limit still denies', () => {
+ const root = makeProject({ thresholds: { max_file_length: 200, pre_flight_trigger_lines: 50 } });
+ try {
+ const payload = (count) => ({
+ tool_name: 'Write',
+ cwd: root,
+ tool_input: { file_path: path.join(root, 'src', 'app.js'), content: makeLines(count) },
+ });
+ const small = runHook(LINT_HOOK, payload(30));
+ assert.equal(small.code, 0);
+ assert.equal(small.stdout, '', 'below trigger means no output at all');
+
+ const huge = runHook(LINT_HOOK, payload(250));
+ assert.equal(huge.code, 2, 'deny takes precedence over ask');
+ assert.equal(decisionOf(huge.stdout), 'deny');
+ } finally {
+ cleanup(root);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Installer pure functions — no settings.json is ever written here
+// ---------------------------------------------------------------------------
+
+test('install-hooks: buildMatcherGroups covers files and commands', () => {
+ const { buildMatcherGroups } = require('../hooks/claude/install-hooks');
+ const groups = buildMatcherGroups(path.join(os.tmpdir(), 'skill'));
+
+ assert.equal(groups.length, 2);
+ assert.equal(groups[0].matcher, 'Write|Edit|NotebookEdit');
+ assert.equal(groups[1].matcher, 'Bash|PowerShell');
+
+ const all = groups.flatMap(g => g.hooks);
+ assert.equal(all.length, 4);
+ for (const h of all) {
+ assert.equal(h.type, 'command');
+ assert.equal(h.command, 'node');
+ assert.equal(h.timeout, 30);
+ assert.ok(String(h.description).startsWith('code-warden:'), 'marker prefix required');
+ assert.ok(fs.existsSync(path.join(HOOKS, path.basename(h.args[0]))), `hook script exists: ${h.args[0]}`);
+ }
+ assert.equal(path.basename(groups[0].hooks[2].args[0]), 'warden-scope-hook.js');
+ assert.equal(groups[0].hooks[2].description, 'code-warden: scope lock gate');
+ assert.equal(path.basename(groups[1].hooks[0].args[0]), 'warden-command-hook.js');
+ assert.equal(groups[1].hooks[0].description, 'code-warden: command secrets gate');
+});
+
+test('install-hooks: stripCodeWardenHooks removes all groups, keeps user hooks', () => {
+ const { buildMatcherGroups, stripCodeWardenHooks } = require('../hooks/claude/install-hooks');
+ const userGroup = {
+ matcher: 'Write',
+ hooks: [{ type: 'command', command: 'node', args: ['user-hook.js'], description: 'my hook' }],
+ };
+ const mixed = [userGroup, ...buildMatcherGroups(path.join(os.tmpdir(), 'skill'))];
+
+ const cleaned = stripCodeWardenHooks(mixed);
+ assert.equal(cleaned.length, 1, 'both code-warden matcher groups removed');
+ assert.equal(cleaned[0].matcher, 'Write');
+ assert.equal(cleaned[0].hooks[0].description, 'my hook');
+
+ // Round-trip: strip is idempotent on already-clean input
+ assert.deepEqual(stripCodeWardenHooks(cleaned), cleaned);
+});
diff --git a/code-warden/tools/tests/lifecycle-hook-tests.js b/code-warden/tools/tests/lifecycle-hook-tests.js
new file mode 100644
index 0000000..4d569dd
--- /dev/null
+++ b/code-warden/tools/tests/lifecycle-hook-tests.js
@@ -0,0 +1,249 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * lifecycle-hook-tests.js
+ * Behavioral tests for the Phase 4 lifecycle surfaces:
+ * - lib/hook-events.js + install-hooks buildEventGroups (multi-event
+ * install/uninstall round trip via pure functions - the user's real
+ * ~/.claude/settings.json is NEVER touched)
+ * - warden-session-hook.js (SessionStart context injection)
+ * - warden-stop-hook.js (opt-in Stop verification)
+ *
+ * All fixtures live in temp dirs under os.tmpdir() with a .git boundary.
+ */
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { spawnSync } = require('node:child_process');
+const path = require('node:path');
+const fs = require('node:fs');
+const os = require('node:os');
+
+const { applyEventGroups, removeMarkedEntries, collectMarkedEntries,
+ CLAUDE_HOOK_EVENTS } = require('../lib/hook-events');
+const { buildEventGroups } = require('../hooks/claude/install-hooks');
+const store = require('../lib/scope-store');
+
+const SKILL_DIR = path.join(__dirname, '..', '..');
+const SESSION_HOOK = path.join(__dirname, '..', 'hooks', 'claude', 'warden-session-hook.js');
+const STOP_HOOK = path.join(__dirname, '..', 'hooks', 'claude', 'warden-stop-hook.js');
+
+const makeFakeSecret = () => ['sk', '-', 'b'.repeat(48)].join('');
+
+function makeRepo({ config, scope, files } = {}) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `cw-life-${process.pid}-`));
+ fs.mkdirSync(path.join(root, '.git'), { recursive: true });
+ if (config) fs.writeFileSync(path.join(root, 'codewarden.json'), JSON.stringify(config, null, 2) + '\n');
+ if (scope) store.saveScope(root, { ...store.createScope(), ...scope });
+ for (const [rel, content] of Object.entries(files || {})) {
+ const full = path.join(root, rel);
+ fs.mkdirSync(path.dirname(full), { recursive: true });
+ fs.writeFileSync(full, content);
+ }
+ return root;
+}
+
+const cleanup = (root) => fs.rmSync(root, { recursive: true, force: true });
+
+function runHook(scriptPath, payload) {
+ const r = spawnSync(process.execPath, [scriptPath], {
+ input: JSON.stringify(payload), encoding: 'utf8',
+ });
+ return { code: r.status ?? r.signal, stdout: r.stdout || '' };
+}
+
+const makeLines = (n) => Array.from({ length: n }, (_, i) => `const l${i} = ${i};`).join('\n') + '\n';
+
+// ---------------------------------------------------------------------------
+// Multi-event install/uninstall round trip (pure functions only)
+// ---------------------------------------------------------------------------
+
+test('hook-events: install covers all four events, uninstall preserves user hooks', () => {
+ const userPre = { matcher: 'Write', hooks: [{ type: 'command', command: 'node', args: ['mine.js'], description: 'my pre hook' }] };
+ const userStop = { hooks: [{ type: 'command', command: 'node', args: ['bye.js'], description: 'my stop hook' }] };
+ const settings = { hooks: { PreToolUse: [userPre], Stop: [userStop] } };
+
+ applyEventGroups(settings, buildEventGroups(SKILL_DIR));
+
+ for (const event of CLAUDE_HOOK_EVENTS) {
+ assert.ok(Array.isArray(settings.hooks[event]), `${event} array exists`);
+ }
+ // stripEventGroups rebuilds group objects, so compare content, not identity
+ assert.deepEqual(settings.hooks.PreToolUse[0], userPre, 'user groups stay first');
+ assert.deepEqual(settings.hooks.Stop[0], userStop);
+ assert.equal(settings.hooks.PostToolUse[0].matcher, 'Write|Edit|NotebookEdit|Bash|PowerShell');
+ assert.equal(settings.hooks.SessionStart[0].matcher, 'startup|resume|clear');
+
+ const marked = collectMarkedEntries(settings.hooks);
+ assert.equal(marked.length, 7, '4 PreToolUse + audit + session + stop');
+ const descs = marked.map(h => h.description);
+ for (const d of ['code-warden: audit ledger', 'code-warden: session context',
+ 'code-warden: stop verification']) {
+ assert.ok(descs.includes(d), `registered: ${d}`);
+ }
+ for (const h of marked) {
+ assert.equal(h.type, 'command');
+ assert.equal(h.command, 'node');
+ assert.equal(h.timeout, 30);
+ assert.ok(fs.existsSync(h.args[0]), `hook script exists: ${h.args[0]}`);
+ }
+
+ // Idempotent: reinstall replaces, never duplicates
+ applyEventGroups(settings, buildEventGroups(SKILL_DIR));
+ assert.equal(collectMarkedEntries(settings.hooks).length, 7);
+
+ // Uninstall: every marked entry removed, user hooks intact, empties pruned
+ assert.equal(removeMarkedEntries(settings), 7);
+ assert.deepEqual(settings.hooks.PreToolUse, [userPre]);
+ assert.deepEqual(settings.hooks.Stop, [userStop]);
+ assert.equal(settings.hooks.PostToolUse, undefined, 'empty event arrays deleted');
+ assert.equal(settings.hooks.SessionStart, undefined);
+ assert.equal(removeMarkedEntries(settings), 0, 'second uninstall is a no-op');
+
+ // Fully-empty settings collapse to no hooks object at all
+ const bare = { hooks: {} };
+ applyEventGroups(bare, buildEventGroups(SKILL_DIR));
+ removeMarkedEntries(bare);
+ assert.equal(bare.hooks, undefined);
+});
+
+// ---------------------------------------------------------------------------
+// SessionStart hook
+// ---------------------------------------------------------------------------
+
+test('session hook: injects architecture context and scope status', () => {
+ const root = makeRepo({
+ scope: { goal: 'Ship it', filesIn: ['src/'] },
+ files: { 'README.md': '# Test Project\n\nDetails here.\n' },
+ });
+ try {
+ const { code, stdout } = runHook(SESSION_HOOK, {
+ session_id: 's1', cwd: root, hook_event_name: 'SessionStart', source: 'startup',
+ });
+ assert.equal(code, 0);
+ const out = JSON.parse(stdout).hookSpecificOutput;
+ assert.equal(out.hookEventName, 'SessionStart');
+ assert.match(out.additionalContext, /Architecture context from README\.md/);
+ assert.match(out.additionalContext, /# Test Project/);
+ assert.match(out.additionalContext, /Scope locked: goal=Ship it, 1 files in scope/);
+ assert.match(out.additionalContext, /Scope Gate and Plan Gate apply before code changes/);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('session hook: scope without context file still injects; neither is silent', () => {
+ const scoped = makeRepo({ scope: { goal: 'Quiet', filesIn: ['lib/'] } });
+ const empty = makeRepo();
+ try {
+ const withScope = runHook(SESSION_HOOK, { cwd: scoped, source: 'resume' });
+ assert.equal(withScope.code, 0);
+ assert.match(JSON.parse(withScope.stdout).hookSpecificOutput.additionalContext,
+ /Scope locked: goal=Quiet/);
+
+ const silent = runHook(SESSION_HOOK, { cwd: empty, source: 'startup' });
+ assert.equal(silent.code, 0);
+ assert.equal(silent.stdout, '', 'no context and no scope means no output');
+ } finally {
+ cleanup(scoped);
+ cleanup(empty);
+ }
+});
+
+test('session hook: output is capped near 2000 chars and stays ASCII', () => {
+ // Non-ASCII fixture (e-acute) built via charcode so this file stays ASCII.
+ const eAcute = String.fromCharCode(0xe9);
+ const big = '# Huge\n' + ('y'.repeat(180) + ' ' + eAcute + '\n').repeat(30);
+ const root = makeRepo({ files: { 'README.md': big } });
+ try {
+ const { code, stdout } = runHook(SESSION_HOOK, { cwd: root, source: 'startup' });
+ assert.equal(code, 0);
+ const ctx = JSON.parse(stdout).hookSpecificOutput.additionalContext;
+ assert.ok(ctx.length <= 2000, `capped (got ${ctx.length})`);
+ assert.ok(/^[\x09\x0A\x0D\x20-\x7E]*$/.test(ctx), 'ASCII only');
+ } finally {
+ cleanup(root);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Stop hook
+// ---------------------------------------------------------------------------
+
+test('stop hook: off by default - exits 0 silently even with violations', () => {
+ const root = makeRepo({
+ config: { thresholds: { max_file_length: 30 } },
+ files: { 'src/long.js': makeLines(40) },
+ });
+ try {
+ const { code, stdout } = runHook(STOP_HOOK, { cwd: root, session_id: 's1' });
+ assert.equal(code, 0);
+ assert.equal(stdout, '', 'verify_on_stop absent means inert');
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('stop hook: stop_hook_active loop guard exits 0 before any scan', () => {
+ const root = makeRepo({
+ config: { session: { verify_on_stop: true }, thresholds: { max_file_length: 30 } },
+ files: { 'src/long.js': makeLines(40) },
+ });
+ try {
+ const { code, stdout } = runHook(STOP_HOOK, { cwd: root, stop_hook_active: true });
+ assert.equal(code, 0);
+ assert.equal(stdout, '');
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('stop hook: fresh violations block with counts and first offenders', () => {
+ const root = makeRepo({
+ config: { session: { verify_on_stop: true }, thresholds: { max_file_length: 30 } },
+ files: {
+ 'src/long.js': makeLines(40),
+ 'src/creds.js': `const KEY = '${makeFakeSecret()}';\n`,
+ },
+ });
+ try {
+ const { code, stdout } = runHook(STOP_HOOK, { cwd: root, session_id: 's1' });
+ assert.equal(code, 0, 'block goes through JSON, not the exit code');
+ const out = JSON.parse(stdout);
+ assert.equal(out.decision, 'block');
+ assert.match(out.reason, /\[CodeWarden\] Stop verification: 2 fresh violations \(1 length, 1 secrets\)/);
+ assert.match(out.reason, /src[\\/]long\.js: 40 lines \(limit 30\)/);
+ assert.match(out.reason, /src[\\/]creds\.js:1: OpenAI key/);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('stop hook: baseline suppresses legacy violations; new ones still block', () => {
+ const root = makeRepo({
+ config: { session: { verify_on_stop: true }, thresholds: { max_file_length: 30 } },
+ files: { 'src/long.js': makeLines(40) },
+ });
+ try {
+ const { runScans } = require('../lib/scan-core');
+ const { createBaseline } = require('../lib/baseline');
+ const baseline = createBaseline(runScans(root, null, root));
+ fs.writeFileSync(path.join(root, '.code-warden-baseline.json'),
+ JSON.stringify(baseline, null, 2) + '\n');
+
+ const clean = runHook(STOP_HOOK, { cwd: root });
+ assert.equal(clean.code, 0);
+ assert.equal(clean.stdout, '', 'baselined legacy debt never traps a session');
+
+ fs.writeFileSync(path.join(root, 'src', 'fresh.js'), makeLines(45));
+ const blocked = runHook(STOP_HOOK, { cwd: root });
+ const out = JSON.parse(blocked.stdout);
+ assert.equal(out.decision, 'block');
+ assert.match(out.reason, /1 fresh violations \(1 length, 0 secrets\)/);
+ assert.match(out.reason, /fresh\.js/);
+ assert.ok(!out.reason.includes('long.js'), 'legacy offender not listed');
+ } finally {
+ cleanup(root);
+ }
+});
diff --git a/code-warden/tools/tests/receipt-audit-tests.js b/code-warden/tools/tests/receipt-audit-tests.js
new file mode 100644
index 0000000..2e8e8d9
--- /dev/null
+++ b/code-warden/tools/tests/receipt-audit-tests.js
@@ -0,0 +1,243 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * receipt-audit-tests.js
+ * Behavioral tests for receipt --from-audit (lib/receipt-audit.js + the
+ * receipt.js CLI wiring) and the additive audit-block validation rules:
+ * - prefill from a temp governed root (scope + ledger + context file)
+ * - a broken hash chain is surfaced and fails complete-validation
+ * - receipts WITHOUT an audit block still validate (schema stays additive)
+ * All fixtures live in temp dirs under os.tmpdir() with a .git boundary;
+ * no .code-warden/ directory is ever created in this repo.
+ */
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { spawnSync } = require('node:child_process');
+const path = require('node:path');
+const fs = require('node:fs');
+const os = require('node:os');
+
+const { createTemplate, validateReceipt } = require('../receipt');
+const { buildAuditReceipt } = require('../lib/receipt-audit');
+const ledgerLib = require('../lib/audit-ledger');
+const store = require('../lib/scope-store');
+
+const RECEIPT_CLI = path.join(__dirname, '..', 'receipt.js');
+
+// ---------------------------------------------------------------------------
+// Fixtures - fake secrets built from parts, never contiguous literals
+// ---------------------------------------------------------------------------
+
+const makeFakeSecret = () => ['sk', '-', 'c'.repeat(48)].join('');
+
+function makeRepo({ scope, files } = {}) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `cw-receipt-${process.pid}-`));
+ fs.mkdirSync(path.join(root, '.git'), { recursive: true });
+ if (scope) store.saveScope(root, { ...store.createScope(), ...scope });
+ for (const [rel, content] of Object.entries(files || {})) {
+ const full = path.join(root, rel);
+ fs.mkdirSync(path.dirname(full), { recursive: true });
+ fs.writeFileSync(full, content);
+ }
+ return root;
+}
+
+const cleanup = (root) => fs.rmSync(root, { recursive: true, force: true });
+const ledgerPathFor = (root) => path.join(root, '.code-warden', 'audit.jsonl');
+
+function payloadFor(root, tool, tool_input) {
+ return { session_id: 'sess-r', cwd: root, hook_event_name: 'PostToolUse',
+ tool_name: tool, tool_input, tool_response: {} };
+}
+
+/** Seed a ledger via the real recordPostToolUse path: 1 Write + 2 commands. */
+function seedLedger(root, fake) {
+ ledgerLib.recordPostToolUse(payloadFor(root, 'Write',
+ { file_path: path.join(root, 'src', 'a.js') }));
+ ledgerLib.recordPostToolUse(payloadFor(root, 'Bash',
+ { command: `curl -H "X-Key: ${fake}" https://api.example.com` }));
+ ledgerLib.recordPostToolUse(payloadFor(root, 'PowerShell',
+ { command: 'npm test' }));
+}
+
+/** Minimal receipt that passes complete-validation (mirrors run-tests.js). */
+function makeCompleteReceipt() {
+ return {
+ schemaVersion: 1,
+ kind: 'code-warden/governance-receipt',
+ status: 'complete',
+ scopeGate: {
+ confirmed: true, goal: 'Add governance receipts.',
+ nonGoals: ['No release'], filesIn: ['code-warden/tools/receipt.js'],
+ filesOut: ['release tags'], verifyAfter: ['npm test'],
+ rollback: 'git checkout HEAD -- code-warden/tools/receipt.js',
+ },
+ planGate: {
+ confirmed: true, patchOrder: ['Add tests', 'Add implementation'],
+ blastRadius: 'MODERATE', humanCheckpoint: 'YES',
+ postPatchChecks: ['npm test'],
+ },
+ finalEvidence: { commands: ['npm test'], reports: [], notes: [] },
+ validation: { canProveCompliance: true },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Prefill from a governed temp root
+// ---------------------------------------------------------------------------
+
+test('from-audit: prefills scope, context, chain state, and command evidence', () => {
+ const fake = makeFakeSecret();
+ const root = makeRepo({
+ scope: { goal: 'Fix auth bug', filesIn: ['src/'] },
+ files: { 'README.md': '# Receipt Fixture\n\nDetails.\n' },
+ });
+ try {
+ seedLedger(root, fake);
+ const { receipt, summary } = buildAuditReceipt({
+ template: createTemplate(), cwd: root, ledgerPath: null,
+ });
+
+ assert.equal(receipt.status, 'draft', 'prefill never auto-completes');
+
+ // Scope Gate: machine-known fields filled, human fields left empty
+ assert.equal(receipt.scopeGate.confirmed, true);
+ assert.equal(receipt.scopeGate.goal, 'Fix auth bug');
+ assert.deepEqual(receipt.scopeGate.filesIn, ['src/']);
+ assert.deepEqual(receipt.scopeGate.nonGoals, [], 'human judgement stays empty');
+ assert.equal(receipt.scopeGate.rollback, '');
+ assert.equal(receipt.planGate.confirmed, false, 'Plan Gate is never prefilled');
+
+ // Architecture context discovery
+ assert.match(receipt.architectureState.source, /README\.md$/);
+ assert.equal(receipt.architectureState.summary, '# Receipt Fixture');
+
+ // Audit corroboration block
+ assert.equal(receipt.audit.entries, 3);
+ assert.equal(receipt.audit.chainValid, true);
+ assert.equal(receipt.audit.brokenAt, undefined, 'no brokenAt on a valid chain');
+ assert.equal(receipt.audit.path, ledgerPathFor(root).replace(/\\/g, '/'));
+
+ // Command evidence: Bash + PowerShell only, secret-redacted
+ assert.equal(receipt.finalEvidence.commands.length, 2);
+ assert.ok(receipt.finalEvidence.commands.some(c => c.includes('npm test')));
+ assert.ok(!JSON.stringify(receipt).includes(fake), 'raw secret never reaches the receipt');
+ assert.match(receipt.finalEvidence.commands[0], /\[REDACTED:OpenAI key\]/);
+ assert.match(receipt.finalEvidence.notes[0], /prefilled from audit ledger 3 entries; chain valid/);
+
+ // Repository metadata present (values are best-effort nulls off-repo)
+ assert.ok('branch' in receipt.repository && 'commit' in receipt.repository);
+
+ assert.match(summary, /3 ledger entries, chain valid/);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('from-audit: missing ledger throws with a clear pointer', () => {
+ const root = makeRepo({ files: { 'codewarden.json': '{}\n' } });
+ try {
+ assert.throws(
+ () => buildAuditReceipt({ template: createTemplate(), cwd: root, ledgerPath: null }),
+ /audit ledger not found/);
+ } finally {
+ cleanup(root);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Broken chains
+// ---------------------------------------------------------------------------
+
+test('from-audit: tampered ledger reports brokenAt and fails complete-validation', () => {
+ const root = makeRepo({ scope: { goal: 'Tamper', filesIn: ['src/'] } });
+ try {
+ seedLedger(root, makeFakeSecret());
+ const lp = ledgerPathFor(root);
+ const lines = fs.readFileSync(lp, 'utf8').trim().split('\n');
+ const doc = JSON.parse(lines[1]);
+ doc.target = 'src/evil.js'; // rewrite history
+ lines[1] = JSON.stringify(doc);
+ fs.writeFileSync(lp, lines.join('\n') + '\n');
+
+ const { receipt, summary } = buildAuditReceipt({
+ template: createTemplate(), cwd: root, ledgerPath: null,
+ });
+ assert.equal(receipt.audit.chainValid, false);
+ assert.equal(receipt.audit.brokenAt, 2);
+ assert.match(summary, /chain BROKEN at line 2/);
+ assert.match(receipt.finalEvidence.notes[0], /chain BROKEN/);
+
+ // Completing the draft on top of the broken chain must fail validation
+ const completed = { ...makeCompleteReceipt(), audit: receipt.audit };
+ const errors = validateReceipt(completed);
+ assert.equal(errors.length, 1);
+ assert.match(errors[0], /audit\.chainValid is false/);
+ assert.match(errors[0], /broken at line 2/);
+ } finally {
+ cleanup(root);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Validation stays additive (schemaVersion 1 receipts keep working)
+// ---------------------------------------------------------------------------
+
+test('validateReceipt: audit block is optional and only blocks when broken', () => {
+ assert.deepEqual(validateReceipt(makeCompleteReceipt()), [],
+ 'pre-existing receipts without an audit block still validate');
+
+ const corroborated = { ...makeCompleteReceipt(),
+ audit: { path: 'x/.code-warden/audit.jsonl', entries: 4, chainValid: true } };
+ assert.deepEqual(validateReceipt(corroborated), [], 'valid chain adds no errors');
+
+ const malformed = { ...makeCompleteReceipt(), audit: 'yes' };
+ assert.deepEqual(validateReceipt(malformed), ['audit must be an object when present']);
+});
+
+// ---------------------------------------------------------------------------
+// CLI wiring (receipt.js --from-audit through the real argv interface)
+// ---------------------------------------------------------------------------
+
+test('receipt CLI: --from-audit writes a draft; missing ledger or --out fails', () => {
+ const root = makeRepo({ scope: { goal: 'CLI', filesIn: ['src/'] } });
+ const bare = makeRepo({ files: { 'codewarden.json': '{}\n' } });
+ try {
+ seedLedger(root, makeFakeSecret());
+ const out = path.join(root, 'draft-receipt.json');
+
+ const okRun = spawnSync(process.execPath,
+ [RECEIPT_CLI, '--from-audit', `--out=${out}`],
+ { encoding: 'utf8', cwd: root });
+ assert.equal(okRun.status, 0, okRun.stderr);
+ assert.match(okRun.stdout, /Receipt prefilled from audit ledger: 3 ledger entries, chain valid/);
+ assert.match(okRun.stdout, /Draft written to /);
+ const written = JSON.parse(fs.readFileSync(out, 'utf8'));
+ assert.equal(written.status, 'draft');
+ assert.equal(written.audit.chainValid, true);
+
+ // Explicit --from-audit= works from an unrelated cwd
+ const out2 = path.join(bare, 'r2.json');
+ const explicit = spawnSync(process.execPath,
+ [RECEIPT_CLI, `--from-audit=${ledgerPathFor(root)}`, `--out=${out2}`],
+ { encoding: 'utf8', cwd: bare });
+ assert.equal(explicit.status, 0, explicit.stderr);
+ assert.equal(JSON.parse(fs.readFileSync(out2, 'utf8')).audit.entries, 3);
+
+ const noLedger = spawnSync(process.execPath,
+ [RECEIPT_CLI, '--from-audit', `--out=${path.join(bare, 'x.json')}`],
+ { encoding: 'utf8', cwd: bare });
+ assert.equal(noLedger.status, 1);
+ assert.match(noLedger.stderr, /audit ledger not found/);
+
+ const noOut = spawnSync(process.execPath, [RECEIPT_CLI, '--from-audit'],
+ { encoding: 'utf8', cwd: root });
+ assert.equal(noOut.status, 1);
+ assert.match(noOut.stderr, /Missing required --out= for --from-audit/);
+ } finally {
+ cleanup(root);
+ cleanup(bare);
+ }
+});
diff --git a/code-warden/tools/tests/run-all-tests.js b/code-warden/tools/tests/run-all-tests.js
index b1191cf..3514e58 100644
--- a/code-warden/tools/tests/run-all-tests.js
+++ b/code-warden/tools/tests/run-all-tests.js
@@ -10,6 +10,16 @@ const TESTS = [
'codex-config-tests.js',
'risk-policy-tests.js',
'reference-selector-tests.js',
+ 'secret-pattern-tests.js',
+ 'config-discovery-tests.js',
+ 'hook-coverage-tests.js',
+ 'git-hook-tests.js',
+ 'baseline-tests.js',
+ 'scope-tests.js',
+ 'command-risk-tests.js',
+ 'audit-ledger-tests.js',
+ 'lifecycle-hook-tests.js',
+ 'receipt-audit-tests.js',
];
let failed = false;
diff --git a/code-warden/tools/tests/scope-tests.js b/code-warden/tools/tests/scope-tests.js
new file mode 100644
index 0000000..3dfd100
--- /dev/null
+++ b/code-warden/tools/tests/scope-tests.js
@@ -0,0 +1,330 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * scope-tests.js
+ * Behavioral tests for the Scope Lock:
+ * - lib/scope-store.js (normalize, evaluate, discovery, summary)
+ * - tools/scope.js (set/add/remove/clear/status via direct main calls)
+ * - warden-scope-hook.js (Claude write hook through its stdin interface)
+ * - warden-apply-patch-hook.js (Codex scope enforcement on patch targets)
+ *
+ * All fixtures live in temp dirs under os.tmpdir() with a .git boundary so
+ * discovery never escapes into the host filesystem (or this repo). No
+ * .code-warden/ directory is ever created inside the repository itself.
+ */
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { spawnSync } = require('node:child_process');
+const path = require('node:path');
+const fs = require('node:fs');
+const os = require('node:os');
+
+const store = require('../lib/scope-store');
+const { main: scopeMain } = require('../scope');
+
+const SCOPE_HOOK = path.join(__dirname, '..', 'hooks', 'claude', 'warden-scope-hook.js');
+const PATCH_HOOK = path.join(__dirname, '..', 'hooks', 'codex', 'warden-apply-patch-hook.js');
+
+// ---------------------------------------------------------------------------
+// Fixture helpers
+// ---------------------------------------------------------------------------
+
+/** Temp repo root with a .git boundary. */
+function makeRepo() {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `cw-scope-${process.pid}-`));
+ fs.mkdirSync(path.join(root, '.git'), { recursive: true });
+ return root;
+}
+
+/** Write a scope file directly (bypasses the CLI for hook-side tests). */
+function writeScope(root, scope) {
+ return store.saveScope(root, { ...store.createScope(), ...scope });
+}
+
+const cleanup = (root) => fs.rmSync(root, { recursive: true, force: true });
+
+function runHook(scriptPath, payload, cwd) {
+ const result = spawnSync(process.execPath, [scriptPath], {
+ input: JSON.stringify(payload),
+ encoding: 'utf8',
+ cwd: cwd || undefined,
+ });
+ return { code: result.status ?? result.signal, stdout: result.stdout || '' };
+}
+
+function claudeReason(stdout) {
+ return JSON.parse(stdout).hookSpecificOutput.permissionDecisionReason;
+}
+
+// ---------------------------------------------------------------------------
+// scope-store: normalize + evaluate
+// ---------------------------------------------------------------------------
+
+test('scope-store: normalizeScopeEntry handles slashes, dots, dirs, escapes', () => {
+ const root = makeRepo();
+ try {
+ fs.mkdirSync(path.join(root, 'src'));
+ assert.equal(store.normalizeScopeEntry('src\\app.js', root), 'src/app.js');
+ assert.equal(store.normalizeScopeEntry('./lib/utils.js', root), 'lib/utils.js');
+ assert.equal(store.normalizeScopeEntry('src', root), 'src/', 'existing dir gets prefix slash');
+ assert.equal(store.normalizeScopeEntry(path.join(root, 'src', 'a.js'), root), 'src/a.js');
+ assert.equal(store.normalizeScopeEntry('../outside.js', root), null);
+ assert.equal(store.normalizeScopeEntry(path.join(os.tmpdir(), 'other.js'), root), null);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('scope-store: evaluateScope verdict table', () => {
+ const root = makeRepo();
+ try {
+ const scope = store.createScope({ goal: 'Test', filesIn: ['src/', 'lib/utils.js'] });
+ const ev = (p) => store.evaluateScope(p, scope, root, root);
+
+ assert.equal(ev(path.join(root, 'src', 'deep', 'a.js')).code, 'in_scope');
+ assert.equal(ev('lib/utils.js').code, 'in_scope', 'relative paths resolve against baseDir');
+ assert.equal(ev(path.join(root, 'docs', 'x.md')).code, 'out_of_scope');
+ assert.equal(ev(path.join(root, '.code-warden', 'scope.json')).code, 'self_protect');
+ assert.equal(ev(path.join(root, '.code-warden', 'other.json')).code, 'self_protect');
+ assert.equal(ev(path.join(os.tmpdir(), 'elsewhere.js')).code, 'outside_root');
+
+ const off = { ...scope, enforce: false };
+ assert.equal(store.evaluateScope(path.join(root, 'docs', 'x.md'), off, root, root).code,
+ 'enforce_off');
+ assert.equal(store.evaluateScope(path.join(root, '.code-warden', 'scope.json'), off, root, root).code,
+ 'self_protect', 'self-protection wins even when enforce is false');
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('scope-store: getScopeSummary reflects the scope file or null', () => {
+ const root = makeRepo();
+ try {
+ assert.equal(store.getScopeSummary(root), null, 'no scope file means null');
+ writeScope(root, { goal: 'Ship it', filesIn: ['src/'] });
+ const sub = path.join(root, 'src');
+ fs.mkdirSync(sub, { recursive: true });
+ const summary = store.getScopeSummary(sub);
+ assert.ok(summary, 'discovered by walking up');
+ assert.equal(summary.goal, 'Ship it');
+ assert.equal(summary.enforce, true);
+ assert.deepEqual(summary.filesIn, ['src/']);
+ } finally {
+ cleanup(root);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// CLI: set / add / remove / clear round trip
+// ---------------------------------------------------------------------------
+
+test('scope CLI: set/add/remove/clear/status round trip', () => {
+ const root = makeRepo();
+ try {
+ fs.mkdirSync(path.join(root, 'src'));
+
+ assert.equal(scopeMain(['set', '--goal=Fix auth bug', 'src/', 'lib/utils.js'], root), 0);
+ const scopePath = store.scopePathFor(root);
+ let scope = store.loadScope(scopePath);
+ assert.equal(scope.kind, 'code-warden/scope');
+ assert.equal(scope.schemaVersion, 1);
+ assert.equal(scope.goal, 'Fix auth bug');
+ assert.equal(scope.enforce, true);
+ assert.deepEqual(scope.filesIn, ['src/', 'lib/utils.js']);
+ assert.deepEqual(scope.expansions, []);
+
+ // add: appended to filesIn AND recorded in the expansions audit trail
+ assert.equal(scopeMain(['add', 'docs/notes.md'], root), 0);
+ scope = store.loadScope(scopePath);
+ assert.deepEqual(scope.filesIn, ['src/', 'lib/utils.js', 'docs/notes.md']);
+ assert.equal(scope.expansions.length, 1);
+ assert.equal(scope.expansions[0].path, 'docs/notes.md');
+ assert.ok(scope.expansions[0].addedAt, 'expansion is timestamped');
+
+ // remove: shrinks filesIn but keeps the audit trail
+ assert.equal(scopeMain(['remove', 'docs/notes.md'], root), 0);
+ scope = store.loadScope(scopePath);
+ assert.deepEqual(scope.filesIn, ['src/', 'lib/utils.js']);
+ assert.equal(scope.expansions.length, 1, 'expansions are an audit trail - never pruned');
+
+ assert.equal(scopeMain(['status'], root), 0);
+
+ assert.equal(scopeMain(['clear'], root), 0);
+ assert.equal(fs.existsSync(scopePath), false);
+ assert.equal(scopeMain(['status'], root), 0, 'status with no scope is informational, not an error');
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('scope CLI: add without a scope fails; set requires a path', () => {
+ const root = makeRepo();
+ try {
+ assert.equal(scopeMain(['add', 'src/'], root), 1);
+ assert.equal(scopeMain(['set', '--goal=No paths'], root), 1);
+ assert.equal(scopeMain(['bogus'], root), 1);
+ } finally {
+ cleanup(root);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Claude hook: warden-scope-hook.js
+// ---------------------------------------------------------------------------
+
+function writePayload(root, file) {
+ return {
+ tool_name: 'Write',
+ cwd: root,
+ tool_input: { file_path: path.join(root, file), content: 'x' },
+ };
+}
+
+test('scope hook: no scope file allows silently', () => {
+ const root = makeRepo();
+ try {
+ const { code, stdout } = runHook(SCOPE_HOOK, writePayload(root, 'anything.js'));
+ assert.equal(code, 0);
+ assert.equal(stdout, '');
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('scope hook: .code-warden/ writes are denied even with NO scope file', () => {
+ const root = makeRepo();
+ try {
+ for (const file of ['.code-warden/scope.json', '.code-warden/audit.jsonl']) {
+ const { code, stdout } = runHook(SCOPE_HOOK, writePayload(root, file));
+ assert.equal(code, 2, `${file} must deny without any scope lock`);
+ assert.match(claudeReason(stdout), /Governance artifacts under \.code-warden\/ are user-managed/);
+ }
+ // Segment match only: a plain 'code-warden' dir (no dot) is NOT protected
+ const allowed = runHook(SCOPE_HOOK, writePayload(root, 'code-warden/tool.js'));
+ assert.equal(allowed.code, 0, 'dotless code-warden directories stay writable');
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('scope hook: in-scope allows, out-of-scope denies with expansion hint', () => {
+ const root = makeRepo();
+ try {
+ writeScope(root, { goal: 'Fix auth bug', filesIn: ['src/'] });
+
+ const ok = runHook(SCOPE_HOOK, writePayload(root, 'src/app.js'));
+ assert.equal(ok.code, 0, 'in-scope write must pass');
+
+ const denied = runHook(SCOPE_HOOK, writePayload(root, 'docs/x.md'));
+ assert.equal(denied.code, 2, 'out-of-scope write must deny');
+ const reason = claudeReason(denied.stdout);
+ assert.match(reason, /outside the declared scope/);
+ assert.match(reason, /goal: Fix auth bug/);
+ assert.match(reason, /code-warden scope add docs\/x\.md/);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('scope hook: enforce:false allows, but self-protection still denies', () => {
+ const root = makeRepo();
+ try {
+ writeScope(root, { goal: 'Test', filesIn: ['src/'], enforce: false });
+
+ const allowed = runHook(SCOPE_HOOK, writePayload(root, 'docs/x.md'));
+ assert.equal(allowed.code, 0, 'enforce:false disables the scope gate');
+
+ const denied = runHook(SCOPE_HOOK, writePayload(root, '.code-warden/scope.json'));
+ assert.equal(denied.code, 2, 'scope file is protected even when enforce is false');
+ assert.match(claudeReason(denied.stdout), /\.code-warden\/ are user-managed/);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('scope hook: writes outside the governed repo are denied distinctly', () => {
+ const root = makeRepo();
+ const other = fs.mkdtempSync(path.join(os.tmpdir(), `cw-scope-out-${process.pid}-`));
+ try {
+ writeScope(root, { goal: 'Test', filesIn: ['src/'] });
+ const { code, stdout } = runHook(SCOPE_HOOK, {
+ tool_name: 'Edit',
+ cwd: root,
+ tool_input: { file_path: path.join(other, 'x.js'), old_string: 'a', new_string: 'b' },
+ });
+ assert.equal(code, 2);
+ assert.match(claudeReason(stdout), /outside the governed repository/);
+ } finally {
+ cleanup(root);
+ cleanup(other);
+ }
+});
+
+test('scope hook: unrelated tools pass through', () => {
+ const root = makeRepo();
+ try {
+ writeScope(root, { goal: 'Test', filesIn: ['src/'] });
+ const { code } = runHook(SCOPE_HOOK, {
+ tool_name: 'Bash', cwd: root, tool_input: { command: 'echo docs/x.md' },
+ });
+ assert.equal(code, 0);
+ } finally {
+ cleanup(root);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Codex hook: apply_patch scope enforcement
+// ---------------------------------------------------------------------------
+
+function patchFor(file) {
+ return [`--- ${file}`, `+++ ${file}`, '@@', '+const x = 1;', ''].join('\n');
+}
+
+test('codex patch hook: out-of-scope target denies, in-scope passes', () => {
+ const root = makeRepo();
+ try {
+ writeScope(root, { goal: 'Fix auth bug', filesIn: ['src/'] });
+
+ const denied = spawnSync(process.execPath, [PATCH_HOOK], {
+ input: JSON.stringify({ tool: 'apply_patch', toolInput: { patch: patchFor('docs/x.md') } }),
+ encoding: 'utf8',
+ cwd: root,
+ });
+ assert.equal(denied.status, 2, 'out-of-scope patch must deny');
+ const body = JSON.parse(denied.stdout);
+ assert.equal(body.deny, true);
+ assert.match(body.message, /Scope lock/);
+ assert.match(body.message, /docs\/x\.md/);
+
+ const allowed = spawnSync(process.execPath, [PATCH_HOOK], {
+ input: JSON.stringify({ tool: 'apply_patch', toolInput: { patch: patchFor('src/app.js') } }),
+ encoding: 'utf8',
+ cwd: root,
+ });
+ assert.equal(allowed.status, 0, 'in-scope patch must pass');
+ } finally {
+ cleanup(root);
+ }
+});
+
+test('codex patch hook: .code-warden/ target denies even with NO scope file', () => {
+ const root = makeRepo();
+ try {
+ const denied = spawnSync(process.execPath, [PATCH_HOOK], {
+ input: JSON.stringify({
+ tool: 'apply_patch',
+ toolInput: { patch: patchFor('.code-warden/scope.json') },
+ }),
+ encoding: 'utf8',
+ cwd: root,
+ });
+ assert.equal(denied.status, 2, 'governance artifacts deny without a scope lock');
+ assert.match(JSON.parse(denied.stdout).message, /\.code-warden\/ are user-managed/);
+ } finally {
+ cleanup(root);
+ }
+});
diff --git a/code-warden/tools/tests/secret-pattern-tests.js b/code-warden/tools/tests/secret-pattern-tests.js
new file mode 100644
index 0000000..07f2bc2
--- /dev/null
+++ b/code-warden/tools/tests/secret-pattern-tests.js
@@ -0,0 +1,158 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * secret-pattern-tests.js
+ * Behavioral tests for the expanded secret pattern set and the all-matches
+ * scanner (scanForAllSecrets).
+ *
+ * Fixture strategy: every fake credential is built via string concatenation
+ * (prefix + repeat) so this source file never contains a contiguous string
+ * that the repo's own scanners would flag.
+ */
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+
+const { SECRET_PATTERNS, scanForSecrets, scanForAllSecrets } =
+ require('../lib/secret-patterns');
+
+// ---------------------------------------------------------------------------
+// Fake credential builders — concatenation only, never contiguous literals
+// ---------------------------------------------------------------------------
+
+const fakeAnthropic = () => 'sk-ant-' + 'a'.repeat(40);
+const fakeOpenAI = () => 'sk-' + 'a'.repeat(48);
+const fakeGoogle = () => 'AIza' + '0'.repeat(35);
+const fakeGitLab = () => 'glpat-' + 'x'.repeat(20);
+const fakeNpm = () => 'npm_' + 'a'.repeat(36);
+const fakeHf = () => 'hf_' + 'B'.repeat(30);
+const fakeAws = () => 'AKIA' + 'B'.repeat(16);
+const fakeJwt = () =>
+ ['eyJ' + 'a'.repeat(12), 'eyJ' + 'b'.repeat(12), 'c'.repeat(12)].join('.');
+
+const wrap = (value) => `const token = '${value}';\n`;
+
+// ---------------------------------------------------------------------------
+// New pattern positives
+// ---------------------------------------------------------------------------
+
+test('secret patterns: Anthropic API key detected with correct label', () => {
+ const hit = scanForSecrets(wrap(fakeAnthropic()));
+ assert.ok(hit, 'expected a hit for an Anthropic-style key');
+ assert.equal(hit.label, 'Anthropic API key');
+});
+
+test('secret patterns: Google API key detected', () => {
+ const hit = scanForSecrets(wrap(fakeGoogle()));
+ assert.ok(hit, 'expected a hit for a Google API key');
+ assert.equal(hit.label, 'Google API key');
+});
+
+test('secret patterns: GitLab PAT detected', () => {
+ const hit = scanForSecrets(wrap(fakeGitLab()));
+ assert.ok(hit, 'expected a hit for a GitLab PAT');
+ assert.equal(hit.label, 'GitLab PAT');
+});
+
+test('secret patterns: npm token detected', () => {
+ const hit = scanForSecrets(wrap(fakeNpm()));
+ assert.ok(hit, 'expected a hit for an npm token');
+ assert.equal(hit.label, 'npm token');
+});
+
+test('secret patterns: Hugging Face token detected', () => {
+ const hit = scanForSecrets(wrap(fakeHf()));
+ assert.ok(hit, 'expected a hit for a Hugging Face token');
+ assert.equal(hit.label, 'Hugging Face token');
+});
+
+test('secret patterns: three-segment JWT detected', () => {
+ const hit = scanForSecrets(wrap(fakeJwt()));
+ assert.ok(hit, 'expected a hit for a three-segment JWT');
+ assert.equal(hit.label, 'JWT');
+});
+
+// ---------------------------------------------------------------------------
+// Negatives — shapes that must NOT match
+// ---------------------------------------------------------------------------
+
+test('secret patterns: short or partial token shapes do not match', () => {
+ const negatives = [
+ 'AIza' + '0'.repeat(34), // Google: one char short
+ 'glpat-' + 'x'.repeat(19), // GitLab: one char short
+ 'npm_' + 'a'.repeat(35), // npm: one char short
+ 'hf_' + 'B'.repeat(29), // HF: one char short
+ 'eyJ' + 'a'.repeat(12) + '.eyJ' + 'b'.repeat(12), // JWT: two segments only
+ 'sk-ant-' + 'a'.repeat(10), // Anthropic: too short
+ ];
+ for (const value of negatives) {
+ assert.equal(scanForSecrets(wrap(value)), null, `expected no hit for: ${value.slice(0, 12)}...`);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Regression: sk-ant- keys were invisible to the OpenAI pattern shape
+// ---------------------------------------------------------------------------
+
+test('regression: old OpenAI pattern shape never matched sk-ant- keys', () => {
+ // The pre-fix scanner only had this shape; the hyphen in sk-ant- breaks
+ // the [A-Za-z0-9] run, so Anthropic keys passed the scan undetected.
+ const oldOpenAiShape = new RegExp('\\bsk-[A-Za-z0-9]{32,}\\b');
+ assert.equal(oldOpenAiShape.test(fakeAnthropic()), false,
+ 'old pattern matching sk-ant- would invalidate this regression test');
+ const hit = scanForSecrets(wrap(fakeAnthropic()));
+ assert.equal(hit.label, 'Anthropic API key', 'new pattern must catch sk-ant- keys');
+});
+
+test('regression: plain OpenAI keys still labeled OpenAI', () => {
+ const hit = scanForSecrets(wrap(fakeOpenAI()));
+ assert.equal(hit.label, 'OpenAI key');
+});
+
+// ---------------------------------------------------------------------------
+// scanForAllSecrets — ordering, dedupe, parity, non-mutation
+// ---------------------------------------------------------------------------
+
+test('scanForAllSecrets: returns all matches ordered by position', () => {
+ // Google key on line 2, AWS key on line 4. AWS precedes Google in the
+ // pattern list, so position ordering (not pattern ordering) is proven.
+ const content = [
+ '// fixture',
+ `const g = '${fakeGoogle()}';`,
+ '// middle',
+ `const a = '${fakeAws()}';`,
+ ].join('\n');
+
+ const hits = scanForAllSecrets(content);
+ assert.equal(hits.length, 2, 'expected both secrets reported');
+ assert.deepEqual(hits.map(h => h.label), ['Google API key', 'AWS access key']);
+ assert.deepEqual(hits.map(h => h.line), [2, 4]);
+});
+
+test('scanForAllSecrets: single hit reported exactly once', () => {
+ const hits = scanForAllSecrets(wrap(fakeNpm()));
+ assert.equal(hits.length, 1);
+ assert.deepEqual(hits[0], { label: 'npm token', line: 1, column: 16 });
+});
+
+test('scanForAllSecrets: agrees with scanForSecrets for single-hit content', () => {
+ const content = `// header\nconst k = '${fakeGitLab()}';\n`;
+ const first = scanForSecrets(content);
+ const all = scanForAllSecrets(content);
+ assert.equal(all.length, 1);
+ assert.deepEqual(all[0], first);
+});
+
+test('scanForAllSecrets: never mutates the shared pattern objects', () => {
+ const before = SECRET_PATTERNS.map(p => `${p.re.source}|${p.re.flags}|${p.re.lastIndex}`);
+ scanForAllSecrets(wrap(fakeAws()) + wrap(fakeJwt()));
+ const after = SECRET_PATTERNS.map(p => `${p.re.source}|${p.re.flags}|${p.re.lastIndex}`);
+ assert.deepEqual(after, before, 'shared regexes must stay untouched');
+});
+
+test('scanForAllSecrets: empty and non-string input returns empty array', () => {
+ assert.deepEqual(scanForAllSecrets(''), []);
+ assert.deepEqual(scanForAllSecrets(null), []);
+ assert.deepEqual(scanForAllSecrets(undefined), []);
+});
diff --git a/code-warden/tools/verify-secrets.js b/code-warden/tools/verify-secrets.js
index def7ff8..581c35d 100644
--- a/code-warden/tools/verify-secrets.js
+++ b/code-warden/tools/verify-secrets.js
@@ -2,18 +2,20 @@
'use strict';
const fs = require('fs');
-const { scanForSecrets } = require('./lib/secret-patterns');
-const { expandPaths } = require('./lib/file-collection');
+const { scanForAllSecrets } = require('./lib/secret-patterns');
+const { expandPaths } = require('./lib/file-collection');
const filePaths = expandPaths(process.argv.slice(2), 'verify-secrets.js');
let hasErrors = false;
for (const filePath of filePaths) {
const content = fs.readFileSync(filePath, 'utf8');
- const hit = scanForSecrets(content);
+ const hits = scanForAllSecrets(content);
- if (hit) {
- console.error(`[FAIL] [CodeWarden] Hardcoded credential detected in ${filePath} - pattern: ${hit.label}`);
+ if (hits.length > 0) {
+ for (const hit of hits) {
+ console.error(`[FAIL] [CodeWarden] Hardcoded credential detected in ${filePath} - pattern: ${hit.label} (line ${hit.line}, column ${hit.column})`);
+ }
console.error(' Rule: All secrets must be sourced from an environment variable (e.g., process.env)');
hasErrors = true;
} else {