1use std::collections::BTreeMap;
34use std::sync::Arc;
35
36use brontes::{
37 Config, ElicitRequest, ElicitRequestParams, ElicitResult, ElicitationAction, ElicitationSchema,
38 InputRequest, InputRequests, InputRequiredResult, Middleware, MiddlewareOutcome, Selector,
39 ToolAnnotations, ToolOutput, selectors,
40};
41
42pub const READ_ONLY: &[&str] = &[
46 "xianyun version",
47 "xianyun status",
48 "xianyun logs",
49 "xianyun doctor",
50 "xianyun config validate",
51 "xianyun config print",
52 "xianyun registry status",
53];
54
55pub const STATE_CHANGING: &[&str] = &[
63 "xianyun up",
64 "xianyun deploy",
65 "xianyun update",
66 "xianyun set-kube-context",
67 "xianyun config migrate",
68 "xianyun registry start",
69 "xianyun registry stop",
70 "xianyun ca inspect",
71];
72
73pub const GATED: &[&str] = &[
78 "xianyun down",
79 "xianyun destroy",
80 "xianyun update-etc-hosts",
81 "xianyun ca trust",
82 "xianyun ca untrust",
83];
84
85pub const EXCLUDED: &[(&str, &str)] = &[
94 (
95 "run",
96 "long-running, interactive reloader supervisor; holds the process until interrupted, which has no sane mapping onto a single request/response tools/call.",
97 ),
98 (
99 "tool",
100 "runs an arbitrary executable named by its first positional argument with access to the cluster -- exactly the glab #8153 shape (an unreviewed command becoming an arbitrary-command-execution primitive). Never allowlist this.",
101 ),
102 (
103 "kubectl",
104 "forwards all trailing arguments verbatim to a real kubectl binary; the flags are not clap-typed (trailing_var_arg), so brontes can't derive a meaningful schema, and an LLM-driven kubectl invocation is its own unreviewed command-execution surface on top of that.",
105 ),
106 (
107 "k9s",
108 "interactive, full-screen terminal UI that needs a live TTY and runs indefinitely; not a request/response fit, and brontes's subprocess exec has no terminal to hand it.",
109 ),
110 (
111 "stern",
112 "streams logs indefinitely until interrupted; a tools/call that never returns blocks the MCP connection for no benefit over just running `xianyun logs` (which IS allowlisted, see READ_ONLY).",
113 ),
114 (
115 "edit-mode-process-supervisor",
116 "hidden internal plumbing `xianyun run` spawns one of per reloader; not a user-facing command (clap's `hide = true` means brontes's own `is_hide_set` filter already drops it from the walked tree, but it's listed here too so the guardrail test's reasoning is complete without needing to know that detail).",
117 ),
118];
119
120const CONFIRM: &str = "confirm";
123
124fn consequence_for(path: &str) -> &'static str {
127 match path {
128 "xianyun down" => {
129 "This stops the local cluster. Deployed state is preserved and `xianyun up` will bring it back."
130 }
131 "xianyun destroy" => {
132 "This destroys the local cluster AND DELETES ALL ITS DATA. This cannot be undone."
133 }
134 "xianyun update-etc-hosts" => {
135 "This rewrites /etc/hosts to add or remove cluster hostnames, typically requiring elevated privileges."
136 }
137 "xianyun ca trust" => {
138 "This installs the Caproni dev CA certificate into the system trust store, typically under sudo."
139 }
140 "xianyun ca untrust" => {
141 "This removes the Caproni dev CA certificate from the system trust store, typically under sudo."
142 }
143 _ => "This performs a destructive or privileged action.",
144 }
145}
146
147pub fn middleware() -> Middleware {
157 Arc::new(|ctx, next| {
158 Box::pin(async move {
159 let Some(answers) = ctx.input_responses.as_ref() else {
160 let mut requests = InputRequests::new();
161 requests.insert(
162 CONFIRM.to_owned(),
163 InputRequest::Elicitation(ElicitRequest::new(
164 ElicitRequestParams::FormElicitationParams {
165 meta: None,
166 message: format!(
167 "Really run `{}`? {}",
168 ctx.tool_name,
169 consequence_for(&path_for_tool_name(&ctx.tool_name))
170 ),
171 requested_schema: ElicitationSchema::new(BTreeMap::new()),
172 },
173 )),
174 );
175 return Ok(InputRequiredResult::new(Some(requests), None).into());
176 };
177
178 let accepted = answers
179 .get(CONFIRM)
180 .cloned()
181 .and_then(|v| serde_json::from_value::<ElicitResult>(v).ok())
182 .is_some_and(|r| r.action == ElicitationAction::Accept);
183
184 if accepted {
185 next(ctx).await
186 } else {
187 Ok(MiddlewareOutcome::Complete(ToolOutput {
188 stdout: String::new(),
189 stderr: "aborted at the user's request\n".to_owned(),
190 exit_code: 1,
191 }))
192 }
193 })
194 })
195}
196
197fn path_for_tool_name(tool_name: &str) -> String {
204 tool_name.replace('_', " ")
205}
206
207fn read_only_annotation() -> ToolAnnotations {
209 ToolAnnotations {
210 title: None,
211 read_only_hint: Some(true),
212 destructive_hint: Some(false),
213 idempotent_hint: Some(true),
214 open_world_hint: Some(false),
215 }
216}
217
218fn state_changing_annotation() -> ToolAnnotations {
220 ToolAnnotations {
221 title: None,
222 read_only_hint: Some(false),
223 destructive_hint: Some(false),
224 idempotent_hint: Some(true),
225 open_world_hint: Some(false),
226 }
227}
228
229fn gated_annotation() -> ToolAnnotations {
231 ToolAnnotations {
232 title: None,
233 read_only_hint: Some(false),
234 destructive_hint: Some(true),
235 idempotent_hint: Some(true),
236 open_world_hint: Some(false),
237 }
238}
239
240#[must_use]
244pub fn build() -> Config {
245 let mut cfg = Config::default()
246 .selector(Selector {
247 cmd: Some(selectors::allow_cmds(READ_ONLY.iter().copied())),
248 ..Default::default()
249 })
250 .selector(Selector {
251 cmd: Some(selectors::allow_cmds(STATE_CHANGING.iter().copied())),
252 ..Default::default()
253 })
254 .selector(Selector {
255 cmd: Some(selectors::allow_cmds(GATED.iter().copied())),
256 middleware: Some(middleware()),
257 ..Default::default()
258 });
259
260 for path in READ_ONLY {
261 cfg = cfg.annotation(*path, read_only_annotation());
262 }
263 for path in STATE_CHANGING {
264 cfg = cfg.annotation(*path, state_changing_annotation());
265 }
266 for path in GATED {
267 cfg = cfg.annotation(*path, gated_annotation());
268 }
269
270 cfg
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn allowlist_tiers_are_disjoint() {
279 let mut seen = std::collections::HashSet::new();
280 for path in READ_ONLY.iter().chain(STATE_CHANGING).chain(GATED) {
281 assert!(seen.insert(*path), "{path} listed in more than one tier");
282 }
283 }
284
285 #[test]
286 fn excluded_reasons_are_non_empty() {
287 for (name, reason) in EXCLUDED {
288 assert!(!reason.is_empty(), "{name} has no exclusion reason");
289 }
290 }
291
292 #[test]
293 fn build_does_not_panic() {
294 let _ = build();
295 }
296
297 #[test]
298 fn path_for_tool_name_round_trips_simple_cases() {
299 assert_eq!(path_for_tool_name("xianyun_status"), "xianyun status");
300 assert_eq!(path_for_tool_name("xianyun_ca_trust"), "xianyun ca trust");
301 }
302}