Agent host contract¶
An Agent Host Protocol (AHP) host runs many agent sessions across harnesses (Claude, Codex, Calliope AI) and lets several clients attach to each one. Any attached client may confirm a pending tool call, so enforcement sits in the host: before a tool call reaches its clients, the host's gate asks Zentinelle. This page is the contract between that gate and Zentinelle: identity, the tool_call evaluation, and the oversight hold (#377).
The gate is additive. Hooks, the gateway and the SDKs keep governing sessions outside the host exactly as before.
Identity: one key per host¶
A host registers once, as agent_type: agent_host, and keeps its sk_agent_ key in the host process. Harness processes never see it. Sessions are not registered; every evaluation names its harness, session and chat in context.
POST /api/zentinelle/v1/register
X-Zentinelle-Bootstrap: bt_<tenant_id>_<signature>
Content-Type: application/json
{"agent_id": "agenthost-dev", "agent_type": "agent_host", "name": "Agent host (dev)"}
{
"agent_id": "agenthost-dev",
"api_key": "sk_agent_...",
"config": {"heartbeat_interval_seconds": 60, "event_batch_size": 100, "event_flush_interval_seconds": 5, "config_refresh_interval_seconds": 300},
"policies": []
}
A platform service can create the same identity with POST /api/zentinelle/v1/operator/agents and a platform or service key with the write scope.
Registering each session through /register was rejected. Sessions are short-lived and numerous, so session churn would become endpoint and key churn, every session would consume an agent entitlement, and each session's key would have to live inside the harness process the gate is policing. With one key per host, endpoint-scoped policies apply to every session the host runs, user-scoped policies follow the top-level user_id, and per-session attribution comes from context.
Evaluating a tool call¶
POST /api/zentinelle/v1/evaluate with the host key in X-Zentinelle-Key.
| Field | Required | Meaning |
|---|---|---|
action |
yes | tool_call |
user_id |
recommended | The person driving the session. Approvals bind to it. |
context.harness |
yes | The provider inside the host, as a lowercase slug of up to 50 characters: claude, codex, calliope. |
context.session_id |
yes | The AHP session. Identifiers take up to 255 characters. |
context.chat_id |
no | The chat within the session. |
context.tool_call_id |
no | The pending tool call. Recommended: it binds an approval to this one call. |
context.tool_name |
yes | The tool, as the harness names it. |
context.tool_input |
no | The tool's arguments. Bound into approvals and stored only when content capture allows. Command and path rules read them; see rules on tool arguments. |
{
"action": "tool_call",
"user_id": "dev@example.com",
"context": {
"harness": "claude",
"session_id": "a41f0c2e",
"chat_id": "a41f0c2e-1",
"tool_call_id": "toolu_01",
"tool_name": "Bash",
"tool_input": {"command": "npm test"}
}
}
Rules on tool arguments¶
A tool_permission policy names argument rules per tool under tool_configs, keyed by the tool's name as the harness sends it (#384):
{
"tool_configs": {
"Bash": {"blocked_commands": ["rm -rf *"], "allowed_commands": ["npm test", "npm run *"]},
"Write": {"allowed_paths": ["/workspace"], "blocked_paths": ["/workspace/.git"]}
}
}
With that policy, {"tool_name": "Bash", "tool_input": {"command": "rm -rf /"}} is denied and {"command": "npm test"} is allowed.
- Commands come from
commandorcmd, a string or an argv list. The command is split into simple commands at;,&&,||,|,&, newlines, parentheses, backticks and$(, and the script ofsh -c,bash -lcand the like, or ofeval, is split the same way. Quotes are removed and whitespace collapsed, then each simple command is matched against glob patterns (*matches anything, spaces included). - A blocked pattern refuses the call if it matches any simple command, or its tail from any word on, so
rm -rf *also refusessudo rm -rf /. It matches text, not meaning:rm -fr /, or a command assembled from variables, is a different text. Blocked patterns are a guard. - With allowed patterns, every simple command must match one in full, so anything not listed is refused. That is the boundary. For allowed patterns the command is read as a shell reads it, and what Zentinelle could read differently from a shell is refused: command substitution, or a backslash before
$, a backtick or a newline, inside double quotes;$'...'quoting; here-documents. Process substitution,<(...)and>(...), is a separate command. A call carrying bothcommandandcmdmust pass with each. - Paths come from
file_path,path,notebook_path,pathsand the file headers of anapply_patchpatch, and a call with none is checked by its working directory. A relative path resolves againstworkdirorcwdin the arguments, elsecontext.cwd. A path is inside a listed prefix when it is the prefix or below it after.and..are resolved. Every path must be inside an allowed prefix and none inside a blocked one. Symbolic links are not visible to Zentinelle. - A command that cannot be parsed, a call with no command or path to check, and a relative path with no working directory where a rule names absolute paths are refused.
- A call its arguments refuse is
deny, neverask: no approval can release it.
The rules read context.tool_input and context.tool_args, the older field; a call carrying both must pass with each.
A host tool call missing harness, session_id or tool_name, or with a malformed one, is refused before evaluation:
{"error": "Invalid agent host tool_call context", "context": {"session_id": ["This field is required."]}}
Decisions¶
decision |
allowed |
The host |
|---|---|---|
allow |
true |
Continues through its normal confirmation policy. |
deny |
false |
Rejects the tool call and shows reason to every client. |
ask |
false |
Holds the tool call pending; see the oversight hold. |
allowed is true only for allow, so a client that reads only allowed treats ask as a refusal.
allow:
{
"contract_version": "1",
"subject": {"tenant_id": "acme", "user_id": "dev@example.com", "endpoint_id": "6d0c4a0e-8a3b-4f8e-9d1c-2b7e5f9a1c33", "agent_id": "agenthost-dev"},
"action": "tool_call",
"resource": {"type": "tool", "id": "Bash"},
"context": {"harness": "claude", "session_id": "a41f0c2e", "chat_id": "a41f0c2e-1", "tool_call_id": "toolu_01", "tool_name": "Bash", "tool_input": {"command": "npm test"}, "resource_type": "tool", "resource_id": "Bash", "trace_id": "0b6f3a52-5d0e-4f7e-9a8e-2f1d6c9b7e41", "taxonomy": {"supported": [], "unsupported": []}},
"trace_id": "0b6f3a52-5d0e-4f7e-9a8e-2f1d6c9b7e41",
"decision": "allow",
"allowed": true,
"reason": null,
"policies_evaluated": [],
"coverage": {"status": "unknown", "counts": {"enforced": 0, "observation_only": 0, "unsupported": 0}},
"warnings": [],
"output_filter_required": false
}
deny (the remaining fields as above):
{
"decision": "deny",
"allowed": false,
"reason": "Tool 'Bash' is explicitly denied",
"policies_evaluated": [{"id": "c2f5...", "version": 3, "name": "No shell", "type": "tool_permission", "result": "fail", "message": "Tool 'Bash' is explicitly denied", "matched_selectors": [], "coverage": "enforced"}]
}
ask (the remaining fields as above):
{
"decision": "ask",
"allowed": false,
"reason": "Tool 'Bash' requires approval. Request approval before proceeding.",
"policies_evaluated": [{"id": "8f08...", "version": 1, "name": "Shell needs a human", "type": "tool_permission", "result": "fail", "message": "Tool 'Bash' requires approval. Request approval before proceeding.", "matched_selectors": [], "coverage": "enforced"}],
"approval": {
"request_id": "5a0d9f3e-0c77-4c1b-9d52-7f4b8f0f3e10",
"status": "pending",
"expires_at": "2026-09-22T20:05:00.412345Z",
"timeout_seconds": 300
}
}
ask is returned when every enforced failure is a missing human approval, and only to agent_host endpoints. Other agent types keep receiving deny for the same policies. A call that any other rule refuses is deny, even when it also needs approval. The policies that can ask are tool_permission (requires_approval), agent_capability (require_approval) and human_oversight (require_approval_for).
The oversight hold¶
/evaluateanswersaskwithapproval.request_id. Zentinelle opens an approval request.- The host keeps the AHP tool call pending. A confirmation from an attached client does not release it; only an approval does.
- The host polls
GET /api/zentinelle/v1/approvals/requests/{request_id}with its key, every 2 seconds or so, until the status leavespendingortimeout_secondshave passed on its own clock. - An operator lists pending requests and approves or denies this one; see deciding.
- On
approved, the host sends the same evaluation again withcontext.approval_tokenadded.allowconsumes the approval and the call proceeds;denyrejects it. - On
denied,expired, or its own timeout, the host rejects the call with the reason.
The window is the shortest approval_timeout_seconds among the host's effective policies, at most 300 seconds. After approval, the token is valid for one use until approval_expires_at, also at most 300 seconds.
The approval is bound to the host, the user, the action, the current policy versions and a digest of every context field except approval_token, request_id and trace_id. The retry must repeat them exactly; a different session, tool, input or tool_call_id is denied. The retry is a full evaluation, so a policy that changed since the request was opened can still refuse it. It never answers ask again: once a token is presented, a failed approval check is a denial.
Each ask opens a new request, so hold one call per request.
Polling¶
Only the host whose call is held can read the request; any other key receives 404.
{"request_id": "5a0d9f3e-0c77-4c1b-9d52-7f4b8f0f3e10", "status": "pending", "expires_at": "2026-09-22T20:05:00.412345Z"}
{
"request_id": "5a0d9f3e-0c77-4c1b-9d52-7f4b8f0f3e10",
"status": "approved",
"expires_at": "2026-09-22T20:05:00.412345Z",
"approval_token": "IjRlMTIy...:1x9CZZ:r4sgb8...",
"approval_expires_at": "2026-09-22T20:06:12.004211Z"
}
{"request_id": "5a0d9f3e-0c77-4c1b-9d52-7f4b8f0f3e10", "status": "denied", "expires_at": "2026-09-22T20:05:00.412345Z", "reason": "Not on the release branch"}
{"request_id": "5a0d9f3e-0c77-4c1b-9d52-7f4b8f0f3e10", "status": "expired", "expires_at": "2026-09-22T20:05:00.412345Z", "reason": "No approval arrived before the request expired"}
The retry that releases an approved call:
{
"action": "tool_call",
"user_id": "dev@example.com",
"context": {
"harness": "claude",
"session_id": "a41f0c2e",
"chat_id": "a41f0c2e-1",
"tool_call_id": "toolu_01",
"tool_name": "Bash",
"tool_input": {"command": "npm test"},
"approval_token": "IjRlMTIy...:1x9CZZ:r4sgb8..."
}
}
Deciding¶
Operators decide in a portal session. Workload keys cannot call these endpoints (401), so no agent can approve its own work.
Open Governance → Approvals (/approvals) in the portal. The inbox shows the
newest pending requests, their agent, user, harness, session, policy reason and
remaining time. It refreshes every 15 seconds. Viewers can inspect requests;
operators and administrators can approve or deny, optionally recording a reason
of up to 1,000 characters. The server checks tenant access, role and expiry again
when saving the decision. An expired request cannot be approved.
Expand Action details to inspect retained arguments or delegation scope and budgets. When arguments are absent, the page explains metadata-only capture and links to Settings. It does not reconstruct arguments that were never retained. The inbox uses the human session and CSRF protection; it never requests or displays the workload's approval token. If a decision cannot be confirmed, refresh the inbox before trying again: the original submission might have been saved.
GET /api/zentinelle/v1/approvals/requests (viewer role or above) lists the tenant's pending, unexpired requests, newest first, up to 100:
{
"requests": [
{
"request_id": "5a0d9f3e-0c77-4c1b-9d52-7f4b8f0f3e10",
"agent_id": "agenthost-dev",
"user_id": "dev@example.com",
"action": "tool_call",
"context": {"harness": "claude", "session_id": "a41f0c2e", "chat_id": "a41f0c2e-1", "tool_call_id": "toolu_01", "tool_name": "Bash", "resource_type": "tool", "resource_id": "Bash", "trace_id": "0b6f3a52-5d0e-4f7e-9a8e-2f1d6c9b7e41", "taxonomy": {"supported": [], "unsupported": []}},
"reason": "Tool 'Bash' requires approval. Request approval before proceeding.",
"trace_id": "0b6f3a52-5d0e-4f7e-9a8e-2f1d6c9b7e41",
"expires_at": "2026-09-22T20:05:00.412345Z",
"created_at": "2026-09-22T20:00:00.412345Z"
}
]
}
The context shown follows the tenant's content capture mode. Under metadata, the default, tool_input is not stored, so the approver sees which tool in which session but not its arguments; set content_capture_mode to redacted or full to show them.
POST /api/zentinelle/v1/approvals/requests/{request_id}/decision (operator role or above, with the CSRF token):
{"request_id": "5a0d9f3e-0c77-4c1b-9d52-7f4b8f0f3e10", "status": "approved", "decided_at": "2026-09-22T20:01:12.004211Z"}
decision is approve or deny. The response is 404 for a request outside the operator's tenant and 409 once the request is decided or expired. Each decision is written to the audit log.
Failing closed¶
Anything other than a 200 with decision: allow means the call does not run:
| Response | Meaning |
|---|---|
400 |
The request or the host context is invalid. |
401 |
The key is missing, unknown, expired, suspended or revoked. |
403 |
agent_id in the body does not match the key. |
503 |
Policy evaluation is unavailable. The body is a structured deny with reason: "Policy evaluation unavailable". |
| No response in time | Zentinelle is unreachable. The host denies. |
Every evaluation is recorded as an event for the host's endpoint, with its session context. A held call is recorded like any refusal, a policy_violation event, with result.decision: "ask" and approval_request_id in its payload.
OTLP traces from the agent host¶
POST /api/zentinelle/v1/otlp/v1/traces accepts OTLP/HTTP JSON trace batches
with the same X-Zentinelle-Key authentication as /events. The authenticated
agent endpoint determines the tenant and agent; telemetry resource attributes
cannot select either. A successful ingest returns HTTP 200 and {}. Protobuf
and gRPC are not supported by this receiver.
Each span becomes an agent_host.span telemetry event. Trace ID and span ID
form its producer event ID, so exporter retries are idempotent and conflicting
replays are refused. The whole batch is validated before any event is stored.
The receiver accepts at most 1,000 spans per request. The existing ingest
content-capture policy and processing queue apply unchanged.
The host's vscode.agent_host.session.uri attribute becomes
payload.context.session_id, matching the session on policy evaluations and
approval requests. Span name and arbitrary attributes are content, omitted
under the default metadata capture policy. Trace IDs, span IDs, duration and
status remain available for session correlation.
Configure the host's native exporter with OTEL_EXPORTER_OTLP_PROTOCOL=http/json,
OTEL_EXPORTER_OTLP_ENDPOINT=<zentinelle-origin>/api/zentinelle/v1/otlp/v1/traces,
and OTEL_EXPORTER_OTLP_HEADERS=X-Zentinelle-Key=<agent-host-key>.
Keep OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=false unless the
operator explicitly enables content capture. This receiver needs no separate
collector, observer or process; it uses the existing tenant event pipeline.
Astrolift pre-approval channel¶
The minting install evaluates tool calls at
POST /api/zentinelle/v1/astrolift/agents/{agent_id}/evaluate with tenant_id,
action: tool_call, the task initiator's user_id, and host context. The endpoint
must belong to that install and tenant; revoked or expired agent credentials
refuse evaluation. No agent key is distributed to the host.
An ask result identifies a held approval request. Astrolift checks its current
controller permission before posting tenant_id, decision: approve|deny,
actor_id, and an optional reason to
/astrolift/agents/{agent_id}/approvals/{request_id}/decision. The decision is
bound to the endpoint and exact tool context, audited with the install and
controller identities, and identical retries reuse the single-use grant.
Astrolift evaluates again with the returned approval_token; current policies
can still deny. Network failures keep the runner blocked.
Observed sessions and enforcement receipts¶
The durable Astrolift AUDIT.agent.session.event bridge carries recorded runner
events, scoped to the enrolled install, tenant, registered cluster and minted
agent endpoint. Task, turn, tool, team and project identities preserve evidence
correlation. Declared agent/spec/tool-preset intent accompanies observed tool
names. Prompt, tool-input and output content is omitted by the bridge; Zentinelle
also applies its content-capture settings before persisting evidence.
Observed tool input and reported approval holds run through the existing policy engine and shared action set. A decision produces an install-scoped durable action, without pretending the observed event preceded execution. Audit policy mode limits the action to observation. Unsupported runtime controls must report the actual fallback and outcome.
An install polls GET /api/zentinelle/v1/astrolift/agents/{agent_id}/enforcements
with its enrollment Bearer credential and tenant_id query parameter. Pending
actions have a fresh nonce and 60-second expiry on each delivery. The signature
is sha256= followed by HMAC-SHA256 over UTF-8 JSON (sorted keys, compact
separators, no NaN), using SHA256 of the presented install credential as the key.
The body binds the action ID, install, tenant, cluster, agent and task/box target.
Zentinelle stores the credential hash, and signs only on that authenticated
polling channel; it does not recover or retain plaintext enrollment credentials.
The install reports a bounded terminal outcome to
POST /api/zentinelle/v1/astrolift/agents/{agent_id}/enforcements/{action_id}/outcome
with tenant_id and outcome. Status is applied, observed, refused or
failed. An identical receipt is idempotent; a conflicting receipt returns 409.
Evidence and policy links accompany the audit record. Other installs cannot read
or acknowledge the action. These endpoints are install operations, not AHP
client events or user authorization grants.
Hub tap identity (#415)¶
A multiuser hub keeps its agent-host key in the hub tap. Configure the trusted
AgentEndpoint config.hub_identity with hub_id and api_url (the hub's
/hub/api URL). The backend calls that URL itself, so it must resolve to
public addresses only, or its host must be listed in
HUB_IDENTITY_ALLOWED_HOSTS (comma-separated; for an in-cluster hub). Other
URLs are refused when the endpoint is saved and when it is used. The tap forwards the caller's hub token as
X-JupyterHub-User-Token. Zentinelle verifies it against that configured hub
on every evaluation, refuses service tokens and tokens without access to the
user's own server, and replaces claimed user/context fields with the verified
hub:<hub_id>:<username> identity. Missing, revoked, redirected, or unreachable
identity checks refuse the evaluation; there is no cache or claimed-ID fallback.
Use TLS between services outside a trusted private network. A hub header on an
endpoint without this administrative binding is also refused. Single-user
agent-host endpoints without a binding retain their existing contract.