From 151d58fab23b67207214c0999c88a3b0bdd88aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20S=C3=BAkup?= Date: Sat, 8 Aug 2026 16:59:24 +0200 Subject: [PATCH 1/2] feat(core): add product and field discovery endpoints to the client create_bug takes a fixed parameter set and has no way to learn which products/components/versions exist or which fields a product requires on entry, so a product with a mandatory custom field cannot be filed into at all, and nothing on the client surface answers "which products may I file into" or "what are this field's legal values". update_bug_fields already has the cf_* update machinery; the create side had no discovery equivalent to point an operator or an LLM client at what create_bug expects. Add three read-only BugzillaClient methods mirroring stock Bugzilla Core v1 routes: enterable_product_ids (GET /rest/product_enterable), products (GET /rest/product, ids/names/include_fields all optional), and bug_fields (GET /rest/field/bug[/{name}]). Each follows the existing client conventions: get_json's auth and .without_url() sanitization (I12), and a missing/malformed envelope is an error, never a silently empty result (the valid_login precedent). product_enterable's documented example encodes ids as strings, but a live probe against bugzilla.mozilla.org returned JSON numbers instead -- both shapes are accepted. bug_fields percent-encodes a caller-supplied field name as a single URL path segment via Url::path_segments_mut, rather than interpolating it into a format string, so an embedded '/' cannot address a different endpoint. Verified live against bugzilla.mozilla.org: all three routes (product_enterable, product, field/bug and field/bug/{name}) behave as documented. --- crates/bugwarden-core/src/client.rs | 94 +++++++++ .../tests/discovery_wiremock.rs | 199 ++++++++++++++++++ docs/DESIGN.md | 6 + 3 files changed, 299 insertions(+) create mode 100644 crates/bugwarden-core/tests/discovery_wiremock.rs diff --git a/crates/bugwarden-core/src/client.rs b/crates/bugwarden-core/src/client.rs index 1d06522..2748336 100644 --- a/crates/bugwarden-core/src/client.rs +++ b/crates/bugwarden-core/src/client.rs @@ -412,6 +412,73 @@ impl BugzillaClient { .filter(|att| !att.is_null())) } + /// GET `/rest/product_enterable` — ids of the products the caller's key + /// may file a bug into. + /// + /// Bugzilla's documented example encodes ids as strings; some + /// deployments return JSON numbers instead. Both are accepted; any + /// other shape is an error, never a silently dropped entry. + pub async fn enterable_product_ids(&self, key: &str) -> Result> { + let v = self.get_json(key, "/product_enterable", &[]).await?; + let ids = v.get("ids").and_then(Value::as_array).ok_or_else(|| { + anyhow!("bugzilla /product_enterable response carries no usable \"ids\" array") + })?; + ids.iter() + .map(|id| { + parse_id(id).ok_or_else(|| { + anyhow!("bugzilla /product_enterable response contains a non-numeric id") + }) + }) + .collect() + } + + /// GET `/rest/product?ids=..&names=..[&include_fields=..]` — returns the + /// whole response envelope (`{"products":[..]}`). `ids`/`names` are + /// independently optional; Bugzilla accepts either, both, or neither + /// (neither means "every accessible product"). + pub async fn products( + &self, + key: &str, + ids: &[u64], + names: &[&str], + include_fields: Option<&[&str]>, + ) -> Result { + let mut query: Vec<(&str, String)> = Vec::new(); + for id in ids { + query.push(("ids", id.to_string())); + } + for name in names { + query.push(("names", (*name).to_string())); + } + if let Some(fields) = include_fields { + query.push(("include_fields", fields.join(","))); + } + self.get_json(key, "/product", &query).await + } + + /// GET `/rest/field/bug[/{name}]` — returns the whole response envelope + /// (`{"fields":[..]}`), every field when `name` is `None`. + /// + /// `name` is a caller-supplied string, so it is percent-encoded as a + /// single URL path segment (`Url::path_segments_mut`) rather than + /// interpolated into a format string: an unescaped `/` in `name` must + /// not be able to address a different endpoint. + pub async fn bug_fields(&self, key: &str, name: Option<&str>) -> Result { + match name { + Some(n) => { + let mut url = reqwest::Url::parse(&self.api_url) + .map_err(|e| anyhow!("bugzilla api_url is not a valid URL: {e}"))?; + url.path_segments_mut() + .map_err(|()| anyhow!("bugzilla api_url cannot be a base for path segments"))? + .push("field") + .push("bug") + .push(n); + self.get_json_url(key, url).await + } + None => self.get_json(key, "/field/bug", &[]).await, + } + } + /// GET `{base_url}/page.cgi?id=quicksearch.html` — the quicksearch /// syntax documentation page. This is a plain HTML page, not a REST /// endpoint, and needs no authentication: no API key is attached. @@ -467,6 +534,22 @@ impl BugzillaClient { parse_response(status, &body) } + /// Authenticated GET of a full, already-built URL — for callers that + /// must percent-encode a caller-supplied path segment + /// (`Url::path_segments_mut`) instead of interpolating it into a + /// format string. Logs the URL's path only, same as [`Self::get_json`] + /// (I12). + async fn get_json_url(&self, key: &str, url: reqwest::Url) -> Result { + let path = url.path().to_string(); + let rb = self + .http + .get(url) + .header(reqwest::header::ACCEPT, "application/json"); + let rb = self.apply_auth(rb, key); + let (status, body) = self.send(rb, "GET", &path).await?; + parse_response(status, &body) + } + /// Authenticated POST/PUT of a JSON payload to a REST path, returning /// the parsed JSON body. async fn send_json_body( @@ -495,6 +578,17 @@ fn sanitize(e: reqwest::Error) -> anyhow::Error { anyhow::Error::new(e.without_url()) } +/// Parse a Bugzilla-reported id that may be a JSON number or a numeric +/// string — `product_enterable`'s documented example encodes them as +/// strings, some deployments as numbers. +fn parse_id(v: &Value) -> Option { + match v { + Value::Number(n) => n.as_u64(), + Value::String(s) => s.parse().ok(), + _ => None, + } +} + /// Fail on HTTP-level errors: a non-2xx status, or a Bugzilla error body /// (`{"error": true, ...}`) even under a 200 status. The error text carries /// the HTTP status and the Bugzilla `message` field when present — never the diff --git a/crates/bugwarden-core/tests/discovery_wiremock.rs b/crates/bugwarden-core/tests/discovery_wiremock.rs new file mode 100644 index 0000000..7847f8c --- /dev/null +++ b/crates/bugwarden-core/tests/discovery_wiremock.rs @@ -0,0 +1,199 @@ +//! HTTP-level integration tests for the discovery client endpoints +//! (wiremock): `enterable_product_ids`, `products`, `bug_fields`. Covers the +//! documented envelope shapes, a malformed envelope, and that no error text +//! contains the API key (I12). + +use bugwarden_core::client::BugzillaClient; +use serde_json::json; +use wiremock::matchers::{method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Deliberately distinctive so a leak into any error text is unmistakable (I12). +const KEY: &str = "SUPERSECRETKEY123"; + +/// Any identity will do here — the client requires one (#55) but these +/// suites assert nothing about it; `user_agent_wiremock.rs` owns that +/// proof. Names neither crate, so a check for either finds nothing. +const TEST_USER_AGENT: &str = "probe-agent/0.0.0"; + +fn client(server: &MockServer) -> BugzillaClient { + BugzillaClient::new(&server.uri(), false, TEST_USER_AGENT).expect("client must build") +} + +#[tokio::test] +async fn enterable_product_ids_parses_string_ids() { + // Bugzilla's own documented example encodes ids as strings. + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/rest/product_enterable")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ids": ["2", "3", "19"] }))) + .mount(&server) + .await; + + let ids = client(&server) + .enterable_product_ids(KEY) + .await + .expect("request must succeed"); + assert_eq!(ids, vec![2, 3, 19]); +} + +#[tokio::test] +async fn enterable_product_ids_parses_numeric_ids() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/rest/product_enterable")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ids": [2, 3, 19] }))) + .mount(&server) + .await; + + let ids = client(&server) + .enterable_product_ids(KEY) + .await + .expect("request must succeed"); + assert_eq!(ids, vec![2, 3, 19]); +} + +#[tokio::test] +async fn enterable_product_ids_errors_on_malformed_envelope() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/rest/product_enterable")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "nope": [] }))) + .mount(&server) + .await; + + let err = client(&server) + .enterable_product_ids(KEY) + .await + .expect_err("a missing ids array must be an error, never an empty list"); + assert!(err.to_string().contains("ids")); +} + +#[tokio::test] +async fn products_sends_ids_names_and_include_fields() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/rest/product")) + .and(query_param("ids", "1")) + .and(query_param("names", "TestProduct")) + .and(query_param("include_fields", "id,name")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "products": [{ "id": 1, "name": "TestProduct" }] + }))) + .mount(&server) + .await; + + let v = client(&server) + .products(KEY, &[1], &["TestProduct"], Some(&["id", "name"])) + .await + .expect("request must succeed"); + assert_eq!(v["products"][0]["name"], json!("TestProduct")); +} + +#[tokio::test] +async fn products_returns_the_full_envelope() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/rest/product")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "products": [{ + "id": 1, + "name": "TestProduct", + "components": [{ + "name": "core", + "default_assigned_to": "admin@bugzilla.org", + }], + }] + }))) + .mount(&server) + .await; + + let v = client(&server) + .products(KEY, &[], &[], None) + .await + .expect("request must succeed"); + // The client is a raw pass-through; local projection is the server + // tool's job, not the client's — this pins that the client itself does + // not already strip anything. + assert_eq!( + v["products"][0]["components"][0]["default_assigned_to"], + json!("admin@bugzilla.org") + ); +} + +#[tokio::test] +async fn bug_fields_with_no_name_fetches_the_full_catalog() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/rest/field/bug")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "fields": [{ "name": "priority", "display_name": "Priority" }] + }))) + .mount(&server) + .await; + + let v = client(&server) + .bug_fields(KEY, None) + .await + .expect("request must succeed"); + assert_eq!(v["fields"][0]["name"], json!("priority")); +} + +#[tokio::test] +async fn bug_fields_with_a_name_addresses_that_field_only() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/rest/field/bug/priority")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "fields": [{ "name": "priority", "values": [{ "name": "P1" }] }] + }))) + .mount(&server) + .await; + + let v = client(&server) + .bug_fields(KEY, Some("priority")) + .await + .expect("request must succeed"); + assert_eq!(v["fields"][0]["values"][0]["name"], json!("P1")); +} + +#[tokio::test] +async fn bug_fields_percent_encodes_the_name_segment() { + // A name containing '/' must not be able to address a different + // endpoint by escaping the path segment. + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/rest/field/bug/a%2Fb")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "fields": [] }))) + .mount(&server) + .await; + + let v = client(&server) + .bug_fields(KEY, Some("a/b")) + .await + .expect("the escaped segment must reach the mock, not /rest/field/bug/a/b"); + assert_eq!(v["fields"], json!([])); +} + +#[tokio::test] +async fn discovery_errors_never_leak_the_key() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/rest/product_enterable")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": true, "message": "You must log in before using this part of Bugzilla." + }))) + .mount(&server) + .await; + + let err = client(&server) + .enterable_product_ids(KEY) + .await + .expect_err("401 must be an error"); + let text = format!("{err:#}"); + assert!(text.contains("401"), "status must be reported: {text}"); + assert!( + !text.contains(KEY), + "API key leaked into error text: {text}" + ); +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 742ea51..49201ed 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -508,6 +508,9 @@ impl BugzillaClient { pub async fn quicksearch_syntax_html(&self) -> anyhow::Result; pub async fn attachment_meta(&self, key: &str, attachment_id: u64) -> anyhow::Result>; // exclude_fields=data pub async fn attachment_data(&self, key: &str, attachment_id: u64) -> anyhow::Result>; // includes base64 `data` + pub async fn enterable_product_ids(&self, key: &str) -> anyhow::Result>; // .ids, string or number, both accepted + pub async fn products(&self, key: &str, ids: &[u64], names: &[&str], include_fields: Option<&[&str]>) -> anyhow::Result; + pub async fn bug_fields(&self, key: &str, name: Option<&str>) -> anyhow::Result; // name is percent-encoded as one path segment } ``` @@ -531,6 +534,9 @@ Endpoint mapping: | attachment_meta | GET /rest/bug/attachment/{attachment_id}?exclude_fields=data | `envelope.attachments.{id}` object, `None` when absent | | attachment_data | GET /rest/bug/attachment/{attachment_id} | `envelope.attachments.{id}` object incl. base64 `data`, `None` when absent | | quicksearch_syntax_html | GET {base_url}/page.cgi?id=quicksearch.html (no auth needed) | HTML string | +| enterable_product_ids | GET /rest/product_enterable | `.ids` as `Vec`; every element must parse as a numeric string or a JSON number, else error | +| products | GET /rest/product?ids=..&names=..[&include_fields=..] (any of the three may be empty/absent) | whole envelope (`{"products":[..]}`), raw — no local projection at this layer | +| bug_fields | GET /rest/field/bug, or /rest/field/bug/{name} with `name` percent-encoded as one path segment (`Url::path_segments_mut`, never string-interpolated) | whole envelope (`{"fields":[..]}`) | Auth per request: `use_auth_header` ? header `Authorization: Bearer {key}` : query param `api_key={key}`. Always `Accept: application/json`. From fbc283f1ddf911a5ebb48b2745f64a552fb46f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20S=C3=BAkup?= Date: Sat, 8 Aug 2026 16:59:38 +0200 Subject: [PATCH 2/2] feat(server): add guarded product and field discovery tools Add two new MCP tools built on the discovery client endpoints: bugzilla_products (no args: enterable products as {id, name}; named, up to 5: components/versions/milestones) and bug_fields (no args: every bug field except its legal values; named, up to 5: full detail including legal value names). Both take no bug id, so no guard capability applies and I8 does not apply. Both tools are pure pass-through of Bugzilla's own answer to the server's key -- never filtered against the guard policy. Bugzilla already scopes product and field visibility to the caller's key; filtering the catalog again here would itself be a policy-enumeration oracle, exactly the kind of leak create_bug's padded uniform refusal exists to prevent for a single product. Operators who want product or field names withheld leave the new global.allow_discovery switch off (the default): both tools are then removed from the tool listing via ToolRouter::remove_route, the same I13 mechanism read-only mode uses, so a policy-enumeration oracle can never be introduced by accident. Detail calls are capped at 5 names with a fixed refusal text and zero upstream requests over the cap -- a large instance's uncapped field catalog is hundreds of KB of legal values that would land verbatim in a model's context, so the catalog view never carries values and only the (bounded) detail view does. Component objects also drop default_assigned_to/default_qa_contact locally: they are account emails, and the local projection is the enforced guarantee, not an unenforced include_fields request to Bugzilla. Recorded as DESIGN.md invariant I16, with the two new tool rows, the global.allow_discovery policy key documented in README.md and examples/policy.toml, and the one-record-per-call audit test extended to cover the new routes. --- README.md | 3 + crates/bugwarden-core/src/policy.rs | 19 ++ crates/bugwarden/src/server.rs | 305 +++++++++++++++++++++++ crates/bugwarden/tests/audit_wiremock.rs | 16 +- crates/bugwarden/tests/tools_wiremock.rs | 256 +++++++++++++++++++ docs/DESIGN.md | 16 ++ examples/policy.toml | 7 + 7 files changed, 621 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ddb01b2..72ee963 100644 --- a/README.md +++ b/README.md @@ -398,6 +398,7 @@ A complete, commented example ships in | `max_attachment_bytes` | integer | `2097152` (2 MiB) | Largest attachment `download_attachment` may return, and the same ceiling on what `add_attachment` may upload — both measured on the decoded size. `0` removes this cap; over http the transport still refuses a request body above 4 MiB, so an upload stays bounded either way. Downloaded content is embedded base64 in the tool result and lands in the model's context — raise deliberately | | `identity_source` | `"whoami"` \| `"declared"` | `"whoami"` | How `created_by_me` resolves the caller's login. `whoami` calls Bugzilla's `GET /rest/whoami` — a fork/BMO extension absent from stock Bugzilla Core v1. `declared` names an operator-configured login instead (see `identity_login`), verified once at startup against the *stock* `GET /rest/valid_login` endpoint and never looked up again per call — the portable path when the deployment has no identity endpoint at all. See the `created_by_me` row below and "Identity resolution" in `docs/DESIGN.md` | | `identity_login` | string | none | Required (and must be non-blank) exactly when `identity_source = "declared"`; a hard startup error if set under `identity_source = "whoami"` (it would otherwise be silently ignored). Names the account that owns *this server's* API key, so it is only meaningful under a server-held key (stdio, or http server-held mode) — a startup error under http per-request key custody, where there is no server-held key for it to describe. Bugzilla compares logins case-sensitively (Perl `eq`); declare it exactly as Bugzilla stores it | +| `allow_discovery` | boolean | `false` | Exposes `bugzilla_products` and `bug_fields`, two read-only tools that return this Bugzilla instance's product and bug-field metadata **exactly as Bugzilla returns it to this server's key, never filtered by this guard policy** — filtering the catalog would itself be a way to probe the policy's rules. Leave this off (the default) if product or field names are themselves confidential; `disabled_tools` still works independently once discovery is on. Older bugwarden versions reject a policy using this key at startup (strict parsing fails closed) | ### `[[rule]]` @@ -685,6 +686,8 @@ none of the three needs an API key. Every other tool does, including | `add_attachment` | Upload a base64-encoded attachment to a bug, optionally private or flagged as a patch, capped by `max_attachment_bytes` (decoded size) | `attach` on the target bug | | `bug_url` | Compute `{server}/show_bug.cgi?id={id}` locally | none (contacts nothing) | | `bugzilla_server_info` | Bugzilla version, extensions, timezone, time, parameters | none | +| `bugzilla_products` | *(needs `global.allow_discovery = true`)* Lists enterable product names, or fetches components/versions/milestones for up to 5 named products — as Bugzilla reports it to this server's key, never filtered by this policy | none | +| `bug_fields` | *(needs `global.allow_discovery = true`)* Lists bug fields (without legal values), or fetches up to 5 named fields with their legal values — as Bugzilla reports it to this server's key, never filtered by this policy | none | | `quicksearch_syntax` | Bugzilla's quicksearch syntax documentation (HTML) | none | | `mcp_server_info` | This server's name and version, the Bugzilla URL, the transport, and a coarse policy summary: rule count, default action, `min_bug_age_days`, read-only flag, disabled tool names. Never a rule name or a match criterion | none | diff --git a/crates/bugwarden-core/src/policy.rs b/crates/bugwarden-core/src/policy.rs index 098d96c..08c0b36 100644 --- a/crates/bugwarden-core/src/policy.rs +++ b/crates/bugwarden-core/src/policy.rs @@ -589,6 +589,15 @@ pub struct GlobalGuards { /// Bugzilla's own `eq` — case-sensitive (see DESIGN.md). #[serde(default)] pub identity_login: Option, + /// Whether the product/field discovery tools (`bugzilla_products`, + /// `bug_fields`) are exposed at all. Defaults to `false`: they return + /// instance metadata exactly as Bugzilla returned it, unfiltered by + /// this policy (I16), so an operator who treats product or field names + /// as confidential must opt in explicitly rather than rely on a + /// filtered catalog — filtering it would itself be a + /// policy-enumeration oracle. + #[serde(default)] + pub allow_discovery: bool, } fn default_max_attachment_bytes() -> u64 { @@ -607,6 +616,7 @@ impl Default for GlobalGuards { max_attachment_bytes: default_max_attachment_bytes(), identity_source: IdentitySource::default(), identity_login: None, + allow_discovery: false, } } } @@ -1765,6 +1775,15 @@ products = ["SUSE*"] assert!(!d.global.allow_private_comments); // I5 assert!(!d.global.read_only); assert_eq!(d.global.min_bug_age_days, 0); + assert!(!d.global.allow_discovery); // I16: off unless opted in + } + + #[test] + fn allow_discovery_defaults_false_and_parses_true() { + let p = Policy::from_toml_str("").unwrap(); + assert!(!p.global.allow_discovery); + let p = Policy::from_toml_str("[global]\nallow_discovery = true\n").unwrap(); + assert!(p.global.allow_discovery); } #[test] diff --git a/crates/bugwarden/src/server.rs b/crates/bugwarden/src/server.rs index bd04597..c29207a 100644 --- a/crates/bugwarden/src/server.rs +++ b/crates/bugwarden/src/server.rs @@ -138,6 +138,10 @@ pub const WRITE_TOOLS: &[&str] = &[ "add_attachment", ]; +/// Names of the product/field discovery tools. These routes are removed +/// from the router unless `global.allow_discovery = true` (I13, I16). +pub const DISCOVERY_TOOLS: &[&str] = &["bugzilla_products", "bug_fields"]; + /// Success result: ONE text block containing pretty-printed JSON. fn ok_json(value: Value) -> CallToolResult { let text = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()); @@ -212,6 +216,8 @@ fn audit_refusal_text(tool: &str) -> Option { "list_attachments" => "Failed to fetch bug attachments".to_string(), "download_attachment" => "Failed to fetch attachment".to_string(), "bugzilla_server_info" => "Failed to fetch bugzilla server info".to_string(), + "bugzilla_products" => "Failed to fetch products".to_string(), + "bug_fields" => "Failed to fetch bug fields".to_string(), "quicksearch_syntax" => "Failed to fetch quicksearch documentation".to_string(), "bug_url" => "Failed to compute the bug url".to_string(), "mcp_server_info" => "Failed to compute server info".to_string(), @@ -261,6 +267,7 @@ const PARAM_ALLOWLIST: &[&str] = &[ "depends_on_add", "depends_on_remove", "duplicate_of", + "field_names", "file_name", "groups", "id", @@ -274,10 +281,12 @@ const PARAM_ALLOWLIST: &[&str] = &[ "limit", "new_since", "offset", + "on_bug_entry_only", "op_sys", "platform", "priority", "product", + "products", "query", "resolution", "severity", @@ -565,6 +574,148 @@ fn id_list_advisory(query: &str, status: &str) -> Option { Some(format!("{semantics} {steer}")) } +/// Cap on named entries in one discovery call +/// ([`BugzillaProductsParams::products`], [`BugFieldsParams::field_names`]): +/// the catalog of a large instance is hundreds of KB and lands verbatim in +/// the model's context, so the detail path is capped rather than left +/// unbounded (I16). +const MAX_DISCOVERY_NAMES: usize = 5; + +/// Project one `/rest/product` response object to the catalog shape +/// (`bugzilla_products` with no `products` named): `{id, name}` only. +fn project_product_catalog(envelope: &Value) -> Vec { + envelope + .get("products") + .and_then(Value::as_array) + .map(|products| { + products + .iter() + .map(|p| { + json!({ + "id": p.get("id").cloned().unwrap_or(Value::Null), + "name": p.get("name").cloned().unwrap_or(Value::Null), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +/// Project one `/rest/product` response object to the detail shape +/// (`bugzilla_products` with `products` named). `default_assigned_to` and +/// `default_qa_contact` are account emails and are stripped by never being +/// selected — an enforced omission, not an unenforced `include_fields` +/// request (I16). +fn project_product_detail(envelope: &Value) -> Vec { + envelope + .get("products") + .and_then(Value::as_array) + .map(|products| products.iter().map(project_one_product).collect()) + .unwrap_or_default() +} + +fn project_one_product(p: &Value) -> Value { + fn named_list(p: &Value, key: &str) -> Vec { + p.get(key) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .map(|i| { + json!({ + "name": i.get("name").cloned().unwrap_or(Value::Null), + "is_active": i.get("is_active").cloned().unwrap_or(Value::Null), + }) + }) + .collect() + }) + .unwrap_or_default() + } + let components: Vec = p + .get("components") + .and_then(Value::as_array) + .map(|cs| { + cs.iter() + .map(|c| { + json!({ + "name": c.get("name").cloned().unwrap_or(Value::Null), + "description": c.get("description").cloned().unwrap_or(Value::Null), + "is_active": c.get("is_active").cloned().unwrap_or(Value::Null), + }) + }) + .collect() + }) + .unwrap_or_default(); + json!({ + "name": p.get("name").cloned().unwrap_or(Value::Null), + "description": p.get("description").cloned().unwrap_or(Value::Null), + "is_active": p.get("is_active").cloned().unwrap_or(Value::Null), + "default_milestone": p.get("default_milestone").cloned().unwrap_or(Value::Null), + "has_unconfirmed": p.get("has_unconfirmed").cloned().unwrap_or(Value::Null), + "components": components, + "versions": named_list(p, "versions"), + "milestones": named_list(p, "milestones"), + }) +} + +/// The fields common to both `bug_fields` shapes — everything but `values`. +fn project_field_common(f: &Value) -> Value { + let has_values = f + .get("values") + .and_then(Value::as_array) + .is_some_and(|v| !v.is_empty()); + json!({ + "name": f.get("name").cloned().unwrap_or(Value::Null), + "display_name": f.get("display_name").cloned().unwrap_or(Value::Null), + "type": f.get("type").cloned().unwrap_or(Value::Null), + "is_custom": f.get("is_custom").cloned().unwrap_or(Value::Null), + "is_mandatory": f.get("is_mandatory").cloned().unwrap_or(Value::Null), + "is_on_bug_entry": f.get("is_on_bug_entry").cloned().unwrap_or(Value::Null), + "visibility_field": f.get("visibility_field").cloned().unwrap_or(Value::Null), + "visibility_values": f.get("visibility_values").cloned().unwrap_or(Value::Null), + "has_values": json!(has_values), + }) +} + +/// Project the `/rest/field/bug` catalog response (`bug_fields` with no +/// `field_names`): every field via [`project_field_common`] — never +/// `values`, which is what makes the catalog cheap — optionally filtered to +/// fields Bugzilla marks `is_on_bug_entry`. +fn project_field_catalog(envelope: &Value, on_bug_entry_only: bool) -> Vec { + envelope + .get("fields") + .and_then(Value::as_array) + .map(|fields| { + fields + .iter() + .filter(|f| { + !on_bug_entry_only + || f.get("is_on_bug_entry") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .map(project_field_common) + .collect() + }) + .unwrap_or_default() +} + +/// Project one `/rest/field/bug/{name}` response to the detail shape +/// (`bug_fields` with `field_names` named): [`project_field_common`] plus +/// `values`, reduced to the legal value NAMES only. +fn project_field_detail(f: &Value) -> Value { + let mut obj = project_field_common(f); + let values: Vec = f + .get("values") + .and_then(Value::as_array) + .map(|vs| vs.iter().filter_map(|v| v.get("name").cloned()).collect()) + .unwrap_or_default(); + if let Value::Object(map) = &mut obj { + map.insert("values".to_string(), Value::Array(values)); + } + obj +} + /// Project a bug object to the requested field set, preserving the /// `_redacted` marker when present. fn project_fields(bug: &Value, fields: &BTreeSet) -> Value { @@ -971,6 +1122,26 @@ pub struct SummarizeBugParams { pub id: u64, } +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct BugzillaProductsParams { + /// Product names to fetch detail for (max 5): components, versions, + /// milestones. Omit for the catalog of enterable product names only. + #[serde(default)] + pub products: Vec, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct BugFieldsParams { + /// Field names to fetch full detail for, including legal values (max + /// 5). Omit for the catalog, which never carries legal values. + #[serde(default)] + pub field_names: Vec, + /// Restrict the catalog to fields Bugzilla marks shown on bug entry. + /// Ignored when `field_names` is set. + #[serde(default)] + pub on_bug_entry_only: bool, +} + /// The MCP server: guard policy, Bugzilla client, and the pruned tool /// router (I13). Construct with [`BugWarden::new`]; serve over any rmcp /// transport. @@ -1065,6 +1236,12 @@ impl BugWarden { tracing::info!(tool = %name, "policy: removing disabled tool"); tool_router.remove_route(name); } + if !guard.policy.global.allow_discovery { + for name in DISCOVERY_TOOLS { + tracing::info!(tool = name, "discovery disabled: removing tool"); + tool_router.remove_route(name); + } + } Ok(Self { cfg, guard, @@ -2620,6 +2797,102 @@ impl BugWarden { } } + #[tool( + description = "Lists this Bugzilla instance's products, or fetches detail for up to 5 named products (components, versions, milestones). Returned exactly as Bugzilla reports it to this server's key — never filtered by this server's guard policy. Only present when the operator enabled global.allow_discovery.", + annotations(read_only_hint = true, open_world_hint = true) + )] + async fn bugzilla_products( + &self, + Parameters(p): Parameters, + ctx: RequestContext, + ) -> Result { + tracing::info!(product_count = p.products.len(), "tool: bugzilla_products"); + let key = self.api_key(&ctx)?; + if p.products.len() > MAX_DISCOVERY_NAMES { + note_refused(&ctx); + return Ok(err_text(format!( + "At most {MAX_DISCOVERY_NAMES} products per call" + ))); + } + if p.products.is_empty() { + let ids = match self.bz.enterable_product_ids(&key).await { + Ok(ids) => ids, + Err(e) => return Ok(err_text(format!("Failed to fetch products\nReason: {e}"))), + }; + if ids.is_empty() { + return Ok(ok_json(json!({ "products": [] }))); + } + match self + .bz + .products(&key, &ids, &[], Some(&["id", "name"])) + .await + { + Ok(v) => Ok(ok_json(json!({ "products": project_product_catalog(&v) }))), + Err(e) => Ok(err_text(format!("Failed to fetch products\nReason: {e}"))), + } + } else { + let names: Vec<&str> = p.products.iter().map(String::as_str).collect(); + match self.bz.products(&key, &[], &names, None).await { + Ok(v) => Ok(ok_json(json!({ "products": project_product_detail(&v) }))), + Err(e) => Ok(err_text(format!("Failed to fetch products\nReason: {e}"))), + } + } + } + + #[tool( + description = "Lists this Bugzilla instance's bug fields (without legal values), or fetches detail for up to 5 named fields including their legal values. Returned exactly as Bugzilla reports it to this server's key — never filtered by this server's guard policy. Only present when the operator enabled global.allow_discovery.", + annotations(read_only_hint = true, open_world_hint = true) + )] + async fn bug_fields( + &self, + Parameters(p): Parameters, + ctx: RequestContext, + ) -> Result { + tracing::info!( + field_count = p.field_names.len(), + on_bug_entry_only = p.on_bug_entry_only, + "tool: bug_fields" + ); + let key = self.api_key(&ctx)?; + if p.field_names.len() > MAX_DISCOVERY_NAMES { + note_refused(&ctx); + return Ok(err_text(format!( + "At most {MAX_DISCOVERY_NAMES} field names per call" + ))); + } + if p.field_names.is_empty() { + match self.bz.bug_fields(&key, None).await { + Ok(v) => Ok(ok_json(json!({ + "fields": project_field_catalog(&v, p.on_bug_entry_only) + }))), + Err(e) => Ok(err_text(format!("Failed to fetch bug fields\nReason: {e}"))), + } + } else { + let mut fields = Vec::with_capacity(p.field_names.len()); + for name in &p.field_names { + let v = match self.bz.bug_fields(&key, Some(name)).await { + Ok(v) => v, + Err(e) => { + return Ok(err_text(format!("Failed to fetch bug fields\nReason: {e}"))) + } + }; + match v + .get("fields") + .and_then(Value::as_array) + .and_then(|f| f.first()) + { + Some(field) => fields.push(project_field_detail(field)), + None => { + return Ok(err_text(format!( + "Failed to fetch bug fields\nReason: no such field '{name}'" + ))) + } + } + } + Ok(ok_json(json!({ "fields": fields }))) + } + } + #[tool( description = "Access the documentation of the bugzilla quicksearch syntax. LLM can learn using this tool. Response is in HTML. Note: through this server's bugs_quicksearch the status filter is prefixed to the query, so under any non-empty status (the default is ALL) a number in the query is content-matched as text; the syntax page's jump-to-bug-number shortcut applies only when status is empty and the query is nothing but numbers. Look up known bug ids with the bug_info tool.", annotations(read_only_hint = true, open_world_hint = true) @@ -3566,6 +3839,38 @@ mod tests { assert!(server.tool_router.has_route("add_attachment")); } + #[test] + fn discovery_tools_absent_by_default_present_when_enabled_i16() { + let (cfg, guard, bz) = parts(""); + let server = BugWarden::new(cfg, guard, bz).expect("server builds"); + for name in DISCOVERY_TOOLS { + assert!( + !server.tool_router.has_route(name), + "discovery tool {name} must be absent by default (I16)" + ); + } + + let (cfg, guard, bz) = parts("[global]\nallow_discovery = true\n"); + let server = BugWarden::new(cfg, guard, bz).expect("server builds"); + for name in DISCOVERY_TOOLS { + assert!( + server.tool_router.has_route(name), + "discovery tool {name} must be present under allow_discovery = true" + ); + } + } + + #[test] + fn disabled_tools_names_a_discovery_tool_with_discovery_off() { + // A discovery tool name stays a valid disabled_tools entry even + // though discovery-off removes its route first (validation runs + // against the full router, same ordering as read-only/I13). + let (cfg, guard, bz) = parts("[global]\ndisabled_tools = [\"bug_fields\"]\n"); + let server = BugWarden::new(cfg, guard, bz).expect("discovery tool name must stay valid"); + assert!(!server.tool_router.has_route("bug_fields")); + assert!(!server.tool_router.has_route("bugzilla_products")); + } + #[test] fn get_tool_serves_the_pruned_instance_router_i13() { // The definition lookup must consult the INSTANCE router `new` diff --git a/crates/bugwarden/tests/audit_wiremock.rs b/crates/bugwarden/tests/audit_wiremock.rs index 0fce7b1..585f4ea 100644 --- a/crates/bugwarden/tests/audit_wiremock.rs +++ b/crates/bugwarden/tests/audit_wiremock.rs @@ -272,6 +272,16 @@ async fn mount_fixture(mock: &MockServer) { .respond_with(ResponseTemplate::new(200).set_body_string("quicksearch")) .mount(mock) .await; + Mock::given(method("GET")) + .and(path("/rest/product_enterable")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ids": [] }))) + .mount(mock) + .await; + Mock::given(method("GET")) + .and(path("/rest/field/bug")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "fields": [] }))) + .mount(mock) + .await; } /// Minimal valid arguments for each routed tool against [`mount_fixture`]. @@ -307,6 +317,8 @@ fn minimal_args(tool: &str) -> Value { "download_attachment" => json!({ "attachment_id": 55 }), "bug_url" => json!({ "bug_id": 7 }), "bugzilla_server_info" => json!({}), + "bugzilla_products" => json!({}), + "bug_fields" => json!({}), "quicksearch_syntax" => json!({}), "mcp_server_info" => json!({}), "summarize_bug" => json!({ "id": 7 }), @@ -318,7 +330,9 @@ fn minimal_args(tool: &str) -> Value { async fn every_routed_tool_writes_exactly_one_record_per_call() { let mock = MockServer::start().await; mount_fixture(&mock).await; - let audited = audited_client_for("", &mock, "test-key").await; + // allow_discovery = true so the full router — discovery tools included + // (I16) — is exercised by this one-record-per-call guarantee (I15). + let audited = audited_client_for("[global]\nallow_discovery = true\n", &mock, "test-key").await; let tools = audited .client diff --git a/crates/bugwarden/tests/tools_wiremock.rs b/crates/bugwarden/tests/tools_wiremock.rs index a49d673..3940c39 100644 --- a/crates/bugwarden/tests/tools_wiremock.rs +++ b/crates/bugwarden/tests/tools_wiremock.rs @@ -1536,3 +1536,259 @@ async fn whoami_transport_error_does_not_leak_the_api_key_i12() { json!("Bug 7 is not accessible through this server") ); } + +/// Discovery is off unless the operator opts in. +const DISCOVERY_POLICY: &str = "[global]\nallow_discovery = true\n"; + +#[tokio::test] +async fn bugzilla_products_catalog_is_id_name_pairs_only() { + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/rest/product_enterable")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ids": [1, 2] }))) + .mount(&mock) + .await; + Mock::given(method("GET")) + .and(path("/rest/product")) + .and(query_param("ids", "1")) + .and(query_param("ids", "2")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "products": [ + { "id": 1, "name": "TestProduct", "description": "hidden from the catalog" }, + { "id": 2, "name": "OtherProduct" }, + ] + }))) + .mount(&mock) + .await; + let client = client_for(DISCOVERY_POLICY, &mock).await; + + let result = call(&client, "bugzilla_products", json!({})).await; + assert!(!is_error(&result), "{}", text_of(&result)); + let envelope: Value = serde_json::from_str(&text_of(&result)).expect("JSON"); + assert_eq!( + envelope["products"], + json!([ + { "id": 1, "name": "TestProduct" }, + { "id": 2, "name": "OtherProduct" }, + ]) + ); +} + +#[tokio::test] +async fn bugzilla_products_detail_strips_account_fields() { + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/rest/product")) + .and(query_param("names", "TestProduct")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "products": [{ + "id": 1, + "name": "TestProduct", + "description": "A test product.", + "is_active": true, + "default_milestone": "---", + "has_unconfirmed": true, + "components": [{ + "name": "core", + "description": "Core component", + "is_active": true, + "default_assigned_to": "admin@bugzilla.org", + "default_qa_contact": "qa@bugzilla.org", + }], + "versions": [{ "name": "1.0", "is_active": true }], + "milestones": [{ "name": "---", "is_active": true }], + }] + }))) + .mount(&mock) + .await; + let client = client_for(DISCOVERY_POLICY, &mock).await; + + let result = call( + &client, + "bugzilla_products", + json!({ "products": ["TestProduct"] }), + ) + .await; + assert!(!is_error(&result), "{}", text_of(&result)); + let text = text_of(&result); + assert!( + !text.contains("default_assigned_to") && !text.contains("default_qa_contact"), + "account emails must never appear in the response: {text}" + ); + let envelope: Value = serde_json::from_str(&text).expect("JSON"); + assert_eq!( + envelope["products"][0], + json!({ + "name": "TestProduct", + "description": "A test product.", + "is_active": true, + "default_milestone": "---", + "has_unconfirmed": true, + "components": [{ "name": "core", "description": "Core component", "is_active": true }], + "versions": [{ "name": "1.0", "is_active": true }], + "milestones": [{ "name": "---", "is_active": true }], + }) + ); +} + +#[tokio::test] +async fn bugzilla_products_over_cap_makes_no_upstream_request() { + let mock = MockServer::start().await; + let client = client_for(DISCOVERY_POLICY, &mock).await; + let result = call( + &client, + "bugzilla_products", + json!({ "products": ["a", "b", "c", "d", "e", "f"] }), + ) + .await; + assert!(is_error(&result)); + assert_eq!(text_of(&result), "At most 5 products per call"); + assert!( + mock.received_requests().await.unwrap().is_empty(), + "the cap refusal must make zero upstream requests" + ); +} + +#[tokio::test] +async fn bug_fields_catalog_carries_no_values() { + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/rest/field/bug")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "fields": [{ + "id": 13, + "name": "priority", + "display_name": "Priority", + "type": 2, + "is_custom": false, + "is_mandatory": false, + "is_on_bug_entry": false, + "visibility_field": null, + "visibility_values": [], + "values": [{ "name": "P1" }, { "name": "P2" }], + }] + }))) + .mount(&mock) + .await; + let client = client_for(DISCOVERY_POLICY, &mock).await; + + let result = call(&client, "bug_fields", json!({})).await; + assert!(!is_error(&result), "{}", text_of(&result)); + let text = text_of(&result); + assert!( + !text.contains("\"values\""), + "catalog must carry no values: {text}" + ); + let envelope: Value = serde_json::from_str(&text).expect("JSON"); + assert_eq!( + envelope["fields"][0], + json!({ + "name": "priority", + "display_name": "Priority", + "type": 2, + "is_custom": false, + "is_mandatory": false, + "is_on_bug_entry": false, + "visibility_field": null, + "visibility_values": [], + "has_values": true, + }) + ); +} + +#[tokio::test] +async fn bug_fields_catalog_can_be_filtered_to_bug_entry_fields() { + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/rest/field/bug")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "fields": [ + { "name": "priority", "is_on_bug_entry": false }, + { "name": "cf_severity_extra", "is_on_bug_entry": true }, + ] + }))) + .mount(&mock) + .await; + let client = client_for(DISCOVERY_POLICY, &mock).await; + + let result = call(&client, "bug_fields", json!({ "on_bug_entry_only": true })).await; + assert!(!is_error(&result), "{}", text_of(&result)); + let envelope: Value = serde_json::from_str(&text_of(&result)).expect("JSON"); + let names: Vec<&str> = envelope["fields"] + .as_array() + .unwrap() + .iter() + .map(|f| f["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["cf_severity_extra"]); +} + +#[tokio::test] +async fn bug_fields_detail_includes_legal_value_names() { + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/rest/field/bug/bug_status")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "fields": [{ + "name": "bug_status", + "display_name": "Status", + "is_custom": false, + "is_mandatory": false, + "is_on_bug_entry": false, + "values": [{ "name": "NEW" }, { "name": "CONFIRMED" }], + }] + }))) + .mount(&mock) + .await; + let client = client_for(DISCOVERY_POLICY, &mock).await; + + let result = call( + &client, + "bug_fields", + json!({ "field_names": ["bug_status"] }), + ) + .await; + assert!(!is_error(&result), "{}", text_of(&result)); + let envelope: Value = serde_json::from_str(&text_of(&result)).expect("JSON"); + assert_eq!(envelope["fields"][0]["values"], json!(["NEW", "CONFIRMED"])); +} + +#[tokio::test] +async fn bug_fields_over_cap_makes_no_upstream_request() { + let mock = MockServer::start().await; + let client = client_for(DISCOVERY_POLICY, &mock).await; + let result = call( + &client, + "bug_fields", + json!({ "field_names": ["a", "b", "c", "d", "e", "f"] }), + ) + .await; + assert!(is_error(&result)); + assert_eq!(text_of(&result), "At most 5 field names per call"); + assert!( + mock.received_requests().await.unwrap().is_empty(), + "the cap refusal must make zero upstream requests" + ); +} + +#[tokio::test] +async fn discovery_tools_absent_from_the_listing_by_default() { + let mock = MockServer::start().await; + let client = client_for("", &mock).await; + let tools = client + .list_all_tools() + .await + .expect("list_tools must succeed"); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert!(!names.contains(&"bugzilla_products")); + assert!(!names.contains(&"bug_fields")); + + let client = client_for(DISCOVERY_POLICY, &mock).await; + let tools = client + .list_all_tools() + .await + .expect("list_tools must succeed"); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert!(names.contains(&"bugzilla_products")); + assert!(names.contains(&"bug_fields")); +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 49201ed..7ee6e6a 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -102,6 +102,20 @@ Dependency direction: `bugwarden -> bugwarden-core`, never the reverse. Client-visible responses are byte-identical with auditing on, off, or failing — except the scoped fail-closed refusals, which reuse the tools' existing uniform failure texts and never vary with the guard's verdict. +- **I16** `bugzilla_products` and `bug_fields` return Bugzilla instance + metadata (products, components, versions, milestones, bug fields and + their legal values) exactly as Bugzilla returned it to the caller's own + key — NEVER filtered against the guard policy. A policy-filtered catalog + would itself be a policy-enumeration oracle, exactly what `create_bug`'s + padded uniform refusal exists to deny (cross-reference that row below). + They return no bug data and no bug ids, so no capability applies and no + classification runs (I8 does not apply — there is no bug id to assess). + Both are removed from the tool listing (`ToolRouter::remove_route`, + I13) unless the operator sets `global.allow_discovery = true`; the + default is `false`. `components[].default_assigned_to` and + `default_qa_contact` (account emails) are stripped by the SERVER's own + projection, never merely omitted from an upstream `include_fields` + request — the omission is enforced locally, not trusted upstream. ## bugwarden-core API (exact signatures) @@ -863,6 +877,8 @@ constraints the model must know. | download_attachment | attachment_id, include_private: bool = false | attachments (on the owning bug) | metadata fetched FIRST (no blob) for guard assessment + attachment_gate; unknown id, metadata OR blob fetch failure, denied owning bug, missing bug_id, and private-without-opt-in all yield the uniform attachment denial. Constant upstream request count on every path (a metadata miss still runs one classify call against bug id 0) so call latency is not an existence oracle. The gate AND the bug-id check re-run on the blob response (TOCTOU), then the actual base64 size is re-checked against the cap (a lying `size` cannot bypass it). Raster image types from a strict allowlist => ContentBlock::image; everything else (incl. image/svg+xml) => BlobResourceContents whose uri carries only the attachment id (uploader-chosen file_name never enters the uri) | | bug_url | bug_id | none (I8 exception) | `{base_url}/show_bug.cgi?id={id}` | | bugzilla_server_info | — | none | client.server_info | +| bugzilla_products | products?: Vec (max 5) | none — present only when `global.allow_discovery = true` (I16) | no `products` named: `enterable_product_ids` then `products(ids, [], [id,name])`, projected to `{id, name}` catalog entries; `products` named: `products([], names, None)`, projected to `{name, description, is_active, default_milestone, has_unconfirmed, components[{name,description,is_active}], versions[{name,is_active}], milestones[{name,is_active}]}` — `default_assigned_to`/`default_qa_contact` are never selected. Over-cap (>5 names) refuses with a fixed text and makes ZERO upstream requests, since the refusal is a pure function of the request's own shape | +| bug_fields | field_names?: Vec (max 5), on_bug_entry_only: bool = false | none — present only when `global.allow_discovery = true` (I16) | no `field_names`: `bug_fields(None)`, projected per field to `{name, display_name, type, is_custom, is_mandatory, is_on_bug_entry, visibility_field, visibility_values, has_values}` — NEVER `values` — optionally filtered to `is_on_bug_entry` fields; `field_names` named: one `bug_fields(Some(name))` call per name (sequential; Bugzilla's field lookup is single-field), same projection plus `values` reduced to legal-value NAMES only. Over-cap (>5 names) refuses with a fixed text and makes ZERO upstream requests. A named field Bugzilla does not recognise is a call-level failure (the generic `Failed to fetch bug fields` text), not a partial result | | quicksearch_syntax | — | none | HTML doc page | | mcp_server_info | — | none | name (CARGO_PKG_NAME) and version (CARGO_PKG_VERSION), the same two the handshake sends; bugzilla server url, transport, and policy summary per I1 | | summarize_bug | id | comments | fetches comments (private filtered with include_private=false), returns the summarization prompt text (fixed prompt template) | diff --git a/examples/policy.toml b/examples/policy.toml index 148b1fa..fb35aa9 100644 --- a/examples/policy.toml +++ b/examples/policy.toml @@ -95,6 +95,13 @@ read_only = false # decoded bytes. 0 removes the cap. Default: 2 MiB. #max_attachment_bytes = 2097152 +# Exposes bugzilla_products and bug_fields: read-only product/field metadata +# returned exactly as Bugzilla answers it to this server's key, NEVER +# filtered by this policy — a filtered catalog would itself let a client +# probe the rules above. Leave this off if product or field names are +# themselves confidential. Default: false. +#allow_discovery = false + # --------------------------------------------------------------------------- # 0. New bug reports: accepted into the desktop products, and only there. #