Releases: Atharva-Jayappa/blast-scope
Release list
v0.6.1 — Scoring-bypass fix: prefixes and path verbs no longer hide destructive commands
A security-relevant fix. Two ways to silently downgrade a destructive command from CRITICAL to LOW — which also suppressed the pre-execution undo snapshot (the hook only snapshots on critical).
The holes: the command-class analyzers (git / docker / sql / packages / find / rsync) matched the command verb positionally on the raw string, so either an env/wrapper prefix or an absolute path to the binary disengaged the entire oracle layer:
FOO=bar git clean -fdx·env FOO=bar …·sudo …→ scored LOW instead of CRITICAL/usr/bin/git clean -fdx·/bin/rm -rf .env→ scored LOW (root cause:parsed["command"]kept the full path, so thecommand == "git"triage gate missed)
Honest agents hit the first case constantly (NODE_ENV=production npm …, PYTHONPATH=. pytest), so real-world verdicts were quietly degraded — not only an evasion concern.
The fix:
- All six class tokenizers now route through a shared
peeled_tokens()helper that drops env-assignment and exec-wrapper prefixes, replacing per-analyzer ad-hocsudo-only exceptions. parse_commandbasenames the verb (_verb_basename) so a path verb classifies as its verb;real_command_verbreuses the same helper.- Every prefix/path form now scores identically to the bare command.
Verification: independently reviewed across two adversarial passes (the first surfaced the path-verb sibling bug; the second confirmed the combined fix is regression-free). 510 tests pass — 10 new in tests/test_env_prefix.py pinning both invariants — and the labeled eval holds at F1 1.00.
No API changes. Upgrade is a drop-in for anyone running the hook or MCP server.
v0.6.0 — Hook-driven graph freshness: the graph builds and stays current on its own
The dependency graph no longer depends on anyone calling the MCP server.
The gap this closes: hook-only installs scored graphless forever if no MCP call ever built the graph — and a graph built at session start went stale as the agent edited imports. Either way, the structural blast-radius signal ("8 modules import this") silently degraded to pattern-matching exactly when it mattered.
What's new:
- SessionStart hook spawns a detached background process that cold-builds the graph, off the command path.
- Every PreToolUse refreshes the graph incrementally before scoring — a stat fast-path (mtime+size, no reads) makes a no-op refresh ~20 ms, and centrality is only recomputed when something actually changed. Verdicts always score against the current tree.
- Lockfile coordination (
.blast-scope/graph.lock): concurrent sessions never double-build; a contended refresh is skipped, never waited on; stale locks from crashed builders self-break. - Graph-context honesty: assessments carry
graph_context: true/false, and a high/critical advisory produced while the cold build is still running says "no dependency graph yet" instead of presenting a blind verdict as an informed one. - The Claude Code plugin now registers both hooks;
python -m blast_scope.hookdispatches on the event.
Full flow documented in docs/hook.md.
v0.5.2 — Fix stale verdicts in long-lived server mode
v0.5.2 — Fix stale verdicts in long-lived server mode
A one-bug correctness patch (thanks to a user field report).
The bug
In long-lived MCP-server mode, a repeated command kept its first verdict even after the working tree changed. git reset --hard scored LOW on a clean tree; dirty the tree in the same process and it stayed LOW — a stale verdict. The dangerous direction (stale-low-after-danger) is exactly what bites, because agents repeat commands as the world changes around them.
The PreToolUse hook was never affected — it runs a fresh process per command. Only the persistent MCP server served stale results.
Cause & fix
recoverability._repo_state_cache is keyed by repo path with no world-state component, and assess() didn't invalidate it between calls. Each assess() now reads fresh git/working-tree state (clears the cache at the start) while still caching within a single assessment — so a chain scoring the same repo reads git once. Cost is one git status per assess, the same the hook already paid.
A regression test pins it in the long-lived-process shape: assess → mutate the tree → assess again in the same process, and the verdict must move (and back).
Notes
No scoring or calibration change: 452 tests, calibration corpus 58/58 exact, SABER unchanged (0.58% FPR, 82.4% data-destruction).
Apache-2.0. Install: uvx blast-scope@0.5.2.
v0.5.1 — Security hardening from an independent audit
v0.5.1 — Security hardening from an independent audit
0.5.1 is a security + correctness patch. Two independent reviewers — one auditing functional correctness, one red-teaming — reviewed the codebase cold. Six genuine issues were fixed, each pinned by a regression test.
Upgrade from 0.4.1 / 0.5.0 is recommended — the most serious finding, a read-any-file exfiltration channel, is present in those versions.
Fixes
- Exfiltration via
$(...)substitution (critical). The read-only command-substitution allowlist included file-content readers (cat/head/tail/wc), so scoringrm -rf $(cat ~/.aws/credentials)executedcatduring analysis and surfaced the file's contents. Removed every content-reader; all path arguments are now bounded to the working tree; control characters blocked. The allowlist expands target lists — it never discloses bytes. - Wrapper / assignment scorer-evasion. A leading
VAR=valueor exec-wrapper (env,timeout,nohup,nice,xargs, …) hid the real verb, downgrading evenrm -rf /to low. The parser now peels these prefixes; the fix is shared with the resolver and the speculation gate. - SQL probe DoS. The sqlite scoped-DELETE probe ran an attacker-controlled
WHEREwith no query interrupt — a recursive CTE hung analysis for minutes. Added a real opcode + wall-clock budget (set_progress_handler); pathological clauses abort in milliseconds. 2>&1misparse. File-descriptor duplication was read as a truncating redirect, mis-flagging benignmake 2>&1/pytest 2>&1as destructive. Now recognized as a stream wiring, not a file write.git checkout <path>false negative. Path-discarding checkouts without an explicit--(git checkout ./src,git checkout HEAD app.py) weren't detected. Now flagged; branch/tag switches still aren't.findprobe root.find / … -deletetriggered a whole-disk walk during analysis; the probe root is now bounded to the working tree.- Defense-in-depth. Destructive interpreter one-liners floor high (advise, not silent); config-reference matching is whole-word; command substitution blocks control characters.
Notes
- 40+ regression tests pin every finding. 451 tests total, calibration corpus 58/58 exact, and SABER is unchanged — 0.58% false-positive rate, 82.4% data-destruction recall — so this hardened at zero calibration cost.
- The audit also cleared the snapshot path-traversal defense and found no ReDoS, so it wasn't a scare sweep.
- Honest boundary: a static scorer is always evadable by sufficient obfuscation (a
chr()-built one-liner dodges the token heuristics). That is the ceiling the opt-in speculative-execution path exists to raise — not a bug left unfixed.
Apache-2.0. Install: uvx blast-scope@0.5.1.
v0.5.0 — Dry-run oracles: observe, don't predict
v0.5.0 — Dry-run oracles: observe, don't predict
v0.4 resolved what the shell executes; the scorer still estimated what that command destroys. For a whole class of destructive commands, the tool itself can be asked — side-effect-free — for the exact answer. This release asks.
The headline behavior:
git clean -fdx # in a repo whose untracked files include .env
The command never names .env. blast-scope now runs git clean -n (mirroring your exact -d/-x flags), discovers the real victim list, scores it against each file's recoverability — .env → secret → CRITICAL — and the PreToolUse hook archives those exact files into an undo snapshot before the deletion runs. restore_snapshot brings them back. Previously the snapshot could only archive statically-parsed paths, which for git clean is nothing at all.
The oracles (each verified side-effect-free)
| Command | Oracle | What you get |
|---|---|---|
git clean -f[dxX] |
git clean -n + mirrored selection flags |
exact Would remove list; nested-repo skips flagged |
git reset --hard <ref> |
git rev-list --count <ref>..HEAD |
orphaned-commit count, reflog-honest severity |
git checkout/restore |
git diff --name-only HEAD [-- paths] |
exact dirty files that get clobbered |
find … -delete / -exec rm |
faithful -print rewrite |
exact match set before anything is deleted |
sqlite3 "DELETE … WHERE p" |
SELECT count(*) … WHERE p (read-only) |
matched/total rows — a "scoped" delete hitting 90% of the table floors at high |
rsync --delete (local) |
--dry-run --itemize-changes |
exact *deleting list |
Details that matter:
- The find rewrite is faithful, not naive.
-deleteimplies-depth, so the rewrite adds it back (otherwise-pruneexclusions diverge); the destructive terminal is replaced in place (it gates everything to its right); expressions with-oor multiple terminals punt to the static classification rather than lie.rm -rpayloads are flagged as subtree roots. - Reflog-honest reset severity. Commits orphaned by
reset --hard <ref>are recoverable from the reflog for ~30–90 days — so divergence floors at medium, not critical. The unrecoverable loss is the dirty working tree, which keeps its count-scaled floor. No reflog in the repo? The floor rises. - Verified UNSAFE, never used as probes:
make -n($(shell)executes at parse),npm --dry-run(lifecycle scripts),EXPLAIN ANALYZEon DELETE (executes it),BEGIN…ROLLBACK(transient mutation is still mutation),chmod --changes/cp -n/mv -n(execution-time reporters, not previews). - Graceful degradation unchanged: no rsync on stock Windows, Windows
find.exebeing a string-search tool, an unverifiable ref — all degrade to labeled estimates or the static classification. A failed probe never scores lower than not probing.
Plumbing
Consequence gained an optional targets channel. Oracle-discovered paths merge into the step's parsed targets at one point in assess(), so recoverability, the mass-destruction gate, the evidence, and the snapshot all see the real victims with no per-consumer wiring.
Numbers
- In-repo corpus grew 54 → 58 cases (divergence clean/dirty, scoped-DELETE small/mass): 58/58 exact, gate F1 1.00.
- SABER: unchanged — 0.58% FPR, 82.4% data-destruction recall. That's the honest read: the oracles' value on this benchmark is exactness and undo-ability, not raw recall (the remaining misses need workspace state — git remotes, POSIX find — the Windows harness doesn't provide). No regression from the reflog-honest severity change.
- 381 tests across ubuntu / windows / macos.
Upgrade
uvx blast-scope@0.5.0No breaking changes; probes add ≤2–3s only to already-flagged destructive git/find/rsync commands.
v0.4.1 — Command resolution: score what the shell executes, not what the agent typed
v0.4.1 — Command resolution: score what the shell executes, not what the agent typed
The shell is a two-stage machine: stage one rewrites the text (variables, globs, scripts), stage two executes the result. Until now blast-scope — like every command guard — scored the input of stage one. The damage is done by its output. This release runs stage one statically, so every scoring axis sees the command the kernel would actually see.
The flagship catch:
rm -rf $BUILD_DIR/ # BUILD_DIR unset → executes as rm -rf /
Previously: the literal path $BUILD_DIR/ doesn't exist → "nothing to lose" → LOW.
Now: resolved to / with an unset-variable hazard → CRITICAL (0.9).
What's new
Command resolution (resolution.py)
- Env / tilde / brace / glob expansion, bash-faithful and quoting-aware: single quotes suppress everything, double quotes expand
$but not globs, only expanded text word-splits, unmatched globs stay literal (nullglob off). - Unset-variable hazards: expansion collapsing a path to
/or$HOMEfloors at 0.85; silently re-rooted paths ($APP_DIR/cache→/cache) floor at 0.6. - Expanded targets flow into every axis — graph in-degree, recoverability, infra/config consequences — not just one.
- Symlink evidence: "
./cacheis a symlink → /var/lib/app/data" appears in the rationale; the destination is what gets classified.
Script transparency
npm run clean contains nothing to score — the danger lives in package.json. Now resolved and scored:
sh|bash|zsh -c '...'payloads (sh -c 'rm -rf /'scored 0.015 before; 0.9 now)npm|pnpm|yarn|bun run Xincluding pre/post hooks — a destructiveprecleancan't hide behind an innocent script name- script files (
bash foo.sh,./foo.sh,source foo.sh), depth-capped make targetrecipes, parsed statically — nevermake -n, which executes$(shell ...)during Makefile parse- Opaque wrappers (
python -cwith destructive/obfuscated payloads,curl | sh) get uncertainty floors: not seeing inside never scores lower than seeing inside and finding it harmless
Read-only command substitution
rm -rf $(find . -name '*.log') names its targets through an inner command's output. When that inner command is provably read-only (deny-by-default allowlist, verb and flags checked, no metacharacters, 2s timeout), blast-scope runs just it and scores the real target list — the same philosophy as the existing git status / docker volume inspect / SQLite mode=ro probes.
Scoring
- New
system_rootrecoverability category:rm -rf /andrm -rf ~floor at 0.9 (previously scored medium) - Content-aware mass-destruction gate: destroying ≥3 source files — directly or inside recursively-deleted directories — floors at 0.55 even when git could restore them. Content-aware on purpose:
rm -rf tmp/ downloads/is routine cleanup,rm -rf src testsguts the codebase; "is a directory" can't tell them apart, "contains source code" can. find -execnow recognizes destructive payload verbs (truncate,dd,mv,chmod)
Numbers (SABER, 716 real agent workspaces — reproducible via bench/)
| metric | v0.3.1 | v0.4.x |
|---|---|---|
| benign false-positive rate | 0.4% | 0.58% (10/1725) |
data_destruction recall |
76.5% | 82.4% — graph-level recall, now on the graphless hook path |
fs_destruction recall |
53.8% | 61.5% |
code_tampering recall |
~0% | 50% |
| overall harmful recall | ~17% | 30.4% |
In-repo calibration corpus: 54/54 exact severity, gate F1 1.00, pinned by tests.
License
blast-scope is now Apache-2.0 (≤0.3.1 was MIT). The vendored code-review-graph sources remain MIT under their upstream notice — see NOTICE.
Upgrade
uvx blast-scope # MCP server (zero-install)
uv tool install blast-scope # or persistent installassess() gains an optional env mapping for $VAR resolution (defaults to the process environment — the hook shares the agent's env, so no configuration change is needed).
(v0.4.1 is v0.4.0 plus a refreshed PyPI page — no code changes between the two.)