polyc_agent/approval_resolve.rs
1//! Resolving an approver's in-flight edit onto the call that executes.
2//!
3//! A human-in-the-loop approver may approve a gated tool call *with edited
4//! arguments* (`#67`). The signed `approval_response` carries the approver's
5//! edit as `modified_args_json` — empty when they approved as-is. This module
6//! owns the one rule that turns "proposed args + optional edit" into "the args
7//! that actually execute", so the harness resume path and any in-process caller
8//! apply an approval identically.
9//!
10//! The split is deliberate: the model's *proposed* args remain the identity the
11//! approval is bound to (matched byte-for-byte on resume, so a re-emitted call
12//! with different args cannot inherit the approval — see
13//! `polyc_crypto::approval::VerifiedResponse::authorizes_call`), while the
14//! *edit* is a separate signed field this resolver substitutes at execution.
15
16/// The approver's in-flight edit to a specific approved call.
17///
18/// Carried alongside the signed approval and keyed by the same `(request_id,
19/// tool_name, args_json)` identity. An absent override means "execute the
20/// proposed call unchanged", so the common approve-as-is path needs no entry.
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct ApprovalOverride {
23 /// The approver's replacement arguments, or empty when they did not edit
24 /// (execute the model's proposed args unchanged). Sourced from the signed
25 /// `approval_response`, so the edit is unforgeable and auditable.
26 pub modified_args_json: String,
27 /// Context the approver attached to inject before the tool runs (`#67`), or
28 /// empty for none — prepended as an internal-only message the model sees
29 /// ahead of the tool result. Signed as part of the `approval_response`.
30 pub injected_context: String,
31}
32
33/// The effective call to execute after applying an approver's edit.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct ResolvedCall {
36 /// The arguments to pass to `ToolExecutor::execute` — the approver's edit
37 /// when they changed it, else the model's proposed args unchanged.
38 pub args_json: String,
39 /// Context to prepend as an internal-only message before the tool result, or
40 /// `None` when the approver injected none.
41 pub injected_context: Option<String>,
42}
43
44/// Resolve the effective execution call from the model's PROPOSED args and the
45/// approver's optional in-flight edit.
46///
47/// This is the single home of the "empty edit ⇒ run the proposed args"
48/// defaulting. An override whose `modified_args_json` is blank (or all
49/// whitespace) is treated as "no edit" — the proposed args execute — so an
50/// approver clicking plain Approve and an approver submitting an empty edit box
51/// resolve to the same behavior. A blank `injected_context` likewise resolves to
52/// `None` (no message injected).
53#[must_use]
54pub fn resolve_approved_call(
55 proposed_args_json: &str,
56 over: Option<&ApprovalOverride>,
57) -> ResolvedCall {
58 let args_json = match over {
59 Some(o) if !o.modified_args_json.trim().is_empty() => o.modified_args_json.clone(),
60 _ => proposed_args_json.to_owned(),
61 };
62 let injected_context = over
63 .map(|o| o.injected_context.trim())
64 .filter(|c| !c.is_empty())
65 .map(str::to_owned);
66 ResolvedCall {
67 args_json,
68 injected_context,
69 }
70}
71
72#[cfg(test)]
73mod tests {
74 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
75
76 use super::*;
77
78 /// No override ⇒ the proposed args execute unchanged (the common approve
79 /// path).
80 #[test]
81 fn no_override_runs_proposed() {
82 let r = resolve_approved_call(r#"{"path":"/etc/hosts"}"#, None);
83 assert_eq!(r.args_json, r#"{"path":"/etc/hosts"}"#);
84 }
85
86 /// An empty edit ⇒ still the proposed args (approver approved as-is via a
87 /// blank edit box; must not execute empty/blank args).
88 #[test]
89 fn empty_edit_runs_proposed() {
90 for blank in ["", " ", "\n\t "] {
91 let over = ApprovalOverride {
92 modified_args_json: blank.to_owned(),
93 injected_context: String::new(),
94 };
95 let r = resolve_approved_call(r#"{"path":"/etc/hosts"}"#, Some(&over));
96 assert_eq!(
97 r.args_json, r#"{"path":"/etc/hosts"}"#,
98 "blank edit {blank:?} must fall back to the proposed args"
99 );
100 }
101 }
102
103 /// A non-empty edit ⇒ the edited args execute in place of the proposal.
104 #[test]
105 fn non_empty_edit_runs_edited() {
106 let over = ApprovalOverride {
107 modified_args_json: r#"{"path":"/etc/hostname"}"#.to_owned(),
108 injected_context: String::new(),
109 };
110 let r = resolve_approved_call(r#"{"path":"/etc/shadow"}"#, Some(&over));
111 assert_eq!(
112 r.args_json, r#"{"path":"/etc/hostname"}"#,
113 "the approver's edit is what executes"
114 );
115 assert_eq!(r.injected_context, None);
116 }
117
118 /// #67: injected context resolves to `Some` when set, `None` when blank, and
119 /// is independent of whether the args were edited.
120 #[test]
121 fn injected_context_resolves_independently_of_args() {
122 let over = ApprovalOverride {
123 modified_args_json: String::new(),
124 injected_context: "only touch files under src/".to_owned(),
125 };
126 let r = resolve_approved_call(r#"{"path":"a"}"#, Some(&over));
127 // No args edit ⇒ proposed args run, but the context is still injected.
128 assert_eq!(r.args_json, r#"{"path":"a"}"#);
129 assert_eq!(
130 r.injected_context.as_deref(),
131 Some("only touch files under src/")
132 );
133
134 // A whitespace-only context is treated as none.
135 let blank = ApprovalOverride {
136 modified_args_json: String::new(),
137 injected_context: " \n".to_owned(),
138 };
139 assert_eq!(
140 resolve_approved_call("{}", Some(&blank)).injected_context,
141 None
142 );
143 }
144}