A Rust reimplementation of one class, ScopeTree: the store that holds
interlock's policy scopes (the per-tenant, per-agent
bindings). It's the only part of interlock whose memory grows with the number of
subjects, so it's the only part with a native version.
You probably don't need it. interlock ships a pure-Python ScopeTree that
needs no compiler. Build this crate when you bind policy to a very large number
of scopes (hundreds of thousands to millions) and the memory of the scope tree
matters. It stores each node with interned ids and packed values, which cuts the
per-node cost by roughly an order of magnitude, and it only creates Python
objects for the one scope being resolved on a cache miss.
It doesn't change how fast a cached lookup is. Resolved rules are cached above
the tree by ScopeRegistry either way, and the rule engine, the cache, and all
105 detectors behave the same with either backend.
pip install maturin
cd rust
maturin develop --release # builds interlock_scopes and installs it into the active environmentinterlock picks the native tree automatically whenever interlock_scopes can be
imported:
python -c "from interlock.policy import SCOPE_BACKEND; print(SCOPE_BACKEND)"
# rustNote: if the native module fails to import for any reason (not built, or built for a different Python), interlock quietly falls back to the Python tree and
SCOPE_BACKENDprintspython. Check it after every build.
Note: a stale
interlock_scopes*.soleft at the repository root by an older build is found before the freshly installed one when you run Python from the root. Your new build then seems to have no effect, and tests can fail in confusing ways.*.sofiles are git-ignored, sogit statuswon't show them. Runls *.soat the root and delete any you find.
The scope tests run against whichever backend is active:
pytest tests/test_scopes.py tests/test_scope_backend.py -qtests/test_scope_backend.py also runs the Python and Rust trees side by side
and checks that every resolve() returns the same result. The Python tree is the
reference.
To compare memory and lookup time, run python benchmarks/scope_memory.py once
without the module and once with it.
rust/engine holds the R31 evaluator, interlock_engine: the declarative
policy rule subset compiled to a Rust plan and evaluated without CPython. The
wheel carries a PyO3 front end to it; a separate cdylib serves everything else.
For a local policy the path is off unless INTERLOCK_NATIVE=1. An artifact
loaded by interlock.policy.artifact is different: it was explicitly compiled
and parity-gated, so CompiledPolicy plans its documents base on by
default (native_enabled=True, INTERLOCK_NATIVE=0 still overrides it through
the legacy engine) and reports native_scopes/python_scopes in status().
Scope templates are Python callables, so bound scopes stay on the Python
engine: one engine is wholly native or wholly Python, never mixed per rule.
The plan is all-or-nothing per policy. interlock/policy/native.py translates a
policy only when every rule is a plain when over action/field/arg with a
built-in operator, or one of the four detectors with a Rust port
(ansi_escape_output_guard, crlf_header_injection_guard,
download_extension_guard, code_eval_exec_guard). A detector outside that
set, a parameterised detector, a modify, an overlay, a disable list, a
shadow rule, or a mesh ceiling keeps the whole policy on the Python engine. That
is what makes the two paths agree by construction. Below
INTERLOCK_NATIVE_MIN_RULES rules (default 8) the JSON boundary costs more than
it saves and the planner declines.
The crate on its own needs no Python:
cargo test --manifest-path rust/engine/Cargo.tomlTwo front ends share Plan::evaluate:
- PyO3:
interlock_scopes.NativePlan(plan_json).evaluate(event_json)returns(verdict, policy_id, reason, attributed_to);interlock_scopes.ENGINE_ABIis the schema version. - C ABI:
rust/ffi(crateinterlock_engine_ffi), for a Node, Go, Rust or C host. It is a separate cdylib because the PyO3 extension is built withextension-moduleand deliberately leaves CPython symbols unresolved, so a non-Pythondlopenof the wheel fails. ABI version 2 adds the load-once handle:il_plan_load(plan_json, len, &err)parses a plan once and returns an opaque handle (null anderr = -1on error);il_eval_plan_handle(handle, event_json, len, &verdict, policy, cap, reason, cap)evaluates against it;il_plan_free(handle)releases it; null, unknown and double frees are no-ops because handles live in a registry, never in dereferenced memory.il_eval_plan(...)is the R31 stateless call, unchanged: plan JSON on every invocation. Both eval calls are buffer-out:0means success and writes a NUL-terminated policy id and reason into caller buffers;-1means any error and writes nothing, so a caller must fail closed. Verdicts are 0 allow, 1 block, 2 modify (never planned) and 3 escalate.
The C path has a genuinely non-Python caller in-tree: rust/abi-smoke/smoke.c
dlopens the library and checks a block, an allow and a fail-closed error.
Build and run it with only cargo and the system C compiler:
bash scripts/check_native_abi.shtests/test_native_engine.py runs both engines over the same events and
compares verdict, policy_id, reason, and attributed_to, including the
ported detectors and the false-positive corpus. Its ctypes check uses the FFI
library when present and skips otherwise; the C smoke is the authoritative
non-Python check. Both skip when the module is absent.
- Built with PyO3 0.24 against the stable ABI (
abi3-py39), so one build loads on CPython 3.9 and newer. - Common parameter values (str, int, bool, float, list) are stored in a compact form. Anything else is kept as the original Python object, so results never depend on the fast path.
rust/**/target/and*.soare git-ignored.maturin developis the supported way to build the Python extension;cargo buildbuilds the FFI library.
See the scopes guide for how scopes work.