Skip to main content

basil_core/
agent_cli.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! `basil-agent`: the standalone Basil daemon.
6//!
7//! It loads a **`0600` sealed bundle** of the broker's own bootstrap credentials
8//! (vault-vh1, `designs/unlock-and-bundle.html`), unlocks it via an enabled +
9//! available slot, hands each backend its credential, zeroizes the plaintext,
10//! and hands off to [`crate::run_grpc`].
11//!
12//! The old plaintext bootstrap paths (`--vault-token` / `$VAULT_TOKEN` /
13//! `~/.vault-token` and the implicit-SPIFFE flags) are **gone**: the sealed
14//! bundle is the only supported source of bootstrap creds, with no fallback.
15//!
16//! The top-level `basil bundle` subcommand creates and manages sealed bundles.
17//! Core broker logic lives in this crate's library.
18
19// `indexing_slicing` gate has no test-allow config option, unlike unwrap/expect.
20// Tests index `serde_json::Value` (e.g. `doc["decision"]`) by construction.
21#![cfg_attr(test, allow(clippy::indexing_slicing))]
22
23use std::collections::BTreeMap;
24use std::io::{self, Write};
25use std::path::PathBuf;
26use std::sync::Arc;
27use std::time::Duration;
28
29use crate::catalog::{Class, KeyAlgorithm};
30use crate::seal::{BackendCred, CredBundle};
31use crate::service::broker::{BrokerIdentityRuntimeConfig, InvocationRuntimeConfig};
32use crate::{
33    AuditLog, Backend, BackendKind, BackendManager, BrokerLimits, BrokerState, CapabilityPolicy,
34    DEFAULT_MAX_ENCRYPT_SIZE, DEFAULT_MAX_PAYLOAD_SIZE, DEFAULT_ROTATION_GRACE_VERSIONS,
35    DEFAULT_SOCKET_MODE, DEFAULT_SVID_TTL_SECS, JwtRevocationStore, ReloadActor, ReloadInputs,
36    ServerConfig, SpiffeConfig, SpiffeVaultBackend, VaultBackend, enforce_capabilities, load,
37    reload_generation, run_grpc,
38};
39use crate::{bundle_cli, doctor, init, unlock};
40use anyhow::{Context, Result, bail};
41#[cfg(feature = "keystore-backend")]
42use basil_keystore_backend::StoreConfig;
43#[cfg(feature = "otlp")]
44use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
45#[cfg(feature = "otlp")]
46use opentelemetry_otlp::{Protocol, WithExportConfig};
47#[cfg(feature = "otlp")]
48use opentelemetry_sdk::logs::SdkLoggerProvider;
49use serde::Deserialize;
50use serde::de::{self, Visitor};
51use tokio::signal::unix::{SignalKind, signal};
52use tracing::{info, warn};
53use tracing_subscriber::{EnvFilter, fmt, prelude::*};
54
55const MAX_INVOCATION_TTL_SECS: u32 = 300;
56const MAX_INVOCATION_CLOCK_SKEW_SECS: u32 = 300;
57const MAX_INVOCATION_REPLAY_CACHE_CAPACITY: usize = 1_000_000;
58const BROKER_KEY_USE_LABEL: &str = "broker_key_use";
59const BROKER_RESPONSE_SIGNING_USE: &str = "response-signing";
60const BROKER_REQUEST_ENCRYPTION_USE: &str = "request-encryption";
61
62/// basil agent: a signing proxy over a Vault transit engine (Vault or `OpenBao`).
63///
64/// `run` launches the broker daemon.
65/// Arguments for the daemon (`run`) path.
66#[derive(Debug, clap::Args)]
67pub struct RunArgs {
68    #[command(flatten)]
69    overrides: ConfigOverrides,
70}
71
72/// Arguments for the unified policy **`explain`** verb (`basil-1zx.3`).
73///
74/// One command, two sources of truth for the same subject/op/key → allow/deny
75/// question:
76///
77/// * **Default (offline dry-run, `basil-4vf`):** loads ONLY the catalog + policy
78///   JSON (no sealed bundle, no backend, no socket), builds the real
79///   [`Pdp`](crate::catalog::Pdp), and evaluates the proposed tuple through the
80///   SAME matcher enforcement uses. It NEVER performs the op, reads a secret, or
81///   talks to a backend: safe to run anywhere, before rollout. `--effective`
82///   previews every grant for the subject.
83/// * **`--live`:** queries the RUNNING broker's serving generation over the
84///   global `--socket` (needs the `explain` admin permission). The offline
85///   config-override paths are irrelevant here, and `--effective` is offline-only.
86///
87/// What the PDP matches on: authorization binds to a registered **subject**.
88/// The offline tool evaluates that subject name directly; Unix uid/gid
89/// resolution is covered by the loader and runtime actor-resolution tests.
90#[derive(Debug, clap::Args)]
91pub struct ExplainArgs {
92    /// The policy subject to evaluate.
93    #[arg(long)]
94    subject: String,
95
96    /// The op to evaluate (`get`, `list`, `sign`, `set`, `mint`, ...). Required
97    /// unless `--effective` is given.
98    #[arg(long, value_parser = parse_op, required_unless_present = "effective")]
99    op: Option<crate::catalog::Op>,
100
101    /// The catalog key/target to evaluate. Required unless `--effective`.
102    #[arg(long, required_unless_present = "effective")]
103    key: Option<String>,
104
105    /// Preview EVERY `(key, op)` the subject is granted across the whole catalog,
106    /// instead of a single tuple. Ignores `--op`/`--key`. Offline-only.
107    #[arg(long)]
108    effective: bool,
109
110    /// Query the RUNNING broker's serving generation over the global `--socket`
111    /// (needs the `explain` admin permission) instead of an offline file dry-run.
112    /// The config-override paths (catalog/policy/config/bundle) are ignored on the
113    /// live path; `--effective` is offline-only and conflicts with `--live`.
114    #[arg(long, conflicts_with = "effective")]
115    live: bool,
116
117    /// Emit a stable machine-readable JSON object instead of human-readable text.
118    #[arg(long)]
119    json: bool,
120
121    #[command(flatten)]
122    overrides: ConfigOverrides,
123}
124
125impl ExplainArgs {
126    /// True when `--live` was given: query the running broker rather than files.
127    #[must_use]
128    pub const fn is_live(&self) -> bool {
129        self.live
130    }
131
132    /// The registered subject to evaluate.
133    #[must_use]
134    pub fn subject(&self) -> &str {
135        &self.subject
136    }
137
138    /// The stable op token to evaluate, when `--op` was given. Always present on
139    /// the live path, where `--effective` (the only thing that makes `--op`
140    /// optional) is rejected by clap.
141    #[must_use]
142    pub fn op_token(&self) -> Option<&'static str> {
143        self.op.map(crate::catalog::Op::token)
144    }
145
146    /// The catalog key/target to evaluate, when `--key` was given.
147    #[must_use]
148    pub fn key(&self) -> Option<&str> {
149        self.key.as_deref()
150    }
151
152    /// Whether `--json` machine-readable output was requested.
153    #[must_use]
154    pub const fn json(&self) -> bool {
155        self.json
156    }
157}
158
159/// clap value parser for a policy [`Op`](crate::catalog::Op).
160fn parse_op(s: &str) -> Result<crate::catalog::Op, String> {
161    crate::catalog::Op::parse(s).map_err(|e| e.to_string())
162}
163
164/// Arguments for the preflight **`doctor`** path (`basil-f0j`).
165///
166/// Resolves the same catalog/policy/bundle/socket config `run` uses, then runs a
167/// set of independent read-only diagnostics: catalog/policy load, backend
168/// capability enforcement, invocation broker-identity/key bindings, feature
169/// compatibility, backend binary on PATH, socket, bundle perms/freshness, and
170/// backend reachability. By default it NEVER unlocks the bundle, binds the socket,
171/// or mutates anything. `--keys` explicitly adds an authenticated read-only
172/// per-key existence probe that unlocks the bundle but still never
173/// reconciles/generates/mutates.
174///
175/// Exit model: non-zero only for FATAL conditions (those that would stop the
176/// broker from starting). Warnings are report-only and exit 0, unless `--strict`
177/// is given, which makes warnings exit non-zero too.
178#[derive(Debug, clap::Args)]
179pub struct DoctorArgs {
180    /// Emit a stable machine-readable JSON document instead of human-readable text.
181    #[arg(long)]
182    json: bool,
183
184    /// Unlock the sealed bundle and run the authenticated read-only per-key
185    /// existence probe (an authenticated tier on top of the offline checks).
186    #[arg(long)]
187    keys: bool,
188
189    /// Treat warnings as failures: exit non-zero if any check is a warning, not
190    /// just on a fatal condition.
191    #[arg(long)]
192    strict: bool,
193
194    #[command(flatten)]
195    overrides: ConfigOverrides,
196}
197
198/// Backend-connection defaults applied to every cred that pins none of its own:
199/// the fallback Vault address and transit mount, plus the JWT-auth parameters a
200/// [`BackendCred::SpiffeSigner`] needs to exchange its self-minted SVID for a
201/// short-lived backend token. Threaded from [`SetupArgs`] to each constructor.
202struct BackendDefaults<'a> {
203    /// Fallback Vault address used when a cred pins no `addr`.
204    vault_addr: &'a str,
205    /// Transit secrets-engine mount path.
206    transit_mount: &'a str,
207    /// JWT auth-method mount path (`auth/<mount>/login`).
208    jwt_auth_mount: &'a str,
209    /// Vault jwt role bound to the broker's SPIFFE id (empty ⇒ unset).
210    jwt_role: &'a str,
211    /// Audience the jwt role's `bound_audiences` expects.
212    jwt_audience: &'a str,
213    /// Lifetime of each self-minted JWT-SVID.
214    svid_ttl: Duration,
215    /// db-keystore encryption cipher.
216    #[cfg(feature = "db-keystore")]
217    db_keystore_cipher: &'a str,
218    /// Default `1Password` provider URI when the sealed cred leaves it empty.
219    #[cfg(feature = "keystore-backend")]
220    onepassword_provider_uri: &'a str,
221    /// Default `1Password` project when the sealed cred leaves it empty.
222    #[cfg(feature = "keystore-backend")]
223    onepassword_project: &'a str,
224    /// Default `1Password` profile when the sealed cred leaves it empty.
225    #[cfg(feature = "keystore-backend")]
226    onepassword_profile: &'a str,
227}
228
229/// Build a [`Backend`] from a single decrypted [`BackendCred`].
230///
231/// `VaultToken` is used directly; `VaultAppRole` is exchanged at the bao `AppRole`
232/// login endpoint for a short-lived token first; `SpiffeSigner` boots a
233/// [`SpiffeVaultBackend`] that self-mints a JWT-SVID and exchanges it at the
234/// jwt-auth login endpoint. Other variants are not yet wired to a backend
235/// constructor (logged OFIs) and fail closed.
236async fn backend_from_cred(
237    name: &str,
238    backend_ref: &crate::catalog::BackendRef,
239    cred: &BackendCred,
240    defaults: &BackendDefaults<'_>,
241) -> Result<Box<dyn Backend>> {
242    match (backend_ref.kind, cred) {
243        (BackendKind::Keystore, _) => backend_from_keystore_cred(name, backend_ref, cred, defaults),
244        (BackendKind::AwsKms, cred) => backend_from_aws_kms_cred(name, cred).await,
245        (BackendKind::GcpKms, cred) => backend_from_gcp_kms_cred(name, cred).await,
246        (
247            BackendKind::Vault,
248            BackendCred::DbKeystoreDek { .. }
249            | BackendCred::OnePassword { .. }
250            | BackendCred::AwsKms { .. }
251            | BackendCred::GcpKms { .. },
252        ) => {
253            bail!("backend `{name}`: non-vault credential cannot construct a vault backend")
254        }
255        (BackendKind::Vault, cred) => backend_from_vault_cred(name, cred, defaults).await,
256    }
257}
258
259/// Build an AWS KMS transit backend from an [`BackendCred::AwsKms`] cred.
260#[cfg(feature = "aws-kms")]
261async fn backend_from_aws_kms_cred(name: &str, cred: &BackendCred) -> Result<Box<dyn Backend>> {
262    match cred {
263        BackendCred::AwsKms { region, profile } => {
264            let backend = crate::core::backend::aws_kms::AwsKmsBackend::new(region, profile)
265                .await
266                .with_context(|| format!("building aws-kms backend for `{name}`"))?;
267            Ok(Box::new(backend))
268        }
269        _ => bail!("backend `{name}`: kind `aws-kms` requires an `AwsKms` credential"),
270    }
271}
272
273#[cfg(not(feature = "aws-kms"))]
274#[allow(clippy::unused_async)]
275async fn backend_from_aws_kms_cred(name: &str, _cred: &BackendCred) -> Result<Box<dyn Backend>> {
276    bail!("backend `{name}`: kind `aws-kms` requires the aws-kms feature")
277}
278
279/// Build a GCP Cloud KMS transit backend from a [`BackendCred::GcpKms`] cred.
280#[cfg(feature = "gcp-kms")]
281async fn backend_from_gcp_kms_cred(name: &str, cred: &BackendCred) -> Result<Box<dyn Backend>> {
282    match cred {
283        BackendCred::GcpKms {
284            project,
285            location,
286            key_ring,
287            service_account_json,
288        } => {
289            let backend = crate::core::backend::gcp_kms::GcpKmsBackend::new(
290                project,
291                location,
292                key_ring,
293                service_account_json
294                    .as_ref()
295                    .map(zero_secrets::SecretString::expose_secret),
296            )
297            .await
298            .with_context(|| format!("building gcp-kms backend for `{name}`"))?;
299            Ok(Box::new(backend))
300        }
301        _ => bail!("backend `{name}`: kind `gcp-kms` requires a `GcpKms` credential"),
302    }
303}
304
305#[cfg(not(feature = "gcp-kms"))]
306#[allow(clippy::unused_async)]
307async fn backend_from_gcp_kms_cred(name: &str, _cred: &BackendCred) -> Result<Box<dyn Backend>> {
308    bail!("backend `{name}`: kind `gcp-kms` requires the gcp-kms feature")
309}
310
311async fn backend_from_vault_cred(
312    name: &str,
313    cred: &BackendCred,
314    defaults: &BackendDefaults<'_>,
315) -> Result<Box<dyn Backend>> {
316    match cred {
317        BackendCred::VaultToken { token, addr } => {
318            let addr = addr.as_deref().unwrap_or(defaults.vault_addr);
319            let backend = VaultBackend::new(addr, token.expose_secret(), defaults.transit_mount)
320                .with_context(|| format!("building token backend for `{name}`"))?;
321            Ok(Box::new(backend))
322        }
323        BackendCred::VaultAppRole {
324            role_id,
325            secret_id,
326            addr,
327        } => {
328            let addr = addr.as_deref().unwrap_or(defaults.vault_addr);
329            let token = unlock::approle_login(addr, role_id, secret_id.expose_secret())
330                .await
331                .with_context(|| format!("AppRole login for backend `{name}`"))?;
332            let backend = VaultBackend::new(addr, token.as_str(), defaults.transit_mount)
333                .with_context(|| format!("building approle backend for `{name}`"))?;
334            Ok(Box::new(backend))
335        }
336        BackendCred::SpiffeSigner { key_pem, spiffe_id } => {
337            // The jwt role is a deployment artifact with no safe default; an empty
338            // role would make every login fail at the backend. Fail closed at
339            // startup with an actionable error instead.
340            anyhow::ensure!(
341                !defaults.jwt_role.trim().is_empty(),
342                "backend `{name}`: SpiffeSigner cred requires a jwt role \
343                 (config key `jwt-role`); none set, failing closed"
344            );
345            let cfg = SpiffeConfig {
346                vault_addr: defaults.vault_addr.to_string(),
347                transit_mount: defaults.transit_mount.to_string(),
348                jwt_auth_mount: defaults.jwt_auth_mount.to_string(),
349                role: defaults.jwt_role.to_string(),
350                spiffe_id: spiffe_id.clone(),
351                audience: defaults.jwt_audience.to_string(),
352                svid_ttl: defaults.svid_ttl,
353            };
354            let backend = SpiffeVaultBackend::from_signer(key_pem.expose_secret(), cfg)
355                .with_context(|| format!("building spiffe-signer backend for `{name}`"))?;
356            Ok(Box::new(backend))
357        }
358        BackendCred::DbKeystoreDek { .. }
359        | BackendCred::OnePassword { .. }
360        | BackendCred::AwsKms { .. }
361        | BackendCred::GcpKms { .. } => {
362            bail!("backend `{name}`: non-vault credential cannot construct a vault backend")
363        }
364        BackendCred::Opaque { kind, .. } => bail!(
365            "backend `{name}`: opaque cred of kind `{kind}` has no backend constructor; fail closed"
366        ),
367    }
368}
369
370#[cfg(feature = "keystore-backend")]
371fn backend_from_keystore_cred(
372    name: &str,
373    backend_ref: &crate::catalog::BackendRef,
374    cred: &BackendCred,
375    defaults: &BackendDefaults<'_>,
376) -> Result<Box<dyn Backend>> {
377    #[cfg(not(feature = "db-keystore"))]
378    let _ = backend_ref;
379
380    match cred {
381        #[cfg(feature = "db-keystore")]
382        BackendCred::DbKeystoreDek { dek } => {
383            let backend = crate::KeystoreBackend::open(StoreConfig::DbKeystore {
384                path: PathBuf::from(&backend_ref.addr),
385                cipher: defaults.db_keystore_cipher.to_string(),
386                dek: dek.clone(),
387            })
388            .with_context(|| format!("building db-keystore backend for `{name}`"))?;
389            Ok(Box::new(backend))
390        }
391        #[cfg(not(feature = "db-keystore"))]
392        BackendCred::DbKeystoreDek { .. } => {
393            bail!("backend `{name}`: DbKeystoreDek requires the db-keystore feature")
394        }
395        #[cfg(feature = "onepassword")]
396        BackendCred::OnePassword {
397            provider_uri,
398            project,
399            profile,
400        } => {
401            let provider_uri = default_if_empty(provider_uri, defaults.onepassword_provider_uri);
402            let project = default_if_empty(project, defaults.onepassword_project);
403            let profile = default_if_empty(profile, defaults.onepassword_profile);
404            anyhow::ensure!(
405                !provider_uri.trim().is_empty(),
406                "backend `{name}`: `OnePassword` cred requires provider_uri or config key \
407                 `onepassword-provider-uri`"
408            );
409            let backend = crate::KeystoreBackend::open(StoreConfig::OnePassword {
410                provider_uri: provider_uri.to_string(),
411                project: project.to_string(),
412                profile: profile.to_string(),
413            })
414            .with_context(|| format!("building onepassword backend for `{name}`"))?;
415            Ok(Box::new(backend))
416        }
417        #[cfg(not(feature = "onepassword"))]
418        BackendCred::OnePassword { .. } => {
419            bail!("backend `{name}`: `OnePassword` requires the onepassword feature")
420        }
421        BackendCred::VaultToken { .. }
422        | BackendCred::VaultAppRole { .. }
423        | BackendCred::SpiffeSigner { .. } => {
424            bail!("backend `{name}`: vault credential cannot construct a keystore backend")
425        }
426        BackendCred::AwsKms { .. } | BackendCred::GcpKms { .. } => {
427            bail!("backend `{name}`: KMS credential cannot construct a keystore backend")
428        }
429        BackendCred::Opaque { kind, .. } => bail!(
430            "backend `{name}`: opaque cred of kind `{kind}` has no keystore constructor; fail closed"
431        ),
432    }
433}
434
435#[cfg(not(feature = "keystore-backend"))]
436fn backend_from_keystore_cred(
437    name: &str,
438    _backend_ref: &crate::catalog::BackendRef,
439    _cred: &BackendCred,
440    _defaults: &BackendDefaults<'_>,
441) -> Result<Box<dyn Backend>> {
442    bail!("backend `{name}`: kind `keystore` requires the keystore-backend feature")
443}
444
445#[cfg(feature = "keystore-backend")]
446fn default_if_empty<'a>(value: &'a str, default: &'a str) -> &'a str {
447    if value.trim().is_empty() {
448        default
449    } else {
450        value
451    }
452}
453
454/// Build the manager from the catalog + the decrypted creds. Every catalog
455/// backend name must have a matching cred in the bundle (fail closed otherwise).
456async fn build_manager(
457    defaults: &BackendDefaults<'_>,
458    catalog: crate::Catalog,
459    creds: &CredBundle,
460) -> Result<(BackendManager, String)> {
461    anyhow::ensure!(
462        !catalog.backends.is_empty(),
463        "catalog declares no backends; nothing to route to"
464    );
465
466    let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
467    let mut label: Option<String> = None;
468    for (name, backend_ref) in &catalog.backends {
469        let cred = creds.backends.get(name).with_context(|| {
470            format!("sealed bundle has no credential for catalog backend `{name}`")
471        })?;
472        let backend = backend_from_cred(name, backend_ref, cred, defaults).await?;
473        if label.is_none() {
474            label = Some(backend.kind().to_string());
475        }
476        backends.insert(name.clone(), backend);
477    }
478    let backend_label = label.unwrap_or_else(|| "unknown".to_string());
479
480    let manager = BackendManager::new(catalog, backends)
481        .context("constructing backend manager from catalog")?;
482    Ok((manager, backend_label))
483}
484
485/// The only startup settings that may be supplied outside the config file.
486#[derive(Debug, Clone, clap::Args)]
487pub struct ConfigOverrides {
488    /// Path to the TOML daemon config file.
489    #[arg(short = 'c', long, env = "BASIL_CONFIG")]
490    pub(crate) config: Option<PathBuf>,
491
492    /// Path to the exported **catalog** JSON (the key inventory + routing table).
493    #[arg(long, env = "BASIL_CATALOG")]
494    pub(crate) catalog: Option<PathBuf>,
495
496    /// Path to the exported **policy** JSON (the authorization allow-list).
497    #[arg(long, env = "BASIL_POLICY")]
498    pub(crate) policy: Option<PathBuf>,
499
500    /// Path to the `0600` sealed credential bundle (vault-vh1).
501    #[arg(long, env = "BASIL_BUNDLE")]
502    pub(crate) bundle: Option<PathBuf>,
503
504    /// Unix socket to listen on.
505    #[arg(long, env = "BASIL_SOCKET")]
506    pub(crate) socket: Option<String>,
507
508    /// Default Vault address (used when a cred pins no `addr`).
509    #[arg(long, env = "VAULT_ADDR")]
510    pub(crate) vault_addr: Option<String>,
511}
512
513/// TOML startup config for `run` and `check`.
514///
515/// Top-level keys intentionally mirror the former long flag names, e.g.
516/// `vault-addr`, `max-encrypt-size`, and `capability-policy`.
517#[derive(Debug, Clone, Default, Deserialize)]
518#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
519pub(crate) struct AgentConfigFile {
520    pub(crate) catalog: Option<PathBuf>,
521    pub(crate) policy: Option<PathBuf>,
522    pub(crate) bundle: Option<PathBuf>,
523    pub(crate) socket: Option<String>,
524    pub(crate) socket_mode: Option<SocketMode>,
525    pub(crate) socket_group: Option<String>,
526    pub(crate) vault_addr: Option<String>,
527    pub(crate) transit_mount: Option<String>,
528    pub(crate) jwt_auth_mount: Option<String>,
529    pub(crate) jwt_role: Option<String>,
530    pub(crate) jwt_audience: Option<String>,
531    pub(crate) svid_ttl_secs: Option<u64>,
532    pub(crate) capability_policy: Option<String>,
533    #[cfg(feature = "keystore-backend")]
534    pub(crate) db_keystore_cipher: Option<String>,
535    #[cfg(feature = "keystore-backend")]
536    pub(crate) onepassword_provider_uri: Option<String>,
537    #[cfg(feature = "keystore-backend")]
538    pub(crate) onepassword_project: Option<String>,
539    #[cfg(feature = "keystore-backend")]
540    pub(crate) onepassword_profile: Option<String>,
541    pub(crate) max_encrypt_size: Option<usize>,
542    pub(crate) max_payload_size: Option<usize>,
543    pub(crate) grace_versions: Option<u32>,
544    pub(crate) retain_versions: Option<u32>,
545    pub(crate) retention_sweep_secs: Option<u64>,
546    pub(crate) audit_log: Option<PathBuf>,
547    pub(crate) no_reconcile: Option<bool>,
548    pub(crate) logging: LoggingConfigFile,
549    pub(crate) unlock: UnlockConfigFile,
550    pub(crate) broker_identity: BrokerIdentityConfigFile,
551    pub(crate) invocation: InvocationConfigFile,
552    pub(crate) jwks: JwksConfigFile,
553}
554
555#[derive(Debug, Clone, Deserialize)]
556#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
557pub(crate) struct LoggingConfigFile {
558    stdout: StdoutLoggingSinkConfigFile,
559    journald: LoggingSinkConfigFile,
560    file: FileLoggingConfigFile,
561    opentelemetry: OpenTelemetryLoggingConfigFile,
562}
563
564impl Default for LoggingConfigFile {
565    fn default() -> Self {
566        Self {
567            stdout: StdoutLoggingSinkConfigFile::default(),
568            journald: LoggingSinkConfigFile { enable: true },
569            file: FileLoggingConfigFile::default(),
570            opentelemetry: OpenTelemetryLoggingConfigFile::default(),
571        }
572    }
573}
574
575impl LoggingConfigFile {
576    fn stdout_enabled(&self) -> bool {
577        self.stdout.enable.unwrap_or(!self.file.enable)
578    }
579}
580
581#[derive(Debug, Clone, Default, Deserialize)]
582#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
583struct StdoutLoggingSinkConfigFile {
584    enable: Option<bool>,
585}
586
587#[derive(Debug, Clone, Deserialize)]
588#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
589struct LoggingSinkConfigFile {
590    enable: bool,
591}
592
593impl Default for LoggingSinkConfigFile {
594    fn default() -> Self {
595        Self { enable: true }
596    }
597}
598
599#[derive(Debug, Clone, Deserialize)]
600#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
601struct FileLoggingConfigFile {
602    enable: bool,
603    #[serde(alias = "path")]
604    dir: Option<PathBuf>,
605    prefix: String,
606    rotation: FileLoggingRotation,
607}
608
609impl Default for FileLoggingConfigFile {
610    fn default() -> Self {
611        Self {
612            enable: false,
613            dir: None,
614            prefix: "basil-agent-".to_owned(),
615            rotation: FileLoggingRotation::Daily,
616        }
617    }
618}
619
620#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
621#[serde(rename_all = "kebab-case")]
622enum FileLoggingRotation {
623    None,
624    Hourly,
625    Daily,
626    Weekly,
627}
628
629impl From<FileLoggingRotation> for tracing_appender::rolling::Rotation {
630    fn from(rotation: FileLoggingRotation) -> Self {
631        match rotation {
632            FileLoggingRotation::None => Self::NEVER,
633            FileLoggingRotation::Hourly => Self::HOURLY,
634            FileLoggingRotation::Daily => Self::DAILY,
635            FileLoggingRotation::Weekly => Self::WEEKLY,
636        }
637    }
638}
639
640#[derive(Debug, Clone, Deserialize)]
641#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
642struct OpenTelemetryLoggingConfigFile {
643    enable: bool,
644    endpoint: Option<String>,
645    protocol: OpenTelemetryProtocol,
646}
647
648impl Default for OpenTelemetryLoggingConfigFile {
649    fn default() -> Self {
650        Self {
651            enable: false,
652            endpoint: None,
653            protocol: OpenTelemetryProtocol::Grpc,
654        }
655    }
656}
657
658#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
659#[serde(rename_all = "kebab-case")]
660enum OpenTelemetryProtocol {
661    Grpc,
662    HttpBinary,
663    HttpJson,
664}
665
666#[derive(Debug, Clone, Copy, PartialEq, Eq)]
667pub(crate) struct SocketMode(pub(crate) u32);
668
669impl SocketMode {
670    const MAX: u32 = 0o7777;
671}
672
673impl<'de> Deserialize<'de> for SocketMode {
674    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
675    where
676        D: serde::Deserializer<'de>,
677    {
678        deserializer.deserialize_any(SocketModeVisitor)
679    }
680}
681
682struct SocketModeVisitor;
683
684impl Visitor<'_> for SocketModeVisitor {
685    type Value = SocketMode;
686
687    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
688        formatter.write_str("an octal socket mode string like \"0660\" or integer mode")
689    }
690
691    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
692    where
693        E: de::Error,
694    {
695        let mode = u32::try_from(value).map_err(|_| E::custom("socket mode is too large"))?;
696        validate_socket_mode(mode)
697            .map(SocketMode)
698            .map_err(E::custom)
699    }
700
701    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
702    where
703        E: de::Error,
704    {
705        let mode = u32::try_from(value).map_err(|_| E::custom("socket mode must be positive"))?;
706        validate_socket_mode(mode)
707            .map(SocketMode)
708            .map_err(E::custom)
709    }
710
711    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
712    where
713        E: de::Error,
714    {
715        parse_socket_mode(value).map(SocketMode).map_err(E::custom)
716    }
717}
718
719fn parse_socket_mode(value: &str) -> Result<u32, String> {
720    let trimmed = value.trim();
721    let octal = trimmed.strip_prefix("0o").unwrap_or(trimmed);
722    if octal.is_empty() {
723        return Err("socket mode must not be empty".to_string());
724    }
725    if !octal.chars().all(|c| matches!(c, '0'..='7')) {
726        return Err(format!("socket mode `{value}` must be octal"));
727    }
728    let mode = u32::from_str_radix(octal, 8)
729        .map_err(|err| format!("socket mode `{value}` is invalid: {err}"))?;
730    validate_socket_mode(mode)
731}
732
733fn validate_socket_mode(mode: u32) -> Result<u32, String> {
734    if mode > SocketMode::MAX {
735        return Err(format!("socket mode {mode:o} exceeds 07777"));
736    }
737    Ok(mode)
738}
739
740#[derive(Debug, Clone, Default, Deserialize)]
741#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
742pub(crate) struct UnlockConfigFile {
743    age_yubikey: Option<bool>,
744    bip39_phrase_file: Option<PathBuf>,
745    unlock_tpm: Option<bool>,
746    unlock_passphrase_file: Option<PathBuf>,
747    unlock_passphrase_no_wipe: Option<bool>,
748    strict_bundle_perms: Option<bool>,
749}
750
751/// The `[broker-identity]` config section for response protection.
752#[derive(Debug, Clone, Default, Deserialize)]
753#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
754pub(crate) struct BrokerIdentityConfigFile {
755    /// Stable broker identity / audience URI.
756    id: Option<String>,
757    /// Catalog key id used to sign invocation responses.
758    response_signing_key_id: Option<String>,
759}
760
761/// The `[invocation]` config section for the sealed invocation gRPC service.
762///
763/// `enable` defaults to `false`: the service is registered so clients get a
764/// stable method, but it rejects all requests unless an operator explicitly
765/// enables bridged sealed invocations.
766#[derive(Debug, Clone, Deserialize)]
767#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
768pub(crate) struct InvocationConfigFile {
769    /// Accept sealed invocation requests. Default `false`.
770    enable: bool,
771    /// Accepted broker audiences for sealed invocations.
772    audience: Vec<String>,
773    /// Catalog key id whose public half receives sealed invocation requests.
774    request_encryption_key_id: Option<String>,
775    /// Maximum accepted signed request TTL in seconds.
776    max_ttl_secs: u32,
777    /// Allowed clock skew in seconds.
778    clock_skew_secs: u32,
779    /// Maximum in-memory replay-cache entries.
780    replay_cache_capacity: usize,
781}
782
783impl Default for InvocationConfigFile {
784    fn default() -> Self {
785        Self {
786            enable: false,
787            audience: Vec::new(),
788            request_encryption_key_id: None,
789            max_ttl_secs: basil_proto::invocation::DEFAULT_EXPIRES_AFTER_SECS,
790            clock_skew_secs: 30,
791            replay_cache_capacity: 4096,
792        }
793    }
794}
795
796/// The `[jwks]` config section: the **opt-in** JWKS HTTP surface (`basil-uce.1`).
797///
798/// `enable` **defaults to `false`**: the HTTP port is NOT opened unless an
799/// operator explicitly turns it on. This is the broker's first HTTP endpoint in
800/// an otherwise gRPC-over-unix-socket system, so it is strictly opt-in. When
801/// enabled, a standard verifier can fetch the issuer JWK set (public keys only)
802/// and validate Basil-minted JWT-SVID signatures without SPIFFE plumbing.
803#[derive(Debug, Clone, Deserialize)]
804#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
805pub(crate) struct JwksConfigFile {
806    /// Open the JWKS HTTP listener. Default `false` (no port opened).
807    enable: bool,
808    /// Address to bind when `enable` is true. Default `127.0.0.1:8201`.
809    listen: String,
810    /// Public base URL the surface is reachable at (no trailing slash), used as
811    /// the OIDC discovery `issuer` and the base for `jwks_uri`. When unset, the
812    /// `/.well-known/openid-configuration` discovery document is **not** served
813    /// (the bare JWKS endpoints still are). Example: `https://basil.example.com`.
814    issuer: Option<String>,
815    /// Optional native TLS listener settings.
816    tls: JwksTlsConfigFile,
817}
818
819#[derive(Debug, Clone, Default, Deserialize)]
820#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
821pub(crate) struct JwksTlsConfigFile {
822    /// Serve the JWKS surface over TLS. Requires the `http-tls` cargo feature.
823    enable: bool,
824    /// PEM certificate chain file for the TLS listener.
825    cert_file: Option<PathBuf>,
826    /// PEM private-key file for the TLS listener.
827    key_file: Option<PathBuf>,
828}
829
830impl Default for JwksConfigFile {
831    fn default() -> Self {
832        Self {
833            enable: false,
834            listen: "127.0.0.1:8201".to_string(),
835            issuer: None,
836            tls: JwksTlsConfigFile::default(),
837        }
838    }
839}
840
841/// Resolved JWKS HTTP-surface settings threaded into [`RunConfig`].
842#[cfg(feature = "http")]
843#[derive(Debug, Clone)]
844struct JwksConfig {
845    enable: bool,
846    listen: std::net::SocketAddr,
847    issuer: Option<String>,
848    tls: Option<crate::jwks::JwksTlsConfig>,
849}
850
851/// The setup inputs shared by every subcommand that needs a live
852/// [`BackendManager`].
853#[derive(Debug, Clone)]
854struct SetupArgs {
855    catalog: PathBuf,
856    policy: PathBuf,
857    bundle: PathBuf,
858    vault_addr: String,
859    transit_mount: String,
860    jwt_auth_mount: String,
861    jwt_role: String,
862    jwt_audience: String,
863    svid_ttl_secs: u64,
864    capability_policy: CapabilityPolicy,
865    #[cfg(feature = "db-keystore")]
866    db_keystore_cipher: String,
867    #[cfg(feature = "keystore-backend")]
868    onepassword_provider_uri: String,
869    #[cfg(feature = "keystore-backend")]
870    onepassword_project: String,
871    #[cfg(feature = "keystore-backend")]
872    onepassword_profile: String,
873    unlock: unlock::UnlockArgs,
874}
875
876#[derive(Debug, Clone)]
877struct RunConfig {
878    socket: Option<String>,
879    socket_mode: u32,
880    socket_group: Option<String>,
881    max_encrypt_size: usize,
882    max_payload_size: usize,
883    grace_versions: u32,
884    retain_versions: Option<u32>,
885    retention_sweep_secs: u64,
886    audit_log: Option<PathBuf>,
887    no_reconcile: bool,
888    invocation: InvocationRuntimeConfig,
889    #[cfg(feature = "http")]
890    jwks: JwksConfig,
891    setup: SetupArgs,
892}
893
894/// clap value parser for [`CapabilityPolicy`] (`strict` | `degraded` | `off`).
895fn parse_capability_policy(s: &str) -> Result<CapabilityPolicy, String> {
896    s.parse()
897}
898
899fn load_run_config(overrides: &ConfigOverrides) -> Result<RunConfig> {
900    let file = load_config_file(overrides)?;
901    let setup = build_setup(&file, overrides)?;
902    #[cfg(feature = "http")]
903    let jwks = resolve_jwks_config(&file.jwks)?;
904    #[cfg(not(feature = "http"))]
905    reject_jwks_config(&file.jwks)?;
906    Ok(RunConfig {
907        socket: overrides.socket.clone().or(file.socket),
908        socket_mode: file.socket_mode.map_or(DEFAULT_SOCKET_MODE, |mode| mode.0),
909        socket_group: file.socket_group,
910        max_encrypt_size: file.max_encrypt_size.unwrap_or(DEFAULT_MAX_ENCRYPT_SIZE),
911        max_payload_size: file.max_payload_size.unwrap_or(DEFAULT_MAX_PAYLOAD_SIZE),
912        grace_versions: file
913            .grace_versions
914            .unwrap_or(DEFAULT_ROTATION_GRACE_VERSIONS),
915        retain_versions: file.retain_versions,
916        retention_sweep_secs: file.retention_sweep_secs.unwrap_or(3600),
917        audit_log: file.audit_log,
918        no_reconcile: file.no_reconcile.unwrap_or(false),
919        invocation: resolve_invocation_config(&file.broker_identity, &file.invocation)?,
920        #[cfg(feature = "http")]
921        jwks,
922        setup,
923    })
924}
925
926fn resolve_invocation_config(
927    identity_file: &BrokerIdentityConfigFile,
928    file: &InvocationConfigFile,
929) -> Result<InvocationRuntimeConfig> {
930    if file.max_ttl_secs == 0 {
931        bail!("invocation.max-ttl-secs must be greater than zero");
932    }
933    if file.max_ttl_secs > MAX_INVOCATION_TTL_SECS {
934        bail!("invocation.max-ttl-secs must be at most {MAX_INVOCATION_TTL_SECS} seconds");
935    }
936    if file.clock_skew_secs > MAX_INVOCATION_CLOCK_SKEW_SECS {
937        bail!(
938            "invocation.clock-skew-secs must be at most {MAX_INVOCATION_CLOCK_SKEW_SECS} seconds"
939        );
940    }
941    if file.replay_cache_capacity == 0 {
942        bail!("invocation.replay-cache-capacity must be greater than zero");
943    }
944    if file.replay_cache_capacity > MAX_INVOCATION_REPLAY_CACHE_CAPACITY {
945        bail!(
946            "invocation.replay-cache-capacity must be at most {MAX_INVOCATION_REPLAY_CACHE_CAPACITY}"
947        );
948    }
949    let broker_identity = resolve_broker_identity_config(identity_file)?;
950    let request_encryption_key_id = optional_nonempty_config(
951        "invocation.request-encryption-key-id",
952        file.request_encryption_key_id.as_ref(),
953    )?;
954    let mut audiences = Vec::with_capacity(file.audience.len());
955    for audience in &file.audience {
956        let trimmed = audience.trim();
957        if trimmed.is_empty() {
958            bail!("invocation.audience must not contain blank values");
959        }
960        validate_basil_identity_uri("invocation.audience", trimmed)?;
961        audiences.push(trimmed.to_string());
962    }
963    audiences.sort();
964    audiences.dedup();
965    if file.enable && audiences.is_empty() {
966        bail!("invocation.audience must be set when invocation.enable is true");
967    }
968    if file.enable && broker_identity.is_none() {
969        bail!(
970            "broker-identity.id and broker-identity.response-signing-key-id are required when invocation.enable is true"
971        );
972    }
973    if file.enable && request_encryption_key_id.is_none() {
974        bail!("invocation.request-encryption-key-id is required when invocation.enable is true");
975    }
976    Ok(InvocationRuntimeConfig {
977        enabled: file.enable,
978        broker_identity,
979        audiences,
980        request_encryption_key_id,
981        max_ttl_secs: file.max_ttl_secs,
982        clock_skew_secs: file.clock_skew_secs,
983        replay_cache_capacity: file.replay_cache_capacity,
984        now_unix_override: None,
985    })
986}
987
988fn resolve_broker_identity_config(
989    file: &BrokerIdentityConfigFile,
990) -> Result<Option<BrokerIdentityRuntimeConfig>> {
991    let id = optional_nonempty_config("broker-identity.id", file.id.as_ref())?;
992    let response_signing_key_id = optional_nonempty_config(
993        "broker-identity.response-signing-key-id",
994        file.response_signing_key_id.as_ref(),
995    )?;
996    match (id, response_signing_key_id) {
997        (None, None) => Ok(None),
998        (Some(id), Some(response_signing_key_id)) => {
999            validate_basil_identity_uri("broker-identity.id", &id)?;
1000            Ok(Some(BrokerIdentityRuntimeConfig {
1001                id,
1002                response_signing_key_id,
1003            }))
1004        }
1005        (None, Some(_)) | (Some(_), None) => {
1006            bail!(
1007                "broker-identity.id and broker-identity.response-signing-key-id must be set together"
1008            )
1009        }
1010    }
1011}
1012
1013fn optional_nonempty_config(field: &str, value: Option<&String>) -> Result<Option<String>> {
1014    let Some(raw) = value else {
1015        return Ok(None);
1016    };
1017    let trimmed = raw.trim();
1018    if trimmed.is_empty() {
1019        bail!("{field} must not be empty");
1020    }
1021    Ok(Some(trimmed.to_string()))
1022}
1023
1024fn validate_basil_identity_uri(field: &str, value: &str) -> Result<()> {
1025    let parsed =
1026        reqwest::Url::parse(value).with_context(|| format!("parsing {field} `{value}`"))?;
1027    if parsed.scheme() != "basil" {
1028        bail!("{field} must use the basil:// scheme");
1029    }
1030    if parsed.host_str().is_none_or(str::is_empty) {
1031        bail!("{field} must include a host component");
1032    }
1033    if !parsed.username().is_empty() || parsed.password().is_some() {
1034        bail!("{field} must not include userinfo");
1035    }
1036    if parsed.query().is_some() || parsed.fragment().is_some() {
1037        bail!("{field} must not include query or fragment components");
1038    }
1039    if parsed.path() == "/" || parsed.path().trim_matches('/').is_empty() {
1040        bail!("{field} must include a non-empty path component");
1041    }
1042    Ok(())
1043}
1044
1045pub(crate) fn validate_invocation_catalog_bindings(
1046    invocation: &InvocationRuntimeConfig,
1047    catalog: &crate::Catalog,
1048) -> Result<()> {
1049    if !invocation.enabled {
1050        return Ok(());
1051    }
1052    let identity = invocation
1053        .broker_identity
1054        .as_ref()
1055        .context("broker identity is required when invocation is enabled")?;
1056    validate_response_signing_key(catalog, &identity.response_signing_key_id)?;
1057    let request_encryption_key_id = invocation
1058        .request_encryption_key_id
1059        .as_deref()
1060        .context("request encryption key is required when invocation is enabled")?;
1061    validate_request_encryption_key(catalog, request_encryption_key_id)
1062}
1063
1064fn catalog_key<'a>(
1065    catalog: &'a crate::Catalog,
1066    key_id: &str,
1067    field: &'static str,
1068) -> Result<&'a crate::catalog::KeyEntry> {
1069    catalog
1070        .keys
1071        .get(key_id)
1072        .with_context(|| format!("{field} references unknown catalog key `{key_id}`"))
1073}
1074
1075fn validate_response_signing_key(catalog: &crate::Catalog, key_id: &str) -> Result<()> {
1076    let key = catalog_key(catalog, key_id, "broker-identity.response-signing-key-id")?;
1077    if key.class != Class::Asymmetric {
1078        bail!("broker response-signing key `{key_id}` must be class `asymmetric`");
1079    }
1080    let Some(algorithm) = key.key_type else {
1081        bail!("broker response-signing key `{key_id}` must declare keyType");
1082    };
1083    if !is_broker_response_signing_algorithm(algorithm) {
1084        bail!(
1085            "broker response-signing key `{key_id}` has unsupported keyType `{}`",
1086            algorithm.token()
1087        );
1088    }
1089    validate_broker_key_use(
1090        key_id,
1091        key,
1092        BROKER_RESPONSE_SIGNING_USE,
1093        "broker response-signing key",
1094    )
1095}
1096
1097fn validate_request_encryption_key(catalog: &crate::Catalog, key_id: &str) -> Result<()> {
1098    let key = catalog_key(catalog, key_id, "invocation.request-encryption-key-id")?;
1099    if key.class != Class::Sealing {
1100        bail!("broker request-encryption key `{key_id}` must be class `sealing`");
1101    }
1102    validate_broker_key_use(
1103        key_id,
1104        key,
1105        BROKER_REQUEST_ENCRYPTION_USE,
1106        "broker request-encryption key",
1107    )
1108}
1109
1110fn validate_broker_key_use(
1111    key_id: &str,
1112    key: &crate::catalog::KeyEntry,
1113    expected: &str,
1114    role: &str,
1115) -> Result<()> {
1116    match key.labels.get(BROKER_KEY_USE_LABEL) {
1117        Some(actual) if actual == expected => Ok(()),
1118        Some(actual) => bail!(
1119            "{role} `{key_id}` must carry label `{BROKER_KEY_USE_LABEL}={expected}`, got `{actual}`"
1120        ),
1121        None => bail!("{role} `{key_id}` must carry label `{BROKER_KEY_USE_LABEL}={expected}`"),
1122    }
1123}
1124
1125const fn is_broker_response_signing_algorithm(algorithm: KeyAlgorithm) -> bool {
1126    matches!(
1127        algorithm,
1128        KeyAlgorithm::Ed25519
1129            | KeyAlgorithm::Rsa2048
1130            | KeyAlgorithm::EcdsaP256
1131            | KeyAlgorithm::MlDsa44
1132            | KeyAlgorithm::MlDsa65
1133            | KeyAlgorithm::MlDsa87
1134    )
1135}
1136
1137/// Resolve the `[jwks]` section, parsing `listen` to a [`SocketAddr`] so a
1138/// malformed address fails closed at startup rather than at bind time. The
1139/// `listen` value is parsed even when `enable` is false (config validity is
1140/// independent of whether the listener will be opened).
1141#[cfg(feature = "http")]
1142fn resolve_jwks_config(file: &JwksConfigFile) -> Result<JwksConfig> {
1143    let listen = file
1144        .listen
1145        .parse::<std::net::SocketAddr>()
1146        .with_context(|| format!("parsing jwks.listen `{}`", file.listen))?;
1147    // Normalize the issuer base URL: must be absolute http(s), trailing slash
1148    // stripped so `issuer + path` is well-formed. A malformed value fails closed
1149    // at startup rather than serving an inconsistent discovery document.
1150    let issuer = match file.issuer.as_deref().map(str::trim) {
1151        Some(s) if !s.is_empty() => Some(
1152            parse_public_http_url("jwks.issuer", s)?
1153                .to_string()
1154                .trim_end_matches('/')
1155                .to_string(),
1156        ),
1157        _ => None,
1158    };
1159    Ok(JwksConfig {
1160        enable: file.enable,
1161        listen,
1162        issuer,
1163        tls: resolve_jwks_tls_config(&file.tls)?,
1164    })
1165}
1166
1167#[cfg(not(feature = "http"))]
1168fn reject_jwks_config(file: &JwksConfigFile) -> Result<()> {
1169    if file.enable {
1170        bail!("jwks.enable requires the http cargo feature");
1171    }
1172    if file.tls.enable {
1173        bail!("jwks.tls.enable requires the http cargo feature");
1174    }
1175    Ok(())
1176}
1177
1178#[cfg(feature = "http")]
1179fn resolve_jwks_tls_config(file: &JwksTlsConfigFile) -> Result<Option<crate::jwks::JwksTlsConfig>> {
1180    if !file.enable {
1181        return Ok(None);
1182    }
1183    #[cfg(not(feature = "http-tls"))]
1184    {
1185        anyhow::bail!("jwks.tls.enable requires the http-tls cargo feature");
1186    }
1187    #[cfg(feature = "http-tls")]
1188    {
1189        let cert_file = file
1190            .cert_file
1191            .clone()
1192            .context("jwks.tls.cert-file is required when jwks.tls.enable = true")?;
1193        let key_file = file
1194            .key_file
1195            .clone()
1196            .context("jwks.tls.key-file is required when jwks.tls.enable = true")?;
1197        Ok(Some(crate::jwks::JwksTlsConfig {
1198            cert_file,
1199            key_file,
1200        }))
1201    }
1202}
1203
1204#[cfg(any(feature = "http", feature = "otlp"))]
1205fn parse_public_http_url(field: &str, value: &str) -> Result<reqwest::Url> {
1206    let parsed =
1207        reqwest::Url::parse(value).with_context(|| format!("parsing {field} `{value}`"))?;
1208    match parsed.scheme() {
1209        "http" | "https" => {}
1210        scheme => bail!("{field} must use http or https, got `{scheme}`"),
1211    }
1212    if !parsed.username().is_empty() || parsed.password().is_some() {
1213        bail!("{field} must not include userinfo");
1214    }
1215    if parsed.fragment().is_some() {
1216        bail!("{field} must not include a fragment");
1217    }
1218    reject_metadata_host(field, &parsed)?;
1219    Ok(parsed)
1220}
1221
1222#[cfg(any(feature = "http", feature = "otlp"))]
1223fn reject_metadata_host(field: &str, parsed: &reqwest::Url) -> Result<()> {
1224    let Some(host) = parsed.host_str() else {
1225        return Ok(());
1226    };
1227    if host.eq_ignore_ascii_case("metadata.google.internal") {
1228        bail!("{field} must not target cloud instance metadata hosts");
1229    }
1230    if let Ok(ip) = host.parse::<std::net::IpAddr>()
1231        && is_metadata_ip(ip)
1232    {
1233        bail!("{field} must not target link-local or cloud instance metadata addresses");
1234    }
1235    Ok(())
1236}
1237
1238#[cfg(any(feature = "http", feature = "otlp"))]
1239fn is_metadata_ip(ip: std::net::IpAddr) -> bool {
1240    match ip {
1241        std::net::IpAddr::V4(addr) => addr.is_link_local() || addr.octets() == [100, 100, 100, 200],
1242        std::net::IpAddr::V6(addr) => addr.is_unicast_link_local(),
1243    }
1244}
1245
1246/// Resolve the setup `doctor --keys` unlocks with. The authenticated key probe is
1247/// a preflight read; it must not consume a passphrase file that the subsequent
1248/// agent start needs, so it sets `passphrase_no_wipe`.
1249fn load_key_probe_config(overrides: &ConfigOverrides) -> Result<SetupArgs> {
1250    let file = load_config_file(overrides)?;
1251    let mut setup = build_setup(&file, overrides)?;
1252    setup.unlock.passphrase_no_wipe = true;
1253    Ok(setup)
1254}
1255
1256pub(crate) fn load_config_file(overrides: &ConfigOverrides) -> Result<AgentConfigFile> {
1257    let Some(path) = &overrides.config else {
1258        return Ok(AgentConfigFile::default());
1259    };
1260    let raw = std::fs::read_to_string(path)
1261        .with_context(|| format!("reading config from {}", path.display()))?;
1262    toml::from_str(&raw).with_context(|| format!("parsing config from {}", path.display()))
1263}
1264
1265struct LoggingGuards {
1266    file_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
1267    #[cfg(feature = "otlp")]
1268    otel_provider: Option<SdkLoggerProvider>,
1269}
1270
1271#[cfg(feature = "otlp")]
1272type OtelTracingLayer =
1273    OpenTelemetryTracingBridge<SdkLoggerProvider, opentelemetry_sdk::logs::SdkLogger>;
1274#[cfg(feature = "otlp")]
1275type OptionalOtelLayer = (Option<OtelTracingLayer>, Option<SdkLoggerProvider>);
1276
1277#[cfg(feature = "otlp")]
1278impl LoggingGuards {
1279    fn shutdown(self) {
1280        let Self {
1281            file_guard,
1282            otel_provider,
1283        } = self;
1284        drop(file_guard);
1285        if let Some(provider) = otel_provider
1286            && let Err(err) = provider.shutdown()
1287        {
1288            warn!(error = %err, "failed to shut down OpenTelemetry logger provider");
1289        }
1290    }
1291}
1292
1293#[cfg(not(feature = "otlp"))]
1294impl LoggingGuards {
1295    fn shutdown(self) {
1296        let Self { file_guard } = self;
1297        drop(file_guard);
1298    }
1299}
1300
1301/// Resolution of the journald log sink.
1302///
1303/// Journald is the right default for the systemd-managed daemon, but a journal
1304/// socket is absent on minimal hosts (containers, initramfs, CI) where the
1305/// offline `basil doctor`/`explain` commands run. An unavailable socket is
1306/// reported once to stderr, but it must not install a duplicate stderr log sink.
1307enum JournaldSink {
1308    /// Journald disabled in config; no journald sink.
1309    Disabled,
1310    /// Journald socket opened; emit through this layer.
1311    Active(tracing_journald::Layer),
1312    /// Journald requested but the socket is unavailable.
1313    Unavailable(String),
1314}
1315
1316fn journald_sink(enabled: bool) -> JournaldSink {
1317    if !enabled {
1318        return JournaldSink::Disabled;
1319    }
1320    match tracing_journald::layer() {
1321        Ok(layer) => JournaldSink::Active(layer),
1322        Err(err) => JournaldSink::Unavailable(err.to_string()),
1323    }
1324}
1325
1326fn init_logging(config: &LoggingConfigFile) -> Result<LoggingGuards> {
1327    let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
1328    let stdout_layer = config.stdout_enabled().then(fmt::layer);
1329
1330    let (journald_layer, journald_error) = match journald_sink(config.journald.enable) {
1331        JournaldSink::Disabled => (None, None),
1332        JournaldSink::Active(layer) => (Some(layer), None),
1333        JournaldSink::Unavailable(err) => (None, Some(err)),
1334    };
1335
1336    let (file_writer, file_guard) = build_file_writer(&config.file)?;
1337    let file_layer = file_writer.map(|writer| fmt::layer().with_writer(writer));
1338
1339    #[cfg(feature = "otlp")]
1340    let (otel_layer, otel_provider) = build_otel_layer(&config.opentelemetry)?;
1341    #[cfg(not(feature = "otlp"))]
1342    ensure_otel_disabled(&config.opentelemetry)?;
1343
1344    let subscriber = tracing_subscriber::registry()
1345        .with(env_filter)
1346        .with(stdout_layer)
1347        .with(journald_layer)
1348        .with(file_layer);
1349    #[cfg(feature = "otlp")]
1350    let subscriber = subscriber.with(otel_layer);
1351    subscriber
1352        .try_init()
1353        .context("initializing tracing subscriber")?;
1354
1355    if let Some(err) = journald_error {
1356        eprintln!("journald log sink unavailable: {err}");
1357    }
1358
1359    Ok(LoggingGuards {
1360        file_guard,
1361        #[cfg(feature = "otlp")]
1362        otel_provider,
1363    })
1364}
1365
1366type FileMakeWriter = tracing_appender::non_blocking::NonBlocking;
1367
1368fn build_file_writer(
1369    config: &FileLoggingConfigFile,
1370) -> Result<(
1371    Option<FileMakeWriter>,
1372    Option<tracing_appender::non_blocking::WorkerGuard>,
1373)> {
1374    if !config.enable {
1375        return Ok((None, None));
1376    }
1377
1378    let Some(dir) = config.dir.as_deref() else {
1379        bail!("logging.file.dir is required when logging.file.enable=true");
1380    };
1381
1382    let appender = match tracing_appender::rolling::RollingFileAppender::builder()
1383        .rotation(config.rotation.into())
1384        .filename_prefix(config.prefix.clone())
1385        .build(dir)
1386    {
1387        Ok(appender) => appender,
1388        Err(err) => {
1389            eprintln!("file log sink unavailable at {}: {err}", dir.display());
1390            return Ok((None, None));
1391        }
1392    };
1393
1394    let writer = ReportingWriter::new(appender);
1395    let (non_blocking, guard) = tracing_appender::non_blocking(writer);
1396    Ok((Some(non_blocking), Some(guard)))
1397}
1398
1399struct ReportingWriter<W> {
1400    inner: W,
1401}
1402
1403impl<W> ReportingWriter<W> {
1404    const fn new(inner: W) -> Self {
1405        Self { inner }
1406    }
1407}
1408
1409impl<W: Write> Write for ReportingWriter<W> {
1410    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1411        match self.inner.write(buf) {
1412            Ok(size) => Ok(size),
1413            Err(err) => {
1414                eprintln!("failed to write to file log sink: {err}");
1415                Err(err)
1416            }
1417        }
1418    }
1419
1420    fn flush(&mut self) -> io::Result<()> {
1421        match self.inner.flush() {
1422            Ok(()) => Ok(()),
1423            Err(err) => {
1424                eprintln!("failed to flush file log sink: {err}");
1425                Err(err)
1426            }
1427        }
1428    }
1429}
1430
1431#[cfg(not(feature = "otlp"))]
1432fn ensure_otel_disabled(config: &OpenTelemetryLoggingConfigFile) -> Result<()> {
1433    if config.enable {
1434        bail!(
1435            "logging.opentelemetry.enable=true but this basil binary was built without the `otlp` feature"
1436        );
1437    }
1438    Ok(())
1439}
1440
1441#[cfg(feature = "otlp")]
1442fn build_otel_layer(config: &OpenTelemetryLoggingConfigFile) -> Result<OptionalOtelLayer> {
1443    if !config.enable {
1444        return Ok((None, None));
1445    }
1446    let endpoint = required_otel_endpoint(config)?;
1447    let exporter = match config.protocol {
1448        OpenTelemetryProtocol::Grpc => opentelemetry_otlp::LogExporter::builder()
1449            .with_tonic()
1450            .with_endpoint(endpoint)
1451            .build(),
1452        OpenTelemetryProtocol::HttpBinary => opentelemetry_otlp::LogExporter::builder()
1453            .with_http()
1454            .with_protocol(Protocol::HttpBinary)
1455            .with_endpoint(endpoint)
1456            .build(),
1457        OpenTelemetryProtocol::HttpJson => opentelemetry_otlp::LogExporter::builder()
1458            .with_http()
1459            .with_protocol(Protocol::HttpJson)
1460            .with_endpoint(endpoint)
1461            .build(),
1462    }
1463    .context("building OpenTelemetry log exporter")?;
1464    let provider = SdkLoggerProvider::builder()
1465        .with_batch_exporter(exporter)
1466        .build();
1467    let layer = OpenTelemetryTracingBridge::new(&provider);
1468    Ok((Some(layer), Some(provider)))
1469}
1470
1471#[cfg(feature = "otlp")]
1472fn required_otel_endpoint(config: &OpenTelemetryLoggingConfigFile) -> Result<String> {
1473    let endpoint = config
1474        .endpoint
1475        .as_deref()
1476        .map(str::trim)
1477        .filter(|value| !value.is_empty())
1478        .context(
1479            "logging.opentelemetry.endpoint is required when OpenTelemetry logging is enabled",
1480        )?;
1481    Ok(parse_public_http_url("logging.opentelemetry.endpoint", endpoint)?.to_string())
1482}
1483
1484fn build_setup(file: &AgentConfigFile, overrides: &ConfigOverrides) -> Result<SetupArgs> {
1485    let catalog = required_path(
1486        overrides.catalog.clone().or_else(|| file.catalog.clone()),
1487        "catalog",
1488    )?;
1489    let policy = required_path(
1490        overrides.policy.clone().or_else(|| file.policy.clone()),
1491        "policy",
1492    )?;
1493    let bundle = required_path(
1494        overrides.bundle.clone().or_else(|| file.bundle.clone()),
1495        "bundle",
1496    )?;
1497    let vault_addr = overrides
1498        .vault_addr
1499        .clone()
1500        .or_else(|| file.vault_addr.clone())
1501        .unwrap_or_else(|| "http://127.0.0.1:8200".to_string());
1502    let capability_policy = file
1503        .capability_policy
1504        .as_deref()
1505        .map_or(Ok(CapabilityPolicy::Strict), parse_capability_policy)
1506        .map_err(|err| anyhow::anyhow!("parsing config key `capability-policy`: {err}"))?;
1507
1508    Ok(SetupArgs {
1509        catalog,
1510        policy,
1511        bundle,
1512        vault_addr,
1513        transit_mount: file
1514            .transit_mount
1515            .clone()
1516            .unwrap_or_else(|| "transit".to_string()),
1517        jwt_auth_mount: file
1518            .jwt_auth_mount
1519            .clone()
1520            .unwrap_or_else(|| "jwt".to_string()),
1521        jwt_role: file.jwt_role.clone().unwrap_or_default(),
1522        jwt_audience: file
1523            .jwt_audience
1524            .clone()
1525            .unwrap_or_else(|| "openbao".to_string()),
1526        svid_ttl_secs: file.svid_ttl_secs.unwrap_or(DEFAULT_SVID_TTL_SECS),
1527        capability_policy,
1528        #[cfg(feature = "db-keystore")]
1529        db_keystore_cipher: file
1530            .db_keystore_cipher
1531            .clone()
1532            .unwrap_or_else(|| "aegis256".to_string()),
1533        #[cfg(feature = "keystore-backend")]
1534        onepassword_provider_uri: file.onepassword_provider_uri.clone().unwrap_or_default(),
1535        #[cfg(feature = "keystore-backend")]
1536        onepassword_project: file.onepassword_project.clone().unwrap_or_default(),
1537        #[cfg(feature = "keystore-backend")]
1538        onepassword_profile: file.onepassword_profile.clone().unwrap_or_default(),
1539        unlock: unlock::UnlockArgs {
1540            age_yubikey: file.unlock.age_yubikey.unwrap_or(false),
1541            bip39_phrase_file: file.unlock.bip39_phrase_file.clone(),
1542            tpm: file.unlock.unlock_tpm.unwrap_or(false),
1543            passphrase_file: file.unlock.unlock_passphrase_file.clone(),
1544            passphrase_no_wipe: file.unlock.unlock_passphrase_no_wipe.unwrap_or(false),
1545            strict_bundle_perms: file.unlock.strict_bundle_perms.unwrap_or(false),
1546        },
1547    })
1548}
1549
1550fn required_path(path: Option<PathBuf>, name: &str) -> Result<PathBuf> {
1551    path.with_context(|| {
1552        format!(
1553            "`{name}` is required; set it in the config file or pass --{name} / the BASIL_{} env var",
1554            name.to_ascii_uppercase()
1555        )
1556    })
1557}
1558
1559/// The loaded, validated catalog/policy plus a live [`BackendManager`] over the
1560/// unlocked creds: the common ground both `run` (serve) and `check` (lint)
1561/// stand on.
1562struct Prepared {
1563    catalog: Arc<crate::Catalog>,
1564    policy: crate::ResolvedPolicy,
1565    config: crate::Config,
1566    manager: BackendManager,
1567    backend_label: String,
1568}
1569
1570/// Load + validate the exported catalog/policy, unlock the sealed bundle, and
1571/// build the routed [`BackendManager`] over the decrypted creds. The shared
1572/// startup pipeline for `run` and `check`; fails closed (clean non-zero, no
1573/// panic) at every step. The plaintext [`CredBundle`] is zeroized before return;
1574/// each backend holds only its own cred.
1575async fn prepare_manager(setup: &SetupArgs) -> Result<Prepared> {
1576    if setup.vault_addr.starts_with("http://") && !setup.vault_addr.contains("127.0.0.1") {
1577        warn!(addr = %setup.vault_addr, "talking to vault over plaintext HTTP");
1578    }
1579
1580    // Load + validate the exported catalog & policy.
1581    let catalog_json = std::fs::read_to_string(&setup.catalog)
1582        .with_context(|| format!("reading catalog from {}", setup.catalog.display()))?;
1583    let policy_json = std::fs::read_to_string(&setup.policy)
1584        .with_context(|| format!("reading policy from {}", setup.policy.display()))?;
1585    let (catalog, policy, config, warnings) =
1586        load(&catalog_json, &policy_json).context("loading catalog/policy")?;
1587    for w in &warnings {
1588        warn!(warning = %w, "catalog/policy load warning");
1589    }
1590    info!(
1591        keys = catalog.keys.len(),
1592        backends = catalog.backends.len(),
1593        "loaded catalog + policy",
1594    );
1595
1596    // Unlock the sealed bundle -> CredBundle (KEK zeroized inside `open`).
1597    let creds = unlock::open_bundle_at_startup(&setup.bundle, &setup.unlock)
1598        .context("unlocking sealed credential bundle")?;
1599    info!(
1600        backend_creds = creds.backends.len(),
1601        "sealed bundle unlocked",
1602    );
1603
1604    // Build the manager from catalog + creds, then drop (zeroize) the CredBundle.
1605    let defaults = BackendDefaults {
1606        vault_addr: &setup.vault_addr,
1607        transit_mount: &setup.transit_mount,
1608        jwt_auth_mount: &setup.jwt_auth_mount,
1609        jwt_role: &setup.jwt_role,
1610        jwt_audience: &setup.jwt_audience,
1611        svid_ttl: Duration::from_secs(setup.svid_ttl_secs),
1612        #[cfg(feature = "db-keystore")]
1613        db_keystore_cipher: &setup.db_keystore_cipher,
1614        #[cfg(feature = "keystore-backend")]
1615        onepassword_provider_uri: &setup.onepassword_provider_uri,
1616        #[cfg(feature = "keystore-backend")]
1617        onepassword_project: &setup.onepassword_project,
1618        #[cfg(feature = "keystore-backend")]
1619        onepassword_profile: &setup.onepassword_profile,
1620    };
1621    let (manager, backend_label) = build_manager(&defaults, catalog, &creds).await?;
1622    let catalog = manager.catalog();
1623    drop(creds); // ZEROIZE the CredBundle: every backend now holds its own cred.
1624
1625    Ok(Prepared {
1626        catalog,
1627        policy,
1628        config,
1629        manager,
1630        backend_label,
1631    })
1632}
1633
1634fn enforce_startup_capabilities(catalog: &crate::Catalog, policy: CapabilityPolicy) -> Result<()> {
1635    let cap = enforce_capabilities(catalog, policy).context("backend capability check failed")?;
1636    info!(
1637        policy = %policy,
1638        enforced = cap.enforced,
1639        skipped_undeclared = cap.skipped_undeclared,
1640        warnings = cap.warnings,
1641        "capability check complete",
1642    );
1643    Ok(())
1644}
1645
1646/// Run the broker daemon: load catalog/policy + the sealed bundle, unlock,
1647/// construct backends from the decrypted creds, then serve.
1648async fn run_daemon(args: RunArgs) -> Result<()> {
1649    let run_config = load_run_config(&args.overrides)?;
1650    // Shared setup: load catalog/policy, unlock the bundle, build the manager
1651    // (the CredBundle is zeroized inside `prepare_manager`).
1652    let Prepared {
1653        catalog,
1654        policy,
1655        config,
1656        manager,
1657        backend_label,
1658    } = prepare_manager(&run_config.setup).await?;
1659
1660    validate_invocation_catalog_bindings(&run_config.invocation, &catalog)
1661        .context("validating invocation broker identity and keys")?;
1662
1663    // Capability enforcement: does each backend PROVIDE what the catalog's keys
1664    // (+ explicit `requires`) need? A pure, offline catalog check (no backend
1665    // I/O, no extra Vault privilege), gated by `capability-policy` (default
1666    // `strict`, fail closed). Runs independently of `no-reconcile`.
1667    enforce_startup_capabilities(&catalog, run_config.setup.capability_policy)?;
1668
1669    // Startup reconcile (vault-zrg): apply each key's `missing` policy against its
1670    // backend BEFORE binding the socket. A required key absent (or a backend
1671    // unreachable during the probe) is a clean fail-closed startup error (the `?`
1672    // propagates a non-zero exit, no panic). `no-reconcile` is a recovery hatch.
1673    if run_config.no_reconcile {
1674        warn!("startup reconcile skipped (no-reconcile); missing keys will fail at request time");
1675    } else {
1676        let summary = manager
1677            .reconcile()
1678            .await
1679            .context("reconciling catalog against backends")?;
1680        info!(
1681            present = summary.present,
1682            generated = summary.generated,
1683            warned = summary.warned,
1684            "catalog reconcile complete",
1685        );
1686    }
1687
1688    let limits = BrokerLimits {
1689        max_encrypt_size: run_config.max_encrypt_size,
1690        max_payload_size: run_config.max_payload_size,
1691        grace_versions: run_config.grace_versions,
1692        svid_ttl_secs: run_config.setup.svid_ttl_secs,
1693        retain_versions: run_config.retain_versions,
1694    };
1695    let jwt_revocations = JwtRevocationStore::load_from_manager(&manager)
1696        .await
1697        .context("loading JWT-SVID revocation deny-list")?;
1698    let mut state =
1699        BrokerState::with_limits(catalog, policy, config, manager, backend_label, limits)
1700            .with_jwt_revocations(jwt_revocations)
1701            // The SIGHUP reload engine (basil-y3e.2) re-reads from the SAME
1702            // configured catalog/policy paths startup used, never the wire.
1703            .with_reload_inputs(ReloadInputs {
1704                catalog_path: run_config.setup.catalog.clone(),
1705                policy_path: run_config.setup.policy.clone(),
1706            });
1707
1708    // Optional JSONL audit sink (`vault-vq5`): open the append-only file ONCE at
1709    // startup so a permissions/path error fails closed here rather than per-op. A
1710    // per-op append is best-effort thereafter (the sink logs-and-continues).
1711    let audit_reopen = if let Some(audit_path) = &run_config.audit_log {
1712        let audit = Arc::new(
1713            AuditLog::open(audit_path)
1714                .with_context(|| format!("opening audit log at {}", audit_path.display()))?,
1715        );
1716        info!(path = %audit_path.display(), "JSONL audit log enabled");
1717        state = state.with_audit_log(Arc::clone(&audit));
1718        Some(audit)
1719    } else {
1720        None
1721    };
1722
1723    let state = Arc::new(state);
1724    spawn_sighup_handler(Arc::clone(&state), audit_reopen);
1725    spawn_retention_sweep(Arc::clone(&state), run_config.retention_sweep_secs);
1726
1727    let socket_path = run_config
1728        .socket
1729        .unwrap_or_else(|| crate::DEFAULT_SOCKET_PATH.to_string());
1730    let server_config = ServerConfig {
1731        socket_path,
1732        socket_mode: run_config.socket_mode,
1733        socket_group: run_config.socket_group,
1734        invocation: run_config.invocation,
1735    };
1736
1737    // Opt-in JWKS HTTP surface (basil-uce.1). When `[jwks] enable` is false (the
1738    // default) NO listener is bound: the broker stays gRPC-over-unix-socket only.
1739    // When enabled, bind here so a bind failure is a clean fail-closed startup
1740    // error (before the gRPC server starts serving), never a mid-run panic. The
1741    // server is tied to the SAME lifecycle as the gRPC server: when the gRPC
1742    // server returns (on its shutdown signal), we trigger the JWKS shutdown and
1743    // await it, so the HTTP surface never outlives the broker.
1744    #[cfg(feature = "http")]
1745    let jwks_shutdown = run_config.jwks.enable.then(|| {
1746        let (tx, rx) = tokio::sync::oneshot::channel::<()>();
1747        let http_state = Arc::clone(&state);
1748        let listen = run_config.jwks.listen;
1749        let http_config = crate::jwks::JwksHttpConfig {
1750            issuer: run_config.jwks.issuer.clone(),
1751            tls: run_config.jwks.tls.clone(),
1752        };
1753        let handle = tokio::spawn(async move {
1754            let shutdown = async {
1755                let _ = rx.await;
1756            };
1757            if let Err(err) = crate::jwks::serve(http_state, listen, http_config, shutdown).await {
1758                warn!(error = %err, "JWKS HTTP surface terminated with error");
1759            }
1760        });
1761        (tx, handle)
1762    });
1763
1764    let grpc_result = run_grpc(server_config, state).await;
1765
1766    #[cfg(feature = "http")]
1767    if let Some((tx, handle)) = jwks_shutdown {
1768        // Signal the HTTP surface to drain and wait for it before returning, so it
1769        // never keeps serving after the broker stops.
1770        let _ = tx.send(());
1771        if let Err(err) = handle.await {
1772            warn!(error = %err, "JWKS HTTP surface task join failed");
1773        }
1774    }
1775
1776    grpc_result?;
1777    Ok(())
1778}
1779
1780/// Install the SIGHUP handler. SIGHUP is the operational "reload" signal: it
1781/// (1) hot-reloads the catalog/policy generation (`basil-y3e`) and then
1782/// (2) reopens the JSONL audit log (rotation). It is installed
1783/// **unconditionally** (even when no audit log is configured) so that SIGHUP
1784/// can never fall through to its default disposition (terminate the process).
1785/// This matters because the Nix module wires `systemctl reload`
1786/// (`nixos-rebuild switch` on a catalog/policy edit) to `kill -HUP $MAINPID`; the
1787/// broker must absorb that and reload in place, not die or re-unlock.
1788///
1789/// The reload is the shared [`reload_generation`] engine: it re-reads the
1790/// configured catalog/policy paths, runs the full startup/`check` validation, and
1791/// on success atomically swaps in a new generation. On **any** failure it fails
1792/// closed (the previous generation keeps serving) and the rejection is audited;
1793/// the broker never panics. Reload runs **before** the audit-log reopen so the
1794/// reload outcome lands in the current log segment. With no audit log configured,
1795/// the reload still runs (it is signal-driven, not audit-driven).
1796fn spawn_sighup_handler(state: Arc<BrokerState>, audit: Option<Arc<AuditLog>>) {
1797    let handle = tokio::spawn(async move {
1798        let mut hangup = match signal(SignalKind::hangup()) {
1799            Ok(signal) => signal,
1800            Err(err) => {
1801                warn!(error = %err, "SIGHUP handler disabled");
1802                return;
1803            }
1804        };
1805        while hangup.recv().await.is_some() {
1806            // 1. Reload the catalog/policy generation (fail-closed: a rejection
1807            //    keeps the previous generation serving; both outcomes audited).
1808            handle_sighup_reload(&state).await;
1809            // 2. Reopen the audit log (rotation), if one is configured.
1810            if let Some(audit) = &audit {
1811                audit.request_reopen();
1812                info!("SIGHUP: requested audit log reopen");
1813            }
1814        }
1815    });
1816    std::mem::drop(handle);
1817}
1818
1819/// Run one SIGHUP-driven reload via the shared [`reload_generation`] engine and
1820/// audit the outcome. Never panics; on rejection the previous generation keeps
1821/// serving and the reason is recorded.
1822async fn handle_sighup_reload(state: &BrokerState) {
1823    match reload_generation(state) {
1824        Ok(outcome) => {
1825            info!(
1826                previous_generation = outcome.previous_generation,
1827                generation = outcome.new_generation,
1828                keys = outcome.key_count,
1829                grants = outcome.grant_count,
1830                "SIGHUP: catalog/policy reload applied",
1831            );
1832            state.record_reload(
1833                outcome.previous_generation,
1834                outcome.new_generation,
1835                "applied",
1836                "signal",
1837                ReloadActor::Sighup,
1838            );
1839            if let Err(err) = state.refresh_jwt_revocations().await {
1840                warn!(
1841                    error = %err,
1842                    generation = outcome.new_generation,
1843                    "SIGHUP: JWT-SVID revocation deny-list refresh failed; previous in-memory set still serving",
1844                );
1845                state.record_reload(
1846                    outcome.new_generation,
1847                    outcome.new_generation,
1848                    "revocation_refresh_failed",
1849                    "signal",
1850                    ReloadActor::Sighup,
1851                );
1852            }
1853        }
1854        Err(err) => {
1855            let active = state.active_generation_id();
1856            warn!(
1857                error = %err,
1858                generation = active,
1859                "SIGHUP: catalog/policy reload rejected; previous generation still serving",
1860            );
1861            state.record_reload(
1862                active,
1863                active,
1864                "rejected",
1865                err.audit_reason(),
1866                ReloadActor::Sighup,
1867            );
1868        }
1869    }
1870}
1871
1872fn spawn_retention_sweep(state: Arc<BrokerState>, interval_secs: u64) {
1873    let limits = state.limits();
1874    if limits.retain_versions.is_none() || interval_secs == 0 {
1875        return;
1876    }
1877    let handle = tokio::spawn(async move {
1878        let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
1879        loop {
1880            interval.tick().await;
1881            if let Err(err) = state.manager().sweep_all_retention(limits).await {
1882                warn!(error = %err, "retention sweep failed");
1883            }
1884        }
1885    });
1886    std::mem::drop(handle);
1887}
1888
1889/// Build the resolved [`doctor::DoctorInputs`] from the config file + overrides,
1890/// using the same path/socket resolution `run` uses. A missing required path
1891/// (catalog/policy/bundle) is a clean config error (non-zero exit): that is a
1892/// usage error, distinct from a diagnostic failure.
1893fn load_doctor_inputs(overrides: &ConfigOverrides) -> Result<doctor::DoctorInputs> {
1894    let file = load_config_file(overrides)?;
1895    let invocation = resolve_invocation_config(&file.broker_identity, &file.invocation)?;
1896    let setup = build_setup(&file, overrides)?;
1897    let socket = overrides
1898        .socket
1899        .clone()
1900        .or(file.socket)
1901        .unwrap_or_else(|| crate::DEFAULT_SOCKET_PATH.to_string());
1902    let socket_mode = file.socket_mode.map_or(DEFAULT_SOCKET_MODE, |mode| mode.0);
1903    let capability_policy = setup.capability_policy;
1904    let unlock_passphrase_selected = setup.unlock.passphrase_file.is_some();
1905    let unlock_bip39_selected = setup.unlock.bip39_phrase_file.is_some();
1906    let unlock_age_yubikey_selected = setup.unlock.age_yubikey;
1907
1908    Ok(doctor::DoctorInputs {
1909        catalog: setup.catalog,
1910        policy: setup.policy,
1911        bundle: setup.bundle,
1912        socket,
1913        socket_mode,
1914        socket_group: file.socket_group,
1915        capability_policy,
1916        invocation,
1917        unlock_passphrase_selected,
1918        unlock_bip39_selected,
1919        unlock_age_yubikey_selected,
1920    })
1921}
1922
1923/// Run the preflight **doctor** (`basil-f0j`): resolve config, run every
1924/// independent read-only check, print the report (human or `--json`), and exit
1925/// per the fatal-vs-warning model. The default path never unlocks the bundle,
1926/// binds the socket, or mutates anything; `--keys` explicitly unlocks only to run
1927/// the authenticated read-only per-key existence probe.
1928async fn run_doctor(args: DoctorArgs) -> Result<()> {
1929    let inputs = load_doctor_inputs(&args.overrides)?;
1930    let mut report = doctor::run_doctor(&inputs, doctor::EnabledFeatures::current()).await;
1931    if args.keys {
1932        let mut key_rows = doctor_key_material_rows(&args.overrides).await;
1933        report.checks.append(&mut key_rows);
1934        report = doctor::DoctorReport::from_checks(report.checks);
1935    }
1936
1937    if args.json {
1938        println!("{}", doctor::render_json(&report)?);
1939    } else {
1940        print!("{}", doctor::render_human(&report));
1941    }
1942
1943    if report.should_exit_nonzero(args.strict) {
1944        // A blocking misconfiguration exits non-zero, but cleanly: the report has
1945        // already been printed, so suppress the redundant anyhow error chain.
1946        std::process::exit(1);
1947    }
1948    Ok(())
1949}
1950
1951/// Unlock the sealed bundle and run the authenticated read-only per-key existence
1952/// probe for `doctor --keys`, mapping the [`crate::CheckReport`] into per-key
1953/// [`doctor::CheckResult`] rows. Every failure path is folded into a secret-free
1954/// key-material row so one bad probe never aborts the rest of the report.
1955async fn doctor_key_material_rows(overrides: &ConfigOverrides) -> Vec<doctor::CheckResult> {
1956    let setup = match load_key_probe_config(overrides) {
1957        Ok(setup) => setup,
1958        Err(err) => {
1959            return doctor::key_material_rows(Err(format!(
1960                "could not resolve key-probe configuration: {err:#}"
1961            )));
1962        }
1963    };
1964    let Prepared { manager, .. } = match prepare_manager(&setup).await {
1965        Ok(prepared) => prepared,
1966        Err(err) => {
1967            return doctor::key_material_rows(Err(format!(
1968                "could not build authenticated backend manager: {err:#}"
1969            )));
1970        }
1971    };
1972    match manager.check().await {
1973        Ok(report) => doctor::key_material_rows(Ok(&report)),
1974        Err(err) => doctor::key_material_rows(Err(err.to_string())),
1975    }
1976}
1977
1978/// Run the offline policy dry-run/explainer (`basil-4vf`).
1979///
1980/// Loads the catalog + policy from files, builds the REAL PDP, evaluates the
1981/// proposed tuple (or the `--effective` preview), and prints the result.
1982/// Entirely offline: no bundle, no backend, no socket, no secret material.
1983/// Default-deny holds exactly as in enforcement.
1984///
1985/// This is the default (non-`--live`) path of `basil explain`; the live path is
1986/// `client_cli::explain_live`, which queries the running broker over the socket.
1987pub fn run_explain(args: &ExplainArgs) -> Result<()> {
1988    use crate::catalog::Pdp;
1989
1990    let file = load_config_file(&args.overrides)?;
1991    let catalog_path = required_path(
1992        args.overrides
1993            .catalog
1994            .clone()
1995            .or_else(|| file.catalog.clone()),
1996        "catalog",
1997    )?;
1998    let policy_path = required_path(
1999        args.overrides
2000            .policy
2001            .clone()
2002            .or_else(|| file.policy.clone()),
2003        "policy",
2004    )?;
2005
2006    let catalog_json = std::fs::read_to_string(&catalog_path)
2007        .with_context(|| format!("reading catalog from {}", catalog_path.display()))?;
2008    let policy_json = std::fs::read_to_string(&policy_path)
2009        .with_context(|| format!("reading policy from {}", policy_path.display()))?;
2010    let (catalog, policy, config, warnings) =
2011        load(&catalog_json, &policy_json).context("loading catalog/policy")?;
2012    for w in &warnings {
2013        warn!(warning = %w, "catalog/policy load warning");
2014    }
2015
2016    let pdp = Pdp::new(&catalog, &policy, &config);
2017
2018    if args.effective {
2019        return print_effective(&pdp, &args.subject, args.json);
2020    }
2021
2022    // Single-tuple explain. clap guarantees op/key are present here (required
2023    // unless --effective), but handle their absence fail-safe rather than unwrap.
2024    let (Some(op), Some(key)) = (args.op, args.key.as_deref()) else {
2025        bail!("--op and --key are required unless --effective is given");
2026    };
2027    print_explanation(&pdp, &args.subject, op, key, args.json)
2028}
2029
2030/// Print the "preview effective permissions" view for an identity to stdout.
2031fn print_effective(pdp: &crate::catalog::Pdp, subject: &str, json: bool) -> Result<()> {
2032    let mut out = std::io::stdout().lock();
2033    render_effective(&mut out, pdp, subject, json)
2034}
2035
2036/// Render the "preview effective permissions" view into `out`.
2037///
2038/// The render seam (separate from [`print_effective`]) so the stable `--json`
2039/// shape and the human text can be asserted without capturing the process's real
2040/// stdout. Production simply renders into a locked `stdout`.
2041fn render_effective(
2042    out: &mut impl std::io::Write,
2043    pdp: &crate::catalog::Pdp,
2044    subject: &str,
2045    json: bool,
2046) -> Result<()> {
2047    let grants = pdp.effective(subject);
2048    if json {
2049        let rows: Vec<serde_json::Value> = grants
2050            .iter()
2051            .map(|g| {
2052                serde_json::json!({
2053                    "key": g.key,
2054                    "op": g.op.token(),
2055                    "via": allow_via_json(&g.via),
2056                    "rule": g.rule_id,
2057                })
2058            })
2059            .collect();
2060        let doc = serde_json::json!({ "subject": subject, "effective": rows });
2061        writeln!(out, "{}", serde_json::to_string_pretty(&doc)?)?;
2062        return Ok(());
2063    }
2064    writeln!(
2065        out,
2066        "effective permissions for subject {subject}: {} grant(s)",
2067        grants.len()
2068    )?;
2069    for g in &grants {
2070        let rule = g.rule_id.as_deref().unwrap_or("<public-class>");
2071        writeln!(
2072            out,
2073            "  ALLOW  {}  {}  via {} [{rule}]",
2074            g.op.token(),
2075            g.key,
2076            allow_via_json(&g.via),
2077        )?;
2078    }
2079    if grants.is_empty() {
2080        writeln!(out, "  (none: default-deny)")?;
2081    }
2082    Ok(())
2083}
2084
2085/// Print a single-tuple explanation (decision + matched rule / denial reason) to
2086/// stdout.
2087fn print_explanation(
2088    pdp: &crate::catalog::Pdp,
2089    subject: &str,
2090    op: crate::catalog::Op,
2091    key: &str,
2092    json: bool,
2093) -> Result<()> {
2094    let mut out = std::io::stdout().lock();
2095    render_explanation(&mut out, pdp, subject, op, key, json)
2096}
2097
2098/// Render a single-tuple explanation into `out`.
2099///
2100/// The render seam (separate from [`print_explanation`]) so the stable `--json`
2101/// allow/deny shape can be asserted without the process's real stdout.
2102fn render_explanation(
2103    out: &mut impl std::io::Write,
2104    pdp: &crate::catalog::Pdp,
2105    subject: &str,
2106    op: crate::catalog::Op,
2107    key: &str,
2108    json: bool,
2109) -> Result<()> {
2110    use crate::catalog::Decision;
2111    let ex = pdp.explain_subject(subject, op, key);
2112
2113    if json {
2114        let mut obj = serde_json::Map::new();
2115        obj.insert("subject".into(), subject.into());
2116        obj.insert("op".into(), op.token().into());
2117        obj.insert("key".into(), key.into());
2118        match &ex.decision {
2119            Decision::Allow { via } => {
2120                obj.insert("decision".into(), "allow".into());
2121                obj.insert("via".into(), allow_via_json(via).into());
2122                let matched = ex.matched.as_ref().map_or(serde_json::Value::Null, |m| {
2123                    serde_json::json!({
2124                        "rule": m.rule_id,
2125                        "via": allow_via_json(&m.via),
2126                        "subject": m.subject,
2127                        "action": m.action,
2128                        "target": m.target,
2129                    })
2130                });
2131                obj.insert("matched_rule".into(), matched);
2132            }
2133            Decision::Deny { reason } => {
2134                obj.insert("decision".into(), "deny".into());
2135                obj.insert("reason".into(), deny_reason_json(*reason).into());
2136            }
2137        }
2138        writeln!(
2139            out,
2140            "{}",
2141            serde_json::to_string_pretty(&serde_json::Value::Object(obj))?
2142        )?;
2143        return Ok(());
2144    }
2145
2146    match &ex.decision {
2147        Decision::Allow { via } => {
2148            writeln!(
2149                out,
2150                "ALLOW  subject {subject}  {}  {key}  (via {})",
2151                op.token(),
2152                allow_via_json(via)
2153            )?;
2154            match &ex.matched {
2155                Some(m) => writeln!(
2156                    out,
2157                    "  matched subject `{}` (rule `{}`): action `{}` over target `{}`",
2158                    m.subject, m.rule_id, m.action, m.target
2159                )?,
2160                None => {
2161                    writeln!(
2162                        out,
2163                        "  matched the world-readable public-class rule (no policy rule needed)"
2164                    )?;
2165                }
2166            }
2167        }
2168        Decision::Deny { reason } => {
2169            writeln!(
2170                out,
2171                "DENY   subject {subject}  {}  {key}  ({})",
2172                op.token(),
2173                deny_reason_json(*reason)
2174            )?;
2175            writeln!(out, "  {}", deny_explanation(*reason))?;
2176        }
2177    }
2178    Ok(())
2179}
2180
2181/// Stable JSON/string token for a [`AllowVia`](crate::catalog::AllowVia).
2182fn allow_via_json(via: &crate::catalog::AllowVia) -> String {
2183    use crate::catalog::AllowVia;
2184    match via {
2185        AllowVia::Subject(subject) => format!("subject:{subject}"),
2186        AllowVia::PublicClass => "public_class".to_string(),
2187    }
2188}
2189
2190/// Stable JSON/string token for a [`DenyReason`](crate::catalog::DenyReason).
2191const fn deny_reason_json(reason: crate::catalog::DenyReason) -> &'static str {
2192    use crate::catalog::DenyReason;
2193    match reason {
2194        DenyReason::UnknownKey => "unknown_key",
2195        DenyReason::NotWritable => "not_writable",
2196        DenyReason::NotPermitted => "not_permitted",
2197    }
2198}
2199
2200/// A human sentence for each deny reason.
2201const fn deny_explanation(reason: crate::catalog::DenyReason) -> &'static str {
2202    use crate::catalog::DenyReason;
2203    match reason {
2204        DenyReason::UnknownKey => "key is not in the catalog",
2205        DenyReason::NotWritable => {
2206            "the key is not writable (write hard-cap), denied regardless of policy"
2207        }
2208        DenyReason::NotPermitted => "no policy grant matches this (subject, op, key): default-deny",
2209    }
2210}
2211
2212/// Run the broker daemon behind `basil agent`.
2213pub async fn run_agent(args: RunArgs) -> Result<()> {
2214    let overrides = args.overrides.clone();
2215    // `Box::pin`: with the cloud-KMS features the daemon future is large; this is
2216    // a once-per-process cold path, so the heap indirection is free.
2217    Box::pin(run_with_config_logging(&overrides, run_daemon(args))).await
2218}
2219
2220/// Run the preflight doctor behind `basil doctor`.
2221pub async fn run_doctor_command(args: DoctorArgs) -> Result<()> {
2222    let overrides = args.overrides.clone();
2223    if args.json {
2224        let mut logging = logging_config_for_overrides(&overrides)?;
2225        logging.stdout.enable = Some(false);
2226        let logging_guards = init_logging(&logging)?;
2227        let result = run_doctor(args).await;
2228        logging_guards.shutdown();
2229        result
2230    } else {
2231        Box::pin(run_with_config_logging(&overrides, run_doctor(args))).await
2232    }
2233}
2234
2235/// Run first-run config scaffolding behind top-level `basil init`.
2236///
2237/// `socket` is the resolved global `--socket <path>` flag (clap folds
2238/// `BASIL_SOCKET` into it); it is threaded into the generated config's
2239/// `socket = ...` line. See [`init::run`] for the full precedence.
2240pub fn run_init(socket: Option<&str>, args: &init::InitArgs) -> Result<()> {
2241    let logging_guards = init_logging(&LoggingConfigFile::default())?;
2242    let result = init::run(args, socket);
2243    logging_guards.shutdown();
2244    result
2245}
2246
2247/// Run sealed-bundle operations behind top-level `basil bundle`.
2248pub fn run_bundle(command: bundle_cli::BundleCommand) -> Result<()> {
2249    let logging_guards = init_logging(&LoggingConfigFile::default())?;
2250    let result = bundle_cli::run(command);
2251    logging_guards.shutdown();
2252    result
2253}
2254
2255async fn run_with_config_logging(
2256    overrides: &ConfigOverrides,
2257    fut: impl std::future::Future<Output = Result<()>>,
2258) -> Result<()> {
2259    let logging = logging_config_for_overrides(overrides)?;
2260    let logging_guards = init_logging(&logging)?;
2261    let result = fut.await;
2262    logging_guards.shutdown();
2263    result
2264}
2265
2266fn logging_config_for_overrides(overrides: &ConfigOverrides) -> Result<LoggingConfigFile> {
2267    Ok(load_config_file(overrides)?.logging)
2268}
2269
2270#[cfg(test)]
2271mod tests {
2272    use std::path::Path;
2273
2274    use super::*;
2275    use crate::catalog::MissingPolicy;
2276    use clap::Parser as _;
2277
2278    fn temp_config(contents: &str) -> PathBuf {
2279        let path = std::env::temp_dir().join(format!(
2280            "basil-agent-config-test-{}-{}.toml",
2281            std::process::id(),
2282            uuid::Uuid::new_v4()
2283        ));
2284        std::fs::write(&path, contents).expect("write temp config");
2285        path
2286    }
2287
2288    fn overrides_for(config: PathBuf) -> ConfigOverrides {
2289        ConfigOverrides {
2290            config: Some(config),
2291            catalog: None,
2292            policy: None,
2293            bundle: None,
2294            socket: None,
2295            vault_addr: None,
2296        }
2297    }
2298
2299    fn catalog_with_invocation_keys(
2300        response_signing: crate::catalog::KeyEntry,
2301        request_encryption: crate::catalog::KeyEntry,
2302    ) -> crate::Catalog {
2303        let mut backends = BTreeMap::new();
2304        backends.insert(
2305            "bao".to_string(),
2306            crate::catalog::BackendRef {
2307                kind: crate::catalog::BackendKind::Vault,
2308                addr: "http://127.0.0.1:8200".to_string(),
2309                engines: Vec::new(),
2310                capabilities: Vec::new(),
2311                mint_key_types: Vec::new(),
2312                requires: Vec::new(),
2313            },
2314        );
2315        let mut keys = BTreeMap::new();
2316        keys.insert("broker.response".to_string(), response_signing);
2317        keys.insert("broker.request".to_string(), request_encryption);
2318        crate::Catalog {
2319            schema_version: 1,
2320            backends,
2321            keys,
2322        }
2323    }
2324
2325    fn catalog_key(
2326        class: Class,
2327        key_type: Option<KeyAlgorithm>,
2328        labels: &[&str],
2329    ) -> crate::catalog::KeyEntry {
2330        crate::catalog::KeyEntry {
2331            class,
2332            key_type,
2333            backend: "bao".to_string(),
2334            engine: None,
2335            path: "path".to_string(),
2336            public_path: matches!(class, Class::Sealing).then(|| "public/path".to_string()),
2337            writable: false,
2338            missing: MissingPolicy::Error,
2339            generate: None,
2340            sealing_pin: None,
2341            labels: crate::catalog::Labels(labels.iter().map(ToString::to_string).collect()),
2342            description: "test key".to_string(),
2343        }
2344    }
2345
2346    #[cfg(feature = "keystore-backend")]
2347    #[test]
2348    fn run_config_file_supplies_all_startup_settings() {
2349        let config = temp_config(
2350            r#"
2351catalog = "/cfg/catalog.json"
2352policy = "/cfg/policy.json"
2353bundle = "/cfg/bundle.sealed"
2354socket = "/run/basil.sock"
2355vault-addr = "http://vault.internal:8200"
2356transit-mount = "basil-transit"
2357jwt-auth-mount = "jwt-basil"
2358jwt-role = "basil-agent"
2359jwt-audience = "openbao-prod"
2360svid-ttl-secs = 120
2361capability-policy = "degraded"
2362db-keystore-cipher = "aegis256"
2363onepassword-provider-uri = "onepassword://basil"
2364onepassword-project = "prod"
2365onepassword-profile = "agent"
2366max-encrypt-size = 4096
2367max-payload-size = 8192
2368grace-versions = 2
2369retain-versions = 5
2370retention-sweep-secs = 60
2371audit-log = "/var/log/basil/audit.jsonl"
2372no-reconcile = true
2373
2374[logging.stdout]
2375enable = false
2376
2377[logging.journald]
2378enable = true
2379
2380[logging.file]
2381enable = true
2382dir = "/var/log/basil"
2383prefix = "agent-"
2384rotation = "weekly"
2385
2386[logging.opentelemetry]
2387enable = true
2388endpoint = "http://localhost:4317"
2389protocol = "grpc"
2390
2391[unlock]
2392age-yubikey = true
2393bip39-phrase-file = "/secure/recovery.txt"
2394unlock-tpm = true
2395unlock-passphrase-file = "/secure/passphrase.txt"
2396unlock-passphrase-no-wipe = true
2397strict-bundle-perms = true
2398"#,
2399        );
2400        let args = ConfigOverrides {
2401            config: Some(config.clone()),
2402            catalog: None,
2403            policy: None,
2404            bundle: None,
2405            socket: None,
2406            vault_addr: None,
2407        };
2408
2409        let loaded_file = load_config_file(&args).expect("load config file");
2410        let loaded = load_run_config(&args).expect("load run config");
2411
2412        assert_eq!(loaded.setup.catalog, PathBuf::from("/cfg/catalog.json"));
2413        assert_eq!(loaded.setup.policy, PathBuf::from("/cfg/policy.json"));
2414        assert_eq!(loaded.setup.bundle, PathBuf::from("/cfg/bundle.sealed"));
2415        assert_eq!(loaded.socket.as_deref(), Some("/run/basil.sock"));
2416        assert_eq!(loaded.setup.vault_addr, "http://vault.internal:8200");
2417        assert_eq!(loaded.setup.transit_mount, "basil-transit");
2418        assert_eq!(loaded.setup.jwt_auth_mount, "jwt-basil");
2419        assert_eq!(loaded.setup.jwt_role, "basil-agent");
2420        assert_eq!(loaded.setup.jwt_audience, "openbao-prod");
2421        assert_eq!(loaded.setup.svid_ttl_secs, 120);
2422        assert_eq!(loaded.setup.capability_policy, CapabilityPolicy::Degraded);
2423        #[cfg(feature = "db-keystore")]
2424        assert_eq!(loaded.setup.db_keystore_cipher, "aegis256");
2425        assert_eq!(loaded.setup.onepassword_provider_uri, "onepassword://basil");
2426        assert_eq!(loaded.setup.onepassword_project, "prod");
2427        assert_eq!(loaded.setup.onepassword_profile, "agent");
2428        assert_eq!(loaded.max_encrypt_size, 4096);
2429        assert_eq!(loaded.max_payload_size, 8192);
2430        assert_eq!(loaded.grace_versions, 2);
2431        assert_eq!(loaded.retain_versions, Some(5));
2432        assert_eq!(loaded.retention_sweep_secs, 60);
2433        assert_eq!(
2434            loaded.audit_log.as_deref(),
2435            Some(Path::new("/var/log/basil/audit.jsonl"))
2436        );
2437        assert!(loaded.no_reconcile);
2438        assert_eq!(loaded_file.logging.stdout.enable, Some(false));
2439        assert!(!loaded_file.logging.stdout_enabled());
2440        assert!(loaded_file.logging.journald.enable);
2441        assert!(loaded_file.logging.file.enable);
2442        assert_eq!(
2443            loaded_file.logging.file.dir.as_deref(),
2444            Some(Path::new("/var/log/basil"))
2445        );
2446        assert_eq!(loaded_file.logging.file.prefix, "agent-");
2447        assert_eq!(
2448            loaded_file.logging.file.rotation,
2449            FileLoggingRotation::Weekly
2450        );
2451        assert!(loaded_file.logging.opentelemetry.enable);
2452        assert_eq!(
2453            (
2454                loaded_file.logging.opentelemetry.endpoint.as_deref(),
2455                loaded_file.logging.opentelemetry.protocol
2456            ),
2457            (Some("http://localhost:4317"), OpenTelemetryProtocol::Grpc)
2458        );
2459        assert!(loaded.setup.unlock.age_yubikey);
2460        assert!(loaded.setup.unlock.tpm);
2461        assert_eq!(
2462            loaded.setup.unlock.bip39_phrase_file.as_deref(),
2463            Some(Path::new("/secure/recovery.txt"))
2464        );
2465        assert_eq!(
2466            loaded.setup.unlock.passphrase_file.as_deref(),
2467            Some(Path::new("/secure/passphrase.txt"))
2468        );
2469        assert!(loaded.setup.unlock.passphrase_no_wipe);
2470        assert!(loaded.setup.unlock.strict_bundle_perms);
2471
2472        std::fs::remove_file(config).expect("remove temp config");
2473    }
2474
2475    #[test]
2476    fn socket_mode_config_accepts_octal_string_and_rejects_bad_values() {
2477        assert_eq!(parse_socket_mode("0600").expect("0600 parses"), 0o600);
2478        assert_eq!(parse_socket_mode("0o660").expect("0o660 parses"), 0o660);
2479        assert!(parse_socket_mode("").is_err());
2480        assert!(parse_socket_mode("0888").is_err());
2481        assert!(parse_socket_mode("10000").is_err());
2482    }
2483
2484    #[test]
2485    fn run_config_file_supplies_socket_ownership_settings() {
2486        let config = temp_config(
2487            r#"
2488catalog = "/cfg/catalog.json"
2489policy = "/cfg/policy.json"
2490bundle = "/cfg/bundle.sealed"
2491socket-mode = "0660"
2492socket-group = "basil-edge"
2493"#,
2494        );
2495        let args = ConfigOverrides {
2496            config: Some(config.clone()),
2497            catalog: None,
2498            policy: None,
2499            bundle: None,
2500            socket: None,
2501            vault_addr: None,
2502        };
2503
2504        let loaded = load_run_config(&args).expect("load run config");
2505
2506        assert_eq!(loaded.socket_mode, 0o660);
2507        assert_eq!(loaded.socket_group.as_deref(), Some("basil-edge"));
2508
2509        std::fs::remove_file(config).expect("remove temp config");
2510    }
2511
2512    #[test]
2513    fn socket_mode_defaults_to_owner_only() {
2514        let config = temp_config(
2515            r#"
2516catalog = "/cfg/catalog.json"
2517policy = "/cfg/policy.json"
2518bundle = "/cfg/bundle.sealed"
2519"#,
2520        );
2521        let args = ConfigOverrides {
2522            config: Some(config.clone()),
2523            catalog: None,
2524            policy: None,
2525            bundle: None,
2526            socket: None,
2527            vault_addr: None,
2528        };
2529
2530        let loaded = load_run_config(&args).expect("load run config");
2531
2532        assert_eq!(loaded.socket_mode, DEFAULT_SOCKET_MODE);
2533        assert!(loaded.socket_group.is_none());
2534
2535        std::fs::remove_file(config).expect("remove temp config");
2536    }
2537
2538    #[test]
2539    fn logging_defaults_stdout_and_journald_on_otel_off() {
2540        let config = AgentConfigFile::default();
2541        assert_eq!(config.logging.stdout.enable, None);
2542        assert!(config.logging.stdout_enabled());
2543        assert!(config.logging.journald.enable);
2544        assert!(!config.logging.file.enable);
2545        assert!(config.logging.file.dir.is_none());
2546        assert_eq!(config.logging.file.prefix, "basil-agent-");
2547        assert_eq!(config.logging.file.rotation, FileLoggingRotation::Daily);
2548        assert!(!config.logging.opentelemetry.enable);
2549        assert_eq!(
2550            config.logging.opentelemetry.protocol,
2551            OpenTelemetryProtocol::Grpc
2552        );
2553        assert!(config.logging.opentelemetry.endpoint.is_none());
2554    }
2555
2556    #[test]
2557    fn stdout_defaults_off_when_file_logging_enabled() {
2558        let config: AgentConfigFile = toml::from_str(
2559            r#"
2560[logging.file]
2561enable = true
2562dir = "/var/log/basil"
2563"#,
2564        )
2565        .expect("parse config");
2566        assert_eq!(config.logging.stdout.enable, None);
2567        assert!(!config.logging.stdout_enabled());
2568    }
2569
2570    #[test]
2571    fn stdout_explicit_config_overrides_file_logging_default() {
2572        let stdout_on: AgentConfigFile = toml::from_str(
2573            r#"
2574[logging.stdout]
2575enable = true
2576
2577[logging.file]
2578enable = true
2579dir = "/var/log/basil"
2580"#,
2581        )
2582        .expect("parse stdout-on config");
2583        assert_eq!(stdout_on.logging.stdout.enable, Some(true));
2584        assert!(stdout_on.logging.stdout_enabled());
2585
2586        let stdout_off: AgentConfigFile = toml::from_str(
2587            r#"
2588[logging.stdout]
2589enable = false
2590"#,
2591        )
2592        .expect("parse stdout-off config");
2593        assert_eq!(stdout_off.logging.stdout.enable, Some(false));
2594        assert!(!stdout_off.logging.stdout_enabled());
2595    }
2596
2597    #[test]
2598    fn file_logging_path_alias_populates_dir() {
2599        let config: AgentConfigFile = toml::from_str(
2600            r#"
2601[logging.file]
2602enable = true
2603path = "/var/log/basil"
2604"#,
2605        )
2606        .expect("parse file config");
2607        assert_eq!(
2608            config.logging.file.dir.as_deref(),
2609            Some(Path::new("/var/log/basil"))
2610        );
2611    }
2612
2613    #[test]
2614    fn file_logging_requires_dir_when_enabled() {
2615        let config: AgentConfigFile = toml::from_str(
2616            r#"
2617[logging.file]
2618enable = true
2619"#,
2620        )
2621        .expect("parse file config");
2622        let err = build_file_writer(&config.logging.file).expect_err("missing dir rejects");
2623        assert!(
2624            err.to_string()
2625                .contains("logging.file.dir is required when logging.file.enable=true")
2626        );
2627    }
2628
2629    #[test]
2630    fn file_logging_constructs_non_blocking_writer() {
2631        let dir = std::env::temp_dir().join(format!(
2632            "basil-agent-log-test-{}-{}",
2633            std::process::id(),
2634            uuid::Uuid::new_v4()
2635        ));
2636        std::fs::create_dir(&dir).expect("create log dir");
2637        let config = FileLoggingConfigFile {
2638            enable: true,
2639            dir: Some(dir.clone()),
2640            prefix: "agent-test".to_owned(),
2641            rotation: FileLoggingRotation::None,
2642        };
2643        let (writer, guard) = build_file_writer(&config).expect("build file writer");
2644        assert!(writer.is_some());
2645        drop(writer);
2646        drop(guard);
2647        std::fs::remove_dir_all(dir).expect("remove log dir");
2648    }
2649
2650    #[test]
2651    fn journald_disabled_produces_no_sink() {
2652        assert!(matches!(journald_sink(false), JournaldSink::Disabled));
2653    }
2654
2655    #[test]
2656    fn journald_without_socket_is_non_fatal_without_stderr_sink() {
2657        // Portability guarantee (basil-ftfj): requesting journald on a host with
2658        // no journal socket (containers, minimal VMs, CI) must NOT abort the
2659        // offline `basil doctor`/`explain` commands. It resolves to either an active
2660        // journald layer (socket present) or a one-time stderr diagnostic (socket
2661        // absent): never a hard error or duplicate stderr log sink.
2662        match journald_sink(true) {
2663            JournaldSink::Active(_) | JournaldSink::Unavailable(_) => {}
2664            JournaldSink::Disabled => panic!("enabled journald must not resolve to Disabled"),
2665        }
2666    }
2667
2668    #[cfg(feature = "http")]
2669    #[test]
2670    fn jwks_http_surface_is_disabled_by_default_no_port_opened() {
2671        // The acceptance bar (basil-uce.1): with no `[jwks]` section the surface
2672        // is OFF: `enable` is false, so `run_daemon` never spawns the listener
2673        // task (the `then(...)` guard is not taken). The documented default
2674        // listen address still parses (config validity is independent of enable).
2675        let defaults = JwksConfigFile::default();
2676        assert!(!defaults.enable, "JWKS surface must default to disabled");
2677        assert_eq!(defaults.listen, "127.0.0.1:8201");
2678
2679        let config = temp_config(
2680            r#"
2681catalog = "/cfg/catalog.json"
2682policy = "/cfg/policy.json"
2683bundle = "/cfg/bundle.sealed"
2684"#,
2685        );
2686        let args = ConfigOverrides {
2687            config: Some(config.clone()),
2688            catalog: None,
2689            policy: None,
2690            bundle: None,
2691            socket: None,
2692            vault_addr: None,
2693        };
2694        let loaded = load_run_config(&args).expect("load run config");
2695        assert!(
2696            !loaded.jwks.enable,
2697            "default config leaves the JWKS port closed"
2698        );
2699        assert_eq!(
2700            loaded.jwks.listen,
2701            "127.0.0.1:8201".parse().expect("default listen")
2702        );
2703
2704        std::fs::remove_file(config).expect("remove temp config");
2705    }
2706
2707    #[test]
2708    fn invocation_service_requires_explicit_config_enable() {
2709        let defaults = InvocationConfigFile::default();
2710        assert!(
2711            !defaults.enable,
2712            "InvocationService must default to rejecting requests"
2713        );
2714        assert_eq!(defaults.max_ttl_secs, 60);
2715        assert_eq!(defaults.clock_skew_secs, 30);
2716        assert_eq!(defaults.replay_cache_capacity, 4096);
2717
2718        let config = temp_config(
2719            r#"
2720catalog = "/cfg/catalog.json"
2721policy = "/cfg/policy.json"
2722bundle = "/cfg/bundle.sealed"
2723"#,
2724        );
2725        let args = overrides_for(config.clone());
2726        let loaded = load_run_config(&args).expect("load run config");
2727        assert!(!loaded.invocation.enabled);
2728        assert!(loaded.invocation.broker_identity.is_none());
2729        assert!(loaded.invocation.request_encryption_key_id.is_none());
2730        std::fs::remove_file(config).expect("remove temp config");
2731
2732        let config = temp_config(
2733            r#"
2734catalog = "/cfg/catalog.json"
2735policy = "/cfg/policy.json"
2736bundle = "/cfg/bundle.sealed"
2737
2738[broker-identity]
2739id = "basil://prod/us-east-1/agent-a"
2740response-signing-key-id = "broker.response"
2741
2742[invocation]
2743enable = true
2744audience = ["basil://prod/us-east-1/agent-a"]
2745request-encryption-key-id = "broker.request"
2746max-ttl-secs = 45
2747clock-skew-secs = 7
2748replay-cache-capacity = 128
2749"#,
2750        );
2751        let args = overrides_for(config.clone());
2752        let loaded = load_run_config(&args).expect("load run config");
2753        assert!(loaded.invocation.enabled);
2754        let identity = loaded
2755            .invocation
2756            .broker_identity
2757            .as_ref()
2758            .expect("broker identity");
2759        assert_eq!(identity.id, "basil://prod/us-east-1/agent-a");
2760        assert_eq!(identity.response_signing_key_id, "broker.response");
2761        assert_eq!(
2762            loaded.invocation.request_encryption_key_id.as_deref(),
2763            Some("broker.request")
2764        );
2765        assert_eq!(
2766            loaded.invocation.audiences,
2767            vec!["basil://prod/us-east-1/agent-a".to_string()]
2768        );
2769        assert_eq!(loaded.invocation.max_ttl_secs, 45);
2770        assert_eq!(loaded.invocation.clock_skew_secs, 7);
2771        assert_eq!(loaded.invocation.replay_cache_capacity, 128);
2772
2773        std::fs::remove_file(config).expect("remove temp config");
2774
2775        let config = temp_config(
2776            r#"
2777catalog = "/cfg/catalog.json"
2778policy = "/cfg/policy.json"
2779bundle = "/cfg/bundle.sealed"
2780
2781[invocation]
2782enable = true
2783"#,
2784        );
2785        let args = overrides_for(config.clone());
2786        let err = load_run_config(&args).expect_err("enabled invocation requires audience");
2787        assert!(
2788            err.to_string()
2789                .contains("invocation.audience must be set when invocation.enable is true")
2790        );
2791
2792        std::fs::remove_file(config).expect("remove temp config");
2793    }
2794
2795    #[test]
2796    fn invocation_config_rejects_missing_keys_invalid_identity_and_bounds() {
2797        let config = temp_config(
2798            r#"
2799catalog = "/cfg/catalog.json"
2800policy = "/cfg/policy.json"
2801bundle = "/cfg/bundle.sealed"
2802
2803[broker-identity]
2804id = "https://not-basil.example/agent-a"
2805response-signing-key-id = "broker.response"
2806
2807[invocation]
2808enable = true
2809audience = ["basil://prod/us-east-1/agent-a"]
2810request-encryption-key-id = "broker.request"
2811"#,
2812        );
2813        let err = load_run_config(&overrides_for(config.clone()))
2814            .expect_err("non-basil broker id rejects");
2815        assert!(
2816            err.to_string()
2817                .contains("broker-identity.id must use the basil:// scheme")
2818        );
2819        std::fs::remove_file(config).expect("remove temp config");
2820
2821        let config = temp_config(
2822            r#"
2823catalog = "/cfg/catalog.json"
2824policy = "/cfg/policy.json"
2825bundle = "/cfg/bundle.sealed"
2826
2827[broker-identity]
2828id = "basil://prod/us-east-1/agent-a"
2829response-signing-key-id = "broker.response"
2830
2831[invocation]
2832enable = true
2833audience = ["basil://prod/us-east-1/agent-a"]
2834max-ttl-secs = 301
2835"#,
2836        );
2837        let err =
2838            load_run_config(&overrides_for(config.clone())).expect_err("excessive ttl rejects");
2839        assert!(
2840            err.to_string()
2841                .contains("invocation.max-ttl-secs must be at most 300 seconds")
2842        );
2843        std::fs::remove_file(config).expect("remove temp config");
2844
2845        let config = temp_config(
2846            r#"
2847catalog = "/cfg/catalog.json"
2848policy = "/cfg/policy.json"
2849bundle = "/cfg/bundle.sealed"
2850
2851[broker-identity]
2852id = "basil://prod/us-east-1/agent-a"
2853response-signing-key-id = "broker.response"
2854
2855[invocation]
2856enable = true
2857audience = ["basil://prod/us-east-1/agent-a"]
2858"#,
2859        );
2860        let err = load_run_config(&overrides_for(config.clone()))
2861            .expect_err("enabled invocation requires request encryption key");
2862        assert!(
2863            err.to_string()
2864                .contains("invocation.request-encryption-key-id is required")
2865        );
2866        std::fs::remove_file(config).expect("remove temp config");
2867    }
2868
2869    #[test]
2870    fn invocation_catalog_bindings_require_expected_class_and_use() {
2871        let invocation = InvocationRuntimeConfig {
2872            enabled: true,
2873            broker_identity: Some(BrokerIdentityRuntimeConfig {
2874                id: "basil://prod/us-east-1/agent-a".to_string(),
2875                response_signing_key_id: "broker.response".to_string(),
2876            }),
2877            audiences: vec!["basil://prod/us-east-1/agent-a".to_string()],
2878            request_encryption_key_id: Some("broker.request".to_string()),
2879            max_ttl_secs: 60,
2880            clock_skew_secs: 30,
2881            replay_cache_capacity: 4096,
2882            now_unix_override: None,
2883        };
2884        let valid = catalog_with_invocation_keys(
2885            catalog_key(
2886                Class::Asymmetric,
2887                Some(KeyAlgorithm::Ed25519),
2888                &["broker_key_use=response-signing"],
2889            ),
2890            catalog_key(
2891                Class::Sealing,
2892                Some(KeyAlgorithm::X25519),
2893                &["broker_key_use=request-encryption"],
2894            ),
2895        );
2896        validate_invocation_catalog_bindings(&invocation, &valid).expect("valid keys pass");
2897
2898        let wrong_response_class = catalog_with_invocation_keys(
2899            catalog_key(
2900                Class::Sealing,
2901                Some(KeyAlgorithm::X25519),
2902                &["broker_key_use=response-signing"],
2903            ),
2904            catalog_key(
2905                Class::Sealing,
2906                Some(KeyAlgorithm::X25519),
2907                &["broker_key_use=request-encryption"],
2908            ),
2909        );
2910        let err = validate_invocation_catalog_bindings(&invocation, &wrong_response_class)
2911            .expect_err("wrong response signing class rejects");
2912        assert!(
2913            err.to_string().contains(
2914                "broker response-signing key `broker.response` must be class `asymmetric`"
2915            )
2916        );
2917
2918        let wrong_request_use = catalog_with_invocation_keys(
2919            catalog_key(
2920                Class::Asymmetric,
2921                Some(KeyAlgorithm::Ed25519),
2922                &["broker_key_use=response-signing"],
2923            ),
2924            catalog_key(
2925                Class::Sealing,
2926                Some(KeyAlgorithm::X25519),
2927                &["broker_key_use=response-signing"],
2928            ),
2929        );
2930        let err = validate_invocation_catalog_bindings(&invocation, &wrong_request_use)
2931            .expect_err("wrong request encryption use rejects");
2932        assert!(err.to_string().contains(
2933            "broker request-encryption key `broker.request` must carry label `broker_key_use=request-encryption`"
2934        ));
2935    }
2936
2937    #[cfg(feature = "http")]
2938    #[test]
2939    fn jwks_http_surface_enables_and_parses_listen_from_config() {
2940        let config = temp_config(
2941            r#"
2942catalog = "/cfg/catalog.json"
2943policy = "/cfg/policy.json"
2944bundle = "/cfg/bundle.sealed"
2945
2946[jwks]
2947enable = true
2948listen = "0.0.0.0:9443"
2949"#,
2950        );
2951        let args = ConfigOverrides {
2952            config: Some(config.clone()),
2953            catalog: None,
2954            policy: None,
2955            bundle: None,
2956            socket: None,
2957            vault_addr: None,
2958        };
2959        let loaded = load_run_config(&args).expect("load run config");
2960        assert!(loaded.jwks.enable);
2961        assert_eq!(
2962            loaded.jwks.listen,
2963            "0.0.0.0:9443".parse().expect("custom listen")
2964        );
2965
2966        std::fs::remove_file(config).expect("remove temp config");
2967    }
2968
2969    #[cfg(feature = "http")]
2970    #[test]
2971    fn jwks_listen_must_be_a_valid_socket_addr() {
2972        let bad = JwksConfigFile {
2973            enable: true,
2974            listen: "not-an-addr".to_string(),
2975            issuer: None,
2976            tls: JwksTlsConfigFile::default(),
2977        };
2978        assert!(
2979            resolve_jwks_config(&bad).is_err(),
2980            "a malformed jwks.listen fails closed at startup"
2981        );
2982    }
2983
2984    #[cfg(feature = "http")]
2985    #[test]
2986    fn jwks_issuer_must_be_absolute_http_url_or_unset() {
2987        // Unset issuer: discovery doc simply isn't served (ok).
2988        let none = JwksConfigFile {
2989            enable: true,
2990            listen: "127.0.0.1:8201".to_string(),
2991            issuer: None,
2992            tls: JwksTlsConfigFile::default(),
2993        };
2994        assert!(resolve_jwks_config(&none).expect("ok").issuer.is_none());
2995
2996        // A relative / schemeless issuer fails closed.
2997        let bad = JwksConfigFile {
2998            enable: true,
2999            listen: "127.0.0.1:8201".to_string(),
3000            issuer: Some("basil.example.com".to_string()),
3001            tls: JwksTlsConfigFile::default(),
3002        };
3003        assert!(
3004            resolve_jwks_config(&bad).is_err(),
3005            "a non-http(s) issuer fails closed at startup"
3006        );
3007
3008        // A valid https issuer with a trailing slash is normalized.
3009        let good = JwksConfigFile {
3010            enable: true,
3011            listen: "127.0.0.1:8201".to_string(),
3012            issuer: Some("https://basil.example.com/".to_string()),
3013            tls: JwksTlsConfigFile::default(),
3014        };
3015        assert_eq!(
3016            resolve_jwks_config(&good).expect("ok").issuer.as_deref(),
3017            Some("https://basil.example.com")
3018        );
3019    }
3020
3021    #[cfg(feature = "http")]
3022    #[test]
3023    fn jwks_issuer_rejects_ssrf_prone_url_shapes() {
3024        for issuer in [
3025            "//basil.example.com",
3026            "ftp://basil.example.com",
3027            "file:///etc/passwd",
3028            "unix:///run/basil-jwks.sock",
3029            "http://169.254.169.254/latest/meta-data/",
3030            "http://metadata.google.internal/computeMetadata/v1/",
3031            "https://user:pass@basil.example.com",
3032            "https://basil.example.com/#fragment",
3033        ] {
3034            let bad = JwksConfigFile {
3035                enable: true,
3036                listen: "127.0.0.1:8201".to_string(),
3037                issuer: Some(issuer.to_string()),
3038                tls: JwksTlsConfigFile::default(),
3039            };
3040            assert!(
3041                resolve_jwks_config(&bad).is_err(),
3042                "jwks.issuer `{issuer}` should fail closed"
3043            );
3044        }
3045    }
3046
3047    #[cfg(feature = "http")]
3048    #[test]
3049    fn jwks_tls_defaults_to_disabled() {
3050        let defaults = JwksConfigFile::default();
3051        assert!(resolve_jwks_config(&defaults).expect("ok").tls.is_none());
3052    }
3053
3054    #[cfg(all(feature = "http", not(feature = "http-tls")))]
3055    #[test]
3056    fn jwks_tls_requires_cargo_feature() {
3057        let config = JwksConfigFile {
3058            enable: true,
3059            listen: "127.0.0.1:8201".to_string(),
3060            issuer: Some("https://basil.example.com".to_string()),
3061            tls: JwksTlsConfigFile {
3062                enable: true,
3063                cert_file: Some("/etc/basil/jwks-cert.pem".into()),
3064                key_file: Some("/etc/basil/jwks-key.pem".into()),
3065            },
3066        };
3067        let err = resolve_jwks_config(&config).expect_err("feature missing");
3068        assert!(err.to_string().contains("http-tls"));
3069    }
3070
3071    #[cfg(not(feature = "http"))]
3072    #[test]
3073    fn jwks_enable_requires_http_feature() {
3074        let config = temp_config(
3075            r#"
3076catalog = "/cfg/catalog.json"
3077policy = "/cfg/policy.json"
3078bundle = "/cfg/bundle.sealed"
3079
3080[jwks]
3081enable = true
3082"#,
3083        );
3084        let err = load_run_config(&overrides_for(config.clone())).expect_err("feature missing");
3085        assert!(err.to_string().contains("http cargo feature"));
3086        std::fs::remove_file(config).expect("remove temp config");
3087    }
3088
3089    #[cfg(feature = "http-tls")]
3090    #[test]
3091    fn jwks_tls_requires_cert_and_key_when_enabled() {
3092        let missing_key = JwksConfigFile {
3093            enable: true,
3094            listen: "127.0.0.1:8201".to_string(),
3095            issuer: Some("https://basil.example.com".to_string()),
3096            tls: JwksTlsConfigFile {
3097                enable: true,
3098                cert_file: Some("/etc/basil/jwks-cert.pem".into()),
3099                key_file: None,
3100            },
3101        };
3102        let err = resolve_jwks_config(&missing_key).expect_err("key required");
3103        assert!(err.to_string().contains("key-file"));
3104    }
3105
3106    #[cfg(feature = "otlp")]
3107    #[test]
3108    fn otel_logging_requires_non_empty_http_endpoint_when_enabled() {
3109        let missing = OpenTelemetryLoggingConfigFile {
3110            enable: true,
3111            endpoint: None,
3112            protocol: OpenTelemetryProtocol::Grpc,
3113        };
3114        assert!(required_otel_endpoint(&missing).is_err());
3115
3116        let empty = OpenTelemetryLoggingConfigFile {
3117            enable: true,
3118            endpoint: Some("   ".to_string()),
3119            protocol: OpenTelemetryProtocol::Grpc,
3120        };
3121        assert!(required_otel_endpoint(&empty).is_err());
3122
3123        let unix = OpenTelemetryLoggingConfigFile {
3124            enable: true,
3125            endpoint: Some("unix:///run/otel.sock".to_string()),
3126            protocol: OpenTelemetryProtocol::Grpc,
3127        };
3128        assert!(required_otel_endpoint(&unix).is_err());
3129
3130        for endpoint in [
3131            "//otel.example.com:4317",
3132            "ftp://otel.example.com:4317",
3133            "file:///tmp/otel.sock",
3134            "http://169.254.169.254/latest/meta-data/",
3135            "http://metadata.google.internal/computeMetadata/v1/",
3136            "https://user:pass@otel.example.com:4317",
3137            "https://otel.example.com:4317/#fragment",
3138        ] {
3139            let bad = OpenTelemetryLoggingConfigFile {
3140                enable: true,
3141                endpoint: Some(endpoint.to_string()),
3142                protocol: OpenTelemetryProtocol::Grpc,
3143            };
3144            assert!(
3145                required_otel_endpoint(&bad).is_err(),
3146                "OpenTelemetry endpoint `{endpoint}` should fail closed"
3147            );
3148        }
3149
3150        let http = OpenTelemetryLoggingConfigFile {
3151            enable: true,
3152            endpoint: Some("http://localhost:4317".to_string()),
3153            protocol: OpenTelemetryProtocol::Grpc,
3154        };
3155        assert_eq!(
3156            required_otel_endpoint(&http).expect("valid endpoint"),
3157            "http://localhost:4317/"
3158        );
3159    }
3160
3161    #[test]
3162    fn cli_overrides_win_over_config_file() {
3163        let config = temp_config(
3164            r#"
3165catalog = "/cfg/catalog.json"
3166policy = "/cfg/policy.json"
3167bundle = "/cfg/bundle.sealed"
3168socket = "/cfg/basil.sock"
3169vault-addr = "http://cfg-vault:8200"
3170"#,
3171        );
3172        let args = ConfigOverrides {
3173            config: Some(config.clone()),
3174            catalog: Some(PathBuf::from("/cli/catalog.json")),
3175            policy: Some(PathBuf::from("/cli/policy.json")),
3176            bundle: Some(PathBuf::from("/cli/bundle.sealed")),
3177            socket: Some("/cli/basil.sock".to_string()),
3178            vault_addr: Some("http://cli-vault:8200".to_string()),
3179        };
3180
3181        let loaded = load_run_config(&args).expect("load run config");
3182
3183        assert_eq!(loaded.setup.catalog, PathBuf::from("/cli/catalog.json"));
3184        assert_eq!(loaded.setup.policy, PathBuf::from("/cli/policy.json"));
3185        assert_eq!(loaded.setup.bundle, PathBuf::from("/cli/bundle.sealed"));
3186        assert_eq!(loaded.socket.as_deref(), Some("/cli/basil.sock"));
3187        assert_eq!(loaded.setup.vault_addr, "http://cli-vault:8200");
3188
3189        std::fs::remove_file(config).expect("remove temp config");
3190    }
3191
3192    #[test]
3193    fn run_cli_rejects_removed_startup_overrides() {
3194        #[derive(Debug, clap::Parser)]
3195        struct TestCli {
3196            #[command(flatten)]
3197            args: RunArgs,
3198        }
3199
3200        let err = TestCli::try_parse_from([
3201            "basil-agent-run",
3202            "--catalog",
3203            "/tmp/catalog.json",
3204            "--policy",
3205            "/tmp/policy.json",
3206            "--bundle",
3207            "/tmp/bundle.sealed",
3208            "--capability-policy",
3209            "off",
3210        ])
3211        .expect_err("removed flag rejected");
3212        assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
3213    }
3214
3215    // ---- explain / effective CLI rendering (basil-4vf) ----------------------
3216
3217    /// A small catalog + least-privilege policy reused by the explain tests: uid
3218    /// 9002 is a `reader` of `grafana.admin_password` (user rule); gid 10 is an
3219    /// `operator` of `grafana.**` (group rule). The loader builds the same real
3220    /// types enforcement uses.
3221    mod explain {
3222        use super::*;
3223        use crate::catalog::{Op, Pdp, load};
3224
3225        const CATALOG: &str = r#"{
3226          "schemaVersion": 1,
3227          "backends": { "bao": { "kind": "vault", "addr": "https://127.0.0.1:8200" } },
3228          "keys": {
3229            "grafana.admin_password": {
3230              "class": "value", "backend": "bao", "engine": "kv2",
3231              "path": "secret/data/grafana/admin", "writable": true,
3232              "missing": "error", "description": "grafana admin value"
3233            }
3234          }
3235        }"#;
3236
3237        const POLICY: &str = r#"{
3238          "schemaVersion": 2,
3239          "subjects": {
3240            "svc.grafana": { "allOf": [ { "kind": "unix", "uid": 9002 } ] },
3241            "ops.wheel": { "allOf": [ { "kind": "unix", "gid": 10 } ] }
3242          },
3243          "roles": { "reader": ["get", "list"], "operator": ["set", "rotate"] },
3244          "rules": [
3245            { "id": "grafana-reader",  "subjects": ["svc.grafana"], "action": ["role:reader"],   "target": ["grafana.admin_password"] },
3246            { "id": "wheel-operator",  "subjects": ["ops.wheel"],   "action": ["role:operator"], "target": ["grafana.**"] }
3247          ],
3248          "config": {
3249            "names": { "users": { "9002": "svc-grafana" }, "groups": { "10": "wheel" } },
3250            "memberships": { "9002": [9002] }
3251          }
3252        }"#;
3253
3254        /// Owned `(Catalog, ResolvedPolicy, Config)` via the loader, so a `Pdp` can
3255        /// borrow them.
3256        fn loaded() -> (
3257            crate::catalog::Catalog,
3258            crate::catalog::ResolvedPolicy,
3259            crate::catalog::Config,
3260        ) {
3261            let (catalog, policy, config, _w) = load(CATALOG, POLICY).expect("fixture loads");
3262            (catalog, policy, config)
3263        }
3264
3265        fn render_explanation_to_string(
3266            pdp: &Pdp,
3267            subject: &str,
3268            op: Op,
3269            key: &str,
3270            json: bool,
3271        ) -> String {
3272            let mut buf = Vec::new();
3273            render_explanation(&mut buf, pdp, subject, op, key, json).expect("render");
3274            String::from_utf8(buf).expect("utf8")
3275        }
3276
3277        #[test]
3278        fn json_allow_shape_carries_matched_rule_fields() {
3279            let (c, p, cfg) = loaded();
3280            let pdp = Pdp::new(&c, &p, &cfg);
3281            let out = render_explanation_to_string(
3282                &pdp,
3283                "svc.grafana",
3284                Op::Get,
3285                "grafana.admin_password",
3286                true,
3287            );
3288            let doc: serde_json::Value = serde_json::from_str(&out).expect("json");
3289            assert_eq!(doc["decision"], "allow");
3290            assert_eq!(doc["subject"], "svc.grafana");
3291            assert_eq!(doc["op"], "get");
3292            assert_eq!(doc["key"], "grafana.admin_password");
3293            assert_eq!(doc["via"], "subject:svc.grafana");
3294            assert_eq!(doc["matched_rule"]["rule"], "grafana-reader");
3295            assert_eq!(doc["matched_rule"]["via"], "subject:svc.grafana");
3296            assert!(doc["matched_rule"]["action"].is_string());
3297            assert!(doc["matched_rule"]["target"].is_string());
3298        }
3299
3300        #[test]
3301        fn json_deny_shape_carries_default_deny_reason() {
3302            let (c, p, cfg) = loaded();
3303            let pdp = Pdp::new(&c, &p, &cfg);
3304            // `svc.grafana` is a reader only: `set` is not granted (default-deny).
3305            let out = render_explanation_to_string(
3306                &pdp,
3307                "svc.grafana",
3308                Op::Set,
3309                "grafana.admin_password",
3310                true,
3311            );
3312            let doc: serde_json::Value = serde_json::from_str(&out).expect("json");
3313            assert_eq!(doc["decision"], "deny");
3314            assert_eq!(doc["reason"], "not_permitted");
3315            // A deny must NOT leak an allow `via`/`matched_rule`.
3316            assert!(doc.get("via").is_none());
3317            assert!(doc.get("matched_rule").is_none());
3318
3319            // An unknown key denies with the unknown-key reason.
3320            let unknown =
3321                render_explanation_to_string(&pdp, "svc.grafana", Op::Get, "no.such.key", true);
3322            let doc: serde_json::Value = serde_json::from_str(&unknown).expect("json");
3323            assert_eq!(doc["decision"], "deny");
3324            assert_eq!(doc["reason"], "unknown_key");
3325        }
3326
3327        #[test]
3328        fn subject_name_selects_the_rule_to_explain() {
3329            let (c, p, cfg) = loaded();
3330            let pdp = Pdp::new(&c, &p, &cfg);
3331            let denied = render_explanation_to_string(
3332                &pdp,
3333                "svc.grafana",
3334                Op::Set,
3335                "grafana.admin_password",
3336                true,
3337            );
3338            let doc: serde_json::Value = serde_json::from_str(&denied).expect("json");
3339            assert_eq!(doc["decision"], "deny", "reader subject cannot set");
3340
3341            let allowed = render_explanation_to_string(
3342                &pdp,
3343                "ops.wheel",
3344                Op::Set,
3345                "grafana.admin_password",
3346                true,
3347            );
3348            let doc: serde_json::Value = serde_json::from_str(&allowed).expect("json");
3349            assert_eq!(doc["decision"], "allow", "operator subject can set");
3350            assert_eq!(doc["via"], "subject:ops.wheel");
3351            assert_eq!(doc["matched_rule"]["rule"], "wheel-operator");
3352        }
3353
3354        #[test]
3355        fn effective_mode_renders_granted_key_op_set() {
3356            let (c, p, cfg) = loaded();
3357            let pdp = Pdp::new(&c, &p, &cfg);
3358            let mut buf = Vec::new();
3359            render_effective(&mut buf, &pdp, "ops.wheel", true).expect("render effective");
3360            let doc: serde_json::Value = serde_json::from_slice(&buf).expect("effective json");
3361            assert_eq!(doc["subject"], "ops.wheel");
3362            let rows = doc["effective"].as_array().expect("effective rows");
3363            let pairs: Vec<(&str, &str)> = rows
3364                .iter()
3365                .filter_map(|r| Some((r["key"].as_str()?, r["op"].as_str()?)))
3366                .collect();
3367            assert!(
3368                pairs.contains(&("grafana.admin_password", "set")),
3369                "operator set granted: {pairs:?}"
3370            );
3371            assert!(
3372                pairs.contains(&("grafana.admin_password", "rotate")),
3373                "operator rotate granted: {pairs:?}"
3374            );
3375            assert!(
3376                rows.iter().all(|r| r["via"] == "subject:ops.wheel"),
3377                "every grant is via the wheel subject"
3378            );
3379
3380            // An unknown subject renders an empty effective set (default-deny),
3381            // not an error and not a spurious allow.
3382            let mut buf = Vec::new();
3383            render_effective(&mut buf, &pdp, "missing.subject", true).expect("render effective");
3384            let doc: serde_json::Value = serde_json::from_slice(&buf).expect("json");
3385            assert!(
3386                doc["effective"].as_array().expect("rows").is_empty(),
3387                "no grants -> empty effective set"
3388            );
3389        }
3390
3391        /// The fail-safe bail: `run_explain` WITHOUT `--op`/`--key` (and not in
3392        /// `--effective` mode) must error cleanly, never panic, never emit a
3393        /// misleading allow. Driven through the real handler with temp catalog/policy
3394        /// files so the bail at the end of `run_explain` is reached.
3395        #[test]
3396        fn missing_op_key_without_effective_bails_cleanly() {
3397            let dir = std::env::temp_dir();
3398            let stamp = format!("{}-{}", std::process::id(), uuid::Uuid::new_v4());
3399            let catalog_path = dir.join(format!("basil-explain-cat-{stamp}.json"));
3400            let policy_path = dir.join(format!("basil-explain-pol-{stamp}.json"));
3401            std::fs::write(&catalog_path, CATALOG).expect("write catalog");
3402            std::fs::write(&policy_path, POLICY).expect("write policy");
3403
3404            let args = ExplainArgs {
3405                subject: "svc.grafana".to_string(),
3406                op: None,
3407                key: None,
3408                effective: false,
3409                live: false,
3410                json: true,
3411                overrides: ConfigOverrides {
3412                    config: None,
3413                    catalog: Some(catalog_path.clone()),
3414                    policy: Some(policy_path.clone()),
3415                    bundle: None,
3416                    socket: None,
3417                    vault_addr: None,
3418                },
3419            };
3420
3421            let err = run_explain(&args).expect_err("missing op/key must bail");
3422            assert!(
3423                err.to_string().contains("--op and --key are required"),
3424                "clean bail, not a panic: {err}"
3425            );
3426
3427            std::fs::remove_file(&catalog_path).ok();
3428            std::fs::remove_file(&policy_path).ok();
3429        }
3430    }
3431}