Python API reference¶
Every public name you can import, with its real signature. Use this page to look things up. To learn how the pieces fit together, start with concepts and the guides linked from each section.
Everything in the first six sections is importable straight from interlock.
Scopes, receipts, the sandbox, and the testing helpers live in subpackages, and
each section says which one.
- Setup:
install,get_engine,set_engine - Guarding calls:
guard,monitor,guard_mcp_session,guard_langchain_tools,enforce_tool_call,enforce_tool_result - Rules and engines:
Rule,PolicyEngine,deny_tool,deny_when - Decisions:
Verdict,Decision,Blocked - Events:
SensorEvent - Run identity:
span,new_span,current_span,current_principal,current_parent_principal,set_principal - Scopes:
interlock.policy - Receipts:
interlock.sink,interlock.receipt - Sandbox:
interlock.brace - Testing helpers:
interlock.testing
For the Node.js daemon and its Python client, see the sidecar reference.
Setup¶
Guide: concepts, writing rules.
install¶
Sets the process-wide engine that every interceptor consults, and returns it.
| Parameter | Meaning |
|---|---|
engine |
The engine to install. If omitted, a new PolicyEngine(rules=rules) is built. |
rules |
Rules to use. With no engine, they become the new engine. With an engine, each one is appended to your engine via engine.add_rule, which changes that object. |
sink |
A receipt sink (for example FileSink("audit.jsonl")). Installs it with set_sink. Omitting it leaves any previously installed sink in place. |
Things to know:
- It is process-global. The last call wins.
- Passing
rulestogether with an engine that has noadd_rulemethod (aScopedEngine, or the sidecar'sRemoteEngine) raisesTypeError, saying the engine takes no extra rules. Build those engines with their rules first, then callinstall(engine=...). For aScopedEngine, bind extra rules at the root scope through a template. install()never removes a sink. Callset_sink(None)to turn receipts off.
import interlock
from interlock import deny_tool
engine = interlock.install(rules=[deny_tool("shell_exec")])
assert interlock.get_engine() is engine
get_engine¶
Returns the installed engine. If nothing was ever installed, it creates an empty
PolicyEngine, installs it, and returns it. An empty engine allows every
call, so a program that forgets install() runs unguarded without any error.
set_engine¶
Installs an engine without touching rules or the sink. It accepts any object with
an evaluate(event) -> Decision method and does no validation.
Guarding calls¶
Guides: integrations, MCP.
Every function in this section turns a call into a SensorEvent,
asks the engine for a Decision, and enforces it. They all share
the same three keyword options:
| Option | Meaning |
|---|---|
engine |
Use this engine instead of the installed one. (guard and monitor always use the installed one.) |
enforcement |
"blocking" (default) enforces the decision. "monitor" records it and never blocks or rewrites. Any other string enforces, so a typo like "monitoring" blocks rather than silently observing. |
principal |
Who is acting. Overrides the principal set by span for these calls. |
All of them raise Blocked when a call is denied under blocking
enforcement. If a rule itself raises, that exception reaches the caller unchanged
and the tool does not run (see PolicyEngine). When a receipt
sink is installed, each decision is recorded, including allows. In monitor mode, a
decision that would have blocked or rewritten the call is recorded with
observed=True, so the log never reads as enforcement that didn't happen.
guard¶
Decorator for your own functions, sync or async. For each call it:
- builds an event whose
actionis the function's__name__and whoseargsare the call's arguments bound to the signature, defaults filled in; - evaluates it and raises
Blockedbefore the body runs on a block; - on a modify, applies
modified_argswherever each argument was passed: positionally, by keyword, or into**kwargs; - runs the function and passes its return value through
enforce_tool_result, so result-phase rules apply too.
policy_id labels every decision on this tool that doesn't name a policy of its
own, on the call side and the result side, so it shows up in Blocked, logs, and
receipts. A rule's own policy_id always wins.
import interlock
from interlock import Blocked, Decision, guard
@guard(policy_id="shell.v1")
def run_shell(cmd: str) -> str:
return cmd
interlock.install(rules=[lambda e: Decision.block("no shells")])
try:
run_shell("ls")
except Blocked as e:
assert e.decision.policy_id == "shell.v1"
import interlock
from interlock import Decision, guard
@guard()
def transfer(amount: int, to: str) -> str:
return f"sent {amount} to {to}"
def cap(event):
if event.args.get("amount", 0) > 100:
return Decision.modify({"amount": 100}, reason="capped", attributed_to="amount")
return None
interlock.install(rules=[cap])
assert transfer(5000, "bob") == "sent 100 to bob"
monitor¶
Shorthand for guard(policy_id, enforcement="monitor", principal). Decisions are
logged to the "interlock" logger at DEBUG and written to the receipt sink if
one is installed, with observed=True on any that would have intervened. Nothing
is ever blocked or rewritten.
guard_mcp_session¶
guard_mcp_session(session, *, engine=None, enforcement="blocking", principal=None,
origin=None, schemas=None) -> Any
Replaces session.call_tool with a guarded version and returns the same session
object. Works whether call_tool is sync or async, and never imports the MCP SDK:
any object with a call_tool(name, arguments) method works.
Each call is checked with enforce_tool_call before it is sent, and the server's
response is checked with enforce_tool_result before it is returned. An official
SDK CallToolResult has its text joined into "result" first (see
result_payload), so result rules can read it. Extra
positional and keyword arguments to call_tool are passed through untouched.
| Parameter | Meaning |
|---|---|
origin |
The server this session talks to. Added to every call as the __origin__ label the MCP trust detectors read (mcp_trust_registry, mcp_capability_lease, mcp_server_attestation). |
schemas |
{tool_name: schema_string}: the schema the server declared for each tool. Added as the __schema__ label that mcp_tool_pinning checks. |
Labels are facts about a call, so only your code may set them. Before the check,
the wrapper drops any TRUST_LABELS key from the arguments the caller passed
(those usually come from the model, and a model that wrote "__origin__": "github"
could otherwise claim to be a trusted server). It then adds the labels from
origin and schemas, and removes every label again before the call goes to the
server. TRUST_LABELS is importable from interlock.interceptors.mcp:
{"__origin__", "__schema__", "__attestation__", "__tools__"}.
import asyncio
from interlock import PolicyEngine, guard_mcp_session
from interlock.detectors.mcp_trust_registry import mcp_trust_registry
class Server:
async def call_tool(self, name, arguments=None):
return {"sent": arguments}
engine = PolicyEngine(rules=[mcp_trust_registry(["github"])])
github = guard_mcp_session(Server(), engine=engine, origin="github")
print(asyncio.run(github.call_tool("create_issue", {"title": "Bug", "__origin__": "evil"})))
# -> {'sent': {'title': 'Bug'}} the spoofed label was dropped, and so was the real one
Only call_tool is wrapped. Connecting, listing tools, reading resources, and
sampling are not intercepted. The MCP trust detectors that act on those events
need you to send them yourself (see the MCP guide).
mcp_capability_lease never refuses a tools/list listing event, so it can share
an engine with mcp_surface_baseline.
guard_langchain_tools¶
Returns guarded copies of LangChain tools as StructuredTools with the same
name, description, and argument schema. Each call is checked with
enforce_tool_call, the original tool is invoked with the vetted arguments, and
its output is checked with enforce_tool_result before it is returned. Result
rules such as tool_result_injection_guard therefore apply to LangChain tools
too.
Requires the [langchain] extra (langchain-core). Importing interlock does not
import LangChain. Calling this function does.
enforce_tool_call¶
enforce_tool_call(action, arguments, *, engine=None, enforcement="blocking", principal=None) -> Dict[str, Any]
The core every adapter uses. Checks one call and returns the arguments to use:
- allow or monitor mode: a copy of
arguments(Nonebecomes{}). - modify: that copy with
modified_argsmerged in. - block: raises
Blocked.
The event gets its own copy of the arguments, so a rule that edits event.args
in place can't change what the tool receives. Only a modify decision can.
enforce_tool_result¶
Checks what a tool returned, as a phase="result" event whose args is
result_payload(result): a copy of result if it is a dict,
the joined text of an MCP CallToolResult, and {"result": result} for anything
else.
- allow or monitor mode: returns the original
result. - modify: returns
decision.modified_result. If that isNone, the original is returned, so a rule cannot replace a result withNone. - block: raises
Blocked. The tool has already run and its side effects stand. The caller just never sees the payload.
Rules and engines¶
Guide: writing rules.
Rule¶
A rule is any function that takes an event and returns a Decision, or None for
"no opinion". Returning Decision.allow() also counts as no opinion: the engine
keeps going.
Rules run on call-phase events by default. To make a rule see results instead, set an attribute on the function:
from interlock import Decision
def withhold_errors(event):
if "Traceback" in str(event.args.get("result", "")):
return Decision.block("internal error text withheld")
return None
withhold_errors.phase = "result" # now it only sees phase="result" events
A rule runs on exactly one phase. To check both, write two rules.
PolicyEngine¶
An ordered list of rules.
| Member | Meaning |
|---|---|
add_rule(rule) -> PolicyEngine |
Appends a rule and returns the engine, so calls chain. |
evaluate(event) -> Decision |
Runs the rules for the event's phase in order and combines their opinions (see below). If none objects, returns Decision.allow(). |
len(engine) |
Number of rules. Zero means everything is allowed. |
How opinions combine:
- A block from any rule wins and ends evaluation, even if an earlier rule rewrote the call.
- Rewrites compose. Each one is applied to a working copy of the event, and every later rule judges the rewritten call (or result). A later rewrite of the same argument wins.
- The result is one decision. A single rewrite comes back exactly as the rule
returned it. Several are combined into one
MODIFYwith every change (modified_argsmerged, or the lastmodified_result), the reasons joined with"; ", and the first rewrite'spolicy_idandattributed_to. - The caller's event is never changed.
from interlock import Decision, PolicyEngine, SensorEvent
engine = PolicyEngine(rules=[
lambda e: Decision.modify({"limit": 100}, reason="capped", policy_id="p.cap"),
lambda e: Decision.modify({"dry_run": True}, reason="dry run", policy_id="p.dry"),
])
d = engine.evaluate(SensorEvent(action="query", args={"limit": 5000}))
assert d.modified_args == {"limit": 100, "dry_run": True}
assert (d.reason, d.policy_id) == ("capped; dry run", "p.cap")
evaluate does not catch exceptions. If a rule raises, the exception propagates
through the interceptor and the tool does not run. Only deny_when converts a
crashing predicate into a clean block. (The sidecar is different:
it turns a raising rule into a block.)
deny_tool¶
Blocks every call whose action equals name exactly. Call phase only.
deny_when¶
Blocks when predicate(event) is truthy. If the predicate raises, the call is
blocked with the reason "rule error, denied fail-closed: <reason> (<error>)"
instead of the exception escaping.
Decisions¶
Guide: concepts.
Verdict¶
An IntEnum: ALLOW = 0, BLOCK = 1, MODIFY = 2. The integers are stable and
are what the wire protocol sends.
Decision¶
A dataclass describing what to do with one event.
| Field | Type | Meaning |
|---|---|---|
verdict |
Verdict |
Allow, block, or modify. Default ALLOW. |
reason |
str |
Human-readable explanation. Ends up in logs, Blocked messages, and receipts. Don't put argument values in it. |
policy_id |
str or None |
Which policy made the decision. |
modified_args |
dict or None |
For a call-side modify: the arguments to merge into the call. |
attributed_to |
str or None |
The name of the argument that triggered the decision ("path"), never its value. |
modified_result |
any or None |
For a result-side modify: the value that replaces the tool's result. |
Build decisions with the class methods:
| Constructor | Use it to |
|---|---|
Decision.allow(reason="", policy_id=None) |
State an explicit allow. Rarely needed: returning None does the same. |
Decision.block(reason, policy_id=None, attributed_to=None) |
Deny the call. |
Decision.modify(modified_args, reason="", policy_id=None, attributed_to=None) |
Let the call continue with some arguments replaced. Only the keys you pass change. |
Decision.modify_result(result, reason="", policy_id=None, attributed_to=None) |
Replace a tool's result (use in a result-phase rule). |
Blocked¶
The exception raised when a call or result is denied. It has two attributes,
decision (the full Decision) and action (the tool name). Its message is
blocked action '<action>': <reason>.
On the call side, the tool never ran. On the result side, it already ran and only its output was withheld.
Events¶
Guide: concepts.
SensorEvent¶
A dataclass describing one action as a rule sees it.
| Field | Type | Meaning |
|---|---|---|
action |
str |
The tool, function, or MCP tool name. |
args |
dict |
On a call: the arguments. On a result: result_payload(result). |
principal |
str or None |
Who is acting, from the principal= option or the current span. |
span_id |
str or None |
The run this event belongs to. |
ts |
float |
Wall-clock time the event was created. |
parent_principal |
str or None |
Who delegated the work, if anyone. |
phase |
str |
"call" (default) or "result". |
You only construct events yourself when testing a rule directly:
from interlock import SensorEvent, deny_tool
rule = deny_tool("shell_exec")
assert rule(SensorEvent(action="shell_exec", args={"cmd": "ls"})).reason == "tool not permitted"
assert rule(SensorEvent(action="read_file", args={})) is None
result_payload¶
Shapes a tool's return value into the args of a result-phase event. Every
result path uses it.
| The tool returned | args |
|---|---|
| a dict | a copy of it. If it has an MCP-style content list of text items and no "result" key, their joined text is added as "result". |
an MCP CallToolResult (anything with a content list of items that have .text) |
{"result": <text items joined with newlines>}, plus each top-level field of its structured content (structured_content or structuredContent, never replacing "result"), plus "is_error" (from is_error or isError) when the result reports it |
| anything else | {"result": <value>} |
from dataclasses import dataclass
from interlock.event import result_payload
@dataclass
class Text:
text: str
@dataclass
class CallToolResult:
content: list
structuredContent: dict = None
isError: bool = False
r = CallToolResult([Text("first"), Text("second")], {"temperature": 21})
assert result_payload(r) == {"result": "first\nsecond", "temperature": 21, "is_error": False}
The view is only what rules see. A result rewrite still replaces the whole value
the caller gets, so a rule that rewrites an MCP result hands back whatever it put
in modified_result.
Run identity¶
Guide: concepts.
These use contextvars, so values follow await and are separate per thread and
per asyncio task.
span¶
Starts a new span id for the block and, if given, sets the principal and parent principal. Everything is restored when the block exits, including on error. Spans nest.
from interlock import current_principal, span
with span(principal="acme/agent42") as span_id:
assert current_principal() == "acme/agent42"
assert current_principal() is None
new_span¶
Like span, but sets the values for the rest of the current context and never
restores them. Returns the new span id.
current_span, current_principal, current_parent_principal¶
current_span() -> Optional[str]
current_principal() -> Optional[str]
current_parent_principal() -> Optional[str]
Read the current values. None when unset.
set_principal¶
Sets the principal for the rest of the current context. The parent principal
changes only if you pass one, so set_principal("x") keeps an inherited
delegation link. Use span when you want the old values restored.
Scopes (interlock.policy)¶
Guide: scopes.
from interlock.policy import Binding, ScopedEngine, ScopeRegistry, ScopeTree, Template
from interlock.policy.scopes import DelegationOverGrant, DelegationDepthExceeded, UnknownTemplate
A scope is a tuple path such as ("root", "acme", "agent42").
interlock.policy also re-exports PolicyEngine, Rule, deny_tool, and
deny_when.
Template¶
A frozen dataclass: a named rule factory. factory(**params) must return a
Rule. Parameters named in union_params accumulate down the tree. All others
are overridden by deeper bindings.
Binding¶
A frozen dataclass attaching a template, with parameters, to one scope node.
| Field | Meaning |
|---|---|
template_id |
Which template this configures. |
params |
Keyword arguments for the factory. |
enabled |
False removes an inherited binding for this template at and below this node, until a deeper node binds it again. |
delegated_from |
A scope tuple. When set, this binding's resolved params must be a subset of what that scope resolves to, or resolving raises DelegationOverGrant. |
ScopeTree and PyScopeTree¶
The store of bindings. ScopeTree is the native Rust tree when the optional
rust/ module is installed, and PyScopeTree otherwise.
PyScopeTree is always available.
| Method | Meaning |
|---|---|
bind(path, bindings) |
Replaces all bindings at path and bumps its version, which invalidates cached engines below it. |
bindings_at(path) |
The bindings stored at exactly path. |
path_versions(leaf) |
The version of every node from root to leaf. Used as the cache key. |
resolve(leaf, union_lookup) |
Low-level merge. Use ScopeRegistry.resolve instead. |
SCOPE_BACKEND¶
"rust" or "python", depending on which tree ScopeTree is.
Union parameters always come back as a set, whichever tree resolved them. A
non-union parameter keeps whatever type you passed.
ScopeRegistry¶
Resolves scopes and caches one compiled PolicyEngine per scope.
| Parameter | Meaning |
|---|---|
tree |
A ScopeTree or PyScopeTree. |
templates |
{template_id: Template}. Copied when the registry is built, so adding to your dict later has no effect: use add_template. A binding that names an id not registered here raises UnknownTemplate when its scope is resolved. |
maxsize |
Maximum cached engines (least recently used are dropped). |
max_delegation_depth |
How deep delegated_from checks may recurse before raising DelegationDepthExceeded. |
| Member | Meaning |
|---|---|
resolve(leaf) -> dict |
The merged {template_id: params} for a scope, after checking every delegated binding on the path. |
compile(leaf) -> PolicyEngine |
Builds a fresh engine for a scope, bypassing the cache. |
for_scope(leaf) -> PolicyEngine |
The cached engine, rebuilt only if a node on the path changed. |
add_template(template) |
Registers a template (or replaces one with the same id) after the registry was built, and drops every cached engine. |
invalidate(leaf) |
Drops one cached engine. Rarely needed. |
hits, misses |
Cache counters. |
ScopedEngine¶
An engine that picks a scope for each event and delegates to that scope's
compiled engine. Install it with interlock.install(engine=ScopedEngine(registry)).
| Parameter | Meaning |
|---|---|
registry |
The ScopeRegistry to resolve against. |
scope_of |
event -> scope tuple. Defaults to principal_scope. |
default_scope |
Used when scope_of returns an empty tuple (typically an event with no principal). |
allow_unscoped |
False (default): an event with no scope is blocked. True: it is allowed without running any rule. |
An event with no scope is blocked by default. With no principal and no
default_scope, evaluate returns a block with the reason no scope for this
event (no principal and no default_scope); denied fail-closed and the policy id
scoped_engine.unscoped (UNSCOPED_POLICY_ID in interlock.policy.scopes). Set
default_scope to a baseline node to give such callers a floor policy instead, or
pass allow_unscoped=True if they really should run unchecked.
principal_scope¶
Splits event.principal on /, dropping empty parts: "root/acme/agent42"
becomes ("root", "acme", "agent42"). No principal gives ().
delegation_chain¶
The scopes an event acts through, delegator first: (parent_scope, own_scope)
when parent_principal is set, otherwise just (own_scope,). Empty scopes are
dropped.
Delegation errors¶
Import these from interlock.policy.scopes. They are not re-exported from
interlock.policy.
| Exception | Raised when |
|---|---|
DelegationError |
Base class for the two below. |
DelegationOverGrant |
A binding with delegated_from grants more than the delegator resolves to. |
DelegationDepthExceeded |
Delegation checks recursed past max_delegation_depth (a loop or a very deep chain). |
Both are raised from resolve, compile, and for_scope, so a bad grant never
reaches the cache. So is UnknownTemplate (a LookupError), for a binding whose
template id isn't registered: a binding that silently vanished would be a rule
that never exists. ScopedEngine.evaluate catches all three and returns a BLOCK
with policy id scoped_engine.resolution, so a call never sees them as raw
exceptions.
Receipts (interlock.sink and interlock.receipt)¶
Guide: audit log.
from interlock.sink import FileSink, InMemorySink, verify_file
from interlock.receipt import set_sink
FileSink¶
Appends one JSON line per receipt to path, chained and signed. key is the
signing key in bytes. Without one, a random per-process key is used and only this
process can verify the file.
A reopened file continues its chain. When path already holds receipts (after
a restart, say), the new FileSink reads the last one and links the next receipt
to it, so one file can span restarts. Keep the same key, or the older receipts
won't verify under the new one. If the file's last line isn't a readable receipt
(a crash mid-write, a foreign file), the constructor raises ValueError instead of
appending to damage nobody saw. Move the file aside to start a new log.
Two FileSinks writing the same file at the same time still fork the chain,
and verification reports the fork as receipt N: chain break (...). One writer per
file at a time.
InMemorySink¶
Keeps receipts in the receipts list attribute. Useful in tests.
ChainedSink¶
The base class. Subclass it and implement _append(receipt) and _read_back()
to store receipts somewhere else. Any object with a write(receipt) method can be
installed as a sink, but only ChainedSink subclasses chain and sign.
| Method | Meaning |
|---|---|
write(receipt) |
Links the receipt to the previous one, signs it, and appends it. Thread-safe within one instance. |
head |
Property: the mac of the latest receipt (GENESIS before the first). Record it somewhere the log's writer can't reach. |
verify(key=None, *, head=None, verifier=None) -> Optional[str] |
Re-checks the whole chain. Returns None if intact, or a message naming the first bad index. key verifies with a different key. head also checks that the chain ends where you recorded it. verifier checks signatures without the signing key (see below). |
Deleting receipts from the end of a log leaves a valid, shorter chain that the
linkage alone can't catch. Comparing against a recorded head does:
import interlock, os, tempfile
from interlock import enforce_tool_call
from interlock.receipt import set_sink
from interlock.sink import FileSink, verify_file
key = b"k" * 32
path = os.path.join(tempfile.mkdtemp(), "audit.jsonl")
sink = FileSink(path, key=key)
interlock.install(rules=[], sink=sink)
for _ in range(3):
enforce_tool_call("read_rows", {"table": "orders"})
head = sink.head
set_sink(None)
with open(path) as fh:
lines = fh.readlines()
with open(path, "w") as fh:
fh.writelines(lines[:2]) # someone deletes the last receipt
print(verify_file(path, key=key)) # -> None: the linkage looks fine
print(verify_file(path, key=key, head=head))
# -> chain ends at receipt 1 but the expected head is ...: receipts were cut from the end, or this is not the log that head came from
Receipt¶
A dataclass. One per enforced decision.
| Field | Meaning |
|---|---|
ts |
Time the receipt was written. |
action |
Tool name. |
verdict |
"ALLOW", "BLOCK", or "MODIFY" (the name, as a string). |
reason, policy_id, attributed_to |
Copied from the decision. |
principal, span_id, phase |
Copied from the event. |
args_digest |
SHA-256 over the event's arguments, as the caller passed them. The values are never stored. |
prev |
The previous receipt's mac, or 64 zeros (GENESIS) for the first. |
mac |
The signer's tag over every other field, hex-encoded. |
observed |
True when monitor mode recorded a decision without enforcing it. The call went through even though verdict may say BLOCK. |
to_json() and Receipt.from_json(line) convert to and from the canonical
one-line form. fields(include_mac=True) returns that canonical shape as a dict.
observed appears in it (and in the signed bytes) only when it is True, so
receipts written before the field existed still verify.
Functions and the signer seam¶
| Name | Meaning |
|---|---|
set_sink(sink) |
Installs the process-wide sink. None turns receipts off. |
get_sink() |
The installed sink, or None. |
Signer |
The type Callable[[bytes, bytes], bytes]: (key, message) -> tag. |
Verifier |
The type Callable[[bytes, bytes], bool]: (message, tag) -> valid. |
hmac_sha256(key, message) |
The default signer. |
process_secret() |
This process's random default key. |
signing_payload(receipt) -> bytes |
The exact bytes a receipt's mac covers (every field but mac). |
verify_chain(receipts, signer, key, *, verifier=None, head=None) |
Checks a list of receipts directly. Same messages as verify(). |
verify_file(path, *, key=None, signer=hmac_sha256, verifier=None, head=None) (in interlock.sink) |
Checks a log on disk without opening it for writing: the auditor's entry point. Needs key or verifier, or raises ValueError. |
digest_args(args) |
The digest used for args_digest. |
Without a verifier, verification recomputes each tag with signer and key, so
the verifier needs the signing key. That's the right shape for the default HMAC.
For a public-key scheme, sign with the private key and hand auditors a verifier
that checks with the public key. Here it is with Ed25519 from the cryptography
package (not an interlock dependency):
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from interlock.sink import FileSink, verify_file
def ed25519_sign(private_bytes, message): # the writer's Signer
return Ed25519PrivateKey.from_private_bytes(private_bytes).sign(message)
sink = FileSink("audit.jsonl", signer=ed25519_sign, key=private_bytes)
public_key = Ed25519PublicKey.from_public_bytes(public_bytes)
def ed25519_verify(message, signature): # the auditor's Verifier
try:
public_key.verify(signature, message)
return True
except InvalidSignature:
return False
verify_file("audit.jsonl", verifier=ed25519_verify) # -> None when intact
Sandbox (interlock.brace)¶
Guide: sandbox.
from interlock.brace import (
Sandbox, SandboxProfile, compile_profile, available_backends,
IsolationUnavailable, LimitUnsupported, KNOWN_TOKENS,
)
Sandbox, SandboxProfile, and compile_profile are also importable from
interlock.
Sandbox¶
Sandbox(profile=None, *, backend=None, engine=None, enforcement="blocking",
principal=None, require=())
| Parameter | Meaning |
|---|---|
profile |
What the process may reach. Defaults to SandboxProfile.locked(): nothing. |
backend |
Pin "bubblewrap", "sandbox-exec", or "soft". Default: the strongest one available. |
engine, enforcement, principal |
As for enforce_tool_call, applied to the launch check. |
require |
enforced tokens that must hold, e.g. ("net", "fs-rw"). run() refuses, before starting anything, when the backend can't deliver one. A bare string counts as one token. |
The constructor raises:
ValueError("unknown backend: ...")for a backend name that doesn't exist (including"nftables"), andRuntimeError("backend not available on this host: ...")for one this machine can't run;ValueErrorfor arequiretoken that isn't inKNOWN_TOKENS, so a typo fails here instead of refusing every run;LimitUnsupportedwhen the profile asks for a resource limit this host can't enforce.
backend_name tells you which backend was picked.
| Method | Meaning |
|---|---|
run(argv, *, capture_output=True, timeout=None) -> SandboxResult |
Checks the launch with the policy engine, then checks require against what the backend would enforce, then runs the command. Raises Blocked or IsolationUnavailable without starting a process. |
plan(argv) -> WrapPlan |
Returns the wrapped command without running it, and without the policy check or the require check. A WrapPlan has exec_argv, popen_kwargs, enforced, and cleanup (call it if it is not None). |
import sys
from interlock.brace import IsolationUnavailable, Sandbox
try:
Sandbox(backend="soft", require=("net",)).run([sys.executable, "-c", "print('hi')"])
except IsolationUnavailable as e:
print(e) # nothing ran
# -> refusing to run: the soft backend cannot enforce ['net'] for this profile (it enforces ['env'])
The launch check is an ordinary call event with action="process_spawn" and these
args: path (argv[0]), argv, allow_network, allowed_hosts, and
write_paths. A rule can block it (run raises Blocked) or return a modify
with a new argv list, which is what runs instead. An empty argv raises
ValueError. A timeout that expires raises subprocess.TimeoutExpired.
SandboxProfile¶
A frozen dataclass.
| Field | Default | Meaning |
|---|---|---|
read_paths |
() |
Host paths mounted read-only. |
write_paths |
() |
Host paths mounted read-write. |
allow_network |
False |
Whether the process gets any network at all. |
allowed_hosts |
() |
Hosts the grant is meant for. The OS backends can't restrict to these, so they open the network fully and don't report net. |
env |
{} |
The child's complete environment. Nothing is inherited from the parent. |
cpu_seconds |
None |
CPU time limit. Applied on every backend. |
memory_mb |
None |
Address-space limit. Applied on every backend on Linux. Refused on macOS, where the kernel won't accept a useful address-space limit (LimitUnsupported). |
unshare_pid |
True |
Separate PID namespace where the backend supports it. |
workdir |
None |
Working directory for the command. Honoured by every backend. |
On Windows, any resource limit raises LimitUnsupported: there are no POSIX
limits to apply. A requested limit is never dropped silently.
SandboxProfile.locked() returns the fully closed default.
profile.with_paths(read=(), write=()) returns a copy with extra paths added.
compile_profile¶
Builds a profile from the {template_id: params} dict that
ScopeRegistry.resolve returns. It reads three bindings and ignores the rest:
| Binding | Params | Effect |
|---|---|---|
egress_allowlist |
allowed_hosts |
A non-empty list turns network on. |
sandbox_fs |
read_paths, write_paths |
Filesystem grants. |
sandbox_limits |
cpu_seconds, memory_mb |
Resource limits. |
base_env becomes the child's environment, and workdir its working directory.
resolved=None or {} gives a locked profile.
SandboxResult¶
| Field | Meaning |
|---|---|
backend |
The backend that ran the command. |
argv |
The command as executed (after any policy rewrite), without the wrapper. |
returncode |
The exit status. |
stdout, stderr |
Captured output, or "" when not captured. |
enforced |
The guarantees that actually held, as tokens: pid, net, fs-ro, fs-rw, rlimit, env. Only soft reports rlimit: bubblewrap and sandbox-exec apply the same limits but don't claim the token, because no escape test pins it for them yet. |
ok |
Property: returncode == 0. |
available_backends and select_backend¶
available_backends() lists what this machine can run, strongest first. soft
is always last and always present. select_backend returns a backend instance,
raising the same errors as Sandbox(backend=...). You rarely need it directly.
IsolationUnavailable¶
A RuntimeError raised by Sandbox.run() when a required token isn't among the
guarantees the backend would enforce. Nothing was started. Attributes: backend
(the backend's name), missing (the required tokens it lacks), and enforced
(what it does enforce).
LimitUnsupported¶
A RuntimeError raised when a profile asks for a resource limit the host can't
enforce: memory_mb on macOS, or any limit on Windows. It's raised when the
Sandbox is built and again by run() (the profile may have changed). The
message names the limit and the platform.
The check itself is interlock.brace.limits.check_limits(profile, platform=None).
interlock.brace.limits.rlimit_preexec(cpu_seconds, memory_mb) builds the
preexec_fn every backend uses to apply the limits in the child.
KNOWN_TOKENS¶
The frozenset of every token a backend can report:
{"env", "rlimit", "fs-ro", "fs-rw", "pid", "net"}. require is checked against
it.
Testing helpers (interlock.testing)¶
Guide: Node.js client.
The package's top level exports nothing. Its modules exist so that clients in other languages, and your own test suites, can check behaviour against a real daemon. Most users never import them.
| Import | What it gives you |
|---|---|
interlock.testing.harness.serve(engine, path, *, sink=None, timeout=5.0) |
Context manager that runs a sidecar daemon on a background thread for the duration of a with block. On exit it closes every open client connection, restores the previous receipt sink, and removes the socket. Put path in a private directory, e.g. under tempfile.mkdtemp(). |
interlock.testing.harness.send_raw(path, line, *, timeout=5.0) |
Sends one raw frame and returns the decoded reply. Use it to test malformed input. |
interlock.testing.fixtures.CONFORMANCE_ENGINE |
The fixed 12-rule engine the conformance corpus is written against. CONFORMANCE_SPEC is its module:attr string for interlock-sidecar --rules. |
interlock.testing.conformance |
cases() (13 events and the decision each must produce), negatives() (9 malformed frames and the error each must produce), build(), write_corpus(path), load_corpus(path), and CORPUS_VERSION. The frozen copy lives at tests/conformance/events.json. |
import os, tempfile
from interlock import PolicyEngine, SensorEvent, deny_tool
from interlock.sidecar import RemoteEngine
from interlock.testing.harness import serve
path = os.path.join(tempfile.mkdtemp(), "guard.sock")
with serve(PolicyEngine(rules=[deny_tool("shell_exec")]), path):
with RemoteEngine(path, timeout=5) as remote:
decision = remote.evaluate(SensorEvent(action="shell_exec", args={}))
assert decision.verdict.name == "BLOCK"