Run controls
Controls that act on a managed agent's run as it runs, from a plan made before anything untrusted is read to the rule of two, each off or audit-only until you switch it on.
- Hold a run to a typed plan with capability tokens
- Hold outbound calls once untrusted and sensitive data meet in a run
- Refuse a finish its success criteria do not prove
- Prove a control in monitor mode before enforcing it
Run controls act on a managed agent's run while it runs, between the model's proposal and the tool call. Each one is set on the agent's runtime (PUT /api/agents/{key}/runtime, or the runtime block of a bundle). An agent that does not set a control runs exactly as before: Liya in Teams, the copilot, the HR and SAP agents, workflows and scheduled runs are unchanged until you switch a control on.
Every decision a control makes is written to the audit log.
Plan guard
The plan guard splits planning from execution, following the dual-LLM and CaMeL designs. Before the run's first model call, a planner turns the run's authorised task (the user's message and the agent's instructions) into a typed plan. The planner never sees a tool result or the conversation, so nothing the run reads later can add a step to the plan.
Each plan step names one tool, how many times it may run, and constraints on its arguments. The steps are checked before the run starts. A tool the agent is not offered is dropped, so a plan can never widen the agent's allow-list. Each remaining step becomes a capability token. Its digest comes from the same function that binds an approved write to what is executed. That digest covers the agent, server, tool, the approved argument values and the run's authority (run-as and permission ceiling). It is signed with a vault-derived key.
Every MCP, subagent and remote-agent call must then match an unused capability. On each use the digest is computed again, the signature is checked, and the server's data-policy fingerprint is compared with the one approved. A call with no matching capability is out of plan. So is a call whose arguments go beyond the approved values, or one made after the server's data policies changed.
{"plan_guard": {
"mode": "enforce",
"plan": [
{"tool": "jira.get_issue", "max_calls": 2, "args": {"key": {"from_task": true}}},
{"tool": "jira.add_comment", "max_calls": 1, "args": {"key": {"from_task": true}}}
]
}}| Field | Meaning |
|---|---|
mode | off, monitor (record out-of-plan calls and let them run) or enforce (refuse them) |
planner | static (the plan you wrote) or model (a governed model call writes the plan for each run). Defaults to static when a plan is given, otherwise model |
plan | For static: up to 20 steps of {tool, max_calls, args, purpose} |
provider, model, timeout_s | For model: who plans. Defaults to the agent's own binding and 15 seconds |
Argument constraints: {"equals": value}, {"one_of": [values]}, {"pattern": "regex"} (must match the whole value) and {"from_task": true}. With from_task, the value must appear word for word in the user's task, so an address or ticket key cannot come from a tool's output. Arguments the plan does not name are unconstrained.
The planner fails closed. If it times out, fails or answers outside its contract, the plan is empty, and in enforce mode every governed call of the run is refused. A stored setting that cannot be read is enforced with an empty plan.
Audit rows: agent.plan.created (the steps, each capability's digest, the plan's digest, and any steps dropped with the reason), agent.plan.refused for every out-of-plan call, and agent.plan.failed.
A run that waits for an approval and resumes gets a new plan from the same task. Its capability counts start again.
Start in monitor mode. Once a week of agent.plan.refused rows shows only calls you would refuse, switch to enforce.
Rule of two
At save time, the agent posture check flags an agent that can both read a system of record and send outward. The runtime rule of two applies the same rule during a run, as data enters the run's context. A run may hold any two of these three, but never all three:
- Untrusted input. Text an outsider wrote: an inbound mail, a chat message or a ticket body (Gmail, Slack, Teams, Discord, Jira, ServiceNow, Zendesk, Freshservice, Pylon, incident.io, Salesforce), anything a remote or local-process MCP server returns, or a task that came in through a webhook, an A2A caller, or an email or Slack trigger.
- Sensitive data. Records from a system of record: ticketing, HR, finance, CRM and file stores.
- An action that can move data out. A write on a messaging or publishing connector (Gmail, Teams, Discord, Pylon, SNS, SQS, Pub/Sub, NATS, OpenAPI), any call to a remote or local-process server, a delegation to a remote agent, or any call whose arguments contain a URL or an email address.
Once both data legs are in the run's context, every call that can move data out is held:
| Mode | What happens |
|---|---|
off | Nothing |
monitor (the instance default) | The call runs and is recorded as agent.ruleoftwo.flagged. No call is refused and no human step is added |
approve | An MCP call is queued for approval and the run waits where it can. Other calls are refused |
enforce | The call is refused, and the tools that can move data out are removed from what the model is offered for the rest of the run |
Set rule_of_two_default (environment TICKETIQ_RULE_OF_TWO_DEFAULT, or PATCH /api/settings) to change the default for every managed agent without a rule_of_two of its own. Lowering it needs governance.weaken and a reason. To override the default for one agent:
{"rule_of_two": {"mode": "approve", "trigger": "both",
"untrusted_servers": ["partner-portal"], "sensitive_servers": ["hr-db"],
"egress_servers": ["webhook-out"]}}trigger: either is stricter: it holds outbound calls once either data leg is in context. The server lists add to the built-in classification.
Audit rows: agent.ruleoftwo.tainted once per leg per run (with the call it came from), agent.ruleoftwo.flagged, agent.ruleoftwo.blocked (with the approval id when queued) and agent.ruleoftwo.stripped.
A run that waits and resumes starts with a clean context. For an agent that waits mid-run in approve or enforce mode, use trigger: either.
Done gate
An agent's answer can say "I've updated the ticket" when no successful call shows it. The done gate makes "done" something the harness checks. You give the agent success criteria. When its run is about to answer, every criterion is checked against what the run actually did.
Each criterion is one of two kinds:
- A tool criterion (
tool) is met by a successful call of that tool in this run, including calls made by its subagents.argscan require exact argument values, andresult_matches(a regex) can require something in the result. - A check criterion (
check) is a verifier call the harness makes itself when the model says it has finished. It is separate from the calls that did the work. It goes through the same governed path as any call: the tool allow-list, Cedar and data policies. It passes when the call succeeds and its result matchesexpect. The verifier must be a tool the agent's runtime attaches; this is checked when you save.
{"done_gate": {"mode": "enforce", "max_pushes": 2, "criteria": [
{"id": "resolved", "tool": "servicenow.update_incident", "args": {"state": "6"}},
{"id": "visible", "check": {"server": "servicenow", "tool": "get_incident",
"args": {"number": "INC0012345"}, "expect": "\"state\":\\s*\"6\""}}]}}| Mode | Unproven finish |
|---|---|
monitor | The answer goes out, and the shortfall is recorded. |
enforce | The model is told which criteria are unproven and asked to carry on, up to max_pushes times (default 2, at most 5). If they are still unproven, the run ends with no answer: done gate: not proven — …. |
Every gated finish writes agent.run.proof. The row holds each criterion's verdict, the evidence it rests on and the proof's sha256. Evidence is the tool name, the sha256 of the arguments and of the result, and which loop made the call. Only these hashes are stored, never the values. Where the trust ledger is enabled, the proof is also a receipt on the tenant's hash-chained ledger (kind event, action type run/done, subject the run's correlation id), and the audit row names that receipt. Each piece of evidence names the ledger's action receipt for the same call, so the proof points at the tamper-evident record of the write. An enforce push writes agent.run.unproven.
The gate is off unless an agent sets done_gate, so existing agents finish exactly as before. A stored gate that cannot be read is enforced with a criterion nothing meets, so it fails closed. Start in monitor mode and read agent.run.proof before you enforce.