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
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,35 @@ jobs:
- name: Test
run: cargo test --workspace --all-targets --locked

rust-macos:
# Portability check on macOS (Apple Silicon, aarch64-apple-darwin): build
# and test the workspace so the platform stays viable for release
# binaries. Kept lean (no fmt — formatting is platform-independent and
# covered by the ubuntu `rust` job).
runs-on: macos-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master
with:
# Keep in sync with rust-toolchain.toml.
toolchain: "1.97.0"
components: clippy
- name: Cache cargo
# Tag runs re-verify a release: keep them off restored build state;
# PR/main runs keep the cache.
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: . -> target
- name: Clippy
run: cargo clippy --workspace --all-targets --locked -- -D warnings
- name: Test
run: cargo test --workspace --all-targets --locked

rust-beta:
# Early warning on the upcoming compiler: never blocks, but surfaces
# new-lint and regression noise before beta becomes stable. No fmt leg
Expand Down
103 changes: 103 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
---
name: release
# yamllint disable-line rule:truthy
on:
push:
tags:
# Version tags: 0.1.0, optionally with a pre-release suffix (0.1.0rc1).
# The project tags without a "v" prefix.
- '[0-9]+.[0-9]+.[0-9]+'
- '[0-9]+.[0-9]+.[0-9]+*'

permissions:
contents: read

jobs:
build:
name: build (${{ matrix.triple }})
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
# Build every platform even if one fails, so a single broken target
# surfaces on its own. The release job needs all legs to succeed, so a
# failure still blocks publishing a partial set of artifacts.
fail-fast: false
matrix:
include:
- os: ubuntu-latest
triple: x86_64-unknown-linux-gnu
- os: macos-latest # Apple Silicon
triple: aarch64-apple-darwin
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master
with:
# Keep in sync with rust-toolchain.toml.
toolchain: "1.97.0"
# No cargo cache here: releases are built hermetically so restored
# build state can never influence published artifacts.

- name: Verify tag matches workspace version
shell: bash
run: |
pkg="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)"
if [ "$pkg" != "$GITHUB_REF_NAME" ]; then
echo "::error::tag '$GITHUB_REF_NAME' != workspace version '$pkg'"
exit 1
fi

- name: Build release binary
run: cargo build --release --locked -p bugwarden

- name: Package release artifact
shell: bash
run: |
triple="${{ matrix.triple }}"
dist="bugwarden-${GITHUB_REF_NAME}-${triple}"
mkdir "$dist"
cp target/release/bugwarden LICENSE README.md "$dist/"
mkdir "$dist/examples"
cp examples/policy.toml "$dist/examples/"
tar czf "${dist}.tar.gz" "$dist"
# shasum -a 256 exists on both the ubuntu and macOS runners and its
# output is compatible with `sha256sum -c`.
shasum -a 256 "${dist}.tar.gz" >"${dist}.tar.gz.sha256"
echo "DIST=$dist" >>"$GITHUB_ENV"

- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.triple }}
path: |
${{ env.DIST }}.tar.gz
${{ env.DIST }}.tar.gz.sha256
if-no-files-found: error

release:
# Publish only after every platform built successfully, so a release never
# ships a partial artifact set.
needs: build
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
# required for `gh release create`
contents: write
steps:
- name: Download build artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
path: dist
merge-multiple: true
- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
# No checkout in this job, so tell gh which repo to act on.
GH_REPO: ${{ github.repository }}
run: >-
gh release create "$GITHUB_REF_NAME"
--title "$GITHUB_REF_NAME"
--generate-notes
dist/*
15 changes: 7 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,13 @@ never a reason to relax them.
removed from the tool listing (`ToolRouter::remove_route`), not merely
made to error (I13).

## The Python Original Is Not an Authority

bugwarden is a Rust rewrite of `mcp-bugzilla` (Python). The Python tree
informs tool description wording and the REST endpoint mapping where
DESIGN.md says so — nothing more. DESIGN.md records deliberate departures
(for example the stricter `allow_private_comments = false` default, I5, and
the intentionally unported `get_current_headers` tool, I10); "Python did X"
is never a justification for undoing them.
## DESIGN.md Records Deliberate Decisions

docs/DESIGN.md is the sole design authority. It records decisions that may
look like accidents but are deliberate (for example the strict
`allow_private_comments = false` default, I5, and the absence of any
header-echo tool, I10); convenience, precedent, or other implementations
are never a justification for undoing them.

## Rust Style and APIs

Expand Down
19 changes: 9 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# bugwarden

**bugwarden** is a Rust rewrite of [mcp-bugzilla](https://github.com/openSUSE/mcp-bugzilla)
with operator-controlled security guards. It is a Model Context Protocol (MCP)
server that exposes a Bugzilla instance to LLM clients — querying bugs,
**bugwarden** is a Model Context Protocol (MCP) server, written in Rust, with
operator-controlled security guards. It exposes a Bugzilla instance to LLM
clients — querying bugs,
searching, reading comments and history, and (where permitted) updating bugs —
while a policy file that the model can neither see nor change decides, per bug,
what the model is allowed to do.
Expand All @@ -17,7 +17,7 @@ touched or data is returned.

## Features

- **Full mcp-bugzilla tool surface**, reimplemented in Rust: bug details,
- **Complete Bugzilla tool surface**: bug details,
history, comments, attachment metadata, quicksearch, comment/status/field/
assignee/CC/dependency updates, duplicate marking, server info, quicksearch
syntax docs, and a bug-summarization prompt tool.
Expand Down Expand Up @@ -78,12 +78,11 @@ is `bug_url`, which computes a URL string locally and contacts nothing.
- **CLI can only tighten.** `--read-only` ORs into the policy's read-only
flag; there is no CLI switch that loosens the policy.

### Intentional differences from the Python original
### Deliberate omissions and strict defaults

- **`get_current_headers` is not ported.** In the original it echoes request
headers — including the API-key header — back to the model. Gone.
- **Private comments default to off.** Without a policy file the Python
original returns private comments when asked; bugwarden's default policy has
- **No header-echo tool.** Incoming request headers — including the API-key
header — are never exposed to the model.
- **Private comments default to off.** The default policy has
`allow_private_comments = false`, so a policy file is required to enable
them.
- **`update_bug_fields` custom fields are restricted** to `cf_*` keys as
Expand All @@ -92,7 +91,7 @@ is `bug_url`, which computes a URL string locally and contacts nothing.
## Installation / build

```bash
git clone https://github.com/openSUSE/bugwarden
git clone https://github.com/plusky/bugwarden
cd bugwarden
cargo build --release
# binary at target/release/bugwarden
Expand Down
12 changes: 5 additions & 7 deletions crates/bugwarden-core/src/client.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
//! Async Bugzilla REST client.
//!
//! A faithful port of the HTTP behavior of the Python original
//! (`mcp-bugzilla/src/mcp_bugzilla/mcp_utils.py`) with one deliberate
//! hardening change: the API key is passed per request instead of being
//! The API key is passed per request instead of being
//! stored on the client, and it must never appear in logs, error messages,
//! or tool results (security invariant I12). Because the key may travel as
//! a URL query parameter (`api_key=...`), every [`reqwest::Error`] is
Expand All @@ -23,8 +21,8 @@ use serde_json::Value;
pub const CLASSIFY_FIELDS: &str =
"id,summary,product,component,status,resolution,severity,priority,keywords,groups,whiteboard,creation_time,last_change_time";

/// Bugzilla `new_since` timestamp format (mirrors the Python
/// `strftime("%Y-%m-%dT%H:%M:%SZ")`).
/// Bugzilla `new_since` timestamp format (UTC, second precision, `Z`
/// suffix).
const TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";

/// Async client for the Bugzilla REST API.
Expand All @@ -45,7 +43,7 @@ pub struct BugzillaClient {

impl BugzillaClient {
/// Create a client for the Bugzilla instance at `base_url` (trailing
/// slashes are trimmed, mirroring the Python `url.rstrip("/")`).
/// slashes are trimmed).
pub fn new(base_url: &str, use_auth_header: bool) -> Result<Self> {
let base_url = base_url.trim_end_matches('/').to_string();
let api_url = format!("{base_url}/rest");
Expand Down Expand Up @@ -422,7 +420,7 @@ mod tests {
}

#[test]
fn timestamp_format_matches_python_strftime() {
fn timestamp_format_renders_utc_z() {
let ts = Utc.with_ymd_and_hms(2024, 1, 2, 3, 4, 5).unwrap();
assert_eq!(
ts.format(TIMESTAMP_FORMAT).to_string(),
Expand Down
2 changes: 1 addition & 1 deletion crates/bugwarden-core/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ pub struct GlobalGuards {
pub min_bug_age_days: i64,
/// Whether private comments may ever be returned. Even when `true` a
/// call must also pass `include_private = true` to see them (I5).
/// Defaults to `false` — intentionally stricter than the Python original.
/// Defaults to `false` — private data is strictly opt-in.
#[serde(default)]
pub allow_private_comments: bool,
/// Strip write capabilities from every grant. The CLI `--read-only` flag
Expand Down
3 changes: 1 addition & 2 deletions crates/bugwarden/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
//! CLI configuration for bugwarden.
//!
//! Env fallbacks mirror the Python original (`mcp-bugzilla`): CLI argument >
//! environment variable > hardcoded default.
//! Precedence: CLI argument > environment variable > hardcoded default.

use std::path::PathBuf;

Expand Down
21 changes: 10 additions & 11 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# bugwarden — design contract

Rust rewrite of `mcp-bugzilla` (Python original: `/home/mpluskal/devel/mcp-bugzilla`)
extended with operator-controlled security guards. This document is the binding
An MCP server exposing Bugzilla to LLM clients, hardened with
operator-controlled security guards. This document is the binding
contract between modules. If an implementation must deviate, note the deviation
explicitly in your report.

Expand Down Expand Up @@ -33,8 +33,7 @@ Dependency direction: `bugwarden -> bugwarden-core`, never the reverse.
- **I5** Private comments (`is_private: true`) are returned only when policy
`global.allow_private_comments = true` AND the call sets
`include_private = true`. Default policy (no file) has
`allow_private_comments = false` (intentionally stricter than the Python
original).
`allow_private_comments = false` — private data is strictly opt-in.
- **I6** Capability implication: `read` implies `summary`. Nothing else is
implied.
- **I7** `update_bug_fields.custom_fields`: every key must start with `cf_`;
Expand All @@ -45,8 +44,8 @@ Dependency direction: `bugwarden -> bugwarden-core`, never the reverse.
locally, contacts nothing).
- **I9** CLI/env can only tighten policy: `--read-only` ORs into
`global.read_only`.
- **I10** The Python tool `get_current_headers` is intentionally NOT ported
(it echoes the API-key header back to the model).
- **I10** No tool may echo incoming request headers back to the client —
that would leak the API-key header to the model.
- **I11** `mark_as_duplicate` requires capability `status` on `bug_id` AND at
least `summary` on `duplicate_of`.
- **I12** The Bugzilla API key must never appear in logs, error messages, or
Expand Down Expand Up @@ -238,7 +237,7 @@ impl BugzillaClient {
}
```

Endpoint mapping (mirrors Python `mcp_utils.py`):
Endpoint mapping:

| method | request | returns |
|---|---|---|
Expand Down Expand Up @@ -266,8 +265,8 @@ pretty-printed JSON. Guard refusals and input-validation failures =>
`CallToolResult::error` with a text block (NOT a protocol error). Protocol
issues (missing API key header) => `McpError::invalid_request`.

Tool descriptions: reuse the Python docstrings (`server.py`) wording where a
tool is a direct port.
Tool descriptions: concise and action-oriented; state the defaults and
constraints the model must know.

| tool | params (schemars struct) | guard capability | notes |
|---|---|---|---|
Expand All @@ -287,11 +286,11 @@ tool is a direct port.
| bugzilla_server_info | — | none | client.server_info |
| quicksearch_syntax | — | none | HTML doc page |
| mcp_server_info | — | none | version (CARGO_PKG_VERSION), 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 (port the Python template) |
| summarize_bug | id | comments | fetches comments (private filtered with include_private=false), returns the summarization prompt text (fixed prompt template) |

## CLI (crates/bugwarden/src/config.rs)

clap derive `Cli`, env fallbacks exactly as the Python original:
clap derive `Cli`, with env fallbacks:

| flag | env | default | notes |
|---|---|---|---|
Expand Down