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
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,21 +353,22 @@ Command-line arguments take precedence over environment variables.
| `--transport <http\|stdio>` | `MCP_TRANSPORT` | `http` | MCP transport. `stdio` is for subprocess launches by an MCP client; `http` exposes a network endpoint at `/mcp` |
| `--host <ADDRESS>` | `MCP_HOST` | `127.0.0.1` | Listen address (http transport only) |
| `--port <PORT>` | `MCP_PORT` | `8000` | Listen port (http transport only) |
| `--allowed-hosts <HOST>` | | — | Hostname or `host:port` authority accepted in an inbound `Host` header (http transport only). Repeatable; each occurrence adds one host. Without it no `Host` validation happens, so a client may address the server by any name |
| `--allowed-hosts <HOST>` | `MCP_ALLOWED_HOSTS` | — | Hostname or `host:port` authority accepted in an inbound `Host` header (http transport only). Repeatable, and each value may list several hosts separated by commas and/or whitespace. Without any host no `Host` validation happens, so a client may address the server by any name |
| `--api-key-header <NAME>` | `MCP_API_KEY_HEADER` | `ApiKey` | HTTP header name in which clients send the Bugzilla API key (http transport only). Not consulted in server-held key mode |
| `--api-key <KEY>` | `BUGZILLA_API_KEY` | — | Bugzilla API key. **Required** for `--transport stdio` unless `--api-key-file` provides it; with `http` it is ignored with a warning (clients send the key per request — use `--api-key-file` for a server-held key) |
| `--api-key-file <PATH>` | `BUGZILLA_API_KEY_FILE` | — | Path to a file holding the Bugzilla API key (container secret, systemd `LoadCredential` path). Mutually exclusive with `--api-key`; an empty value counts as unset. Over `http` this selects server-held key mode: every request is served with this key and the per-request header is not consulted |
| `--use-auth-header` | | `false` | Authenticate to Bugzilla with `Authorization: Bearer <key>` instead of the `api_key` query parameter |
| `--use-auth-header` | `BUGZILLA_USE_AUTH_HEADER` | `false` | Authenticate to Bugzilla with `Authorization: Bearer <key>` instead of the `api_key` query parameter. As an environment variable it takes the literal `true` or `false`, exactly like `MCP_READ_ONLY` |
| `--read-only` | `MCP_READ_ONLY` | `false` | Disable all write tools. Tighten-only: ORed with the policy's `global.read_only`; cannot re-enable writes a policy forbids. As an environment variable it takes the literal `true` or `false` — `1`, `yes` and an empty value are a usage error, not a synonym |
| `--policy <PATH>` | `BUGWARDEN_POLICY` | — | Path to the guard policy TOML. Without it, an allow-all policy applies (with private comments off and the 2 MiB attachment cap still in force) |
| `--audit-config <PATH>` | `BUGWARDEN_AUDIT_CONFIG` | — | Path to the audit stream configuration TOML (worked example in [`examples/audit.toml`](examples/audit.toml)). Without it, no audit stream is written. Records carry W3C trace ids when the client sends a `traceparent` in the request's `_meta`, enabling correlation with client-side traces |
| — | `RUST_LOG` | `info` | Tracing filter for the diagnostic log, which always goes to **stderr** — stdout belongs to the stdio transport. An unparsable value falls back to `info` |

An empty value counts as unset for `--api-key` and `--api-key-file` only, so
`BUGZILLA_API_KEY_FILE=` in a unit file leaves the two key modes unaffected
rather than erroring (under stdio it then leaves no key source at all, which
is a startup error of its own). An empty `BUGWARDEN_POLICY` or
`BUGWARDEN_AUDIT_CONFIG` is a usage error.
An empty value counts as unset for `--api-key`, `--api-key-file` and
`--allowed-hosts`, so `BUGZILLA_API_KEY_FILE=` in a unit file leaves the two
key modes unaffected rather than erroring (under stdio it then leaves no key
source at all, which is a startup error of its own), and `MCP_ALLOWED_HOSTS=`
names no host, leaving `Host` validation off as if it were never set. An empty
`BUGWARDEN_POLICY` or `BUGWARDEN_AUDIT_CONFIG` is a usage error.

Exit status: `0` on clean shutdown, `1` on a startup or runtime failure (an
unreadable policy or audit configuration, a key misconfiguration, a Bugzilla
Expand Down
4 changes: 2 additions & 2 deletions crates/bugwarden/completions/_bugwarden
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ _bugwarden() {
stdio\:"Stdio transport. The API key comes from \`--api-key\` / \`BUGZILLA_API_KEY\` or \`--api-key-file\` at startup"))' \
'--host=[Host address for the MCP server to listen on (http transport only). Defaults to 127.0.0.1 or the MCP_HOST environment variable]:HOST:_default' \
'--port=[Port for the MCP server to listen on (http transport only). Defaults to 8000 or the MCP_PORT environment variable]:PORT:_default' \
'*--allowed-hosts=[Hostname or '\''host\:port'\'' authority accepted in an inbound Host header (http transport only). Repeat the flag to allow further hosts. Command line only, no environment variable; without it Host validation stays off and any Host header is served]:HOST:_default' \
'*--allowed-hosts=[Hostname or '\''host\:port'\'' authority accepted in an inbound Host header (http transport only). Repeat the flag, or separate entries with commas or whitespace; environment variable MCP_ALLOWED_HOSTS can also be used. Empty entries are dropped, so \`MCP_ALLOWED_HOSTS=\` is an unset (like \`BUGZILLA_API_KEY_FILE=\`); without a host, Host validation stays off and any Host header is served]:HOST:_default' \
'--api-key-header=[HTTP header for clients to send the Bugzilla API key. Defaults to '\''ApiKey'\'' or the MCP_API_KEY_HEADER environment variable. Not consulted in server-held key mode (--api-key-file over http)]:API_KEY_HEADER:_default' \
'--api-key=[Bugzilla API key. Required for --transport stdio (no HTTP headers exist there) unless --api-key-file provides it. Environment variable BUGZILLA_API_KEY can also be used. Ignored for --transport http (clients send the key per-request via the API key header; use --api-key-file for a server-held key)]:API_KEY:_default' \
'--api-key-file=[Path to a file holding the Bugzilla API key (e.g. a container secret or systemd LoadCredential path). Mutually exclusive with --api-key. Over http this selects server-held key mode\: every request is served with this key and the per-request API key header is not consulted. An empty value counts as absent, like --api-key (so \`BUGZILLA_API_KEY_FILE=\` is an unset, not an error)]:API_KEY_FILE:_files' \
'--policy=[Path to the guard policy TOML file. Environment variable BUGWARDEN_POLICY can also be used. Without it an allow-all default policy is used]:POLICY:_files' \
'--audit-config=[Path to the audit configuration TOML file (see examples/audit.toml). Environment variable BUGWARDEN_AUDIT_CONFIG can also be used. Without it no audit stream is written]:AUDIT_CONFIG:_files' \
'--use-auth-header[Use '\''Authorization\: Bearer'\'' header instead of the api_key query parameter (required for some Bugzilla instances)]' \
'--use-auth-header[Use '\''Authorization\: Bearer'\'' header instead of the api_key query parameter (required for some Bugzilla instances). Environment variable BUGZILLA_USE_AUTH_HEADER=true can also be used]' \
'--read-only[Disables all tools which modify the state of a bug. Environment variable MCP_READ_ONLY=true can also be used. Can only tighten the guard policy, never loosen it]' \
'-h[Print help (see more with '\''--help'\'')]' \
'--help[Print help (see more with '\''--help'\'')]' \
Expand Down
4 changes: 2 additions & 2 deletions crates/bugwarden/completions/bugwarden.fish
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ complete -c bugwarden -l transport -d 'Transport for the MCP server: \'http\' (d
stdio\t'Stdio transport. The API key comes from `--api-key` / `BUGZILLA_API_KEY` or `--api-key-file` at startup'"
complete -c bugwarden -l host -d 'Host address for the MCP server to listen on (http transport only). Defaults to 127.0.0.1 or the MCP_HOST environment variable' -r
complete -c bugwarden -l port -d 'Port for the MCP server to listen on (http transport only). Defaults to 8000 or the MCP_PORT environment variable' -r
complete -c bugwarden -l allowed-hosts -d 'Hostname or \'host:port\' authority accepted in an inbound Host header (http transport only). Repeat the flag to allow further hosts. Command line only, no environment variable; without it Host validation stays off and any Host header is served' -r
complete -c bugwarden -l allowed-hosts -d 'Hostname or \'host:port\' authority accepted in an inbound Host header (http transport only). Repeat the flag, or separate entries with commas or whitespace; environment variable MCP_ALLOWED_HOSTS can also be used. Empty entries are dropped, so `MCP_ALLOWED_HOSTS=` is an unset (like `BUGZILLA_API_KEY_FILE=`); without a host, Host validation stays off and any Host header is served' -r
complete -c bugwarden -l api-key-header -d 'HTTP header for clients to send the Bugzilla API key. Defaults to \'ApiKey\' or the MCP_API_KEY_HEADER environment variable. Not consulted in server-held key mode (--api-key-file over http)' -r
complete -c bugwarden -l api-key -d 'Bugzilla API key. Required for --transport stdio (no HTTP headers exist there) unless --api-key-file provides it. Environment variable BUGZILLA_API_KEY can also be used. Ignored for --transport http (clients send the key per-request via the API key header; use --api-key-file for a server-held key)' -r
complete -c bugwarden -l api-key-file -d 'Path to a file holding the Bugzilla API key (e.g. a container secret or systemd LoadCredential path). Mutually exclusive with --api-key. Over http this selects server-held key mode: every request is served with this key and the per-request API key header is not consulted. An empty value counts as absent, like --api-key (so `BUGZILLA_API_KEY_FILE=` is an unset, not an error)' -r -F
complete -c bugwarden -l policy -d 'Path to the guard policy TOML file. Environment variable BUGWARDEN_POLICY can also be used. Without it an allow-all default policy is used' -r -F
complete -c bugwarden -l audit-config -d 'Path to the audit configuration TOML file (see examples/audit.toml). Environment variable BUGWARDEN_AUDIT_CONFIG can also be used. Without it no audit stream is written' -r -F
complete -c bugwarden -l use-auth-header -d 'Use \'Authorization: Bearer\' header instead of the api_key query parameter (required for some Bugzilla instances)'
complete -c bugwarden -l use-auth-header -d 'Use \'Authorization: Bearer\' header instead of the api_key query parameter (required for some Bugzilla instances). Environment variable BUGZILLA_USE_AUTH_HEADER=true can also be used'
complete -c bugwarden -l read-only -d 'Disables all tools which modify the state of a bug. Environment variable MCP_READ_ONLY=true can also be used. Can only tighten the guard policy, never loosen it'
complete -c bugwarden -s h -l help -d 'Print help (see more with \'--help\')'
complete -c bugwarden -s V -l version -d 'Print version'
10 changes: 8 additions & 2 deletions crates/bugwarden/man/bugwarden.1
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Host address for the MCP server to listen on (http transport only). Defaults to
Port for the MCP server to listen on (http transport only). Defaults to 8000 or the MCP_PORT environment variable
.TP
\fB\-\-allowed\-hosts\fR \fI<HOST>\fR
Hostname or \*(Aqhost:port\*(Aq authority accepted in an inbound Host header (http transport only). Repeat the flag to allow further hosts. Command line only, no environment variable; without it Host validation stays off and any Host header is served
Hostname or \*(Aqhost:port\*(Aq authority accepted in an inbound Host header (http transport only). Repeat the flag, or separate entries with commas or whitespace; environment variable MCP_ALLOWED_HOSTS can also be used. Empty entries are dropped, so `MCP_ALLOWED_HOSTS=` is an unset (like `BUGZILLA_API_KEY_FILE=`); without a host, Host validation stays off and any Host header is served
.TP
\fB\-\-api\-key\-header\fR \fI<API_KEY_HEADER>\fR [default: ApiKey]
HTTP header for clients to send the Bugzilla API key. Defaults to \*(AqApiKey\*(Aq or the MCP_API_KEY_HEADER environment variable. Not consulted in server\-held key mode (\-\-api\-key\-file over http)
Expand All @@ -62,7 +62,7 @@ Bugzilla API key. Required for \-\-transport stdio (no HTTP headers exist there)
Path to a file holding the Bugzilla API key (e.g. a container secret or systemd LoadCredential path). Mutually exclusive with \-\-api\-key. Over http this selects server\-held key mode: every request is served with this key and the per\-request API key header is not consulted. An empty value counts as absent, like \-\-api\-key (so `BUGZILLA_API_KEY_FILE=` is an unset, not an error)
.TP
\fB\-\-use\-auth\-header\fR
Use \*(AqAuthorization: Bearer\*(Aq header instead of the api_key query parameter (required for some Bugzilla instances)
Use \*(AqAuthorization: Bearer\*(Aq header instead of the api_key query parameter (required for some Bugzilla instances). Environment variable BUGZILLA_USE_AUTH_HEADER=true can also be used
.TP
\fB\-\-read\-only\fR
Disables all tools which modify the state of a bug. Environment variable MCP_READ_ONLY=true can also be used. Can only tighten the guard policy, never loosen it
Expand Down Expand Up @@ -106,6 +106,9 @@ Fallback for \fB\-\-host\fR.
.B MCP_PORT
Fallback for \fB\-\-port\fR.
.TP
.B MCP_ALLOWED_HOSTS
Fallback for \fB\-\-allowed\-hosts\fR.
.TP
.B MCP_API_KEY_HEADER
Fallback for \fB\-\-api\-key\-header\fR.
.TP
Expand All @@ -115,6 +118,9 @@ Fallback for \fB\-\-api\-key\fR.
.B BUGZILLA_API_KEY_FILE
Fallback for \fB\-\-api\-key\-file\fR.
.TP
.B BUGZILLA_USE_AUTH_HEADER
Fallback for \fB\-\-use\-auth\-header\fR.
.TP
.B MCP_READ_ONLY
Fallback for \fB\-\-read\-only\fR.
.TP
Expand Down
102 changes: 97 additions & 5 deletions crates/bugwarden/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,12 @@ pub struct Cli {
pub port: u16,

/// Hostname or 'host:port' authority accepted in an inbound Host header
/// (http transport only). Repeat the flag to allow further hosts. Command
/// line only, no environment variable; without it Host validation stays
/// (http transport only). Repeat the flag, or separate entries with
/// commas or whitespace; environment variable MCP_ALLOWED_HOSTS can also
/// be used. Empty entries are dropped, so `MCP_ALLOWED_HOSTS=` is an unset
/// (like `BUGZILLA_API_KEY_FILE=`); without a host, Host validation stays
/// off and any Host header is served.
#[arg(long, value_name = "HOST")]
#[arg(long, env = "MCP_ALLOWED_HOSTS", value_name = "HOST")]
pub allowed_hosts: Vec<String>,

/// HTTP header for clients to send the Bugzilla API key. Defaults to
Expand Down Expand Up @@ -87,8 +89,9 @@ pub struct Cli {
pub api_key_file: Option<PathBuf>,

/// Use 'Authorization: Bearer' header instead of the api_key query
/// parameter (required for some Bugzilla instances).
#[arg(long)]
/// parameter (required for some Bugzilla instances). Environment
/// variable BUGZILLA_USE_AUTH_HEADER=true can also be used.
#[arg(long, env = "BUGZILLA_USE_AUTH_HEADER")]
pub use_auth_header: bool,

/// Disables all tools which modify the state of a bug. Environment
Expand Down Expand Up @@ -153,6 +156,26 @@ pub enum KeyCustody {
}

impl Cli {
/// The Host authorities this deployment answers to: every
/// `--allowed-hosts` occurrence, or `MCP_ALLOWED_HOSTS` when the flag is
/// absent — clap takes one source, not both — split on commas and
/// whitespace so one variable can carry a list, with empty entries
/// dropped.
///
/// Dropping them is what makes `MCP_ALLOWED_HOSTS=` read as unset (the
/// `BUGZILLA_API_KEY_FILE=` convention) instead of naming one unmatchable
/// authority: rmcp validates against a non-empty list, and an entry it
/// cannot parse is skipped, so a lone empty entry would refuse every
/// Host. Naming a host only ever narrows what the disabled state serves
/// (I9); dropping an empty one restores exactly the documented default.
pub fn resolved_allowed_hosts(&self) -> Vec<&str> {
self.allowed_hosts
.iter()
.flat_map(|entry| entry.split(|c: char| c == ',' || c.is_whitespace()))
.filter(|host| !host.is_empty())
.collect()
}

/// Resolve who holds the Bugzilla API key — the whole table, once, at
/// startup.
///
Expand Down Expand Up @@ -423,6 +446,75 @@ mod tests {
assert!(msg.contains("--transport stdio requires"), "{msg}");
}

#[test]
fn every_flag_declares_an_environment_fallback() {
// Container deployments configure bugwarden entirely through the
// environment (issues #31/#32), so a flag without an `env` fallback
// is a hole in that contract — this fails the moment one appears.
let mut cmd = command();
cmd.build();
let env_less: Vec<String> = cmd
.get_arguments()
.filter(|arg| !matches!(arg.get_id().as_str(), "help" | "version"))
.filter(|arg| arg.get_env().is_none())
.map(|arg| arg.get_id().to_string())
.collect();
assert!(
env_less.is_empty(),
"every flag needs an environment fallback, these have none: {env_less:?}"
);
let env_of = |id: &str| {
cmd.get_arguments()
.find(|arg| arg.get_id().as_str() == id)
.and_then(clap::Arg::get_env)
.map(|env| env.to_string_lossy().into_owned())
};
assert_eq!(
env_of("allowed_hosts").as_deref(),
Some("MCP_ALLOWED_HOSTS")
);
assert_eq!(
env_of("use_auth_header").as_deref(),
Some("BUGZILLA_USE_AUTH_HEADER")
);
}

#[test]
fn allowed_hosts_split_on_commas_and_whitespace_dropping_empties() {
// One environment variable has to be able to carry the whole list,
// and both sources feed the one field — so the normalization the
// environment needs is proven here, on the flag.
let cli = Cli::parse_from([
"bugwarden",
"--bugzilla-server",
"https://bugzilla.example.com",
"--allowed-hosts",
"a.example:8000, b.example",
"--allowed-hosts",
" c.example\td.example ",
"--allowed-hosts",
"",
]);
assert_eq!(
cli.resolved_allowed_hosts(),
["a.example:8000", "b.example", "c.example", "d.example"]
);

// An entry naming no host leaves validation off — `MCP_ALLOWED_HOSTS=`
// is unset, not "allow nothing" (see `resolved_allowed_hosts`).
let cli = Cli::parse_from([
"bugwarden",
"--bugzilla-server",
"https://bugzilla.example.com",
"--allowed-hosts",
" ,, ",
]);
assert!(
cli.resolved_allowed_hosts().is_empty(),
"an entry naming no host must leave Host validation off"
);
}

#[test]
fn cli_debug_never_prints_the_startup_key_i12() {
let mut cli = base_cli("stdio");
Expand Down
Loading