신규: AIBOM — 모든 에이전트·도구·커넥터의 실시간 BOM. 자세히 보기
MITRITY
Docs / 통합 / Framework Adapters

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

TermMeans
AdapterA 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.
FrameworkThe agent runtime being wrapped: claude-agent-sdk, langchain, openai-agents, crewai; custom for a Go agent built without one.
Built-in toolA tool the framework executes itself, without an MCP tools/call: the Agent SDK's Bash, Write, Edit, WebFetch; a LangChain BaseTool's _run.
EntranceWhere 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.
EdgeThe 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 surfaceclaude_agent_sdklangchaincustom; a framework named with govern.WithFramework that has a surface of its own is sent as that surface
attestation frameworkclaude-agent-sdklangchaincustom, or the name govern.WithFramework(name, version) gives
attestation adaptermitrity-python / mitrity-jsmitrity-pythonmitrity-go
framework_versionthe SDK package version (claude_agent_sdk.__version__; the npm package version)langchain_core.__version__the version WithFramework gives; omitted otherwise
tool_namethe hook's tool_name (Bash, Write, …)tool.nameTool.Name(), or the tool argument of Caller.Call
tool_inputthe hook's tool_inputthe parsed keyword arguments of the call; a single positional string argument is sent under the tool's one declared argument name, else inputthe map[string]any handed to Call, verbatim ({} for a nil map)
session_idthe hook's session_idexplicit session_id, else config["configurable"]["thread_id"], else one id per process generated when the adapter is first usedgovern.WithSessionID, else the value govern.ContextWithSessionID put on the context, else one id per Governor generated when it is built
cwdthe hook's cwdthe process working directory at call timegovern.WithCwd, else the process working directory at call time
tool_use_idthe hook's tool_use_idthe run manager's run_id when presentthe value govern.ContextWithToolUseID put on the context, else a random id per call
Action type on the edgebuiltin:<lowercased tool_name> (builtin:bash, builtin:write); shell:execute when routedbuiltin:<lowercased tool.name> (builtin:terminal for ShellTool)builtin:<lowercased tool_name>; shell:execute when routed
audit surfaceagent_hookagent_hookagent_hook
MCP toolsthrough the gateway, mcp:<tool>, surface=mcp_gateway; not admitted againnot applicablethrough 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:

VariableDefaultRule
MITRITY_ADMISSION_ADDRunix:/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_TIMEOUT500msDeadline for one decision; parsed as a Go duration or a bare number of seconds; clamped to 30s.
MITRITY_HOOK_HOLD_TIMEOUT540sHold budget; 0 disables the second call (a held is then a deny); clamped to 570s.
MITRITY_HOOK_FAIL_MODEIgnored 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:

  1. One adapter version speaks exactly one protocol version. It is a constant the package exports (mitrity.admission.PROTOCOL_VERSION, PROTOCOL_VERSION from @mitrity/sdk, admission.ProtocolVersion in Go) and it is the value sent on every request. There is no negotiation and no fallback: an edge that does not speak it answers 400, which is a deny whose reason names the version the adapter sent and says the edge or the adapter needs upgrading.
  2. 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.
  3. 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.
  4. adapter_version on 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.
AdapterPackage versionProtocol
mitrity-python0.1.x, 0.2.x (adds the OpenAI Agents SDK and CrewAI adapters)1
mitrity-js0.1.x1
mitrity-go0.1.x1

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.

FieldClaude Agent SDK adapterLangChain adapterGo adapter
frameworkclaude-agent-sdklangchaincustom, or the name WithFramework gives
framework_versionthe SDK package versionlangchain_core.__version__the version WithFramework gives; omitted otherwise
framework_version_sourceomitted. 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 adapteromitted, as for the Claude Agent SDK adapter
session_idthe 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 adapteras for the Claude Agent SDK adapter
adaptermitrity-python / mitrity-jsmitrity-pythonmitrity-go
adapter_versionthe package versionthe package versionmitrity.Version, the module version
hooked_toolsthe 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 listedthe name of every tool passed through govern() / govern_tools(), accumulated for the sessionthe 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_toolsthe 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-sentempty by construction — the adapter can only see the tools it was handed; coverage is exactly hooked_tools and the documentation says sowhat 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_toolsthe options' disallowed_toolsemptyempty
other_mcp_serversevery 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 enumerateemptywhat WithOtherMCPServers(names...) declares: every MCP server the agent reaches other than the gateway, whether or not its client is wrapped with WrapCaller
permission_modethe options' permission_mode (default when unset); re-attested when a hook input reports a different modeomittedomitted
sandboxfrom 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 configuredomittedomitted
config_hashsee belowsee belowsee 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

FrameworkSession startRe-attest
Claude Agent SDK, TypeScriptthe 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, Pythonthe first UserPromptSubmit or PreToolUse of a session_id, because the Python SDK's HookEvent has no SessionStartthe same
LangChainthe first governed invocation of a session_ida govern() call that adds a tool after the session attested
OpenAI Agents SDKthe first governed invocation of a session_id, before that call is admitteda govern() / govern_tools() call on the same governor that changes the lists after the session attested
CrewAIthe first governed invocation of a session_id, before that call is admitteda govern() call that adds a tool after the session attested
Gothe first governed call (Governor.Admit, through Wrap or WrapCaller) of a session_id; the attestation is sent before that call is admitteda 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-sdkclaude-agent-sdk, langchainlangchain-core, openai-agentsopenai-agents, crewaicrewai. 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 PreToolUse matcher over hooked_tools (default: the execution-capable set) whose callback admits the call: allow → {} (G10); allow with updated_inputpermissionDecision: "allow" and updatedInput; deny → permissionDecision: "deny" with the reason; held → the second call of G4, with systemMessage telling the operator it is waiting; any failure → deny with the outage reason (G2);
  • installs a PostToolUse matcher over every tool that records the execution and re-attests when a tool the adapter never admitted ran (G6);
  • installs the session-start trigger (SessionStart in TypeScript, UserPromptSubmit in Python) that attests once per session_id;
  • sets mcp_servers to the gateway entry (under gateway_name) merged with any servers in overrides, strict_mcp_config=True and setting_sources=[] unless overridden, and reports what the overrides widened in other_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 calls Client.Decide with the tool's name and input verbatim, G11) and only then calls the inner tool;
  • returns the zero T and a *govern.DeniedError on 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's reason on a policy deny; on an outage, a sentence that says it was not a policy decision), and PolicyDenied(), Held() and Unreachable() say which; the error unwraps to the *admission.Error behind 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_input merged key by key over the input (G5); the caller's map is never mutated; routed_to is counted in Stats().Routed and 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 Wrap time, 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 PreToolUse for mcp__<gateway>__<tool> is the gateway's call to judge; the adapter's PreToolUse matcher 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 the other_mcp_servers finding.
  • Subagents are covered. PreToolUse fires inside Task-spawned subagents with agent_id set; the adapter admits those calls like any other and passes the parent session's session_id.
  • permission_mode is attested, not enforced. bypassPermissions and dontAsk make the adapter the only gate; the coverage posture says so (permission_mode_bypass). The adapter does not refuse them.
  • The relay must be on PATH for routing (G5): a rewritten Bash runs mitrity-hook exec <ticket> through the framework, so a machine without the binary fails the routed call with exit 127 — visibly, and with the ticket expiring into a not_redeemed event. The adapter documents this next to builtin_exec_routing.

LangChain

  • Coverage is what you hand it. govern_tools is 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 ShellTool calls its argument commands; the edge's command-family keys decide what is parsed as a command (G11). Until commands joins that family on the edge, a shell tool's argument is DLP-scanned and content-scanned but its command tree is not evaluated against resolved_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/_arun up to the hold budget and then raises.

Go

  • Coverage is what you hand it. Wrap and WrapCaller are 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 with WithUngoverned and WithOtherMCPServers, 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 permissionDecision to 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 Call up 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 WrapCaller admits is judged as builtin:<lowercased tool> with audit surface=agent_hook, not as mcp:<tool>: a policy for such a tool is written as builtin: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 PATH for routing (G5), as for the Agent SDK: a rewritten Bash runs mitrity-hook exec <ticket> through whatever your tool does with its command, 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.

#GuaranteeThe test
C1G7Every request carries X-Mitrity-Admission-Token (the file's contents, trimmed), X-Mitrity-Admission-Version: 1 and Content-Type: application/json.
C2G7The 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.
C3G7An 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.
C4G2With no listener at the address, decide returns a deny within the deadline, error set, and the reason contains the "not a policy decision" sentence.
C5G3A 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.
C6G2400, 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.
C7G10An allow runs the tool with the original input, and the framework hook output carries no permissionDecision.
C8G9A deny blocks the tool in the framework's idiom and the edge's reason reaches the model verbatim.
C9G4The 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.
C10G5An 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.
C11G6The 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.
C12G6A 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.
C13G11surface, 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.
C14G11A serialized request body over 64 KiB is denied locally; the fake server sees no request.
C15G2MITRITY_HOOK_FAIL_MODE=open changes nothing: C4 still denies.
C16G8Ten 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.
C17G7With logging captured at DEBUG, neither the token nor the tool input appears in any log record.
C18G6A changed permission_mode on a later hook input, or an unadmitted tool reaching PostToolUse, produces a second attestation with a different config_hash.
C19versioningThe exported PROTOCOL_VERSION equals the header sent in C1.
C20G1(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, C19As written, in package admission (Client.Admit, Client.Decide); C4, C14, C16 and C17 again through govern.Wrap.
C7An allow calls the inner tool with the original input and returns its result and error unchanged; nothing is added.
C8A 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.
C9As written; the second call happens inside Call, and its deadline is the hold budget plus a margin, not the decision deadline.
C10The inner tool receives the merged input; routed_to is counted and logged; the caller's map is unchanged. There is no permissionDecision to emit.
C11The 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.
C12WithUngoverned 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.
C13surface (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.
C18A 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.
C20Both 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.

Frameworkrequest surfaceattestation frameworkAdapterRequires
OpenAI Agents SDKopenai_agentsopenai-agentsmitrity (Python) mitrity.openai_agentsmitrity ≥ 0.2, openai-agents ≥ 0.20
CrewAIcrewaicrewaimitrity (Python) mitrity.crewaimitrity ≥ 0.2, crewai ≥ 1.15
Gocustom (no Go surface is defined; a framework named with WithFramework that has one is sent as that surface)custom, or the name WithFramework givesmitrity-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 surfaceopenai_agents
attestation framework / adapteropenai-agents / mitrity-python
framework_versionagents.__version__ (the openai-agents distribution version)
tool_namethe tool's name: the function tool's name, shell, apply_patch, local_shell
tool_inputfunction 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_idexplicit session_id, else RunConfig.group_id when the tool context carries a run config, else one id per process
cwdexplicit cwd, else the process working directory
tool_use_idthe SDK's call_id (ToolContext.tool_call_id; the shell call's and apply_patch call's call_id)
Action type on the edgebuiltin:<lowercased tool name> (builtin:shell, builtin:apply_patch, builtin:<function>)
MCP toolsnot 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 to tool_input_guardrails. An allow is ToolGuardrailFunctionOutput.allow() (silent, G10); a deny is reject_content(reason) — the SDK returns the reason to the model as the tool's output and never invokes the tool. The adapter also wraps on_invoke_tool, which is what applies an updated_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 after govern()), the invoker admits it itself and a deny there raises mitrity.openai_agents.MitrityDenied (an AgentsException), which ends the run: once the guardrail is gone there is no gentler channel, and a call never runs unadmitted.
  • ShellTool and ApplyPatchTool: the SDK's own approval flow. The adapter's needs_approval(run_context, action | operation, call_id) admits the call: an allow returns your own needs_approval answer (your approval settings still apply on top, G10); a deny returns True, and the adapter's on_approval then answers {"approve": False, "reason": <reason>} — the SDK's rejection item carries the reason to the model. When MITRITY allowed and you had no on_approval, the adapter's on_approval returns no decision, so your pending-approval interrupt is untouched. The wrapped shell executor applies an updated_input over the action's fields before your executor runs (G5). An apply_patch rewrite 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 decision needs_approval made — 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 of ShellTool): 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. Prefer ShellTool.
  • Hosted tools (WebSearchTool, FileSearchTool, CodeInterpreterTool, ImageGenerationTool, HostedMCPTool) execute in the vendor's cloud and are out of reach (Governance Coverage); ComputerTool drives a computer through your Computer implementation 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.
FieldOpenAI Agents SDK adapter
hooked_toolsthe names of every governed FunctionTool, ShellTool, ApplyPatchTool and LocalShellTool, accumulated per governor
unhooked_exec_toolsthe 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_toolsempty
other_mcp_serversthe 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, sandboxomitted
config_hashas 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 as Agent objects with the same governor; a Handoff object built with handoff() is left as is — wrap its agent yourself before building the handoff.
  • RunConfig.tool_execution.pre_approval_tool_input_guardrails moves 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):

#GuaranteeThe test
C21G1, G9A 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.
C22G5, G11The 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.
C23G9, G10ShellTool: 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.
C24G6Hosted 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.
C25G11tool_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 surfacecrewai
attestation framework / adaptercrewai / mitrity-python
framework_versioncrewai.__version__
tool_nametool.name
tool_inputthe 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_idexplicit session_id, else one id per process — CrewAI exposes no run or task identity to a tool
cwdexplicit cwd, else the process working directory
tool_use_idabsent: 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 edgebuiltin:<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).

FieldCrewAI adapter
hooked_toolsthe name of every tool passed through govern / govern_tools, accumulated per governor
unhooked_exec_toolsempty by construction — the adapter sees only the tools it was handed
disallowed_tools, other_mcp_serversempty
permission_mode, sandboxomitted
config_hashas 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 that allow_code_execution=True installs (crewai-tools, running in Docker or unsafely on the host), MCP tools the agent's mcps setting fetches — never pass through govern and 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 any BaseTool; _arun of 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 / _arun up to the hold budget, then returns the ToolFailure.

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

#GuaranteeThe test
C26G1, G9run(**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.
C27G5, G11Keyword 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.
C28G6The 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.

AttackAnswer
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 matcherAttested 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 serverAttested: other_mcp_servers (degraded).
Load MCP servers from a settings file the adapter did not readAttested 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 allowsRefused 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 allowConformance 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 reasonThe adapter's deadline answers first (G3) with the real reason; the framework's timeout is a backstop, not the path.
  • 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 Bash executes in
  • Host Observer — the Linux observer that catches execution the adapter never saw
  • Writing Policiesbuiltin:* action types and the semantic command constraints the edge applies to admitted calls