New: Agent approvals, red teaming and model scanning. See how
MITRITY
Docs / Integrations / Admission API

Admission API

The admission API lets an agent framework ask MITRITY for a decision before it runs one of its own built-in tools, such as Claude Code's Bash. Mitrity Gateway and Mitrity MCP Sidecar both serve it, on a local socket.

The gateway and the 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. Without the admission API, the only 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, next to MCP tools/call and the Mesh Authorizer. Before a framework runs a built-in tool, it calls one local endpoint. The edge then runs the same pipeline it runs for an MCP call, and answers allow, deny or held. That pipeline covers tool permissions, injection detection, threat intelligence, command analysis, policy rules, DLP, holds and delegation. So the three entrances share one decision engine, one policy model and one audit trail.

Both edge binaries serve it. Its callers are:

  • mitrity-hook, the PreToolUse hook for Claude Code and the Claude Agent SDK;
  • 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 small and local, and never reachable from the network. It carries the agent's most sensitive material: the command it is about to run, and the file it is about to write. So its threat model starts from this: anything that can reach this socket can ask the edge to judge traffic as this agent.

Transport

The API listens on a loopback address or a Unix socket, 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, such as :8777, 0.0.0.0:8777 or 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 Mesh Authorizer and LLM Gateway listeners use the same check, so all three surfaces refuse the same addresses for the same reason.

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

Authentication

Callers authenticate with a per-process token. The edge generates it when it starts, writes it to a file with mode 0600, and never logs it. 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, with no hint about length or prefix.
  • The token lives as long as the edge process. A restart invalidates it, so 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 lets a future revision change the request shape without a body being silently misread in between.
  • The edge reports the token file path at startup, and reading that file is the only supported way to get the token. The token is never passed on a command line, because 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. This field is not the audit surface: read the note below.
framework_versionstringnoThe caller's own statement of the framework's release. For mitrity-hook, it is 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. 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. It correlates a run's events, and keys the session's framework version when the session attested a probed one (above). It is also the scope of the write-then-run taint store. Without it, the store falls back to the MCP session or the agent.
cwdstringnoThe working 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. It is 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. The value is 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, and 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.

Response: 200 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, and safe to show the model and to print in a terminal. It 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. It correlates the blocked call with the approval a human sees in the MITRITY console. It is not a polling handle: see Holds under Decision semantics below.
risk_scorenumber0.0–1.0, the pipeline's composite score.
admission_idstringIdentifies this decision. It 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 (the rewrite is {"command": "mitrity-hook exec <ticket>"}; see Routed Bash), 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. It lets the caller log where the execution went, without inspecting the rewritten command.

Error responses:

  • 400: a malformed body, or a bad version;
  • 401: a 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 the sandbox settings;
  • the configuration hash.

Framework Adapters lists every field, and where each adapter takes it from. The edge stores the latest attestation, and ships it on the next heartbeat.

It returns 204 with no body. Callers call it 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. The agent supplies each of them, so the edge bounds each one 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, or else reports its CLAUDE_CODE_VERSION pin (Routed Bash, "Where the version comes from").
framework_version_sourcenormalized to one of pinned · probed · unknownHow the version was learned. It is never stored as spelled. A version with no source, or with a source the edge does not recognize, reads as pinned, the only way an older caller ever had a version. No version reads as unknown, whatever the source claimed. probed is for a caller that ran the framework itself. When a heartbeat carries no source (an edge that predates the field), the control plane reads it by the same rule: pinned with a version, else unknown.
session_idclamped to 128 bytesThe framework session the attestation describes. It 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 that reports its own package version under the hook's session id cannot take the session over.
  • Past the bound, the edge first forgets the oldest session the gate would route for. It forgets 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, but never grant it.
  • A session that attests again with a probed version replaces its version, in either direction. (claude --resume after an upgrade runs session-start under the same session id.) 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 overwrites nothing, for example a downgrade whose probe fails, leaving only the pin. 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.

Withheld routing 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).

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 you can read a fleet 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.

The control plane corroborates the claim against the execution statistics the edge reports on every heartbeat (ExecutionStats), and renders the per-agent coverage badge. Governance Coverage describes both. 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. 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. So a project file could 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 the 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. You do it with your fleet's device management (Jamf, Intune, a configuration profile), deliberately outside anything the agent's own uid can do. Otherwise the agent could rewrite the policy that constrains it.

It is 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_pathnoThe absolute 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. 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, and neither does an absent one. Under builtin_exec_routing: governed_shell, either case 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. It is stated next to the value in the document's warnings.

Response: 200 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). It 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. When the env block pins CLAUDE_CODE_VERSION, the warnings also state 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. Under builtin_exec_routing: governed_shell, they also name 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, and no trailing newline;
  • 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, that is 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. It is 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, and 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, and refuse to run when the sandbox cannot start.
sandbox.network.allowedDomains, sandbox.network.allowManagedDomainsOnlyThe agent's DLP destination allowlist, with 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. The two are emitted together, and only together. When the agent has no allowlist, both are absent, with a warning saying the framework's default network policy applies: an empty allowlist would silently mean "no egress at all", a policy nobody wrote, and the lock without a list would mean the same.
sandbox.network.allowUnixSocketsOnly under builtin_exec_routing: governed_shell, when admission.listen_addr is a Unix socket: a one-element list naming that socket. It lets the relay that a routed Bash runs 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 before the hook is 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: an unknown framework, or a bad hook_path;
  • 401: a bad token;
  • 503: no mission profile was ever primed.

The 503 is the same fail-closed rule as on /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.

It is 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 that /v1/admit issued for a routed Bash call (Routed Bash), and run the already-judged command in the governed shell, streaming its output. Only Mitrity Gateway serves it (the sidecar has no exec source), and only when exec.enabled is true. Otherwise it answers 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. It is 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"}, and 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). That cap is below the default exec.limits.stdin_max_bytes (1 MiB). So 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.

Response: 200 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, and never sees an unredacted byte. Frames may interleave in any order, but 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. First, the checks before the ticket is taken. None of these spends the ticket or writes an execution event:

  • 401: a bad token;
  • 404 {"error":"exec_unavailable"}: the sidecar, or a gateway without exec;
  • 400: a malformed body, a 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 for an unknown or expired ticket ({"error":"ticket_unknown"} / {"error":"ticket_expired"});
  • 409 for a ticket 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;
  • 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 10s–10m);
  • is bound to exactly one admission decision, its execution plan, and the mission profile the plan was judged under;
  • is redeemable only together with the admission token header, so a ticket alone (read from a process table) is worthless.

Stdin is guarded at two layers:

  1. The redeem itself, above: a request-time stdin is refused before the ticket is taken. That refusal is not an execution: no event, and the ticket is kept.
  2. The executor: the plan's stdin is part of what was judged, and is fixed at issue. 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 that 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 is refused, rather than outrun. That covers a refresh inside the ticket's lifetime, and one that turned routing off. The answer is 409 profile_changed, the ticket is spent, and the execution event records 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. That 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, and 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. The action type is 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. The governed shell, not the framework, is the tool that will execute it. So rules and tool permissions written for the governed shell 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, so audit can answer both "which entrance" and "which framework":

  • the surface field of the request;
  • framework, framework_version and framework_version_source on the attestation;
  • on every event an admitted call produces, metadata.framework_version as judged, and metadata.framework_version_source.

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;
  • 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.

The caller, in turn, 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 caller can ask for less waiting, never for more: an edge configured to wait 9 minutes never waits longer because a caller asked it to.

When the caller's budget runs out before 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. Under a policy that says timeout_action: allow, an implementation that applies it here lets any caller turn a held action into an allow, just by asking impatiently. 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". So fail-closed cannot live in the framework; it must live in the hook.

So the hook follows this contract:

  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 is equivalent, and preferred when the hook has a reason worth rendering: {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"…"}}.

  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 call 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 call. It 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 under Decision semantics), 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, and no local policy evaluation. If it cannot reach the edge, it denies and says so.

Hook configuration

The hook is configured by environment variables 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, by 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. It is 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. The edge's runtime-directory guard creates the directory when it is missing. It 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.

Those defaults 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 that the kubelet creates root-owned and world-writable, which the 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, because the parent is root's and world-writable. There, the pod shape answers it, rather than 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 on that volume, ephemeral ones included;
  • 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, not prevented: the attestation stops arriving, and the execution statistics (ExecutionStats) stop incrementing.

Audit metadata privacy

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

  • Documents are fingerprinted, not sent. A value under a content-shaped key is replaced by sha256:<hex>;len=<bytes>. The content-shaped keys are content, contents, new_string, old_string, text, data, body, code, script, stdin, file_text, edits, patch and diff. 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. Each of these is replaced by [REDACTED:<shape>] in place, and the command line around it is unchanged:

    • 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;
    • high-entropy tokens with known secret prefixes.

    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's 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. Rewriting command to a relay that talks to the co-located gateway is the only mechanism that moves the execution while keeping the framework's tool, transcript and result rendering intact. Both ends are MITRITY's: the hook is a plain binary on the machine, and 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. That would be 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. The default is 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, with a pre-release ordering below the release it precedes;
  • nothing else: no ranges, no wildcards.

An edge configured with 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 chosen in this order:

  1. the session's version, when the session attested a probed version on /v1/attest: one measured from the framework binary that started the hook;
  2. else the call's own framework_version (source pinned);
  3. else unknown.

A probed value wins on purpose. The per-call value comes from the hook's environment, which the framework's own settings can set at project scope: a file under the working directory the agent writes. The probed value was learned from the running process at session start, before any tool ran. So 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 keys nothing, and is judged call by call from that same environment-supplied value. That happens on Windows, on a platform with no process reader, and when the framework is absent from the hook's ancestry. It is 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, and that 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. It is 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. The answer is a 200 with decision: deny, risk_score: 1.0, no ticket, and a reason that starts routing_unsupported: . The reason 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"). It is audited like the other routed refusals: a blocked shell:execute decision, blocked_by: command, metadata.reason: routing_unsupported, metadata.framework_tool: Bash, and no execution event. Use it for an install that wants the governed shell enforced rather than degraded.

Either way, the event carries:

  • metadata.routing_unsupported: "true";
  • routing_unsupported_reason, one of framework_version_missing, framework_version_invalid, framework_version_below_minimum;
  • 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. It counts per reason, not per version: the reason set is three fixed tokens the caller cannot grow, whereas a per-version key was a bounded set that 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. It reads that process from the kernel:

    • on Linux, /proc/<pid>/status, /proc/<pid>/exe and /proc/<pid>/cmdline (an executable replaced on disk since it started reads as none);
    • on macOS, the kern.proc.pid and kern.procargs2 sysctls (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:

    • with a 3 s deadline, killed when it expires;
    • with a minimal environment of PATH and HOME only;
    • in working directory /, with no stdin;
    • with stderr discarded, and stdout bounded to 4 KiB.

    The first whitespace-delimited token of the first non-empty line must parse as a release number: 2.1.260 (Claude Code) gives 2.1.260, while a usage message, or Claude Code 2.1.260, is no version.

    The probe runs even when a pin is set and, when it succeeds, wins: it is the binary running this very session, so it corrects a stale pin and catches a downgrade. A framework that was found but did not answer (it 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, from the managed env block or a shell. It is an operator's statement, not a measurement, and the deterministic alternative. It is the only source:

    • on Windows, where the probe is a no-op;
    • on any platform without a process reader;
    • wherever the framework is not on the hook's ancestry, such as an SDK host that spawns a CLI not named claude.
  3. Probed from claude on PATH, never at session-start. The session hook runs as the agent, and a project-scope settings file under the agent-writable working directory can set its PATH. 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, or 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 is 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 into the rendered env block as CLAUDE_CODE_VERSION: the framework_version and framework_version_source query parameters of GET /v1/managed-settings. 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. The warning names at most 64 bytes of the value, marked (truncated) when it cut. 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. The exception is when the session-start probe can run the framework that starts each session: then 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. Anything holding the token can reach POST /v1/attest, 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;
  • a pin that overstates the install makes the edge route to a framework that may run the original command.

What corroborates the version 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 usual. The edge reads cwd and, when present, tool_input.timeout (in milliseconds: Claude Code's own per-call timeout). 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. It stores the plan 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 usual: 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;
    • 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. They 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. The hook answers allow rather than ask, because the policy, including its hold for a human, is the decision. 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, and calls POST /v1/exec with the ticket and no stdin. It writes each stdout/stderr frame to its own stdout/stderr as it arrives, and exits with the command's exit code. Its failure codes are:

    • 124: timed out (as timeout(1));
    • 126: could not start (ticket unknown/expired/redeemed, 409 profile_changed, 401, 429, 503, socket down), with 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. When sandbox.enabled is true, Claude Code runs Bash commands (now the relay) inside its sandbox, and hook commands outside it. The relay needs exactly one thing the sandbox denies by default: a connection to the admission socket. That is a filesystem Unix socket. On Linux, the sandbox's network namespace has no loopback route, so a TCP listener would not help. So when builtin_exec_routing: governed_shell, the managed-settings renderer emits:

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

Under governed_shell, the allowance is rendered 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) never withholds the allowance. It yields a warning instead, naming:

  • the cause;
  • the consequence on the machine that installs the document (fallback, or deny under the knob);
  • the way out (upgrade and re-render, or pin).

The reason: the socket's eligibility is decided per session, at runtime. 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 would fail 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. There, 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. Each routed command produces two events, both with action_type: shell:execute and surface: agent_hook:

  1. 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);
  2. the execution, at redeem time: execution_result, command_analysis copied, and 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, and 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, while this leaks the shape of the policy in one call. It is 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 that 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. It is 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, and 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. On an unmanaged machine it is detected, not prevented, 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 it 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 the gate 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. It reports unhooked_exec_tools completely, including tools it chose not to hook. It carries 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, and 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, and exits with the command's exit code (or 124/126/137 as specified). It 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, with its plan, pins, sandbox modes and egress allowlist
  • Integration Modes: the gateway, the sidecar, and the admission API as the third entrance
  • Deployment Guide: mitrity-gateway --standalone for developer machines, and where the binaries come from
  • Writing Policies: builtin:* 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