Skip to content

Wave 1: eval-explain, shadow-mode, cli-distribution, editor-schemas, diff-fuzz-bench - #4

Merged
bb-connor merged 85 commits into
mainfrom
worktree-wave-1
Jul 15, 2026
Merged

Wave 1: eval-explain, shadow-mode, cli-distribution, editor-schemas, diff-fuzz-bench#4
bb-connor merged 85 commits into
mainfrom
worktree-wave-1

Conversation

@bb-connor

Copy link
Copy Markdown
Contributor

Wave 1 — five roadmap features, executed end-to-end

This branch delivers the first execution wave from the design portfolio (docs/superpowers/specs/2026-07-12-portfolio-overview.md), built task-by-task with a fresh implementer + independent reviewer per task and an Opus final review per plan plus a whole-wave cross-plan review. 61 commits, all four SDK suites green (Rust 24 test binaries, TypeScript 403, Python 332, Go), testkit conformance 45/45, cargo clippy --workspace -- -D warnings + cargo fmt clean.

What landed

Plan Summary
eval-explain h2h eval / h2h explain — one-shot action evaluation with --type/--target/content/context flags, an --action-json/--action-file escape hatch, --explain rule-trace rendering, --format text|json|receipt, and decision-mapped exit codes (0 allow / 1 deny / 2 input error / 4 warn). CLI-only, over the existing evaluate_audited().
shadow-mode HushGuard monitor (shadow) enforcement mode in the TS + Python SDKs: gate() single enforcement path, per-rule overrides, panic supremacy, onWarn-never-under-monitor; additive optional enforcement {mode, outcome} object in the receipt schema, mirrored across all four SDK receipt types.
cli-distribution h2h lint --fix (decision-neutral auto-fixes, --dry-run), five-target release-binary workflow (checksums + provenance), Homebrew tap automation, @hushspec/cli npm wrapper, install-matrix docs.
editor-schemas CI schema meta-validation guard (draft 2020-12 + restored format assertions), cmd_fmt modeline preservation, # yaml-language-server: modelines on all 15 shipped policies + h2h init scaffolds, Pages workflow serving schemas at their $id, SchemaStore entry + editor-setup guide.
diff-fuzz-bench Differential cross-SDK fuzz harness in hushspec-testkit (portable case bundles → Rust oracle + TS/Python/Go harnesses → comparison → minimizer → schema-validated regression-fixture emitter → hushspec-difftest), criterion benches + a release-mode overhead gate, PR + nightly CI jobs.

The harness earned its keep mid-build

While wiring up the differential fuzzer, it caught a real, decision-flipping cross-SDK bug: with two origin profiles tied on match-score, Rust's evaluator selected the last tied profile (max_by_key) while TypeScript, Python, and Go all selected the first (score > best). A concrete policy reproduced Rust denying an action the other three allowed. With your approval, Rust was aligned to the majority (first-match-wins) and the behavior is now conformance-pinned across all four SDKs by fixtures/origins/evaluation/tied-profiles.test.yaml (commit f453fc4). End-to-end fuzz runs against the live SDKs now report zero divergences.

Reviewing this PR

The five plans share a few files (the CLI Commands enum, the receipt schema, cmd_fmt), and the whole-wave review confirmed they compose cleanly — but the commits are grouped by plan and the per-task/per-plan review trail is recorded in the branch's SDD ledger, so it reads plan-by-plan. Happy to split into five stacked per-plan PRs if you'd prefer that for review — say the word.

Deferred to maintainer (out of scope / needs your sign-off)

  • Live release/publish dry-runsrelease.yml (5-target build), the Homebrew tap PR flow, and the npm publish were validated locally only; no tag was pushed and nothing was published. First live run needs the tap repo + TAP_GITHUB_TOKEN/NPM_TOKEN and a disposable-tag dry-run.
  • hushspec.dev DNS + Pages — modeline/$id URLs are dormant until the custom domain + "Pages → Source: GitHub Actions" are configured; the raw-GitHub fallback covers the interim.
  • publish.yml gates on ci.yml, so a manual publish now also runs the heavy fuzz + timing-sensitive bench jobs — consider scoping those out of the publish path.
  • Regex-caching perf — benches confirmed evaluate() recompiles glob/secret regexes per call (~1.2 ms on the 26-pattern default policy); this is the existing docs/plans/04-governance-security.md §5.4.4 backlog item, now with a concrete measurement.
  • Lower-priority divergences the harness found on malformed input (unreachable by the generator, so non-gating): merge_strategy phase boundary (Go rejects at validate vs parse), args_size type strictness (TS/Python lenient).
  • h2h lsp (editor-schemas stretch) and cargo-fuzz targets (diff-fuzz-bench optional Task 18) were deferred — both need extra toolchain and weren't required for the wave.

🤖 Generated with Claude Code

bb-connor and others added 30 commits July 12, 2026 18:15
Commit c16ba68 added browser_automation/code_execution (+3 more) schema
entries and a valid fixture without regenerating the cross-SDK contracts,
leaving TS/Python validators rejecting the fixture. Regenerated via
scripts/generate_sdk_contracts.py; all four SDK suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
file_write and patch_apply share evaluate_path_guards with file_read, so a
forbidden_paths.exceptions match can allow the action before
secret_patterns/patch_integrity is ever consulted; their precedence_note
strings omitted that stage. Also trim the rule-trace line before printing
so an empty matched_rule no longer leaves trailing whitespace.
builtin:strict has no shell_commands block, so the rm -rf / example
actually ALLOWs (exit 0) despite implying a deny. builtin:ai-agent
denies it via rules.shell_commands.forbidden_patterns[0].
The --format json and --format receipt arms of `h2h eval` used
`if let Ok(json) = serde_json::to_string_pretty(...) { println!(...); }`,
which silently drops the Err case: on a (currently unreachable)
serialization failure the process would print nothing yet still exit
with the decision code, breaking the fail-closed invariant that a
decision exit always comes with decision output.

Extract the serialize-and-print logic into print_json_report(), which
matches exhaustively on the Result: Ok prints the JSON unchanged, Err
reports "error: failed to serialize output: {e}" to stderr (matching
the module's existing error style) and returns exit code 2. Add a
unit test that pins the Err behavior via a Serialize impl that always
fails, since no real HushSpec type can trigger it end-to-end today.
extensions.* override keys only checked the 'extensions.' prefix, so a
typo like 'extensions.postur' passed construction and silently never
matched any evaluation result, leaving the intended escalation in
monitor mode (fail-open). Validate the second segment against the
generated EXTENSION_KEYS_SET/EXTENSION_KEYS (posture/origins/detection),
mirroring the existing rules.* top-segment check. Deeper segments stay
unvalidated (policy-dependent, hot-swappable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds `--fix`/`--dry-run` to `h2h lint`, restricted to the subset of L002
(overlapping patterns), L003 (shadowed exceptions), and L008 (duplicate
patterns) findings that are provably decision-neutral to remove:

- L008 is always safe (byte-identical duplicate entries).
- L002's own check only proves "may overlap" via sampling, not
  subsumption, so only its degenerate byte-identical-pair case is acted
  on (the same condition L008 already covers).
- L003's check is also sampling-based and can miss real overlaps for
  wildcarded exceptions (e.g. pattern `*.secret` / exception `file.*`
  both match `file.secret`, but the fixed synthetic-substitution set
  never constructs that string), so only literal (non-wildcarded)
  exceptions are auto-removed, verified directly rather than trusting
  the check's own sample.

`cmd_lint.rs` becomes `cmd_lint/mod.rs` with the fix engine in a new
`fix.rs`; `run_all_checks` is extracted as a shared pub(crate) helper.
`location` on the three fixable checks is upgraded from a file-level
fallback to an entry-precise `rules.<block>.<field>[<idx>]` pointer (it
carried no index information before). JSON output gains additive
`fixable` (per finding, content-aware rather than code-only) and
`fixed` (per file) fields.

Also caches compiled glob regexes in `find_overlapping_globs`/
`check_shadowed_exceptions`, which were recompiling a pattern's regex
on every pairwise/candidate comparison (~30s for a single 40-pattern
policy in debug builds, dominated by `Regex::new`). Fixing that was
necessary to make the fixpoint re-lint and the corpus-wide test
practical; a matching pre-existing hot path inside
`hushspec::evaluate`'s core matching (used by `h2h diff`, exercised
here only as the neutrality oracle) was left alone as out of scope.

`--fix` only rewrites a file when something was actually fixed, through
the same canonical formatter `h2h fmt` uses, so a policy with nothing
to fix is left byte-for-byte untouched (comments and all).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…guard)

- x86_64-apple-darwin leg: macos-13 (retired Dec 2025) -> macos-15-intel
- release job: add "Ensure release exists" step (gh release view||create)
  before "Upload to release" so a fresh tag push doesn't fail on upload
- build job: guard workflow_dispatch tag input against ^v[0-9] before
  spending five build legs on a malformed tag
- bump actions/attest-build-provenance v1 -> v2 (subject-path contract
  verified unchanged across the bump)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hardening from CD Task 3's review: reject tags that don't match strict
semver (blocks Ruby-string injection into the rendered formula, and
brings the workflow's tag guard in line with the script's), make
sha() abort loudly instead of splicing in an empty checksum when a
target is missing from SHA256SUMS, and wire gh auth setup-git so the
homebrew job's git push to the tap repo has credentials.
Replace the hand-rolled `file://${path}` comparison with node:url's
pathToFileURL, so the npm shim's entry-point check works on Windows
(npm's .cmd wrapper invokes h2h.js directly, and import.meta.url there
is a forward-slash file:///C:/... URL a manual template never
produces) and on paths containing spaces (import.meta.url
percent-encodes them; a raw template does not). realpathSync is kept
for the existing POSIX symlink case (npm's bin mechanism).

Export the check as a pure, injectable isEntryPoint(metaUrl, argv1,
realpath) and cover it with node:test cases: direct path, a real
symlink (mirroring node_modules/.bin), a space-containing path,
Windows-shaped inputs (documented POSIX-host caveat), module-import
(different metaUrl), missing argv[1], and a nonexistent path.

Also: when spawnSync fails to exec the resolved binary (r.error set),
print the underlying error to stderr before exiting instead of exiting
silently with just a status code.
bb-connor and others added 4 commits July 13, 2026 14:04
Add crates/hushspec/tests/bench_thresholds.rs: an #[ignore]d,
release-mode-only test that measures evaluate() vs. evaluate_audited()
(enabled/disabled AuditConfig) in-process over 60 batches of 2,000
iterations and asserts the median overhead stays under env-overridable
budgets (HUSHSPEC_BENCH_BUDGET_ENABLED_US, default 10.0;
HUSHSPEC_BENCH_BUDGET_DISABLED_US, default 2.0). Normal `cargo test`
skips it; Task 16's CI job runs it explicitly in --release with
--ignored and sets 25/5.

Measured on this shared/non-dedicated sandbox (release, three runs):
  evaluate:               ~70.4-70.9 us/iter
  evaluate_audited(off):  ~72.2-72.6 us/iter (overhead ~1.67-1.83us)
  evaluate_audited(on):   ~84.8-85.1 us/iter (overhead ~14.0-14.4us)

At the roadmap's literal 10/2 defaults, the disabled-overhead assertion
passes (~1.7-1.8us < 2us) but the enabled-overhead assertion fails
(~14.0-14.4us > 10us) on this hardware -- consistent with Task 14's
benches, which measured a 12-20us band for the same delta. At Task 16's
planned CI budgets (25/5), both assertions pass comfortably. Per the
task brief, the 10/2 defaults are left as-is rather than weakened
locally; the R10 roadmap claim ("<10us receipt generation") is
empirically optimistic for non-dedicated hardware and should be
revisited by a maintainer against real CI numbers once Task 16 lands.
Adds two required PR checks to .github/workflows/ci.yml, inserted
after cross-sdk-roundtrip and before docs:

- differential-fuzz: builds all three SDKs (npm ci && npm run build,
  pip install -e "packages/python[dev]", Go via setup-go) then runs
  `hushspec-difftest --seed-from-string "$GITHUB_SHA" --groups 500
  --actions-per-group 4 --report target/difftest/report.json` in
  --release. Fixed groups/actions-per-group keeps the run at a PR-sized
  budget (2000 cases; ~5s locally once built); seeding from the commit
  SHA makes any given run's fuzz corpus fully reproducible by re-running
  with the same --seed-from-string value. Uploads target/difftest/
  (bundle + report) as an artifact on failure so a real divergence is
  reproducible from the CI logs alone.
- bench-thresholds: compiles the criterion benches (--no-run, cheap
  compile-only check) then runs Task 15's #[ignore]'d
  receipt_overhead_within_budget gate in --release with CI-specific
  budgets (HUSHSPEC_BENCH_BUDGET_ENABLED_US=25,
  HUSHSPEC_BENCH_BUDGET_DISABLED_US=5), wider than the local 10/2
  defaults to absorb noisy-neighbor CI runner jitter per Task 15's own
  measurements.

Also pins crates/hushspec-testkit/Cargo.toml's proptest dependency from
"1" to "=1.11.0" (the version already resolved per `cargo tree -p
hushspec-testkit -i proptest`). Cargo.lock is gitignored in this repo,
so an unpinned "1" range could let a fresh CI checkout resolve a newer
proptest release with a different RNG-to-value mapping, silently
breaking the --seed / --seed-from-string reproducibility the new
differential-fuzz job depends on. Exact-pinning closes that gap without
touching any other workspace member (proptest has exactly one consumer:
hushspec-testkit).

Verified locally: YAML parses and contains both job keys; `cargo tree
-p hushspec-testkit -i proptest` resolves 1.11.0; `cargo build -p
hushspec-testkit` and `cargo check --workspace` are clean with the pin;
the exact PR-CI differential-fuzz command, reproduced locally with
--seed-from-string "$(git rev-parse HEAD)", printed "2000 cases across
1 chunk(s); 0 divergence(s)" and exited 0 against the real
TypeScript/Python/Go harnesses; the bench-thresholds gate passes
locally at the 25/5 CI budgets (consistent with Task 15's own numbers)
once isolated from an unrelated concurrent build on this shared
sandbox that had transiently inflated one earlier reading.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-existing test-target lints not caught by the CI clippy command
(cargo clippy --workspace -- -D warnings, which does not compile test
targets): io_other_error and clone_on_copy in tests/sink.rs, plus an
assertions_on_constants in the bench-thresholds release guard. All are
now clean under --all-targets, making the branch robust to CI hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eba557b581

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .github/workflows/release.yml
bb-connor and others added 24 commits July 13, 2026 15:25
The release job built the workspace with 'cross build --locked' /
'cargo build --locked', but this workspace intentionally gitignores
Cargo.lock, so a locked build fails before producing any artifacts
(Codex review, PR #4). Removed --locked from both workspace build
commands; kept it on 'cargo install cross --locked', which uses the
cross crate's own published lockfile and is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI runs clippy on stable (1.97.0), which flags a match arm whose sole
body is a guardless `if depth > 0` in the regex-complexity check as
collapsible; local 1.93.0 clippy did not. Rewrote the arm to use a
match guard (`b'+' | b'*' if depth > 0 =>`) — semantically identical
since the only fallthrough is the no-op `_ => {}`. Verified clean under
stable 1.97 clippy (workspace) + fmt + full hushspec-cli tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Strips references to the development process from source comments —
'Controller directive', 'the brief', 'Task N', 'Red-verification', and
pointers to the ephemeral design spec — while keeping the technical
substance each comment carried (cross-engine regex constraints, the
detection matched_rule normalization rationale, the fmt/lint line-number
invariant, the npm tarball wrapper-dir layout). Comment-only; fmt, build,
tsc, and Python/JS parse all clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gate()'s provider-failure branch under monitor mode returned proceed=true
with a would_block outcome but emitted no receipt or observer event, so a
monitored provider outage let every action proceed with zero audit trail —
violating the core shadow-mode invariant that a monitored block is never
silent. Route the branch through record() (which emits the observer event
even without a receipt); a sink-only guard still can't record without a
loaded policy to hash, so monitor deployments should include an observer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- testkit: a phantom or non-reproducing divergence in --minimize mode
  aborted the whole run and discarded co-found real divergences; record
  such divergences un-minimized instead. hushspec-gen exits 2 on a write
  error rather than panicking.
- lint --fix: on a write failure, report the file's actual (pre-fix)
  findings and no applied fixes, instead of the post-fix in-memory state.
- render_formula.sh: grep -F for the exact checksum filename (dots were
  BRE wildcards).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ships reproducible, dependency-pinned release binaries: un-ignores and
commits the workspace Cargo.lock and restores --locked on the release
cross/cargo builds, so the attested artifacts are built from a fixed
dependency graph rather than resolving newest-compatible versions fresh
at release time (supply-chain review finding). 'cargo install cross
--locked' was already correct (uses cross's own lockfile).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In flag mode, h2h eval now rejects a missing --target (exit 2) for the
seven action types that are meaningless without one (file_read/write,
egress, tool_call, shell_command, computer_use, input_inject), instead
of silently scoring against an empty target (which a '**' allowlist
matches, yielding ALLOW). patch_apply acts on --content and stays
target-optional; the --action-json/--action-file escape hatches are
unchanged, mirroring the raw evaluate() contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A comprehensive review of the whole codebase surfaced pre-existing
defects where the four SDKs disagreed or failed open. These bring
TS/Python/Go into parity with the Rust reference and restore
fail-closed behavior, with shared conformance fixtures pinning each.

Fail-closed / parity:
- egress: an omitted `default` now materializes to `block` (Go/TS were
  allowing non-allowlisted domains; Rust/Python denied)
- posture: an unknown posture state now denies (Py/Go/TS skipped the
  capability guard and fell through to allow)
- patch_integrity: materialize max_additions/max_deletions/
  max_imbalance_ratio defaults (Go/TS left require_balance unenforced
  and additions unbounded)
- glob: a leading `**/` matches zero or more segments, so `**/.env`
  covers a bare repo-root `.env` (all SDKs)
- merge: browser_automation/code_execution blocks are no longer dropped
  on composition (TS/Python dropped them, Go kept the wrong side)
- conditions: `when` can deactivate all 12 rule blocks (Rust/Go
  dispatch omitted the two guards); TS parses zoneless timestamps as
  UTC; TS/Python reject malformed HH:MM; Python no longer treats bool
  as numeric; array-vs-array context match aligned to Rust

Security:
- SSRF: the TS http-loader detects IPv4-mapped IPv6
  (::ffff:127.0.0.1, ::ffff:169.254.169.254) in dotted or hextet form
- observer: TS/Python observer events honor redact_content, so a denied
  secret no longer leaks into the observer stream
- sink: the Rust FileReceiptSink writes each record in one syscall, so
  concurrent appends no longer interleave and corrupt the JSONL trail

Correctness:
- receipt: content_hash is always computed (an empty hash violated the
  receipt schema when audit was disabled); Go rule_trace serializes as
  [] not null
- detection: Rust DetectionCategory serializes snake_case; a TS unknown
  category resolves a conservative threshold instead of never denying
- CLI: `h2h panic activate` now takes effect for eval/test/diff;
  `h2h fmt` preserves (and `--format json` persists) the two guard
  blocks; the panic sentinel check fails closed; `h2h diff` probes
  forbidden_paths for write/patch, not only read
- Go validate rejects invalid nested profile enums and pattern-less
  secret rules

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…th gaps

The review found the shipped policies miss several current credential
formats, and that whole-block `extends` replacement silently drops
SSH-key patterns from the compliance verticals.

- secret_patterns: also match AWS temporary/STS keys (ASIA), GitHub
  gho_/ghu_/ghr_ and fine-grained PATs (github_pat_...), OpenAI project
  keys (sk-proj-...), and Anthropic keys containing `_`
- ai-agent: block raw_file_write/raw_file_delete (default already did)
- cicd: add .kube/.docker/.npmrc and id_rsa*/id_ed25519*/id_ecdsa*
- library compliance policies (hipaa/pci-dss/soc2/fedramp/ferpa/
  cicd-hardened): re-list id_rsa*/id_ed25519*/id_ecdsa*, which the
  whole-block forbidden_paths replacement had dropped from the base

Regenerated the embedded TS builtins; all patterns verified RE2-safe
across Rust/Go/JS/Python.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The earlier "always compute content_hash" change (meant to make
disabled-audit receipts schema-valid) hashed the whole policy on the
disabled fast path, which broke the R10 zero-overhead-when-disabled
bench budget (6.4us > 5us) and, because only three of four SDKs got the
change, introduced a receipt divergence the parity gates don't cover.

Revert to the original consistent behavior across Rust/Python/Go (empty
content_hash when audit is disabled) so the fast path stays cheap. The
empty-hash-vs-schema conformance nit is deferred: the correct fix makes
the receipt schema's content_hash optional when audit is off, rather
than a perf-violating eager hash.

Also apply cargo fmt (rules.rs, cmd_diff.rs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s schema-valid

The zero-overhead disabled-audit fast path emits an empty content_hash, which
violated the receipt schema's ^[0-9a-f]{64}$ pattern. Make the field optional
(drop it from PolicySummary.required) and omit it when empty across all four
SDKs, so the fast path stays cheap AND produces a schema-valid receipt. Also
align content_redacted to omit-when-false everywhere (Rust/Go already did;
TS/Python now match) so receipts serialize byte-consistently. Adds Rust tests
that validate both the enabled and disabled receipts against the schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…heck

The regex-safety check only rejected non-RE2 features, so a nested-quantifier
pattern like (a+)+$ passed validation and then catastrophically backtracked in
the TS RegExp and Python re engines (the RE2-based Rust/Go SDKs are immune).
Add an identical nested-quantifier heuristic to all four safety checks so a
pattern that validates is safe across every SDK -- verified to reject the same
set byte-for-byte and to leave every shipped policy pattern valid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The built-in regex detection engine (prompt-injection / jailbreak /
exfiltration) existed but was never called from any enforcement path, so
injection content passed as `allow`. Wire it to the policy's `detection:`
extension, identically across all four SDKs, locked by shared fixtures.

- `evaluate_with_detection(spec, action)` is now spec-driven: it reads
  `extensions.detection`, runs the built-in detectors on the action content,
  and returns the strictest of the policy and detection decisions
  (matched_rule "detection" on escalation). Exact no-op when there is no
  detection extension or the content is empty.
- Mapping: prompt_injection -> injection detector with Level floors
  (safe 0.0 / suspicious 0.25 / high 0.5 / critical 0.75); jailbreak ->
  jailbreak detector with score*100 vs the 0-100 thresholds. threat_intel
  is left to a custom detector (the built-in regex engine has no pattern-db
  or similarity model).
- Wired into enforcement: h2h eval/test, the shared-fixture runners, and
  HushGuard (TS/Python) for both the receipt-free and sink/audit paths (an
  escalation appends a "detection" rule-trace entry to the receipt). Go
  exposes EvaluateWithDetection; the differential-fuzz oracle still uses
  pure evaluate().
- The exfiltration detector's ssn/credit_card patterns use explicit ASCII
  non-digit boundaries instead of \b, so `café123-45-6789` is detected
  identically across the Unicode-\b (Rust/Python) and ASCII-\b (Go/JS)
  engines.

Two shared fixtures (prompt-injection, jailbreak) exercise the mapping and
pass byte-identically in all four SDKs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- strict/default/cicd had no shell_commands block, so a shell_command
  action (`rm -rf /`) was ALLOWED under "maximum security" (they only
  blocked shell as a tool). strict now denies all shell; default/cicd
  deny a dangerous-command list.
- panic (deny-all) had no input_injection block, so input_inject was
  allowed; now denied.
- air-gapped allowed a bare id_rsa (missed the id_rsa sweep); added the
  three key patterns.
- the openai_key regex over-matched ordinary prose ("sk-" mid-word plus
  hyphens); split into a precise legacy `sk-[A-Za-z0-9]{48}` and a
  project `sk-proj-[A-Za-z0-9_]{20,}`.
- replaced `\s`/`\S`/`\d`/`\w` with explicit ASCII classes in every
  shipped secret/patch/shell pattern (they differ Unicode-vs-ASCII
  across the four regex engines, breaking cross-SDK parity).

Regenerated the embedded TS builtins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Reject non-finite floats (NaN/+/-Inf) in validation across all four
  SDKs. A `max_imbalance_ratio: .nan` previously validated and made
  require_balance fail open (`ratio > NaN` is always false), and made Go
  silently drop content_hash from an audit receipt (json.Marshal errors
  on NaN).
- Make the built-in detector patterns engine-agnostic: `\s`->`[ \t\n\r\f]`,
  `\S`->`[^ \t\n\r\f]`, `\d`->`[0-9]`, `\w`->`[A-Za-z0-9_]`. `\s` was
  Unicode in Rust/Python but ASCII in Go/JS, so Go missed NBSP-obfuscated
  injection the others caught; now all four decide identically.
- Fix glob end-of-text anchoring: Python `$` matched before a trailing
  newline (now `\Z`), and TS `?` counted UTF-16 units (now the `u` flag),
  so all four globs match Rust/Go end-of-text semantics.

Verified: 1000 differential-fuzz cases, 0 divergences.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…itor guards

- `h2h panic activate --sentinel <path>` (the documented form) was a
  no-op because eval/test/diff only checked the hardcoded default path;
  they now accept --sentinel and check it. cmd_panic status/deactivate
  use fail-closed try_exists() instead of exists().
- A TS HushGuard configured with a sink but no observer under monitor
  mode produced zero audit output when the policy provider failed
  (record() skipped the sink on an undefined receipt), violating the
  "a monitored block is never silent" invariant. It now emits a minimal
  failure receipt to the sink.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A systematic parity audit found decision-flipping and validation
divergences beyond what the fuzz reaches. These bring the four SDKs back
to identical decisions/accept-reject; verified with 1200+ fresh
differential-fuzz cases at zero divergences.

Decision parity:
- conditions `context` value-matching now matches the Rust reference in
  TS/Python/Go: array-vs-array intersects, and array membership covers
  number/bool actuals (previously string-only, and array-vs-array was
  TS-only).
- Python patch_stats splits on `\n` only (was `splitlines()`, which also
  split on \r/\v/\f/NEL/LS/PS and skewed addition/deletion counts).
- Go denies an empty/unknown `posture.current` (was substituting the
  initial state and allowing) and no longer selects an origin profile
  whose match field is an empty-string sentinel.

Validation parity:
- the regex-safety check rejects the same exotic constructs in all four
  SDKs: possessive quantifiers (incl. brace `{n,}+`), `\Z`/`\z`
  end-anchors, and empty classes `[]`/`[^]`.
- TS and Python now validate the browser_automation/code_execution rule
  blocks (Rust/Go already did; malformed content under them was silently
  accepted).
- Go rejects a non-integer float for integer fields (was truncating
  1.5->1) and the empty-string/missing-field sentinels the other three
  reject (origin visibility, metadata enums, posture transitions).
- Python's duration check is ASCII (`\d`->`[0-9]`), rejecting the
  Unicode digits it alone accepted.

Detector parity:
- the exfiltration ssn (`\d`->`[0-9]`) and email (`\b`->explicit ASCII
  boundaries) patterns are now engine-agnostic like the rest.

Also: Python receipt_to_dict omits null optionals (was emitting explicit
null where Rust/Go/TS omit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rust embeds the builtin rulesets via include_str! and TS via a generated
builtin.ts, but Python and Go had no embedded builtins, so a portable
policy using `extends: builtin:strict` resolved in Rust/TS and hard-failed
in Python/Go. Embed the six builtins (default/strict/permissive/ai-agent/
cicd/remote-desktop) into both packages via new generators and wire each
resolver with a builtin-aware composite loader, matching Rust/TS (a bare
name with no path separators or dots is tried as a builtin first). All
four SDKs now resolve `builtin:strict` to the identical merged policy.

- scripts/generate_{python,go}_builtins.py generate packages/python/
  hushspec/builtins.py and packages/go/hushspec/builtins.go from
  rulesets/*.yaml; both added to the Generated Sources CI check.
- Python resolve()/resolve_file() and Go Resolve()/ResolveFile() default
  to the composite loader; unknown builtins error.
- Python re-exports load_builtin/BUILTIN_NAMES (Go exports LoadBuiltin).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The hand-maintained PANIC_POLICY_YAML strings in the TS and Python
evaluate modules were missing the input_injection deny block that
rulesets/panic.yaml carries, so panicPolicy()/panic_policy() ALLOWED
input_inject actions under the emergency deny-all policy (Rust is
unaffected -- it include_str!s the file). Add the block and a drift-guard
test asserting the panic policy denies input_inject plus every other
governed action type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ai-agent.yaml and library/general/recommended.yaml redefined
shell_commands with only a few patterns, so (whole-block replace) they
allowed mkfs, dd if=, chmod 777, > /dev/sd, and curl/wget piped into sh.
Add the dangerous-command patterns default/cicd already carry so both are
at least as strong as the default they extend. Regenerated the embedded
builtins (TS/Python/Go).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From a parity audit:
- conditions: Go now distinguishes an integer-shaped expected value from a
  float (matching Rust/Python) instead of coercing, so a `when` context
  match no longer flips a decision; HH:MM parsing rejects a leading `+` in
  Rust/Go (TS/Python already did).
- validation: Rust/TS/Python reject an empty-string origin match field (Go
  already did); TS/Python's regex reject-exotic check is now escape/class-
  aware like Rust/Go, so it no longer over-rejects patterns like `[*+]` or
  `\\Z`; Go rejects negative/null integers for policy_version,
  code_execution.*, and required patch fields.
- resolve: the Python/Go composite loaders reject http(s):// references
  with a clear error (matching Rust/TS).
- middleware: TS HushGuard.evaluate() emits a failure receipt to the sink
  on provider failure (the sink-only monitor fix previously covered only
  gate/check/enforce).
- the builtin generators write LF explicitly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- glob (TS): the translator emitted `.` for `?`/`**`, which in JS excludes
  CR/LS/PS as well as LF, so a target with an interior `\r` slipped past
  forbidden_paths/tool_access.block in TS while Rust/Python/Go denied. Emit
  `[^\n]` so every engine's wildcard excludes exactly `\n`.
- panic sentinel (Go, Python): the file-existence check failed OPEN on a
  stat error (permission/IO); it now fails CLOSED (activates) like Rust.
- YAML robustness (Python): the parser rejects duplicate mapping keys
  (Rust/TS/Go already did), caps alias/anchor expansion (billion-laughs),
  and catches RecursionError instead of crashing.
- SSRF (Rust, TS): the private-IP filter also flags the deprecated
  IPv4-compatible IPv6 form (`::a9fe:a9fe`), not only the mapped form.
- signing (Rust): Ed25519 verification uses verify_strict (rejects
  small-order keys and signature malleability).
- regex (TS): `\Z`/`\z` inside a character class is rejected again (a
  regression from the last wave's class-aware rewrite; the other three
  reject `[\Z]` at compile).
- metadata merge (TS/Python/Go): a resolved policy's metadata is now
  child-over-parent like every other field (TS/Python dropped it; Go kept
  the base's).
- extends depth cap (all four): an acyclic chain is capped at depth 32
  with a clean error instead of unbounded recursion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- the builtin generators emit non-ASCII (incl. astral/emoji) as raw UTF-8
  (ensure_ascii=False) instead of UTF-16 surrogate escapes, which Go
  rejects at compile and Python mis-decodes; byte-identical for today's
  ASCII rulesets.
- the crates.io publish step tolerates only an "already published" failure
  and fails on any other (auth/network/packaging) error instead of
  swallowing all failures.
- the differential-fuzz generator emits all seven posture transition
  triggers (was four), exercising every transition path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bb-connor
bb-connor merged commit 5227b89 into main Jul 15, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant