Private betaRequest early access →

Sign in

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:exec on 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 202 or a 403 has bypassed the approval entirely. Run the command only on an explicit 200 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

  1. Define the action in your own catalogue. The action must be a permission in your tenant's own vocabulary — for example a shell namespace with an exec action, which is shell:exec. Free text and Zynth's own permissions are refused.
  2. 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: AUTONOMOUS is answered at once, HUMAN_IN_LOOP and HUMAN_APPROVE park 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.
  3. 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

Shell
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"
        }
      }'
StatusstatusMeaning — and what your executor does
200authorizedRun it. approval_id names the approval this act spent, or is null when no approval was needed.
202pendingA human must decide. Poll GET /api/v1/approvals/{approval_id} (an agent may always read its own request), then send the identical request again.
403deniedDo 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.0 and 1 would 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.reasonExample of what is refused
url_userinfopostgres://svc:…@prod-db-1, https://token@github.com/…
secret_assignmentpassword=…, API_KEY=…, client_secret: …
secret_flag--password …, --token=…, an argv ["--password", "…"]
secret_key_namea key such as db_password, github_token, authorization
private_key_block, bearer_token, jwt, known_token_prefixPEM 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.

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

Python
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.