1use std::collections::BTreeMap;
4
5use serde_json::{json, Value};
6
7use crate::error::{ErrorCode, ToolError};
8
9pub const SNAPSHOT_VERSION: &str = "2.0.32";
10
11pub const MUTATION_ALLOW_LIST: [&str; 7] = [
12 "session.compact",
13 "session.switchAgent",
14 "session.switchModel",
15 "session.view",
16 "session.inbox.cancel",
17 "session.inbox.update",
18 "session.form.cancel",
19];
20
21pub const GET_DENY_LIST: [&str; 13] = [
22 "server.connect",
23 "fs.*",
24 "session.log",
25 "event.subscribe",
26 "shell.*",
27 "pty.*",
28 "persistentPty.*",
29 "server.experimental.persistentPty.*",
30 "credential.*",
31 "config.*",
32 "mcp.*",
33 "integration.*",
34 "experimental.session.export",
35];
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Method {
39 Get,
40 Post,
41 Put,
42 Patch,
43 Delete,
44}
45
46impl Method {
47 pub fn as_str(self) -> &'static str {
48 match self {
49 Method::Get => "GET",
50 Method::Post => "POST",
51 Method::Put => "PUT",
52 Method::Patch => "PATCH",
53 Method::Delete => "DELETE",
54 }
55 }
56
57 fn parse(s: &str) -> Option<Method> {
58 match s {
59 "GET" => Some(Method::Get),
60 "POST" => Some(Method::Post),
61 "PUT" => Some(Method::Put),
62 "PATCH" => Some(Method::Patch),
63 "DELETE" => Some(Method::Delete),
64 _ => None,
65 }
66 }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum ParamIn {
71 Path,
72 Query,
73}
74
75impl ParamIn {
76 fn as_str(self) -> &'static str {
77 match self {
78 ParamIn::Path => "path",
79 ParamIn::Query => "query",
80 }
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct ParamDecl {
86 pub name: String,
87 pub location: ParamIn,
88}
89
90#[derive(Debug, Clone, PartialEq)]
91pub struct Operation {
92 pub id: String,
93 pub method: Method,
94 pub path: String,
95 pub params: Vec<ParamDecl>,
96 pub success_status: u16,
97 pub json_response: bool,
98 pub summary: String,
99 pub body_required: Vec<String>,
100}
101
102impl Operation {
103 pub fn mutating(&self) -> bool {
104 self.method != Method::Get
105 }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum TableSource {
110 Live,
111 Snapshot,
112}
113
114#[derive(Debug, Clone)]
115pub struct OperationTable {
116 pub source: TableSource,
117 ops: BTreeMap<String, Operation>,
118}
119
120fn resolve_ref<'a>(schema: &'a Value, doc: &'a Value) -> &'a Value {
122 if let Some(r) = schema.get("$ref").and_then(Value::as_str) {
123 if let Some(name) = r.strip_prefix("#/components/schemas/") {
124 if let Some(target) = doc.pointer("/components/schemas").and_then(|s| s.get(name)) {
125 return target;
126 }
127 }
128 }
129 schema
130}
131
132pub fn body_required_of(op_schema: &Value, doc: &Value) -> Vec<String> {
135 let Some(schema) = op_schema.pointer("/requestBody/content/application~1json/schema") else {
136 return Vec::new();
137 };
138 resolve_ref(schema, doc)
139 .get("required")
140 .and_then(Value::as_array)
141 .map(|a| {
142 a.iter()
143 .filter_map(|v| v.as_str().map(str::to_string))
144 .collect()
145 })
146 .unwrap_or_default()
147}
148
149fn parse_operation(
150 path: &str,
151 method: Method,
152 op: &Value,
153 doc: &Value,
154) -> Result<Operation, String> {
155 let id = op
156 .get("operationId")
157 .and_then(Value::as_str)
158 .ok_or_else(|| format!("{} {path}: no operationId", method.as_str()))?
159 .to_string();
160 let mut params = Vec::new();
161 for p in op
162 .get("parameters")
163 .and_then(Value::as_array)
164 .into_iter()
165 .flatten()
166 {
167 let name = p
168 .get("name")
169 .and_then(Value::as_str)
170 .ok_or_else(|| format!("{id}: parameter without name"))?;
171 let loc = p
172 .get("in")
173 .and_then(Value::as_str)
174 .ok_or_else(|| format!("{id}: parameter {name} without location"))?;
175 let location = match loc {
176 "path" => ParamIn::Path,
177 "query" => ParamIn::Query,
178 _ => continue,
179 };
180 params.push(ParamDecl {
181 name: name.to_string(),
182 location,
183 });
184 }
185 let responses = op
186 .get("responses")
187 .and_then(Value::as_object)
188 .ok_or_else(|| format!("{id}: no responses"))?;
189 let (status, resp) = responses
190 .iter()
191 .filter_map(|(k, v)| {
192 k.parse::<u16>()
193 .ok()
194 .filter(|s| (200..300).contains(s))
195 .map(|s| (s, v))
196 })
197 .min_by_key(|(s, _)| *s)
198 .ok_or_else(|| format!("{id}: no 2xx response"))?;
199 let json_response = status == 200 && resp.pointer("/content/application~1json").is_some();
200 Ok(Operation {
201 id,
202 method,
203 path: path.to_string(),
204 params,
205 success_status: status,
206 json_response,
207 summary: op
208 .get("summary")
209 .and_then(Value::as_str)
210 .unwrap_or("")
211 .to_string(),
212 body_required: body_required_of(op, doc),
213 })
214}
215
216fn levenshtein(a: &str, b: &str) -> usize {
217 let a: Vec<char> = a.chars().collect();
218 let b: Vec<char> = b.chars().collect();
219 let mut prev: Vec<usize> = (0..=b.len()).collect();
220 for (i, ca) in a.iter().enumerate() {
221 let mut cur = vec![i + 1];
222 for (j, cb) in b.iter().enumerate() {
223 let sub = prev[j] + usize::from(ca != cb);
224 cur.push(sub.min(prev[j + 1] + 1).min(cur[j] + 1));
225 }
226 prev = cur;
227 }
228 prev[b.len()]
229}
230
231impl OperationTable {
232 pub fn from_openapi(doc: &Value, source: TableSource) -> Result<Self, String> {
233 let paths = doc
234 .get("paths")
235 .and_then(Value::as_object)
236 .ok_or("document has no paths")?;
237 let mut ops = BTreeMap::new();
238 for (path, item) in paths {
239 let Some(item) = item.as_object() else {
240 continue;
241 };
242 for (m, op) in item {
243 let Some(method) = Method::parse(&m.to_ascii_uppercase()) else {
244 continue;
245 };
246 let op = parse_operation(path, method, op, doc)?;
247 if ops.contains_key(&op.id) {
248 return Err(format!("duplicate operationId {}", op.id));
249 }
250 ops.insert(op.id.clone(), op);
251 }
252 }
253 Ok(Self { source, ops })
254 }
255
256 pub fn snapshot() -> Self {
257 let v: Value = serde_json::from_str(include_str!("openapi.table.json"))
258 .expect("snapshot table is valid JSON");
259 Self::from_json(&v, TableSource::Snapshot).expect("snapshot table is well formed")
260 }
261
262 fn from_json(v: &Value, source: TableSource) -> Result<Self, String> {
263 let list = v
264 .get("operations")
265 .and_then(Value::as_array)
266 .ok_or("no operations array")?;
267 let mut ops = BTreeMap::new();
268 for o in list {
269 let s = |k: &str| {
270 o.get(k)
271 .and_then(Value::as_str)
272 .map(str::to_string)
273 .ok_or_else(|| format!("operation field {k} missing"))
274 };
275 let id = s("id")?;
276 let method = Method::parse(&s("method")?).ok_or("bad method")?;
277 let mut params = Vec::new();
278 for p in o
279 .get("params")
280 .and_then(Value::as_array)
281 .ok_or("no params")?
282 {
283 let name = p
284 .get("name")
285 .and_then(Value::as_str)
286 .ok_or("param name")?
287 .to_string();
288 let location = match p.get("in").and_then(Value::as_str) {
289 Some("path") => ParamIn::Path,
290 Some("query") => ParamIn::Query,
291 _ => return Err("bad param location".into()),
292 };
293 params.push(ParamDecl { name, location });
294 }
295 let body_required = o
296 .get("body_required")
297 .and_then(Value::as_array)
298 .ok_or("no body_required")?
299 .iter()
300 .filter_map(|v| v.as_str().map(str::to_string))
301 .collect();
302 let op = Operation {
303 id: id.clone(),
304 method,
305 path: s("path")?,
306 params,
307 success_status: o
308 .get("success")
309 .and_then(Value::as_u64)
310 .ok_or("no success")? as u16,
311 json_response: o.get("json").and_then(Value::as_bool).ok_or("no json")?,
312 summary: s("summary")?,
313 body_required,
314 };
315 ops.insert(id, op);
316 }
317 Ok(Self { source, ops })
318 }
319
320 pub fn get(&self, id: &str) -> Option<&Operation> {
321 self.ops.get(id)
322 }
323
324 pub fn iter(&self) -> impl Iterator<Item = &Operation> {
325 self.ops.values()
326 }
327
328 pub fn len(&self) -> usize {
329 self.ops.len()
330 }
331
332 pub fn is_empty(&self) -> bool {
333 self.ops.is_empty()
334 }
335
336 pub fn nearest(&self, id: &str, n: usize) -> Vec<String> {
338 let mut scored: Vec<(usize, &String)> =
339 self.ops.keys().map(|k| (levenshtein(id, k), k)).collect();
340 scored.sort();
341 scored.into_iter().take(n).map(|(_, k)| k.clone()).collect()
342 }
343
344 pub fn to_json(&self) -> Value {
345 let ops: Vec<Value> = self
346 .ops
347 .values()
348 .map(|o| {
349 json!({
350 "id": o.id,
351 "method": o.method.as_str(),
352 "path": o.path,
353 "params": o.params.iter().map(|p| json!({"name": p.name, "in": p.location.as_str()})).collect::<Vec<_>>(),
354 "success": o.success_status,
355 "json": o.json_response,
356 "summary": o.summary,
357 "body_required": o.body_required,
358 })
359 })
360 .collect();
361 json!({ "version": SNAPSHOT_VERSION, "operations": ops })
362 }
363}
364
365pub fn glob_matches(pattern: &str, id: &str) -> bool {
367 match pattern.strip_suffix('*') {
368 Some(prefix) => id.starts_with(prefix),
369 None => pattern == id,
370 }
371}
372
373pub struct GateInput<'a> {
374 pub live: Option<&'a OperationTable>,
375 pub snapshot: &'a OperationTable,
376 pub id: &'a str,
377 pub allow_mutation: bool,
378 pub locked: bool,
379 pub server_version: Option<&'a str>,
380}
381
382pub fn gate(input: GateInput) -> Result<(Operation, TableSource), ToolError> {
383 let id = input.id;
384 if id.is_empty() || id.contains('/') || id.chars().any(char::is_whitespace) {
385 return Err(ToolError::new(
386 ErrorCode::InvalidArgument,
387 "operation ids only, not a method or path",
388 )
389 .hint("pass an operationId such as session.get"));
390 }
391 let in_snapshot = input.snapshot.get(id);
392 let (op, source) = match input.live {
393 Some(live) => match live.get(id) {
394 Some(op) => {
395 if in_snapshot.is_none() {
396 return Err(ToolError::new(
397 ErrorCode::BlockedOperation,
398 format!("operation {id} is unknown to the bundled snapshot"),
399 )
400 .details(json!({"reason": "unknown_to_snapshot"})));
401 }
402 (op, TableSource::Live)
403 }
404 None if in_snapshot.is_some() => {
405 return Err(ToolError::new(
406 ErrorCode::UnknownOperation,
407 format!("unknown operation {id}"),
408 )
409 .hint(&format!(
410 "not in the live document (server {})",
411 input.server_version.unwrap_or("unknown")
412 )));
413 }
414 None => return Err(unknown(live, id)),
415 },
416 None => match in_snapshot {
417 Some(op) => (op, TableSource::Snapshot),
418 None => return Err(unknown(input.snapshot, id)),
419 },
420 };
421 if !op.mutating() {
422 if GET_DENY_LIST.iter().any(|p| glob_matches(p, id)) {
423 return Err(blocked(id, "read denied by the escape-hatch deny-list"));
424 }
425 } else {
426 if source == TableSource::Snapshot {
427 return Err(ToolError::new(
428 ErrorCode::ServiceDown,
429 "live document needed before a mutation",
430 )
431 .hint("start the OpenCode service and retry"));
432 }
433 if !MUTATION_ALLOW_LIST.contains(&id) {
434 return Err(blocked(id, "mutation not on the escape-hatch allow-list"));
435 }
436 if id == "session.switchAgent" && input.locked {
437 return Err(ToolError::new(
438 ErrorCode::PermissionsLocked,
439 "permissions are locked; switching agent is refused",
440 ));
441 }
442 if !input.allow_mutation {
443 return Err(ToolError::new(
444 ErrorCode::MutationBlocked,
445 format!("{id} mutates state; pass allow_mutation to run it"),
446 ));
447 }
448 }
449 if !(op.success_status == 204 || (op.success_status == 200 && op.json_response)) {
450 return Err(ToolError::new(
451 ErrorCode::NonJsonResponse,
452 format!("{id} does not return JSON"),
453 ));
454 }
455 Ok((op.clone(), source))
456}
457
458fn unknown(table: &OperationTable, id: &str) -> ToolError {
459 ToolError::new(
460 ErrorCode::UnknownOperation,
461 format!("unknown operation {id}"),
462 )
463 .hint(&format!("nearest: {}", table.nearest(id, 3).join(", ")))
464}
465
466fn blocked(id: &str, why: &str) -> ToolError {
467 ToolError::new(ErrorCode::BlockedOperation, format!("{id}: {why}"))
468}
469
470fn path_segment_ok(v: &str) -> bool {
471 v != "."
472 && v != ".."
473 && !v.chars().any(|c| {
474 c.is_control() || c.is_whitespace() || matches!(c, '/' | '\\' | '?' | '#' | '%')
475 })
476}
477
478pub fn validate_params(op: &Operation, params: &[(String, String)]) -> Result<(), ToolError> {
479 let bad = |m: String| ToolError::new(ErrorCode::InvalidArgument, m);
480 for (name, value) in params {
481 if name.is_empty() || value.is_empty() {
482 return Err(bad("param name and value must be non-empty".into()));
483 }
484 if value.contains('\0') || value.contains('\n') {
485 return Err(bad(format!(
486 "param {name}: value must not contain NUL or newline"
487 )));
488 }
489 if name.contains('\0') || name.contains('\n') {
490 return Err(bad("param name must not contain NUL or newline".into()));
491 }
492 let base = name.split('[').next().unwrap_or(name);
493 if base.is_empty() {
494 return Err(bad("param name must not be empty before `[`".into()));
495 }
496 if base.eq_ignore_ascii_case("header")
497 || base.eq_ignore_ascii_case("-H")
498 || name.starts_with('-')
499 {
500 return Err(bad("headers cannot be set".into()));
501 }
502 let Some(decl) = op.params.iter().find(|p| p.name == base) else {
503 return Err(
504 bad(format!("param {base} is not declared by {}", op.id)).hint(&format!(
505 "declared: {}",
506 op.params
507 .iter()
508 .map(|p| p.name.as_str())
509 .collect::<Vec<_>>()
510 .join(", ")
511 )),
512 );
513 };
514 if decl.location == ParamIn::Path && !path_segment_ok(value) {
517 return Err(bad(format!(
518 "param {base}: a path value must be a single plain segment (not `.`/`..`, and without `/`, `\\`, `?`, `#`, `%` or whitespace)"
519 )));
520 }
521 }
522 Ok(())
523}
524
525pub fn validate_session_id(id: &str) -> Result<(), ToolError> {
526 let ok = id.len() == 30
527 && id.starts_with("ses_")
528 && id[4..].bytes().all(|b| b.is_ascii_alphanumeric());
529 if ok {
530 Ok(())
531 } else {
532 Err(ToolError::new(
533 ErrorCode::SessionNotFound,
534 format!("not a session id: {id}"),
535 )
536 .hint("session ids look like ses_ followed by 26 letters or digits"))
537 }
538}
539
540pub fn validate_pair_id(id: &str) -> Result<(), ToolError> {
541 if id.len() == 12 && id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
542 Ok(())
543 } else {
544 Err(
545 ToolError::new(ErrorCode::InvalidArgument, format!("not a pair id: {id}"))
546 .hint("pair ids are 12 lowercase hex characters"),
547 )
548 }
549}