From e393b5f88b9c2cf74645e184a5d22f12e243c742 Mon Sep 17 00:00:00 2001 From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:30:52 +0200 Subject: [PATCH] Decide agent access per endpoint, not per HTTP method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch beside "share with agents" assumes GET means read and POST means write. For an API where every operation is a POST it is useless: off, and the collection can't be used; on, and there is no guard left. A collection can now carry an access policy instead — a jq filter answering "allow", "ask" or "deny" per endpoint, run against what the manifest already publishes. Every scalar `x-` extension on an OpenAPI operation comes through the loader into `meta`, so a rule reads the API's own vocabulary; Fiber knows nothing about `x-kind` or any other key, and the filter is the one place meaning is attached. "ask" puts the call in front of a person through MCP elicitation, in the client they are already sitting in, and sends it only on accept. Ten minutes, then it is refused. A client with no way to prompt often declares the capability and cancels instantly anyway, so a cancel that arrives too fast to have been read says nobody was asked rather than "denied" — the difference decides whether an agent retries elsewhere or gives up. Everything fails closed. A filter that doesn't compile, throws, fans out or answers something other than the three words denies the whole collection, and a path the catalogue doesn't list is decided with no metadata rather than inheriting a listed endpoint's permission. The editor's preview and the server's answer are one shared function over one shared catalogue, so the two cannot disagree about an endpoint. Collections with no policy keep the old switch, untouched. --- .../access-policies-for-shared-collections.md | 14 + README.md | 32 + deploy/toolhive.md | 5 +- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 75 +++ src-tauri/src/loader.rs | 69 ++ src-tauri/src/mcp.rs | 478 ++++++++++++-- src-tauri/src/openapi.rs | 33 + src-tauri/src/policy.rs | 590 ++++++++++++++++++ src-tauri/src/store.rs | 10 +- src/lib/api.ts | 44 ++ src/lib/components/PolicyEditor.svelte | 192 ++++++ src/lib/components/SectionSettings.svelte | 39 +- tests/e2e/mock-ipc.ts | 14 + tests/e2e/settings.spec.ts | 41 ++ 16 files changed, 1564 insertions(+), 75 deletions(-) create mode 100644 .changeset/access-policies-for-shared-collections.md create mode 100644 src-tauri/src/policy.rs create mode 100644 src/lib/components/PolicyEditor.svelte 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 dce0358..8ac76a2 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 bb0ff1f..ce9e624 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 8552cf0..b780951 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 { @@ -588,6 +610,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)> { @@ -770,6 +843,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 f96bc15..308c6d1 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, 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; /// One pass over the body: find the byte where character `limit + 1` would /// start and cut there. Counting the characters first and *then* collecting @@ -73,13 +96,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 { @@ -91,9 +107,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)] @@ -251,6 +283,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, } @@ -348,39 +381,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 { @@ -511,26 +692,32 @@ 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, } 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(), - }) - .collect(); + }); + } ok_json(&serde_json::json!({ "sections": summaries, - "warnings": warnings.as_ref(), + "warnings": warnings, })) } @@ -547,11 +734,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() @@ -580,6 +772,7 @@ impl FiberMcp { "limit": limit, "nextOffset": next_offset, "truncated": next_offset.is_some(), + "warnings": warnings, })) } @@ -591,6 +784,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() @@ -599,6 +801,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, @@ -630,12 +834,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, @@ -653,6 +860,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() { @@ -660,17 +868,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." @@ -1294,6 +1528,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(), }; @@ -1352,6 +1587,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(), @@ -1363,6 +1602,7 @@ mod tests { mcp: crate::store::McpAccess { enabled, allow_writes, + policy: policy.to_string(), }, requests: vec![SavedRequest { id: "req-1".into(), @@ -1391,6 +1631,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(), } } @@ -1437,6 +1678,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![ @@ -1496,7 +1858,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('/');