Framework Adapters
mitrity-hook governs Claude Code's built-in tools from outside the framework: a process the framework spawns per tool call. That works because Claude Code has a hook system with a command-line idiom. The Claude Agent SDK, LangChain and the frameworks after them do not spawn hooks — they run your code in-process. What replaces the hook there is a library you import, and a library can be wired in a hundred ways. A Go agent has no hook system and, as a rule, no framework either: its tools are plain functions, and what replaces the hook is a wrapper you apply to each one.
This page pins the one way that counts as MITRITY governance. It specifies what a language adapter guarantees when it wraps an agent framework, the public API and package layout of the adapters (mitrity on PyPI, @mitrity/sdk on npm, github.com/mitrity-io/mitrity-go), the attestation an adapter must report, the versioning rule that ties an adapter release to the admission protocol, and the conformance tests every adapter ships. An integration that admits some tool calls, or fails open when the edge is down, or attests coverage it does not have, is not an adapter in the sense of this page, and MITRITY never presents it as one. The guarantees below are the definition; the conformance tests are how an implementation proves it meets them.
The wire contract this page builds on is the Admission API. Nothing in it is restated here; where this page says "admit", it means POST /v1/admit exactly as that contract defines it.
Vocabulary
| Term | Means |
|---|---|
| Adapter | A library that connects one framework's tool execution to the co-located edge: built-in tools through POST /v1/admit, MCP tools through Mitrity Gateway, and the runtime's posture through POST /v1/attest. |
| Framework | The agent runtime being wrapped: claude-agent-sdk, langchain, openai-agents, crewai; custom for a Go agent built without one. |
| Built-in tool | A tool the framework executes itself, without an MCP tools/call: the Agent SDK's Bash, Write, Edit, WebFetch; a LangChain BaseTool's _run. |
| Entrance | Where a call meets the governance pipeline: the MCP gateway, the Mesh Authorizer, or the admission API. An adapter uses the last one for built-ins and leaves MCP tools to the first. |
| Edge | The co-located mitrity-gateway (or mitrity-mcp-sidecar) serving the admission API for exactly one agent identity. |
Guarantees
An adapter MUST hold every one of these. They are the acceptance criteria, and the conformance tests map onto them one for one.
G1 — Every tool invocation is judged before it runs. Every built-in tool the adapter wraps is admitted through POST /v1/admit before the framework executes it. Every MCP tool reaches the model through Mitrity Gateway, where the gateway's own pipeline judges it as mcp:<tool>. A tool that reaches neither entrance is not governed, and the adapter says so in unhooked_exec_tools (built-ins) or other_mcp_servers (MCP) rather than staying silent.
G2 — Fail closed, with no fail-open mode. An unreachable socket, a missing or empty token file, a 401 that survives the one permitted retry, a 400, a 503, a malformed body, a decision value the adapter does not recognize, a response that arrives after the deadline — each is a deny. The deny reason tells the model it is not a policy decision, so an outage and a rule are distinguishable from inside the conversation. Adapters do not implement the hook's MITRITY_HOOK_FAIL_MODE=open escape hatch (Hook configuration): the hook is a binary an operator installs once and can flip while provisioning; an adapter is code you ship, and a fail-open switch in shipped code is a switch nobody audits. An adapter reads that variable and ignores it.
G3 — The adapter owns its deadline. One decision is bounded by the adapter's own deadline, 500 ms by default and never more than 30 s, measured from before the token file is read to after the body is parsed. The adapter never inherits the framework's hook timeout. This holds even where the framework does block on a timed-out hook. Two different mechanisms are involved, and the caller contract describes the first: a Claude Code command hook — the process mitrity-hook runs as — that times out is "no opinion" and the tool call proceeds; an Agent SDK callback hook — what an adapter registers — that times out blocks the call since Claude Code 2.1.210, with a generic message. Neither is the design: a framework timeout arrives a long time later; the adapter's deadline yields the real reason now.
G4 — Holds are two calls, and a hold nobody answered is a deny. The adapter first asks with hold_timeout_seconds: 0 under its own deadline (G3). Only when that answers held does it re-submit the same request with its hold budget (540 s by default, never more than 570 s) so the edge long-polls the approval. An allow from the second call runs the tool; a deny blocks it, naming the approval_id; a held after the budget, or any failure of the second call, blocks it. The cost — two approval records for one action, because the API has no poll route and the edge opens a new approval for the re-submitted request; the second is the one a human resolves — is the price the hook pays too (caller contract, item 4) and is stated in every adapter's documentation. Where the framework lets the adapter tell the model it is waiting, it does.
G5 — updated_input runs, the original does not. When an allow carries updated_input, the adapter merges it key by key over the framework's tool input and the framework runs the merged input. Keys absent from updated_input keep the model's values. Where the framework offers no channel through which the merged input can reach the executor — the OpenAI Agents SDK's apply_patch editor, a function tool whose argument is not an object — the rewrite is a deny: the original bytes never run under a decision made about different ones. When routed_to is present the adapter records it (its log line, its statistics) without inspecting the rewritten command. Under builtin_exec_routing: governed_shell this is how a Bash call becomes mitrity-hook exec <ticket>: the framework runs the relay, the relay redeems the ticket, and the adapter never redeems a ticket or executes anything itself.
G6 — Attestation is accurate and arrives at session start. The adapter sends POST /v1/attest with the fields below when the session starts — on the framework's session-start hook where one exists, and on the first hook invocation of a session where none does — and again whenever the config hash changes. Lists are computed from the configuration the adapter actually installed, never from what it intended: a tool the matcher does not cover is unhooked; an MCP server you added next to the gateway is other; settings files the adapter did not enumerate are reported as such.
G7 — Loopback or Unix socket, token from a file, never in argv or logs. The adapter refuses an admission address that is neither loopback nor a Unix socket before any I/O, with the hook's exact rule. The token is read from the file on every attempt, sent only in X-Mitrity-Admission-Token, and never logged, never placed in an environment variable the adapter sets, never written anywhere. A 401 re-reads the file and retries exactly once.
G8 — No fabricated allow. There is no cached decision, no local policy evaluation, no degraded mode. An allow the adapter acts on is an allow the edge sent for this request. Every judged call is one admission request (or two, under G4), so the execution statistics the edge reports (ExecutionStats) count what happened.
G9 — Deny in the framework's own blocking idiom. A deny is expressed the way the framework itself blocks a tool — permissionDecision: "deny" with permissionDecisionReason for the Agent SDK, a ToolException carrying the reason for LangChain, a returned *govern.DeniedError in Go, a tool-input guardrail's reject_content or an approval's rejection for the OpenAI Agents SDK, a ToolFailure for CrewAI — never as a warning, a log line or a modified result. The edge's reason reaches the model verbatim. One narrow exception: where the framework's only channel back to the model is the tool's output (the OpenAI Agents SDK's LocalShellTool), the deny is delivered as that output with nothing executed — a refusal in the tool's voice, never the modified result of an execution.
G10 — Allow is silent, except when routed. An allowed call produces no permissionDecision for the Agent SDK, so your own permission flow (allowed_tools, can_use_tool, prompts) continues unchanged: MITRITY is a second opinion on top of your rules, not a replacement. The one exception is a routed call (G5): there the adapter emits permissionDecision: "allow" together with updatedInput, exactly as Routed Bash step 3 specifies for the hook, because a human should not be prompted about a relay command that carries a ticket.
G11 — The request is verbatim. tool_name is the framework's name for the tool, case preserved; tool_input is the framework's input object with nothing renamed, reordered or dropped. The adapter never translates argument names to help a policy match: a rule that matches command matches command, and a tool whose argument is called commands is matched by a rule that says commands. A serialized request body larger than the edge's 64 KiB cap (admission.max_body_bytes, default 65536; measured over the whole body, not tool_input alone) is denied locally with the reason "larger than MITRITY will judge" rather than sent to be refused.
Wire mapping
| Claude Agent SDK (Python, TypeScript) | LangChain (Python) | Go (mitrity-go) | |
|---|---|---|---|
request surface | claude_agent_sdk | langchain | custom; a framework named with govern.WithFramework that has a surface of its own is sent as that surface |
attestation framework | claude-agent-sdk | langchain | custom, or the name govern.WithFramework(name, version) gives |
attestation adapter | mitrity-python / mitrity-js | mitrity-python | mitrity-go |
framework_version | the SDK package version (claude_agent_sdk.__version__; the npm package version) | langchain_core.__version__ | the version WithFramework gives; omitted otherwise |
tool_name | the hook's tool_name (Bash, Write, …) | tool.name | Tool.Name(), or the tool argument of Caller.Call |
tool_input | the hook's tool_input | the parsed keyword arguments of the call; a single positional string argument is sent under the tool's one declared argument name, else input | the map[string]any handed to Call, verbatim ({} for a nil map) |
session_id | the hook's session_id | explicit session_id, else config["configurable"]["thread_id"], else one id per process generated when the adapter is first used | govern.WithSessionID, else the value govern.ContextWithSessionID put on the context, else one id per Governor generated when it is built |
cwd | the hook's cwd | the process working directory at call time | govern.WithCwd, else the process working directory at call time |
tool_use_id | the hook's tool_use_id | the run manager's run_id when present | the value govern.ContextWithToolUseID put on the context, else a random id per call |
| Action type on the edge | builtin:<lowercased tool_name> (builtin:bash, builtin:write); shell:execute when routed | builtin:<lowercased tool.name> (builtin:terminal for ShellTool) | builtin:<lowercased tool_name>; shell:execute when routed |
audit surface | agent_hook | agent_hook | agent_hook |
| MCP tools | through the gateway, mcp:<tool>, surface=mcp_gateway; not admitted again | not applicable | through the gateway, mcp:<tool>, not admitted again; a client that reaches an MCP server directly may be wrapped with govern.WrapCaller, and its calls are then admitted like any other tool (builtin:<lowercased tool>, surface=agent_hook) |
The builtin: convention is the edge's, not the adapter's: the edge derives the action type from tool_name, so a policy written as builtin:* covers every framework's built-ins and one written as builtin:terminal covers the LangChain shell tool by its own name (Writing Policies). Argument names are likewise the framework's (G11): the edge's command-shaped keys are command, cmd, script, args and argv (semantic command constraints), so a tool whose argument is spelled differently is content-scanned and DLP-scanned but not parsed as a command until that key joins the family on the edge. The adapter does not paper over that with a rename.
Discovery and configuration
An adapter finds the edge the way the hook does, from the same environment variables with the same defaults, so one provisioning step serves both:
| Variable | Default | Rule |
|---|---|---|
MITRITY_ADMISSION_ADDR | unix:/run/mitrity/admission.sock (127.0.0.1:8777 on Windows) | unix:/unix:// prefix or an absolute path is a Unix socket; otherwise the host must be 127.0.0.1, ::1 or localhost. Anything else is refused before any I/O (G7). |
MITRITY_ADMISSION_TOKEN_FILE | /run/mitrity/admission.token (%PROGRAMDATA%\Mitrity\admission.token on Windows) | Read per attempt; empty file is a failure. |
MITRITY_HOOK_TIMEOUT | 500ms | Deadline for one decision; parsed as a Go duration or a bare number of seconds; clamped to 30s. |
MITRITY_HOOK_HOLD_TIMEOUT | 540s | Hold budget; 0 disables the second call (a held is then a deny); clamped to 570s. |
MITRITY_HOOK_FAIL_MODE | — | Ignored by adapters (G2). |
The two path defaults are the host layout and the adapters' compiled-in fallback. On Kubernetes the charts and the injector set both variables explicitly to the /run/mitrity/edge/ layout (Hook configuration), so an adapter never needs to know which it is in.
Every value may also be passed explicitly to the client constructor; an explicit value wins over the environment. A malformed duration falls back to the default rather than failing the session, exactly as the hook does — a misconfigured timeout must not become an ungoverned session.
Versioning rule
The admission API is versioned by the X-Mitrity-Admission-Version header (1 today). The rule that ties an adapter release to it:
- One adapter version speaks exactly one protocol version. It is a constant the package exports (
mitrity.admission.PROTOCOL_VERSION,PROTOCOL_VERSIONfrom@mitrity/sdk,admission.ProtocolVersionin Go) and it is the value sent on every request. There is no negotiation and no fallback: an edge that does not speak it answers400, which is a deny whose reason names the version the adapter sent and says the edge or the adapter needs upgrading. - The edge echoes the header on every response. An adapter checks it when present and treats a different value as a protocol error — a deny — because a body under a different schema is not a body it can read safely.
- Adapter packages are semver. Adopting a new protocol version is at least a MINOR bump of the adapter and is recorded in the table below; a protocol change that removes a field the adapter's public API exposed is a MAJOR bump. Within one protocol version, adapter releases are additive.
adapter_versionon the attestation is the package version. The control plane can therefore tell an adapter that still speaks a retired protocol from one that has been upgraded, without a wire change.
| Adapter | Package version | Protocol |
|---|---|---|
mitrity-python | 0.1.x, 0.2.x (adds the OpenAI Agents SDK and CrewAI adapters) | 1 |
mitrity-js | 0.1.x | 1 |
mitrity-go | 0.1.x | 1 |
A new protocol version lands in the Admission API first, then in the edge, then in this table with the adapter release that adopts it.
Attestation fields
The body of POST /v1/attest is the runtime attestation. The table says where each field comes from in an adapter, so two adapters report the same fact the same way.
| Field | Claude Agent SDK adapter | LangChain adapter | Go adapter |
|---|---|---|---|
framework | claude-agent-sdk | langchain | custom, or the name WithFramework gives |
framework_version | the SDK package version | langchain_core.__version__ | the version WithFramework gives; omitted otherwise |
framework_version_source | omitted. A version an adapter read in-process is a reported value, and the edge records a version sent without a source as pinned — which, on the attestation, means exactly "reported, not measured by running the framework". Only mitrity-hook reports probed, for a version it obtained by running the framework binary that started the session; an adapter never sends probed (POST /v1/attest). | omitted, as for the Claude Agent SDK adapter | omitted, as for the Claude Agent SDK adapter |
session_id | the session_id the calls that follow will carry, when the adapter has one at attest time. The edge keys a session's version only on a probed attestation, which an adapter never sends; an adapter's reported version is pinned and sets no session version, so each call is judged by its own framework_version. The id still ties the attestation to the calls for the coverage view. Omitted when the adapter has none. | as for the Claude Agent SDK adapter | as for the Claude Agent SDK adapter |
adapter | mitrity-python / mitrity-js | mitrity-python | mitrity-go |
adapter_version | the package version | the package version | mitrity.Version, the module version |
hooked_tools | the built-in tools the installed PreToolUse matcher names: by default the execution-capable set Bash, Write, Edit, MultiEdit, NotebookEdit, WebFetch, WebSearch; a tool the tools option removes from the runtime is never listed | the name of every tool passed through govern() / govern_tools(), accumulated for the session | the name of every tool passed through Wrap / WrapFunc / WrapAll or named with Governor.Declare, plus every tool name a WrapCaller client has admitted, accumulated for the session, deduplicated and sorted |
unhooked_exec_tools | the execution-capable set minus hooked_tools, with nothing else subtracted: like the hook, the adapter lists a disallowed tool here too and the control plane subtracts disallowed_tools before raising the finding; a tool the tools option removes from the runtime is neither hooked nor unhooked; a tool that reaches PostToolUse without having been admitted is added and the attestation re-sent | empty by construction — the adapter can only see the tools it was handed; coverage is exactly hooked_tools and the documentation says so | what WithUngoverned(names...) declares: the adapter can only see the tools it was handed, so you name the execution-capable ones the agent runs outside it; empty otherwise, and the documentation says so |
disallowed_tools | the options' disallowed_tools | empty | empty |
other_mcp_servers | every mcp_servers entry other than the gateway entry; plus one settings:<source> entry per enabled settings source when strict_mcp_config is false, because MCP servers those files add are ungoverned paths the adapter did not enumerate | empty | what WithOtherMCPServers(names...) declares: every MCP server the agent reaches other than the gateway, whether or not its client is wrapped with WrapCaller |
permission_mode | the options' permission_mode (default when unset); re-attested when a hook input reports a different mode | omitted | omitted |
sandbox | from the options' sandbox block when present: enabled, allow_unsandboxed_commands, fail_if_unavailable, each null when the key is absent; the object is omitted when no sandbox is configured | omitted | omitted |
config_hash | see below | see below | see below |
The prefixed entries are deliberate. An adapter that loads your ~/.claude/settings.json or the project's .mcp.json cannot enumerate the servers those files add without reimplementing the framework's settings merge, and a truthful attestation is one that names the gap rather than one that hides it behind an empty list. A non-strict configuration therefore reads as other_mcp_servers in the coverage posture — degraded, with the remediation naming the source — until you either pass strict_mcp_config or narrow setting_sources. hosted:<server_label> is the same idea for an MCP server the model's vendor calls from its own cloud (the OpenAI Agents SDK's HostedMCPTool, below): its tool calls never pass the gateway or the adapter, so the entry names the ungoverned path by the label the tool carries. Both conventions are part of the wire schema and of the finding's remediation text, so the console renders such an entry as the gap it names — "MCP servers from a settings file the adapter did not enumerate", "an MCP server called from the vendor's cloud" — rather than as a server named settings:user or hosted:docs. A bare entry is a server the adapter enumerated; an adapter introduces no other prefix — a new convention lands in the wire schema and the finding text first, so the console can render it.
Config hash
config_hash is the SHA-256, hex-encoded, of the RFC 8785 (JCS) serialization of this object — the same canonicalization rule the managed-settings renderer uses, for the same reason: two implementations must produce the same bytes.
{
"adapter": "mitrity-python",
"adapter_version": "0.1.0",
"disallowed_tools": [],
"framework": "claude-agent-sdk",
"framework_version": "0.2.157",
"hooked_tools": ["Bash", "Edit", "MultiEdit", "NotebookEdit", "WebFetch", "WebSearch", "Write"],
"mcp_servers": ["mitrity"],
"other_mcp_servers": [],
"permission_mode": "default",
"sandbox": {"allow_unsandboxed_commands": false, "enabled": true, "fail_if_unavailable": true},
"setting_sources": [],
"strict_mcp_config": true,
"unhooked_exec_tools": []
}
The example is shown in JCS member order (sorted by the UTF-16 code units of the names), which is the order the hashed bytes carry; a serializer that emits insertion order produces a different hash. Lists are sorted. The four list keys — hooked_tools, unhooked_exec_tools, disallowed_tools, other_mcp_servers — are always present in the hashed object, [] when empty, even though the wire body omits an empty list. A value the framework has but the adapter could not read is null. A key the framework has no counterpart for is omitted, never sent as null or a default: the wrapper adapters — LangChain, OpenAI Agents SDK, CrewAI — omit mcp_servers, permission_mode, sandbox, setting_sources and strict_mcp_config and hash the eight keys that remain. An adapter adds no key of its own: a new fact worth hashing lands in this object first, so two implementations keep producing the same bytes. The hash covers the governed configuration — what decides coverage — not the whole options object: a change to the model or the system prompt is not configuration drift in the sense the coverage posture reports.
The Go adapter hashes adapter, adapter_version, framework, framework_version (null when no framework was named), hooked_tools, unhooked_exec_tools, disallowed_tools (always empty) and other_mcp_servers, and like the LangChain adapter omits the keys it does not have. admission.CanonicalJSON and admission.ConfigHash implement the rule and are exported so a caller can check a hash it was given.
When to attest
| Framework | Session start | Re-attest |
|---|---|---|
| Claude Agent SDK, TypeScript | the SessionStart hook the adapter installs (startup, resume, clear, compact, fork) | a hook input whose permission_mode differs from the attested one; a PostToolUse for a tool the adapter never admitted |
| Claude Agent SDK, Python | the first UserPromptSubmit or PreToolUse of a session_id, because the Python SDK's HookEvent has no SessionStart | the same |
| LangChain | the first governed invocation of a session_id | a govern() call that adds a tool after the session attested |
| OpenAI Agents SDK | the first governed invocation of a session_id, before that call is admitted | a govern() / govern_tools() call on the same governor that changes the lists after the session attested |
| CrewAI | the first governed invocation of a session_id, before that call is admitted | a govern() call that adds a tool after the session attested |
| Go | the first governed call (Governor.Admit, through Wrap or WrapCaller) of a session_id; the attestation is sent before that call is admitted | a tool wrapped, declared or first called through WrapCaller after the session attested (the config hash changes); a failed attestation is retried at the next governed call, no sooner than 30 s later |
Attestation is best effort in the sense the hook defines: a failed attest is logged, never a reason to stop the session, because its absence is the signal the control plane is built to notice. A failed admit is a deny; the two must not be confused.
Public API and package layout
Python — distribution mitrity, import package mitrity
src/mitrity/
__init__.py __version__
admission/ the wire client; no framework imports
__init__.py Client, Config, AdmitRequest, Decision, Verdict, Attestation,
SandboxPosture, AdmissionError and subclasses, PROTOCOL_VERSION,
ADAPTER_NAME, EXEC_CAPABLE_TOOLS
claude_agent_sdk/ requires the `claude-agent-sdk` extra
__init__.py governed_options, Governor
langchain/ requires the `langchain` extra
__init__.py govern, govern_tools, GovernedTool
openai_agents/ requires the `openai-agents` extra (below)
__init__.py govern, govern_tools, OpenAIAgentsGovernor, MitrityDenied
crewai/ requires the `crewai` extra (below)
__init__.py govern, govern_tools, GovernedTool, CrewAIGovernor
Runtime dependency: httpx only. Extras: claude-agent-sdk → claude-agent-sdk, langchain → langchain-core, openai-agents → openai-agents, crewai → crewai. Python ≥ 3.10.
from mitrity.admission import Client, AdmitRequest
client = Client() # discovers the edge from the environment
client = Client(addr="unix:/run/mitrity/admission.sock",
token_file="/run/mitrity/admission.token",
timeout=0.5, hold_timeout=540.0)
decision = client.admit(AdmitRequest(surface="custom", tool_name="Bash",
tool_input={"command": "rm -rf build"}))
# Decision(decision="allow" | "deny" | "held", reason, approval_id, risk_score,
# admission_id, updated_input, routed_to) — raises AdmissionError on any failure
verdict = client.decide(request) # the two-phase call of G4; never raises
# Verdict(allowed: bool, reason: str, decision: Decision | None,
# error: AdmissionError | None, updated_input: dict | None,
# routed_to: str | None, held: bool)
# verdict.error is set when the deny is the adapter's own (G2), None for a policy deny
client.attest(Attestation(framework="custom", adapter="mitrity-python", ...))
await client.admit_async(request); await client.decide_async(request); await client.attest_async(att)
Client is safe to share across threads and tasks and holds no per-request state. Errors: AdmissionError is the base; AdmissionUnreachable, AdmissionTimeout, AdmissionUnauthorized, AdmissionNotReady (503), AdmissionProtocolError (400, version mismatch, malformed body, unknown decision; its subclass AdmissionPayloadTooLarge is the local 64 KiB cap) and AdmissionConfigError (a refused address, no token path) are the subclasses. Every one of them is a deny at the call site.
from mitrity.claude_agent_sdk import governed_options
from claude_agent_sdk import query
options = governed_options(
gateway={"type": "stdio", "command": "mitrity-gateway",
"args": ["--config", "/etc/mitrity/gateway.yaml"]},
permission_mode="default",
allowed_tools=["Bash", "Read", "mcp__mitrity"],
)
async for message in query(prompt="...", options=options):
...
governed_options(*, client=None, gateway=None, gateway_name="mitrity", hooked_tools=None, **overrides) -> ClaudeAgentOptions:
- installs a
PreToolUsematcher overhooked_tools(default: the execution-capable set) whose callback admits the call: allow →{}(G10); allow withupdated_input→permissionDecision: "allow"andupdatedInput; deny →permissionDecision: "deny"with the reason; held → the second call of G4, withsystemMessagetelling the operator it is waiting; any failure → deny with the outage reason (G2); - installs a
PostToolUsematcher over every tool that records the execution and re-attests when a tool the adapter never admitted ran (G6); - installs the session-start trigger (
SessionStartin TypeScript,UserPromptSubmitin Python) that attests once persession_id; - sets
mcp_serversto the gateway entry (undergateway_name) merged with any servers inoverrides,strict_mcp_config=Trueandsetting_sources=[]unless overridden, and reports what the overrides widened inother_mcp_servers; - passes every other override through unchanged, prepending its own hooks to any you supplied (the SDK runs all matching hooks; a deny from any of them wins).
Sets the matcher timeout on its own hooks to the hold budget plus 30 s so the adapter, not the framework, is the one that answers.
from mitrity.langchain import govern, govern_tools
tools = govern_tools([ShellTool(), WriteFileTool()], session_id=thread_id)
govern(tool, *, client=None, session_id=None, cwd=None) -> GovernedTool returns a BaseTool with the inner tool's name, description, args_schema, return_direct, response_format, handle_tool_error, handle_validation_error, metadata and tags. Its _run and _arun admit first (client.decide / client.decide_async), raise ToolException with the edge's reason on a deny or an outage (G9 — the inner tool's handle_tool_error decides whether the framework turns that into an error result the model sees), merge updated_input into the keyword arguments (G5), and only then call the inner tool. govern_tools(tools, **kw) maps govern over a sequence and returns a list in the same order.
TypeScript — package @mitrity/sdk
src/
index.ts re-exports
admission.ts AdmissionClient, AdmissionError, types, PROTOCOL_VERSION, ADAPTER_NAME
claude-agent-sdk.ts governedOptions, Governor
ESM and CommonJS builds, Node ≥ 20, no runtime dependencies beyond node:* (node:http over the Unix socket or loopback). @anthropic-ai/claude-agent-sdk is an optional peer dependency, imported for types only.
import { AdmissionClient, governedOptions } from "@mitrity/sdk";
import { query } from "@anthropic-ai/claude-agent-sdk";
const client = new AdmissionClient(); // discovers the edge from the environment
// or: new AdmissionClient({ addr, tokenFile, timeoutMs: 500, holdTimeoutMs: 540_000 })
await client.admit({ surface: "custom", toolName: "Bash", toolInput: { command: "ls" } });
await client.decide(request); // Verdict, never throws
await client.attest(attestation);
const options = governedOptions({
gateway: { type: "stdio", command: "mitrity-gateway", args: ["--config", "/etc/mitrity/gateway.yaml"] },
permissionMode: "default",
allowedTools: ["Bash", "Read", "mcp__mitrity"],
});
for await (const message of query({ prompt: "...", options })) { /* ... */ }
governedOptions({ client?, gateway?, gatewayName?, hookedTools?, ...overrides }): Options has the semantics of the Python function, with SessionStart as the attestation trigger. Field names on the wire are the contract's (tool_name, hold_timeout_seconds); the TypeScript API uses camelCase and maps.
Go — module github.com/mitrity-io/mitrity-go
mitrity.go Version
admission/ the wire client; standard library only
Client, Config, Request, Decision, Verdict, Attestation, SandboxPosture,
Error (Kind: Config, Unreachable, Timeout, Canceled, Unauthorized,
NotReady, Protocol, PayloadTooLarge) and the Err* sentinels,
ProtocolVersion, AdapterName, ExecCapableTools, CanonicalJSON, ConfigHash
govern/ the tool wrappers
Governor, New, Option (WithClient, WithSessionID, WithCwd, WithLogger,
WithFramework, WithOtherMCPServers, WithUngoverned), Tool[T], Func, Wrap,
WrapFunc, WrapAll, Caller[R], CallerFunc, WrapCaller, DeniedError,
ErrDenied, ContextWithSessionID, ContextWithToolUseID, Stats
internal/edgetest/ the in-process fake edge the conformance tests run against
Go ≥ 1.24. No dependency beyond the standard library (net/http over the Unix socket or loopback TCP; go.mod declares no requirement). There is no framework package: a Go agent's tools are functions, so the adapter is a wrapper you apply to each of them, and the request surface is custom.
import "github.com/mitrity-io/mitrity-go/admission"
client := admission.New() // discovers the edge from the environment
client = admission.New(admission.WithAddr("unix:/run/mitrity/admission.sock"),
admission.WithTokenFile("/run/mitrity/admission.token"),
admission.WithTimeout(500*time.Millisecond), admission.WithHoldTimeout(540*time.Second))
decision, err := client.Admit(ctx, admission.Request{ToolName: "Bash",
ToolInput: map[string]any{"command": "rm -rf build"}})
// Decision{Decision: Allow | Deny | Held, Reason, ApprovalID, RiskScore, AdmissionID,
// UpdatedInput, RoutedTo}; err is an *admission.Error on any failure
verdict := client.Decide(ctx, req) // the two-phase call of G4; never fails
// Verdict{Allowed bool, Reason string, Decision *Decision, Err error,
// UpdatedInput map[string]any, RoutedTo string, Held bool}
// verdict.Err is set when the deny is the adapter's own (G2), nil for a policy deny
err = client.Attest(ctx, admission.Attestation{Framework: "custom", Adapter: "mitrity-go", ...})
Client is safe to share across goroutines and holds no per-request state. Every call takes a context.Context; the client applies its own deadline inside it (G3), and a context that ends first is a deny (KindCanceled), never an allow. Errors: every failure is an *admission.Error whose Kind is one of KindConfig (a refused address, no token path), KindUnreachable (no listener, token file absent or empty), KindTimeout, KindCanceled, KindUnauthorized (a 401 after the one retry), KindNotReady (503), KindProtocol (400, a version mismatch, a missing version header, a malformed body, an unknown decision) and KindPayloadTooLarge (the local 64 KiB cap; it also matches ErrProtocol); each has an Err* sentinel for errors.Is. Every one of them is a deny at the call site.
import "github.com/mitrity-io/mitrity-go/govern"
g := govern.New(govern.WithUngoverned("Exec"), govern.WithOtherMCPServers("slack"))
shell := govern.WrapFunc(g, "Bash", func(ctx context.Context, in map[string]any) (string, error) {
cmd, _ := in["command"].(string)
out, err := exec.CommandContext(ctx, "sh", "-c", cmd).CombinedOutput()
return string(out), err
})
out, err := shell.Call(ctx, map[string]any{"command": "rm -rf build"})
var denied *govern.DeniedError
if errors.As(err, &denied) { /* denied.Error() is the reason for the model */ }
admitted := govern.WrapCaller[ToolResult](g, directMCPClient)
Wrap[T](g, tool Tool[T]) Tool[T] — a Tool[T] is a Name() and a Call(ctx, map[string]any) (T, error) — returns a tool of the same name and shape whose Call:
- admits first (
Governor.Admit, which callsClient.Decidewith the tool's name and input verbatim, G11) and only then calls the inner tool; - returns the zero
Tand a*govern.DeniedErroron a policy deny, on a hold nobody answered and on any failure to obtain a decision (G2, G9):errors.Is(err, govern.ErrDenied)matches all three,Error()is the reason the model should see (the edge'sreasonon a policy deny; on an outage, a sentence that says it was not a policy decision), andPolicyDenied(),Held()andUnreachable()say which; the error unwraps to the*admission.Errorbehind an adapter-side deny; - waits on a hold inside
Call, with the second call of G4, up to the hold budget; - runs the inner tool with
updated_inputmerged key by key over the input (G5); the caller's map is never mutated;routed_tois counted inStats().Routedand logged; - adds nothing on an allow: the inner tool's result and error are returned unchanged (G10 — Go has no framework permission flow for the adapter to stay out of, so "silent" means the wrapper is invisible on the allow path);
- registers the tool's name with the governor at
Wraptime, so the attestation names it before it first runs (G6).
WrapFunc(g, name, fn) is Wrap over a function; WrapAll(g, tools) maps Wrap over a slice and preserves order; Governor.Declare(names...) registers names ahead of a lazy wrap; Governor.Admit(ctx, name, input) returns the input to run for a tool whose shape fits neither, and the caller runs only what it returns.
WrapCaller[R](g, inner Caller[R]) Caller[R] — a Caller[R] is Call(ctx, tool string, args map[string]any) (R, error), the shape of a generic tool client — admits each call with tool as tool_name and args as tool_input, verbatim, and registers each tool name as it is first called. It is for a client that reaches an MCP server directly; a client that talks to Mitrity Gateway is never wrapped, since the gateway's pipeline already judges those calls as mcp:<tool> and admitting them again would audit one action twice.
Governor options: WithClient (default admission.New()), WithSessionID, WithCwd, WithLogger (nil disables the governor's own lines), WithFramework(name, version), WithOtherMCPServers(names...), WithUngoverned(names...). Governor.Attestation() returns the attestation it sends; Governor.Stats() counts admitted, allowed, denied, held, unreachable and routed calls (G8).
Framework notes
Claude Agent SDK
- The SDK's callback hooks block on timeout (Claude Code ≥ 2.1.210: the tool is not run and the model is told the hook did not answer). That is a safety net, not the design: under G3 the adapter answers first, with a reason.
- MCP tools are not admitted. A
PreToolUseformcp__<gateway>__<tool>is the gateway's call to judge; the adapter'sPreToolUsematcher names built-ins only, so the two entrances never double-audit one call. Tools of any other MCP server are neither admitted nor governed; they are theother_mcp_serversfinding. - Subagents are covered.
PreToolUsefires insideTask-spawned subagents withagent_idset; the adapter admits those calls like any other and passes the parent session'ssession_id. permission_modeis attested, not enforced.bypassPermissionsanddontAskmake the adapter the only gate; the coverage posture says so (permission_mode_bypass). The adapter does not refuse them.- The relay must be on
PATHfor routing (G5): a rewrittenBashrunsmitrity-hook exec <ticket>through the framework, so a machine without the binary fails the routed call with exit127— visibly, and with the ticket expiring into anot_redeemedevent. The adapter documents this next tobuiltin_exec_routing.
LangChain
- Coverage is what you hand it.
govern_toolsis the boundary: a tool you did not pass is invisible to the adapter, so the attestation claims exactly the governed names and nothing about the rest. The README says this in its first paragraph. - Argument names are the tool's. The adapter does not know that
ShellToolcalls its argumentcommands; the edge's command-family keys decide what is parsed as a command (G11). Untilcommandsjoins that family on the edge, a shell tool's argument is DLP-scanned and content-scanned but its command tree is not evaluated againstresolved_commands; the adapter README states this rather than renaming the key. - Holds are synchronous. LangChain has no way to tell the model a call is waiting; the adapter waits inside
_run/_arunup to the hold budget and then raises.
Go
- Coverage is what you hand it.
WrapandWrapCallerare the boundary: a tool you did not wrap is invisible to the adapter, so the attestation claims exactly the governed names — plus the gaps you declare withWithUngovernedandWithOtherMCPServers, which is the only way the adapter can know them. The README says this in its first paragraph. - No framework, no permission flow. There is no
permissionDecisionto omit and no exception type to match: a deny is an error value (*govern.DeniedError) and an allow is the inner tool's own return. A program that ignores the error and runs the tool anyway has removed the adapter (threat model). - Holds are synchronous. A Go agent has no way to tell the model a call is waiting; the wrapper waits inside
Callup to the hold budget and then returns the deny. The caller's context bounds the wait: a context that ends first is a deny. - A directly reached MCP server is admitted as a built-in. The admission API has one entrance and one naming rule, so a call
WrapCalleradmits is judged asbuiltin:<lowercased tool>with auditsurface=agent_hook, not asmcp:<tool>: a policy for such a tool is written asbuiltin:slack_post, and the console shows it on the hook surface. The gateway path is unchanged. The adapter does not paper over this with a rename (G11). - The relay must be on
PATHfor routing (G5), as for the Agent SDK: a rewrittenBashrunsmitrity-hook exec <ticket>through whatever your tool does with itscommand, and a machine without the binary fails the routed call visibly. - Argument names are the tool's. As for LangChain: the edge's command-family keys decide what is parsed as a command (G11); the adapter does not rename.
Conformance tests
Every adapter ships these, against an in-process fake admission server over a Unix socket (loopback TCP on Windows), and runs them in CI on every supported runtime version. A test may be shared between the Python and TypeScript packages in intent, not in code.
| # | Guarantee | The test |
|---|---|---|
| C1 | G7 | Every request carries X-Mitrity-Admission-Token (the file's contents, trimmed), X-Mitrity-Admission-Version: 1 and Content-Type: application/json. |
| C2 | G7 | The token is read per attempt: after the fake server rotates its token and answers 401, the adapter re-reads the file and succeeds on the retry; a second 401 is a deny. |
| C3 | G7 | An address that is neither loopback nor a Unix socket is refused before any connection is attempted (the fake server sees no request); a Client built from it denies with AdmissionConfigError. |
| C4 | G2 | With no listener at the address, decide returns a deny within the deadline, error set, and the reason contains the "not a policy decision" sentence. |
| C5 | G3 | A server that sleeps past the deadline yields a deny within deadline + 100 ms; a response that arrives afterwards is discarded — the fake server's late allow never becomes a run. |
| C6 | G2 | 400, 503, an empty body, a body that is not JSON, a decision value other than allow/deny/held, and a response whose version header is not 1 are each a deny with the matching error subclass. |
| C7 | G10 | An allow runs the tool with the original input, and the framework hook output carries no permissionDecision. |
| C8 | G9 | A deny blocks the tool in the framework's idiom and the edge's reason reaches the model verbatim. |
| C9 | G4 | The first request carries hold_timeout_seconds: 0; on held the second carries the hold budget in seconds; allow on the second runs the tool, deny blocks naming approval_id, held blocks; a hold budget of 0 sends no second request; the budget is clamped to 570 s. |
| C10 | G5 | An allow with updated_input runs the merged input (untouched keys keep the model's values) and, for the Agent SDK, emits permissionDecision: "allow" with updatedInput; routed_to is recorded. |
| C11 | G6 | The first session event produces one POST /v1/attest whose body carries every field of the table above with the values the options imply; the same session does not attest twice; a new session_id does. |
| C12 | G6 | A tool outside the matcher appears in unhooked_exec_tools; a disallowed tool appears in disallowed_tools (and, unhooked, still in unhooked_exec_tools — the control plane subtracts); a tool the tools option removes appears in neither; an extra MCP server appears in other_mcp_servers; non-strict settings produce settings:<source> entries; a sandbox block yields the three booleans with absent keys null. |
| C13 | G11 | surface, tool_name and tool_input arrive verbatim (a key named commands stays commands); session_id and cwd are populated from the framework's event, and so is tool_use_id where the wire mapping names a call id — an adapter whose framework has none sends no tool_use_id, never a fabricated one. |
| C14 | G11 | A serialized request body over 64 KiB is denied locally; the fake server sees no request. |
| C15 | G2 | MITRITY_HOOK_FAIL_MODE=open changes nothing: C4 still denies. |
| C16 | G8 | Ten concurrent invocations with ten distinct inputs produce ten admission requests — one per invocation, none coalesced, cached or reused — and ten independent decisions, each delivered to the invocation that asked; where the framework has a call id, each request carries its own tool_use_id. |
| C17 | G7 | With logging captured at DEBUG, neither the token nor the tool input appears in any log record. |
| C18 | G6 | A changed permission_mode on a later hook input, or an unadmitted tool reaching PostToolUse, produces a second attestation with a different config_hash. |
| C19 | versioning | The exported PROTOCOL_VERSION equals the header sent in C1. |
| C20 | G1 | (Agent SDK) a PreToolUse for an mcp__<gateway>__* tool produces no admission request; (LangChain) both _run and _arun of a governed tool admit before the inner tool runs, and a deny raises ToolException honoring the inner tool's handle_tool_error. |
Which of these are framework-conditional. C1–C11, C14, C15, C17 and C19 apply to every adapter as written (C7's and C10's permissionDecision clauses are the Agent SDK's spelling of a silent allow and a routed allow; a wrapper adapter asserts the same through its own idiom). C12's disallowed_tools, settings:<source> and sandbox assertions and C18's permission_mode assertion exist only where the framework has those things (the Claude Agent SDK); a wrapper adapter — LangChain, OpenAI Agents SDK, CrewAI — runs C12 over the lists its attestation table says it computes and C18 over the re-attestation trigger its When to attest row names. C13's and C16's tool_use_id assertions apply where the wire mapping names a call id; CrewAI has none, sends none, and runs both tests without them. C20 is the Claude Agent SDK and LangChain adapters' own test, which is why the OpenAI Agents SDK and CrewAI sections count from C21. A carve-out is stated in the adapter's section next to the field it concerns, and is never widened silently.
An implementation that cannot run one of these on some platform (a Unix socket on native Windows) substitutes loopback TCP and says so in the test name.
The Go adapter's tests carry the row they prove in their name (TestC4… in package admission, TestC7… in package govern), against the fake edge in internal/edgetest. Where a row is phrased in a framework's terms, this is what it means for Go:
| # | Go |
|---|---|
| C1–C6, C14–C17, C19 | As written, in package admission (Client.Admit, Client.Decide); C4, C14, C16 and C17 again through govern.Wrap. |
| C7 | An allow calls the inner tool with the original input and returns its result and error unchanged; nothing is added. |
| C8 | A deny returns the zero value and a *govern.DeniedError; errors.Is(err, govern.ErrDenied) holds, Error() carries the edge's reason verbatim, and the inner tool was never called. |
| C9 | As written; the second call happens inside Call, and its deadline is the hold budget plus a margin, not the decision deadline. |
| C10 | The inner tool receives the merged input; routed_to is counted and logged; the caller's map is unchanged. There is no permissionDecision to emit. |
| C11 | The first governed call of a session_id sends one POST /v1/attest, before that call's admission; the body carries framework, adapter, adapter_version, hooked_tools and config_hash (and framework_version, unhooked_exec_tools, other_mcp_servers when set); the same session does not attest twice; a new session_id does. |
| C12 | WithUngoverned names appear in unhooked_exec_tools, WithOtherMCPServers names in other_mcp_servers, Declared and wrapped names in hooked_tools, each deduplicated and sorted. disallowed_tools, settings:<source> entries and sandbox do not apply. |
| C13 | surface (custom), tool_name and tool_input arrive verbatim; session_id, cwd and tool_use_id are the option, the context value or the default of the wire-mapping table. |
| C18 | A tool wrapped, declared or first called through WrapCaller after the session attested produces a second attestation with a different config_hash. There is no permission_mode and no PostToolUse. |
| C20 | Both Wrap and WrapCaller admit before the inner call runs, and a deny from either is a *govern.DeniedError with the inner never called. The "no admission request for a gateway tool" half has no mechanism to test: the adapter installs no matcher, and the gateway client is simply not wrapped. |
OpenAI Agents SDK and CrewAI adapters
The two Python adapters below ship in mitrity 0.2; the Go adapter has its own section above and stays in this table for the surface it reserves. Every guarantee in Guarantees applies to them. Where a framework offers no channel for a rewrite or for a deny, the narrow exceptions G5 and G9 already state apply — an apply_patch rewrite and a non-object rewrite are denies, a LocalShellTool deny is the executor's output — and each is named again below where it applies. These sections say only what is specific to each framework — the wire mapping, the blocking idiom, the attestation, and the stated limits.
| Framework | request surface | attestation framework | Adapter | Requires |
|---|---|---|---|---|
| OpenAI Agents SDK | openai_agents | openai-agents | mitrity (Python) mitrity.openai_agents | mitrity ≥ 0.2, openai-agents ≥ 0.20 |
| CrewAI | crewai | crewai | mitrity (Python) mitrity.crewai | mitrity ≥ 0.2, crewai ≥ 1.15 |
| Go | custom (no Go surface is defined; a framework named with WithFramework that has one is sent as that surface) | custom, or the name WithFramework gives | mitrity-go (github.com/mitrity-io/mitrity-go): packages admission and govern — an admission client and per-tool wrappers (govern.Wrap, govern.WrapCaller) | mitrity-go 0.1.x |
OpenAI Agents SDK — mitrity.openai_agents
The SDK runs function tools, its shell tools and its apply_patch editor in-process; hosted tools run in OpenAI's cloud. There is no hook to install, so the adapter wraps the tools: govern(agent) returns a clone of the agent (Agent.clone) whose tools ask the edge first, and govern_tools(tools) does the same for a list. Coverage is exactly what is wrapped, as for LangChain.
| OpenAI Agents SDK (Python) | |
|---|---|
request surface | openai_agents |
attestation framework / adapter | openai-agents / mitrity-python |
framework_version | agents.__version__ (the openai-agents distribution version) |
tool_name | the tool's name: the function tool's name, shell, apply_patch, local_shell |
tool_input | function tool: the model's JSON arguments parsed to an object (a non-object is sent under input); shell: the fields of the SDK's ShellActionRequest (commands, timeout_ms, max_output_length); apply_patch: the fields of the ApplyPatchOperation the model set (type, path, diff, move_to); local_shell: the fields of the call's action (command as the list the API carries, env, timeout_ms, user, working_directory). Nothing renamed (G11): commands and a list-valued command reach the edge as such |
session_id | explicit session_id, else RunConfig.group_id when the tool context carries a run config, else one id per process |
cwd | explicit cwd, else the process working directory |
tool_use_id | the SDK's call_id (ToolContext.tool_call_id; the shell call's and apply_patch call's call_id) |
| Action type on the edge | builtin:<lowercased tool name> (builtin:shell, builtin:apply_patch, builtin:<function>) |
| MCP tools | not admitted by the adapter. Servers in Agent.mcp_servers materialize their tools inside the runner, never in agent.tools; Mitrity Gateway governs its own as mcp:<tool>, every other server is attested (below) |
Blocking idiom (G9), per tool type. Each type is refused the way the SDK itself refuses that type:
FunctionTool: a tool input guardrail the adapter prepends totool_input_guardrails. An allow isToolGuardrailFunctionOutput.allow()(silent, G10); a deny isreject_content(reason)— the SDK returns the reason to the model as the tool's output and never invokes the tool. The adapter also wrapson_invoke_tool, which is what applies anupdated_input(G5): the merged object is re-serialized and the original invoker runs it. If a call reaches the invoker without having passed the guardrail (you replaced the guardrail list aftergovern()), the invoker admits it itself and a deny there raisesmitrity.openai_agents.MitrityDenied(anAgentsException), which ends the run: once the guardrail is gone there is no gentler channel, and a call never runs unadmitted.ShellToolandApplyPatchTool: the SDK's own approval flow. The adapter'sneeds_approval(run_context, action | operation, call_id)admits the call: an allow returns your ownneeds_approvalanswer (your approval settings still apply on top, G10); a deny returnsTrue, and the adapter'son_approvalthen answers{"approve": False, "reason": <reason>}— the SDK's rejection item carries the reason to the model. When MITRITY allowed and you had noon_approval, the adapter'son_approvalreturns no decision, so your pending-approval interrupt is untouched. The wrapped shell executor applies anupdated_inputover the action's fields before your executor runs (G5). Anapply_patchrewrite is a deny: the editor receives the operation without a call id to correlate a rewrite to, and the original bytes must not run under a decision made about different ones. The execution channel is gated as well: the wrapped executor and the wrapped editor re-check the decisionneeds_approvalmade — remembered per(tool, call_id)together with a digest of the input it was made for — and admit on their own a call nothing judged; an approval item the adapter cannot tie to a decision it made is rejected, never left to your handler; an execution whose bytes differ from the judged ones is a deny.LocalShellTool(deprecated upstream in favor ofShellTool): the SDK offers neither a guardrail nor an approval for it, so the wrapped executor is the only channel — a deny is returned as the executor's output (MITRITY denied this command and nothing was executed: <reason>) with nothing run. PreferShellTool.- Hosted tools (
WebSearchTool,FileSearchTool,CodeInterpreterTool,ImageGenerationTool,HostedMCPTool) execute in the vendor's cloud and are out of reach (Governance Coverage);ComputerTooldrives a computer through yourComputerimplementation and is not judged. They pass through untouched and are attested as unhooked. A tool type the adapter does not know is passed through and attested as unhooked too — the default for a future SDK tool is "not governed", never silence.
| Field | OpenAI Agents SDK adapter |
|---|---|
hooked_tools | the names of every governed FunctionTool, ShellTool, ApplyPatchTool and LocalShellTool, accumulated per governor |
unhooked_exec_tools | the names of the hosted tools, ComputerTool and unknown tool types present on the governed agent or list (web_search, file_search, code_interpreter, image_generation, hosted_mcp, computer_use_preview, …) |
disallowed_tools | empty |
other_mcp_servers | the name of every Agent.mcp_servers entry other than the one passed as gateway= (an MCPServer instance or its name), plus hosted:<server_label> for every HostedMCPTool — the prefixed convention of Attestation fields: OpenAI's cloud calls that server, so its tool calls never pass the gateway or the adapter, and the entry names the gap by the tool's server_label rather than posing as a server the adapter enumerated |
permission_mode, sandbox | omitted |
config_hash | as for LangChain: the canonical object of Config hash minus the five keys a wrapper adapter lacks — the eight that remain, with unhooked_exec_tools and other_mcp_servers carrying the lists above |
Attestation is sent on the first governed invocation of a session_id, before that call is admitted, and again when a later govern / govern_tools call on the same governor changes the lists (When to attest).
Framework notes:
- Handoffs.
govern(agent)governs handoff targets given asAgentobjects with the same governor; aHandoffobject built withhandoff()is left as is — wrap its agent yourself before building the handoff. RunConfig.tool_execution.pre_approval_tool_input_guardrailsmoves the guardrail before the approval step; it does not change what the adapter does — the guardrail runs before the tool either way.- Agents as tools (
agent.as_tool()) are function tools whose "input" is a prompt; they are admitted like any function tool (builtin:<name>), and the inner agent's own tools are governed only if that agent was. - Streaming changes nothing: guardrails and approvals run in the same place.
Conformance, in addition to C1–C19 run against mitrity.openai_agents (C20 belongs to the Claude Agent SDK and LangChain adapters; the framework-conditional assertions are scoped as Conformance tests says):
| # | Guarantee | The test |
|---|---|---|
| C21 | G1, G9 | A governed FunctionTool's guardrail admits before the invoker runs; an allow yields allow() and the original invoker runs the original arguments; a deny yields reject_content with the edge's reason and the invoker is never called. Through Runner.run with a stub model, the rejection reaches the model as the tool output. |
| C22 | G5, G11 | The JSON arguments arrive as tool_input verbatim (commands stays commands); an allow with updated_input runs the merged object; a rewrite of a non-object argument is a deny. |
| C23 | G9, G10 | ShellTool: a deny makes needs_approval return True and on_approval reject with the reason; an allow defers to your needs_approval; the wrapped executor runs rewritten commands. ApplyPatchTool: a deny rejects with the reason; an allow with updated_input is a deny. LocalShellTool: a deny is the executor's output and your executor never runs. An approval item the adapter cannot tie to a decision it made is rejected; the wrapped executor and editor admit an unjudged call themselves and deny an execution whose bytes differ from the judged ones. |
| C24 | G6 | Hosted tools, ComputerTool and a ShellTool whose environment.type is not local (it executes in OpenAI's cloud and is passed through unwrapped) appear in unhooked_exec_tools; Agent.mcp_servers entries other than the gateway appear in other_mcp_servers, as does hosted:<server_label>; the gateway server does not. |
| C25 | G11 | tool_use_id is the SDK's call_id; session_id is RunConfig.group_id when the tool context carries one. |
CrewAI — mitrity.crewai
A CrewAI BaseTool executes in-process through _run / _arun, and the framework reaches it through to_structured_tool(), which binds _run. govern(tool) returns a BaseTool (GovernedTool) with the inner tool's name, description, args_schema, result_schema, result_as_answer, max_usage_count and tool_failure_policy, whose _run and _arun admit first; cache_function is never copied — a governed tool never serves a cached result without a new decision (G8). govern_tools(tools) maps it over a sequence. Same shape as LangChain, with one difference in the idiom.
| CrewAI (Python) | |
|---|---|
request surface | crewai |
attestation framework / adapter | crewai / mitrity-python |
framework_version | crewai.__version__ |
tool_name | tool.name |
tool_input | the keyword arguments of the call, after the tool's own args_schema validation when it ran (BaseTool.run), verbatim; a single positional argument is sent under the tool's one declared argument name, else input |
session_id | explicit session_id, else one id per process — CrewAI exposes no run or task identity to a tool |
cwd | explicit cwd, else the process working directory |
tool_use_id | absent: CrewAI has no tool-call id and the adapter fabricates none. This is the explicit carve-out from C13 and C16, which run against this adapter without their tool_use_id assertions; a call's identity on the audit trail is its admission (admission_id), one per invocation |
| Action type on the edge | builtin:<lowercased tool.name> |
Blocking idiom (G9). A deny, an unreachable edge, and a rewrite the adapter cannot place are each returned from _run / _arun as a crewai.tools.tool_failure.ToolFailure whose message is the reason — the framework's declared channel for "this tool did not do what it was asked": the agent sees the message verbatim, the failure is recorded on the task output and the event bus, and the agent's tool_failure_policy (warn by default, raise to abort) decides the reaction. Raising an exception instead would also reach the agent as text, but as an accident the framework caught rather than a decision it recorded. An allow is silent (G10). updated_input is merged over the keyword arguments, or replaces the single positional argument, before the inner tool runs (G5).
| Field | CrewAI adapter |
|---|---|
hooked_tools | the name of every tool passed through govern / govern_tools, accumulated per governor |
unhooked_exec_tools | empty by construction — the adapter sees only the tools it was handed |
disallowed_tools, other_mcp_servers | empty |
permission_mode, sandbox | omitted |
config_hash | as for LangChain |
Framework notes:
- Coverage is what you hand it. Tools CrewAI adds to an agent itself — the delegation tools (
Delegate work to coworker,Ask question to coworker), the code interpreter thatallow_code_execution=Trueinstalls (crewai-tools, running in Docker or unsafely on the host), MCP tools the agent'smcpssetting fetches — never pass throughgovernand are invisible to the adapter. The README says this in its first paragraph; the host observer is what sees the code interpreter's executions on Linux. @tool-decorated tools (crewai.tools.Tool) are governed like anyBaseTool;_arunof a sync one is run in a worker thread rather than through the framework's_arun, which executes the function before reporting that it is not async.- Argument names are the tool's (G11), exactly as for LangChain.
- Holds are synchronous: the adapter waits inside
_run/_arunup to the hold budget, then returns theToolFailure.
Conformance, in addition to C1–C19 run against mitrity.crewai (C20 belongs to the Claude Agent SDK and LangChain adapters; C13 and C16 run without their tool_use_id assertions, the carve-out the table above states; the other framework-conditional assertions are scoped as Conformance tests says):
| # | Guarantee | The test |
|---|---|---|
| C26 | G1, G9 | run(**kwargs) and to_structured_tool().invoke(input=…) admit before the inner _run runs; a deny returns a ToolFailure carrying the edge's reason and the inner tool never runs; arun admits before _arun. |
| C27 | G5, G11 | Keyword arguments arrive verbatim after the tool's own validation; an allow with updated_input runs the merged arguments, or replaces the single positional argument; a rewrite the adapter cannot place is a ToolFailure. |
| C28 | G6 | The first governed call of a session attests once with every governed name in hooked_tools; a tool governed later on the same governor re-attests with a new config_hash. |
Threat model
The attacker is the model, or anything that has persuaded it, and it runs inside the same process as the adapter.
| Attack | Answer |
|---|---|
Remove the adapter (edit the code, drop governed_options) | Not prevented in-process: the adapter is your code. Detected: the attestation stops arriving (attestation_missing/attestation_stale), the execution statistics (ExecutionStats) stop counting admissions while MCP calls continue (hook_silent), and on Linux the host observer sees execs with no MITRITY ancestor. See Findings and how to close them. |
| Narrow the matcher | Attested accurately: the removed tool lands in unhooked_exec_tools (degraded). |
| Call a tool without its wrapper (Go) | Attested only if declared: WithUngoverned puts it in unhooked_exec_tools (degraded). An undeclared one is invisible to the attestation and is caught the way a removed adapter is: hook_silent against the MCP traffic, and the observer on Linux. |
| Add a second MCP server | Attested: other_mcp_servers (degraded). |
| Load MCP servers from a settings file the adapter did not read | Attested as settings:<source> (degraded) unless the configuration is strict. |
Give the agent an MCP server the model's vendor calls from its own cloud (an OpenAI HostedMCPTool) | Attested as hosted:<server_label> (degraded): those calls never pass the gateway, and the entry says so. |
Point MITRITY_ADMISSION_ADDR at a host that always allows | Refused before any I/O (G7): the address must be loopback or a socket. |
| Wait out the edge (kill it, then act) | Every call denies (G2); the missing heartbeat is an availability finding. |
| Run the original command after a routed allow | Conformance C10; the ticket expires into not_redeemed; the observer sees the exec. Detected, not prevented, exactly as for the hook. |
| Make the hook time out so the framework's generic error hides the reason | The adapter's deadline answers first (G3) with the real reason; the framework's timeout is a backstop, not the path. |
Related Documentation
- Admission API — the wire contract every guarantee above refers to: endpoints, decision semantics, holds, routed Bash, the caller contract for the hook, conformance for callers
- Governance Coverage — how the attestation and the observed traffic become the execution-coverage badge, and the finding each attestation field feeds
- Governed Shell — the governed shell a routed
Bashexecutes in - Host Observer — the Linux observer that catches execution the adapter never saw
- Writing Policies —
builtin:*action types and the semantic command constraints the edge applies to admitted calls