|8 min read|Yvann Lièvre

Keeping Humans in the Loop: Rego Approval Gates Before AI Agent Remediation

An OPA Rego policy enforces mandatory human approval before AI agent remediation runs: automatic isolation, sign-off required for any destructive action.

OPA RegoHuman-in-the-LoopRemediation
Keeping Humans in the Loop: Rego Approval Gates Before AI Agent Remediation

Vendors in 2026 love to advertise sub-ten-second automatic remediation. Aqua Compass, Elastic Workflows, and a handful of others promise an agent that detects, decides, and fixes before an analyst has finished reading the alert. On paper, that is the ultimate promise of automated incident response. On the ground, it is exactly what keeps a security leader up at night: an agent that alone decides to disable a critical service account, encrypt a disk, or cut a network path is not saving time, it is relocating the risk.

The real question is not "should remediation be automated?" but "how do you insert a human checkpoint before an agent touches production, without giving up speed on the actions that genuinely don't need one?" The answer sits in a precise mechanism: a policy-as-code layer that classifies every action by blast radius, and an enforcement point that queries that policy before anything executes.

A distinction worth making: this is not tool scoping

This topic often gets conflated with static tool-permission scoping (which API calls an agent is allowed to make, fixed once in its configuration). This is something different: a decision made per action, at execution time, that takes the actual target and context into account. An agent may technically have the right to call the account-disable API; the question asked on every single call is whether this disablement, on this account, in this context, can run alone or must wait for a human.

Classifying actions by blast radius

The starting point is not technical, it is doctrinal: not every remediation action carries the same weight. Two axes are enough to classify them.

The first axis is reversibility. Isolating a host from the network undoes in one click. Encrypting a disk, deleting an account, or revoking certificates does not undo, or only after hours of restoration work. The second axis is scope: an action touching a single isolated asset does not carry the same weight as one touching a domain account, a user group, or an entire network range.

These two axes do not live in an architect's head, they are encoded as policy data, versioned like code:

# policy/data/actions.yaml
low_risk:
  - action: isolate_host
    reversible: true
    scope: single_asset
  - action: block_ip
    reversible: true
    scope: single_asset
  - action: quarantine_file
    reversible: true
    scope: single_asset
 
requires_approval:
  - action: disable_account
    reversible: false
    scope: identity
  - action: delete_account
    reversible: false
    scope: identity
  - action: encrypt_disk
    reversible: false
    scope: single_asset
  - action: revoke_all_sessions
    reversible: false
    scope: domain

This classification is the real business decision. Everything that follows is just the mechanism that enforces it.

OPA as the decision engine (PDP)

Open Policy Agent plays the role of PDP (Policy Decision Point) here: the agent submits a structured request (the action, its target, the context) and gets back one of three verdicts: allow, require_approval, or deny. The engine knows nothing about the agent's internal business logic, it evaluates a standalone Rego policy:

package threatclaw.remediation
 
default decision := {"result": "deny", "reason": "action not classified"}
 
low_risk_actions := {"isolate_host", "block_ip", "quarantine_file"}
approval_required_actions := {"disable_account", "delete_account", "encrypt_disk", "revoke_all_sessions"}
 
decision := {"result": "allow", "reason": "reversible action, single-asset scope"} if {
    input.action in low_risk_actions
    input.target.scope == "single_asset"
}
 
decision := {"result": "require_approval", "reason": "irreversible or wide-scope action"} if {
    input.action in approval_required_actions
}

The most important line is the default at the top: if an action shows up on neither list, the default verdict is deny. An unclassified action is never treated as low-risk by omission. It is the classic deny-by-default posture, applied here to an agent's autonomy.

The enforcement point: the agent asks, it no longer decides alone

Before every remediation call, the agent queries the PDP over HTTP against the OPA server:

curl -s -X POST http://opa.internal:8181/v1/data/threatclaw/remediation/decision \
  -H "Content-Type: application/json" \
  -d '{
        "input": {
          "action": "disable_account",
          "target": {"type": "user", "id": "j.smith", "scope": "identity"},
          "context": {"initiator": "agent", "case_id": "INC-4821"}
        }
      }'

The response is deterministic and readable by the agent's orchestrator:

{"result": {"result": "require_approval", "reason": "irreversible or wide-scope action"}}

Before this gate existed, the logic was hardcoded into the agent, a plain branch with no nuance:

# before: hardcoded decision, no external checkpoint
if action == "isolate_host":
    execute(action)
elif action == "disable_account":
    execute(action)   # no risk distinction whatsoever

Once the PDP is wired in, the agent no longer decides anything on its own: it executes, queues for human review, or rejects, based on an external and auditable answer:

decision=$(curl -s -X POST http://opa.internal:8181/v1/data/threatclaw/remediation/decision \
  -d @input.json | jq -r '.result.result')
 
case "$decision" in
  allow)            execute_action ;;
  require_approval) queue_for_human_review ;;
  *)                reject_action ;;
esac

A change of a few lines moves the responsibility elsewhere: the risk logic leaves the agent's code and lives in a versioned policy that gets reviewed and tested independently.

The trap: an untested Rego policy is a silent regression

A Rego policy is code. Left untested, it breaks silently: an action gets renamed, a typo slips into a set literal, the input structure gets refactored, and an action that used to require approval quietly gets auto-approved, with nobody noticing until the incident happens. This is the single most expensive trap in this design, and the easiest to avoid.

The fix is a unit-test suite against the policy itself, run with conftest, in a dedicated policy_test.rego file:

package threatclaw.remediation
 
test_isolate_host_is_autonomous if {
    decision.result == "allow" with input as {
        "action": "isolate_host",
        "target": {"type": "host", "id": "WKS-042", "scope": "single_asset"}
    }
}
 
test_disable_account_requires_approval if {
    decision.result == "require_approval" with input as {
        "action": "disable_account",
        "target": {"type": "user", "id": "test", "scope": "identity"}
    }
}
 
test_unclassified_action_is_denied if {
    decision.result == "deny" with input as {
        "action": "wipe_disk",
        "target": {"type": "host", "id": "WKS-042", "scope": "single_asset"}
    }
}

These tests plug directly into CI, right next to the application's regular test suite:

policy-tests:
  stage: test
  script:
    - conftest verify --policy policy/

Any policy change that would break the classification (say, accidentally moving disable_account into the autonomous bucket) fails the pipeline before merge, exactly like a broken unit test on application code. The policy stops being a config file hand-edited in production; it becomes a versioned, reviewed, tested artifact.

Aligned with the human-control doctrine

This is not an isolated technical trade-off, it reflects a plain principle: a human signs off on every write action that changes production state, and automation only covers what is both reversible and narrow in scope. Isolating a compromised host undoes in a single gesture; disabling an account, encrypting a volume, or revoking access across a whole domain does not come back the same way. Until an action has been explicitly classified as reversible and single-scope by the policy, it stays queued for a human, never executed by default.

That blast-radius classification, tested and enforced on every remediation decision, is exactly what we ship in the ThreatClaw policy pack: ready-to-use approval gates covering the most common remediation actions, tested in CI before every release.

Related articles