Skip to content

feat(governance): enterprise agent governance control plane across Rust, PyO3, WASM, and Python/JS - #415

Merged
juyterman1000 merged 2 commits into
mainfrom
feat/enterprise-governance-engine
Sep 6, 2026
Merged

feat(governance): enterprise agent governance control plane across Rust, PyO3, WASM, and Python/JS#415
juyterman1000 merged 2 commits into
mainfrom
feat/enterprise-governance-engine

Conversation

@juyterman1000

Copy link
Copy Markdown
Owner

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):

  1. Rust Engine (\entroly-engine::governance):

    • Cryptographic Agent Identity: Deterministic payload serialization, HMAC-SHA256 token generation, and constant-time verification (\egov1:...).
    • ABAC Policy Evaluation: Fail-closed / deny-by-default, path glob & prefix blacklists, blast radius enforcement (\max_files, \max_lines), risk level caps, and mandatory human-approval gates.
    • Composable Risk Scorer: Multi-signal weighted risk scoring across SAST, blast radius, authentication, dependency changes, migrations, coverage, and identity status.
    • Tamper-Evident Audit Chain: SHA-256 hash chaining (\prev_hash | event_id | payload) with chain verification for detecting log tampering.
    • Deterministic Provenance: Content-addressed provenance IDs (\ProvenanceNode::compute_id).
    • MCP Supply-Chain Scanner: Static inspection of MCP tool schemas for prompt injection vectors, schema drift against known-good hashes, and tool name shadowing.
  2. PyO3 Native Bindings (\entroly-core::governance_bindings):

    • 9 thin JSON in/out PyO3 functions exposing the Rust governance engine directly to Python with zero business logic leakage.
  3. WASM & Node Bindings (\entroly-wasm::governance_bindings\ & \js/governance.js):

    • wasm-bindgen exports and TypeScript/JavaScript orchestration wrapper providing cross-runtime parity for JS/Node agent runtimes.
  4. Python Governance Package (\entroly/governance/):

    • Strongly-typed domain models (\domain.py), identity management (\identity.py), policy evaluation (\policy.py), runtime authorization service (\�uthorization.py), SQLite WAL & JSONL tamper-evident audit logger (\�udit.py), and synchronous typed event bus (\events.py).

Verification & Test Evidence

  • Rust Engine Tests: 20/20 unit tests passing in \entroly-engine\ (\cargo test governance).
  • Native Core Tests: 114/114 tests passing in \entroly-core\ (\cargo test --lib).
  • Python Unit Tests: 7/7 tests passing in \ ests/test_governance.py\ covering token roundtrips, policy allow/deny/approval, authorization gating, audit tamper detection, and event dispatch.
  • Linters & Static Analysis:

    • uff check entroly/\ passed with zero errors.
    • \cargo clippy --lib -- -D warnings\ clean across \entroly-engine, \entroly-core, and \entroly-wasm.
  • Pre-push Hook: Completed all pre-push verification gates cleanly.

Risk & Rollback

  • Risk: Low. Net-new additive foundation modules with zero changes to existing proxy or context optimization execution paths.
  • Rollback: Revert this PR cleanly without side-effects.

- 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
juyterman1000 merged commit f9debd1 into main Sep 6, 2026
41 checks passed
@juyterman1000
juyterman1000 deleted the feat/enterprise-governance-engine branch September 6, 2026 03:06
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>
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