supercode_harness/approvals.rs
1//! ORCH-9 (observed tier): one uniform listing of the approval requests
2//! waiting for an answer, across every harness supercode drives.
3//!
4//! **What a source is, at the pinned harness versions.** An approval is only
5//! listable where some door holds it. At the pin there is exactly one uniform
6//! source plus supercode's own queue:
7//!
8//! * **Live protocol requests** — a request exists while a driven runtime's
9//! turn is blocked on it, and it is delivered to supercode as an ordinary
10//! runtime event ([`crate::HarnessEvent`]) on the connection that raised
11//! it: ACP's `session/request_permission` (hermes, openclaw, grok, gemini,
12//! goose, supercode), opencode's `permission.asked` bus event, Codex's
13//! server-to-client `*Approval` reverse requests, Claude Code's stream-json
14//! `can_use_tool` control request, and — on a joined supercode runtime —
15//! the frontend broker's own `request` envelope. It stops existing the
16//! moment `harness.v1.runtimes.respond` answers it.
17//! * **supercode's own queued subagent approvals**
18//! ([`crate::subagents::QueuedApproval`]) — the requests background children
19//! raised on the parent's queue.
20//!
21//! **There is no file or database source at the pin.** hermes 0.21.0 has no
22//! `hermes approvals` at all, and openclaw 2026.7.1-2 has no
23//! `approvals pending | resolve | grants` (both are upstream-main-only; see
24//! the version note in `docs/composable-harness/inventory/orchestration.md`
25//! and the committed help fixtures in `crates/harness/src/parity/fixtures/`).
26//! [`ApprovalKind::Stored`] and [`ApprovalKind::Proposal`] are therefore
27//! defined here and never produced: they are the shapes a later pin's stored
28//! operator approvals and allowlist proposals will land in, and nothing in
29//! this module invents them today.
30//!
31//! pi is a further honest absence: its adapter can `respond`, but pi has no
32//! per-tool-call approval system at all at its pin
33//! (`docs/composable-harness/inventory/pi.md` §4), so no pi request shape is
34//! recognized — none is ever emitted.
35//!
36//! **Answering (ORCH-20, controlled tier).** This module never talks to a
37//! harness itself. It TRANSLATES one uniform decision — `allow_once`,
38//! `allow_always`, `deny` — into the option token and reply envelope the door
39//! that raised the request already accepts, and
40//! `harness.v1.approvals.resolve` hands that envelope to
41//! `harness.v1.runtimes.respond`, the harness's own door, unchanged. A
42//! decision the request does not offer is refused by name with the offered
43//! ones listed; nothing is ever guessed at, and no adapter learns a new
44//! vocabulary because of this module.
45
46use std::collections::BTreeMap;
47
48use serde::{Deserialize, Serialize};
49use serde_json::Value;
50
51use crate::subagents::{QueuedApproval, QueuedApprovalOutcome};
52use crate::{HarnessEvent, HarnessId};
53
54/// Where a row came from.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum ApprovalKind {
58 /// A request a running turn is blocked on right now.
59 Live,
60 /// An operator approval a harness keeps in its own store. No harness at
61 /// the pinned versions has one; see the module docs.
62 Stored,
63 /// A mined allowlist proposal rather than an outstanding request. No
64 /// harness at the pinned versions has one; see the module docs.
65 Proposal,
66}
67
68impl ApprovalKind {
69 /// Stable wire spelling.
70 pub const fn as_str(self) -> &'static str {
71 match self {
72 Self::Live => "live",
73 Self::Stored => "stored",
74 Self::Proposal => "proposal",
75 }
76 }
77}
78
79/// Lifecycle state of one approval row.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum ApprovalStatus {
83 /// Waiting for an answer.
84 Pending,
85 /// Answered yes (once or for the session).
86 Allowed,
87 /// Answered no.
88 Denied,
89 /// Timed out before anyone answered.
90 Expired,
91 /// Withdrawn by the side that raised it.
92 Cancelled,
93}
94
95impl ApprovalStatus {
96 /// Stable wire spelling.
97 pub const fn as_str(self) -> &'static str {
98 match self {
99 Self::Pending => "pending",
100 Self::Allowed => "allowed",
101 Self::Denied => "denied",
102 Self::Expired => "expired",
103 Self::Cancelled => "cancelled",
104 }
105 }
106}
107
108/// One answer the door that raised this request accepts.
109///
110/// `id` is the token the harness's own responder expects — an ACP
111/// `optionId`, an opencode reply word, a supercode frontend decision. It is
112/// never invented: a row carries options only where the request itself
113/// enumerates them or the pinned source documents the responder's vocabulary.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct ApprovalOption {
116 /// Token passed back to the harness.
117 pub id: String,
118 /// Human label when the request carries one.
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub label: Option<String>,
121 /// The protocol's own classification of the answer, when it states one.
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub kind: Option<String>,
124}
125
126impl ApprovalOption {
127 fn bare(id: &str) -> Self {
128 Self {
129 id: id.to_string(),
130 label: None,
131 kind: None,
132 }
133 }
134}
135
136/// One approval request, in the vocabulary shared by every harness.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct ApprovalRow {
139 /// Stable, addressable identity. Live rows are
140 /// `<connection>/<native request id>`; supercode's queued subagent rows
141 /// are `supercode/subagent/<child>/<queued_at_ms>/<index>`.
142 pub id: String,
143 /// Harness whose door raised the request.
144 pub harness: HarnessId,
145 /// Which source this row came from.
146 pub kind: ApprovalKind,
147 /// Lifecycle state.
148 pub status: ApprovalStatus,
149 /// The tool, command, or edit being asked about, on one line.
150 pub subject: String,
151 /// Harness-native session the request belongs to, when it names one.
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub session_id: Option<String>,
154 /// Live runtime the request arrived on, when there is one.
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub runtime_id: Option<String>,
157 /// Unix-ms wall-clock time the request was first seen.
158 pub requested_at_ms: i64,
159 /// How long it has been waiting, as of this listing.
160 pub age_ms: i64,
161 /// Answers the door accepts. Empty where the protocol does not enumerate
162 /// them, and always empty on a row that is no longer `pending`.
163 #[serde(default)]
164 pub options: Vec<ApprovalOption>,
165}
166
167/// `harness.v1.approvals.list` request.
168#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(default)]
170pub struct ApprovalsQuery {
171 /// Only this harness. Omit for every harness.
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub harness: Option<String>,
174 /// Only this session (a harness-native session id, or a child agent id
175 /// for supercode's queued subagent rows).
176 #[serde(skip_serializing_if = "Option::is_none")]
177 pub session: Option<String>,
178}
179
180impl ApprovalsQuery {
181 /// Whether one row survives this query's filters.
182 pub fn matches(&self, row: &ApprovalRow) -> bool {
183 if let Some(harness) = self.harness.as_deref() {
184 if row.harness.as_str() != harness {
185 return false;
186 }
187 }
188 if let Some(session) = self.session.as_deref() {
189 let hit = row.session_id.as_deref() == Some(session)
190 || row.runtime_id.as_deref() == Some(session);
191 if !hit {
192 return false;
193 }
194 }
195 true
196 }
197}
198
199/// The uniform decision `harness.v1.approvals.resolve` takes, in the
200/// vocabulary shared by every harness rather than any one door's spelling.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(rename_all = "snake_case")]
203pub enum ApprovalDecision {
204 /// Allow this one call.
205 AllowOnce,
206 /// Allow this call and every matching one for the rest of the session.
207 AllowAlways,
208 /// Refuse.
209 Deny,
210}
211
212impl ApprovalDecision {
213 /// Every decision, in escalating order.
214 pub const ALL: [Self; 3] = [Self::AllowOnce, Self::AllowAlways, Self::Deny];
215
216 /// Stable wire spelling.
217 pub const fn as_str(self) -> &'static str {
218 match self {
219 Self::AllowOnce => "allow_once",
220 Self::AllowAlways => "allow_always",
221 Self::Deny => "deny",
222 }
223 }
224
225 /// Parse a wire or CLI spelling. `allow_once` and `allow-once` are the
226 /// same decision: the CLI hyphenates its positional, the RPC does not.
227 pub fn parse(text: &str) -> Option<Self> {
228 let normalized = text.trim().to_ascii_lowercase().replace('-', "_");
229 Self::ALL
230 .into_iter()
231 .find(|decision| decision.as_str() == normalized)
232 }
233}
234
235/// `harness.v1.approvals.resolve` request.
236///
237/// Exactly one of `decision` and `option_id` is given: the uniform decision
238/// this module translates, or the door's own option token when a caller has
239/// already read it off the row and wants it passed through untranslated.
240#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(default)]
242pub struct ApprovalsResolveParams {
243 /// The row id `harness.v1.approvals.list` reported.
244 pub id: String,
245 /// Uniform decision to translate onto this request's own options.
246 #[serde(skip_serializing_if = "Option::is_none")]
247 pub decision: Option<ApprovalDecision>,
248 /// One of the row's own `options[].id` values, passed through as-is.
249 #[serde(skip_serializing_if = "Option::is_none")]
250 pub option_id: Option<String>,
251}
252
253/// What a caller asked for, once the params have been validated.
254#[derive(Debug, Clone, PartialEq, Eq)]
255pub enum ApprovalChoice {
256 /// Translate this uniform decision onto the request's own options.
257 Decision(ApprovalDecision),
258 /// Send this exact option token, after checking the request offers it.
259 Option(String),
260}
261
262impl ApprovalChoice {
263 /// What the caller asked for, as it will appear in a refusal.
264 pub fn asked(&self) -> &str {
265 match self {
266 Self::Decision(decision) => decision.as_str(),
267 Self::Option(option) => option.as_str(),
268 }
269 }
270}
271
272/// Everything `harness.v1.runtimes.respond` needs to answer one request.
273///
274/// This is a plan, not an effect: building it neither touches a runtime nor
275/// forgets the row. The service hands it straight to
276/// `harness.v1.runtimes.respond`, so the answer travels the adapter path that
277/// already existed and no adapter is changed by this verb.
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub struct ApprovalResolution {
280 /// Runtime connection holding the request.
281 pub connection: String,
282 /// Native JSON request id the door expects back.
283 pub request_id: Value,
284 /// Option token actually sent.
285 pub option_id: String,
286 /// The door's own reply envelope carrying that token.
287 pub response: Value,
288}
289
290/// Why one resolve could not be planned.
291#[derive(Debug, Clone, PartialEq, Eq)]
292pub enum ApprovalResolveError {
293 /// No live request with this row id is outstanding on this service.
294 UnknownId(String),
295 /// The row is supercode's own queued subagent record, which is an audit
296 /// entry rather than an answerable door.
297 QueuedSubagentRow(String),
298 /// The request enumerates answers, but none of them is this decision.
299 NotOffered {
300 /// What was asked for.
301 asked: String,
302 /// The option ids the request itself offers.
303 offered: Vec<String>,
304 },
305 /// The request enumerates no answers at all, so there is nothing uniform
306 /// to select. Codex's reverse approval request is the one such door at
307 /// the pin (see [`classify_live_request`]).
308 NoOptions {
309 /// Door that raised it.
310 door: &'static str,
311 },
312}
313
314impl std::fmt::Display for ApprovalResolveError {
315 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
316 match self {
317 Self::UnknownId(id) => write!(
318 formatter,
319 "no approval request `{id}` is waiting on this service — a live request \
320 exists only inside the process driving the runtime whose turn it blocks, \
321 and only until it is answered. Answer it there: the SDK client or TUI that \
322 started the runtime, or `harness.v1.approvals.resolve` over that same \
323 `supercode harness serve` stdio session (SUP-62: no cross-process relay)"
324 ),
325 Self::QueuedSubagentRow(id) => write!(
326 formatter,
327 "`{id}` is a queued subagent record — supercode's own audit trail of a \
328 request the parent's own handler answers (the terminal's modal, or the \
329 frontend request broker). Answer it on the door that raised it: the \
330 `request` envelope row of the joined supercode runtime"
331 ),
332 Self::NotOffered { asked, offered } => write!(
333 formatter,
334 "this request does not offer `{asked}` — it offers: {}",
335 if offered.is_empty() {
336 "(nothing)".to_string()
337 } else {
338 offered.join(", ")
339 }
340 ),
341 Self::NoOptions { door } => write!(
342 formatter,
343 "this `{door}` request enumerates no answers, so there is no option to \
344 select — answer it with `harness.v1.runtimes.respond` and that door's own \
345 reply body"
346 ),
347 }
348 }
349}
350
351impl std::error::Error for ApprovalResolveError {}
352
353/// Which protocol door raised a live request.
354///
355/// A door decides two things this module needs and nothing else: the option
356/// vocabulary a uniform decision maps onto, and the envelope
357/// `harness.v1.runtimes.respond` carries the answer in. Each is the door's
358/// own — see [`ApprovalResolution`].
359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360pub enum ApprovalDoor {
361 /// ACP `session/request_permission` (hermes, openclaw, grok, gemini,
362 /// goose, supercode's own ACP server).
363 Acp,
364 /// opencode's `permission.asked` bus event.
365 Opencode,
366 /// Codex's `*Approval` server-to-client reverse request.
367 Codex,
368 /// Claude Code's stream-json `can_use_tool` control request, raised to the
369 /// permission handler supercode registers with
370 /// `--permission-prompt-tool stdio`.
371 ClaudeCode,
372 /// supercode's own frontend request broker, on a joined runtime.
373 SupercodeFrontend,
374}
375
376impl ApprovalDoor {
377 /// Stable spelling used in refusal messages.
378 pub const fn as_str(self) -> &'static str {
379 match self {
380 Self::Acp => "acp",
381 Self::Opencode => "opencode",
382 Self::Codex => "codex",
383 Self::ClaudeCode => "claude-code",
384 Self::SupercodeFrontend => "supercode-frontend",
385 }
386 }
387
388 /// How this door spells one uniform decision, best match first.
389 ///
390 /// Each list is the door's OWN vocabulary, never a coinage:
391 ///
392 /// * ACP names the classification on the request — `PermissionOptionKind`
393 /// is `allow_once | allow_always | reject_once | reject_always` — so a
394 /// decision is matched against each option's `kind` and, for an agent
395 /// that sends none, against its `optionId`.
396 /// * opencode's reply set is `once | always | reject`
397 /// (`docs/composable-harness/inventory/opencode.md` §"Ask/approve flow").
398 /// * supercode's frontend broker takes [`FRONTEND_DECISIONS`].
399 /// * Claude Code's permission handler answers with a `behavior`, and the
400 /// protocol defines exactly two: `allow` and `deny`
401 /// ([`CLAUDE_CODE_BEHAVIORS`]). `allow_always` is NOT among them —
402 /// persisting a rule is a separate `updatedPermissions` field carrying
403 /// the request's own `permission_suggestions`, which the uniform
404 /// `(door, options, choice)` translation here does not carry — so the
405 /// decision is refused by name with the two that are offered.
406 /// * Codex's reverse request enumerates nothing, and this module invents
407 /// no vocabulary for it (ORCH-9's own stance); its rows refuse with
408 /// [`ApprovalResolveError::NoOptions`].
409 const fn spellings(self, decision: ApprovalDecision) -> &'static [&'static str] {
410 match (self, decision) {
411 (Self::Acp, ApprovalDecision::AllowOnce) => &["allow_once"],
412 (Self::Acp, ApprovalDecision::AllowAlways) => &["allow_always"],
413 (Self::Acp, ApprovalDecision::Deny) => &["reject_once", "reject_always"],
414 (Self::Opencode, ApprovalDecision::AllowOnce) => &["once"],
415 (Self::Opencode, ApprovalDecision::AllowAlways) => &["always"],
416 (Self::Opencode, ApprovalDecision::Deny) => &["reject"],
417 (Self::ClaudeCode, ApprovalDecision::AllowOnce) => &["allow"],
418 (Self::ClaudeCode, ApprovalDecision::AllowAlways) => &[],
419 (Self::ClaudeCode, ApprovalDecision::Deny) => &["deny"],
420 (Self::SupercodeFrontend, ApprovalDecision::AllowOnce) => &["allow"],
421 (Self::SupercodeFrontend, ApprovalDecision::AllowAlways) => &["allow_for_session"],
422 (Self::SupercodeFrontend, ApprovalDecision::Deny) => &["deny"],
423 (Self::Codex, _) => &[],
424 }
425 }
426
427 /// The reply envelope this door carries a chosen option token in.
428 ///
429 /// `None` for a door with no enumerated answers — nothing is guessed.
430 fn reply(self, option_id: &str) -> Option<Value> {
431 match self {
432 // ACP: the client answers by SELECTING one of the request's own
433 // optionIds. This exact envelope cleared a real hermes 0.21.0
434 // permission request in
435 // `docs/interop/research/orch9-hermes-approval-receipt-2026-09-03.json`.
436 Self::Acp => Some(serde_json::json!({
437 "outcome": {"outcome": "selected", "optionId": option_id},
438 })),
439 // opencode: the reply word, POSTed to
440 // `/session/{id}/permissions/{permissionID}` by the adapter.
441 Self::Opencode => Some(serde_json::json!({"response": option_id})),
442 // supercode's own broker takes the decision by name
443 // (`crate::runtime::supercode_http::frontend_response`).
444 Self::SupercodeFrontend => Some(serde_json::json!({"decision": option_id})),
445 // Claude Code: the permission handler's result. `deny` must carry
446 // a `message` — measured against claude 2.1.258, which refuses a
447 // bare `{"behavior":"deny"}` with "Expected {behavior: 'allow',
448 // updatedInput?: object} or {behavior: 'deny', message: string}".
449 // Recorded in
450 // `docs/interop/research/orc2-claude-respond-receipt-2026-09-04.json`.
451 Self::ClaudeCode if option_id == "deny" => Some(serde_json::json!({
452 "behavior": "deny",
453 "message": "denied through supercode approvals",
454 })),
455 Self::ClaudeCode => Some(serde_json::json!({"behavior": option_id})),
456 Self::Codex => None,
457 }
458 }
459}
460
461/// Translate one caller's choice into the token and envelope a request's own
462/// door accepts.
463///
464/// Kept free of the registry so the whole translation is testable from a
465/// request's options alone.
466pub fn plan_reply(
467 door: ApprovalDoor,
468 options: &[ApprovalOption],
469 choice: &ApprovalChoice,
470) -> Result<(String, Value), ApprovalResolveError> {
471 if options.is_empty() {
472 return Err(ApprovalResolveError::NoOptions {
473 door: door.as_str(),
474 });
475 }
476 let offered = || {
477 options
478 .iter()
479 .map(|option| option.id.clone())
480 .collect::<Vec<_>>()
481 };
482 let chosen = match choice {
483 // An explicit token is passed through, but only after the request
484 // itself is confirmed to offer it.
485 ApprovalChoice::Option(option_id) => options
486 .iter()
487 .find(|option| &option.id == option_id)
488 .ok_or_else(|| ApprovalResolveError::NotOffered {
489 asked: option_id.clone(),
490 offered: offered(),
491 })?,
492 ApprovalChoice::Decision(decision) => door
493 .spellings(*decision)
494 .iter()
495 .find_map(|spelling| {
496 options.iter().find(|option| {
497 option.kind.as_deref() == Some(*spelling) || option.id == *spelling
498 })
499 })
500 .ok_or_else(|| ApprovalResolveError::NotOffered {
501 asked: decision.as_str().to_string(),
502 offered: offered(),
503 })?,
504 };
505 let response = door
506 .reply(&chosen.id)
507 .ok_or(ApprovalResolveError::NoOptions {
508 door: door.as_str(),
509 })?;
510 Ok((chosen.id.clone(), response))
511}
512
513/// One live protocol request as the classifier reads it off the wire.
514#[derive(Debug, Clone, PartialEq, Eq)]
515pub struct LiveRequest {
516 /// Native JSON id the harness expects back through `runtimes.respond`.
517 pub request_id: Value,
518 /// Protocol door that raised it.
519 pub door: ApprovalDoor,
520 /// Session the request names, when it names one.
521 pub session_id: Option<String>,
522 /// One-line description of what is being asked about.
523 pub subject: String,
524 /// Answers the door accepts.
525 pub options: Vec<ApprovalOption>,
526}
527
528/// Recognize a permission/approval request in one live runtime event.
529///
530/// Returns `None` for every other event, including the responses that answer
531/// these requests. The three recognized shapes are exactly the ones the
532/// adapters in [`crate::runtime`] surface; each is matched on its own
533/// protocol's spelling rather than on a guess about the harness.
534pub fn classify_live_request(kind: &str, payload: &Value) -> Option<LiveRequest> {
535 match kind {
536 // ACP `session/request_permission` (hermes, openclaw, grok, gemini,
537 // goose, and supercode's own ACP server).
538 "session/request_permission" => acp_request(payload),
539 // opencode's bus event; the reply goes to
540 // `/session/{id}/permissions/{permissionID}`.
541 "permission.asked" => opencode_request(payload),
542 // supercode's own runtime, joined over the HTTP frontend: the
543 // request broker publishes `{"type":"request","request":{…}}` and
544 // `runtimes.respond` answers it by the same integer id
545 // (`crate::runtime::supercode_http`).
546 "request" => supercode_request(payload),
547 // Claude Code's stream-json control channel. Only the `can_use_tool`
548 // subtype is a permission request; every other control_request the
549 // CLI can raise is left alone.
550 "control_request" => claude_code_request(payload),
551 // Codex app-server / mcp-server reverse requests
552 // (`execCommandApproval`, `applyPatchApproval`). A notification with
553 // the same name is not a request: only a JSON-RPC message carrying an
554 // `id` can be answered.
555 _ if kind.ends_with("Approval") => codex_request(payload),
556 _ => None,
557 }
558}
559
560fn request_id(payload: &Value) -> Option<Value> {
561 payload
562 .get("id")
563 .filter(|id| !id.is_null())
564 .filter(|id| id.is_string() || id.is_number())
565 .cloned()
566}
567
568fn acp_request(payload: &Value) -> Option<LiveRequest> {
569 let request_id = request_id(payload)?;
570 let params = payload.get("params").unwrap_or(&Value::Null);
571 let tool_call = params.get("toolCall");
572 let subject = tool_call
573 .and_then(|call| call.get("title"))
574 .and_then(Value::as_str)
575 .map(str::to_string)
576 .or_else(|| {
577 tool_call
578 .and_then(|call| call.get("rawInput"))
579 .and_then(command_line)
580 })
581 .or_else(|| {
582 tool_call
583 .and_then(|call| call.get("kind"))
584 .and_then(Value::as_str)
585 .map(str::to_string)
586 })
587 .unwrap_or_else(|| "permission request".to_string());
588 let options = params
589 .get("options")
590 .and_then(Value::as_array)
591 .map(|options| {
592 options
593 .iter()
594 .filter_map(|option| {
595 Some(ApprovalOption {
596 id: option.get("optionId").and_then(Value::as_str)?.to_string(),
597 label: option
598 .get("name")
599 .and_then(Value::as_str)
600 .map(str::to_string),
601 kind: option
602 .get("kind")
603 .and_then(Value::as_str)
604 .map(str::to_string),
605 })
606 })
607 .collect()
608 })
609 .unwrap_or_default();
610 Some(LiveRequest {
611 request_id,
612 door: ApprovalDoor::Acp,
613 session_id: params
614 .get("sessionId")
615 .and_then(Value::as_str)
616 .map(str::to_string),
617 subject: one_line(&subject),
618 options,
619 })
620}
621
622fn opencode_request(payload: &Value) -> Option<LiveRequest> {
623 let properties = payload.get("properties").unwrap_or(payload);
624 let permission = properties
625 .get("permission")
626 .filter(|value| value.is_object())
627 .unwrap_or(properties);
628 let id = permission.get("id").and_then(Value::as_str)?;
629 let subject = permission
630 .get("title")
631 .and_then(Value::as_str)
632 .or_else(|| permission.get("pattern").and_then(Value::as_str))
633 .or_else(|| permission.get("type").and_then(Value::as_str))
634 .unwrap_or("permission request");
635 Some(LiveRequest {
636 request_id: Value::String(id.to_string()),
637 door: ApprovalDoor::Opencode,
638 session_id: permission
639 .get("sessionID")
640 .and_then(Value::as_str)
641 .map(str::to_string),
642 subject: one_line(subject),
643 // opencode's own reply vocabulary at the pin
644 // (`docs/composable-harness/inventory/opencode.md` §"Ask/approve
645 // flow": clients reply `once | always | reject`).
646 options: ["once", "always", "reject"]
647 .into_iter()
648 .map(ApprovalOption::bare)
649 .collect(),
650 })
651}
652
653fn supercode_request(payload: &Value) -> Option<LiveRequest> {
654 let request = payload.get("request")?;
655 // Only approvals: an MCP elicitation travels the same envelope but is a
656 // form to fill in, not a permission to grant.
657 if request.get("kind").and_then(Value::as_str) != Some("approval") {
658 return None;
659 }
660 let request_id = request
661 .get("id")
662 .filter(|id| id.is_number())
663 .cloned()
664 .filter(|id| !id.is_null())?;
665 let inner = request.get("payload").unwrap_or(&Value::Null);
666 let subject = inner
667 .get("subject")
668 .and_then(Value::as_str)
669 .filter(|subject| !subject.is_empty())
670 .or_else(|| inner.get("tool").and_then(Value::as_str))
671 .unwrap_or("permission request");
672 Some(LiveRequest {
673 request_id,
674 door: ApprovalDoor::SupercodeFrontend,
675 // A request raised by a background child names that child; a request
676 // from the parent's own loop names nothing beyond the runtime.
677 session_id: inner
678 .get("child_agent_id")
679 .and_then(Value::as_str)
680 .map(str::to_string),
681 subject: one_line(subject),
682 // The decisions `runtimes.respond` accepts on this door
683 // (`crate::runtime::supercode_http::frontend_response`).
684 options: FRONTEND_DECISIONS
685 .into_iter()
686 .map(ApprovalOption::bare)
687 .collect(),
688 })
689}
690
691/// Claude Code's `can_use_tool` control request.
692///
693/// Ground truth (claude 2.1.258, recorded live in
694/// `docs/interop/research/orc2-claude-respond-receipt-2026-09-04.json`): with
695/// `--permission-prompt-tool stdio` the CLI writes
696/// `{"type":"control_request","request_id":"<uuid>","request":{"subtype":"can_use_tool","tool_name":"Bash","display_name":"Bash","input":{…},"description":…,"permission_suggestions":[…],"blocked_path":…,"tool_use_id":"toolu_…"}}`
697/// and blocks the turn until a `control_response` answers that `request_id`.
698fn claude_code_request(payload: &Value) -> Option<LiveRequest> {
699 let request = payload.get("request")?;
700 if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") {
701 return None;
702 }
703 let request_id = payload
704 .get("request_id")
705 .filter(|id| id.is_string())
706 .cloned()?;
707 let tool = request
708 .get("tool_name")
709 .and_then(Value::as_str)
710 .or_else(|| request.get("display_name").and_then(Value::as_str))
711 .unwrap_or("tool");
712 let detail = request
713 .get("input")
714 .and_then(command_line)
715 .or_else(|| {
716 request
717 .get("description")
718 .and_then(Value::as_str)
719 .map(str::to_string)
720 })
721 .or_else(|| {
722 request
723 .get("blocked_path")
724 .and_then(Value::as_str)
725 .map(str::to_string)
726 });
727 let subject = match detail {
728 Some(detail) if !detail.is_empty() => format!("{tool} {detail}"),
729 _ => tool.to_string(),
730 };
731 Some(LiveRequest {
732 request_id,
733 door: ApprovalDoor::ClaudeCode,
734 // The request names the blocked tool call, never a session: the
735 // connection's own runtime id is the session identity here.
736 session_id: None,
737 subject: one_line(&subject),
738 // The two `behavior` values the CLI's permission-result validator
739 // accepts; see [`ApprovalDoor::spellings`].
740 options: CLAUDE_CODE_BEHAVIORS
741 .into_iter()
742 .map(ApprovalOption::bare)
743 .collect(),
744 })
745}
746
747fn codex_request(payload: &Value) -> Option<LiveRequest> {
748 let request_id = request_id(payload)?;
749 let params = payload.get("params").unwrap_or(&Value::Null);
750 let subject = params
751 .get("command")
752 .and_then(command_line)
753 .or_else(|| {
754 params
755 .get("fileChanges")
756 .and_then(Value::as_object)
757 .map(|changes| {
758 let files = changes.keys().cloned().collect::<Vec<_>>().join(", ");
759 if files.is_empty() {
760 "apply patch".to_string()
761 } else {
762 format!("apply patch: {files}")
763 }
764 })
765 })
766 .or_else(|| {
767 params
768 .get("reason")
769 .and_then(Value::as_str)
770 .map(str::to_string)
771 })
772 .unwrap_or_else(|| "approval request".to_string());
773 Some(LiveRequest {
774 request_id,
775 door: ApprovalDoor::Codex,
776 session_id: ["threadId", "conversationId", "sessionId"]
777 .into_iter()
778 .find_map(|key| params.get(key).and_then(Value::as_str))
779 .map(str::to_string),
780 subject: one_line(&subject),
781 // The Codex reverse request does not enumerate its answers, and this
782 // module does not invent a vocabulary for it.
783 options: Vec::new(),
784 })
785}
786
787/// A command as one line, whether the protocol sends a string or an argv.
788fn command_line(value: &Value) -> Option<String> {
789 match value {
790 Value::String(text) => Some(text.clone()),
791 Value::Array(parts) => {
792 let joined = parts
793 .iter()
794 .filter_map(Value::as_str)
795 .collect::<Vec<_>>()
796 .join(" ");
797 (!joined.is_empty()).then_some(joined)
798 }
799 Value::Object(object) => object
800 .get("command")
801 .or_else(|| object.get("cmd"))
802 .and_then(command_line),
803 _ => None,
804 }
805}
806
807fn one_line(text: &str) -> String {
808 let flattened = text.split_whitespace().collect::<Vec<_>>().join(" ");
809 if flattened.is_empty() {
810 "permission request".to_string()
811 } else {
812 flattened
813 }
814}
815
816/// Render a native JSON request id into the stable id segment used by
817/// [`ApprovalRow::id`].
818fn id_segment(request_id: &Value) -> String {
819 match request_id {
820 Value::String(text) => text.clone(),
821 other => other.to_string(),
822 }
823}
824
825#[derive(Debug, Clone)]
826struct LiveEntry {
827 request_id: Value,
828 door: ApprovalDoor,
829 harness: HarnessId,
830 runtime_id: String,
831 session_id: Option<String>,
832 subject: String,
833 options: Vec<ApprovalOption>,
834 requested_at_ms: i64,
835}
836
837/// The live pending requests held by one service's open runtime connections.
838///
839/// A request enters when the connection surfaces it and leaves when it is
840/// answered or the connection goes away. Nothing here survives the process:
841/// a live request only exists while the turn that raised it is blocked.
842#[derive(Debug, Default)]
843pub struct ApprovalRegistry {
844 /// Connection id → the requests still outstanding on it, oldest first.
845 entries: BTreeMap<String, Vec<LiveEntry>>,
846}
847
848impl ApprovalRegistry {
849 /// Empty registry.
850 pub fn new() -> Self {
851 Self::default()
852 }
853
854 /// Record one runtime event if it is a permission/approval request.
855 ///
856 /// Returns `true` when the event was recognized and is now listable. A
857 /// repeat of a request already held is not duplicated.
858 pub fn observe(
859 &mut self,
860 connection: &str,
861 harness: &HarnessId,
862 runtime_id: &str,
863 event: &HarnessEvent,
864 now_ms: i64,
865 ) -> bool {
866 let Some(request) = classify_live_request(&event.kind, &event.payload) else {
867 return false;
868 };
869 let entries = self.entries.entry(connection.to_string()).or_default();
870 if entries
871 .iter()
872 .any(|entry| entry.request_id == request.request_id)
873 {
874 return false;
875 }
876 entries.push(LiveEntry {
877 request_id: request.request_id,
878 door: request.door,
879 harness: harness.clone(),
880 runtime_id: runtime_id.to_string(),
881 session_id: request.session_id,
882 subject: request.subject,
883 options: request.options,
884 requested_at_ms: now_ms,
885 });
886 true
887 }
888
889 /// Drop one request because it was answered.
890 pub fn answered(&mut self, connection: &str, request_id: &Value) -> bool {
891 let Some(entries) = self.entries.get_mut(connection) else {
892 return false;
893 };
894 let before = entries.len();
895 entries.retain(|entry| &entry.request_id != request_id);
896 let removed = entries.len() < before;
897 if entries.is_empty() {
898 self.entries.remove(connection);
899 }
900 removed
901 }
902
903 /// Drop everything held for a connection that closed or failed.
904 pub fn forget(&mut self, connection: &str) {
905 self.entries.remove(connection);
906 }
907
908 /// Whether nothing is outstanding.
909 pub fn is_empty(&self) -> bool {
910 self.entries.values().all(Vec::is_empty)
911 }
912
913 /// Plan the answer to one listed row (ORCH-20).
914 ///
915 /// Looks the row up by the same `id` [`Self::rows`] published, translates
916 /// the caller's choice through the door that raised it, and returns what
917 /// `harness.v1.runtimes.respond` needs. Nothing is sent and nothing is
918 /// forgotten here: the service performs the answer on the existing
919 /// respond path, which is also what drops the row.
920 pub fn resolution(
921 &self,
922 row_id: &str,
923 choice: &ApprovalChoice,
924 ) -> Result<ApprovalResolution, ApprovalResolveError> {
925 // A queued subagent record is addressable but not answerable; say so
926 // rather than reporting it simply missing.
927 if row_id.starts_with(SUBAGENT_ROW_PREFIX) {
928 return Err(ApprovalResolveError::QueuedSubagentRow(row_id.to_string()));
929 }
930 let (connection, entry) = self
931 .entries
932 .iter()
933 .flat_map(|(connection, entries)| entries.iter().map(move |entry| (connection, entry)))
934 .find(|(connection, entry)| {
935 format!("{connection}/{}", id_segment(&entry.request_id)) == row_id
936 })
937 .ok_or_else(|| ApprovalResolveError::UnknownId(row_id.to_string()))?;
938 let (option_id, response) = plan_reply(entry.door, &entry.options, choice)?;
939 Ok(ApprovalResolution {
940 connection: connection.clone(),
941 request_id: entry.request_id.clone(),
942 option_id,
943 response,
944 })
945 }
946
947 /// Every outstanding request as a row, aged against `now_ms`.
948 pub fn rows(&self, now_ms: i64) -> Vec<ApprovalRow> {
949 self.entries
950 .iter()
951 .flat_map(|(connection, entries)| {
952 entries.iter().map(move |entry| ApprovalRow {
953 id: format!("{connection}/{}", id_segment(&entry.request_id)),
954 harness: entry.harness.clone(),
955 kind: ApprovalKind::Live,
956 status: ApprovalStatus::Pending,
957 subject: entry.subject.clone(),
958 session_id: entry.session_id.clone(),
959 runtime_id: Some(entry.runtime_id.clone()),
960 requested_at_ms: entry.requested_at_ms,
961 age_ms: now_ms.saturating_sub(entry.requested_at_ms).max(0),
962 options: entry.options.clone(),
963 })
964 })
965 .collect()
966 }
967}
968
969/// The answers supercode's own frontend accepts, mirroring
970/// [`crate::frontend::FrontendApprovalDecision`]. Used for both a live
971/// request on a joined supercode runtime and a queued subagent request.
972const FRONTEND_DECISIONS: [&str; 3] = ["allow", "allow_for_session", "deny"];
973
974/// The `behavior` values Claude Code's permission-handler protocol defines.
975///
976/// Its own validator names exactly these two: "Expected {behavior: 'allow',
977/// updatedInput?: object} or {behavior: 'deny', message: string}" (claude
978/// 2.1.258, recorded in
979/// `docs/interop/research/orc2-claude-respond-receipt-2026-09-04.json`).
980const CLAUDE_CODE_BEHAVIORS: [&str; 2] = ["allow", "deny"];
981
982/// Row-id prefix every queued subagent record carries.
983const SUBAGENT_ROW_PREFIX: &str = "supercode/subagent/";
984
985/// supercode's own queued subagent approvals, as uniform rows.
986///
987/// The queue is append-only, so the index is a stable identity within the
988/// process that owns it. A record whose outcome is still unknown is
989/// `pending`; one the parent's own handler already answered carries that
990/// answer, which is why this listing never has to guess (see
991/// [`crate::subagents::QueuedApproval`]).
992pub fn subagent_rows(queued: &[QueuedApproval], now_ms: i64) -> Vec<ApprovalRow> {
993 queued
994 .iter()
995 .enumerate()
996 .map(|(index, record)| {
997 let status = match record.outcome {
998 None => ApprovalStatus::Pending,
999 Some(QueuedApprovalOutcome::Allowed) => ApprovalStatus::Allowed,
1000 Some(QueuedApprovalOutcome::Denied) => ApprovalStatus::Denied,
1001 };
1002 let subject = match record.subject.as_deref() {
1003 Some(subject) if !subject.is_empty() => {
1004 format!("{} {}", record.tool, subject)
1005 }
1006 _ => record.tool.clone(),
1007 };
1008 ApprovalRow {
1009 id: format!(
1010 "{SUBAGENT_ROW_PREFIX}{}/{}/{index}",
1011 record.child_agent_id, record.queued_at_ms
1012 ),
1013 harness: HarnessId::from(HarnessId::SUPERCODE),
1014 kind: ApprovalKind::Live,
1015 status,
1016 subject: one_line(&subject),
1017 session_id: Some(record.child_agent_id.clone()),
1018 runtime_id: None,
1019 requested_at_ms: record.queued_at_ms,
1020 age_ms: now_ms.saturating_sub(record.queued_at_ms).max(0),
1021 options: if status == ApprovalStatus::Pending {
1022 FRONTEND_DECISIONS
1023 .into_iter()
1024 .map(ApprovalOption::bare)
1025 .collect()
1026 } else {
1027 Vec::new()
1028 },
1029 }
1030 })
1031 .collect()
1032}
1033
1034/// Whether supercode can list approvals for this harness id at all.
1035///
1036/// The uniform-verb contract: a harness whose runtime cannot carry a
1037/// protocol request is refused by name rather than answered with an empty
1038/// list. An unknown id is refused the same way.
1039pub fn lists_approvals(harness: &str) -> bool {
1040 crate::support::harness_support(harness)
1041 .is_some_and(|descriptor| descriptor.runtime.capabilities.respond_to_requests)
1042}
1043
1044/// Every harness id `approvals.list` accepts, in registry order.
1045pub fn approval_harnesses() -> Vec<String> {
1046 crate::support::harness_support_registry()
1047 .harnesses
1048 .into_iter()
1049 .filter(|descriptor| descriptor.runtime.capabilities.respond_to_requests)
1050 .map(|descriptor| descriptor.id.as_str().to_string())
1051 .collect()
1052}
1053
1054/// Wall-clock now in unix milliseconds.
1055pub fn now_ms() -> i64 {
1056 std::time::SystemTime::now()
1057 .duration_since(std::time::UNIX_EPOCH)
1058 .map(|elapsed| elapsed.as_millis() as i64)
1059 .unwrap_or_default()
1060}