Escalation flow - human-in-the-loop approvals

Tool Guard supports policy-driven escalation: a rule's effect: escalate registers the call as a pending decision and returns action_taken=escalated + an escalation_id to the agent. An operator approves or denies via the proxy's REST endpoints; the audit chain captures every transition.

Endpoints


POST /evaluate
  → if a rule fires with effect=escalate:
      response = 202 Accepted
      body includes:
        {
          "action_taken": "escalated",
          "decision":     "escalated",
          "escalation_id": "<envelope_id>",
          "poll_url":     "/escalations/<envelope_id>",
          ...
        }
      a pending entry is registered in the proxy
      the audit chain logs the create event

GET  /escalations
  → snapshot of every escalation (pending + resolved)

GET  /escalations/<id>
  → fetch one entry: state, approver, reason, timestamps

POST /escalations/<id>/approve
  Authorization: Bearer <approver-token>
  Body (optional): {"approver": "alice", "reason": "verified runbook"}
  → state transitions pending → approved
  → audit chain logs the approve event

POST /escalations/<id>/deny
  Authorization: Bearer <approver-token>
  Body (optional): {"approver": "alice", "reason": "policy violation"}
  → state transitions pending → denied
  → audit chain logs the deny event

Token configuration


tg-proxy -approver-token=<strong-random-string>
        -escalation-default-timeout-min=30

When -approver-token is empty (default), the mutating endpoints return 401 - escalation lives in the audit log but cannot be resolved. Set the flag to enable the flow.

The token is a single shared bearer string. For per-operator identity, put a reverse proxy in front that maps user identity to the token; the mutating endpoints accept an optional approver field in the body so the identity lands in the audit chain.

Lifecycle states


pending  ──── approve ───▶  approved
   │
   ├──────── deny ──────▶  denied
   │
   └──── timeout (default 15 min, configurable) ────▶  expired

The reaper sweeps every 30 seconds; any pending entry past its expires_at becomes expired. The agent's poll loop should treat expired the same as denied.

Example policy


policy_id: pol-sql-write-needs-approval
scope:
  tool_names: [query]
  tool_groups: [database_ops]
rules:
  - rule_id: rule-sql-write-needs-approval
    rule_type: sql_classify
    conditions:
      and:
        - field: tool_name
          operator: eq
          value: query
        - sql_classify:
            field: parameters.sql
            dialect: postgres
            require:
              denied_top_level_kinds: [INSERT, UPDATE, DELETE]
    effect: escalate
    effect_config:
      severity: high
      escalate_to: dba-on-call
      timeout_minutes: 30

denied_top_level_kinds fires the rule when the SQL IS one of the listed kinds (the inverse of top_level_kinds). For escalation policies this is the natural shape: "escalate writes" rather than "only allow reads."

Agent-side resume

A real agent loop integrates escalation with a poll-or-give-up pattern:


import time, requests

def call_with_approval(envelope):
    r = requests.post(PROXY + "/evaluate", json=envelope)
    body = r.json()
    if body["action_taken"] != "escalated":
        return body  # allow or deny - done

    poll_url = PROXY + body["poll_url"]
    deadline = time.time() + 30 * 60  # 30 min
    while time.time() < deadline:
        time.sleep(15)
        e = requests.get(poll_url).json()
        if e["state"] == "approved":
            # Approval IS the authorization - call the tool now.
            # Do NOT re-POST the same envelope to /evaluate: the proxy
            # rejects a re-used envelope_id (collision guard, below).
            return {"action_taken": "allowed", "via": "human-approval"}
        if e["state"] in ("denied", "expired"):
            return e
    return {"state": "client_timeout"}

Re-submitting an approved envelope

The envelope_id is the escalation's identity. Once an escalation for an envelope_id exists, a second /evaluate carrying the same envelope_id is rejected rather than re-evaluated - this guards against an authorization-confusion bypass where a caller reuses a known-approved envelope_id with a fresh payload. So a poll that returns approved IS the authorization to proceed: call the tool, don't re-evaluate. Use a fresh envelope_id for every new tool call.

Combining with deny policies

If a stricter policy (effect: deny) and the escalation policy (effect: escalate) both match the same envelope, the proxy returns the strictest result - deny wins. The escalation rule only takes effect when no deny rule fires for the same call.

Scope escalation policies to a specific tool name, agent_id, or tool_group (the fields scope supports) so they don't collide with the org-wide deny policies. For example: only escalate writes that come from agent_id="ops-bot", since that agent has the appropriate audit context.

Audit chain

Every transition (create, approve, deny, expire) emits a new hash-chained entry. tg verify walks the full chain - including rotated files - and surfaces the lifecycle as a sequence of linked records.

Storage

The pending-escalation store is in-memory for v0.1.0, bounded (defaultEscalationMaxEntries = 10_000) with LRU eviction of the oldest resolved entries - pending requests are never silently dropped, only ones already approved or denied. A proxy restart discards all pending entries (the agent's poll returns 404, which the client treats as expired). File-backed persistence so restarts preserve outstanding approvals is a known gap; until it exists, drain pending escalations before restarting the proxy.

Metrics


tg_proxy_evaluations_escalated_total

is bumped on every escalation create. Combine with the /escalations endpoint's snapshot for a dashboard showing pending count and oldest pending age.