From 4879c0033964ae135288ab040b12f5f61e3e2e30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Pe=C3=B1a?= Date: Thu, 28 May 2026 11:25:15 -0600 Subject: [PATCH 1/2] feat(hooks): Cure 4b cross-cutting hooks + 1.20.0 (DOJ-4571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three generalized PreToolUse hooks distributed via the toolkit so every consumer repo inherits cross-cutting defenses without duplicating bash scripts. Per-repo opt-in via `.claude/config/cross-cutting-hooks.json`. File absence -> all three hooks no-op (full backward compatibility). Hooks (under `hooks/cross-cutting/`): - pre-write-no-cleartext-secret-in-config.sh Blocks Write/Edit/MultiEdit of JSON/YAML/TOML/env config files that introduce ${...KEY|SECRET|TOKEN|PASSWORD|...} placeholders without the cure-shape _FILE/_PATH suffix. Generalized from DOJ-4554's openclaw.json-specific version (PR #266 in dojo-agent-openclaw-plugin). - pre-write-cross-repo-schema-ownership.sh Blocks new SQL migrations for tables not owned by this repo, per a config-driven owned_tables allowlist + migration_paths glob. Empty allowlist blocks every migration in the configured paths (the gateway pattern). Prevents the DOJ-4524 15-day persistence freeze failure mode. - pre-write-version-bump-discipline.sh Blocks multi-step version bumps by delegating to a per-repo validator script. Each entry in version_bumps[] names a file pattern, version- extraction regex, and validator. Old version read from git HEAD blob; new from proposed content. Bash native =~ matching (avoids sed-delim clashes with URL-shape regexes containing /). Per-surface defer_to_local_hook flag implements the belt-and-braces coexistence with existing Cure 4a hooks: dojo-agent-openclaw-plugin's 4a hooks stay in place AND the repo opts in with defer_to_local_hook= true on all three surfaces; dojo-os has no 4a coverage and opts in with 4b owning enforcement. Three bypass-marker comment leaders (#, //, --) so the marker fits whichever syntax the target file uses. Trailing terminator class includes backslash so JSON-serialized embedded newlines (marker\\n) don't break detection. Files: - hooks/cross-cutting/{*.sh,README.md,lib/,tests/} - schemas/cross-cutting-hooks.schema.json — JSON Schema for the config - hooks/hooks.json — register 3 new scripts under PreToolUse Write|Edit|MultiEdit AFTER pre-edit.sh - hooks/test-hooks.sh — wire in test-cross-cutting.sh runner - package.json, .claude-plugin/plugin.json, README.md — bump 1.19.0 -> 1.20.0 - package.json files[] — add schemas/ so the JSON Schema ships in the npm package - CHANGELOG.md — [1.20.0] entry - openspec/changes/2026-05-28-doj-4571-cure-4b-cross-repo-hooks/ — approved proposal/design/tasks spec Tests: 23/23 cross-cutting + 225/225 manifest rules = 248/248 total. Consumer-repo opt-in PRs (dojo-os + dojo-agent-openclaw-plugin) land as sibling PRs after 1.20.0 publishes. Per the DOJ-4571 belt-and-braces decision the gateway's existing 4a hooks are NOT deleted. Refs: DOJ-4571 (this work), DOJ-4554 (Cure 4a foundation), DOJ-4064 (4-cure thesis), DOJ-4524, DOJ-4208, DOJ-4061. Created by Claude Code on behalf of @lapc506 Co-Authored-By: Claude Opus 4.7 --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 99 ++++- README.md | 2 +- hooks/cross-cutting/README.md | 203 +++++++++ hooks/cross-cutting/lib/jq-input.sh | 95 +++++ hooks/cross-cutting/lib/load-config.sh | 118 ++++++ .../pre-write-cross-repo-schema-ownership.sh | 209 +++++++++ ...pre-write-no-cleartext-secret-in-config.sh | 159 +++++++ .../pre-write-version-bump-discipline.sh | 239 +++++++++++ .../cross-cutting/tests/test-cross-cutting.sh | 397 ++++++++++++++++++ hooks/hooks.json | 17 +- hooks/test-hooks.sh | 29 ++ .../design.md | 262 ++++++++++++ .../proposal.md | 188 +++++++++ .../tasks.md | 81 ++++ package.json | 4 +- schemas/cross-cutting-hooks.schema.json | 116 +++++ 17 files changed, 2214 insertions(+), 6 deletions(-) create mode 100644 hooks/cross-cutting/README.md create mode 100755 hooks/cross-cutting/lib/jq-input.sh create mode 100755 hooks/cross-cutting/lib/load-config.sh create mode 100755 hooks/cross-cutting/pre-write-cross-repo-schema-ownership.sh create mode 100755 hooks/cross-cutting/pre-write-no-cleartext-secret-in-config.sh create mode 100755 hooks/cross-cutting/pre-write-version-bump-discipline.sh create mode 100755 hooks/cross-cutting/tests/test-cross-cutting.sh create mode 100644 openspec/changes/2026-05-28-doj-4571-cure-4b-cross-repo-hooks/design.md create mode 100644 openspec/changes/2026-05-28-doj-4571-cure-4b-cross-repo-hooks/proposal.md create mode 100644 openspec/changes/2026-05-28-doj-4571-cure-4b-cross-repo-hooks/tasks.md create mode 100644 schemas/cross-cutting-hooks.schema.json diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 7ea4fec..3750b9d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "make-no-mistakes", - "version": "1.21.0", + "version": "1.22.0", "description": "The disciplined dev lifecycle — implement issues, review PRs, sync releases, test E2E, manage sessions, stash secrets, and enforce manifest-driven tool-call hooks. One plugin to make no mistakes.", "author": { "name": "Luis Andres Pena Castillo", diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bffa6b..110e0e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,99 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.22.0] - 2026-05-29 + +### Added + +- **Cure 4b cross-cutting PreToolUse hooks (DOJ-4571).** Three generalized + hooks distributed via the toolkit so every consumer repo inherits + cross-cutting defenses, parametrized via a per-repo opt-in config file at + `.claude/config/cross-cutting-hooks.json`. File absence → all three hooks + no-op (full backward compatibility). Hooks live in + `hooks/cross-cutting/` alongside the existing manifest-driven rules: + + - `pre-write-no-cleartext-secret-in-config.sh` — blocks Write/Edit/ + MultiEdit of JSON/YAML/TOML/env config files that introduce + `${...KEY|SECRET|TOKEN|PASSWORD|...}` placeholders without the + cure-shape `_FILE` / `_PATH` suffix. Generalized from DOJ-4554's + openclaw.json-specific version (PR #266 in + `dojo-agent-openclaw-plugin`). + - `pre-write-cross-repo-schema-ownership.sh` — blocks new SQL + migrations for tables not owned by this repo, per a config-driven + `owned_tables` allowlist + `migration_paths` glob. Empty allowlist + blocks every migration in the configured paths (the gateway pattern, + where the repo has no migration pipeline). Generalized from + DOJ-4554's `pre-write-plugin-side-migration.sh`. + - `pre-write-version-bump-discipline.sh` — blocks multi-step version + bumps on any pinned dependency by delegating to a per-repo validator + script. Each entry in the `version_bumps` array names a file + pattern, version-extraction regex, and validator script. Old version + is read from the git HEAD blob; new version from the proposed + content; both via bash native `=~` matching (avoids sed-delimiter + clashes with regexes containing `/`). + +- **Per-surface `defer_to_local_hook` flag (belt-and-braces).** Repos + that already have a tighter Cure 4a hook for one of these surfaces + (currently only `dojo-agent-openclaw-plugin`) set + `defer_to_local_hook: true` on the matching config block. The 4b hook + emits an info-stderr and fail-opens; the 4a hook owns enforcement. + Lets the config block stay live (visible, documented, ready for the + day 4a is retired) without firing the looser 4b version. + +- **Schema:** `schemas/cross-cutting-hooks.schema.json` (JSON Schema for + editor autocomplete + CI validation). + +- **Bypass markers:** three comment leaders accepted (`#`, `//`, `--`) + so the marker fits whichever syntax the target file uses. Trailing + terminator class extended to include backslash so JSON-serialized + embedded newlines (`marker\n...`) don't break detection. + +- **Tests:** `hooks/cross-cutting/tests/test-cross-cutting.sh` — 23 + hermetic fixtures (≥7 per hook) spinning up isolated git repos per + case; wired into `npm run test-hooks` after the manifest-rules block. + Total runner now reports 248/248 passing. + +- **Docs:** `hooks/cross-cutting/README.md` — opt-in walkthrough, + surface semantics, bypass markers, belt-and-braces with local 4a + hooks, three-layer rollback (per-surface disable / + `CLAUDE_DISABLE_PLUGIN_HOOKS` / plugin pin), fail-open invariants. + +### Changed + +- `hooks/hooks.json` description updated to surface the new + `hooks/cross-cutting/` directory alongside `hooks/rules/` and + `hooks/atomic/`. +- `hooks/hooks.json` PreToolUse `Write|Edit|MultiEdit|NotebookEdit` + block now registers the 3 cross-cutting scripts AFTER `pre-edit.sh` + and alongside `hooks/atomic/pre-atomic.sh` (manifest-driven rules run + first; atomic-design and cross-cutting hooks layer on as siblings). +- `package.json` `files[]` adds `schemas/` and `references/` so the + JSON Schemas and example configs ship in the npm package (also + benefits `schemas/atomic-design-rules.schema.json` and + `references/atomic-design-rules.example.json` from 1.21.0). + +### Notes + +- Originally targeted `1.20.0` (per the parallel-version note in 1.21.0); + PR #28 landed first as 1.21.0, so this rebases onto 1.22.0 to preserve + monotonic ordering. No semantic content change vs. the originally + proposed 1.20.0. +- Two review fixes from PR #32 (dojo-code-reviewer): replaced GNU-only + `sed ... //I` with explicit bracket-class spelling (BSD sed + compatibility on macOS); switched HIGH_IMPACT_RE / CURE_RE from + quad-backslash escaping to single-quote-plus-interpolation convention. +- Consumer-repo opt-in (config files in `dojo-os` and + `dojo-agent-openclaw-plugin`) lands in sibling PRs after `1.22.0` + publishes. Per DOJ-4571 belt-and-braces decision, + `dojo-agent-openclaw-plugin` keeps its existing 4a hooks AND opts in + with `defer_to_local_hook: true` on all three surfaces; `dojo-os` + opts in with the 4b hooks owning enforcement. +- Refs: DOJ-4571 (this work), DOJ-4554 (Cure 4a foundation), DOJ-4064 + (4-cure thesis), DOJ-4524 (the persistence-freeze incident the + schema-ownership hook prevents), DOJ-4208 (the cleartext-key incident + the cleartext-secret hook prevents), DOJ-4061 (the gateway-version-bump + chain the version-bump hook prevents). + ## [1.21.0] - 2026-05-29 ### Added @@ -53,8 +146,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 2026-05-14 audit (pathways, launchpad, community, projects, marketplace, hackathons, events, agent, dojo-score, plus platform as the shared pillar). The example only enumerates a subset; consumers configure their own list. -- **Parallel-version coordination:** version `1.20.0` is claimed by the - DOJ-4571 Cure 4b cross-repo hooks PR. This release follows as `1.21.0`. +- **Parallel-version coordination:** version `1.20.0` was originally + reserved for the DOJ-4571 Cure 4b cross-repo hooks PR. PR #28 + (this release) landed first as `1.21.0`; DOJ-4571 followed as + `1.22.0` to preserve monotonic ordering. See `[1.22.0]` above. ## [1.19.0] - 2026-05-26 diff --git a/README.md b/README.md index be90377..aece84e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # make-no-mistakes -**Version: 1.21.0** · [CHANGELOG](./CHANGELOG.md) · [Marketplace](https://github.com/DojoCodingLabs/make-no-mistakes-toolkit) +**Version: 1.22.0** · [CHANGELOG](./CHANGELOG.md) · [Marketplace](https://github.com/DojoCodingLabs/make-no-mistakes-toolkit) The disciplined dev lifecycle — implement issues, review PRs, sync releases, test E2E, and manage sessions. One plugin to make no mistakes. diff --git a/hooks/cross-cutting/README.md b/hooks/cross-cutting/README.md new file mode 100644 index 0000000..279e7a6 --- /dev/null +++ b/hooks/cross-cutting/README.md @@ -0,0 +1,203 @@ +# Cross-cutting PreToolUse hooks — Cure 4b (DOJ-4571) + +These hooks ship as part of the `make-no-mistakes` toolkit and apply to +every repo that installs the plugin. They are **opt-in per repo** via a +single config file at the consumer-repo root. + +| File | Purpose | Surface | Source | +|------|---------|---------|--------| +| `pre-write-no-cleartext-secret-in-config.sh` | Block `${...KEY/SECRET/TOKEN/PASSWORD/...}` placeholders in any JSON/YAML/TOML/env config file that the runtime would substitute to disk in cleartext. | `cleartext_secrets` | Generalized from DOJ-4554 hook of the same family in `dojo-agent-openclaw-plugin`. | +| `pre-write-cross-repo-schema-ownership.sh` | Block new SQL migrations for tables this repo does not own. | `schema_ownership` | Generalized from DOJ-4554 `pre-write-plugin-side-migration.sh`. | +| `pre-write-version-bump-discipline.sh` | Block multi-step version bumps in pinned dependencies by delegating to a per-repo validator script. | `version_bumps` | Generalized from DOJ-4554 `pre-write-openclaw-version-bump-discipline.sh`. | + +## Opt-in + +Create `.claude/config/cross-cutting-hooks.json` at your repo root. File +absence → all three hooks no-op. Minimal opt-in: + +```json +{ + "$schema": "https://raw.githubusercontent.com/DojoCodingLabs/make-no-mistakes-toolkit/main/schemas/cross-cutting-hooks.schema.json", + "version": 1, + "cleartext_secrets": { "enabled": true } +} +``` + +Full example (every surface enabled, every override demonstrated): + +```json +{ + "$schema": "https://raw.githubusercontent.com/DojoCodingLabs/make-no-mistakes-toolkit/main/schemas/cross-cutting-hooks.schema.json", + "version": 1, + "cleartext_secrets": { + "enabled": true, + "defer_to_local_hook": false, + "extra_block_patterns": ["MY_CUSTOM_TOKEN"], + "extra_cure_suffixes": ["_REF", "_VOLUME"] + }, + "schema_ownership": { + "enabled": true, + "defer_to_local_hook": false, + "owned_tables": ["chat_sessions", "chat_messages"], + "migration_paths": ["supabase/migrations"] + }, + "version_bumps": [ + { + "file_pattern": "Dockerfile", + "version_regex": "openclaw/releases/download/(v[0-9]+\\.[0-9]+\\.[0-9]+)/", + "validator_script": "scripts/check-openclaw-version-bump.sh", + "validator_args": [], + "defer_to_local_hook": false + } + ] +} +``` + +JSON Schema for editor autocomplete + CI validation: +[`schemas/cross-cutting-hooks.schema.json`](../../schemas/cross-cutting-hooks.schema.json). + +## How each hook works + +### `cleartext_secrets` + +Triggers on `Write|Edit|MultiEdit` of any file ending in `.json`, +`.jsonc`, `.yaml`, `.yml`, `.toml`, `.env`, or starting with `.env.`. + +Built-in blocked tails (case-sensitive, after optional `[A-Z_]*` prefix +and before optional `[A-Z0-9_]*` suffix): + +- `SERVICE_ROLE` +- `JWT_SECRET` +- `PRIVATE_KEY` +- `CLIENT_SECRET` +- `ADMIN_TOKEN` +- `PASSWORD` +- `ENCRYPTION_KEY` +- `SIGNING_SECRET` + +Built-in cure-shape suffixes (placeholders ending in these PASS): + +- `_FILE` +- `_PATH` + +Use `extra_block_patterns` and `extra_cure_suffixes` to extend both +sets. The hook only ADDS to built-ins — there is no removal API; use +the bypass marker for one-off overrides. + +### `schema_ownership` + +Triggers on `Write` only (`Edit`/`MultiEdit` on existing migrations is +allowed — typical for cleanup/annotation of historical artifacts). +Fires only when `FILE_PATH` lives under one of `migration_paths` +(default `["supabase/migrations"]`) AND ends in `.sql`. + +Behavior depends on `owned_tables`: + +- `[]` → blocks every match (the gateway pattern: no migrations belong + in this repo at all) +- `["table_a", ...]` → extracts `CREATE/ALTER/DROP/RENAME TABLE ` + identifiers from the proposed content and blocks if any referenced + name is not in the allowlist + +Conservative SQL parsing: only the four statement types above. Migrations +that only define functions, views, policies, or data are allowed (the +ownership check has no signal to act on). + +### `version_bumps` + +Triggers on `Write|Edit|MultiEdit` of any file whose basename matches a +configured `file_pattern`. For each match: + +1. Extract `OLD_VERSION` from the git HEAD blob via the configured + `version_regex` (single capture group). +2. Extract `NEW_VERSION` from the proposed content via the same regex. +3. If both extract, differ, and the `validator_script` is executable, + invoke ` [extra_args]`. +4. Validator exit codes: + - `0` → pass + - `2` → block (the validator's stderr is shown above the hook's block message) + - any other → warn + fail-open (defense in depth, never block on validator infrastructure) + +If `OLD_VERSION` cannot be extracted (e.g. file is new), the hook passes +silently — the rule applies to BUMPS, not introductions. + +## Bypass markers + +Each hook honors a kebab-case bypass marker matching its surface. Add +the marker as a comment near the offending content: + +- `# hook-bypass: cross-cutting-cleartext-secret` +- `# hook-bypass: cross-cutting-schema-ownership` +- `# hook-bypass: cross-cutting-version-bump` + +Three comment leaders are accepted so the marker fits whichever syntax +the target file uses: + +| Leader | Used in | +|--------|---------| +| `#` | Bash / YAML / TOML / Python (SQL also accepts this) | +| `//` | JSON-with-comments / JS / TS / C-family | +| `--` | SQL / Haskell / Lua | + +## Belt-and-braces with local 4a hooks + +If your repo already has a tighter `.claude/hooks/`-level 4a hook for +one of these surfaces (the canonical case is +`dojo-agent-openclaw-plugin`), set `defer_to_local_hook: true` on the +matching config block. The 4b hook logs an info-stderr and fail-opens; +the 4a hook owns enforcement. This lets the config block stay live +(visible, documented, ready for the day 4a is retired) without firing +the looser 4b version. + +Default `false` → both hooks fire. They produce the same verdict by +construction (4b generalizes 4a) so double-blocks are harmless; the +only user-visible artifact is two stderr blocks instead of one. + +## Disabling + +Three layers, least to most invasive: + +1. **Per surface.** Set `enabled: false` (or omit the key) in the + per-repo config. +2. **All toolkit hooks for the current shell.** Set + `CLAUDE_DISABLE_PLUGIN_HOOKS=1` in your environment. +3. **Plugin pin.** Pin the consumer repo to the pre-Cure-4b toolkit + version (`1.19.0`) in your plugin install command. + +A full rollback (delete the config file) is also valid — the hooks +no-op without their config. + +## Fail-open invariants + +Every hook exits 0 (pass) silently when any of these are true: + +- `CLAUDE_DISABLE_PLUGIN_HOOKS=1` +- `jq` not on PATH +- Hook input JSON malformed +- Config file missing +- Config file present but unsupported `version` +- Per-surface `enabled` is false +- Per-surface `defer_to_local_hook` is true (with an info-stderr line) +- `version_bumps`: validator script missing/non-executable + +This matches the existing toolkit hook posture (defense in depth, never +a single point of failure). + +## Tests + +See `hooks/cross-cutting/tests/test-cross-cutting.sh` — invoked by +`npm run test-hooks` alongside the manifest-driven `rules.json` tests. +Coverage: ≥5 fixtures per hook (block on positive, pass on negative, +no-op when disabled, no-op when config missing, bypass marker honored). + +## Reference + +- DOJ-4571 — this work (Cure 4b) +- DOJ-4554 — Cure 4a foundation in `dojo-agent-openclaw-plugin` +- DOJ-4524 — 15-day persistence-freeze incident that motivated the + schema-ownership hook +- DOJ-4208 — service-role key cleartext-leak incident that motivated + the cleartext-secret hook +- DOJ-4061 — gateway version-bump fix-forward chain that motivated + the version-bump hook +- DOJ-4064 — 4-cure thesis diff --git a/hooks/cross-cutting/lib/jq-input.sh b/hooks/cross-cutting/lib/jq-input.sh new file mode 100755 index 0000000..747f212 --- /dev/null +++ b/hooks/cross-cutting/lib/jq-input.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# Shared input parser for the cross-cutting PreToolUse hooks (DOJ-4571). +# Sourced — never executed directly. Populates the following globals in the +# caller's shell: +# +# HOOK_INPUT_RAW raw JSON received on stdin (cached) +# TOOL_NAME $.tool_name +# FILE_PATH $.tool_input.file_path +# PROPOSED Write→content, Edit→new_string, MultiEdit→\n-joined +# edits[].new_string +# +# Fail-open semantics: on missing `jq` or malformed JSON, the caller's +# script should exit 0 (warn-then-pass). This file sets all globals to +# empty in those cases and lets the caller decide how to react. +# +# Usage: +# source "$(dirname "$0")/lib/jq-input.sh" +# cc_parse_hook_input || exit 0 # fail-open +# +# Conventions: no `set -e` here (sourced into scripts that manage their own +# `set -u`); functions return non-zero on errors so callers can branch. + +CC_JQ_INPUT_LOADED=1 + +# Read stdin once and cache. Subsequent calls reuse the cached value so +# multi-step extractors don't drain the pipe. +cc_read_stdin() { + if [[ -z "${HOOK_INPUT_RAW:-}" ]]; then + HOOK_INPUT_RAW="$(cat)" + fi +} + +# Populate TOOL_NAME, FILE_PATH, PROPOSED. Returns 0 on success. +# Returns 1 if jq is missing (caller should warn + exit 0). +# Returns 2 if jq parse fails on tool_name/file_path (caller should +# warn + exit 0). +cc_parse_hook_input() { + TOOL_NAME="" + FILE_PATH="" + PROPOSED="" + + if ! command -v jq >/dev/null 2>&1; then + return 1 + fi + + cc_read_stdin + + if [[ -z "$HOOK_INPUT_RAW" ]]; then + return 2 + fi + + TOOL_NAME=$(printf '%s' "$HOOK_INPUT_RAW" | jq -r '.tool_name // empty' 2>/dev/null) || return 2 + FILE_PATH=$(printf '%s' "$HOOK_INPUT_RAW" | jq -r '.tool_input.file_path // empty' 2>/dev/null) || return 2 + + case "$TOOL_NAME" in + Write) + PROPOSED=$(printf '%s' "$HOOK_INPUT_RAW" | jq -r '.tool_input.content // empty' 2>/dev/null) || PROPOSED="" + ;; + Edit) + PROPOSED=$(printf '%s' "$HOOK_INPUT_RAW" | jq -r '.tool_input.new_string // empty' 2>/dev/null) || PROPOSED="" + ;; + MultiEdit) + PROPOSED=$(printf '%s' "$HOOK_INPUT_RAW" | jq -r '[.tool_input.edits[]?.new_string // empty] | join("\n")' 2>/dev/null) || PROPOSED="" + ;; + *) + PROPOSED="" + ;; + esac + + return 0 +} + +# Convenience: returns 0 if a bypass marker is present anywhere in the +# raw hook input. Bypass syntax matches three common comment leaders so +# the marker can live in whichever comment shape the target file uses: +# +# "# hook-bypass: " — Bash / YAML / TOML / Python / SQL alt +# "// hook-bypass: " — JS / TS / JSONC +# "-- hook-bypass: " — SQL / Haskell / Lua +# +# The trailing terminator class accepts whitespace, end-of-string, a +# closing JSON quote, or a backslash. The backslash case matters because +# the hook input arrives as a JSON-serialized envelope: an embedded +# newline in tool_input.content is encoded as the literal two-char +# sequence "\n", so the bytes immediately after the marker look like +# "marker\n...". Without the backslash terminator, the regex would not +# match common usage where the bypass comment is followed by a newline. +cc_has_bypass_marker() { + local marker="$1" + [[ -z "$marker" ]] && return 1 + cc_read_stdin + # ERE quoting note: backslash inside a bracket class needs to be escaped + # to a literal "\" in the shell single-quoted regex. + printf '%s' "$HOOK_INPUT_RAW" | grep -qE "(#|//|--)[[:space:]]*hook-bypass:[[:space:]]*${marker}([[:space:]\\\"]|$)" +} diff --git a/hooks/cross-cutting/lib/load-config.sh b/hooks/cross-cutting/lib/load-config.sh new file mode 100755 index 0000000..e29164c --- /dev/null +++ b/hooks/cross-cutting/lib/load-config.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# Consumer-repo config loader for the cross-cutting PreToolUse hooks +# (DOJ-4571). Sourced — never executed directly. +# +# Populates: +# CONFIG_FOUND "1" if .claude/config/cross-cutting-hooks.json exists, +# "0" otherwise (caller should exit 0 — opt-in semantics) +# CONFIG_JSON raw JSON content of the config file, or "{}" +# CONFIG_VERSION the .version integer from the config, or 0 +# +# Fail-open: missing jq, malformed JSON, or unsupported version → caller +# should warn-then-pass. Same convention as jq-input.sh. +# +# Repo-root detection order: +# 1. $CLAUDE_PROJECT_ROOT (set by Claude Code) +# 2. Walk up from $FILE_PATH's directory until a .git directory is found +# 3. Walk up from $PWD until a .git directory is found +# +# Usage: +# source "$(dirname "$0")/lib/load-config.sh" +# cc_load_config || exit 0 # fail-open +# [[ "$CONFIG_FOUND" != "1" ]] && exit 0 +# +# Optional helpers: +# cc_surface_enabled — 0 if ..enabled === true +# cc_surface_defer_to_local — 0 if ..defer_to_local_hook === true + +CC_LOAD_CONFIG_LOADED=1 + +CONFIG_FOUND="0" +CONFIG_JSON="{}" +CONFIG_VERSION=0 +CONFIG_PATH="" + +cc_find_repo_root() { + local d + if [[ -n "${CLAUDE_PROJECT_ROOT:-}" && -d "${CLAUDE_PROJECT_ROOT}/.git" ]]; then + printf '%s' "$CLAUDE_PROJECT_ROOT" + return 0 + fi + + if [[ -n "${FILE_PATH:-}" ]]; then + d="$(dirname "$FILE_PATH")" + while [[ "$d" != "/" && "$d" != "." && ! -d "$d/.git" ]]; do + d="$(dirname "$d")" + done + if [[ -d "$d/.git" ]]; then + printf '%s' "$d" + return 0 + fi + fi + + d="$PWD" + while [[ "$d" != "/" && ! -d "$d/.git" ]]; do + d="$(dirname "$d")" + done + if [[ -d "$d/.git" ]]; then + printf '%s' "$d" + return 0 + fi + + return 1 +} + +cc_load_config() { + CONFIG_FOUND="0" + CONFIG_JSON="{}" + CONFIG_VERSION=0 + CONFIG_PATH="" + + if ! command -v jq >/dev/null 2>&1; then + return 1 + fi + + local repo_root + repo_root="$(cc_find_repo_root)" || return 0 # no repo root → opt-out + CONFIG_PATH="${repo_root}/.claude/config/cross-cutting-hooks.json" + + if [[ ! -f "$CONFIG_PATH" ]]; then + return 0 # file missing → CONFIG_FOUND stays "0", caller exits 0 + fi + + if ! CONFIG_JSON="$(cat "$CONFIG_PATH" 2>/dev/null)"; then + return 2 + fi + + if ! printf '%s' "$CONFIG_JSON" | jq -e . >/dev/null 2>&1; then + # Malformed JSON — emit a warning and fail-open + echo "[cross-cutting-hooks] WARN: malformed config at ${CONFIG_PATH}; failing open." >&2 + CONFIG_JSON="{}" + return 2 + fi + + CONFIG_VERSION=$(printf '%s' "$CONFIG_JSON" | jq -r '.version // 0' 2>/dev/null) + if [[ "$CONFIG_VERSION" != "1" ]]; then + echo "[cross-cutting-hooks] WARN: unsupported version=${CONFIG_VERSION} at ${CONFIG_PATH} (expected 1); failing open." >&2 + return 2 + fi + + CONFIG_FOUND="1" + return 0 +} + +# Return 0 (true) if surface's .enabled is true, else 1. +cc_surface_enabled() { + local surface="$1" + local value + value=$(printf '%s' "$CONFIG_JSON" | jq -r ".${surface}.enabled // false" 2>/dev/null) + [[ "$value" == "true" ]] +} + +# Return 0 (true) if surface's .defer_to_local_hook is true, else 1. +cc_surface_defer_to_local() { + local surface="$1" + local value + value=$(printf '%s' "$CONFIG_JSON" | jq -r ".${surface}.defer_to_local_hook // false" 2>/dev/null) + [[ "$value" == "true" ]] +} diff --git a/hooks/cross-cutting/pre-write-cross-repo-schema-ownership.sh b/hooks/cross-cutting/pre-write-cross-repo-schema-ownership.sh new file mode 100755 index 0000000..0f8e294 --- /dev/null +++ b/hooks/cross-cutting/pre-write-cross-repo-schema-ownership.sh @@ -0,0 +1,209 @@ +#!/bin/bash +# Cure 4b cross-cutting hook (DOJ-4571 #2): block creation of SQL +# migrations for tables this repo does not own. +# +# Generalized from +# dojo-agent-openclaw-plugin/.claude/hooks/pre-write-plugin-side-migration.sh +# (DOJ-4554). The 4a hook hard-blocks ANY supabase/migrations/*.sql write +# because the gateway repo has no migration pipeline; the 4b +# generalization supports two modes via per-repo config: +# +# owned_tables: [] → block every write in the configured +# migration_paths (gateway pattern: no migrations +# at all belong in this repo) +# owned_tables: [...] → block only writes that touch a CREATE/ALTER/ +# DROP for a table not in the allowlist +# (dojo-os pattern: this repo owns chat_sessions, +# chat_messages, ...; reject migrations for tables +# owned by other repos) +# +# Surface: `schema_ownership` +# Bypass: `# hook-bypass: cross-cutting-schema-ownership` or `// ...` +# +# Tool match: Write only. Edit/MultiEdit on existing migrations is +# expected (e.g. cleanup "moved to other repo" comments). +# +# Fail-open on missing jq, malformed input, missing/disabled config, +# `defer_to_local_hook: true`, or `CLAUDE_DISABLE_PLUGIN_HOOKS=1`. + +set -u + +HOOK_NAME="pre-write-cross-repo-schema-ownership.sh" +BYPASS_MARKER="cross-cutting-schema-ownership" +SURFACE="schema_ownership" + +if [[ "${CLAUDE_DISABLE_PLUGIN_HOOKS:-}" == "1" ]]; then + exit 0 +fi + +LIB_DIR="$(dirname "$0")/lib" +# shellcheck source=lib/jq-input.sh +source "${LIB_DIR}/jq-input.sh" +# shellcheck source=lib/load-config.sh +source "${LIB_DIR}/load-config.sh" + +if ! cc_parse_hook_input; then + echo "[${HOOK_NAME}] WARN: input parse failed; failing open." >&2 + exit 0 +fi + +# Only intercept Write (new file). Edit/MultiEdit on existing migrations +# is expected (annotation cleanup of historical mis-placed migrations). +if [[ "$TOOL_NAME" != "Write" ]]; then + exit 0 +fi + +if cc_has_bypass_marker "$BYPASS_MARKER"; then + exit 0 +fi + +cc_load_config || exit 0 +[[ "$CONFIG_FOUND" != "1" ]] && exit 0 +cc_surface_enabled "$SURFACE" || exit 0 + +if cc_surface_defer_to_local "$SURFACE"; then + echo "[${HOOK_NAME}] info: defer_to_local_hook=true; local 4a hook owns this surface in this repo." >&2 + exit 0 +fi + +# Resolve migration_paths (default ["supabase/migrations"]). +MIG_PATHS_JSON=$(printf '%s' "$CONFIG_JSON" | jq -c ".${SURFACE}.migration_paths // [\"supabase/migrations\"]" 2>/dev/null) +if [[ -z "$MIG_PATHS_JSON" || "$MIG_PATHS_JSON" == "null" || "$MIG_PATHS_JSON" == "[]" ]]; then + MIG_PATHS_JSON='["supabase/migrations"]' +fi + +# Check if FILE_PATH lives under any migration_paths entry AND is a .sql file. +IN_MIG_PATH="0" +while IFS= read -r p; do + [[ -z "$p" ]] && continue + # Match either "

/...*.sql" anywhere in the path or at the root. + case "$FILE_PATH" in + */"${p}"/*.sql|"${p}"/*.sql) + IN_MIG_PATH="1" + break + ;; + esac +done < <(printf '%s' "$MIG_PATHS_JSON" | jq -r '.[]' 2>/dev/null) + +if [[ "$IN_MIG_PATH" != "1" ]]; then + exit 0 +fi + +# Resolve owned_tables. +OWNED_TABLES_JSON=$(printf '%s' "$CONFIG_JSON" | jq -c ".${SURFACE}.owned_tables // []" 2>/dev/null) +OWNED_COUNT=$(printf '%s' "$OWNED_TABLES_JSON" | jq -r 'length' 2>/dev/null) +OWNED_COUNT=${OWNED_COUNT:-0} + +if [[ "$OWNED_COUNT" == "0" ]]; then + # Empty allowlist → block ANY migration in this repo (the gateway pattern). + cat >&2 <&2 + exit 0 +fi + +# Capture identifiers after CREATE TABLE, ALTER TABLE, DROP TABLE, RENAME TABLE. +# Strip optional IF NOT EXISTS / IF EXISTS, optional schema prefix (`public.`), +# and trailing punctuation. ERE-only to stay portable. +REFERENCED_TABLES=$(echo "$PROPOSED" \ + | grep -oiE '(CREATE|ALTER|DROP|RENAME)[[:space:]]+TABLE[[:space:]]+(IF[[:space:]]+(NOT[[:space:]]+)?EXISTS[[:space:]]+)?[A-Za-z_][A-Za-z0-9_."]*' \ + | sed -E 's/.*TABLE[[:space:]]+(IF[[:space:]]+(NOT[[:space:]]+)?EXISTS[[:space:]]+)?//I' \ + | sed -E 's/"//g' \ + | sed -E 's/^[A-Za-z_][A-Za-z0-9_]*\.//' \ + | sed -E 's/[^A-Za-z0-9_].*$//' \ + | grep -v '^$' \ + | sort -u) + +if [[ -z "$REFERENCED_TABLES" ]]; then + # Migration doesn't reference any table at the SQL level (could be a + # function, view, policy, or data-only migration). Allow — the ownership + # check has no signal to act on. + exit 0 +fi + +# Build a lookup of owned tables. +OWNED_LIST=$(printf '%s' "$OWNED_TABLES_JSON" | jq -r '.[]' 2>/dev/null | sort -u) + +UNOWNED="" +while IFS= read -r t; do + [[ -z "$t" ]] && continue + if ! echo "$OWNED_LIST" | grep -qxF "$t"; then + UNOWNED="${UNOWNED}${t} " + fi +done <<<"$REFERENCED_TABLES" + +UNOWNED=$(echo "$UNOWNED" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ' | sed 's/ $//') + +if [[ -z "$UNOWNED" ]]; then + exit 0 +fi + +cat >&2 <&2 + exit 0 +fi + +# Tool-name filter. +case "$TOOL_NAME" in + Write|Edit|MultiEdit) ;; + *) exit 0 ;; +esac + +# Bypass marker honored before any other work. +if cc_has_bypass_marker "$BYPASS_MARKER"; then + exit 0 +fi + +# Config gate: opt-in per repo + per surface. +cc_load_config || exit 0 +[[ "$CONFIG_FOUND" != "1" ]] && exit 0 +cc_surface_enabled "$SURFACE" || exit 0 + +if cc_surface_defer_to_local "$SURFACE"; then + echo "[${HOOK_NAME}] info: defer_to_local_hook=true; local 4a hook owns this surface in this repo." >&2 + exit 0 +fi + +# File-path filter — config-shaped files only. +# Match basename suffixes: .json, .jsonc, .yaml, .yml, .toml +# Plus the conventional env-file patterns: .env, .env.*, *.env +case "$FILE_PATH" in + *.json|*.jsonc|*.yaml|*.yml|*.toml) ;; + */.env|*/.env.*|.env|.env.*|*.env) ;; + *) exit 0 ;; +esac + +if [[ -z "$PROPOSED" ]]; then + exit 0 +fi + +# ─── Built-in high-impact secret patterns ─────────────────────────────────── +# Tail patterns (must appear after an optional [A-Z_]* prefix and before an +# optional [A-Z0-9_]* suffix). Mirror the DOJ-4554 PR #266 review fixes for +# numbered/versioned secrets: ${DB_PASSWORD_1}, ${JWT_SECRET_V2}, etc. +BUILTIN_TAILS='SERVICE_ROLE|JWT_SECRET|PRIVATE_KEY|CLIENT_SECRET|ADMIN_TOKEN|PASSWORD|ENCRYPTION_KEY|SIGNING_SECRET' + +# Per-repo extras (additive only — never removes built-ins). +EXTRA_PATTERNS_PIPE="" +if command -v jq >/dev/null 2>&1; then + EXTRA_PATTERNS_PIPE=$(printf '%s' "$CONFIG_JSON" | jq -r "(.${SURFACE}.extra_block_patterns // []) | join(\"|\")" 2>/dev/null) || EXTRA_PATTERNS_PIPE="" +fi + +TAILS="$BUILTIN_TAILS" +if [[ -n "$EXTRA_PATTERNS_PIPE" ]]; then + TAILS="${TAILS}|${EXTRA_PATTERNS_PIPE}" +fi + +HIGH_IMPACT_RE="\\\$\\{[A-Z_]*(${TAILS})[A-Z0-9_]*\\}" + +# ─── Cure-shape suffixes (placeholders ending in these are OK) ────────────── +BUILTIN_SUFFIXES="_FILE|_PATH" +EXTRA_SUFFIXES_PIPE="" +if command -v jq >/dev/null 2>&1; then + EXTRA_SUFFIXES_PIPE=$(printf '%s' "$CONFIG_JSON" | jq -r "(.${SURFACE}.extra_cure_suffixes // []) | join(\"|\")" 2>/dev/null) || EXTRA_SUFFIXES_PIPE="" +fi + +CURE_SUFFIXES="$BUILTIN_SUFFIXES" +if [[ -n "$EXTRA_SUFFIXES_PIPE" ]]; then + CURE_SUFFIXES="${CURE_SUFFIXES}|${EXTRA_SUFFIXES_PIPE}" +fi + +CURE_RE="(${CURE_SUFFIXES})\\}$" + +# ─── Scan proposed content ────────────────────────────────────────────────── +MATCHES="" +if echo "$PROPOSED" | grep -qE "$HIGH_IMPACT_RE"; then + ALL=$(echo "$PROPOSED" | grep -oE '\$\{[A-Z][A-Z0-9_]*\}') + FILTERED="" + while IFS= read -r tok; do + [[ -z "$tok" ]] && continue + if echo "$tok" | grep -qE "$HIGH_IMPACT_RE" && ! echo "$tok" | grep -qE "$CURE_RE"; then + FILTERED="${FILTERED}${tok} " + fi + done <<<"$ALL" + MATCHES=$(echo "$FILTERED" | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ */ /g; s/^ //; s/ $//') +fi + +if [[ -z "$MATCHES" ]]; then + exit 0 +fi + +cat >&2 < [extra_args...]`. +# Exit 2 from the script → block; exit 0 → pass; any other exit → warn + +# fail-open (defense in depth, never block on validator infrastructure). +# +# Fail-open on missing jq, missing git, malformed input, missing/disabled +# config, missing validator script, or `CLAUDE_DISABLE_PLUGIN_HOOKS=1`. + +set -u + +HOOK_NAME="pre-write-version-bump-discipline.sh" +BYPASS_MARKER="cross-cutting-version-bump" +SURFACE="version_bumps" + +if [[ "${CLAUDE_DISABLE_PLUGIN_HOOKS:-}" == "1" ]]; then + exit 0 +fi + +LIB_DIR="$(dirname "$0")/lib" +# shellcheck source=lib/jq-input.sh +source "${LIB_DIR}/jq-input.sh" +# shellcheck source=lib/load-config.sh +source "${LIB_DIR}/load-config.sh" + +if ! cc_parse_hook_input; then + echo "[${HOOK_NAME}] WARN: input parse failed; failing open." >&2 + exit 0 +fi + +case "$TOOL_NAME" in + Write|Edit|MultiEdit) ;; + *) exit 0 ;; +esac + +if cc_has_bypass_marker "$BYPASS_MARKER"; then + exit 0 +fi + +cc_load_config || exit 0 +[[ "$CONFIG_FOUND" != "1" ]] && exit 0 + +# version_bumps is an array; this surface has no top-level enabled/defer +# (the per-entry defer_to_local_hook handles the belt-and-braces). +ENTRIES_COUNT=$(printf '%s' "$CONFIG_JSON" | jq -r ".${SURFACE} | length // 0" 2>/dev/null) +ENTRIES_COUNT=${ENTRIES_COUNT:-0} +if [[ "$ENTRIES_COUNT" == "0" ]]; then + exit 0 +fi + +# Locate repo root (needed to resolve validator paths + read HEAD blob). +REPO_ROOT="${CLAUDE_PROJECT_ROOT:-}" +if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT/.git" ]]; then + REPO_ROOT="$(cc_find_repo_root 2>/dev/null)" || REPO_ROOT="" +fi +if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT/.git" ]]; then + echo "[${HOOK_NAME}] WARN: cannot locate repo root; failing open." >&2 + exit 0 +fi + +if ! command -v git >/dev/null 2>&1; then + echo "[${HOOK_NAME}] WARN: git not on PATH; failing open." >&2 + exit 0 +fi + +# Find which entry (if any) matches FILE_PATH. +MATCH_IDX="" +MATCH_FILE_PATTERN="" +MATCH_VERSION_REGEX="" +MATCH_VALIDATOR="" +MATCH_VALIDATOR_ARGS_JSON="[]" +MATCH_DEFER="false" + +for i in $(seq 0 $((ENTRIES_COUNT - 1))); do + fp=$(printf '%s' "$CONFIG_JSON" | jq -r ".${SURFACE}[$i].file_pattern // empty" 2>/dev/null) + [[ -z "$fp" ]] && continue + # Match basename or trailing-component glob. + case "$FILE_PATH" in + */"$fp"|"$fp"|*"$fp") + MATCH_IDX="$i" + MATCH_FILE_PATTERN="$fp" + MATCH_VERSION_REGEX=$(printf '%s' "$CONFIG_JSON" | jq -r ".${SURFACE}[$i].version_regex // empty" 2>/dev/null) + MATCH_VALIDATOR=$(printf '%s' "$CONFIG_JSON" | jq -r ".${SURFACE}[$i].validator_script // empty" 2>/dev/null) + MATCH_VALIDATOR_ARGS_JSON=$(printf '%s' "$CONFIG_JSON" | jq -c ".${SURFACE}[$i].validator_args // []" 2>/dev/null) + MATCH_DEFER=$(printf '%s' "$CONFIG_JSON" | jq -r ".${SURFACE}[$i].defer_to_local_hook // false" 2>/dev/null) + break + ;; + esac +done + +if [[ -z "$MATCH_IDX" ]]; then + exit 0 +fi + +if [[ "$MATCH_DEFER" == "true" ]]; then + echo "[${HOOK_NAME}] info: defer_to_local_hook=true for entry ${MATCH_FILE_PATTERN}; local 4a hook owns this surface in this repo." >&2 + exit 0 +fi + +# Validator script must exist + be executable. Resolve relative to repo root. +VALIDATOR_ABS="$MATCH_VALIDATOR" +if [[ "$VALIDATOR_ABS" != /* ]]; then + VALIDATOR_ABS="${REPO_ROOT}/${VALIDATOR_ABS}" +fi +if [[ ! -x "$VALIDATOR_ABS" ]]; then + echo "[${HOOK_NAME}] WARN: validator missing or not executable at ${VALIDATOR_ABS}; failing open." >&2 + exit 0 +fi + +if [[ -z "$MATCH_VERSION_REGEX" ]]; then + echo "[${HOOK_NAME}] WARN: empty version_regex for entry ${MATCH_FILE_PATTERN}; failing open." >&2 + exit 0 +fi + +# Extract NEW version from PROPOSED content. +# For Edit/MultiEdit, PROPOSED is the new_string only — typically enough for +# the version bump (the line containing the version literal is in the diff). +# For Write, PROPOSED is the full new file. +if [[ -z "$PROPOSED" ]]; then + exit 0 +fi + +# Extract the first capture group from the first line that matches the +# user-supplied version_regex. We use bash native regex matching (`=~`) +# instead of sed, because the regex commonly contains `/` (URL paths) which +# would clash with sed's default `s/.../.../` delimiter. Bash =~ has no +# delimiter at all and exposes the captures via ${BASH_REMATCH[1]}. +NEW_VERSION="" +while IFS= read -r line; do + if [[ "$line" =~ $MATCH_VERSION_REGEX ]]; then + NEW_VERSION="${BASH_REMATCH[1]}" + [[ -n "$NEW_VERSION" ]] && break + fi +done <<<"$PROPOSED" + +if [[ -z "$NEW_VERSION" ]]; then + # No version literal in the proposed content → this edit isn't a version + # bump; pass. + exit 0 +fi + +# Extract OLD version from git HEAD blob of the same path (relative to repo root). +REL_PATH="$FILE_PATH" +case "$REL_PATH" in + "$REPO_ROOT"/*) REL_PATH="${REL_PATH#${REPO_ROOT}/}" ;; +esac + +OLD_BLOB=$(git -C "$REPO_ROOT" show "HEAD:${REL_PATH}" 2>/dev/null || true) +if [[ -z "$OLD_BLOB" ]]; then + # File is new at HEAD — no version to compare against; pass. + exit 0 +fi + +# Same bash =~ extraction as NEW_VERSION (avoids sed-delimiter clashes with +# regexes that contain `/`). +OLD_VERSION="" +while IFS= read -r line; do + if [[ "$line" =~ $MATCH_VERSION_REGEX ]]; then + OLD_VERSION="${BASH_REMATCH[1]}" + [[ -n "$OLD_VERSION" ]] && break + fi +done <<<"$OLD_BLOB" + +if [[ -z "$OLD_VERSION" ]]; then + # File at HEAD doesn't contain the version pattern — first introduction, + # nothing to discipline; pass. + exit 0 +fi + +if [[ "$OLD_VERSION" == "$NEW_VERSION" ]]; then + # No change. + exit 0 +fi + +# Delegate to validator. Build args array from JSON. +EXTRA_ARGS=() +while IFS= read -r arg; do + [[ -z "$arg" ]] && continue + EXTRA_ARGS+=("$arg") +done < <(printf '%s' "$MATCH_VALIDATOR_ARGS_JSON" | jq -r '.[]' 2>/dev/null) + +set +e +"$VALIDATOR_ABS" "$OLD_VERSION" "$NEW_VERSION" "${EXTRA_ARGS[@]}" +VALIDATOR_EXIT=$? +set -e + +case "$VALIDATOR_EXIT" in + 0) + exit 0 + ;; + 2) + cat >&2 <&2 + exit 0 + ;; +esac diff --git a/hooks/cross-cutting/tests/test-cross-cutting.sh b/hooks/cross-cutting/tests/test-cross-cutting.sh new file mode 100755 index 0000000..1f9e2f3 --- /dev/null +++ b/hooks/cross-cutting/tests/test-cross-cutting.sh @@ -0,0 +1,397 @@ +#!/usr/bin/env bash +# hook-bypass: secret-leak +# ============================================================================= +# test-cross-cutting.sh — Test runner for the Cure 4b cross-cutting hooks +# (DOJ-4571). Invoked by hooks/test-hooks.sh. +# +# The "secret-leak" bypass marker above is intentional: this file contains +# synthetic ${SUPABASE_SERVICE_ROLE_KEY} / ${DB_PASSWORD} fixtures that +# would otherwise trip the toolkit's own `secrets-hardcoded` rule. These +# are obviously test inputs, not real credentials. +# +# Pattern: each test spins up an isolated git repo in a tempdir, writes a +# synthetic .claude/config/cross-cutting-hooks.json, and pipes a JSON +# hook-input envelope to the hook script. We assert exit code + (optionally) +# a stderr substring. +# +# Why isolated repos: load-config.sh walks up for a .git directory; we never +# want the test to leak into the real toolkit repo's own config. +# +# Why fixtures are materialized in subshells (not inline): hook scripts +# inspect the literal stdin string for bypass markers. The fixture payloads +# stay in pipe buffers, never on argv. +# +# Usage: bash hooks/cross-cutting/tests/test-cross-cutting.sh +# Exit: 0 if all pass; 1 if any fail. +# ============================================================================= +set -uo pipefail + +CC_DIR="$(cd "$(dirname "$0")/.." && pwd)" +HOOK_SECRET="$CC_DIR/pre-write-no-cleartext-secret-in-config.sh" +HOOK_SCHEMA="$CC_DIR/pre-write-cross-repo-schema-ownership.sh" +HOOK_VERSION="$CC_DIR/pre-write-version-bump-discipline.sh" + +if ! command -v jq >/dev/null 2>&1; then + echo "ERROR: jq is required to run cross-cutting tests." >&2 + exit 1 +fi +if ! command -v git >/dev/null 2>&1; then + echo "ERROR: git is required to run cross-cutting tests." >&2 + exit 1 +fi + +CC_PASS=0 +CC_FAIL=0 +CC_FAIL_DETAILS=() + +# --- helpers --------------------------------------------------------------- + +# Create an isolated git repo with optional config + optional initial files. +# Echoes the absolute repo path on stdout. +make_repo() { + local base + base="$(mktemp -d)" + ( + cd "$base" || exit 1 + git init -q --initial-branch=main + git config user.email t@t.t + git config user.name t + git commit -q --allow-empty -m "init" + ) + echo "$base" +} + +# Write a config file at /.claude/config/cross-cutting-hooks.json. +# Pass JSON as $2. +write_config() { + local repo="$1" json="$2" + mkdir -p "$repo/.claude/config" + printf '%s' "$json" > "$repo/.claude/config/cross-cutting-hooks.json" +} + +# Drive a hook with synthetic stdin (a single tool-input envelope). +# Args: +# Stdout: actual exit code +# Side effect: writes hook stderr to LAST_STDERR_FILE (a deterministic path). +# +# The stderr file lives at a fixed path so that `actual=$(drive_hook …)` +# subshell capture doesn't lose the assignment (assigning LAST_STDERR_FILE +# inside the subshell would not propagate out). +LAST_STDERR_FILE="/tmp/cc-test-last-stderr.$$" +drive_hook() { + local hook="$1" repo="$2" input="$3" + : > "$LAST_STDERR_FILE" # truncate + local exit_code=0 + ( + cd "$repo" || exit 1 + CLAUDE_PROJECT_ROOT="$repo" \ + printf '%s' "$input" | bash "$hook" + ) 2>"$LAST_STDERR_FILE" || exit_code=$? + echo "$exit_code" +} + +# Run one assertion. Args: [] +assert_case() { + local name="$1" expected="$2" actual="$3" expected_stderr="${4:-}" + local status="PASS" reason="" + + if [ "$actual" != "$expected" ]; then + status="FAIL"; reason="exit expected=$expected actual=$actual; stderr=$(cat "$LAST_STDERR_FILE" 2>/dev/null | head -c 400)" + elif [ -n "$expected_stderr" ]; then + if ! grep -qF -- "$expected_stderr" "$LAST_STDERR_FILE" 2>/dev/null; then + status="FAIL"; reason="stderr did not contain '$expected_stderr'; got=$(cat "$LAST_STDERR_FILE" 2>/dev/null | head -c 400)" + fi + fi + + if [ "$status" = "PASS" ]; then + CC_PASS=$((CC_PASS + 1)) + echo " PASS cross-cutting / ${name}" + else + CC_FAIL=$((CC_FAIL + 1)) + echo " FAIL cross-cutting / ${name} -- ${reason}" + CC_FAIL_DETAILS+=("cross-cutting/${name}: ${reason}") + fi +} + +# Final cleanup hook. +trap 'rm -f "$LAST_STDERR_FILE"' EXIT + +# --- cleartext_secrets tests ----------------------------------------------- + +# 1. Blocks Write of a YAML config introducing a high-impact placeholder. +repo="$(make_repo)" +write_config "$repo" '{"version":1,"cleartext_secrets":{"enabled":true}}' +input="$(jq -nc --arg fp "$repo/config.yaml" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"db:\n service_role_key: ${SUPABASE_SERVICE_ROLE_KEY}\n"}}')" +actual="$(drive_hook "$HOOK_SECRET" "$repo" "$input")" +assert_case "cleartext-secret/blocks-service-role-in-yaml" "2" "$actual" "BLOCKED" +rm -rf "$repo" + +# 2. Allows the *_FILE cure shape. +repo="$(make_repo)" +write_config "$repo" '{"version":1,"cleartext_secrets":{"enabled":true}}' +input="$(jq -nc --arg fp "$repo/config.yaml" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"db:\n service_role_key_file: ${SUPABASE_SERVICE_ROLE_KEY_FILE}\n"}}')" +actual="$(drive_hook "$HOOK_SECRET" "$repo" "$input")" +assert_case "cleartext-secret/allows-file-cure-shape" "0" "$actual" +rm -rf "$repo" + +# 3. No-ops when config is missing. +repo="$(make_repo)" +input="$(jq -nc --arg fp "$repo/config.yaml" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"db:\n pass: ${DB_PASSWORD}\n"}}')" +actual="$(drive_hook "$HOOK_SECRET" "$repo" "$input")" +assert_case "cleartext-secret/no-op-without-config" "0" "$actual" +rm -rf "$repo" + +# 4. No-ops when surface disabled. +repo="$(make_repo)" +write_config "$repo" '{"version":1,"cleartext_secrets":{"enabled":false}}' +input="$(jq -nc --arg fp "$repo/config.yaml" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"db:\n pass: ${DB_PASSWORD}\n"}}')" +actual="$(drive_hook "$HOOK_SECRET" "$repo" "$input")" +assert_case "cleartext-secret/no-op-when-disabled" "0" "$actual" +rm -rf "$repo" + +# 5. Honors bypass marker. +repo="$(make_repo)" +write_config "$repo" '{"version":1,"cleartext_secrets":{"enabled":true}}' +input="$(jq -nc --arg fp "$repo/config.yaml" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"# hook-bypass: cross-cutting-cleartext-secret\ndb:\n pass: ${DB_PASSWORD}\n"}}')" +actual="$(drive_hook "$HOOK_SECRET" "$repo" "$input")" +assert_case "cleartext-secret/honors-bypass-marker" "0" "$actual" +rm -rf "$repo" + +# 6. Defers to local 4a hook. +repo="$(make_repo)" +write_config "$repo" '{"version":1,"cleartext_secrets":{"enabled":true,"defer_to_local_hook":true}}' +input="$(jq -nc --arg fp "$repo/config.yaml" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"db:\n pass: ${DB_PASSWORD}\n"}}')" +actual="$(drive_hook "$HOOK_SECRET" "$repo" "$input")" +assert_case "cleartext-secret/defers-to-local-4a-hook" "0" "$actual" "defer_to_local_hook=true" +rm -rf "$repo" + +# 7. Passes non-config file types. +repo="$(make_repo)" +write_config "$repo" '{"version":1,"cleartext_secrets":{"enabled":true}}' +input="$(jq -nc --arg fp "$repo/src/foo.ts" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"const x = `${DB_PASSWORD}`;"}}')" +actual="$(drive_hook "$HOOK_SECRET" "$repo" "$input")" +assert_case "cleartext-secret/passes-non-config-file" "0" "$actual" +rm -rf "$repo" + +# 8. extra_cure_suffixes allows _REF tail. +repo="$(make_repo)" +write_config "$repo" '{"version":1,"cleartext_secrets":{"enabled":true,"extra_cure_suffixes":["_REF"]}}' +input="$(jq -nc --arg fp "$repo/config.yaml" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"db:\n pass: ${DB_PASSWORD_REF}\n"}}')" +actual="$(drive_hook "$HOOK_SECRET" "$repo" "$input")" +assert_case "cleartext-secret/extra-cure-suffix-allows-ref" "0" "$actual" +rm -rf "$repo" + +# --- schema_ownership tests ------------------------------------------------ + +# 1. Blocks Write of a migration when owned_tables=[] (gateway pattern). +repo="$(make_repo)" +write_config "$repo" '{"version":1,"schema_ownership":{"enabled":true,"owned_tables":[],"migration_paths":["supabase/migrations"]}}' +input="$(jq -nc --arg fp "$repo/supabase/migrations/20260528_001_add_table.sql" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"CREATE TABLE chat_sessions (id uuid PRIMARY KEY);"}}')" +actual="$(drive_hook "$HOOK_SCHEMA" "$repo" "$input")" +assert_case "schema-ownership/blocks-when-no-tables-owned" "2" "$actual" "no migration pipeline" +rm -rf "$repo" + +# 2. Allows Write of a migration for an owned table. +repo="$(make_repo)" +write_config "$repo" '{"version":1,"schema_ownership":{"enabled":true,"owned_tables":["chat_sessions"],"migration_paths":["supabase/migrations"]}}' +input="$(jq -nc --arg fp "$repo/supabase/migrations/20260528_001.sql" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"ALTER TABLE chat_sessions ADD COLUMN persona text;"}}')" +actual="$(drive_hook "$HOOK_SCHEMA" "$repo" "$input")" +assert_case "schema-ownership/allows-owned-table" "0" "$actual" +rm -rf "$repo" + +# 3. Blocks Write of a migration for an unowned table. +repo="$(make_repo)" +write_config "$repo" '{"version":1,"schema_ownership":{"enabled":true,"owned_tables":["users"],"migration_paths":["supabase/migrations"]}}' +input="$(jq -nc --arg fp "$repo/supabase/migrations/20260528_001.sql" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"CREATE TABLE chat_sessions (id uuid PRIMARY KEY);"}}')" +actual="$(drive_hook "$HOOK_SCHEMA" "$repo" "$input")" +assert_case "schema-ownership/blocks-unowned-table" "2" "$actual" "not owned by this repo" +rm -rf "$repo" + +# 4. No-ops when surface disabled. +repo="$(make_repo)" +write_config "$repo" '{"version":1,"schema_ownership":{"enabled":false,"owned_tables":[]}}' +input="$(jq -nc --arg fp "$repo/supabase/migrations/20260528_001.sql" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"CREATE TABLE chat_sessions (id uuid PRIMARY KEY);"}}')" +actual="$(drive_hook "$HOOK_SCHEMA" "$repo" "$input")" +assert_case "schema-ownership/no-op-when-disabled" "0" "$actual" +rm -rf "$repo" + +# 5. No-ops when config is missing. +repo="$(make_repo)" +input="$(jq -nc --arg fp "$repo/supabase/migrations/20260528_001.sql" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"CREATE TABLE chat_sessions (id uuid PRIMARY KEY);"}}')" +actual="$(drive_hook "$HOOK_SCHEMA" "$repo" "$input")" +assert_case "schema-ownership/no-op-without-config" "0" "$actual" +rm -rf "$repo" + +# 6. Passes Edit (only Write is intercepted). +repo="$(make_repo)" +write_config "$repo" '{"version":1,"schema_ownership":{"enabled":true,"owned_tables":[],"migration_paths":["supabase/migrations"]}}' +input="$(jq -nc --arg fp "$repo/supabase/migrations/20260528_001.sql" \ + '{tool_name:"Edit",tool_input:{file_path:$fp,old_string:"CREATE TABLE chat_sessions",new_string:"-- moved to dojo-os\n-- CREATE TABLE chat_sessions"}}')" +actual="$(drive_hook "$HOOK_SCHEMA" "$repo" "$input")" +assert_case "schema-ownership/passes-edit" "0" "$actual" +rm -rf "$repo" + +# 7. Honors bypass marker. +repo="$(make_repo)" +write_config "$repo" '{"version":1,"schema_ownership":{"enabled":true,"owned_tables":[],"migration_paths":["supabase/migrations"]}}' +input="$(jq -nc --arg fp "$repo/supabase/migrations/20260528_001.sql" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"-- hook-bypass: cross-cutting-schema-ownership\nCREATE TABLE chat_sessions (id uuid PRIMARY KEY);"}}')" +actual="$(drive_hook "$HOOK_SCHEMA" "$repo" "$input")" +assert_case "schema-ownership/honors-bypass-marker" "0" "$actual" +rm -rf "$repo" + +# 8. Defers to local 4a hook. +repo="$(make_repo)" +write_config "$repo" '{"version":1,"schema_ownership":{"enabled":true,"defer_to_local_hook":true,"owned_tables":[],"migration_paths":["supabase/migrations"]}}' +input="$(jq -nc --arg fp "$repo/supabase/migrations/20260528_001.sql" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"CREATE TABLE chat_sessions (id uuid PRIMARY KEY);"}}')" +actual="$(drive_hook "$HOOK_SCHEMA" "$repo" "$input")" +assert_case "schema-ownership/defers-to-local-4a-hook" "0" "$actual" "defer_to_local_hook=true" +rm -rf "$repo" + +# --- version_bumps tests --------------------------------------------------- + +# Helper: build a repo with a Dockerfile pinned to OLD_VERSION + a validator +# script that blocks if NEW - OLD spans more than 1 minor. +make_version_repo() { + local old_version="$1" + local repo + repo="$(make_repo)" + mkdir -p "$repo/scripts" + cat > "$repo/Dockerfile" < "$repo/scripts/check-version.sh" <<'EOF' +#!/usr/bin/env bash +# Toy validator: block if minor delta > 1. +old="$1" new="$2" +o="${old#v}" n="${new#v}" +om=$(echo "$o" | awk -F. '{print $2}') +nm=$(echo "$n" | awk -F. '{print $2}') +delta=$(( nm - om )) +if [ "${delta#-}" -gt 1 ]; then + echo "validator: minor delta $delta > 1 (old=$old new=$new)" >&2 + exit 2 +fi +exit 0 +EOF + chmod +x "$repo/scripts/check-version.sh" + (cd "$repo" && git add -A && git -c user.email=t@t.t -c user.name=t commit -q -m "pin $old_version") + echo "$repo" +} + +# 1. Blocks multi-minor bump via validator. +repo="$(make_version_repo "v2026.4.10")" +write_config "$repo" "$(jq -nc \ + --arg fp "Dockerfile" \ + --arg vr 'openclaw/releases/download/(v[0-9]+\.[0-9]+\.[0-9]+)/' \ + --arg vs "scripts/check-version.sh" \ + '{version:1,version_bumps:[{file_pattern:$fp,version_regex:$vr,validator_script:$vs}]}')" +input="$(jq -nc --arg fp "$repo/Dockerfile" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"FROM scratch\nADD https://github.com/example/openclaw/releases/download/v2026.6.0/openclaw /openclaw\n"}}')" +actual="$(drive_hook "$HOOK_VERSION" "$repo" "$input")" +assert_case "version-bump/blocks-multi-minor" "2" "$actual" "BLOCKED" +rm -rf "$repo" + +# 2. Allows single-minor bump. +repo="$(make_version_repo "v2026.4.10")" +write_config "$repo" "$(jq -nc \ + --arg fp "Dockerfile" \ + --arg vr 'openclaw/releases/download/(v[0-9]+\.[0-9]+\.[0-9]+)/' \ + --arg vs "scripts/check-version.sh" \ + '{version:1,version_bumps:[{file_pattern:$fp,version_regex:$vr,validator_script:$vs}]}')" +input="$(jq -nc --arg fp "$repo/Dockerfile" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"FROM scratch\nADD https://github.com/example/openclaw/releases/download/v2026.5.0/openclaw /openclaw\n"}}')" +actual="$(drive_hook "$HOOK_VERSION" "$repo" "$input")" +assert_case "version-bump/allows-single-minor" "0" "$actual" +rm -rf "$repo" + +# 3. No-ops when no entries configured. +repo="$(make_version_repo "v2026.4.10")" +write_config "$repo" '{"version":1,"version_bumps":[]}' +input="$(jq -nc --arg fp "$repo/Dockerfile" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"FROM scratch\nADD https://github.com/example/openclaw/releases/download/v2026.6.0/openclaw /openclaw\n"}}')" +actual="$(drive_hook "$HOOK_VERSION" "$repo" "$input")" +assert_case "version-bump/no-op-when-empty" "0" "$actual" +rm -rf "$repo" + +# 4. No-ops when validator script missing. +repo="$(make_version_repo "v2026.4.10")" +write_config "$repo" "$(jq -nc \ + --arg fp "Dockerfile" \ + --arg vr 'openclaw/releases/download/(v[0-9]+\.[0-9]+\.[0-9]+)/' \ + --arg vs "scripts/missing.sh" \ + '{version:1,version_bumps:[{file_pattern:$fp,version_regex:$vr,validator_script:$vs}]}')" +input="$(jq -nc --arg fp "$repo/Dockerfile" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"FROM scratch\nADD https://github.com/example/openclaw/releases/download/v2026.6.0/openclaw /openclaw\n"}}')" +actual="$(drive_hook "$HOOK_VERSION" "$repo" "$input")" +assert_case "version-bump/no-op-when-validator-missing" "0" "$actual" "validator missing" +rm -rf "$repo" + +# 5. Defers to local 4a hook. +repo="$(make_version_repo "v2026.4.10")" +write_config "$repo" "$(jq -nc \ + --arg fp "Dockerfile" \ + --arg vr 'openclaw/releases/download/(v[0-9]+\.[0-9]+\.[0-9]+)/' \ + --arg vs "scripts/check-version.sh" \ + '{version:1,version_bumps:[{file_pattern:$fp,version_regex:$vr,validator_script:$vs,defer_to_local_hook:true}]}')" +input="$(jq -nc --arg fp "$repo/Dockerfile" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"FROM scratch\nADD https://github.com/example/openclaw/releases/download/v2026.6.0/openclaw /openclaw\n"}}')" +actual="$(drive_hook "$HOOK_VERSION" "$repo" "$input")" +assert_case "version-bump/defers-to-local-4a-hook" "0" "$actual" "defer_to_local_hook=true" +rm -rf "$repo" + +# 6. Honors bypass marker. +repo="$(make_version_repo "v2026.4.10")" +write_config "$repo" "$(jq -nc \ + --arg fp "Dockerfile" \ + --arg vr 'openclaw/releases/download/(v[0-9]+\.[0-9]+\.[0-9]+)/' \ + --arg vs "scripts/check-version.sh" \ + '{version:1,version_bumps:[{file_pattern:$fp,version_regex:$vr,validator_script:$vs}]}')" +input="$(jq -nc --arg fp "$repo/Dockerfile" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"# hook-bypass: cross-cutting-version-bump\nFROM scratch\nADD https://github.com/example/openclaw/releases/download/v2026.6.0/openclaw /openclaw\n"}}')" +actual="$(drive_hook "$HOOK_VERSION" "$repo" "$input")" +assert_case "version-bump/honors-bypass-marker" "0" "$actual" +rm -rf "$repo" + +# 7. Passes when version unchanged. +repo="$(make_version_repo "v2026.4.10")" +write_config "$repo" "$(jq -nc \ + --arg fp "Dockerfile" \ + --arg vr 'openclaw/releases/download/(v[0-9]+\.[0-9]+\.[0-9]+)/' \ + --arg vs "scripts/check-version.sh" \ + '{version:1,version_bumps:[{file_pattern:$fp,version_regex:$vr,validator_script:$vs}]}')" +input="$(jq -nc --arg fp "$repo/Dockerfile" \ + '{tool_name:"Write",tool_input:{file_path:$fp,content:"FROM scratch\nADD https://github.com/example/openclaw/releases/download/v2026.4.10/openclaw /openclaw\n# cosmetic edit\n"}}')" +actual="$(drive_hook "$HOOK_VERSION" "$repo" "$input")" +assert_case "version-bump/passes-when-version-unchanged" "0" "$actual" +rm -rf "$repo" + +# --- summary --------------------------------------------------------------- +echo "" +CC_TOTAL=$((CC_PASS + CC_FAIL)) +echo "Cross-cutting results: ${CC_PASS} / ${CC_TOTAL} passed" + +if [ "$CC_FAIL" -gt 0 ]; then + echo "" + echo "Cross-cutting failures:" + for line in "${CC_FAIL_DETAILS[@]}"; do + echo " - $line" + done + exit 1 +fi +exit 0 diff --git a/hooks/hooks.json b/hooks/hooks.json index 559c106..fc291d0 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "make-no-mistakes — manifest-driven PreToolUse + PostToolUse hooks. Generic regex rules live in hooks/rules/rules.yaml (compiled to rules.json). Per-repo atomic-design enforcement lives in hooks/atomic/ and reads .atomic-design-rules.json at the consumer repo root (no-op if absent). See hooks/rules/README.md and hooks/atomic/README.md.", + "description": "make-no-mistakes — manifest-driven PreToolUse + PostToolUse hooks. Generic regex rules live in hooks/rules/rules.yaml (compiled to rules.json). Per-repo atomic-design enforcement lives in hooks/atomic/ and reads .atomic-design-rules.json at the consumer repo root (no-op if absent). Cross-cutting Cure 4b hooks (DOJ-4571) live in hooks/cross-cutting/ and read per-repo config from .claude/config/cross-cutting-hooks.json at the consumer-repo root (no-op if absent). See hooks/rules/README.md, hooks/atomic/README.md, and hooks/cross-cutting/README.md.", "hooks": { "PreToolUse": [ { @@ -24,6 +24,21 @@ "type": "command", "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/atomic/pre-atomic.sh", "timeout": 5 + }, + { + "type": "command", + "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/cross-cutting/pre-write-no-cleartext-secret-in-config.sh", + "timeout": 5 + }, + { + "type": "command", + "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/cross-cutting/pre-write-cross-repo-schema-ownership.sh", + "timeout": 5 + }, + { + "type": "command", + "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/cross-cutting/pre-write-version-bump-discipline.sh", + "timeout": 10 } ] } diff --git a/hooks/test-hooks.sh b/hooks/test-hooks.sh index fc13d04..a391bc4 100755 --- a/hooks/test-hooks.sh +++ b/hooks/test-hooks.sh @@ -195,6 +195,35 @@ else echo " SKIP stale-push (hook not executable at $STALE_HOOK)" fi +# ============================================================================= +# Cross-cutting hook tests (DOJ-4571) — Cure 4b +# +# These hooks live in hooks/cross-cutting/ and have a richer surface than the +# manifest rules (consume per-repo config from a synthetic test repo, delegate +# to validator scripts, etc.). The dedicated runner sets up isolated git +# repos per case so the tests are hermetic. +# ============================================================================= +CC_TEST_RUNNER="$HOOKS_DIR/cross-cutting/tests/test-cross-cutting.sh" +if [ -x "$CC_TEST_RUNNER" ]; then + echo "" + echo "Running cross-cutting hook tests…" + CC_TMP_OUT="$(mktemp)" + if bash "$CC_TEST_RUNNER" | tee "$CC_TMP_OUT"; then + : # passed, output already shown + else + FAIL=$((FAIL + 1)) + FAIL_DETAILS+=("cross-cutting test runner returned non-zero (see output above)") + fi + # Sum PASS/FAIL into our totals by counting " PASS cross-cutting / …" + # and " FAIL cross-cutting / …" lines. + CC_PASS_LINES=$(grep -c "^ PASS cross-cutting / " "$CC_TMP_OUT" 2>/dev/null || echo 0) + CC_FAIL_LINES=$(grep -c "^ FAIL cross-cutting / " "$CC_TMP_OUT" 2>/dev/null || echo 0) + PASS=$((PASS + CC_PASS_LINES - 0)) # already counted FAIL_LINES above as runner exit + rm -f "$CC_TMP_OUT" +else + echo " SKIP cross-cutting (test runner not executable at $CC_TEST_RUNNER)" +fi + echo "" TOTAL=$((PASS + FAIL)) echo "Results: ${PASS} / ${TOTAL} passed" diff --git a/openspec/changes/2026-05-28-doj-4571-cure-4b-cross-repo-hooks/design.md b/openspec/changes/2026-05-28-doj-4571-cure-4b-cross-repo-hooks/design.md new file mode 100644 index 0000000..4b436a6 --- /dev/null +++ b/openspec/changes/2026-05-28-doj-4571-cure-4b-cross-repo-hooks/design.md @@ -0,0 +1,262 @@ +# Design — Cure 4b cross-repo hooks (DOJ-4571) + +## Architecture decision: standalone scripts vs. rules.yaml entries + +The toolkit currently has two parallel hook surfaces: + +1. **Manifest-driven rules** in `hooks/rules/rules.yaml` → + `rules.json`, evaluated by `hooks/pre-bash.sh` / `pre-edit.sh` / + `post-slack.sh` via the shared `lib/eval-rule.sh` regex engine. +2. **Standalone hook scripts** in `hooks/atomic/` (`pre-atomic.sh`, + `post-atomic-drift.sh`) — purpose-built scripts registered directly + in `hooks/hooks.json` for behavior that the regex manifest cannot + express (reading `.atomic-design-rules.json`, walking the file tree, + etc.). + +The 3 DOJ-4554 hooks have different fits: + +| Hook | Stateful needs | Best fit | +|------|----------------|----------| +| no-cleartext-secret | Regex match on file content + optional config-driven extra patterns | **Standalone** (config-aware) | +| schema-ownership | Reads per-repo `owned_tables` allowlist; matches against migration SQL content | **Standalone** (config-driven) | +| version-bump-discipline | `git show HEAD:Dockerfile` to extract old version, delegates to repo-local validator script | **Standalone** (git + subprocess) | + +All three need the per-repo config file mechanism, which `rules.yaml` +does not have (`rules.yaml` rules are global — the same regex fires +identically everywhere). The build-time `.private/substitutions.json` +substitution mechanism in `rules.yaml` is the closest analog, but it +substitutes a single literal at toolkit-package build time, not at hook +runtime from the consumer-repo's filesystem. That's the wrong direction +of customization for Cure 4b. + +**Decision:** Ship the 3 hooks as standalone scripts in +`hooks/cross-cutting/`, register them in the existing `hooks/hooks.json` +manifest, and consult `.claude/config/cross-cutting-hooks.json` at the +consumer-repo root at hook-run time. + +This mirrors the `hooks/atomic/` pattern that's already in production +in the toolkit. No new architecture is introduced — just a third +sibling directory next to `atomic/` and `rules/`. + +## File layout + +``` +hooks/ +├── atomic/ (existing) +├── rules/ (existing) +├── lib/ (existing — shared helpers) +├── cross-cutting/ ← NEW +│ ├── README.md +│ ├── lib/ +│ │ ├── load-config.sh # source the consumer-repo config +│ │ └── jq-input.sh # shared jq input parsing +│ ├── pre-write-no-cleartext-secret-in-config.sh +│ ├── pre-write-cross-repo-schema-ownership.sh +│ └── pre-write-version-bump-discipline.sh +├── hooks.json (extended) +├── pre-bash.sh (unchanged) +├── pre-edit.sh (unchanged) +└── post-slack.sh (unchanged) + +schemas/ +└── cross-cutting-hooks.schema.json ← NEW (JSON Schema) + +openspec/changes/ +└── 2026-05-28-doj-4571-cure-4b-cross-repo-hooks/ ← this directory +``` + +## Per-repo config schema + +```jsonc +{ + "$schema": ".../cross-cutting-hooks.schema.json", + "version": 1, // mandatory, gates future schema changes + + "cleartext_secrets": { + "enabled": true, + "defer_to_local_hook": false, // belt-and-braces: true → 4b fail-opens, 4a owns + "extra_block_patterns": [ // each entry is an ERE regex + "MY_CUSTOM_TOKEN" // appended to the built-in pattern set + ], + "extra_cure_suffixes": [ // values cured by these suffixes pass + "_REF", "_VOLUME" // defaults already include _FILE, _PATH + ] + }, + + "schema_ownership": { + "enabled": true, + "defer_to_local_hook": false, // belt-and-braces flag (same semantics) + "owned_tables": [ // table names this repo legitimately migrates + "chat_sessions", "chat_messages" + ], + "migration_paths": [ // path globs treated as migration directories + "supabase/migrations" // defaults to ["supabase/migrations"] when empty + ] + }, + + "version_bumps": [ // each entry: one file under discipline + { + "file_pattern": "Dockerfile", // basename match (supports trailing-component glob) + "version_regex": "openclaw/releases/download/(v[0-9]+\\.[0-9]+\\.[0-9]+)/", + "validator_script": "scripts/check-openclaw-version-bump.sh", + "validator_args": [], // optional args passed after old/new versions + "defer_to_local_hook": false // belt-and-braces flag (per-entry) + } + ] +} +``` + +### Default behavior when config is missing + +- File missing → all three hooks no-op (`exit 0`). This preserves + backward compatibility for repos that haven't opted in. +- File present but `cleartext_secrets.enabled: false` → that hook + no-ops; others still run if their respective `enabled: true`. +- `version: 1` mismatch → all hooks log a warning to stderr and no-op + (fail-open on schema mismatch; never block on infrastructure error). + +### Override semantics + +Built-in patterns are always active when `enabled: true`. Per-repo +config is purely additive (`extra_block_patterns`, `extra_cure_suffixes`). +We deliberately do NOT support pattern removal — if a consumer needs to +suppress a built-in for a specific edit, they use the bypass marker +mechanism inherited from the existing toolkit rules +(`// hook-bypass: ` substring in the proposed content). + +### Belt-and-braces: `defer_to_local_hook` + +Per Andrés' Phase 0 decision, the 4a hooks in `dojo-agent-openclaw-plugin` +stay in place after Cure 4b ships. To prevent rare divergence between +the tighter 4a hook and the looser 4b hook on the same surface, the +config exposes a `defer_to_local_hook` boolean per surface (and per +entry inside the `version_bumps` array). When `true`: + +- The 4b hook for that surface logs an info-stderr message + (`[] info: defer_to_local_hook=true; local 4a hook owns this surface in this repo`) + and `exit 0` immediately after parsing config. +- This happens AFTER the `enabled: true` check, so toggling + `defer_to_local_hook: true` keeps the config block live (visible, + documented, ready for the day the 4a hook is retired) without firing + the 4b enforcement. + +When `false` (default), both hooks fire. They produce the same verdict +by construction (4b generalizes 4a) so double-blocks are harmless; +the only user-visible artifact is two stderr blocks instead of one. + +## Hook anatomy + +Each cross-cutting hook follows this skeleton: + +```bash +#!/bin/bash +set -u +HOOK_NAME="pre-write-…-config.sh" +SURFACE="cleartext_secrets" # which config key gates this hook + +source "$(dirname "$0")/lib/jq-input.sh" # populates TOOL_NAME, FILE_PATH, PROPOSED +source "$(dirname "$0")/lib/load-config.sh" # populates CONFIG_JSON or empty + +# 1. Tool-name filter (Write|Edit|MultiEdit only). +case "$TOOL_NAME" in Write|Edit|MultiEdit) ;; *) exit 0 ;; esac + +# 2. Surface-enabled check. +enabled=$(echo "$CONFIG_JSON" | jq -r ".${SURFACE}.enabled // false") +[[ "$enabled" != "true" ]] && exit 0 + +# 3. File-path filter (per-hook). +# 4. Content extraction (Write.content / Edit.new_string / MultiEdit.edits[]). +# 5. Pattern matching (built-ins + config-extra). +# 6. Block (exit 2) with structured message, or pass (exit 0). +``` + +**Fail-open invariants:** + +- Missing `jq` → warn to stderr, exit 0. +- Malformed config JSON → warn, exit 0. +- Malformed hook input JSON → warn, exit 0. +- Validator script for `version_bumps` missing or non-executable → warn, + exit 0 (never block on tooling absence). +- Unsupported `version` in config → warn, exit 0. + +This matches the DOJ-4554 hooks' fail-open posture (defense in depth, +not a single point of failure). + +## Bypass marker convention + +Each hook ships a kebab-case bypass marker matching its filename: + +- `cross-cutting-cleartext-secret` +- `cross-cutting-schema-ownership` +- `cross-cutting-version-bump` + +Adding `# hook-bypass: cross-cutting-cleartext-secret` or +`// hook-bypass: cross-cutting-cleartext-secret` to the proposed +content (in a comment near the offending line) skips that hook for that +write. The marker is searched in the raw tool_input JSON string the +hook reads on stdin, identical to the bypass mechanism in +`hooks/rules/rules.yaml`. + +## Versioning and rollback + +### Versioning + +- This PR bumps the toolkit from `1.19.0` → `1.20.0` (minor — additive + feature, no breaking change for repos that don't opt in). +- The per-repo config file declares `version: 1`. Future breaking + changes to the config schema bump that to `version: 2` and the hooks + fail-open on unsupported versions (warning to stderr). +- The `$schema` link in the config file pins to `main` branch of the + toolkit repo for editor autocomplete; consumers can pin to a release + tag for reproducibility. + +### Rollback + +Three rollback layers, from least invasive to most: + +1. **Per-surface disable.** Set `.cleartext_secrets.enabled = false` + (or any other top-level key) in the consumer-repo config. The hook + becomes a no-op for that surface only; other surfaces still run. + Effective on next Claude Code session reload. +2. **Per-hook disable via `CLAUDE_DISABLE_PLUGIN_HOOKS`.** The existing + toolkit kill-switch (`CLAUDE_DISABLE_PLUGIN_HOOKS=1`) already + disables all plugin hooks. Cross-cutting hooks honor the same env + var by exit-0-ing at the top of each script (consistent with + `pre-bash.sh` / `pre-edit.sh` behavior). Effective for the current + shell only. +3. **Plugin version pin.** Pin the consumer repo to `1.19.0` (the + pre-Cure-4b version) in the plugin install command. Effective for + that repo until the pin is bumped. + +A full rollback path (delete the config file) is also valid — the +hooks no-op without their config. + +## Test strategy + +Extend `hooks/test-hooks.sh` to cover the new scripts. The existing +script is a thin runner that loops over the rule-tests block in +`rules.json`; cross-cutting hooks need a parallel runner because they +don't go through `rules.json`. Approach: + +- Add a `test-cross-cutting.sh` script in `hooks/cross-cutting/tests/` + that drives each script with synthetic stdin and synthetic + `.claude/config/cross-cutting-hooks.json` fixtures. +- Wire it into `hooks/test-hooks.sh` so `npm run test-hooks` runs both + manifest tests and cross-cutting tests. +- ≥5 fixtures per hook: (a) blocks on positive match, (b) passes on + negative match, (c) no-op when config disabled, (d) no-op when + config file missing, (e) bypass marker honored. + +## What NOT to do + +- Don't move the existing `hooks/atomic/` scripts into + `hooks/cross-cutting/` — atomic-design enforcement is a different + surface (consumes `.atomic-design-rules.json`, not + `.claude/config/cross-cutting-hooks.json`). +- Don't extend `rules.yaml` to support per-repo config — that's a + bigger architectural change that breaks the "manifest rules are + global" invariant. +- Don't ship without the JSON Schema — without it, consumers will + silently misconfigure (typos in keys, missing required fields). +- Don't auto-create the config file in consumer repos — the file's + presence is the opt-in signal. diff --git a/openspec/changes/2026-05-28-doj-4571-cure-4b-cross-repo-hooks/proposal.md b/openspec/changes/2026-05-28-doj-4571-cure-4b-cross-repo-hooks/proposal.md new file mode 100644 index 0000000..cf7cc5a --- /dev/null +++ b/openspec/changes/2026-05-28-doj-4571-cure-4b-cross-repo-hooks/proposal.md @@ -0,0 +1,188 @@ +# Proposal — Cure 4b cross-repo hooks (DOJ-4571) + +## Why + +[DOJ-4554](https://linear.app/dojo-coding/issue/DOJ-4554) shipped **Cure +4a** in `dojo-agent-openclaw-plugin` — 6 PreToolUse hooks lived in the +repo's own `.claude/hooks/` directory, each tightly bound to that repo's +specific schemas (`openclaw.json`, gateway version pins, plugin-side +migration paths). + +The 4-cure thesis (per +[atomic-design-drift-thesis](https://dojocoding.slack.com/archives/C0AS5K6U70E/p1778746023651289) +and DOJ-4064) has two write-time layers: + +- **Cure 4a (repo-level).** `.claude/hooks/` in each repo, repo-specific + patterns. Shipped in DOJ-4554. +- **Cure 4b (toolkit-level).** Generic hooks distributed via + `make-no-mistakes-toolkit` so every repo using the toolkit inherits + cross-cutting defenses without duplicating bash scripts. + +Three of DOJ-4554's six hooks are generalizable beyond the gateway repo: + +1. `pre-write-no-cleartext-secret-in-openclaw-json.sh` — block `${*_KEY}` + / `${*_SECRET}` / `${*_TOKEN}` env-var placeholders that the runtime + would substitute and write to disk in cleartext. Currently scoped to + `openclaw.json`; the same anti-pattern applies to any config file the + runtime templates at boot (Helm values, docker-compose env files, + K8s ConfigMaps, application-config JSON/YAML/TOML). +2. `pre-write-plugin-side-migration.sh` — block creation of SQL + migrations in a repo that has no migration pipeline. Currently + hard-coded to "plugin repo writes any `supabase/migrations/*.sql`"; + the generic form is "this repo doesn't own these tables, don't write + migrations for them" with a per-repo allowlist of owned schemas. +3. `pre-write-openclaw-version-bump-discipline.sh` — block multi-step + version bumps (e.g. `v2026.4.10` → `v2026.5.7` in one PR, which + triggered the DOJ-4061 fix-forward chain). Currently scoped to the + gateway Dockerfile + `scripts/check-openclaw-version-bump.sh`; the + generic form is "any pinned dependency in any file, with per-repo + config naming the validator script". + +The remaining 3 hooks (`pre-write-openclaw-catalog.sh`, +`pre-write-openclaw-required-fields.sh`, +`pre-write-persona-injection-tag-contract.sh`) are too repo-specific to +generalize and stay in the gateway repo as 4a hooks. + +## What + +Ship the 3 generalized cross-cutting hooks via the toolkit's existing +plugin distribution surface. Each repo that consumes the toolkit gets +the hooks for free; per-repo behavior is parametrized through a new +config file consulted at hook-run time. + +### Hooks + +1. **`hooks/cross-cutting/pre-write-no-cleartext-secret-in-config.sh`** + Generalized cleartext-secret guard. Triggers on Write/Edit/MultiEdit + of any config file (JSON, YAML, YML, TOML, env). Blocks high-impact + secret placeholders (`SERVICE_ROLE`, `JWT_SECRET`, `PRIVATE_KEY`, + `CLIENT_SECRET`, `ADMIN_TOKEN`, `ENCRYPTION_KEY`, `SIGNING_SECRET`, + `PASSWORD`) that are not paired with the cure-shape suffix (`_FILE`, + `_PATH`, `_REF` — extensible via config). Per-repo config names + additional placeholder patterns to block and extra cure-shape suffixes + to allow. + +2. **`hooks/cross-cutting/pre-write-cross-repo-schema-ownership.sh`** + Generalized plugin-side-migration guard. Reads + `.claude/config/cross-cutting-hooks.json` at the consumer repo root + for the `schema_ownership` block, which declares either + `owned_tables: ["table_a", "table_b"]` (allowlist; block migrations + that don't touch any owned table) or `migration_paths: []` / + `migration_paths: ["supabase/migrations"]` (path scope; block writes + inside the listed paths when `owned_tables` is empty). Default when + no config: no-op (preserves backward compatibility for repos that + haven't opted in). + +3. **`hooks/cross-cutting/pre-write-version-bump-discipline.sh`** + Generalized single-version-step guard. Reads + `.claude/config/cross-cutting-hooks.json` for the `version_bumps` + array; each entry names a file pattern (`Dockerfile`, + `package.json`, etc.), a version-extractor regex, and a validator + script path. On Write/Edit of a matching file, extracts the old + version (git HEAD blob), the new version (post-edit content), and + delegates to the named validator script. Exit 2 from the validator + blocks. Default when no config: no-op. + +### Per-repo configuration + +New file at consumer-repo root: `.claude/config/cross-cutting-hooks.json`. + +```json +{ + "$schema": "https://raw.githubusercontent.com/DojoCodingLabs/make-no-mistakes-toolkit/main/schemas/cross-cutting-hooks.schema.json", + "version": 1, + "cleartext_secrets": { + "enabled": true, + "defer_to_local_hook": false, + "extra_block_patterns": [], + "extra_cure_suffixes": [] + }, + "schema_ownership": { + "enabled": true, + "defer_to_local_hook": false, + "owned_tables": [], + "migration_paths": [] + }, + "version_bumps": [] +} +``` + +`version: 1` is mandatory and gates future schema changes. Missing top-level +keys default to `enabled: false` (opt-in per surface). + +The `defer_to_local_hook` flag (per-surface, defaults `false`) supports the +**belt-and-braces** coexistence model: when `true`, the 4b hook for that +surface fail-opens with an info-stderr ("local 4a hook covers this surface, +deferring") instead of blocking. This is the explicit escape hatch for repos +that already have a tighter repo-specific 4a hook for the same surface and +don't want the looser 4b version to double-block or diverge. Default `false` +→ both hooks fire (harmless when they agree, which they will by construction +since the 4b hooks are direct generalizations of the 4a sources). + +Per-surface entries for `version_bumps` (an array, not an object) carry +`defer_to_local_hook` as a top-level array-sibling key — see +`schemas/cross-cutting-hooks.schema.json` for the full shape. + +### Distribution + +The hooks live in `hooks/cross-cutting/` inside the toolkit and are +registered in the toolkit's existing `hooks/hooks.json` manifest under +PreToolUse `Write|Edit|MultiEdit`. No consumer-repo `settings.json` +change is required beyond installing the plugin — Claude Code's plugin +hook discovery wires them in automatically. The per-repo config file is +the only opt-in. + +### Validation + +Apply the new hooks to two consumer repos in the same change cycle to +prove the cross-cutting model: + +- `dojo-os` — populate `.claude/config/cross-cutting-hooks.json` with + `owned_tables: ["chat_sessions", "chat_messages", ...]` so the + schema-ownership hook fires when the gateway repo (after its config + is also set) attempts to add a migration for a dojo-os-owned table. +- `dojo-agent-openclaw-plugin` — populate + `.claude/config/cross-cutting-hooks.json` with `defer_to_local_hook: + true` for all three surfaces (the gateway's existing 4a hooks own + enforcement). The config still declares `owned_tables: []`, + `migration_paths: ["supabase/migrations"]`, and the Dockerfile + `version_bumps` entry for documentation and for the eventual day + the 4a hooks are retired — but at PR-time the 4b hooks fail-open + per the defer flag. The 4a hooks + (`pre-write-no-cleartext-secret-in-openclaw-json.sh`, + `pre-write-plugin-side-migration.sh`, + `pre-write-openclaw-version-bump-discipline.sh`) are KEPT in + `.claude/hooks/` per the belt-and-braces decision. + +The two consumer-repo PRs land **after** this toolkit PR merges and +publishes a new minor version (`1.20.0`). They are tracked as separate +deliverables under DOJ-4571 acceptance. + +## Impact + +- `hooks/cross-cutting/` — new directory with 3 generic hook scripts. +- `hooks/hooks.json` — new PreToolUse Write/Edit/MultiEdit entries for + the 3 cross-cutting scripts (registered after the existing + `pre-edit.sh` dispatcher so manifest-driven rules run first). +- `schemas/cross-cutting-hooks.schema.json` — JSON Schema for the + per-repo config file (enables editor autocomplete + CI validation). +- `hooks/cross-cutting/README.md` — user-facing docs (enable / disable + per surface, config examples, override semantics, troubleshooting). +- `hooks/test-hooks.sh` — extend with fixtures for the 3 new scripts + (positive matches, negative matches, no-config no-op, bypass markers). +- `CHANGELOG.md` — new `1.20.0` entry under `[Unreleased]`. +- `package.json` + `.claude-plugin/plugin.json` — version bump + `1.19.0 → 1.20.0`. + +## Out of scope + +- Repo-specific Cure 4a hooks remain in their repos. This PR does not + delete them from the gateway repo; the toolkit hooks are additive. +- The OpenSpec convention hook (DOJ-3849) stays in `dojo-os` — + different concern, not generalizable until more repos adopt + `openspec/changes/`. +- The remaining 3 gateway-specific hooks (catalog, + required-fields, persona-injection-tag) are not candidates for 4b. +- Consumer-repo enablement PRs (`dojo-os` + `dojo-agent-openclaw-plugin` + config files) are tracked as sibling PRs after this one merges; this + PR ships the toolkit-side infrastructure only. diff --git a/openspec/changes/2026-05-28-doj-4571-cure-4b-cross-repo-hooks/tasks.md b/openspec/changes/2026-05-28-doj-4571-cure-4b-cross-repo-hooks/tasks.md new file mode 100644 index 0000000..f4306d6 --- /dev/null +++ b/openspec/changes/2026-05-28-doj-4571-cure-4b-cross-repo-hooks/tasks.md @@ -0,0 +1,81 @@ +# Tasks — Cure 4b cross-repo hooks (DOJ-4571) + +## Toolkit (this PR) + +- [ ] Create `hooks/cross-cutting/` directory with: + - [ ] `README.md` (config schema, enablement, override semantics, rollback) + - [ ] `lib/jq-input.sh` (shared `TOOL_NAME` / `FILE_PATH` / `PROPOSED` extractor) + - [ ] `lib/load-config.sh` (consumer-repo config loader with fail-open) + - [ ] `pre-write-no-cleartext-secret-in-config.sh` + - [ ] `pre-write-cross-repo-schema-ownership.sh` + - [ ] `pre-write-version-bump-discipline.sh` +- [ ] Create `schemas/cross-cutting-hooks.schema.json` (JSON Schema for + consumer-repo config) +- [ ] Extend `hooks/hooks.json` to register the 3 new scripts under + PreToolUse `Write|Edit|MultiEdit` (registered AFTER existing + `pre-edit.sh` so manifest-driven rules fire first) +- [ ] Create `hooks/cross-cutting/tests/test-cross-cutting.sh` with + ≥5 fixtures per hook +- [ ] Wire `test-cross-cutting.sh` into `hooks/test-hooks.sh` +- [ ] Update `package.json` `"files"` array if needed (verify `hooks/` + already covers `hooks/cross-cutting/` and `schemas/` is included) +- [ ] Bump `package.json` + `.claude-plugin/plugin.json` from + `1.19.0 → 1.20.0` +- [ ] Append `[1.20.0]` entry to `CHANGELOG.md` +- [ ] Update `README.md` version line if it references the version +- [ ] Run `npm run build` (toolkit lib build) +- [ ] Run `npm run test-hooks` (must include the new tests) +- [ ] Commit (Conventional, scope `hooks`), push, open PR targeting `main` +- [ ] Tag dojo-code-reviewer with `@dojo-code-reviewer review` +- [ ] Loop until reviewer 5/5 +- [ ] HITL gate: `pr-open-state` — request user approval before merging +- [ ] `gh pr merge --squash --delete-branch` after approval + +## Consumer-repo enablement (sibling PRs, after toolkit PR merges) + +These are tracked under DOJ-4571 acceptance ("At least 2 repos consuming +the toolkit hooks") but live in their own PRs in their own repos. + +### `dojo-os` PR + +- [ ] Add `.claude/config/cross-cutting-hooks.json` with: + - `cleartext_secrets.enabled: true` (built-in defaults sufficient) + - `schema_ownership.enabled: true`, `owned_tables: []`, `migration_paths: ["supabase/migrations"]` + - `version_bumps: []` (no equivalent gateway-pin to discipline) +- [ ] Add doc reference in `docs/repo-health/hooks.md` (or equivalent) + pointing at the toolkit README +- [ ] Verify by attempting a write of a migration touching an unowned + table (synthetic test) — hook must block + +### `dojo-agent-openclaw-plugin` PR + +- [ ] Add `.claude/config/cross-cutting-hooks.json` with: + - `cleartext_secrets.enabled: true` + - `schema_ownership.enabled: true`, `owned_tables: []`, + `migration_paths: ["supabase/migrations"]` (block ANY migration + write — gateway repo has no pipeline) + - `version_bumps: [{ file_pattern: "Dockerfile", ... validator_script: + "scripts/check-openclaw-version-bump.sh" }]` +- [ ] After confirming the toolkit cross-cutting hooks cover the same + ground, delete the now-redundant local Cure 4a hooks: + - `.claude/hooks/pre-write-no-cleartext-secret-in-openclaw-json.sh` + (replaced by toolkit `cleartext_secrets`) + - `.claude/hooks/pre-write-plugin-side-migration.sh` + (replaced by toolkit `schema_ownership` with empty `owned_tables`) + - `.claude/hooks/pre-write-openclaw-version-bump-discipline.sh` + (replaced by toolkit `version_bumps` entry) +- [ ] Update `docs/repo-health/hooks.md` accordingly + +HITL gate before opening these PRs: `cross-repo-rollout` — confirm +coordination strategy (one combined effort vs. staggered, who reviews). + +## Acceptance criteria mapping + +| AC item | Tracked by | +|---------|------------| +| Design proposal in `openspec/changes/` | this directory | +| 3 generic hooks generalized + tested against ≥2 repo fixtures | `hooks/cross-cutting/*.sh` + tests | +| Per-repo config schema documented | `schemas/cross-cutting-hooks.schema.json` + `hooks/cross-cutting/README.md` | +| ≥2 repos consuming toolkit hooks | dojo-os PR + dojo-agent-openclaw-plugin PR | +| Versioning + rollback documented | design.md "Versioning and rollback" + README | diff --git a/package.json b/package.json index 2181c05..21573ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lapc506/make-no-mistakes", - "version": "1.21.0", + "version": "1.22.0", "description": "The disciplined dev lifecycle — implement issues, review PRs, sync releases, test E2E, manage sessions, stash secrets, and enforce manifest-driven tool-call hooks (no SSH+DB, no manual prod, no minified build, no secret leaks, Slack format). OpenCode + Claude Code plugin.", "type": "module", "main": "./dist/index.js", @@ -24,6 +24,8 @@ "skills/", "scripts/", "hooks/", + "schemas/", + "references/", "slack-config.example.json", "README.md", "LICENSE" diff --git a/schemas/cross-cutting-hooks.schema.json b/schemas/cross-cutting-hooks.schema.json new file mode 100644 index 0000000..0d29a50 --- /dev/null +++ b/schemas/cross-cutting-hooks.schema.json @@ -0,0 +1,116 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/DojoCodingLabs/make-no-mistakes-toolkit/main/schemas/cross-cutting-hooks.schema.json", + "title": "make-no-mistakes cross-cutting hooks — per-repo config", + "description": "Per-repo configuration for the toolkit's Cure 4b cross-cutting PreToolUse hooks. Lives at the consumer-repo root as .claude/config/cross-cutting-hooks.json. File absence → all 4b hooks no-op (opt-in). DOJ-4571.", + "type": "object", + "additionalProperties": false, + "required": ["version"], + "properties": { + "$schema": { + "type": "string", + "description": "Pin the schema version for editor autocomplete and CI validation." + }, + "version": { + "type": "integer", + "const": 1, + "description": "Schema version. Mandatory. Hooks fail-open with a stderr warning on unknown versions." + }, + "cleartext_secrets": { + "type": "object", + "additionalProperties": false, + "description": "Configures pre-write-no-cleartext-secret-in-config.sh.", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Master switch for this surface. False (or missing) → hook no-ops." + }, + "defer_to_local_hook": { + "type": "boolean", + "default": false, + "description": "Belt-and-braces: when true, this 4b hook fail-opens with an info-stderr message because a tighter local 4a hook owns enforcement for this surface in this repo." + }, + "extra_block_patterns": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "ERE regexes for additional env-var name patterns to treat as high-impact secrets. Appended to the built-in pattern set." + }, + "extra_cure_suffixes": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Additional env-var name suffixes (e.g. _REF, _VOLUME) treated as evidence of the secret-volume cure pattern. Placeholders ending in any of these suffixes are allowed. Built-in defaults: _FILE, _PATH." + } + } + }, + "schema_ownership": { + "type": "object", + "additionalProperties": false, + "description": "Configures pre-write-cross-repo-schema-ownership.sh.", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Master switch. False (or missing) → hook no-ops." + }, + "defer_to_local_hook": { + "type": "boolean", + "default": false, + "description": "Belt-and-braces flag — same semantics as cleartext_secrets." + }, + "owned_tables": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" + }, + "default": [], + "description": "Table names this repo legitimately migrates. Empty array combined with non-empty migration_paths → every write under those paths is blocked (the gateway-plugin pattern, where the repo has no migration pipeline at all)." + }, + "migration_paths": { + "type": "array", + "items": { "type": "string" }, + "default": ["supabase/migrations"], + "description": "Path globs treated as migration directories. A Write/Edit inside any of these triggers ownership checking against owned_tables." + } + } + }, + "version_bumps": { + "type": "array", + "default": [], + "description": "Configures pre-write-version-bump-discipline.sh. Each entry guards one file pattern + delegates to one validator script.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["file_pattern", "version_regex", "validator_script"], + "properties": { + "file_pattern": { + "type": "string", + "description": "Basename pattern (case-sensitive). Supports trailing-component glob, e.g. 'Dockerfile', '**/Dockerfile', 'package.json'." + }, + "version_regex": { + "type": "string", + "description": "ERE regex with a single capturing group that extracts the version literal from a matching line of the file content. Example: 'openclaw/releases/download/(v[0-9]+\\.[0-9]+\\.[0-9]+)/'." + }, + "validator_script": { + "type": "string", + "description": "Path (relative to the consumer-repo root) of an executable script. Invoked with two arguments: . Exit 2 → 4b hook blocks. Exit 0 → 4b hook passes. Any other exit code → 4b hook warns and fail-opens." + }, + "validator_args": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Optional extra args passed after old_version + new_version." + }, + "defer_to_local_hook": { + "type": "boolean", + "default": false, + "description": "Belt-and-braces flag — per-entry. true → this entry's 4b enforcement defers to the local 4a hook." + } + } + } + } + } +} From 4e441b81cf086cfe848472f249f2eef9b4b387d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Pe=C3=B1a?= Date: Fri, 29 May 2026 17:51:51 -0600 Subject: [PATCH 2/2] fix(hooks): cross-platform sed + cleaner regex quoting (DOJ-4571 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address dojo-code-reviewer findings on PR #32 (2 P2 + 1 P3, 0 blockers). P2 — schema_ownership hook crashes on BSD sed (macOS): pre-write-cross-repo-schema-ownership.sh used `sed -E '...//I'` to match the TABLE keyword case-insensitively while stripping the IF NOT EXISTS clause. The `/I` flag is a GNU extension; BSD sed on macOS errors out with "unknown option to \`s'". Replaced with explicit bracket-class spelling (`[Tt][Aa][Bb][Ll][Ee]` etc.) which is ERE-portable across both implementations. `grep -i` upstream stays as-is — it's portable. P2 + P3 — cleartext-secret hook regex quoting: HIGH_IMPACT_RE + CURE_RE used quad-backslash escaping inside double quotes ("\\\\\\$\\\\{..."), which is fragile across shell versions and hard to maintain. Switched to single-quote-plus-interpolation convention: the static literal part stays single-quoted (no bash re-interpretation), the dynamic ${TAILS}/${CURE_SUFFIXES} group is interpolated via a separate double-quoted segment. Significantly more readable, matches the rest of the toolkit's bash codebase. Tests: 248/248 still passing (23 cross-cutting + 225 manifest). No behavior change — these are pure portability and readability fixes. Refs: DOJ-4571, PR #32 review by dojo-code-reviewer. Created by Claude Code on behalf of @lapc506 Co-Authored-By: Claude Opus 4.7 --- .../pre-write-cross-repo-schema-ownership.sh | 10 +++++++++- .../pre-write-no-cleartext-secret-in-config.sh | 12 ++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/hooks/cross-cutting/pre-write-cross-repo-schema-ownership.sh b/hooks/cross-cutting/pre-write-cross-repo-schema-ownership.sh index 0f8e294..7392212 100755 --- a/hooks/cross-cutting/pre-write-cross-repo-schema-ownership.sh +++ b/hooks/cross-cutting/pre-write-cross-repo-schema-ownership.sh @@ -143,9 +143,17 @@ fi # Capture identifiers after CREATE TABLE, ALTER TABLE, DROP TABLE, RENAME TABLE. # Strip optional IF NOT EXISTS / IF EXISTS, optional schema prefix (`public.`), # and trailing punctuation. ERE-only to stay portable. +# +# Portability note (DOJ-4571 review P2, dojo-code-reviewer): the previous +# implementation used `sed -E '...//I'` to match the TABLE keyword case- +# insensitively. The `/I` flag is a GNU extension; BSD sed on macOS errors +# out with "unknown option to `s'". Replaced with explicit bracket-class +# spelling (`[Tt][Aa][Bb][Ll][Ee]`) which is ERE-portable across both +# implementations. `grep -i` is portable for the upstream extraction so it +# stays as-is. REFERENCED_TABLES=$(echo "$PROPOSED" \ | grep -oiE '(CREATE|ALTER|DROP|RENAME)[[:space:]]+TABLE[[:space:]]+(IF[[:space:]]+(NOT[[:space:]]+)?EXISTS[[:space:]]+)?[A-Za-z_][A-Za-z0-9_."]*' \ - | sed -E 's/.*TABLE[[:space:]]+(IF[[:space:]]+(NOT[[:space:]]+)?EXISTS[[:space:]]+)?//I' \ + | sed -E 's/.*[Tt][Aa][Bb][Ll][Ee][[:space:]]+([Ii][Ff][[:space:]]+([Nn][Oo][Tt][[:space:]]+)?[Ee][Xx][Ii][Ss][Tt][Ss][[:space:]]+)?//' \ | sed -E 's/"//g' \ | sed -E 's/^[A-Za-z_][A-Za-z0-9_]*\.//' \ | sed -E 's/[^A-Za-z0-9_].*$//' \ diff --git a/hooks/cross-cutting/pre-write-no-cleartext-secret-in-config.sh b/hooks/cross-cutting/pre-write-no-cleartext-secret-in-config.sh index 9fd400b..3731384 100755 --- a/hooks/cross-cutting/pre-write-no-cleartext-secret-in-config.sh +++ b/hooks/cross-cutting/pre-write-no-cleartext-secret-in-config.sh @@ -92,7 +92,14 @@ if [[ -n "$EXTRA_PATTERNS_PIPE" ]]; then TAILS="${TAILS}|${EXTRA_PATTERNS_PIPE}" fi -HIGH_IMPACT_RE="\\\$\\{[A-Z_]*(${TAILS})[A-Z0-9_]*\\}" +# Quoting note (DOJ-4571 review P2 + P3, dojo-code-reviewer): use single +# quotes around the static literal part of the regex so backslashes pass +# straight through to grep without bash re-interpretation. The dynamic +# ${TAILS} group is interpolated via a separate double-quoted segment. +# This is significantly more readable than the previous quad-backslash +# escaping (\\\$\\{...) and matches the convention in the rest of the +# toolkit's bash codebase. +HIGH_IMPACT_RE='\$\{[A-Z_]*('"${TAILS}"')[A-Z0-9_]*\}' # ─── Cure-shape suffixes (placeholders ending in these are OK) ────────────── BUILTIN_SUFFIXES="_FILE|_PATH" @@ -106,7 +113,8 @@ if [[ -n "$EXTRA_SUFFIXES_PIPE" ]]; then CURE_SUFFIXES="${CURE_SUFFIXES}|${EXTRA_SUFFIXES_PIPE}" fi -CURE_RE="(${CURE_SUFFIXES})\\}$" +# Same single-quote-plus-interpolation convention as HIGH_IMPACT_RE. +CURE_RE='('"${CURE_SUFFIXES}"')\}$' # ─── Scan proposed content ────────────────────────────────────────────────── MATCHES=""