Skip to main content

mur_common/
proposal.rs

1//! Agent-proposed commands and actions — the `propose` tool's shared model.
2//!
3//! An agent that needs the user to run something calls `propose`; murmur shows
4//! it as a chip above the composer. Two families:
5//!
6//! - **insert-only** (`shell`, `slash`): `Tab` puts the text into an empty
7//!   composer; the user reviews and sends it. Never sent by a single key.
8//! - **executable** (`restart`): `Enter` on an empty, idle composer runs it.
9//!
10//! [`vet`] is the one gate. The runtime calls it inside the tool's `execute`
11//! (a rejection goes back to the model as a tool error) and murmur calls it on
12//! the streamed args (so an arg the runtime would reject is never rendered).
13//! It is pure — same input, same verdict on both sides.
14//!
15//! It lives here because `mur-agent-runtime` must not depend on `mur-core`,
16//! and both of them need the type. No I/O.
17//!
18//! `restart` takes no target: it always means "restart the agent that
19//! proposed it", so a proposal aimed at another agent cannot be expressed.
20//!
21//! Design: `docs/superpowers/specs/2026-09-28-murmur-proposal-chip-design.md`.
22
23use std::fmt;
24
25/// Canonical tool name. Shared by the runtime executor and the TUI interceptor.
26pub const PROPOSE_TOOL: &str = "propose";
27
28/// Longest label accepted, in chars. The chip is one line above the composer.
29pub const LABEL_MAX_CHARS: usize = 80;
30
31/// Longest insert-only command accepted, in chars.
32pub const COMMAND_MAX_CHARS: usize = 500;
33
34/// Wire values of the `kind` argument.
35pub const KIND_SHELL: &str = "shell";
36pub const KIND_SLASH: &str = "slash";
37pub const KIND_RESTART: &str = "restart";
38
39/// Allowlist of `kind` values, in schema order.
40pub const KINDS: [&str; 3] = [KIND_SHELL, KIND_SLASH, KIND_RESTART];
41
42/// What a proposal does when the user accepts it.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum ProposalKind {
45    /// Shell command, inserted as `!<cmd>` (murmur's shell mode). Insert-only.
46    Shell(String),
47    /// Slash command, inserted as `/<cmd>`. Insert-only.
48    Slash(String),
49    /// Restart the proposing agent. Executable. No target by construction.
50    Restart,
51    /// A single suggested reply (murmur's ghost), inserted verbatim.
52    /// Insert-only. Never built by [`vet`] — `reply` is not in [`KINDS`];
53    /// only murmur's `suggest_replies` reveal constructs it.
54    Reply(String),
55}
56
57/// A vetted proposal. Only [`vet`] builds one from untrusted args.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Proposal {
60    pub label: String,
61    pub kind: ProposalKind,
62}
63
64impl Proposal {
65    /// Restart proposal with a caller-supplied label — for murmur's own hints
66    /// (the `RESTART_HINT` sites), which are trusted, not model output.
67    pub fn restart(label: impl Into<String>) -> Self {
68        Self {
69            label: label.into(),
70            kind: ProposalKind::Restart,
71        }
72    }
73
74    /// A suggested reply shown as the composer's ghost text. Insert-only.
75    pub fn reply(text: impl Into<String>) -> Self {
76        let text = text.into();
77        Self {
78            label: text.clone(),
79            kind: ProposalKind::Reply(text),
80        }
81    }
82
83    /// Whether this is a suggested reply (murmur's ghost).
84    pub fn is_reply(&self) -> bool {
85        matches!(self.kind, ProposalKind::Reply(_))
86    }
87
88    /// Whether `Enter` may run this proposal. Only native actions qualify;
89    /// insert-only kinds never run on a single key (spec principle C).
90    pub fn is_executable(&self) -> bool {
91        matches!(self.kind, ProposalKind::Restart)
92    }
93
94    /// Text `Tab` puts into an empty composer, or `None` for executable kinds.
95    pub fn insert_text(&self) -> Option<String> {
96        match &self.kind {
97            ProposalKind::Shell(cmd) => Some(format!("!{cmd}")),
98            ProposalKind::Slash(cmd) => Some(format!("/{cmd}")),
99            ProposalKind::Reply(text) => Some(text.clone()),
100            ProposalKind::Restart => None,
101        }
102    }
103}
104
105/// Why a proposal was rejected. `Display` is the tool error the model reads,
106/// so every message says what to change.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum VetError {
109    NotAnObject,
110    MissingField(&'static str),
111    UnknownKind(String),
112    EmptyField(&'static str),
113    TooLong { field: &'static str, max: usize },
114    ControlChar(&'static str),
115    Placeholder { field: &'static str, token: String },
116    SecretShaped(&'static str),
117    RestartTakesNoCommand,
118}
119
120impl fmt::Display for VetError {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        match self {
123            Self::NotAnObject => write!(f, "propose: arguments must be a JSON object"),
124            Self::MissingField(k) => write!(f, "propose: `{k}` is required"),
125            Self::UnknownKind(k) => write!(
126                f,
127                "propose: kind `{k}` is not allowed; use one of {}",
128                KINDS.join(", ")
129            ),
130            Self::EmptyField(k) => write!(f, "propose: `{k}` must not be empty"),
131            Self::TooLong { field, max } => {
132                write!(f, "propose: `{field}` is longer than {max} characters")
133            }
134            Self::ControlChar(k) => write!(
135                f,
136                "propose: `{k}` contains a newline or control character; propose one single-line command"
137            ),
138            Self::Placeholder { field, token } => write!(
139                f,
140                "propose: `{field}` contains the placeholder `{token}`; write the concrete value"
141            ),
142            Self::SecretShaped(k) => write!(
143                f,
144                "propose: `{k}` looks like it contains a secret; never put credentials in a proposal"
145            ),
146            Self::RestartTakesNoCommand => write!(
147                f,
148                "propose: `restart` takes no `command`; it always restarts you, the proposing agent"
149            ),
150        }
151    }
152}
153
154impl std::error::Error for VetError {}
155
156/// Vet raw `propose` tool args into a [`Proposal`].
157///
158/// Rejects: missing/empty/over-long fields, kinds outside the allowlist,
159/// newlines or control characters, unresolved placeholders (`<name>`,
160/// `{agent}`, `{{x}}`), secret-shaped strings, and a `command` on `restart`.
161pub fn vet(args: &serde_json::Value) -> Result<Proposal, VetError> {
162    let obj = args.as_object().ok_or(VetError::NotAnObject)?;
163    let label = str_field(obj, "label")?.ok_or(VetError::MissingField("label"))?;
164    let kind = str_field(obj, "kind")?.ok_or(VetError::MissingField("kind"))?;
165    let command = str_field(obj, "command")?;
166
167    check_text("label", label, LABEL_MAX_CHARS)?;
168
169    let kind = match kind {
170        KIND_RESTART => {
171            if command.is_some() {
172                return Err(VetError::RestartTakesNoCommand);
173            }
174            ProposalKind::Restart
175        }
176        KIND_SHELL | KIND_SLASH => {
177            let raw = command.ok_or(VetError::MissingField("command"))?;
178            // Check the raw string: `trim()` below would silently drop a
179            // trailing `\n`/`\r` and let it through.
180            if raw.chars().any(char::is_control) {
181                return Err(VetError::ControlChar("command"));
182            }
183            // Accept the prefix the model may add; store the bare command.
184            let prefix = if kind == KIND_SHELL { '!' } else { '/' };
185            let cmd = raw.trim().strip_prefix(prefix).unwrap_or(raw.trim()).trim();
186            check_text("command", cmd, COMMAND_MAX_CHARS)?;
187            if kind == KIND_SHELL {
188                ProposalKind::Shell(cmd.to_string())
189            } else {
190                ProposalKind::Slash(cmd.to_string())
191            }
192        }
193        other => return Err(VetError::UnknownKind(other.to_string())),
194    };
195
196    Ok(Proposal {
197        label: label.trim().to_string(),
198        kind,
199    })
200}
201
202fn str_field<'a>(
203    obj: &'a serde_json::Map<String, serde_json::Value>,
204    key: &'static str,
205) -> Result<Option<&'a str>, VetError> {
206    match obj.get(key) {
207        None | Some(serde_json::Value::Null) => Ok(None),
208        Some(serde_json::Value::String(s)) => Ok(Some(s.as_str())),
209        Some(_) => Err(VetError::MissingField(key)),
210    }
211}
212
213fn check_text(field: &'static str, s: &str, max: usize) -> Result<(), VetError> {
214    if s.chars().any(char::is_control) {
215        return Err(VetError::ControlChar(field));
216    }
217    if s.trim().is_empty() {
218        return Err(VetError::EmptyField(field));
219    }
220    if s.chars().count() > max {
221        return Err(VetError::TooLong { field, max });
222    }
223    if let Some(token) = find_placeholder(s) {
224        return Err(VetError::Placeholder { field, token });
225    }
226    if matches!(crate::redact::redact_secrets(s), std::borrow::Cow::Owned(_)) {
227        return Err(VetError::SecretShaped(field));
228    }
229    Ok(())
230}
231
232/// First template-style placeholder in `s`: `<ident>`, `{ident}`, `{{ident}}`.
233/// `${VAR}` is a shell expansion, not a placeholder, and is left alone.
234fn find_placeholder(s: &str) -> Option<String> {
235    use regex_lite::Regex;
236    use std::sync::OnceLock;
237    static RX: OnceLock<Regex> = OnceLock::new();
238    let rx = RX.get_or_init(|| {
239        Regex::new(r"<[A-Za-z_][A-Za-z0-9_-]*>|(^|[^$])(\{\{?[A-Za-z_][A-Za-z0-9_-]*\}\}?)")
240            .expect("static placeholder regex")
241    });
242    let caps = rx.captures(s)?;
243    let m = caps.get(2).or_else(|| caps.get(0))?;
244    Some(m.as_str().to_string())
245}
246
247#[cfg(test)]
248#[path = "proposal_tests.rs"]
249mod tests;