Skip to the content.

ADR-0046 — Human-in-the-Loop Gateway (Phase 4D)

Date: 2026-04-09
Status: Accepted
Deciders: GNAT Platform Team


Context

GNAT’s AI agents can now be granted write, export, playbook-trigger, and workspace-management permissions via AgentGovernor (ADR-0045). For most trust levels and action types the governor’s permission matrix is sufficient: either the action is allowed and it executes immediately, or it is denied outright.

However, a subset of agent actions are high-impact enough that neither automatic approval nor outright denial is the correct policy:

Prior to this ADR there was no mechanism to pause an agent action and hold it in a review queue until a human operator approved or rejected it. The existing gnat/review/ module contained a fully implemented ReviewService and ReviewQueueStore, but they were reachable only from the report lifecycle (ADR-0034); agents had no bridge to that infrastructure.

The result was an all-or-nothing choice: either grant agents unrestricted write access, or block the action class entirely. Neither option is suitable for production deployments where agents need occasional high-impact capability under controlled conditions.


Decision

Introduce HITLGateway (gnat/agents/hitl.py) as a thin policy bridge between AgentGovernor and gnat/review/service.py. Every agent action evaluated by AgentGovernor.require_can_act() is additionally evaluated by HITLGateway.evaluate() before it may execute.

Impact Tier Classification

Impact level is a field on AgentAction (see ADR-0045) set by the agent at action creation time. HITLGateway routes on that field:

Impact Level Routing Policy Review Queue Entry
low Auto-approved, execution proceeds immediately None (logged only)
medium Auto-approved, execution proceeds immediately None (logged only)
high Blocked pending human approval via ReviewService PENDING ReviewItem created
critical Blocked pending human approval; XSOAR playbook notification sent PENDING ReviewItem created + XSOAR alert

HITLGateway API

Located at gnat/agents/hitl.py:

from gnat.agents.hitl import HITLGateway
from gnat.agents.governor import AgentAction, AgentActionType

gateway = HITLGateway()

# Primary entry point — called by AgentGovernor after permission check passes
approved, review_item = gateway.evaluate(action)
if not approved:
    # action is PENDING; agent should poll or await human decision
    print(f"Action {action.action_id} awaiting review: {review_item.id}")

# Submit a specific action to the review queue explicitly
review_item = gateway.submit_for_approval(action)

# Poll queue for a decision
from gnat.review.service import ReviewStatus
status = gateway.check_approval_status(review_item.id)
# status is one of ReviewStatus.PENDING, APPROVED, REJECTED

# Auto-approve (used in test harnesses and auto-escalation policies)
gateway.auto_approve_pending(review_item.id, reviewer="auto-policy")

evaluate() Logic

def evaluate(
    self, action: AgentAction
) -> tuple[bool, ReviewItem | None]:
    if action.impact_level in ("low", "medium"):
        self._log_auto_approved(action)
        return True, None

    review_item = self.submit_for_approval(action)

    if action.impact_level == "critical":
        self._notify_xsoar(action, review_item)

    return False, review_item

The action is blocked (returns False) for high and critical levels regardless of the trust level of the agent. Even a trusted_internal agent must pause for a human reviewer if its action carries impact_level="critical".

submit_for_approval() — ReviewService Bridge

submit_for_approval() converts the AgentAction dataclass into a STIX-compatible metadata dict and delegates to ReviewService.submit():

def submit_for_approval(self, action: AgentAction) -> ReviewItem:
    payload = {
        "type": "agent-action-review",
        "action_id": action.action_id,
        "agent_id": action.agent_id,
        "action_type": action.action_type.value,
        "target_ref": action.target_ref,
        "impact_level": action.impact_level,
        "context_id": action.context_id,
        "submitted_at": action.submitted_at.isoformat(),
    }
    return self._review_service.submit(
        item_type="agent_action",
        payload=payload,
        submitter=action.agent_id,
        priority="high" if action.impact_level == "critical" else "normal",
    )

No new storage is introduced — ReviewItem and ReviewQueueStore from gnat/review/ are used as-is.

Approval Timeout

check_approval_status() enforces a configurable timeout:

def check_approval_status(self, review_id: str) -> ReviewStatus:
    item = self._review_service.get(review_id)
    elapsed = (datetime.utcnow() - item.submitted_at).total_seconds()
    if (
        item.status == ReviewStatus.PENDING
        and elapsed > self._approval_timeout_seconds
    ):
        self._review_service.reject(
            review_id,
            reason="auto-rejected: approval timeout exceeded",
            reviewer="hitl-gateway",
        )
        return ReviewStatus.REJECTED
    return item.status

Default approval_timeout_seconds is 3600 (one hour). Configurable via the [agents] INI section:

[agents]
hitl_approval_timeout_seconds = 3600
hitl_xsoar_playbook_id = P-GNAT-AGENT-ALERT

XSOAR Notification for Critical Actions

For critical impact actions, HITLGateway calls the XSOAR connector’s upsert_object() with a pre-formed STIX incident custom object:

def _notify_xsoar(
    self, action: AgentAction, review_item: ReviewItem
) -> None:
    incident = {
        "type": "x-gnat-incident",
        "name": f"HITL Review Required: {action.action_type.value}",
        "severity": "high",
        "agent_id": action.agent_id,
        "action_id": action.action_id,
        "review_id": review_item.id,
        "target_ref": action.target_ref,
    }
    try:
        self._xsoar_client.upsert_object(incident)
    except Exception as exc:
        # Notification failure must never block the review queue entry
        logger.warning("XSOAR notification failed: %s", exc)

The XSOAR client is a trusted_internal connector instance constructed from the INI [xsoar] section. If XSOAR is not configured, the notification is skipped and a warning is logged; the ReviewItem is still created.

Sequence Diagram

Agent                  AgentGovernor          HITLGateway          ReviewService
  |                         |                      |                     |
  |── require_can_act() ──► |                      |                     |
  |                         |── evaluate(action) ► |                     |
  |                         |                      |── submit() ────────►|
  |                         |                      |◄── ReviewItem ──────|
  |                         |                      |                     |
  |                         |  [critical only]      |                     |
  |                         |                      |── _notify_xsoar()   |
  |                         |                      |   (XSOARClient)     |
  |                         |◄── (False, item) ────|                     |
  |◄── AgentActionPending ──|                      |                     |
  |                         |                      |                     |
  |   [human approves]      |                      |                     |
  |── check_approval() ─────────────────────────► |── get(review_id) ──►|
  |◄── APPROVED ─────────────────────────────────── |◄── ReviewStatus ───|

Consequences

Positive

Negative / Trade-offs

Deferred


Alternatives Considered

Rebuild a Dedicated HITL Queue

A purpose-built queue store separate from gnat/review/ was considered to avoid coupling agent governance to the report review subsystem. Rejected because ReviewService and ReviewQueueStore already implement exactly the required semantics (item submission, status polling, approval/rejection, timeout), and duplication would create two review mechanisms that diverge over time. The bridge pattern costs fewer than 120 lines of code.

Email-Only Notification

Sending an email to a configured address for high and critical actions was prototyped. Rejected because email provides no structured approval path: the reviewer has no UI from which to approve or reject the action back into the system. Notifications via XSOAR (and future adapters) provide a structured approval workflow.

Synchronous Approval via Long-Poll

Blocking the agent’s calling thread in a long-poll loop until the review is resolved was considered. Rejected because it ties up a thread for the full approval window (up to one hour by default) and makes the system unresponsive to cancellation. The asynchronous poll-or-suspend model is more appropriate for an embedded agent runtime.

Trust-Level Exemption for trusted_internal

A proposal to exempt trusted_internal agents from HITL checks for high impact actions was considered. Rejected on security grounds: trust level reflects the provenance of the agent code, not the risk of the target action. Even a fully trusted agent should not autonomously trigger a production SOAR playbook without a human sign-off.


Licensed under the Apache License, Version 2.0