Skip to main content

opencode_ctl/
openapi.rs

1//! OpenAPI operation table (v2.0.32 snapshot) and the escape-hatch gate.
2
3use 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
120/// Resolve one level of `$ref` into `components.schemas`.
121fn 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
132/// The `required` property names of an operation's JSON request body schema.
133/// `op_schema` is the operation object (it holds `requestBody`).
134pub 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    /// The `n` ids closest to `id` by Levenshtein distance, ties alphabetical.
337    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
365/// Exact match, or `prefix.*` where the trailing `*` matches the rest of the id.
366pub 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        // A path value is one URL segment. The CLI builds the URL with `new URL(path, base)`, which collapses a
515        // literal `.`/`..` segment, so `inboxID=..` would reach a different (blocked) route.
516        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}