diff --git a/.changeset/access-policies-for-shared-collections.md b/.changeset/access-policies-for-shared-collections.md new file mode 100644 index 0000000..d584825 --- /dev/null +++ b/.changeset/access-policies-for-shared-collections.md @@ -0,0 +1,14 @@ +--- +'fiber': minor +--- + +Decide what an agent may call per endpoint, not per HTTP method. A shared +collection can carry an access policy — a jq filter answering `"allow"`, `"ask"` +or `"deny"` for each endpoint — which is the only workable guard for an API +where every operation is a POST and the method says nothing about what it does. +The filter reads whatever the manifest publishes: every scalar `x-` extension on +an OpenAPI operation is now carried through the loader, so a rule can be written +against the API's own vocabulary. `"ask"` puts the call in front of you in your +agent's client and sends it only once you approve. Section settings shows the +answers for the collection's real endpoints as you type. Collections without a +policy keep the switch they had, unchanged. diff --git a/README.md b/README.md index da096fb..9bf812e 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,38 @@ for anything more. Turn sharing off and the collection is hidden completely, not just read-only. Credentials are applied on the way out and stripped from everything that comes back. +### When the method says nothing + +That switch assumes GET means read and POST means write. For an API where every +call is a POST it is useless: off, and the API can't be used; on, and there is no +guard left. + +So a collection can carry an **access policy** instead — a jq filter answering +`"allow"`, `"ask"` or `"deny"` per endpoint, reading what the manifest already +publishes about each one. Every scalar `x-` extension on an OpenAPI operation +comes through the loader, so a rule can be written against the API's own words: + +```jq +if .meta["x-kind"] == "query" then "allow" +elif .meta["x-kind"] == "command" then "ask" +else "deny" end +``` + +Fiber knows nothing about `x-kind`, or any other key. The filter is where meaning +is attached, in one place you can read and change. Section settings runs it +against the collection's real endpoints as you type and shows what each one gets. + +`"ask"` puts the call in front of you — in your agent's client, which is where +you are sitting when an agent is working — and sends it only if you approve. +One approval covers one call. If nobody answers within ten minutes, or the +client has no way to show a prompt, the call is refused rather than sent. + +A policy replaces the switch entirely while it is set, GET included, so a read +that returns your whole customer table can say so. Anything it cannot answer +for — including a path the collection doesn't list, which is how an agent would +reach an endpoint nobody reviewed — is denied, as is everything in a collection +whose policy has a mistake in it. + Two tools exist for jq specifically: `loader_manifest` fetches your raw manifest, and `try_loader_filter` tests a filter against it. So you can ask an agent to write a loader filter instead of learning jq first. diff --git a/deploy/toolhive.md b/deploy/toolhive.md index 9c78099..6a21e73 100644 --- a/deploy/toolhive.md +++ b/deploy/toolhive.md @@ -60,7 +60,10 @@ section files. Two things a section needs to be usable over MCP: - `mcp.enabled = true` in its `[mcp]` table. Sharing is off by default; enable it explicitly in Section settings. Add `allowWrites = true` to permit - anything beyond GET/HEAD/OPTIONS. + anything beyond GET/HEAD/OPTIONS, or a `policy` filter to decide per endpoint + (see the README). A policy that answers `"ask"` needs a client that can show + an approval prompt; through a proxy that cannot relay one, `ask` refuses, so + a server nobody is sitting in front of wants `"allow"` and `"deny"` only. - for authenticated sections, a `secretRef` — the app writes `":auth"`. That exact string is the key you provide below. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d412033..419c5ca 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3818,6 +3818,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "url", "uuid", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ef6b406..10069e3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -73,7 +73,7 @@ keyring = { version = "4.1.6", features = ["v1"] } jaq-core = "3.1.0" jaq-std = "3.0.2" jaq-json = "2.0.2" -rmcp = { version = "3.1.4", features = ["server", "macros", "transport-io", "schemars"] } +rmcp = { version = "3.1.4", features = ["server", "macros", "transport-io", "schemars", "elicitation"] } dirs = "6.0.0" schemars = "1.2.2" serde_norway = "0.9.42" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 956d15c..941b08e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ mod http; mod loader; pub mod mcp; mod openapi; +mod policy; mod secrets; mod send; mod store; @@ -42,6 +43,7 @@ mod gui { use crate::loader::{self, LoaderError, LoaderRun}; use crate::mcp; use crate::openapi; + use crate::policy; use crate::secrets::{self, SecretError}; use crate::send::{send_authenticated, send_authenticated_streaming}; use crate::store::{self, Section, StoreError}; @@ -198,6 +200,26 @@ mod gui { endpoints: Vec, } + /// What a policy would do with each of a collection's endpoints, for the + /// editor to show while it is being written. + #[derive(serde::Serialize, Default)] + #[serde(rename_all = "camelCase")] + struct PolicyPreview { + items: Vec, + /// Set when the policy could not run, in which case every row denies. + warning: Option, + } + + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct PolicyRow { + method: String, + path: String, + name: String, + loaded: bool, + access: policy::Access, + } + #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct EndpointSchemas { @@ -603,6 +625,57 @@ mod gui { loader::LoaderConfig::default() } + /// How a policy would answer for every endpoint this collection has. + /// + /// The policy comes from the editor rather than from disk, so the preview + /// follows the keystrokes; the endpoints come from the same shared + /// catalogue the MCP server decides on, so what the preview shows is what + /// an agent will get. Pure jq over data already in hand — no network, and + /// nothing here can send anything. + #[tauri::command] + async fn policy_preview( + paths: State<'_, Paths>, + sections: State<'_, SectionMem>, + mem: State<'_, LoaderMem>, + section_id: String, + policy: String, + ) -> Result { + let Some(section) = sections.get_or_load(&paths.sections, §ion_id)? else { + return Ok(PolicyPreview::default()); + }; + // The saved section decides nothing here; only its endpoints do. + let mut section = (*section).clone(); + section.mcp.policy = policy; + + let loaded = mem.endpoints(&paths.loaders, §ion_id); + let entries = policy::catalogue(§ion, &loaded); + let (accesses, warning) = policy::decide_catalogue(§ion, &entries); + + Ok(PolicyPreview { + items: entries + .into_iter() + .zip(accesses) + .map(|(entry, access)| PolicyRow { + method: entry.method, + path: entry.path, + name: entry.name, + loaded: entry.loaded, + access, + }) + .collect(), + warning, + }) + } + + /// Starting points for a policy, offered in the editor. + #[tauri::command] + fn policy_templates() -> Vec<(String, String)> { + policy::TEMPLATES + .iter() + .map(|(name, query)| (name.to_string(), query.to_string())) + .collect() + } + /// Worked filters for the manifest shapes people actually hit. #[tauri::command] fn loader_templates() -> Vec<(String, String)> { @@ -785,6 +858,8 @@ mod gui { default_loader, parse_openapi, loader_templates, + policy_preview, + policy_templates, mcp_clients, mcp_binary, mcp_install, diff --git a/src-tauri/src/loader.rs b/src-tauri/src/loader.rs index 20f8f7d..95c3d9d 100644 --- a/src-tauri/src/loader.rs +++ b/src-tauri/src/loader.rs @@ -114,6 +114,17 @@ pub struct LoadedEndpoint { pub description: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub tag: String, + /// Whatever the manifest says about this endpoint beyond the fields above: + /// every scalar `x-` extension when the manifest is OpenAPI, plus anything + /// a filter chose to put here for a manifest that isn't. + /// + /// Fiber never reads a key of its own out of this. It exists so an access + /// policy can be written against the vocabulary an API already publishes — + /// `x-kind`, `x-scope`, `deprecated` — instead of Fiber guessing from the + /// HTTP method, which for an API where every call is a POST tells you + /// nothing at all. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub meta: BTreeMap, #[serde(default)] pub parameters: Vec, /// A JSON body to start from. Derived from the manifest when it is an @@ -139,6 +150,7 @@ impl Default for LoadedEndpoint { name: String::new(), description: String::new(), tag: String::new(), + meta: BTreeMap::new(), parameters: Vec::new(), body: String::new(), body_kind: crate::http::BodyKind::Json, @@ -603,6 +615,11 @@ fn enrich_openapi( if endpoint.tag.is_empty() { endpoint.tag = crate::openapi::first_tag(Some(operation)); } + // The filter had the same document and got there first, so a key it + // set stands. Anything it didn't mention comes off the operation. + for (name, value) in crate::openapi::extensions(Some(operation)) { + endpoint.meta.entry(name).or_insert(value); + } if endpoint.parameters.is_empty() { endpoint.parameters = crate::openapi::operation_params(document, item, Some(operation)); } @@ -942,6 +959,58 @@ mod tests { assert_eq!(get.body, ""); } + /// The reason `meta` exists: an API where the HTTP method says nothing puts + /// what it does say in an extension, and Fiber has to carry it without + /// knowing what it means. + #[tokio::test] + async fn operation_extensions_survive_into_the_cache() { + let manifest = r##"{ + "openapi": "3.1.1", + "paths": { + "/customers/search": { + "post": { "operationId": "searchCustomers", "x-kind": "query" } + }, + "/orders": { + "post": { + "operationId": "createOrder", + "x-kind": "command", + "x-internal": true, + "x-owner": { "team": "billing" } + } + } + } + }"##; + + let openapi = TEMPLATES + .iter() + .find(|(name, _)| *name == "OpenAPI") + .unwrap() + .1; + let (endpoints, _, _, _) = run(&config(openapi), answering(manifest)).await.unwrap(); + + let search = endpoints + .iter() + .find(|e| e.path == "/customers/search") + .unwrap(); + assert_eq!(search.meta.get("x-kind"), Some(&serde_json::json!("query"))); + + let orders = endpoints.iter().find(|e| e.path == "/orders").unwrap(); + assert_eq!( + orders.meta.get("x-kind"), + Some(&serde_json::json!("command")) + ); + // Any scalar, not only strings — a policy can compare against `true`. + assert_eq!( + orders.meta.get("x-internal"), + Some(&serde_json::json!(true)) + ); + // An object-valued extension is skipped: this ends up in a cache and in + // every search result, so it is not a place to carry a document. + assert!(!orders.meta.contains_key("x-owner")); + // And nothing that isn't an extension leaks in beside them. + assert!(!orders.meta.contains_key("operationId")); + } + /// A jq filter that already filled in a body used to skip schema extraction /// entirely. The body stays as the filter wrote it; the schema still lands /// in the cache so the editor can validate against the document. diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 491fce5..8a7c117 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -12,7 +12,9 @@ //! - **Sections opt in**, one at a time, and the default is off. A section the //! user hasn't exposed is invisible here — not merely read-only. //! - **Writes opt in separately.** Anything but GET/HEAD/OPTIONS needs a second -//! switch on that section. +//! switch on that section — or, where the HTTP method says nothing useful +//! about what a call does, a policy filter that reads the API's own +//! vocabulary instead. See `policy.rs`. //! - **Credentials never come back out.** Auth headers are redacted from every //! response, so a token can't be laundered through a tool result. //! - **Bodies are truncated**, with a jq filter available to query the rest. @@ -25,8 +27,14 @@ use std::time::{Duration, Instant, UNIX_EPOCH}; use rmcp::handler::server::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; -use rmcp::model::{CallToolResult, ServerCapabilities, ServerInfo}; -use rmcp::{tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler, ServiceExt}; +use rmcp::model::{ + CallToolResult, ElicitRequestParams, ElicitationAction, ElicitationSchema, ServerCapabilities, + ServerInfo, +}; +use rmcp::service::RequestContext; +use rmcp::{ + tool, tool_handler, tool_router, ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, +}; use serde::{Deserialize, Serialize}; use crate::auth::AuthState; @@ -35,6 +43,7 @@ use crate::history::HistoryStore; // two lists would drift, and the one that drifted would be the one that leaks. use crate::http::{redact_with, BodyKind, FormField, Header, HttpError, HttpState, RequestSpec}; use crate::loader; +use crate::policy::{self, is_read_only, Access}; use crate::secrets; use crate::store::{self, Section}; @@ -47,6 +56,20 @@ const MAX_SEARCH_LIMIT: usize = 200; const MANIFEST_CACHE_TTL: Duration = Duration::from_secs(30); const MAX_CONCURRENT_REQUESTS: usize = 16; const MAX_CONCURRENT_LOADERS: usize = 2; +/// How long an approval waits for a person. Long, because the point of asking +/// is that someone reads it, and they may be at lunch; finite, because an agent +/// blocked forever on a prompt nobody will ever see is worse than a refusal. +const APPROVAL_TIMEOUT: Duration = Duration::from_secs(600); +/// Approvals in flight at once. A loop in an agent must not be able to bury the +/// user in prompts — past this it is refused rather than queued. +const MAX_PENDING_APPROVALS: usize = 8; +/// A refusal this fast came from the client itself, not from a person reading +/// it. Not a rule the protocol gives us, but the difference between an error +/// that says "you were denied" and one that says "nobody was asked". +const NOBODY_READ_IT: Duration = Duration::from_millis(250); +/// Enough of a body to recognise the request by, in a dialog someone has to +/// read in a second. +const APPROVAL_BODY_CHARS: usize = 400; /// The message for a send that failed, with the container context the error /// itself cannot carry. @@ -108,13 +131,6 @@ fn require_same_origin(base_url: &str, path: &str) -> Result<(), McpError> { }) } -fn is_read_only(method: &str) -> bool { - matches!( - method.trim().to_ascii_uppercase().as_str(), - "GET" | "HEAD" | "OPTIONS" - ) -} - #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct EndpointSummary { @@ -126,9 +142,25 @@ struct EndpointSummary { name: String, description: String, tag: String, + /// Whatever the manifest published about this endpoint beyond the fields + /// above — `x-` extensions, mostly. Returned because it is what a policy + /// decides on, so an agent can see why it got the answer it got. + #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] + meta: std::collections::BTreeMap, parameters: Vec, /// True when a loader reported this rather than a person writing it. loaded: bool, + /// `allow`, `ask` or `deny` — what `send_request` will do with this one. + /// Knowing in advance beats discovering it by being refused. + access: Access, +} + +/// A collection's endpoints, with whatever went wrong deciding on them. +struct Catalogue { + endpoints: Vec, + /// A policy that failed to run. Everything is denied when this is set, so + /// it has to reach the user rather than only the log. + warning: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -286,6 +318,7 @@ pub struct FiberMcp { request_ids: Arc, requests: Arc, loaders: Arc, + approvals: Arc, #[expect(dead_code, reason = "the tool_handler macro reads this field")] tool_router: ToolRouter, } @@ -383,39 +416,187 @@ impl FiberMcp { }) } - /// Every endpoint an exposed section has, hand-written or loaded. - fn endpoints_of(&self, section: &Section) -> Vec { - let mut found: Vec = section - .requests - .iter() - .map(|request| EndpointSummary { + /// Every endpoint an exposed section has, hand-written or loaded, each with + /// the access it would get. + fn catalogue_of(&self, section: &Section) -> Catalogue { + let cache = self.loader_cache_of(§ion.id); + let entries = policy::catalogue(section, &cache.endpoints); + let (accesses, warning) = policy::decide_catalogue(section, &entries); + + let endpoints = entries + .into_iter() + .zip(accesses) + .map(|(entry, access)| EndpointSummary { section_id: section.id.clone(), section: section.name.clone(), - key: request.id.clone(), - method: request.method.clone(), - path: request.path.clone(), - name: request.name.clone(), - description: request.description.clone(), - tag: request.tag.clone(), - parameters: Vec::new(), - loaded: false, + key: entry.key, + method: entry.method, + path: entry.path, + name: entry.name, + description: entry.description, + tag: entry.tag, + meta: entry.meta, + parameters: entry.parameters, + loaded: entry.loaded, + access, }) .collect(); - let cache = self.loader_cache_of(§ion.id); - found.extend(cache.endpoints.iter().map(|endpoint| EndpointSummary { - section_id: section.id.clone(), - section: section.name.clone(), - key: endpoint.key(), - method: endpoint.method.clone(), - path: endpoint.path.clone(), - name: endpoint.name.clone(), - description: endpoint.description.clone(), - tag: endpoint.tag.clone(), - parameters: endpoint.parameters.clone(), - loaded: true, - })); - found + Catalogue { endpoints, warning } + } + + /// Puts one call in front of a person, and waits. + /// + /// The prompt goes to the MCP client, because that is where whoever asked + /// for the call is sitting — in their agent, not in Fiber's window. It is + /// also the weaker of the two places to ask: the client renders the dialog, + /// so a client that answers on its own behalf answers for the user too. A + /// headless `claude -p` does exactly that, declaring the capability and + /// then cancelling in five milliseconds without showing anyone anything. + /// Which is safe — it cancels rather than accepts — but it is not an + /// approval, and the error has to say so or the agent will keep trying. + /// Fiber's own dialog, for when this cannot reach anybody, comes next. + /// + /// Only `accept` sends the request. Decline, cancel, timeout, a client that + /// can't ask, too many prompts already waiting: all refusals. + async fn approve( + &self, + context: &RequestContext, + section: &Section, + method: &str, + url: &str, + body: Option<&str>, + ) -> Result<(), McpError> { + let peer_info = context.peer.peer_info(); + let client = peer_info + .as_ref() + .map(|info| info.client_info.name.clone()) + .unwrap_or_else(|| "this client".to_string()); + // Absent info means an old client that never told us; try, and let the + // answer decide. A client that told us it cannot is taken at its word. + if peer_info + .as_ref() + .is_some_and(|info| info.capabilities.elicitation.is_none()) + { + return Err(McpError::invalid_params( + format!( + "`{method} {url}` needs a person to approve it, and {client} cannot show an \ + approval prompt. Run it from a client that can, or change this collection's \ + access policy in Fiber." + ), + None, + )); + } + + let Ok(_pending) = self.approvals.clone().try_acquire_owned() else { + return Err(McpError::invalid_params( + format!( + "`{method} {url}` needs approval, and {MAX_PENDING_APPROVALS} approvals are \ + already waiting. Answer those first." + ), + None, + )); + }; + + let mut message = format!( + "Fiber: approve {method} {url}?\n\nCollection: {} ({})", + section.name, section.base_url + ); + if let Some(body) = body.map(str::trim).filter(|body| !body.is_empty()) { + let (preview, cut) = truncate(body, APPROVAL_BODY_CHARS); + message.push_str(&format!( + "\nBody: {preview}{}", + if cut { "… (truncated)" } else { "" } + )); + } + message.push_str( + "\n\nFiber will send this authenticated as you. Approving covers this one call.", + ); + + // An empty form: the question is the message, and accept/decline is the + // whole answer. A field to fill in would only invite a client to fill + // it in. + let params = ElicitRequestParams::FormElicitationParams { + meta: None, + message, + requested_schema: ElicitationSchema::new(Default::default()), + }; + + let asked_at = Instant::now(); + let result = context + .peer + .create_elicitation_with_timeout(params, Some(APPROVAL_TIMEOUT)) + .await; + let waited = asked_at.elapsed(); + + match result { + Ok(reply) if reply.action == ElicitationAction::Accept => Ok(()), + Ok(_) if waited < NOBODY_READ_IT => Err(McpError::invalid_params( + format!( + "`{method} {url}` needs a person to approve it. {client} answered in \ + {}ms, which is too fast for anyone to have read it — it is most likely \ + running without a way to prompt, so nobody was asked. Run it from an \ + interactive session, or change this collection's access policy in Fiber.", + waited.as_millis() + ), + None, + )), + Ok(_) => Err(McpError::invalid_params( + format!("`{method} {url}` was not approved."), + None, + )), + Err(rmcp::service::ServiceError::Timeout { .. }) => Err(McpError::invalid_params( + format!( + "`{method} {url}` needs approval and nobody answered within {} minutes.", + APPROVAL_TIMEOUT.as_secs() / 60 + ), + None, + )), + Err(err) => Err(McpError::invalid_params( + format!("`{method} {url}` needs approval, and asking for it failed: {err}."), + None, + )), + } + } + + /// The access one call gets, and the reason if it is a refusal. + /// + /// `send_request` names a method and a path rather than a catalogue key, so + /// the entry has to be found by matching. Nothing matching is not an error: + /// it is a call with no metadata, which a policy decides on like any other. + fn decide_one(&self, section: &Section, method: &str, path: &str) -> (Access, Option) { + let catalogue = self.catalogue_of(section); + if let Some(entry) = catalogue + .endpoints + .iter() + .find(|entry| policy::same_endpoint(&entry.method, &entry.path, method, path)) + { + return (entry.access, catalogue.warning); + } + + if section.mcp.policy.trim().is_empty() { + let access = if is_read_only(method) || section.mcp.allow_writes { + Access::Allow + } else { + Access::Deny + }; + return (access, None); + } + + let (access, failure) = policy::decide_one( + §ion.mcp.policy, + &policy::Facts { + method, + path, + name: "", + description: "", + tag: "", + meta: &Default::default(), + loaded: false, + known: false, + }, + ); + (access, failure) } fn fetcher(&self, section: &Section) -> loader::Fetcher { @@ -546,6 +727,9 @@ impl FiberMcp { base_url: String, endpoints: usize, allows_writes: bool, + /// Set when the collection decides access per endpoint rather than + /// by HTTP method, in which case `allowsWrites` says nothing. + has_policy: bool, has_loader: bool, /// `"missing"` when the collection is authenticated and this /// process cannot see its credential — a call to it would fail @@ -557,15 +741,18 @@ impl FiberMcp { } let (all, warnings) = self.all_sections()?; - let summaries: Vec = all - .iter() - .filter(|section| section.mcp.enabled) - .map(|section| Summary { + let mut warnings: Vec = warnings.as_ref().clone(); + let mut summaries = Vec::new(); + for section in all.iter().filter(|section| section.mcp.enabled) { + let catalogue = self.catalogue_of(section); + warnings.extend(catalogue.warning); + summaries.push(Summary { id: section.id.clone(), name: section.name.clone(), base_url: section.base_url.clone(), - endpoints: self.endpoints_of(section).len(), + endpoints: catalogue.endpoints.len(), allows_writes: section.mcp.allow_writes, + has_policy: !section.mcp.policy.trim().is_empty(), has_loader: section.loader.is_some(), // `has`, not `get`: presence is the question, and on the // desktop the difference between the two is a password prompt. @@ -574,12 +761,11 @@ impl FiberMcp { .secret_ref() .is_some_and(|reference| !secrets::has(reference)) .then_some("missing"), - }) - .collect(); + }); + } // The per-collection flag says what is wrong; this says why, once, // rather than repeating a paragraph on every entry. - let mut warnings = warnings.as_ref().clone(); if summaries.iter().any(|summary| summary.credential.is_some()) { if let Some(advice) = secrets::injected_source().missing_credential_advice() { warnings.push(advice.to_string()); @@ -605,11 +791,16 @@ impl FiberMcp { .method .as_deref() .map(|method| method.trim().to_uppercase()); + let mut warnings = Vec::new(); let found: Vec = self .exposed()? .iter() .filter(|section| section_filter.is_none_or(|id| section.id == id)) - .flat_map(|section| self.endpoints_of(section)) + .flat_map(|section| { + let catalogue = self.catalogue_of(section); + warnings.extend(catalogue.warning); + catalogue.endpoints + }) .filter(|endpoint| { method_filter .as_deref() @@ -638,6 +829,7 @@ impl FiberMcp { "limit": limit, "nextOffset": next_offset, "truncated": next_offset.is_some(), + "warnings": warnings, })) } @@ -649,6 +841,15 @@ impl FiberMcp { Parameters(args): Parameters, ) -> Result { let section = self.exposed_section(&args.section_id)?; + // The same decision search_endpoints reported, taken the same way, so + // the two can't disagree about one endpoint. + let catalogue = self.catalogue_of(§ion); + let access = catalogue + .endpoints + .iter() + .find(|endpoint| endpoint.key == args.key) + .map(|endpoint| endpoint.access); + if let Some(request) = section .requests .iter() @@ -657,6 +858,8 @@ impl FiberMcp { return ok_json(&serde_json::json!({ "sectionId": section.id, "key": request.id, + "access": access, + "warning": catalogue.warning, "loaded": false, "method": request.method, "path": request.path, @@ -688,12 +891,15 @@ impl FiberMcp { ok_json(&serde_json::json!({ "sectionId": section.id, "key": endpoint.key(), + "access": access, + "warning": catalogue.warning, "loaded": true, "method": endpoint.method, "path": endpoint.path, "name": endpoint.name, "description": endpoint.description, "tag": endpoint.tag, + "meta": endpoint.meta, "headers": [], "body": endpoint.body, "bodyKind": endpoint.body_kind, @@ -711,6 +917,7 @@ impl FiberMcp { async fn send_request( &self, Parameters(args): Parameters, + context: RequestContext, ) -> Result { let section = self.exposed_section(&args.section_id)?; let method = match args.method.trim() { @@ -718,17 +925,43 @@ impl FiberMcp { method => method.to_uppercase(), }; - if !is_read_only(&method) && !section.mcp.allow_writes { - return Err(McpError::invalid_params( - format!( - "`{method}` is not allowed for this collection. Only GET, HEAD and OPTIONS are \ - permitted unless writes are enabled for it in Section settings." - ), - None, - )); - } - + // Before the decision, not after: there is no sense asking someone to + // approve a request that would be refused whatever they said. require_same_origin(§ion.base_url, &args.path)?; + + match self.decide_one(§ion, &method, &args.path) { + (Access::Allow, _) => {} + (Access::Deny, failure) => { + let why = match (failure, section.mcp.policy.trim().is_empty()) { + // A policy that could not run denies everything, and the + // reason is the one thing that leads anywhere. + (Some(failure), _) => format!( + "This collection's access policy could not decide: {failure}. Nothing is \ + permitted until it is fixed in Section settings." + ), + (None, true) => format!( + "`{method}` is not allowed for this collection. Only GET, HEAD and OPTIONS \ + are permitted unless writes are enabled for it in Section settings." + ), + (None, false) => format!( + "`{method} {}` is not allowed for this collection. Its access policy \ + decides per endpoint — search_endpoints reports what each one allows.", + args.path + ), + }; + return Err(McpError::invalid_params(why, None)); + } + (Access::Ask, _) => { + self.approve( + &context, + §ion, + &method, + &store::join_url(§ion.base_url, &args.path), + args.body.as_deref(), + ) + .await?; + } + } if args.body_kind == BodyKind::File { return Err(McpError::invalid_params( "File bodies are not exposed to MCP because a file path could read arbitrary host data." @@ -1420,6 +1653,7 @@ pub async fn serve() -> Result<(), Box> { request_ids: Arc::new(AtomicU64::new(0)), requests: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_REQUESTS)), loaders: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_LOADERS)), + approvals: Arc::new(tokio::sync::Semaphore::new(MAX_PENDING_APPROVALS)), tool_router: FiberMcp::tool_router(), }; @@ -1478,6 +1712,10 @@ mod tests { } fn section(enabled: bool, allow_writes: bool) -> Section { + policed(enabled, allow_writes, "") + } + + fn policed(enabled: bool, allow_writes: bool, policy: &str) -> Section { Section { id: "sec-1".into(), name: "Acme".into(), @@ -1489,6 +1727,7 @@ mod tests { mcp: crate::store::McpAccess { enabled, allow_writes, + policy: policy.to_string(), }, requests: vec![SavedRequest { id: "req-1".into(), @@ -1517,6 +1756,7 @@ mod tests { request_ids: Arc::new(AtomicU64::new(0)), requests: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_REQUESTS)), loaders: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_LOADERS)), + approvals: Arc::new(tokio::sync::Semaphore::new(MAX_PENDING_APPROVALS)), tool_router: FiberMcp::tool_router(), } } @@ -1563,6 +1803,127 @@ mod tests { } } + /// The filter every collection of this shape wants: three POSTs, three + /// answers, taken off what the spec already says about them. + const BY_KIND: &str = r#"if .meta["x-kind"] == "query" then "allow" + elif .meta["x-kind"] == "command" then "ask" + else "deny" end"#; + + fn loaded(dir: &std::path::Path, endpoints: Vec) { + let loaders = dir.join("loaders"); + std::fs::create_dir_all(&loaders).unwrap(); + loader::write_cache( + &loaders, + "sec-1", + &loader::LoaderCache { + loaded_at: 1, + endpoints, + schemas: Default::default(), + response_schemas: Default::default(), + }, + ) + .unwrap(); + } + + fn endpoint(method: &str, path: &str, kind: &str) -> loader::LoadedEndpoint { + loader::LoadedEndpoint { + method: method.into(), + path: path.into(), + name: path.into(), + meta: [("x-kind".to_string(), serde_json::json!(kind))] + .into_iter() + .collect(), + ..Default::default() + } + } + + #[test] + fn a_policy_separates_posts_the_method_cannot() { + let dir = scratch("policy-kinds"); + // Writes are off, which under the old rule would refuse all three. + let acme = policed(true, false, BY_KIND); + store::save(&dir, &acme).unwrap(); + loaded( + &dir, + vec![ + endpoint("POST", "/customers/search", "query"), + endpoint("POST", "/orders", "command"), + endpoint("POST", "/events", "subscription"), + ], + ); + + let mcp = server(&dir); + let by_path = |path: &str| mcp.decide_one(&acme, "POST", path).0; + assert_eq!(by_path("/customers/search"), Access::Allow); + assert_eq!(by_path("/orders"), Access::Ask); + assert_eq!(by_path("/events"), Access::Deny); + + // And the agent can see it coming rather than finding out by refusal. + let catalogue = mcp.catalogue_of(&acme); + assert!(catalogue.warning.is_none()); + assert_eq!( + catalogue + .endpoints + .iter() + .find(|endpoint| endpoint.path == "/orders") + .map(|endpoint| endpoint.access), + Some(Access::Ask) + ); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn a_path_the_collection_never_listed_gets_no_ones_permission() { + let dir = scratch("policy-unknown"); + let acme = policed(true, false, BY_KIND); + store::save(&dir, &acme).unwrap(); + loaded(&dir, vec![endpoint("POST", "/orders/{id}", "query")]); + + let mcp = server(&dir); + // The template it did list, filled in: same endpoint, same answer. + assert_eq!(mcp.decide_one(&acme, "POST", "/orders/42").0, Access::Allow); + // A path underneath it is a different endpoint, and unknown. + assert_eq!( + mcp.decide_one(&acme, "POST", "/orders/42/refund").0, + Access::Deny + ); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn a_policy_that_cannot_run_closes_the_collection() { + let dir = scratch("policy-broken"); + let acme = policed(true, true, "this is not jq"); + store::save(&dir, &acme).unwrap(); + loaded(&dir, vec![endpoint("GET", "/orders", "query")]); + + let mcp = server(&dir); + // Writes are switched on, and it still refuses: a policy that cannot + // answer must not fall back to the rule it replaced. + let (access, why) = mcp.decide_one(&acme, "GET", "/orders"); + assert_eq!(access, Access::Deny); + assert!(why.is_some(), "the reason has to reach the user"); + assert!(mcp.catalogue_of(&acme).warning.is_some()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn a_collection_with_no_policy_keeps_the_old_rule() { + let dir = scratch("policy-absent"); + let acme = section(true, false); + store::save(&dir, &acme).unwrap(); + let mcp = server(&dir); + assert_eq!(mcp.decide_one(&acme, "GET", "/user/42").0, Access::Allow); + assert_eq!(mcp.decide_one(&acme, "POST", "/user/42").0, Access::Deny); + + let writable = policed(true, true, ""); + assert_eq!( + mcp.decide_one(&writable, "POST", "/user/42").0, + Access::Allow + ); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn credentials_never_travel_back() { let headers = vec![ @@ -1622,7 +1983,7 @@ mod tests { .unwrap(); let mcp = server(&dir); - let found = mcp.endpoints_of(&acme); + let found = mcp.catalogue_of(&acme).endpoints; assert_eq!(found.len(), 2); assert!(found.iter().any(|e| e.key == "req-1" && !e.loaded)); assert!(found.iter().any(|e| e.key == "POST /orders" && e.loaded)); diff --git a/src-tauri/src/openapi.rs b/src-tauri/src/openapi.rs index 70f3526..55d0545 100644 --- a/src-tauri/src/openapi.rs +++ b/src-tauri/src/openapi.rs @@ -436,6 +436,39 @@ fn collect_params( } } +/// Every scalar `x-` extension on an operation, kept under the name the +/// document uses. +/// +/// OpenAPI reserves `x-` for extensions, so carrying all of them is following +/// the spec rather than blessing any one vendor's. Nothing here knows what a +/// given key means — `x-kind`, `x-internal`, `x-sla` are all the same to Fiber. +/// A policy filter is where meaning gets attached, which is the only place a +/// person can see it and change it. +/// +/// Scalars only: an object-valued extension can be arbitrarily large, and this +/// ends up in a cache and in every search result. +pub(crate) fn extensions( + operation: Option<&serde_json::Map>, +) -> std::collections::BTreeMap { + operation + .map(|operation| { + operation + .iter() + .filter(|(name, value)| { + name.starts_with("x-") + && matches!( + value, + serde_json::Value::String(_) + | serde_json::Value::Number(_) + | serde_json::Value::Bool(_) + ) + }) + .map(|(name, value)| (name.clone(), value.clone())) + .collect() + }) + .unwrap_or_default() +} + pub(crate) fn first_tag(operation: Option<&serde_json::Map>) -> String { operation .and_then(|operation| operation.get("tags")) diff --git a/src-tauri/src/policy.rs b/src-tauri/src/policy.rs new file mode 100644 index 0000000..e55f5f1 --- /dev/null +++ b/src-tauri/src/policy.rs @@ -0,0 +1,590 @@ +//! What an agent may call, and what needs a person first. +//! +//! Sharing a collection used to have one dial beyond on/off: anything but +//! GET, HEAD and OPTIONS needed a second switch. That works when the HTTP +//! method says what a call does. It says nothing at all about an API where +//! every operation is a POST — the read that lists your customers and the +//! write that refunds them are the same shape, so the switch is either off, +//! and the API is unusable, or on, and there is no guard left. +//! +//! What such an API does publish is its own vocabulary: an `x-kind` on each +//! operation, a `deprecated` flag, a scope. So the dial here is a **jq filter** +//! over that vocabulary, returning `"allow"`, `"ask"` or `"deny"` per endpoint. +//! Nothing in Fiber knows what `x-kind` means; the filter is where meaning is +//! attached, in one place a person can read and change. +//! +//! ```jq +//! if .meta["x-kind"] == "query" then "allow" +//! elif .meta["x-kind"] == "command" then "ask" +//! else "deny" end +//! ``` +//! +//! jq for the same three reasons loaders use it: it cannot do anything but +//! transform, it can be re-run against the real endpoint list as you type, and +//! the people writing this already know it. A second matching language, made up +//! here, would buy nothing and would have to grow every time someone's rule +//! didn't fit. +//! +//! **Everything fails closed.** A filter that doesn't compile, throws, returns +//! a number, or returns nothing denies the call. A policy that is being edited +//! is not an open door. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// jq is not a place to write an essay. +const MAX_POLICY_CHARS: usize = 4_096; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Access { + /// Send it. + Allow, + /// Send it once a person has said so. + Ask, + /// Refuse, and say why. + Deny, +} + +/// What a policy filter is handed. This is a contract: a filter someone wrote +/// against these fields must keep working, so fields are added here and never +/// renamed or removed. +/// +/// `known` is the one that isn't about the endpoint. `send_request` takes a +/// method and a path, not a catalogue entry, so an agent can name a path the +/// collection has never heard of — `POST /orders/17/refund` when the manifest +/// only lists `POST /orders/{id}`. When nothing in the catalogue matches, the +/// call still gets a decision, but with `known: false` and no metadata, so a +/// filter that keys off `.meta` lands in its own `else` branch. Which is the +/// point: an unrecognised path must not be able to inherit a recognised one's +/// permission. +#[derive(Debug, Clone)] +pub struct Facts<'a> { + pub method: &'a str, + pub path: &'a str, + pub name: &'a str, + pub description: &'a str, + pub tag: &'a str, + pub meta: &'a BTreeMap, + /// True when a loader reported this rather than a person typing it. + pub loaded: bool, + /// True when this describes an endpoint the collection actually lists. + pub known: bool, +} + +impl Facts<'_> { + pub fn input(&self) -> serde_json::Value { + serde_json::json!({ + "method": self.method, + "path": self.path, + "name": self.name, + "description": self.description, + "tag": self.tag, + "meta": self.meta, + "loaded": self.loaded, + "known": self.known, + }) + } +} + +/// Runs a policy over a whole endpoint list in one pass. +/// +/// One pass because the filter is compiled per call: deciding five hundred +/// endpoints one at a time would compile the same filter five hundred times. +/// Wrapping it in `[ .[] | ( … ) ]` costs nothing and jq allows a `def` inside +/// the parentheses, so a filter with helpers in it still works. +pub fn decide(policy: &str, endpoints: &[serde_json::Value]) -> Result, String> { + if policy.trim().is_empty() { + return Err("the access policy is empty".to_string()); + } + if policy.len() > MAX_POLICY_CHARS { + return Err(format!( + "the access policy is longer than {MAX_POLICY_CHARS} characters" + )); + } + if endpoints.is_empty() { + return Ok(Vec::new()); + } + + let wrapped = format!("[ .[] | ( {policy} ) ]"); + let input = serde_json::Value::Array(endpoints.to_vec()); + let output = crate::loader::apply(&wrapped, &input).map_err(|err| err.to_string())?; + + let items = output.as_array().ok_or_else(|| { + "the policy produced something other than one answer per endpoint".to_string() + })?; + if items.len() != endpoints.len() { + return Err(format!( + "the policy produced {} answers for {} endpoints — it must return exactly one of \ + \"allow\", \"ask\" or \"deny\" per endpoint", + items.len(), + endpoints.len() + )); + } + + items + .iter() + .map(|item| match item.as_str() { + Some("allow") => Ok(Access::Allow), + Some("ask") => Ok(Access::Ask), + Some("deny") => Ok(Access::Deny), + _ => Err(format!( + "the policy answered {item} — it must return \"allow\", \"ask\" or \"deny\"" + )), + }) + .collect() +} + +/// The method-based rule, which is what a collection with no policy still uses. +pub fn is_read_only(method: &str) -> bool { + matches!( + method.trim().to_ascii_uppercase().as_str(), + "GET" | "HEAD" | "OPTIONS" + ) +} + +/// One endpoint a collection exposes, from either source. +/// +/// Shared so that the editor's preview and the MCP server's answer are the same +/// computation over the same list. Two of these would drift, and the one that +/// drifted would be the one that said "allow". +#[derive(Debug, Clone)] +pub struct Entry { + /// A saved request's id, or `METHOD /path` for a loaded endpoint. + pub key: String, + pub method: String, + pub path: String, + pub name: String, + pub description: String, + pub tag: String, + pub meta: BTreeMap, + pub parameters: Vec, + pub loaded: bool, +} + +impl Entry { + pub fn facts(&self) -> Facts<'_> { + Facts { + method: &self.method, + path: &self.path, + name: &self.name, + description: &self.description, + tag: &self.tag, + meta: &self.meta, + loaded: self.loaded, + known: true, + } + } +} + +/// Everything a collection exposes: what a person typed, then what the loader +/// last reported. +pub fn catalogue( + section: &crate::store::Section, + loaded: &[crate::loader::LoadedEndpoint], +) -> Vec { + let mut entries: Vec = section + .requests + .iter() + .map(|request| Entry { + key: request.id.clone(), + method: request.method.clone(), + path: request.path.clone(), + name: request.name.clone(), + description: request.description.clone(), + tag: request.tag.clone(), + // A typed request has no manifest behind it, so a policy sees it + // with nothing to go on and lands in whatever branch covers that. + meta: BTreeMap::new(), + parameters: Vec::new(), + loaded: false, + }) + .collect(); + + entries.extend(loaded.iter().map(|endpoint| Entry { + key: endpoint.key(), + method: endpoint.method.clone(), + path: endpoint.path.clone(), + name: endpoint.name.clone(), + description: endpoint.description.clone(), + tag: endpoint.tag.clone(), + meta: endpoint.meta.clone(), + parameters: endpoint.parameters.clone(), + loaded: true, + })); + entries +} + +/// One decision per entry, and the reason if nothing is allowed. +/// +/// No policy keeps the rule the collection has always had: read-only unless +/// writes were switched on. A policy replaces that rule outright, GET included +/// — an API where a read can dump the customer table deserves to be able to say +/// so — and a policy that cannot run denies everything rather than falling back +/// to the switch it replaced. +pub fn decide_catalogue( + section: &crate::store::Section, + entries: &[Entry], +) -> (Vec, Option) { + if section.mcp.policy.trim().is_empty() { + let accesses = entries + .iter() + .map(|entry| { + if is_read_only(&entry.method) || section.mcp.allow_writes { + Access::Allow + } else { + Access::Deny + } + }) + .collect(); + return (accesses, None); + } + + let inputs: Vec = + entries.iter().map(|entry| entry.facts().input()).collect(); + match decide(§ion.mcp.policy, &inputs) { + Ok(accesses) => (accesses, None), + Err(message) => ( + vec![Access::Deny; entries.len()], + Some(format!( + "collection `{}` denies everything: its access policy failed — {message}", + section.id + )), + ), + } +} + +/// Starting points, offered in the editor. +/// +/// Read-only leads because it is what a collection already does before anyone +/// writes a policy. The others are worked examples of reading an API's own +/// vocabulary; `x-kind` is one API's word for it, not one Fiber knows. +pub const TEMPLATES: &[(&str, &str)] = &[ + ( + "Read-only", + r#"if .method == "GET" or .method == "HEAD" or .method == "OPTIONS" +then "allow" else "deny" end"#, + ), + ( + "By x-kind", + r#"if .meta["x-kind"] == "query" then "allow" +elif .meta["x-kind"] == "command" then "ask" +elif .loaded | not then "ask" +else "deny" end"#, + ), + ( + "Ask before anything that isn't a read", + r#"if .method == "GET" or .method == "HEAD" or .method == "OPTIONS" +then "allow" else "ask" end"#, + ), + ( + "Nothing deprecated", + r#"if .meta.deprecated == true then "deny" +elif .method == "GET" then "allow" +else "ask" end"#, + ), +]; + +/// The decision for one call, with the reason a failure would have carried. +/// Any failure is a denial: see the module comment. +pub fn decide_one(policy: &str, facts: &Facts) -> (Access, Option) { + match decide(policy, &[facts.input()]) { + Ok(accesses) => (accesses[0], None), + Err(message) => (Access::Deny, Some(message)), + } +} + +/// The path a policy and the catalogue are compared on: query string and +/// fragment removed, and an absolute URL reduced to its path. +/// +/// An absolute URL only reaches here after `join_url_scoped` has established it +/// stays on the collection's own origin, so this is about matching +/// `https://api.example.com/orders/42` to `/orders/{id}` rather than about +/// letting anything through. +pub fn normalize_path(path: &str) -> String { + let path = path.trim(); + let path = if path.starts_with("http://") || path.starts_with("https://") { + match reqwest::Url::parse(path) { + Ok(url) => url.path().to_string(), + Err(_) => path.to_string(), + } + } else { + path.to_string() + }; + path.split(['?', '#']) + .next() + .unwrap_or_default() + .to_string() +} + +/// Does a catalogue entry describe this call? +/// +/// Exact first, then `{name}` placeholders against one path segment each, so +/// `POST /orders/17/refund` finds the `POST /orders/{id}/refund` the manifest +/// listed. A placeholder never matches an empty segment and never spans a `/`: +/// `/orders/{id}` is not `/orders/17/refund`, which would otherwise hand a +/// nested endpoint whatever permission its parent has. +pub fn same_endpoint(entry_method: &str, entry_path: &str, method: &str, path: &str) -> bool { + if !entry_method.trim().eq_ignore_ascii_case(method.trim()) { + return false; + } + let entry_path = normalize_path(entry_path); + let path = normalize_path(path); + if entry_path == path { + return true; + } + + let entry_segments: Vec<&str> = entry_path.split('/').collect(); + let segments: Vec<&str> = path.split('/').collect(); + if entry_segments.len() != segments.len() { + return false; + } + entry_segments + .iter() + .zip(segments.iter()) + .all(|(entry, segment)| { + if entry.starts_with('{') && entry.ends_with('}') && entry.len() > 2 { + !segment.is_empty() + } else { + entry == segment + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn meta(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(key, value)| ((*key).to_string(), serde_json::json!(value))) + .collect() + } + + fn facts<'a>( + method: &'a str, + path: &'a str, + meta: &'a BTreeMap, + known: bool, + ) -> Facts<'a> { + Facts { + method, + path, + name: "", + description: "", + tag: "", + meta, + loaded: known, + known, + } + } + + const KIND: &str = r#"if .meta["x-kind"] == "query" then "allow" + elif .meta["x-kind"] == "command" then "ask" + else "deny" end"#; + + #[test] + fn a_policy_reads_the_apis_own_vocabulary() { + let query = meta(&[("x-kind", "query")]); + let command = meta(&[("x-kind", "command")]); + let subscription = meta(&[("x-kind", "subscription")]); + + // The whole point: three POSTs, three different answers. + assert_eq!( + decide_one(KIND, &facts("POST", "/graphql/read", &query, true)).0, + Access::Allow + ); + assert_eq!( + decide_one(KIND, &facts("POST", "/orders", &command, true)).0, + Access::Ask + ); + assert_eq!( + decide_one(KIND, &facts("POST", "/events", &subscription, true)).0, + Access::Deny + ); + } + + #[test] + fn an_unlisted_path_cannot_borrow_a_listed_ones_permission() { + // No catalogue entry means no metadata, so a filter keyed off `.meta` + // falls to its own else branch rather than inheriting anything. + let none = meta(&[]); + assert_eq!( + decide_one(KIND, &facts("POST", "/orders/17/refund", &none, false)).0, + Access::Deny + ); + } + + #[test] + fn a_broken_policy_denies_rather_than_opens() { + let none = meta(&[]); + for broken in [ + "", + "this is not jq", + r#""maybe""#, + "42", + ".meta | keys | length", + ] { + let (access, reason) = decide_one(broken, &facts("GET", "/x", &none, true)); + assert_eq!(access, Access::Deny, "`{broken}` must deny"); + assert!(reason.is_some(), "`{broken}` must say why"); + } + } + + #[test] + fn every_endpoint_gets_exactly_one_answer() { + let query = meta(&[("x-kind", "query")]); + let command = meta(&[("x-kind", "command")]); + let inputs = vec![ + facts("POST", "/a", &query, true).input(), + facts("POST", "/b", &command, true).input(), + ]; + assert_eq!( + decide(KIND, &inputs).unwrap(), + vec![Access::Allow, Access::Ask] + ); + + // A filter that fans out or swallows an endpoint is a filter whose + // answers no longer line up with the list they were asked about. + assert!(decide(r#""allow", "deny""#, &inputs).is_err()); + assert!(decide(r#"empty"#, &inputs).is_err()); + } + + fn section_with(policy: &str, allow_writes: bool) -> crate::store::Section { + crate::store::Section { + id: "acme".into(), + name: "Acme".into(), + base_url: "https://api.acme.com".into(), + mcp: crate::store::McpAccess { + enabled: true, + allow_writes, + policy: policy.to_string(), + }, + requests: vec![crate::store::SavedRequest { + id: "req-1".into(), + name: "Ping".into(), + method: "GET".into(), + path: "/ping".into(), + ..Default::default() + }], + ..Default::default() + } + } + + fn loaded_endpoint(method: &str, path: &str, kind: &str) -> crate::loader::LoadedEndpoint { + crate::loader::LoadedEndpoint { + method: method.into(), + path: path.into(), + name: path.into(), + meta: [("x-kind".to_string(), serde_json::json!(kind))] + .into_iter() + .collect(), + ..Default::default() + } + } + + /// The editor's preview and the MCP server's answer are this same call, so + /// this is the one that has to be right for the two never to disagree. + #[test] + fn a_catalogue_covers_both_kinds_of_endpoint() { + let section = section_with(KIND, false); + let loaded = vec![ + loaded_endpoint("POST", "/customers/search", "query"), + loaded_endpoint("POST", "/orders", "command"), + ]; + + let entries = catalogue(§ion, &loaded); + assert_eq!(entries.len(), 3, "one typed request and two loaded"); + assert_eq!(entries[0].key, "req-1"); + assert!(!entries[0].loaded); + assert_eq!(entries[1].key, "POST /customers/search"); + assert!(entries[1].loaded); + + let (accesses, warning) = decide_catalogue(§ion, &entries); + assert!(warning.is_none()); + // A typed request carries no metadata, so it lands in the else branch — + // which is why a policy has to say what it wants for those. + assert_eq!(accesses, vec![Access::Deny, Access::Allow, Access::Ask]); + } + + #[test] + fn no_policy_leaves_the_method_rule_in_charge() { + let loaded = vec![loaded_endpoint("POST", "/orders", "command")]; + + let read_only = section_with("", false); + let entries = catalogue(&read_only, &loaded); + assert_eq!( + decide_catalogue(&read_only, &entries).0, + vec![Access::Allow, Access::Deny], + "GET allowed, POST not, exactly as before policies existed" + ); + + let writable = section_with("", true); + assert_eq!( + decide_catalogue(&writable, &entries).0, + vec![Access::Allow, Access::Allow] + ); + } + + #[test] + fn a_broken_policy_denies_the_whole_catalogue() { + let section = section_with("this is not jq", true); + let entries = catalogue(§ion, &[loaded_endpoint("GET", "/orders", "query")]); + let (accesses, warning) = decide_catalogue(§ion, &entries); + assert!(accesses.iter().all(|access| *access == Access::Deny)); + assert!( + warning.is_some_and(|message| message.contains("acme")), + "the collection has to be named, or a warning in a list of them is useless" + ); + } + + #[test] + fn every_template_compiles_and_answers() { + let loaded = vec![ + loaded_endpoint("GET", "/orders", "query"), + loaded_endpoint("POST", "/orders", "command"), + ]; + for (name, filter) in TEMPLATES { + let section = section_with(filter, false); + let entries = catalogue(§ion, &loaded); + let (accesses, warning) = decide_catalogue(§ion, &entries); + assert!(warning.is_none(), "template `{name}` failed: {warning:?}"); + assert_eq!(accesses.len(), entries.len(), "template `{name}`"); + } + } + + #[test] + fn placeholders_match_one_segment_and_no_more() { + assert!(same_endpoint("GET", "/orders/{id}", "get", "/orders/42")); + assert!(same_endpoint( + "POST", + "/orders/{id}/refund", + "POST", + "/orders/42/refund" + )); + assert!(same_endpoint( + "GET", + "/orders/{id}", + "GET", + "/orders/42?full=1" + )); + assert!(same_endpoint( + "GET", + "/orders/{id}", + "GET", + "https://api.example.com/orders/42" + )); + + assert!(!same_endpoint( + "GET", + "/orders/{id}", + "GET", + "/orders/42/refund" + )); + assert!(!same_endpoint("GET", "/orders/{id}", "GET", "/orders/")); + assert!(!same_endpoint("GET", "/orders/{id}", "POST", "/orders/42")); + assert!(!same_endpoint("GET", "/orders", "GET", "/customers")); + } +} diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index 2ebfeff..c977753 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -113,9 +113,17 @@ pub struct McpAccess { /// Whether agents can see this section at all. #[serde(default)] pub enabled: bool, - /// Whether they may use anything but GET, HEAD and OPTIONS. + /// Whether they may use anything but GET, HEAD and OPTIONS. Ignored when + /// `policy` is set — a policy is the whole answer, including for GET. #[serde(default)] pub allow_writes: bool, + /// A jq filter deciding `"allow"`, `"ask"` or `"deny"` per endpoint. Empty + /// leaves the method-based rule above in charge, which is what every + /// collection written before this did, so nothing needs migrating. + /// + /// See `policy.rs` for what the filter is handed and why this is jq. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub policy: String, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/lib/api.ts b/src/lib/api.ts index c038f1e..233d9c8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -244,6 +244,12 @@ export interface LoadedEndpoint { description: string; /** OpenAPI tag, used as a folder. Empty is ungrouped. */ tag?: string; + /** + * What the manifest says about this endpoint beyond the fields above: every + * scalar `x-` extension when it is OpenAPI, plus anything the filter added. + * Fiber never reads a key of its own out of it — an access policy does. + */ + meta?: Record; parameters?: SpecParam[]; /** A JSON body to start from, when the manifest was an OpenAPI document. */ body: string; @@ -272,6 +278,12 @@ export interface McpAccess { enabled: boolean; /** Whether they may use anything but GET, HEAD and OPTIONS. */ allowWrites: boolean; + /** + * A jq filter answering `"allow"`, `"ask"` or `"deny"` per endpoint, for an + * API where the HTTP method doesn't say what a call does. Empty leaves + * `allowWrites` in charge; set, it decides everything, GET included. + */ + policy?: string; } /** A group of requests sharing a base URL. */ @@ -482,6 +494,38 @@ export function loaderTemplates(): Promise<[string, string][]> { return invoke<[string, string][]>('loader_templates'); } +/** What an agent may do with one endpoint. */ +export type Access = 'allow' | 'ask' | 'deny'; + +export interface PolicyRow { + method: string; + path: string; + name: string; + /** True when a loader reported this rather than someone typing it. */ + loaded: boolean; + access: Access; +} + +export interface PolicyPreview { + items: PolicyRow[]; + /** Set when the policy could not run, in which case every row denies. */ + warning?: string | null; +} + +/** + * How a policy would answer for every endpoint a collection has. Pure jq over + * data already on disk — nothing is fetched and nothing is sent — so it can run + * as the filter is typed. + */ +export function policyPreview(sectionId: string, policy: string): Promise { + return invoke('policy_preview', { sectionId, policy }); +} + +/** Starting points for a policy, offered in the editor. */ +export function policyTemplates(): Promise<[string, string][]> { + return invoke<[string, string][]>('policy_templates'); +} + /** Write-only by design: there is no command to read a secret back out. */ export function setSecret(reference: string, value: string): Promise { return invoke('set_secret', { reference, value }); diff --git a/src/lib/components/PolicyEditor.svelte b/src/lib/components/PolicyEditor.svelte new file mode 100644 index 0000000..e58c9f6 --- /dev/null +++ b/src/lib/components/PolicyEditor.svelte @@ -0,0 +1,192 @@ + + +{#if !policy} +

+ An access policy decides per endpoint instead of by HTTP method — for an API where the method + says nothing, because every call is a POST. It is a + + jq filter + + answering "allow", "ask" or + "deny" for each one, reading whatever your manifest publishes + about it. +

+
+ +
+{:else} +
+ Access policy + + {#if templates.length} + { + if (next) section.mcp.policy = next; + }} + > + + {current ?? 'Custom'} + + + + + + {#each templates as [name, filter] (name)} + + {#snippet children({ selected })} + + {name} + {/snippet} + + {/each} + + + + + {/if} + +
+ +
+ +
+ + +
+

+ This collection's endpoints + {#if rows.length} + + {counts.allow} allow + {counts.ask} ask + {counts.deny} deny + + {/if} +

+
+ {#if warning} +

{warning}

+ {:else if rows.length === 0} +

+ No endpoints yet — type some, import a spec, or run a loader. +

+ {:else} + {#each rows as row (row.method + row.path + row.name)} +
+ + {row.method} + + {row.path} + {row.access} +
+ {/each} + {/if} +
+
+ +

+ The policy decides everything while it is set, GET included — a read that returns your whole + customer table can say so. Anything it cannot answer for, including a path the collection + doesn't list, is denied. "ask" puts the call in front of you in + the agent's own client, and only sending it once you say so. +

+{/if} diff --git a/src/lib/components/SectionSettings.svelte b/src/lib/components/SectionSettings.svelte index 7ffe582..ec39cdc 100644 --- a/src/lib/components/SectionSettings.svelte +++ b/src/lib/components/SectionSettings.svelte @@ -17,6 +17,7 @@ import CapturePicker from './CapturePicker.svelte'; import ImportSpec from './ImportSpec.svelte'; import LoaderTab from './LoaderTab.svelte'; + import PolicyEditor from './PolicyEditor.svelte'; import { urlField } from '$lib/urlfield'; interface Props { @@ -434,21 +435,31 @@ Let agents see and call this collection - + + {#if !section.mcp.policy} + -

- Shared read-only by default: an agent can see and call this collection, but only - with GET, HEAD and OPTIONS until you allow more. It's authenticated as you, and - credentials are never returned. Turn the top switch off to hide it entirely — an - unshared collection is invisible, not merely read-only. -

+

+ Shared read-only by default: an agent can see and call this collection, but only + with GET, HEAD and OPTIONS until you allow more. It's authenticated as you, and + credentials are never returned. Turn the top switch off to hide it entirely — an + unshared collection is invisible, not merely read-only. +

+ {/if} + +
+ +
diff --git a/tests/e2e/mock-ipc.ts b/tests/e2e/mock-ipc.ts index 7c66184..5dd84a0 100644 --- a/tests/e2e/mock-ipc.ts +++ b/tests/e2e/mock-ipc.ts @@ -172,6 +172,14 @@ export interface MockOptions { /** What a refresh reports instead. Defaults to `loaded` — no change. */ refreshed?: LoadedEndpoint[]; templates?: [string, string][]; + /** What `policy_templates` offers. Empty hides the picker. */ + policyTemplates?: [string, string][]; + /** What `policy_preview` answers, since the mock cannot run jq. */ + policyPreview?: { + items: { method: string; path: string; name: string; loaded: boolean; access: string }[]; + warning?: string | null; + }; + policyPreviewError?: string; /** Held open so a test can drive the stream itself. See `chunk` below. */ deferSend?: boolean; /** Held open so a test can observe the refresh while it is still running. */ @@ -521,6 +529,12 @@ export async function install(page: Page, options: MockOptions = {}): Promise { await expect(page.getByText('https://pay.example.com/v1', { exact: true })).toBeVisible(); }); + test('an access policy replaces the write switch, and shows what it would do', async ({ + page + }) => { + await install(page, { + sections: [section()], + policyTemplates: [['Read-only', 'if .method == "GET" then "allow" else "deny" end']], + policyPreview: { + items: [ + { method: 'GET', path: '/users', name: 'listUsers', loaded: true, access: 'allow' }, + { method: 'POST', path: '/orders', name: 'createOrder', loaded: true, access: 'ask' } + ] + } + }); + await page.goto('/'); + await openSettings(page); + + const drawer = page.locator('.drawer'); + await expect(drawer.getByLabel('Allow more than GET, HEAD and OPTIONS')).toBeVisible(); + await drawer.getByRole('button', { name: 'Add an access policy' }).click(); + + // The switch and the policy are the same decision, so only one is offered. + await expect(drawer.getByLabel('Allow more than GET, HEAD and OPTIONS')).toHaveCount(0); + // And the answers are shown against the collection's real endpoints. + await expect(drawer.getByText('1 allow')).toBeVisible(); + await expect(drawer.getByText('1 ask')).toBeVisible(); + await expect(drawer.getByText('/orders')).toBeVisible(); + + await drawer.getByRole('button', { name: 'Remove' }).first().click(); + await expect(drawer.getByLabel('Allow more than GET, HEAD and OPTIONS')).toBeVisible(); + }); + + test('a policy that cannot run says so, rather than looking permissive', async ({ page }) => { + await install(page, { + sections: [section({ mcp: { enabled: true, allowWrites: false, policy: 'not jq' } })], + policyPreview: { items: [], warning: 'that filter isn\'t valid jq' } + }); + await page.goto('/'); + await openSettings(page); + await expect(page.locator('.drawer').getByText("that filter isn't valid jq")).toBeVisible(); + }); + test('MCP write access is locked until the collection is shared', async ({ page }) => { await install(page, { sections: [section()] }); await page.goto('/');