Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/access-policies-for-shared-collections.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion deploy/toolhive.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `"<sectionId>:auth"`.
That exact string is the key you provide below.

Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
75 changes: 75 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ mod http;
mod loader;
pub mod mcp;
mod openapi;
mod policy;
mod secrets;
mod send;
mod store;
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -198,6 +200,26 @@ mod gui {
endpoints: Vec<loader::LoadedEndpoint>,
}

/// 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<PolicyRow>,
/// Set when the policy could not run, in which case every row denies.
warning: Option<String>,
}

#[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 {
Expand Down Expand Up @@ -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<PolicyPreview, store::StoreError> {
let Some(section) = sections.get_or_load(&paths.sections, &section_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, &section_id);
let entries = policy::catalogue(&section, &loaded);
let (accesses, warning) = policy::decide_catalogue(&section, &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)> {
Expand Down Expand Up @@ -785,6 +858,8 @@ mod gui {
default_loader,
parse_openapi,
loader_templates,
policy_preview,
policy_templates,
mcp_clients,
mcp_binary,
mcp_install,
Expand Down
69 changes: 69 additions & 0 deletions src-tauri/src/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, serde_json::Value>,
#[serde(default)]
pub parameters: Vec<crate::openapi::SpecParam>,
/// A JSON body to start from. Derived from the manifest when it is an
Expand All @@ -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,
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading