Approvals for actions Zynth does not execute
Your agents do things Zynth never sees: they run shell commands in a sandbox, push to a release branch, start a deploy. The act endpoint lets your executor ask Zynth before it does any of them:
"This agent intends to exercise
shell:execon the action described by this context. Authorize it."
Zynth answers through the same authorization engine every Zynth route uses — your roles and delegations, the agent's oversight mode and risk ceiling, your ABAC policies — and, when a human must decide, parks the request in the same approvals queue your reviewers already use. When the identical action is presented again after approval, the approval is spent once and the answer is "authorized".
Zynth decides; your executor enforces
Read this before anything else. Zynth is the decision point for an action it never sees performed; your executor is the enforcement point. The guarantee is exactly this:
A human holding approver authority approved this described action; the approval can be consumed once, by this agent; and all of it is on the tamper-evident record.
It is not "Zynth verified what ran". Two consequences follow:
- An executor that ignores a
202or a403has bypassed the approval entirely. Run the command only on an explicit200 authorized. Treat everything else — a denial, a refusal, an expiry, a timeout, a network error — as "do not run". - An executor can describe one action and perform another. Build the context from the actual invocation — the exact argv you are about to execute — never from text the model wrote. Then what the human approved is what runs.
The same applies to policy conditions: an ABAC condition on resource.host is evaluated against
the context your executor sends, so it is exactly as trustworthy as the executor.
Setting it up
- Define the action in your own catalogue. The action must be a permission in your tenant's
own vocabulary — for example a
shellnamespace with anexecaction, which isshell:exec. Free text and Zynth's own permissions are refused. - Delegate two permissions to the agent:
authz:act(the door — permission to ask at all) and the action itself (shell:exec). The delegation's oversight mode decides whether a human is asked:AUTONOMOUSis answered at once,HUMAN_IN_LOOPandHUMAN_APPROVEpark a request. The action is priced at the risk you declared on the permission (default 8 for a write), and an agent whose risk ceiling is lower is refused. - Call it with the agent's own token. The act is attributed to the agent's governed identity; the tenant and the subject come from the token, never from the request body.
Availability depends on the deployment: the act endpoint is part of agent approvals and returns
404 on every method while agent approvals are switched off. It is never plan-gated — asking
whether a dangerous action is allowed is not a premium feature.
The call
curl -X POST https://auth.example.com/api/v1/authz/act \
-H "Authorization: Bearer $AGENT_TOKEN" -H "Content-Type: application/json" \
-d '{
"permission": "shell:exec",
"context": {
"argv": ["pg_dump", "--schema-only", "billing"],
"host": "prod-db-1",
"credential": "prod-db-1/svc"
}
}'
| Status | status | Meaning — and what your executor does |
|---|---|---|
200 | authorized | Run it. approval_id names the approval this act spent, or is null when no approval was needed. |
202 | pending | A human must decide. Poll GET /api/v1/approvals/{approval_id} (an agent may always read its own request), then send the identical request again. |
403 | denied | Do not run it. Nothing was parked — a denial is not a question for a human. If a reviewer denied this exact action, approval_id and approval_status: "denied" name it, and the denial stands for the request window. |
422 | — | Refused before the engine ran: the permission is not one of yours, or the context is malformed or carries an obvious secret. detail.reason and detail.field say which. |
429 | — | The caller already has 10 requests waiting for a human (see the pending cap). |
Every answer from the engine carries a decision_id; an authorized act is recorded with it as the
correlation id, so "what did this decision release?" is one query on the events API.
The approval binds to the exact action
The approval is bound to a fingerprint over the agent, the permission and the whole context.
Approving {"argv": ["pg_dump", "--schema-only", "billing"], "host": "prod-db-1"} does not approve
{"argv": ["psql", "-c", "DROP DATABASE billing"], "host": "prod-db-1"} — that parks a new request.
Key order does not matter; any other difference does. An approval is single-use and lapses unused
after its window (default 10 minutes); a request nobody decides expires (default 15 minutes).
The context: what you may send
The context is stored on the approval, shown in full to the reviewer, and fingerprinted, so it has to be deterministic, readable and free of secrets.
- A flat JSON object of at most 32 keys; keys are 1–64 characters of
A-Z a-z 0-9 _ . -, starting with a letter. - Values are strings (up to 4,096 characters), integers, booleans,
null, or lists of those (up to 128 items — an argv). No floats (1.0and1would fingerprint differently across languages) and no nested objects. - At most 8 KiB as compact JSON, and never empty — a request with nothing in it gives the reviewer nothing to decide.
Never put a credential in the context. Reference it by name ("credential": "prod-db-1/svc")
and keep the value wherever your executor already keeps it. This obligation is yours: Zynth
cannot recognise every secret in a free-form argument list, and it does not redact. It refuses the
obvious shapes outright, naming the field but never echoing the value:
detail.reason | Example of what is refused |
|---|---|
url_userinfo | postgres://svc:…@prod-db-1, https://token@github.com/… |
secret_assignment | password=…, API_KEY=…, client_secret: … |
secret_flag | --password …, --token=…, an argv ["--password", "…"] |
secret_key_name | a key such as db_password, github_token, authorization |
private_key_block, bearer_token, jwt, known_token_prefix | PEM private keys, Bearer …, JWTs, well-known vendor token prefixes |
Who may approve
The request appears in the approvals queue
with resource_type: "external_action" and its full context. In the console, the queue shows the
description in full: the command line, every argument on its own and quoted so word boundaries are
visible, every other field, and any invisible or direction-changing character spelled out instead of
rendered. The usual rules apply:
agents can never decide; nobody decides their own request; the reviewer must hold the action's
permission (shell:exec) unless a require_approval policy designates an approver_permission;
N-of-M, escalation and owner break-glass work unchanged.
A require_approval policy can also target the context. With effect require_approval, action
shell:exec and a condition on resource.host, even an otherwise autonomous principal must get a
human's yes for commands against that host.
The pending cap
A looping or compromised agent could fill a reviewer's queue with distinct requests, so each
principal may hold at most 10 live pending requests. The eleventh distinct act is refused with
429 and reason: "pending_cap" until a request is decided or expires. Re-presenting an action
that is already pending is never capped — it adds nothing to the queue.
curl -H "Authorization: Bearer $AGENT_TOKEN" \
https://auth.example.com/api/v1/authz/act/pending/$AGENT_ID
# {"subject_id": "…", "pending": 3, "cap": 10, "reached": false}
A principal may read its own count; reading another's needs agents:approvals:read. The console
shows the count on the agent's page when the cap is reached.
Budgets, meters and the record
An act is an act, not a question: unlike POST /authz/check,
it charges the agent's task-token budget, counts toward its blast-radius meter, and is recorded —
authz.act_authorized for every authorized act (with the decision_id), authz.act_refused for
every refusal before the engine ran, and the approval's own requested / decided / consumed events.
An authorized act by an agent is also listed in its action journal, marked irreversible: Zynth
authorized it but did not perform it, so it cannot undo it. POST /authz/check is unchanged — it
still parks nothing and charges nothing.
What it does not do
- No execution. Zynth never runs, proxies or observes the action.
- No content. The context describes the action; it is not a place for prompts, file contents or output.
- No offline receipt. The decision is on Zynth's tamper-evident record; there is no signed, offline-verifiable receipt for a third party.
A working executor
The Zynth source tree ships a complete reference hook in examples/act-hook: a
standard-library Python client that does all of the above. It builds the context from the argv it
is about to run, names credentials instead of sending them, waits for a reviewer on 202,
presents the identical context to spend the approval, and runs nothing on any answer other than
authorized (exit status 126 means "not run"). It is the model to port into your own sandbox,
whatever language that is. The loop it implements is short enough to read in full:
context = {"argv": argv, "cwd": os.getcwd(), "host": socket.gethostname()} # the real invocation
status, body = act(permission, context)
if status == 202: # a human must decide
if wait_for_decision(body["approval_id"]) != "approved":
sys.exit(126) # denied, expired, timed out: do not run
status, body = act(permission, context) # the IDENTICAL context spends the approval
if status == 200 and body["status"] == "authorized":
subprocess.run(argv) # exactly what was described
else:
sys.exit(126) # anything else: do not run
Sender-constrained agent tokens
The examples above authenticate with a plain bearer token. When your deployment requires
sender-constrained (DPoP) agent tokens, the agent mints its
token with a proof and the executor sends Authorization: DPoP <token> plus a fresh DPoP proof
header on every call to the act endpoint and on each poll of the approval. The decision,
the context and the answers are unchanged.