claudemd-cli 0.17.1 → 0.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,133 @@ All notable changes to the `claudemd` plugin. This changelog tracks plugin artif
8
8
  - **Canonical spec version source**: `spec/CLAUDE.md` top-line title (`# AI-CODING-SPEC vX.Y.Z — Core`) + `spec/CLAUDE-changelog.md` top `##` entry.
9
9
  - **Plugin semver vs spec semver** are independent: plugin patch (0.2.0 → 0.2.1) may ship when spec is unchanged (this release); plugin minor (0.1.9 → 0.2.0) ships when spec minor updates (v0.2.0 shipped spec v6.10.0).
10
10
 
11
+ ## [0.17.3] - 2026-05-14
12
+
13
+ **Patch — fix: CRITICAL — `pre-bash-safety-check.sh` multi-line CMD §8 SAFETY bypass closure. Multi-line bash commands containing `rm -rf $UNSAFE_VAR` on any line other than the first silently passed the hook; the matching multi-line `npx pkg@PIN` case wrongly denied as unpinned. One root cause, two opposite-direction defects.**
14
+
15
+ ### Background
16
+
17
+ v0.17.2 shipped the bare-`rm -rf $HOME` whitelist closure (Bug 5 / Steam-disaster class). End-to-end dogfood Round 4 — running the *just-shipped* hook against realistic multi-line bash scripts — surfaced that the §8 SAFETY hook's pattern extraction was completely broken for any multi-line CMD where the rm/npx call wasn't on line 1. The fix landed in v0.17.2 reduced bare-`$HOME` to a denied case for *single-line* commands but the hook was systematically failing to fire on multi-line ones.
18
+
19
+ Minimal repro for the CRITICAL leg (pre-fix):
20
+
21
+ ```
22
+ CMD = "TMP=$(mktemp -d)\nrm -rf $UNSAFE_VAR"
23
+ → hook output: <empty> (no decision, allow)
24
+ ```
25
+
26
+ The single-line equivalent `rm -rf $UNSAFE_VAR` correctly denied. The multi-line form bypassed §8 SAFETY entirely — the exact spec rule the hook exists to enforce. An agent that issues a heredoc-style bash command with any setup line before the destructive call would slip through.
27
+
28
+ Reverse leg (false-DENY, also pre-fix):
29
+
30
+ ```
31
+ CMD = "TMP=$(mktemp -d)\nnpx prettier@3.0.0 --check ."
32
+ → deny: "npx unpinned package: TMP=$(mktemp"
33
+ ```
34
+
35
+ Pinned `prettier@3.0.0` flagged as `TMP=$(mktemp` unpinned. Innocent scripts denied; users would either disable the hook or wrap every npx call in a one-liner — eroding §8 trust.
36
+
37
+ ### Root cause
38
+
39
+ `pre-bash-safety-check.sh` extracted rm-target and npx-package via per-line sed:
40
+
41
+ ```bash
42
+ rm_tail=$(echo "$SANITIZED_CMD" | sed -E "s/.*${RM_FLAG_REGEX}//" | head -n1)
43
+ npx_tail=$(echo "$SANITIZED_CMD" | sed -E "s/.*${NPX_REGEX}//" | ...)
44
+ ```
45
+
46
+ `sed -E` processes line-by-line. Lines without `rm`/`npx` passed through unchanged; only the rm/npx line had its prefix stripped. Downstream:
47
+
48
+ - `head -n1` (rm path): always returned line 1 regardless of where the rm sat. If rm was on line ≥2, line 1 had no rm content → `rm_target` empty → deny path never fired. **§8 SAFETY bypass.**
49
+ - `for tok in $npx_tail` (npx path): bash word-splitting iterated tokens from ALL lines. Line 1's first token (typically `TMP=$(mktemp`) became `pkg_token` → flagged as unpinned. **False deny on innocent scripts.**
50
+
51
+ Sanitize already stripped heredoc bodies / line comments / quoted bodies, so the remaining newlines were between *independent command lines* — safe to flatten.
52
+
53
+ ### What changed
54
+
55
+ - `[fix CRITICAL]` **`hooks/pre-bash-safety-check.sh`** — new `SANITIZED_CMD_FLAT=$(printf '%s' "$SANITIZED_CMD" | tr '\n' ' ')` computed once after sanitize. Both extraction passes (`rm_tail` and `npx_tail`) now read `SANITIZED_CMD_FLAT` instead of `SANITIZED_CMD`. The `head -n1` on the rm extraction is removed (no longer needed; whole flat string contains all targets after the sed strip).
56
+
57
+ - `[test]` **`tests/fixtures/bash-safety/corpus.tsv`** +7 multi-line cases (`__NL__` LF marker per corpus convention):
58
+ - `deny`: multi-line `rm -rf $UNSAFE` (was false-ALLOW — the CRITICAL leg).
59
+ - `deny`: multi-line bare `rm -rf $HOME` on line 2 (v0.17.2 Bug 5 cross-check — confirms the prior fix is intact when rm is on a later line).
60
+ - `pass`: multi-line `rm -rf $HOME/cache` on line 2 (whitelist subpath survives multi-line).
61
+ - `pass`: multi-line `npx prettier@3.0.0` on line 2 (was false-DENY).
62
+ - `pass`: multi-line `npx ./node_modules/.bin/foo` on line 2 (local path survives).
63
+ - `deny`: multi-line `npx prettier` unpinned on line 2 (real catch preserved).
64
+ - `deny`: `rm -rf $UNSAFE` on line 3 of 5 (cross-checks the head -n1 removal — earlier lines no longer hide a deeper rm).
65
+
66
+ Corpus 69 → 76. `bash tests/hooks/pre-bash-safety.test.sh` 76/76.
67
+
68
+ - `[fix LOW]` **`commands/claudemd-rules.md`** — `--verbose` was documented as if it were a script flag (`$ARGS --verbose`), but `scripts/hard-rules-audit.js` rejects it as `Unknown argument`. The command's intent was for `--verbose` to be an agent presentation directive (show full `rules` array in output), not a script flag. A user typing `/claudemd-rules --verbose` would have `CLAUDEMD_RULES_DAYS="--verbose"` set as env, then the script would crash with `--days requires a positive integer (got '--verbose')`. The md now documents a 4-row parsing table (`""` / `90` / `--verbose` / `90 --verbose` → env + agent output) that the LLM follows to split numeric and presentation tokens cleanly.
69
+
70
+ ### Why patch (not minor)
71
+
72
+ Restores documented §8 SAFETY behavior — the hook was supposed to deny `rm -rf $VAR without validating VAR` per spec §8 (immutable), but multi-line CMDs bypassed it. CHANGELOG `fix:` not `change:` or `feat:`. No new hook surface, no new flag, no new spec rule. The corpus addition is regression-anchor only.
73
+
74
+ Severity disclosure: the CRITICAL leg means any user on plugin v0.17.2 or earlier had a §8 SAFETY hole for multi-line bash CMDs that included `rm -rf $UNSAFE_VAR` on lines ≥2. We have no telemetry indicating active exploitation, but the spec-coverage gap is real — recommend immediate update.
75
+
76
+ ### Tests
77
+
78
+ - `bash tests/run-all.sh`: 411 node-test + 2 integration suites pass.
79
+ - `bash tests/hooks/pre-bash-safety.test.sh`: 76/76 (was 69/69 after v0.17.2; +7 multi-line cases this release).
80
+ - Manual end-to-end: 6-scenario rm/npx matrix (multi-line + single-line × bare/subpath/glob/pinned/local/unpinned) verified post-fix; v0.17.2's bare-`$HOME` denials still fire correctly when `$HOME` sits on line ≥2.
81
+
82
+ ### Operator notes
83
+
84
+ - Update path: plugin marketplace update + `/reload-plugins`. Installed plugin picks up the new hook body via `${CLAUDE_PLUGIN_ROOT}` expansion.
85
+ - **If you have v0.17.2 or earlier, agent-issued multi-line bash with `rm -rf $UNSAFE` was silently allowed**. Audit your `~/.claude/logs/claudemd.jsonl` for the absence of `§8-rm-rf-var` rows on sessions where you expect the hook should have fired.
86
+ - The per-cmd escape hatch `[allow-rm-rf-var]` / `[allow-npx-unpinned]` continues to work in multi-line CMDs (already operates on raw `$CMD`, not sanitized).
87
+
88
+ ## [0.17.2] - 2026-05-14
89
+
90
+ **Patch — fix: 6-bug end-to-end dogfood pass. §8 SAFETY `rm -rf $VAR` whitelist closure (closes bare-`$HOME` Steam-disaster class); `transcript-vocab-scan` multi-paragraph false-negative; CLI `lint` whitespace-in-positional misclassified as path; CLI `audit` silent-OK on non-JSONL files; manifest `spec_version` drift v6.11.12 → v6.11.16; `update.js` raw Node stack trace on bogus env value.**
91
+
92
+ ### Background
93
+
94
+ End-to-end agent dogfood across 3 rounds: real user paths through bin CLI, all 17 hooks, install/update/uninstall flows. 411 unit + 2 integration tests as baseline; added 18 regression tests across the 6 fixes. Highest-severity finding: a CRITICAL whitelist gap in `pre-bash-safety-check.sh` that allowed bare `rm -rf $HOME` (and `$PWD`/`$TMPDIR`/`$OLDPWD`) to pass without a `[allow-rm-rf-var]` token. The whitelist was supposed to certify the variable is shell-typed; in practice it also certified the *bare* expansion, which is exactly the Steam-disaster shape (Valve/steam-for-linux#3671: `rm -rf "$STEAM_ROOT/"*` with empty STEAM_ROOT wiped entire home dirs). Spec §8 already forbids `rm -rf $VAR without validating VAR` — the hook was simply not enforcing the spec it was supposed to enforce.
95
+
96
+ ### What changed
97
+
98
+ - `[fix CRITICAL]` **`hooks/pre-bash-safety-check.sh`** — whitelisted vars (HOME/PWD/OLDPWD/TMPDIR) now require ≥1 non-`/` character in the literal-path residue (the rm-target with all `$VAR` expansions + quotes stripped). `rm -rf $HOME` / `rm -rf "$HOME"` / `rm -rf ${HOME}` / `rm -rf $HOME/` all DENY post-fix. `rm -rf $HOME/cache` / `rm -rf $HOME/*` / `rm -rf "$HOME/sub"` continue to ALLOW — subpath-bounded targets retain the prior behavior. `BASH_SAFETY_INDIRECT_CALL=1` path (`bash -c '...'` unwrapping) inherits the same check. `tests/fixtures/bash-safety/corpus.tsv` +10 rows: 7 new `deny` (bare-var shapes incl. trailing-slash) + 3 new `pass` (glob / quoted / braced subpath) — corpus-driven test goes 62 → 69.
99
+
100
+ - `[fix HIGH]` **`hooks/transcript-vocab-scan.sh`** — `jq` per-text-block `gsub("[\\r\\n]+"; " ")` before the outer `join(" ")` collapses internal newlines so the whole assistant turn is one scan-friendly line. Pre-fix, an agent turn like `"I significantly improved X.\n\nNext step is Y."` extracted as multi-line text; downstream `tail -n 1` then picked only "Next step is Y." and the §10-V hit in the first paragraph was silently dropped. The hook's docstring comment claimed `join(" ")` made each turn one line, but that only joined CONTENT BLOCKS — embedded `\n` inside a single `.text` block survived. `tests/hooks/transcript-vocab-scan.test.sh` 8 → 10 (+2 multi-paragraph anchors: first-para-only and last-para-only banned word both caught).
101
+
102
+ - `[fix MED]` **`bin/claudemd-lint.js`** — `lint` positional argument path-shape heuristic now requires no whitespace. Pre-fix, `claudemd-cli lint "Fixed crash in scripts/audit.js:42 (12/12 tests pass)"` exit 2 "file not found" because the heuristic only looked for `/`. Real paths are token-shaped; whitespace-containing positionals are inline sentences with file:line citations. `tests/scripts/lint-cli.test.js` +1 test (`sentence with /file:line citation` → text-scan + banned-vocab variant).
103
+
104
+ - `[fix MED]` **`bin/claudemd-lint.js`** — `audit` subcommand pre-flight check: a non-empty JSONL file with zero parseable JSON rows exits 2 with `"audit: no parseable JSON rows in <path> (expected JSONL transcript with one JSON object per line)"`. Pre-fix, pointing audit at a non-JSONL file (plain log, CSV, corrupted transcript) silently exited 0 with `"OK: no §10-V hits across 0 assistant turn(s)"` — CI hooks would falsely greenlight a wrong-format input. Same silent-success class as v0.9.14 / v0.9.21 lint fall-through. `parseTranscript`'s documented per-row silent-skip contract is preserved: the guard fires ONLY when 100% of non-empty lines fail to parse. `tests/scripts/lint-cli.test.js` +3 tests (non-JSONL, empty file degenerate-OK, partial-corruption preserves silent-skip).
105
+
106
+ - `[fix LOW]` **`scripts/update.js`** — `.then().catch()` wrapper translates env-shape errors (unknown `CLAUDEMD_UPDATE_CHOICE` like `YOLO`) into a one-line stderr + exit 1, mirroring the validation-error contract used by audit.js / sparkline.js. Pre-fix, an unknown choice surfaced as a 5-line Node promise-rejection stack trace (`Error: unknown choice: YOLO\n at update (file:.../update.js:41:11)...`) + exit 1 — same exit code but unreadable for users typo-ing the env var. `tests/scripts/update.test.js` +1 test (assert exit 1 + clean stderr + no `at update (file:...` stack lines).
107
+
108
+ - `[fix data]` **`spec/hard-rules.json`** — `spec_version` synced `v6.11.12` → `v6.11.16`. Four prior patch releases (v6.11.13–v6.11.16, all compression/wording-only with `§13.2 budget cost: 0`) did not add or remove HARD rules, so the manifest was never bumped. But `/claudemd-rules` and `safety-coverage-audit` both surface this field at the top of their output — users saw "Spec v6.11.12" against a v6.11.16 spec on disk. Manifest is now bumped with every spec H1 change. `tests/scripts/hard-rules-drift.test.js` +1 test (`hard-rules-7`) asserts `manifest.spec_version === spec/CLAUDE.md H1 version` — future H1 bumps that miss the manifest sync will fail CI before reaching users.
109
+
110
+ ### Why patch (not minor)
111
+
112
+ All 6 changes are `fix:` per CHANGELOG convention — each restores intended/documented behavior:
113
+
114
+ - §8 SAFETY explicitly forbids `rm -rf $VAR without validating VAR`; bare `$HOME` falls under that rule. The whitelist was an over-permissive shortcut, not the documented intent.
115
+ - §10-V transcript scanning was documented to scan agent assistant text; truncating to the last line of the last turn was a parser implementation bug, not the documented behavior.
116
+ - CLI `lint` whitespace heuristic and `audit` non-JSONL exit 2 both close silent-success / spurious-error variants of the same parser-discipline class fixed in v0.9.14 / v0.9.16 / v0.9.21.
117
+ - `update.js` clean error contract matches the rest of the script suite.
118
+ - `hard-rules.json` `spec_version` sync is a data fix, no behavior change for hook consumers.
119
+
120
+ No LLM-visible metadata change. No new HARD rules. No new hook surface. No public CLI flag added or removed. Existing `[allow-rm-rf-var]` per-cmd escape hatch unchanged — users with intentional `rm -rf $HOME` patterns (rare) can still bypass with the token.
121
+
122
+ ### Tests
123
+
124
+ - `bash tests/run-all.sh`: 411 node-test + 2 integration suites pass. Test count unchanged (the 6 new node-test cases offset 0 deletions; corpus + bash hook test counts grew internally: corpus 62 → 69, transcript-vocab-scan 8 → 10).
125
+ - `bash tests/hooks/pre-bash-safety.test.sh`: 69/69 (was 62/62 + 10 corpus rows wired in).
126
+ - `bash tests/hooks/transcript-vocab-scan.test.sh`: 10/10 (was 8/8).
127
+ - Manual end-to-end:
128
+ - 12 `rm -rf` shape matrix verified: bare `$HOME`/`$PWD`/`$TMPDIR`/`$OLDPWD` + trailing slash all DENY; subpath/glob/quoted/braced all ALLOW.
129
+ - Real CC transcript audit on 30 recent `~/.claude/projects/-mnt-data-ssd-dev-projects-claudemd/*.jsonl`: 13 flagged with §10-V hits (signal validated against production prose).
130
+
131
+ ### Operator notes
132
+
133
+ - **Breaking change risk: low.** `rm -rf $HOME` (bare, no subpath) is now denied — but no real workflow runs that intentionally; it's a footgun. Workflows using `rm -rf $HOME/<subpath>` (the normal shape) are unaffected.
134
+ - **Update path:** plugin marketplace update + `/reload-plugins`; the `${CLAUDE_PLUGIN_ROOT}` expansion in hook registration means installed plugin picks up the new hook bodies automatically (`reference_plugin_root_hook_expansion.md`). No manual re-install needed.
135
+ - **If `rm -rf $HOME` is denied for a legitimate use case** (rare — wholesale home wipe in container build, etc.), the existing `[allow-rm-rf-var]` per-cmd escape hatch still works: `rm -rf $HOME [allow-rm-rf-var]`.
136
+ - **`/claudemd-rules` output:** "Spec v6.11.16" header replaces stale "Spec v6.11.12" immediately on first run post-update.
137
+
11
138
  ## [0.17.1] - 2026-05-14
12
139
 
13
140
  **Patch — fix: `memory-read-check.sh` tag-match phase now sanitizes quoted bodies (and heredoc bodies / line comments) before tag scan; closes the FP class where descriptive text inside `--title "..."` / `-m "..."` / `'release/...'` triggered §11 deny on incidental keyword matches.**
@@ -197,7 +197,13 @@ function lintCmd(rawArgs) {
197
197
  // pre-commit-hook ergonomic where `--file` is explicit.
198
198
  if (positional.length === 1) {
199
199
  const arg = positional[0];
200
- const looksLikePath = arg.includes('/') || arg === '.' || arg === '..';
200
+ // Path-shape heuristic: contains `/`, or is `.`/`..`. Whitespace disqualifies
201
+ // — `lint "Fixed crash in scripts/audit.js:42 (12/12 tests pass)"` is one
202
+ // quoted positional whose `/` came from a file:line citation inside a
203
+ // sentence, not a literal file path. Without this guard, the auto-detect
204
+ // branch saw `/` and exited 2 with "file not found", forcing users to
205
+ // either omit citations from inline text or explicitly switch to --stdin.
206
+ const looksLikePath = (arg.includes('/') || arg === '.' || arg === '..') && !/\s/.test(arg);
201
207
  try {
202
208
  const st = fs.statSync(arg);
203
209
  if (st.isFile()) {
@@ -287,6 +293,25 @@ function auditCmd(rawArgs) {
287
293
  }
288
294
 
289
295
  const jsonl = fs.readFileSync(transcriptPath, 'utf8');
296
+
297
+ // Silent-success guard: parseTranscript intentionally skips unparseable rows
298
+ // (matches transcript-vocab-scan.sh). But if the WHOLE file fails to parse
299
+ // — user pointed audit at a CSV, plain log, or corrupted JSONL — `turns.length`
300
+ // is 0 and the CLI happily prints "OK: 0 assistant turn(s)" exit 0. Same
301
+ // silent-OK family as v0.9.14 / v0.9.21. Pre-flight: a non-empty file that
302
+ // yields zero parseable JSON rows is malformed, not clean.
303
+ const nonEmptyLines = jsonl.split('\n').filter(l => l.trim().length > 0);
304
+ if (nonEmptyLines.length > 0) {
305
+ let parsedAny = false;
306
+ for (const l of nonEmptyLines) {
307
+ try { JSON.parse(l); parsedAny = true; break; } catch { /* keep scanning */ }
308
+ }
309
+ if (!parsedAny) {
310
+ process.stderr.write(`audit: no parseable JSON rows in ${transcriptPath} (expected JSONL transcript with one JSON object per line)\n`);
311
+ process.exit(2);
312
+ }
313
+ }
314
+
290
315
  const turns = parseTranscript(jsonl);
291
316
  const patterns = readPatterns();
292
317
  const annotated = turns.map(t => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudemd-cli",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
4
4
  "description": "Standalone CLI for §10-V banned-vocab + transcript scanning. Companion to the claudemd Claude Code plugin (github.com/sdsrss/claudemd) for use in git pre-commit hooks, GitHub Actions, and other agents.",
5
5
  "type": "module",
6
6
  "bin": {