1use std::fmt;
24
25pub const PROPOSE_TOOL: &str = "propose";
27
28pub const LABEL_MAX_CHARS: usize = 80;
30
31pub const COMMAND_MAX_CHARS: usize = 500;
33
34pub const KIND_SHELL: &str = "shell";
36pub const KIND_SLASH: &str = "slash";
37pub const KIND_RESTART: &str = "restart";
38
39pub const KINDS: [&str; 3] = [KIND_SHELL, KIND_SLASH, KIND_RESTART];
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum ProposalKind {
45 Shell(String),
47 Slash(String),
49 Restart,
51 Reply(String),
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Proposal {
60 pub label: String,
61 pub kind: ProposalKind,
62}
63
64impl Proposal {
65 pub fn restart(label: impl Into<String>) -> Self {
68 Self {
69 label: label.into(),
70 kind: ProposalKind::Restart,
71 }
72 }
73
74 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 pub fn is_reply(&self) -> bool {
85 matches!(self.kind, ProposalKind::Reply(_))
86 }
87
88 pub fn is_executable(&self) -> bool {
91 matches!(self.kind, ProposalKind::Restart)
92 }
93
94 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#[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
156pub 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 if raw.chars().any(char::is_control) {
181 return Err(VetError::ControlChar("command"));
182 }
183 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
232fn 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;