feat(governance): enterprise agent governance control plane across Rust, PyO3, WASM, and Python/JS - #415
Merged
Merged
Conversation
- Core Rust engine in entroly-engine::governance with cryptographic agent identity (HMAC-SHA256), ABAC policy evaluation (fail-closed, path denial, blast radius caps, approval gating), composable multi-signal risk scorer, tamper-evident audit hash chaining, and MCP supply-chain scanner (20 unit tests) - PyO3 native bindings in entroly-core::governance_bindings registering with the Python module - wasm-bindgen bindings in entroly-wasm and JS orchestration in js/governance.js for Node/WASM parity - Python orchestration package in entroly/governance/ (domain, identity, policy, authorization, audit, events) with native execution and fallback - Comprehensive test coverage in tests/test_governance.py (7 passed)
`governance/identity.py` imported `entroly_core` at module scope with its own try/except. The fallback was correct, but the decision was not: a direct import lets this module call into a core the rest of the process has already refused — below the declared minimum version, or missing symbols other modules require. `usable_core()` exists to be the single answer to "may this process call into entroly_core?", because a mixed process is worse than either pure mode. That split is what produced `ContextFragment.__new__() got an unexpected keyword argument 'recency_score'`. This was the sole cause of all six red jobs on the PR — five wheel builds and the pure-Python fallback — each failing `test_ungated_native_importers_do_not_grow` with 2219–4296 other tests passing. The two symbols are now fetched individually off the gated module. They are new in this release, so a core that is otherwise perfectly usable can still lack them; absence has to fall back rather than raise at first call. Verified both directions: against the current published core the symbols are absent and `_RUST_AVAILABLE` is False; after `maturin develop --release` builds this PR's bindings, all three resolve True. 25 governance and capability tests pass, ruff clean, and reintroducing the bare import fails the gate.
juyterman1000
added a commit
that referenced
this pull request
Sep 6, 2026
* feat(governance): give the control plane a product path `entroly/governance/` shipped in #415 with passing tests and no entry point. Nothing outside the package imported it, so the repository's own reachability check listed all seven modules as unreachable and the total rose from 37 to 44 — 2,676 lines no user could invoke. `tests/test_governance.py` passed throughout, because importing a module is exactly what it does, and the architecture notes name that trap: "a test that imports a module directly does not prove a user can reach it." Adds `entroly govern` — identity, policy, audit and status — as a thin projection of the governance API with no parallel logic. Reachability measured by the repo's own checker: unreachable 44 -> 37, governance 7 -> 0, restoring the documented pre-existing baseline. Driving the command surfaced four API mismatches that a direct-import test cannot see: - `AuthorizationService.stats` is a property, not a method. - `GovernanceAuditLog.verify_chain()` returns `(ok, detail)`. `bool()` on a non-empty tuple is always True, so the first version of this command would have reported a BROKEN audit chain as intact — a fail-open check, written in the same change that adds the gate against it. - The identity credential field is `identity_token`; reading `token` reported every signed identity as unsigned. - `Policy` exposes `id`/`allowed_scopes`/`max_risk_level`, not `scope`/`effect`; guessing rendered every policy blank, which reads as "no rules". Behaviour is fail-closed and auditable: a denial carries the policy that produced it and the reason, `audit verify` exits non-zero on a broken chain so CI can gate on it, the credential itself is never printed, and each response states what it does not prove. The remaining plan modules (evidence, risk, provenance, economics, supply_chain, triage, gate) and the server/proxy middleware are not included. This makes the shipped half reachable and inspectable; it does not yet route every agent action through the loop. * fix(governance): audit records ignored every isolation setting CI's README/argparse audit rejected `govern` as an undocumented subcommand. The allow-list next to it is the cheap exit; documenting the command is the honest one, since a command no user is told about is barely more reachable than one no module imports. Running the three commands before documenting them -- rather than writing them from the source -- showed `govern audit verify` reporting a path under the real `~/.entroly` while `ENTROLY_DIR` pointed somewhere else. Three defects behind that, each reproduced first: - `ENTROLY_DIR` was ignored entirely. Twenty-four modules honour it; governance was the one subsystem that could not be scoped to a project, so every test and sandbox wrote into the operator's real home directory. - The default was a module-level `Path("~/...").expanduser()`, expanded once at import. Relocating HOME afterwards had no effect -- which is exactly how tests and sandboxes isolate. - `ENTROLY_AUDIT_DIR` outranked the explicit constructor argument, so `GovernanceAuditLog(audit_dir=tmp)` silently wrote elsewhere whenever the variable was set, voiding a caller's isolation without a word. `tests/test_governance.py` constructs it exactly that way. Resolution now happens at call time, most specific first: explicit argument > `ENTROLY_AUDIT_DIR` > `ENTROLY_DIR`/governance/audit > home. Records round-trip through the resolved location and the hash chain still verifies. README documents `govern` with its limits stated: `audit verify` proves recorded entries were not altered, not that every action was recorded, and `govern status` reports local control-plane state, not an attestation that each agent action passed through it. Both regressions mutation-verified against the previous resolution. * fix(governance): python and rust disagreed on non-ascii identity tokens Rust is the core and Python is a wrapper over it, but only `identity.py` calls into `entroly_core`; `audit.py` and `policy.py` reimplement governance in Python. Nothing checked the implementations agreed. For identity tokens they did not. `_identity_payload_json` canonicalized with `ensure_ascii=True`, emitting `ä` where serde_json emits raw UTF-8. Rust re-serializes the payload it parses rather than hashing the string Python sends, so the two hashed different bytes for any identity carrying a non-ASCII field. Pure ASCII agreed, which is why it looked correct everywhere it was tried. Reproduced end to end: an identity for user "josé" minted where the native engine is absent fails verification where it is present, and `resolve_identity` treats that as forgery and downgrades to anonymous/read-only. An agent belonging to a user with an accent in their name silently loses every granted scope, and the log blames the token rather than the name. The ASCII control passes. Fixed by conforming Python to the Rust canonical form. A second coupling was undeclared: serde emits struct fields in declaration order, so the two forms agree only while `AgentIdentityPayload` stays alphabetical -- reordering it for readability would invalidate every issued credential and still pass `cargo test`. `tests/test_governance_native_conformance.py` now parses the Rust struct directly and fails by name, rather than surfacing as an opaque hash mismatch. It also pins the audit chain hash, which the Rust binding documents as identical across all three distributions while Python computes it independently; measured equal, now gated. PyYAML was declared in no dependency group and is not pulled in transitively, yet `governance/policy.py` is its only importer and degrades to built-in deny-by-default when the import fails -- logged, never raised. On a default `pip install entroly` an operator's policies.yaml was read by nothing: every agent ran read-only regardless of what the file said, while `load_policies` returned as though it had loaded. Now a declared dependency, and `policy_source_status` reports which source is in force and why, because two fallbacks are silent. Also from driving the command rather than reading it: - `--limit 0` meant 20 (`int(limit or 20)` cannot tell zero from unset). - `--limit -1` dumped the entire audit log: SQLite reads a negative LIMIT as unbounded, so the flag did the opposite of bounding on the one store built to grow without end. - An invalid identity scope escaped past `--json` and printed nothing, while an invalid `--risk` returned structured JSON. Same error class, two contracts. - `--policy-file` was registered on `policy check` but not `policy list`, so inspecting a candidate file failed with "unrecognized arguments". - Policy paths had the same import-time `expanduser()` and missing `ENTROLY_DIR` defect as the audit directory, fixed the same way. Every regression mutation-verified. One earlier version of the field-order test survived its mutation because it asserted on Python's own output; it now reads governance.rs. * fix(receipts): omitted-evidence previews flattened the source they quote Reported from the dashboard: previews in the omitted-evidence explorer looked garbled beside the selected fragments above them. `_preview` was `" ".join(text.split())`, which collapses every newline, tab and indent. A Python function came out as one run-on line -- `def f(name, size): palette = load_palette(name) return draw(...)` -- which is no longer valid in its own language. The explorer renders these in a block styled `white-space: pre-wrap`, directly next to selected fragments that keep their line breaks, so the data and the styling disagreed and the result read as corruption. Omitted evidence exists so a user can audit what the selector left out. A preview that silently reflows its source is a worse answer than a shorter, faithful one. Previews now keep line breaks, drop trailing whitespace and blank-line runs, and remain bounded at the same limit. The two single-line consumers -- the `- Preview:` Markdown bullet and `explain_omitted` -- flatten at render time, where that is a formatting choice rather than stored loss. Checked before changing the stored form: `novelty.py` consumes previews through `concept_terms`, and the term sets are identical for flattened and structured text, so novelty scoring is unaffected. It also prefers full text when available and only falls back to the preview. Mutation-verified: reverting to the flattening fails 3 of the new tests. One regression asserts every preview line is a substring of the source, so a preview can be shortened but never fabricated. * fix(governance): audit verify claimed integrity it cannot provide `govern audit verify` reported that chain verification "proves the recorded entries were not altered after the fact". Measured against a five-record log, that is false. The chain is unkeyed SHA-256 over (prev_hash, event_id, payload). What it actually detects: payload edited in place detected record removed from the middle detected records truncated from the end NOT detected edit with the chain recomputed NOT detected wholly fabricated consistent log NOT detected The last case reported "Chain intact (3 records verified)" over attacker-written records granting admin. Because the chain carries no secret, write access to the file is enough to produce a history that verifies, and a bare hash chain has no anchor for its own length, so dropping the newest records leaves every remaining link valid. The mechanism is reasonable tamper-evidence against accidental corruption and naive edits. The claim was not. Overstating what a verification proves is the failure mode the trust invariants exist to prevent, and it was written in the same change that added the command. `audit verify` now enumerates what it does and does not detect, and the claim boundary names the unkeyed limitation. Each of the five rows above is a test, so the disclosure cannot drift from the behaviour in either direction. Closing the two gaps means keying the chain with the operator secret and anchoring the record count -- a change to a security primitive that also has a Rust side, and worth doing on its own rather than folded in here. The first version of the disclosure test grepped the function source and matched the explanatory comment quoting the old wording, failing while the behaviour was already right; it now asserts on the emitted payload. It also passed an `audit_dir` field that nothing reads, so it silently exercised the operator's real ~/.entroly instead of a temp directory. * ci: derive the engine-less dependency list from pyproject The pure-Python fallback job installs `-e .` with `--no-deps` so the native engine is genuinely absent, then reinstalls the runtime dependencies by hand. That hand-written list was a second copy of `[project].dependencies`, and it drifted the moment one was added: `pyyaml` went into pyproject and not into the job, so the governance policy engine fell back to deny-by-default there and `test_an_operator_policy_file_is_actually_read` failed in CI rather than at the source. The copy had already drifted in a quieter way. It pinned `httpx>=0.27`, `starlette>=0.37` and `uvicorn>=0.30` while pyproject requires `>=0.28.1`, `>=1.3.1` and `>=0.51.0`, so the job had been resolving older minimums than any real install produces -- testing a configuration no user has. The list is now read from pyproject with `entroly-core` filtered out, which is the one dependency this job exists to exclude. The filter asserts it removed exactly one entry, so a rename cannot silently reintroduce the engine and leave the job quietly testing the wrong surface. --------- Co-authored-by: juyterman1000 <208309368+juyterman1000@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Introduces the foundation for the Enterprise Agent Governance Control Plane across the Rust core engine, PyO3 bindings, WASM bindings, and Python/JS orchestration shells.
As AI agents increasingly execute real-world mutations (tool calls, code changes, deployments), enterprises require cryptographically verifiable agent identities, scoped permissions, fail-closed policy enforcement, audit hash chains, and supply-chain security.
Architecture
Following Entroly's core principle (heavy cryptographic and computational logic in Rust; Python and Node as orchestration runtimes):
Rust Engine (\entroly-engine::governance):
PyO3 Native Bindings (\entroly-core::governance_bindings):
WASM & Node Bindings (\entroly-wasm::governance_bindings\ & \js/governance.js):
Python Governance Package (\entroly/governance/):
Verification & Test Evidence
uff check entroly/\ passed with zero errors.
Risk & Rollback