Approvals & Human Review

Applies to: ChengOS v0.2.0+ | Last verified: 2026-09-25 | Source: crates/cheng-core/src/domain/approval.rs, crates/cheng-core/src/services/tool_call_approval.rs, crates/cheng-core/src/services/approval_gate.rs, crates/cheng-nodes/src/nodes/builtin/utils/approver.rs, crates/cheng-api/src/services/approval_reaper.rs

Two different mechanisms put a human in the loop:

ApprovalReview
Question"May I do this?""Is this change acceptable?"
AskedBefore the actionAfter the work is staged
Statewaiting_for_reviewwaiting_for_review
TriggerThe approval gateA tool returning requires_review: true
ExampleRunning an SSH commandAn agent's document edits

Both suspend the execution durably. Neither is a UI convention you can bypass by calling the API differently — the gate blocks inside the node.

The approval gate

The gate is a protocol primitive shared by every high-risk node — SSH, HTTP writes, MCP, code execution — and by the utils/approver node you can place on the canvas yourself.

caller
  │
  ▼
 is_whitelisted()? ──── yes ──► return Approved (whitelist_hit = true)
  │ no
  ▼
 subscribe(approval_bus)          ◀─ subscribe FIRST, never miss an event
  │
  ▼
 persist execution → waiting_for_review + ApprovalContext
  │
  ▼
 publish ApprovalRequested        ◀─ the WS event that renders the approval card
  │
  ▼
 await decision OR durable poll, with timeout
  │
  ▼
 persist execution → running, clear ApprovalContext
  │
  ▼
 publish ApprovalResolved
  │
  ▼
 approved && scope == session ──► on_session_approved hook (add to whitelist)

The order of those steps is the design. Subscribing before persisting eliminates the race where a fast click fires its event before the receiver is attached. Persisting before publishing eliminates the race where the user clicks Approve and the API rejects it because the state is still running. Get either order wrong and you have a rare, unreproducible hang.

Decisions are also recorded on the approval context, not only broadcast: the bus is an in-memory broadcast channel, so a decision published while no waiter is subscribed would otherwise be lost forever.

Risk levels and decisions

Risk levels are ordered low < medium < high < critical — deliberately, so a classified risk can be compared against a configured threshold with ordinary comparison operators. medium is the default.

Decisions: approved, rejected (feedback goes back to the LLM for replanning), or skip (skip this action and return a feedback string).

Scope: once (default) or session — session adds the action to the whitelist for the rest of the conversation.

Outcomes recorded on the result: approved, rejected, skipped, timed_out, plus a whitelist_hit flag distinguishing "the user said yes" from "a previous rule matched".

The approver node

utils/approver puts a gate anywhere on the canvas.

InputMeaning
action_nameWhat is being authorised, e.g. HTTP POST to api.stripe.com
risk_levellow / medium / high / critical
descriptionShown to the user in the card
param_summaryJSON summary of the parameters, shown in the card
extra_whitelistExtra comma-separated action names to auto-approve
timeout_secsDefault 300; auto-reject after this

The output has an approved boolean and a separate runtime-conditional approved signal port that is only present when approval was granted. Wire your dangerous action through that port and it is skipped automatically on rejection, timeout, or policy denial — no explicit condition node required, because a null input means "don't take this path".

Deciding

REST:

POST /executions/:id/approve
{ "request_id": "…", "decision": "approved|rejected|skip", "scope": "once|session", "reason": "…" }

approve/reject are accepted as aliases of approved/rejected. An unknown decision is a 400, not a silent default.

In the UI: the approval card in chat and the editor.

From an external channel: ~~y to approve, ~~n [reason] to reject. ~~y xxx is deliberately refused — the first version will not accept anything that reads as a conditional approval, because "yes, but…" has no unambiguous meaning at a security gate. See Channel Routing.

The approval reaper

The handshake has three participants: the gate blocked inside a node, the execution row saying waiting_for_review, and the user's decision. If the gate disappears without releasing the row — a process restart, a panicked task, a node cancelled out from under it — the row would park forever, and the user would face an approval card whose buttons return NO_ACTIVE_APPROVAL_WAITER.

The reaper closes that hole. Any approval that has outlived its own timeout_secs by more than a grace period is transitioned to timeout.

Env varDefaultMeaning
APPROVAL_REAPER_ENABLEDtrueDisable the sweep entirely
APPROVAL_REAPER_INTERVAL_SECS120How often to sweep
APPROVAL_REAPER_GRACE_SECS300Slack beyond each request's timeout_secs

A live gate always releases its own wait within timeout_secs, so the grace period keeps the reaper off healthy approvals. The transition uses a compare-and-set on both the state and the request_id, so a decision landing at the same instant always wins over the reaper.

Reviews

A review is the other shape: the work is already done, staged somewhere, and a human decides whether to keep it.

When a tool returns requires_review: true, the engine moves the execution to waiting_for_review and suspends it durably. The canonical example is document shadow sessions: an agent's edits are staged in a shadow, the diff is reviewable per block, and the execution resumes on the decision.

Review is per-item, which is why a shadow session can end in partially_accepted — three of five edits taken, the rest still pending. An approval, by contrast, is a single yes or no.

Resuming a paused execution uses the resume path, not a new chat message:

POST /executions/:id/pause
POST /executions/:id/resume

Agent file changes: risk-tiered, not all-or-nothing

When an agent edits files or runs processes through its tools, the question "does this need a card?" is answered by one deterministic policy, not per node:

TierExamplesCard?
Read-onlyviewing, listing, searching, diffing filesNever
Small, scoped, undoablecreating a file, a backed-up edit or patch up to 64 KiB, copying to a new pathNo — runs automatically and records an undo credential
Larger or riskieroverwriting, touching a protected path such as .env or .git, turning off a safety checkYes
Destructivedelete, move, rollback, any multi-file change setAlways, bound to the exact paths
Process executionworkspace shell commands, running testsAlways

Before deciding, a preflight inspects the planned change where the files live, without writing. The facts it saw are pinned: if a file changes between the card and the execution, the approved call fails as stale instead of landing on something you did not review. An automatic call carries an undo credential whose revert refuses if the file has changed since.

The card shows the reason code (destructive_operation, overwrite, protected_target, …). A remembered "always allow" covers only that operation on those paths. The full rules — the low-impact envelope, protected paths, trusted automation, and the audit record — are in Tool-call Approval Policy.

Designing with gates

Next steps