|
| 1 | +# Cross-cutting PreToolUse hooks — Cure 4b (DOJ-4571) |
| 2 | + |
| 3 | +These hooks ship as part of the `make-no-mistakes` toolkit and apply to |
| 4 | +every repo that installs the plugin. They are **opt-in per repo** via a |
| 5 | +single config file at the consumer-repo root. |
| 6 | + |
| 7 | +| File | Purpose | Surface | Source | |
| 8 | +|------|---------|---------|--------| |
| 9 | +| `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`. | |
| 10 | +| `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`. | |
| 11 | +| `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`. | |
| 12 | + |
| 13 | +## Opt-in |
| 14 | + |
| 15 | +Create `.claude/config/cross-cutting-hooks.json` at your repo root. File |
| 16 | +absence → all three hooks no-op. Minimal opt-in: |
| 17 | + |
| 18 | +```json |
| 19 | +{ |
| 20 | + "$schema": "https://raw.githubusercontent.com/DojoCodingLabs/make-no-mistakes-toolkit/main/schemas/cross-cutting-hooks.schema.json", |
| 21 | + "version": 1, |
| 22 | + "cleartext_secrets": { "enabled": true } |
| 23 | +} |
| 24 | +``` |
| 25 | + |
| 26 | +Full example (every surface enabled, every override demonstrated): |
| 27 | + |
| 28 | +```json |
| 29 | +{ |
| 30 | + "$schema": "https://raw.githubusercontent.com/DojoCodingLabs/make-no-mistakes-toolkit/main/schemas/cross-cutting-hooks.schema.json", |
| 31 | + "version": 1, |
| 32 | + "cleartext_secrets": { |
| 33 | + "enabled": true, |
| 34 | + "defer_to_local_hook": false, |
| 35 | + "extra_block_patterns": ["MY_CUSTOM_TOKEN"], |
| 36 | + "extra_cure_suffixes": ["_REF", "_VOLUME"] |
| 37 | + }, |
| 38 | + "schema_ownership": { |
| 39 | + "enabled": true, |
| 40 | + "defer_to_local_hook": false, |
| 41 | + "owned_tables": ["chat_sessions", "chat_messages"], |
| 42 | + "migration_paths": ["supabase/migrations"] |
| 43 | + }, |
| 44 | + "version_bumps": [ |
| 45 | + { |
| 46 | + "file_pattern": "Dockerfile", |
| 47 | + "version_regex": "openclaw/releases/download/(v[0-9]+\\.[0-9]+\\.[0-9]+)/", |
| 48 | + "validator_script": "scripts/check-openclaw-version-bump.sh", |
| 49 | + "validator_args": [], |
| 50 | + "defer_to_local_hook": false |
| 51 | + } |
| 52 | + ] |
| 53 | +} |
| 54 | +``` |
| 55 | + |
| 56 | +JSON Schema for editor autocomplete + CI validation: |
| 57 | +[`schemas/cross-cutting-hooks.schema.json`](../../schemas/cross-cutting-hooks.schema.json). |
| 58 | + |
| 59 | +## How each hook works |
| 60 | + |
| 61 | +### `cleartext_secrets` |
| 62 | + |
| 63 | +Triggers on `Write|Edit|MultiEdit` of any file ending in `.json`, |
| 64 | +`.jsonc`, `.yaml`, `.yml`, `.toml`, `.env`, or starting with `.env.`. |
| 65 | + |
| 66 | +Built-in blocked tails (case-sensitive, after optional `[A-Z_]*` prefix |
| 67 | +and before optional `[A-Z0-9_]*` suffix): |
| 68 | + |
| 69 | +- `SERVICE_ROLE` |
| 70 | +- `JWT_SECRET` |
| 71 | +- `PRIVATE_KEY` |
| 72 | +- `CLIENT_SECRET` |
| 73 | +- `ADMIN_TOKEN` |
| 74 | +- `PASSWORD` |
| 75 | +- `ENCRYPTION_KEY` |
| 76 | +- `SIGNING_SECRET` |
| 77 | + |
| 78 | +Built-in cure-shape suffixes (placeholders ending in these PASS): |
| 79 | + |
| 80 | +- `_FILE` |
| 81 | +- `_PATH` |
| 82 | + |
| 83 | +Use `extra_block_patterns` and `extra_cure_suffixes` to extend both |
| 84 | +sets. The hook only ADDS to built-ins — there is no removal API; use |
| 85 | +the bypass marker for one-off overrides. |
| 86 | + |
| 87 | +### `schema_ownership` |
| 88 | + |
| 89 | +Triggers on `Write` only (`Edit`/`MultiEdit` on existing migrations is |
| 90 | +allowed — typical for cleanup/annotation of historical artifacts). |
| 91 | +Fires only when `FILE_PATH` lives under one of `migration_paths` |
| 92 | +(default `["supabase/migrations"]`) AND ends in `.sql`. |
| 93 | + |
| 94 | +Behavior depends on `owned_tables`: |
| 95 | + |
| 96 | +- `[]` → blocks every match (the gateway pattern: no migrations belong |
| 97 | + in this repo at all) |
| 98 | +- `["table_a", ...]` → extracts `CREATE/ALTER/DROP/RENAME TABLE <name>` |
| 99 | + identifiers from the proposed content and blocks if any referenced |
| 100 | + name is not in the allowlist |
| 101 | + |
| 102 | +Conservative SQL parsing: only the four statement types above. Migrations |
| 103 | +that only define functions, views, policies, or data are allowed (the |
| 104 | +ownership check has no signal to act on). |
| 105 | + |
| 106 | +### `version_bumps` |
| 107 | + |
| 108 | +Triggers on `Write|Edit|MultiEdit` of any file whose basename matches a |
| 109 | +configured `file_pattern`. For each match: |
| 110 | + |
| 111 | +1. Extract `OLD_VERSION` from the git HEAD blob via the configured |
| 112 | + `version_regex` (single capture group). |
| 113 | +2. Extract `NEW_VERSION` from the proposed content via the same regex. |
| 114 | +3. If both extract, differ, and the `validator_script` is executable, |
| 115 | + invoke `<validator_script> <OLD_VERSION> <NEW_VERSION> [extra_args]`. |
| 116 | +4. Validator exit codes: |
| 117 | + - `0` → pass |
| 118 | + - `2` → block (the validator's stderr is shown above the hook's block message) |
| 119 | + - any other → warn + fail-open (defense in depth, never block on validator infrastructure) |
| 120 | + |
| 121 | +If `OLD_VERSION` cannot be extracted (e.g. file is new), the hook passes |
| 122 | +silently — the rule applies to BUMPS, not introductions. |
| 123 | + |
| 124 | +## Bypass markers |
| 125 | + |
| 126 | +Each hook honors a kebab-case bypass marker matching its surface. Add |
| 127 | +the marker as a comment near the offending content: |
| 128 | + |
| 129 | +- `# hook-bypass: cross-cutting-cleartext-secret` |
| 130 | +- `# hook-bypass: cross-cutting-schema-ownership` |
| 131 | +- `# hook-bypass: cross-cutting-version-bump` |
| 132 | + |
| 133 | +Three comment leaders are accepted so the marker fits whichever syntax |
| 134 | +the target file uses: |
| 135 | + |
| 136 | +| Leader | Used in | |
| 137 | +|--------|---------| |
| 138 | +| `#` | Bash / YAML / TOML / Python (SQL also accepts this) | |
| 139 | +| `//` | JSON-with-comments / JS / TS / C-family | |
| 140 | +| `--` | SQL / Haskell / Lua | |
| 141 | + |
| 142 | +## Belt-and-braces with local 4a hooks |
| 143 | + |
| 144 | +If your repo already has a tighter `.claude/hooks/`-level 4a hook for |
| 145 | +one of these surfaces (the canonical case is |
| 146 | +`dojo-agent-openclaw-plugin`), set `defer_to_local_hook: true` on the |
| 147 | +matching config block. The 4b hook logs an info-stderr and fail-opens; |
| 148 | +the 4a hook owns enforcement. This lets the config block stay live |
| 149 | +(visible, documented, ready for the day 4a is retired) without firing |
| 150 | +the looser 4b version. |
| 151 | + |
| 152 | +Default `false` → both hooks fire. They produce the same verdict by |
| 153 | +construction (4b generalizes 4a) so double-blocks are harmless; the |
| 154 | +only user-visible artifact is two stderr blocks instead of one. |
| 155 | + |
| 156 | +## Disabling |
| 157 | + |
| 158 | +Three layers, least to most invasive: |
| 159 | + |
| 160 | +1. **Per surface.** Set `enabled: false` (or omit the key) in the |
| 161 | + per-repo config. |
| 162 | +2. **All toolkit hooks for the current shell.** Set |
| 163 | + `CLAUDE_DISABLE_PLUGIN_HOOKS=1` in your environment. |
| 164 | +3. **Plugin pin.** Pin the consumer repo to the pre-Cure-4b toolkit |
| 165 | + version (`1.19.0`) in your plugin install command. |
| 166 | + |
| 167 | +A full rollback (delete the config file) is also valid — the hooks |
| 168 | +no-op without their config. |
| 169 | + |
| 170 | +## Fail-open invariants |
| 171 | + |
| 172 | +Every hook exits 0 (pass) silently when any of these are true: |
| 173 | + |
| 174 | +- `CLAUDE_DISABLE_PLUGIN_HOOKS=1` |
| 175 | +- `jq` not on PATH |
| 176 | +- Hook input JSON malformed |
| 177 | +- Config file missing |
| 178 | +- Config file present but unsupported `version` |
| 179 | +- Per-surface `enabled` is false |
| 180 | +- Per-surface `defer_to_local_hook` is true (with an info-stderr line) |
| 181 | +- `version_bumps`: validator script missing/non-executable |
| 182 | + |
| 183 | +This matches the existing toolkit hook posture (defense in depth, never |
| 184 | +a single point of failure). |
| 185 | + |
| 186 | +## Tests |
| 187 | + |
| 188 | +See `hooks/cross-cutting/tests/test-cross-cutting.sh` — invoked by |
| 189 | +`npm run test-hooks` alongside the manifest-driven `rules.json` tests. |
| 190 | +Coverage: ≥5 fixtures per hook (block on positive, pass on negative, |
| 191 | +no-op when disabled, no-op when config missing, bypass marker honored). |
| 192 | + |
| 193 | +## Reference |
| 194 | + |
| 195 | +- DOJ-4571 — this work (Cure 4b) |
| 196 | +- DOJ-4554 — Cure 4a foundation in `dojo-agent-openclaw-plugin` |
| 197 | +- DOJ-4524 — 15-day persistence-freeze incident that motivated the |
| 198 | + schema-ownership hook |
| 199 | +- DOJ-4208 — service-role key cleartext-leak incident that motivated |
| 200 | + the cleartext-secret hook |
| 201 | +- DOJ-4061 — gateway version-bump fix-forward chain that motivated |
| 202 | + the version-bump hook |
| 203 | +- DOJ-4064 — 4-cure thesis |
0 commit comments