Bernstein — Multi-Agent Orchestration
Version updated for https://github.com/sipyourdrink-ltd/bernstein to version v3.7.1.
- This action is used across all versions by 5 repositories.
Action Type
This is a Composite action.
Go to the GitHub Marketplace to find the latest changes.
Action Summary
Bernstein is a deterministic orchestrator for CLI coding agents, scheduling tasks using plain Python to ensure reproducibility. It records every artifact write in an always-on lineage spine and replay journal, providing audit logs with receipts for offline verification.
What’s Changed
v3.7.1
Security and integrity hardening. Every item below is a confirmed defect with a regression test that fails on the pre-fix code, not a speculative hardening.
Each fix was reproduced against the shipped code before it was written, and independently reviewed afterwards by reviewers whose brief was to run the same input against the previous release and report anything that got worse. Several fixes were rejected on that basis and held back rather than shipped; they are listed at the end.
Behaviour changes (read before upgrading)
mission definenow refuses a phase that declares no gate tasks. A phase is a verification gate plus a budget envelope; one with an empty gate had nothing to verify and projectedpassedon no evidence at all. The check runs at define time, so existing ledgers are untouched: no recorded mission changes its verdict or its hash. If you have a spec declaring a gateless phase,mission definenow rejects it and says why.- A tampered mission ledger reports as unverified rather than not-found. The declared mission id sits in a row no hash covers, so it cannot on its own justify a not-found; only a chain that verifies has earned the right to say the mission does not exist. The HTTP route and
mission verifynow apply one shared rule, so a torn chain reads asunverifiedon both, instead of a 404 on one and a projection on the other. bernstein approveandbernstein rejectrefuse identifiers outside[A-Za-z0-9._-]{1,64}. The bound matches the rule the rest of the tree already used. An earlier draft of this change capped at 59, which would have stranded ordinary descriptive backlog ids: such a task could post artifacts, because the artifact surface accepted the id, but could never be approved or rejected.ConnectionDocument.secret_nameis deprecated, not removed. The field is nowbroker_ref.secret_namekeeps working as a deprecated alias on the constructor, on attribute reads, and oncreate_documentandrotate_document, emitting a warning and forwarding; supplying both spellings with different values raises. No code change is required to take this patch. The serialised form and the signed preimage are unchanged, so existing signatures and chain digests still verify.
Fixed
Path containment across the ledger, journals, and artifact store
Path traversal was reachable from HTTP route parameters in the work ledger, replay journal, SLA store, and artifact store. One pre-existing containment check guarded nothing: it resolved the path, tested containment on that throwaway value, then used the original unchecked path for filesystem access.
Every construction of the affected journal paths, for both task journals and run journals, now goes through one barrier shared by the writer and the readers. That sharing is the point: a local copy of the same check was found in the MCP server, already drifted from the original. A test now scans for journal paths built from an identifier and fails on any construction that bypasses the barrier, so a new reader is covered by construction rather than by memory.
Identifiers are bounded by encoded byte length rather than character count, so an over-long identifier is refused by the barrier and read as absent instead of surfacing as a filesystem error from a deeper layer. A capacity refusal is a distinct exception type from a containment violation, so readers can degrade on the first without ever swallowing the second.
Identifier validation before the filesystem is addressed
Stored SLA contracts and artifact rows now validate their identifiers before deriving a path, and the CLI and HTTP surfaces report a not-found rather than an internal error when one is rejected. Validation is lexical and runs first, so a hostile identifier never reaches path resolution; resolution still happens for well-formed ids, because it is the only thing that catches a run directory that is a symlink out of the runs root.
Also fixed here: log injection through unsanitised values, clear-text storage of sensitive material, a super-linear-backtracking regex in the adapter version scan (adversarial input took 14.7s against a 0.5s budget), and clear-text transport origins now required to be TLS or loopback-pinned.
Cache engine: traversal, claim race, and torn eviction
Verified against the shipped code before the fix: cache evict ../../../../pwned wrote its recall report outside the cache directory; a 16-thread cold start produced two or three claim winners in nine of twenty runs, meaning duplicate agent spawns; an interrupted transitive revocation left the root tombstoned while its children still served. The recall report path is now resolved and contained first, so an unsafe key is refused before the tombstone journal, the audit chain, or the filesystem is touched.
Suspend and resume: receipt binding and approval gating
The resume path took the latest receipt for a task rather than the one bound to the selected suspend row, never validated receipt identity before a release or resume mutation, enforced approval only in the CLI so a library caller could resume an approval-gated park with no decision at all, interpolated task_id straight into an approval filename, and accepted incomplete or unrelated continuity proofs.
The fix is one definition rather than four patches: a single predicate decides what counts as settled, one scope covers every suspend receipt rather than only the most recent park, chain and journal are both consulted so removing a terminal audit entry cannot re-arm an approval, and one implementation builds every approvals path. Reverting any single call site to its own construction fails the test that asserts they agree.
Mission phases: gate binding, and a host-independent projection
A phase could project as passed without the receipt binding its declared gate: the evidence check compared a receipt’s own task ids against its own hash list and never consulted the declared gate, so a receipt for an unrelated task satisfied the phase. Four sibling bypasses existed alongside it. A pass now requires the declared gate set to match, equal lengths, the stored hash to equal the recomputed hash, and the bundles to still hash as recorded.
Sealing a phase receipt had routed its hash through a redaction helper that reads the host’s home directory and process-global state, so the same ledger could project passed on one machine and unverified on another, and an offline verifier on a second host could not recompute the attested value. The self-consistency check that introduced that dependence has been removed rather than repaired: an attacker who rewrites a chain entry recomputes the hash too, so it only ever caught accidental corruption that the chain walk already catches, with a clearer verdict. receipt_hash() is a pure function of the receipt’s fields again, verified byte-identical to the previous release across seven ledger shapes and across differing home directories. A separate normalisation fixes negative zero, which serialises as a distinct token and had moved the status hash for any ledger that recorded one.
Audit query now reads the same segments the verifier does
verify() has replayed archived segments for some time; query() read only live ones, and the lineage projection uses query(). The two therefore disagreed about what exists, and an operator running ordinary audit-log retention was told a definition-lineage link was broken when nothing was broken. Every release until now made some accusation about an archived-but-honest record; nothing is wrong with such a record, so the correct output is silence.
The projection now reads archived segments too. Undecodable bytes still raise rather than being replaced: an invalid byte in an audit segment is evidence, and a reader that quietly substitutes a placeholder would return a record that does not match what is on disk while the verifier chokes on the same bytes.
Held back
Five changes were reviewed, found to introduce defects of their own, and moved to the next minor release rather than shipped here. They are not lost; each is tracked with its findings.
Two of them could not fit a patch release at all: a fail-closed rule that turned ordinary audit-log retention into a permanent refusal, and a journal seal that no production path emitted, which left an unsealed run indistinguishable from an unfinished one. One was correct but expensive: reading every segment you authenticate is right, and doing it took a per-gate operation from 0.3ms to 688ms on a large chain, which needs an index rather than a patch. The remaining two had headline fixes that did not hold under review.
Holding them back is the point of reviewing at all.
Code-scanning
The open-alert set was worked to disposition rather than to a number: real defects fixed with regression tests, and coercions that pin canonical serialisation (a bool is an int subclass, and int and float serialise differently) dismissed as false positives with a documented rationale, because removing them would change hash inputs and break byte-identical replay.