Skip to main content

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}