Novo: AIBOM: uma lista de materiais em tempo real de cada agente, ferramenta e conector. Veja como
MITRITY
Docs / Integrações / Admission API

Admission API

Mitrity Gateway and Mitrity MCP Sidecar govern what an agent asks them to do. On their own they do not govern what the agent's framework does by itself: Claude Code's Bash, Write, Edit and WebFetch, the Claude Agent SDK's built-in tools, LangChain's ShellTool, a subprocess.run in the agent's own code. None of those produce an MCP call, so none of them reach the governance pipeline through MCP, and the only other way to close that path is to disable those tools.

The admission API closes the gap without asking the framework to speak MCP. It is the third entrance to the governance engine, alongside MCP tools/call and the Mesh Authorizer: a framework calls one local endpoint before it runs a built-in tool, and the edge runs the same pipeline it runs for an MCP call — tool permissions, injection detection, threat intelligence, command analysis, policy rules, DLP, holds, delegation — and answers allow, deny or held. One decision engine, one policy model, one audit trail, three entrances.

Both edge binaries serve it. Its callers are mitrity-hook, the PreToolUse hook for Claude Code and the Claude Agent SDK, and the language adapters. This page is the wire contract they implement; if you are writing a caller of your own, the conformance rules at the end are the acceptance criteria.

The API is deliberately a tiny, local surface that is never reachable from the network. It carries the agent's most sensitive material — the command it is about to run, the file it is about to write — so its threat model starts from "anything that can reach this socket can ask the edge to judge traffic as this agent".

Transport

A loopback listener, and nothing else:

FormExamplePermissions
Unix-domain socket (preferred)unix:/run/mitrity/admission.sock (the host layout; unix:/run/mitrity/edge/admission.sock on Kubernetes — see Hook configuration)socket file mode 0600, owned by the edge's uid, in a 0700 directory the edge owns
Loopback TCP127.0.0.1:8777, [::1]:8777, localhost:8777

Any other listen address — :8777, 0.0.0.0:8777, a routable interface — is a configuration error: the process refuses to start and exits non-zero. It is not a warning and there is no override flag. The check is the one the Mesh Authorizer and LLM Gateway listeners already use, so all three surfaces refuse the same addresses for the same reason.

HTTP/1.1 with JSON bodies. No TLS: a loopback or Unix-socket listener has nothing to terminate, and the confidentiality boundary is the socket's file permissions and the loopback interface.

Authentication

A per-process token, generated when the edge starts, written to a file with mode 0600, and never logged. The caller reads the file and sends the value in a header:

X-Mitrity-Admission-Token: <token>
X-Mitrity-Admission-Version: 1
Content-Type: application/json
  • The comparison is constant-time. A token mismatch is 401 with an empty body — no hint about length or prefix.
  • The token lives for the lifetime of the edge process. A restart invalidates it; callers re-read the file on a 401 and retry exactly once.
  • X-Mitrity-Admission-Version is required and must be 1. An absent or unrecognized version is 400; this is what lets a future revision change the request shape without a silently misinterpreted body in between.
  • The token file path is reported by the edge at startup and is the only supported way to obtain it. The token is never passed on a command line: argv is world-readable on Linux.

Endpoints

POST /v1/admit

Ask for a decision on a built-in tool call that has not run yet.

Request

{
  "surface": "claude_code",
  "framework_version": "2.0.31",
  "session_id": "b9e1…",
  "cwd": "/workspace/repo",
  "tool_name": "Bash",
  "tool_input": { "command": "rm -rf build", "description": "clean" },
  "tool_use_id": "toolu_01…",
  "hold_timeout_seconds": 540
}
FieldTypeRequiredNotes
surfaceenumyesWhich framework is asking: claude_code · claude_agent_sdk · langchain · openai_agents · crewai · custom. Read the note below — this field is not the audit surface.
framework_versionstringnoThe caller's own statement of the framework's release — for mitrity-hook, the CLAUDE_CODE_VERSION pin in its environment, sent on every call. Under builtin_exec_routing: governed_shell a Bash is routed only for a version at or above admission.routing_min_framework_version, but the version judged is the session's: when the session attested a probed version on /v1/attest, that version governs every call of the session and a per-call value that disagrees is not believed (logged once per session); a session with no probed attestation — including one that only attested a pin — is judged by this field, as source pinned; with neither, the version is unknown (Routed Bash, "Which frameworks are routed" and "Where the version comes from"). The event records the version judged and framework_version_source.
session_idstringnoThe framework's session identifier. Correlates a run's events; keys the session's framework version when the session attested a probed one (above); also the scope of the write-then-run taint store — without it the store falls back to the MCP session or the agent.
cwdstringnoWorking directory the tool would run in. Relative paths in tool_input are resolved against it before path constraints are evaluated.
tool_namestringyesThe framework's tool name, verbatim and case-preserved (Bash, Write, WebFetch).
tool_inputobjectyesThe framework's tool input, verbatim.
tool_use_idstringnoThe framework's id for this call; carried onto the audit event so a reviewer can line the decision up against the transcript.
hold_timeout_secondsinteger, minimum: 0noThe caller's time budget for a hold, in seconds. 0 means "do not wait": a matched hold answers held immediately with the approval_id. Omitted means "wait as long as the edge is configured to". A negative value is a 400. Always clamped down to the edge's own admission.hold_timeout — a caller can ask for less waiting, never for more. See Holds under Decision semantics.

surface here is not the audit surface. The request's surface names the calling framework; the audit surface names the entrance and is always agent_hook for every call on this API, whichever framework asked. The two also spell the same framework differently — this request uses underscores, the attestation's framework uses hyphens:

request surfaceattestation framework
claude_codeclaude-code
claude_agent_sdkclaude-agent-sdk
langchainlangchain
openai_agentsopenai-agents
crewaicrewai
customcustom

A caller must map between them rather than pass either through.

Response200 with:

{
  "decision": "deny",
  "reason": "policy rule \"no destructive commands\" denied resolved command: rm",
  "approval_id": null,
  "risk_score": 0.82,
  "admission_id": "3f0c…",
  "updated_input": null
}
FieldTypeNotes
decisionenumallow · deny · held.
reasonstringHuman-readable, safe to show the model and to print in a terminal. Names the rule and the resolved command, never a secret value.
approval_idstringThe approval this decision is waiting on. Present when decision=held, and on a deny whose reason is that the caller's budget expired with the approval still open. Correlates the blocked call with the approval a human sees in the MITRITY console — it is not a polling handle; see Holds below.
risk_scorenumber0.0–1.0, the pipeline's composite score.
admission_idstringIdentifies this decision; equals the event_id of the audit event written for it.
updated_inputobjectOptional rewritten tool input. Present only on allow. The caller MUST run the rewritten input, not the original. This is how a Bash call is routed into the first-party governed shell (see Routed Bash — the rewrite is {"command": "mitrity-hook exec <ticket>"}), and how a redacted argument is substituted. Keys not present in updated_input keep their original value: the object is merged into the framework's tool_input, which is also how Claude Code applies updatedInput.
routed_tostringPresent only when updated_input routes the call elsewhere. governed_shell is the only defined value. Lets the caller log where the execution went without inspecting the rewritten command.

Error responses: 400 malformed body or bad version, 401 bad token, 503 the edge is not ready (no mission profile was ever primed). Every non-200 is a deny for the caller — see the fail-closed rule under Decision semantics.

POST /v1/attest

Report the runtime's governance posture. The body is the runtime attestation: which built-in tools are hooked and which are not, the other MCP servers configured, the permission mode and sandbox settings, and the configuration hash. The full field list, and where each adapter takes each field from, is on Framework Adapters. The edge stores the latest attestation and ships it on the next heartbeat.

Returns 204 with no body. Called at session start and whenever the runtime configuration hash changes.

Three fields of the body tell the edge what the runtime knows about the framework's release. Each is agent-supplied, so the edge bounds it before anything stores or acts on it:

FieldBoundNotes
framework_versiontrimmed, clamped to 256 bytesThe release the runtime measured or was given. mitrity-hook session-start probes it from the framework binary that started the hook, else reports its CLAUDE_CODE_VERSION pin (Routed Bash, "Where the version comes from").
framework_version_sourcenormalized to one of pinned · probed · unknownHow it was learned. Never stored as spelled: a version with no source, or with one the edge does not recognize, reads as pinned — the only way an older caller ever had a version — and no version reads as unknown whatever the source claimed. probed is for a caller that ran the framework itself. The control plane reads the field by the same rule when a heartbeat carries none (an edge that predates it): pinned with a version, else unknown.
session_idclamped to 128 bytesThe framework session the attestation describes. Keys the session's version on the edge: a version attested here with framework_version_source: probed governs every /v1/admit of the same session_id, whatever each call reports; a pinned attestation rides the heartbeat but sets no session version, so its calls are judged by their own framework_version. A session id over 128 bytes keys nothing.

The edge remembers attested versions per session in a table bounded at 64 sessions. Only an attestation whose framework_version_source is probed writes an entry — a pinned or sourceless one never does, so an SDK adapter reporting its own package version under the hook's session id cannot take the session over. Past the bound, the oldest session the gate would route for is forgotten first, and the oldest of the rest only when no routable entry is left: a forgotten session is judged by its per-call value or as unknown, so a flood of bogus attestations can withhold routing, never grant it. Withheld is not nothing: when the per-call value does not qualify, under the default admission.routing_unsupported_action: fallback that session's Bash runs in the framework's own sandbox instead of the governed shell, and the table is filled from an endpoint the agent can reach — an install that must not degrade that way sets the knob to deny, under which a withheld route is a refusal (Routed Bash). A session that attests again with a probed version — claude --resume after an upgrade runs session-start under the same session id — replaces its version in either direction: the edge does not hold a session to its lowest attested version, because a resumed session on an upgraded framework would then stay unrouted; the corroborating axis is the ticket (not_redeemed), not monotonicity. A re-attestation that is not probed — a downgrade whose probe fails, leaving only the pin — overwrites nothing: the earlier probed entry keeps governing the session until the edge restarts. The table is process memory, empty after a restart; the hook re-attests on every session start, resume included.

What the control plane keeps of this: framework_version and framework_version_source are persisted on the agent's attestation record and shown on the agent's coverage view, so a fleet can be read by the kind of evidence its versions are. session_id is the edge's key: it rides the heartbeat with the rest of the attestation but is not persisted on that per-agent record — per-session correlation lives on the audit events, which carry metadata.session_id.

What the control plane does with the attestation — corroborating the claim against the execution statistics the edge reports on every heartbeat (ExecutionStats) and rendering the per-agent coverage badge — is described on Governance Coverage. The attestation on its own never yields governed there.

Which settings files may raise the claim. The runtime derives the attestation from the framework's settings files, and the session's working directory — where project and local settings live — arrives on the hook's stdin from the monitored agent, which can write files under it. A project file could therefore name a hook entry for every tool or deny a tool it never gives up, and forge the one report whose job is to say what is not covered. The rule is one-directional: agent-writable scope can only lower the attestation, never raise it. hooked_tools and disallowed_tools come from managed and user settings only; from project and local scope the runtime takes what subtracts — a disabled hook engine, additional other_mcp_servers, sandbox keys that weaken isolation — and ignores what would add. The reference implementation is mitrity-hook session-start; mitrity-hook doctor names project-scope hook entries it ignored, so a misplaced install is diagnosable. No wire field records the scope: the attestation is self-asserted either way, and the corroboration rules on Governance Coverage are what hold it to account.

GET /v1/managed-settings

Render the served agent's mission profile as a framework managed-settings document — the configuration that makes the hook, the MCP allowlist and the OS sandbox non-removable on a managed machine. The edge already holds the profile; without this endpoint a renderer would need a second, agent-credentialed path to the control plane just to read policy the edge is enforcing anyway.

The edge renders the document; it never installs it. Writing it to the platform's managed path is a privileged, one-time administrator action, done with your fleet's device management (Jamf, Intune, a configuration profile), and deliberately outside anything the agent's own uid can do — otherwise the agent could rewrite the policy that constrains it.

Authenticated with the same two headers as /v1/admit. Query parameters:

ParameterRequiredNotes
frameworknoWhich framework's dialect to render. claude-code is the only defined value and the default; any other value is 400.
hook_pathnoAbsolute path of the mitrity-hook binary to embed in the hook commands. Ignored when the edge pins admission.hook_path — an operator-controlled value always wins over a caller-supplied one. Otherwise it must be absolute and its basename must be mitrity-hook (or mitrity-hook.exe); anything else is a 400, and the default is the bare name mitrity-hook, resolved on the user's PATH.
framework_versionnoThe Claude Code release the caller measured or was given at render time — mitrity-hook render-managed-settings probes the claude on the operator's PATH, or reports its own CLAUDE_CODE_VERSION pin (Routed Bash, "Where the version comes from"). Trimmed and clamped to 256 bytes. A release number is pinned into the rendered env block as CLAUDE_CODE_VERSION; a value that does not parse pins nothing, as does absence — and under builtin_exec_routing: governed_shell either yields a warning (Routed Bash, "The relay and the framework's own sandbox").
framework_version_sourcenoHow the caller learned it, pinned or probed, normalized as on /v1/attest. Stated next to the value in the document's warnings.

Response200 with:

{
  "framework": "claude-code",
  "generated_at": "2026-09-18T09:14:02Z",
  "settings_hash": "9f2c…",
  "warnings": ["credential masking not rendered: broker grants carry no host binding"],
  "settings": { }
}
FieldTypeNotes
frameworkstringEchoes the rendered dialect.
generated_atstringRFC 3339 render time.
settings_hashstring, ^[0-9a-f]{64}$SHA-256 over the canonicalized settings document — see below. Identifies this rendering, so an administrator can tell whether the file on a machine is the one MITRITY produced.
warningsarrayPolicy this renderer could not express in the framework's vocabulary. Never populated for cosmetic reasons, and never silently omitted: an unrenderable control is a coverage gap the administrator has to know about. Also states, when the env block pins CLAUDE_CODE_VERSION, the pinned value and its framework_version_source — the value is the caller's, and the document is installed with administrator privilege, so it must be reviewable on its face — and, under builtin_exec_routing: governed_shell, a version the routing gate would not route for (Routed Bash).
settingsobjectThe managed-settings document, ready to write to the platform's managed path.

Canonicalization. Two independent implementations hash this document — the edge that renders it and anything that later checks a machine against it — so the byte sequence is pinned rather than left to a JSON library's defaults. It is RFC 8785 (JSON Canonicalization Scheme): UTF-8, object members sorted by the code points of their names, no insignificant whitespace, no trailing newline, and only the escapes RFC 8259 requires. The rendered document contains only strings, booleans, non-negative integers, arrays and objects, so a full JCS implementation is not needed — but an implementation that hashes a JSON encoder's output with HTML escaping left on, or with struct field order instead of sorted keys, produces a different hash and is wrong.

settings_hash is NOT the attestation's config_hash. They cover different things on purpose, and treating them as comparable produces a drift alarm that is always on. settings_hash covers this document. config_hash covers the runtime's effective configuration — for Claude Code, managed settings merged with user, project and local settings and CLI flags. allowManagedHooksOnly and allowManagedMcpServersOnly pin the hooks and the MCP list, but sandbox.* and permissions.* still merge with a developer's own files, so on any machine with a user settings file the two values legitimately differ. The comparison settings_hash supports is "is the managed-settings file on this machine the one MITRITY rendered", against a hash of that file — not "does the effective configuration match".

settings is the framework's own schema, not MITRITY's. For claude-code the renderer emits exactly these keys, each derived from the mission profile:

KeyDerived from
hooks.PreToolUseOne entry matching the execution-capable built-in tools, running <hook_path> pre-tool-use.
hooks.SessionStartOne entry running <hook_path> session-start.
allowManagedHooksOnlyAlways true — the hook is the entrance; a removable entrance is not one.
envThe hook's own configuration, pinned so it is as non-removable as the hook: MITRITY_ADMISSION_ADDR and MITRITY_ADMISSION_TOKEN_FILE from the edge's own listener and token path, MITRITY_HOOK_FAIL_MODE=closed, and CLAUDE_CODE_VERSION when the caller reported a release number (framework_version above; a value that does not parse is never pinned). Managed values beat user and project settings. Absent, with a warning, when the edge did not supply its listen address and token path.
allowedMcpServersThe co-located edge's own stdio command, and nothing else.
allowManagedMcpServersOnlyAlways true, so the allowlist is exclusive rather than advisory.
sandbox.enabled, sandbox.allowUnsandboxedCommands, sandbox.failIfUnavailableThe isolation posture: on, no unsandboxed retry, refuse to run when the sandbox cannot start.
sandbox.network.allowedDomains, sandbox.network.allowManagedDomainsOnlyThe agent's DLP destination allowlist, and allowManagedDomainsOnly: true beside it — array settings merge across scopes, so without the lock a developer could append to the allowlist in their own settings file. Emitted together and only together: both absent when the agent has no allowlist — an empty allowlist would silently mean "no egress at all", which is a policy nobody wrote, and the lock without a list would mean the same — with a warning saying the framework's default network policy applies.
sandbox.network.allowUnixSocketsUnder builtin_exec_routing: governed_shell only, when admission.listen_addr is a Unix socket: a one-element list naming that socket, so the relay a routed Bash runs can reach the edge from inside the framework's sandbox (Routed Bash, "The relay and the framework's own sandbox"). Rendered whatever framework version the caller reported; absent, with a warning, for a loopback TCP listener. allowAllUnixSockets is never emitted.
permissions.denyBash(…) rules derived from deny rules whose constraints name commands (commands, resolved_commands).

The permissions.deny rules are belt and braces, not the enforcement. They are a first-word prefix match in the framework's own vocabulary and every wrapper defeats them; the command analysis behind /v1/admit is what actually decides. They are rendered because a second, independent block costs nothing and covers the window where the hook is not yet installed.

One of the constraint keys they derive from, commands, is deprecated for authoring — it is the evadable first-word denylist that semantic command constraints replaced. Reading an existing rule's commands here is not authoring: a tenant that still has such rules should still get the belt-and-braces block for them.

Error responses: 400 unknown framework or a bad hook_path, 401 bad token, 503 no mission profile was ever primed — the same fail-closed rule as /v1/admit: a renderer that cannot read policy must not emit a document that looks like policy.

GET /healthz

Liveness and readiness. 200 with {"status":"ok","profile_age_seconds":12} when a mission profile is cached and current; 503 when the edge has never been primed. Unauthenticated — it reveals nothing beyond "a MITRITY edge is listening here", which anyone who can reach the socket already knows.

POST /v1/exec

Redeem an execution ticket issued by /v1/admit for a routed Bash call (Routed Bash) and run the already-judged command in the governed shell, streaming its output. Served only by Mitrity Gateway (the sidecar has no exec source) and only when exec.enabled is true; otherwise 404.

Request

{ "ticket": "met_1Kx…" }
FieldTypeRequiredNotes
ticketstringyesThe exec_ticket the /v1/admit response embedded in updated_input.command. Opaque, ≥ 128 bits of entropy, met_ prefix.
stdinstringnoMust be absent or empty. Not an input at redeem time: a routed Bash is judged at admit time with no stdin — the framework's Bash has none and the relay sends none — and the plan under the ticket is what runs; bytes arriving here were never hashed, scanned or, for a shell reading its input, parsed and merged into the tree. The redeem checks the field before the ticket is taken, size first: a value above exec.limits.stdin_max_bytes is 400 {"error":"stdin_too_large"}, any other non-empty value is 400 {"error":"stdin_unjudged"}. Neither spends the ticket and neither writes an execution event — the ticket stays redeemable until its TTL. The whole request body is bounded earlier still by admission.max_body_bytes (64 KiB by default; a 400 whose error is a sentence, not a stable token), and that cap is below the default exec.limits.stdin_max_bytes (1 MiB): under the defaults an oversized stdin fails the body cap first, and stdin_too_large is reachable only on an edge whose max_body_bytes was raised above stdin_max_bytes.

Response200 with Content-Type: application/x-ndjson, one JSON object per line, streamed as the command runs:

{"stream":"stdout","data":"<base64>"}
{"stream":"stderr","data":"<base64>"}
{"exit":{"status":"completed","exit_code":0,"duration_ms":412,"timeout_seconds":120,"truncated":false,"sandbox_mode":"bubblewrap","redaction_count":0,"event_id":"…"}}

stdout/stderr frames carry bytes that have already been through truncation, credential redaction and output-phase DLP — the caller relays them; it never sees an unredacted byte. Frames may interleave in any order; each stream's bytes are in order. Exactly one exit frame ends the stream; its fields are the subset of the execution result a caller needs to render the outcome, and event_id is the audit event the full result was written to.

Error responses, in the order the redeem checks them. Before the ticket is taken — none of these spends it or writes an execution event: 401 bad token; 404 {"error":"exec_unavailable"} (the sidecar, or a gateway without exec); 400 malformed body, body over admission.max_body_bytes, or no ticket; 503 no profile; then the request-time stdin, size first — 400 {"error":"stdin_too_large"} above exec.limits.stdin_max_bytes (reachable only when admission.max_body_bytes exceeds it; under the defaults the body cap fires first), else 400 {"error":"stdin_unjudged"} for any non-empty value. Then the ticket is taken: 404 unknown or expired ({"error":"ticket_unknown"} / {"error":"ticket_expired"}), 409 already redeemed ({"error":"ticket_redeemed"}). Past that point the ticket is spent and a refusal is an execution event of status: failed_to_start with the same reason: 409 {"error":"profile_changed"} — the mission profile is no longer the one the plan was judged under, or no longer routes Bash into the governed shell — and 429 {"error":"concurrency_limit"} (the shell is at exec.limits.max_concurrent). Every non-200 is a failed execution for the caller, never a reason to run the original command.

Ticket rules. A ticket is single-use, expires admission.exec_ticket_ttl after issue (default 60s, range 10s10m), is bound to exactly one admission decision, its execution plan and the mission profile the plan was judged under, and is redeemable only together with the admission token header — a ticket alone (read from a process table) is worthless. Stdin is guarded at two layers. The first is the redeem itself, above: a request-time stdin is refused before the ticket is taken, and that refusal is not an execution — no event, the ticket kept. The second is the executor: the plan's stdin is part of what was judged and is fixed at issue, and the executor accepts only bytes equal to the judged plan's — a routed plan carries none and the redeem passes none, so a conforming relay never reaches this layer; were it ever reached with other bytes, nothing would run, the execution event would record status: failed_to_start with reason stdin_unjudged, and the ticket would be spent. The profile binding is the profile's identity — its control-plane version and updated_at, compared together so neither alone decides; a profile delivered without an updated_at reads as a new profile on every refresh, which fails closed. A ticket redeemed under a profile other than the one its plan was judged under — a refresh inside the ticket's lifetime, or one that turned routing off — is refused the same way rather than outrun: 409 profile_changed, the ticket spent, the execution event failed_to_start / profile_changed; a command judged under an older profile never runs under a newer one. Issue and redeem are audited: the decision event carries metadata.exec_ticket_issued: "true"; an expiry writes an execution event with execution_result.status: not_redeemed, which is the signal that the framework did not run what the hook told it to. The audit contract stays exactly two events per routed command: a refused redeem writes none, a spent ticket writes the second.

Decision semantics

An admitted call is translated into exactly the same action an MCP call produces, and goes through exactly the same pipeline.

Action type. builtin: followed by the lowercased tool_name:

Framework toolAction type
Bashbuiltin:bash
Writebuiltin:write
Editbuiltin:edit
Readbuiltin:read
WebFetchbuiltin:webfetch
WebSearchbuiltin:websearch
NotebookEditbuiltin:notebookedit
Globbuiltin:glob
Grepbuiltin:grep
Taskbuiltin:task
anything elsebuiltin:<lowercased name>

The table is a naming convention, not an allowlist: an unknown tool gets builtin:<lower> and is evaluated by the same rules. A policy writes tool_pattern: "builtin:*" to cover the whole surface and tool_pattern: "builtin:bash" to target one tool (Writing Policies).

One exception, by design: when the policy sets builtin_exec_routing: governed_shell, an admitted Bash is evaluated as shell:execute, not builtin:bash, because the governed shell — not the framework — is the tool that will execute it. Rules and tool permissions written for the governed shell then cover Bash automatically, and a rule on builtin:bash no longer matches (there is no framework execution to govern). The event records metadata.framework_tool: Bash so audit can still answer "what did the model call". See Routed Bash.

Surface. The audit surface is agent_hook on every event from this API, for every framework. The requesting framework is recorded separately (the surface field of the request; framework, framework_version and framework_version_source on the attestation; and on every event an admitted call produces, metadata.framework_version as judged and metadata.framework_version_source), so audit can answer both "which entrance" and "which framework".

Parameters. tool_input is flattened into the action's parameters by exactly the rule the MCP path uses for tools/call arguments: a string value is copied verbatim; any other JSON value is serialized back to JSON and stored as its text. Nothing is renamed, reordered or dropped — a rule that matches command on an MCP shell tool matches command here.

Identity. The acting identity is the configured agent of the co-located edge. The request does not carry an agent id and the edge would not believe one if it did: a process that can reach this socket is inside the agent's trust boundary and could otherwise assert any identity in the tenant. One edge process serves one agent identity.

Audit. Every admitted call — allowed, denied, held or timed out — produces an audit event with surface=agent_hook. There is no quiet path: a decision the control plane never hears about is not governance.

Fail closed. This is an execution surface, so the policy's fail_mode does not apply. The edge denies when:

  • no mission profile was ever primed, or
  • the pipeline itself errors.

A primed profile that is past its TTL and could not be refreshed keeps serving: the edge enforces the last-known policy and stamps every event it reports meanwhile with the metadata key profile_stale=true, while its heartbeat reports AGENT_STATUS_DEGRADED.

And the caller denies when the edge is unreachable, returns a non-200, or does not answer inside the caller's deadline. Both halves must hold; either one alone is a bypass.

Holds. decision=held means a human must approve. The caller states its time budget in hold_timeout_seconds; the edge long-polls the approval up to that budget and returns allow or deny if the approval resolves inside it. If the budget expires first, the edge returns deny with a reason naming the pending approval_id — a hold that nobody answered is not an allow.

Three different timeouts meet here, and confusing them is how a hold silently becomes an allow:

TimeoutOwnerWhat it bounds
hold_timeout_seconds on the policy's blocking configuration (BlockingConfig) — the hold_timeout_minutes you set on a hold policythe policyHow long the approval itself stays open before the control plane resolves it per timeout_action.
admission.hold_timeoutthe edge listenerThe longest this listener will wait on any one call, whatever the policy says. Default 9m.
hold_timeout_seconds on the requestthe callerThis call's budget.

The effective wait is the minimum of the three: a request for less waiting, never more, so an edge configured to wait 9 minutes never waits longer because a caller asked it to.

When the caller's budget runs out short of the policy's own timeout, the approval is still open and nobody has timed it out — so the policy's timeout_action MUST NOT fire. An implementation that applies it here lets any caller turn a held action into an allow by asking impatiently, under a policy that says timeout_action: allow. Deny, and name the pending approval_id.

A caller that sends 0 has chosen not to wait at all and gets held plus the approval_id on the first round trip. This API deliberately exposes no local poll route in v1: a decision belongs to the call that asked for it, and a "check back later" endpoint would invite a caller to run the tool the moment it got bored. The approval_id is for correlating the blocked call with the approval a human sees in the MITRITY console; a caller that wants a second answer re-submits /v1/admit. Either way the tool call is blocked, because held is not an allow.

Caller contract: the Claude Code PreToolUse hook

The hook is the reference caller and the strictest one, because of one vendor behavior: a Claude Code hook that times out does not block the tool call. The framework's default hook timeout is 600 s and a timeout is treated as "no opinion". Fail-closed therefore cannot live in the framework; it must live in the hook.

Consequently:

  1. The hook owns a sub-second deadline of its own (500 ms by default) for the whole /v1/admit round trip. It never inherits the framework's timeout.
  2. Any failure is a deny: socket missing, connection refused, 401, 503, malformed response, deadline exceeded. The hook exits 2 with the reason on stderr, which is how Claude Code blocks a tool call and feeds the reason back to the model. The JSON form — {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"…"}} — is equivalent and preferred when the hook has a reason worth rendering.
  3. allow is silent: exit 0, no output, so the framework's own permission flow continues unchanged. When the response carries updated_input, the hook emits it as updatedInput so the framework runs the rewritten call.
  4. held long-polls within the framework's budget, not beyond it — in two calls. The first asks with hold_timeout_seconds: 0 under the hook's own deadline, so an unreachable edge is a deny in milliseconds rather than after the framework gives up; only a held answer starts the second, which re-submits the same request with hold_timeout_seconds = (framework timeout − safety margin) so the edge long-polls the approval. The hook denies if the approval has not resolved by then. Because the API has no poll route (see Holds), the second call is a new admission and the edge opens a second approval record for the same action; the operator resolves whichever is pending and the stale one times out. Every adapter pays the same price (Framework Adapters, G4).
  5. The hook never fabricates an allow. There is no "degraded mode", no cached previous decision, no local policy evaluation. If it cannot reach the edge it denies and says so.

Hook configuration

The hook is configured by environment only — its command line is written once into a settings document an administrator installs, and argv is world-readable on Linux. The same variables, with the same defaults and ceilings, configure the language adapters (Framework Adapters), so one provisioning step serves both callers.

VariableDefaultMeaning
MITRITY_ADMISSION_ADDRunix:/run/mitrity/admission.sock (127.0.0.1:8777 on Windows)The edge's admission.listen_addr. Must be loopback or a Unix socket; anything else is refused before any I/O, with the rule the edge applies to its own listener.
MITRITY_ADMISSION_TOKEN_FILE/run/mitrity/admission.token (%PROGRAMDATA%\Mitrity\admission.token on Windows)The edge's admission.token_file. Read on every attempt, so a token minted by a restarted edge is picked up.
MITRITY_HOOK_TIMEOUT500ms, ceiling 30sThe caller's own deadline for one decision (item 1).
MITRITY_HOOK_HOLD_TIMEOUT540s, ceiling 570s; 0 disables waitingThe budget of the second call (item 4). The ceilings keep both under the framework's 600 s hook budget, past which the framework kills the hook and runs the tool ungoverned.
MITRITY_HOOK_FAIL_MODEclosedHook only. open makes an unreachable edge allow — never a policy deny — and prints ALLOWING this action UNGOVERNED to the transcript on every such allow; mitrity-hook doctor reports it as a failure. It exists for a first install, on a machine an operator is standing at, and a managed-settings document pins it back to closed. The language adapters read this variable and ignore it: shipped code does not get a fail-open switch.
CLAUDE_CODE_VERSIONunsetHook only. An operator's pin of the framework's release number; Claude Code does not set it. Sent as framework_version on every /v1/admit, and attested as source pinned when the session-start probe cannot ask the framework that started the hook — the deterministic alternative to the probe, and the only source on Windows (Routed Bash, "Where the version comes from"). render-managed-settings pins the version it learns into the managed env block under this name.

The two path defaults are the host layout: /run/mitrity is a directory the edge owns with mode 0700 — its runtime-directory guard creates the directory when missing and refuses to start on one owned by another uid or writable by group or world, because anything that can write there can replace the socket. They are the compiled-in fallback of mitrity-hook and of the adapters when the variables are unset; the edge itself has no default — admission.listen_addr and admission.token_file are required whenever admission.enabled is true. On Kubernetes the runtime volume is an emptyDir the kubelet creates root-owned and world-writable, which that guard refuses, so the Kubernetes layout puts both files one level down, in a directory the edge creates itself: unix:/run/mitrity/edge/admission.sock and /run/mitrity/edge/admission.token. The mitrity-gateway and mitrity-mcp-sidecar Helm charts default to those values, the injector fixes them, and all three set both variables explicitly on the agent container, so the fallback never applies there. The guard's reason holds one level up as well — the parent is root's and world-writable — and is answered there by the pod shape rather than by mode bits: the only container that mounts the runtime volume writable is the edge's own, the agent's mount is read-only, the injector's validating webhook refuses any other container (ephemeral ones included) on that volume, and nothing outside the pod can reach an emptyDir.

A malformed duration falls back to its default rather than failing: a misconfigured timeout must not become a crashed hook and an ungoverned session.

Managed settings (allowManagedHooksOnly, a gateway-only MCP allowlist) make the hook non-removable on managed machines. On unmanaged machines removal is detected — the attestation stops arriving, the execution statistics (ExecutionStats) stop incrementing — not prevented.

Audit metadata privacy

An admitted call's tool_input reaches the audit event as param.<key> metadata (each value capped at 4096 bytes), because for an execution surface the argument is the evidence: "rm -rf denied with the policy reason, with the resolved command in the audit log" is the promise. Two things are kept out of it at the edge's wire boundary, for every surface except the LLM gateway (which fingerprints every prompt segment):

  • Documents are fingerprinted, not sent. A value under a content-shaped key — content, contents, new_string, old_string, text, data, body, code, script, stdin, file_text, edits, patch, diff — is replaced by sha256:<hex>;len=<bytes>. A Write is audited as "wrote N bytes to this path with this hash"; your source never travels.
  • Secrets inside arguments are redacted span by span. Bearer and Basic authorization values, KEY=value assignments whose name says token, secret, password, passphrase, API key, access key, private key or credential, --password/--token-style flag values, URL userinfo passwords, bare AWS access keys and GitHub tokens, PEM private-key blocks, and high-entropy tokens with known secret prefixes are each replaced by [REDACTED:<shape>] in place; the command line around them is unchanged. The event carries param_redactions (the count) whenever at least one span was replaced, so a reader can tell an edge redaction from the agent's own text.

The redaction is deliberately not a fingerprint: the whole point of this surface is the readable command. What it never does is widen — the control plane persists what arrives and adds no key that could carry a body. Scripts and tool output never ride on the event at all.

Routed Bash

builtin_exec_routing on the policy (default framework) decides where a framework built-in execution tool admitted here actually runs. Under governed_shell the framework never executes the model's command; the first-party governed shell does, and the framework runs a relay instead.

Why a rewrite and not an MCP call. A PreToolUse hook can change a tool's input (updatedInput); it cannot change which tool runs, and nothing else in the framework turns a Bash call into a tools/call. The only mechanism that keeps the framework's tool, transcript and result rendering intact while moving the execution is to rewrite command to a relay that talks to the co-located gateway. Both ends are MITRITY's: the hook is a plain binary on the machine, the gateway holds the plan.

Which frameworks are routed. A routed allow rests on one thing the edge cannot check after the fact: that the framework runs the relay it was handed rather than the model's command. A framework that ignores updatedInput would run the original command with its own permission prompt suppressed by the allow — fail-open on the one path built to be fail-closed. So the edge routes only for a framework whose version it knows honors updatedInput: at or above admission.routing_min_framework_version (default 2.0.10, the Claude Code release that introduced PreToolUse updatedInput; the Claude Agent SDK spawns the same Claude Code binary and runs its hooks, so the same number covers it). The knob is a release number, strictly MAJOR[.MINOR[.PATCH]] — an optional leading v, an optional -prerelease / +build suffix, a pre-release ordering below the release it precedes, nothing else (no ranges, no wildcards) — and a value that does not parse refuses to start. A reported version is held to the same grammar, with the suffix, if any, non-empty and made only of letters, digits, ., - and +; anything else is framework_version_invalid.

The version judged is the session's, not the call's, when the session attested a probed version on /v1/attest — one measured from the framework binary that started the hook; else the call's own framework_version (source pinned), else unknown. A probed value wins on purpose. The per-call value is the hook's environment, which the framework's own settings can set at project scope — a file under the working directory the agent writes — whereas the probed value was learned from the running process at session start, before any tool ran. A per-call value that disagrees with its session's probed attestation is not believed; the edge logs the disagreement once per session and judges by the attestation. A session that could only attest a pin (Windows, a platform with no process reader, a framework absent from the hook's ancestry) keys nothing and is judged call by call from that same environment-supplied value — the trade the probed-only rule makes so that an adapter's reported version can never take over a session; the managed env block outranks project scope, which is where the pin belongs.

Below the minimum, absent or unparseable, admission.routing_unsupported_action decides:

  • fallback (the default): the call is neither routed nor denied for it — judged in framework mode for that one call, as builtin:bash, and the framework runs it.
  • deny: the call is refused before the shell is asked and before any hold — a 200 with decision: deny, risk_score: 1.0, no ticket, and a reason that starts routing_unsupported: and names the observed version and its source (or that none was reported), the reason token below, the minimum the edge routes from, and the way out ("Upgrade Claude Code, or pin CLAUDE_CODE_VERSION"). Audited like the other routed refusals: a blocked shell:execute decision, blocked_by: command, metadata.reason: routing_unsupported, metadata.framework_tool: Bash, no execution event. For an install that wants the governed shell enforced rather than degraded.

Either way the event carries metadata.routing_unsupported: "true" and routing_unsupported_reason, one of framework_version_missing, framework_version_invalid, framework_version_below_minimum, next to the framework_version it was judged by (absent when none was known) and framework_version_source. The edge logs the outcome on the first call per reason and then every thousandth — per reason, not per version: the reason set is three fixed tokens the caller cannot grow, where a per-version key was a bounded set a caller could fill to silence the warning for a real downgrade.

Where the version comes from. Claude Code exports no version to its hooks, so the hook learns it in this order, best evidence first, and attests the best it has as framework_version + framework_version_source:

  1. Probed from the framework that started the hook (probed). At session-start the hook finds the process that spawned it — the parent, or the parent's parent, since Claude Code runs a command hook through sh -c; never further — from the kernel: /proc/<pid>/status, /proc/<pid>/exe and /proc/<pid>/cmdline on Linux (an executable replaced on disk since it started reads as none), the kern.proc.pid and kern.procargs2 sysctls on macOS (no ps, no subprocess). A process is the framework when its kernel-reported executable's basename, or its own argv[0], is claude; what runs is always the kernel-reported executable, never the name. The hook runs it as <executable> --version — a 3 s deadline, killed when it expires; a minimal environment of PATH and HOME only; working directory /; no stdin; stderr discarded; stdout bounded to 4 KiB — and the first whitespace-delimited token of the first non-empty line must parse as a release number (2.1.260 (Claude Code)2.1.260; a usage message, or Claude Code 2.1.260, is no version). The probe runs even when a pin is set and, succeeding, wins: it is the binary running this very session, so it corrects a stale pin and catches a downgrade. A framework that was found and did not answer — timed out, exited non-zero, printed no release number, or has no readable executable — leaves the version unknown when nothing is pinned; it is never replaced by another install's number.
  2. Pinned (pinned): CLAUDE_CODE_VERSION in the hook's environment — the managed env block, or a shell. An operator's statement, not a measurement, and the deterministic alternative: the only source on Windows, where the probe is a no-op; on any platform without a process reader; and wherever the framework is not on the hook's ancestry (an SDK host that spawns a CLI not named claude).
  3. Probed from claude on PATHnever at session-start. The session hook runs as the agent, its PATH is one a project-scope settings file under the agent-writable working directory can set, and the PATH branch would be reached exactly when the framework did not start the hook; so a session attests only what its spawner reports, or the pin. The PATH lookup exists for the subcommands an operator runs from a shell — render-managed-settings and doctor — where the framework is on no ancestry, and only when nothing is pinned: the pin ranks above it because the claude on PATH need not be the one a session runs (a desktop-app session, an SDK host with an embedded CLI).
  4. unknown — nothing measured, nothing pinned. The gate does not route for it.

What the probe will run is narrow by construction, and every failure of these rules reads as unknown. The candidate is resolved through its symlinks first, so the file vetted is the file run; the resolved path MUST be absolute; MUST NOT lie under a refused root — $TMPDIR (the platform temp directory), /tmp, /var/tmp, /dev/shm, CLAUDE_PROJECT_DIR, the session's working directory (from the SessionStart payload: agent-supplied, but a refused root can only refuse more) and the hook's own working directory, each compared in its raw and its symlink-resolved form (/tmp is /private/tmp on macOS); MUST be a regular file, not world-writable, and executable. Only a program named claude is ever a candidate, a PATH entry that resolves to a relative path is refused, and nothing else is ever executed.

render-managed-settings pins what its caller reports (the framework_version and framework_version_source query parameters of GET /v1/managed-settings) into the rendered env block as CLAUDE_CODE_VERSION, and the document's warnings state the pinned value and its source — the value is the caller's, and the document is installed with administrator privilege, so it must be reviewable on its face. A value that does not parse is never pinned: the renderer parses and bounds the value itself rather than trusting the handler that judged it, and the warning names at most 64 bytes of it, marked (truncated) when it cut, since the env pin beside it carries the whole value (up to 256 bytes) and a reader comparing the two must not take the shorter one for the pin. A pin is a statement about the install as of the render: upgrading Claude Code means re-rendering, unless the session-start probe can run the framework that starts each session, in which case the running version takes precedence over the pin. Pin the version your fleet actually runs, and if in doubt raise routing_min_framework_version rather than lower it. mitrity-hook doctor prints the version the hook would attest and where it learned it (it may consult PATH; the session hook does not).

The attested version is the agent's to send. POST /v1/attest is reachable by anything holding the token, so a session's version is self-asserted like the rest of the attestation: the probe runs the framework's own --version, so a hostile framework can print anything, and a pin that overstates the install makes the edge route to a framework that may run the original command. What corroborates it is the ticket — a routed command the framework never redeems is written as not_redeemed — and framework_version_source on the attestation and on every event says which case a fleet is in. The bounded session table (POST /v1/attest, above) forgets routable sessions first, so a flood of attestations can only withhold routing, never widen it.

The sequence.

  1. The hook sends /v1/admit for Bash as today. cwd and, when present, tool_input.timeout (milliseconds — Claude Code's own per-call timeout) are read; the timeout is clamped to the policy's exec_timeout_seconds.

  2. The edge evaluates the call as shell:execute (above). On allow it builds the execution plan — pins included, so the bytes are fixed at this moment — stores it under a fresh ticket, and answers:

    {
      "decision": "allow",
      "reason": "allowed; routed to the governed shell",
      "admission_id": "3f0c…",
      "routed_to": "governed_shell",
      "updated_input": { "command": "mitrity-hook exec met_1Kx…" }
    }
    

    held behaves as today: the hook long-polls, and an approval yields the same allow + updated_input. deny is unchanged. Three refusals come before the shell is asked, and before any hold: a policy that routes Bash on an edge that serves no governed shell (exec.enabled false, or the sidecar) is denied routing_unavailable; an admission.hook_path that cannot be rendered into the relay command is denied hook_path_invalid; and, under admission.routing_unsupported_action: deny, a framework whose version is not known to honor updatedInput is denied routing_unsupported ("Which frameworks are routed", above). All three are fail-closed — the framework must not run the command itself — and are audited as a blocked shell:execute decision (blocked_by: command, metadata.reason, metadata.framework_tool: Bash); no ticket is issued and no execution event follows.

  3. The hook emits permissionDecision: "allow" with updatedInput {"command": "mitrity-hook exec met_1Kx…"}. Only command is rewritten; description, timeout and run_in_background keep the model's values through the merge. allow rather than ask: the policy — including its hold for a human — is the decision, and showing a human a relay command with a ticket in it would be a prompt about nothing. This matches the framework's own posture for sandboxed Bash (autoAllowBashIfSandboxed, default true), and it is why routing is a policy knob, not a default.

  4. The framework runs mitrity-hook exec met_1Kx…. The relay reads the token file, calls POST /v1/exec with the ticket and no stdin, writes each stdout/stderr frame to its own stdout/stderr as it arrives, and exits with the command's exit code. Failure codes: 124 timed out (as timeout(1)), 126 could not start (ticket unknown/expired/redeemed, 409 profile_changed, 401, 429, 503, socket down — the reason on stderr), 137 killed. It never runs the original command, and it never runs anything itself.

  5. The gateway writes the execution event; a PostToolUse hook sees the redacted output like any other Bash result.

The relay and the framework's own sandbox. Claude Code runs Bash commands — now the relay — inside its sandbox when sandbox.enabled is true, and hook commands outside it. The relay needs exactly one thing the sandbox denies by default: a connection to the admission socket (a filesystem Unix socket; on Linux the sandbox's network namespace has no loopback route, so a TCP listener would not help). The managed-settings renderer therefore emits, when builtin_exec_routing: governed_shell:

KeyValueWhy
sandbox.enabledtrueunchanged
sandbox.allowUnsandboxedCommandsfalseunchanged
sandbox.failIfUnavailabletrueunchanged
sandbox.network.allowUnixSockets["<the admission.listen_addr socket path>"]lets the relay reach the admission socket — that one socket, not every socket on the host (allowAllUnixSockets is never rendered). The sandbox is not the relay's alone: on the paths this page warns about — a hook shadowed earlier on PATH, a hook that errors, MITRITY_HOOK_FAIL_MODE=open — and under routing_unsupported_action: fallback, the model's own command runs in it, so it is widened by exactly one socket and nothing else; the model's routed command runs in the governed shell, whose own sandbox denies AF_UNIX.
sandbox.network.allowedDomains + allowManagedDomainsOnlyunchanged: rendered together whenever the policy has a destination allowlist, both omitted (with the warning) when it has nonethe relay makes no network connection and the governed shell applies its own proxy on top, but the same fallback paths run the model's command in this sandbox, so routing neither drops nor widens the egress allowlist

The allowance is rendered under governed_shell whenever the listener is a Unix socket, whatever version the caller reported: a version the gate would not route for — missing, unparseable, below the minimum — yields a warning naming the cause, the consequence on the machine that installs the document (fallback, or deny under the knob) and the way out (upgrade and re-render, or pin), never a withheld allowance. The socket's eligibility is decided per session at runtime, and a document rendered where the framework is absent is routinely installed on machines whose session-start probe succeeds; withholding the allowance on render-time evidence would leave the relay unable to reach the socket on exactly those machines, and every routed Bash failing closed until a re-render. Routing needs a Unix-socket admission listener: with a loopback TCP admission.listen_addr the relay inside the sandbox cannot reach the edge (on Linux the sandbox's network namespace has no loopback route), no allowance is rendered, and the renderer says so in warnings[]. The allowance is what macOS's Seatbelt needs. The upstream sandbox runtime documents allowUnixSockets as ignored on Linux, where a pathname Unix socket is reached through the mount namespace rather than the network namespace and bubblewrap on its own refuses no AF_UNIX connect (the governed shell adds a seccomp stage to do that; see bubblewrap_no_seccomp) — so the relay is expected to reach the socket there with or without the key. This has not yet been confirmed against a Claude Code release. The runtime attestation reports the sandbox block as configured; the coverage posture reads sandbox_disabled / unsandboxed_commands_allowed from it as before — routing changes which sandbox matters for Bash, not what is attested.

Audit. Two events per routed command, both action_type: shell:execute, surface: agent_hook: the decision at admit time (command_analysis, metadata.framework_tool: Bash, metadata.exec_ticket_issued: "true", metadata.framework_version as judged and metadata.framework_version_source — the last on every event an admitted call produces, routed or not), and the execution at redeem time (execution_result, command_analysis copied, execution_result.decision_event_id = the first event's id). A ticket that expires unredeemed writes the second event with status: not_redeemed.

Boundary. Routing covers Bash. Write, Edit, WebFetch and the rest are admitted and then performed by the framework as before; run_in_background Bash calls are routed like any other and bounded by the policy timeout, not the framework's background lifetime.

Threat model

The attacker is the model, or anything that has persuaded the model. It runs inside the agent's trust boundary and can read every file the agent can read.

AttackMitigation
Call the API as a different agentIdentity is the edge's configured agent; the request carries none.
Reach the socket from another hostLoopback/UDS only, enforced at config load; a routable listener refuses to start.
Steal the tokenMode-0600 file owned by the edge's uid; never in argv, never logged. A process that can read it is already the agent's uid.
Replay an old allowDecisions are per-request; there is no cacheable token of an allow. admission_id is an audit identifier, not a capability.
Kill the edge, then run freelyCaller-side fail-closed: connection refused is a deny. The missing heartbeat is also an availability finding.
Remove the hookManaged settings prevent it where the machine is managed; attestation, traffic statistics and the host observer detect it where it is not.
Downgrade the protocolX-Mitrity-Admission-Version is required; an unknown version is a 400, which the caller treats as a deny.
Read the whole policyGET /v1/managed-settings hands the rendered deny rules and the DLP destination allowlist to anything holding the token — that is, to the model. This IS a widening: /v1/admit leaks one rule's reason per decision, this leaks the shape of the policy in one call. Accepted deliberately: the same process can read the agent's files and watch its own denies accumulate, so the policy is not a secret from it, and an attacker who learns the allowlist still cannot change it. It is not accepted quietly — the endpoint is separate from /v1/admit precisely so an operator who disagrees can put a socket-level or policy-level control on it.
Steer the rendered hook commandhook_path chooses the binary embedded in a document an administrator will install with root-equivalent privilege. Constrained rather than trusted: an operator-pinned admission.hook_path overrides it outright, and otherwise it must be absolute with basename mitrity-hook. That is a guard rail, not a guarantee — the administrator installing the document MUST read the rendered commands first, which is why the edge renders and never installs.
Smuggle the real command past the parserCommand analysis, not the admission API — see semantic command constraints.
Steal an execution ticket (read mitrity-hook exec met_… from the process table and redeem it)Single use, 60-second TTL, bound to one decision, redeemable only with the 0600 token — without the token it is 401; with it the reader already is the agent. Never a credential.
Run the original command anyway (ignore updated_input, strip the hook, run Bash directly)Conformance item 4 for the caller; managed settings pin the hook; the ticket expiring unredeemed writes a not_redeemed event, and the observer (Linux) sees an exec with no MITRITY ancestor. Detected, not prevented, on an unmanaged machine — as for hook removal.
Replay a ticket after the plan changed (get a benign plan ticketed, alter the workspace, redeem)The plan — pins included — is fixed at admit time; the redeem executes that plan, not the workspace.
Run the original command on a framework that ignores updated_input (an old or unversioned install)Routing is gated on a framework version at or above admission.routing_min_framework_version — the session's when the hook attested a probed one at session start, else the call's own framework_version, since a pin sets no session version; below it, absent or unparseable, the call is judged and the framework runs it, or is denied under routing_unsupported_action: deny (routing_unsupported). The gate closes the accidental case. It is only as true as the binary asked or the pin written: a hostile framework can print any --version, and it is not a defense against an operator who pins a lie.
Raise a session's routing eligibility by attesting a higher version (POST /v1/attest with the token, which the model's process holds)Self-asserted by design: the session's version is one axis, never sufficient alone, and framework_version_source on every event says what kind of evidence it was. The bounded session table forgets routable sessions first, so flooding it can withhold routing, never widen it; a version the framework then fails to honor shows up as an expired ticket — the not_redeemed event — and, on Linux, as an exec with no MITRITY ancestor. Detected, not prevented.

Conformance

A caller conforms when all of the following hold. These are the acceptance criteria for mitrity-hook and for every adapter.

  1. Sends X-Mitrity-Admission-Token and X-Mitrity-Admission-Version: 1 on every request.
  2. Treats every non-200, transport error and deadline miss as a deny.
  3. Enforces its own deadline, shorter than the framework's.
  4. Runs updated_input when present, and the original input only when it is absent.
  5. Blocks in the framework's own idiom on deny (exit 2 / permissionDecision for Claude Code) rather than merely warning.
  6. Calls /v1/attest at session start and on configuration change, reporting unhooked_exec_tools completely — including tools it chose not to hook — and carrying session_id and, when it knows one, the framework's version with a truthful framework_version_source: probed only for a version it measured from the running framework, pinned for one it was given. The hook never reports a version learned from a claude on the agent's PATH ("Where the version comes from").
  7. Under routing, relays POST /v1/exec frames verbatim, sends no stdin, exits with the command's exit code (or 124/126/137 as specified), never executes the original command and never executes anything itself.
  • Framework Adapters — what a language adapter guarantees on top of this contract, the attestation fields, and the conformance tests every adapter ships
  • Governance Coverage — the decision points, the execution-coverage badge the attestation feeds, its findings, and installing the hook
  • Governed Shell — the sandboxed shell a routed Bash executes in: the plan, the pins, the sandbox modes, the egress allowlist
  • Integration Modes — the gateway, the sidecar, and the admission API as the third entrance
  • Deployment Guidemitrity-gateway --standalone for developer machines, and where the binaries come from
  • Writing Policiesbuiltin:* and shell:execute action types, semantic command constraints, script inspection and the taint store
  • Approval Workflows — hold policies, hold_timeout_minutes and timeout_action
  • Host Observer — the Linux observer that attributes every process execution to governance, and how it joins hooked executions to admission decisions