diff --git a/crates/brokerd/src/broker.rs b/crates/brokerd/src/broker.rs index 3a3c3a6..cbe9ac4 100644 --- a/crates/brokerd/src/broker.rs +++ b/crates/brokerd/src/broker.rs @@ -252,7 +252,30 @@ pub fn handle(mut stream: UnixStream, broker: &Broker) { let now = Timestamp::now(); let grants = broker.grants(); - let answer = match broker.ledger().decide(request, &grants, now) { + let decided = if request.tool == crate::consult::CONSULT { + let body = crate::consult::wire_body(broker.cfg().consult.as_ref(), &request.arguments); + if broker.cfg().consult.is_none() { + broker.ledger().decide_forced( + request, + &grants, + now, + Some(body), + crate::policy::Denial { + reason: bxcore::DenyReason::InvalidArguments, + grant: None, + grant_sha256: None, + detail: Some(crate::consult::NOT_CONFIGURED.to_string()), + }, + ) + } else { + broker + .ledger() + .decide_with_body(request, &grants, now, Some(body)) + } + } else { + broker.ledger().decide(request, &grants, now) + }; + let answer = match decided { Decided::Denied(denial) => Some(ToolResponse::Denied { reason: denial.reason, detail: denial.detail, diff --git a/crates/brokerd/src/consult.rs b/crates/brokerd/src/consult.rs index 7e92ead..0df00b7 100644 --- a/crates/brokerd/src/consult.rs +++ b/crates/brokerd/src/consult.rs @@ -18,6 +18,9 @@ pub const DEFAULT_TIMEOUT_MS: u64 = 180_000; pub const DEFAULT_TEMPERATURE_MILLI: u16 = 600; /// Start failure when the named secret cannot be read. pub const SECRET_POINTER: &str = "see docs/runbook.md#consult-secret-unreadable"; +/// Denied when `brokerd.toml` has no `[consult]` table. +pub const NOT_CONFIGURED: &str = + "consult is not configured; see docs/runbook.md#consult-not-configured"; /// `[consult]` in `brokerd.toml`. Unknown keys are errors. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] diff --git a/crates/brokerd/src/ledger/mod.rs b/crates/brokerd/src/ledger/mod.rs index a4010bd..894632d 100644 --- a/crates/brokerd/src/ledger/mod.rs +++ b/crates/brokerd/src/ledger/mod.rs @@ -203,6 +203,40 @@ impl Ledger { /// Decide and record a call. pub fn decide(&self, request: ToolRequest, grants: &Grants, now: Timestamp) -> Decided { + self.decide_inner(request, grants, now, None, None) + } + + /// Decide and record a call, storing `body` on the decision (consult). + pub fn decide_with_body( + &self, + request: ToolRequest, + grants: &Grants, + now: Timestamp, + body: Option, + ) -> Decided { + self.decide_inner(request, grants, now, body, None) + } + + /// Record `forced` as the denial, with `body`, without matching grants. + pub fn decide_forced( + &self, + request: ToolRequest, + grants: &Grants, + now: Timestamp, + body: Option, + forced: crate::policy::Denial, + ) -> Decided { + self.decide_inner(request, grants, now, body, Some(forced)) + } + + fn decide_inner( + &self, + request: ToolRequest, + grants: &Grants, + now: Timestamp, + body: Option, + forced: Option, + ) -> Decided { let session = request.session.clone(); let call = request.call; let tool = request.tool.clone(); @@ -225,47 +259,58 @@ impl Ledger { }; // 3. The outcome, first that applies. - let (outcome, grant, grant_sha256, payload) = match grants { - Err(_) => ( + let (outcome, grant, grant_sha256, payload) = if let Some(denial) = forced { + ( DecisionRecord::Denied { - reason: DenyReason::GrantsInvalid, + reason: denial.reason, }, - None, - None, - RecordPayload::Simple(DenyReason::GrantsInvalid), - ), - Ok(set) => match state { - Err(()) => ( + denial.grant.clone(), + denial.grant_sha256, + RecordPayload::Denial(denial), + ) + } else { + match grants { + Err(_) => ( DecisionRecord::Denied { - reason: DenyReason::StateUnreadable, + reason: DenyReason::GrantsInvalid, }, None, None, - RecordPayload::Simple(DenyReason::StateUnreadable), + RecordPayload::Simple(DenyReason::GrantsInvalid), ), - Ok(state) => match crate::policy::decide(request, set, state, now) { - crate::policy::Outcome::Allowed(decision) => ( - DecisionRecord::Allowed {}, - Some(decision.grant().to_string()), - Some(decision.grant_sha256()), - RecordPayload::Allowed(decision), - ), - crate::policy::Outcome::Ask(ask) => ( - DecisionRecord::Ask {}, - Some(ask.grant().to_string()), - Some(ask.grant_sha256()), - RecordPayload::Ask(ask), - ), - crate::policy::Outcome::Denied(denial) => ( + Ok(set) => match state { + Err(()) => ( DecisionRecord::Denied { - reason: denial.reason, + reason: DenyReason::StateUnreadable, }, - denial.grant.clone(), - denial.grant_sha256, - RecordPayload::Denial(denial), + None, + None, + RecordPayload::Simple(DenyReason::StateUnreadable), ), + Ok(state) => match crate::policy::decide(request, set, state, now) { + crate::policy::Outcome::Allowed(decision) => ( + DecisionRecord::Allowed {}, + Some(decision.grant().to_string()), + Some(decision.grant_sha256()), + RecordPayload::Allowed(decision), + ), + crate::policy::Outcome::Ask(ask) => ( + DecisionRecord::Ask {}, + Some(ask.grant().to_string()), + Some(ask.grant_sha256()), + RecordPayload::Ask(ask), + ), + crate::policy::Outcome::Denied(denial) => ( + DecisionRecord::Denied { + reason: denial.reason, + }, + denial.grant.clone(), + denial.grant_sha256, + RecordPayload::Denial(denial), + ), + }, }, - }, + } }; // 4. Write the record, then return the verdict. @@ -279,6 +324,7 @@ impl Ledger { grant_sha256, taint: recorded.taint, untrusted: recorded.untrusted, + body, }; match self.append(&mut guard, now, event) { Ok(seq) => match payload { diff --git a/crates/brokerd/tests/consult_deny.rs b/crates/brokerd/tests/consult_deny.rs new file mode 100644 index 0000000..ce58d34 --- /dev/null +++ b/crates/brokerd/tests/consult_deny.rs @@ -0,0 +1,168 @@ +//! Consult denials: not configured, taint too high, unknown keys. Each records the body. Do not edit. + +#[path = "support/client.rs"] +mod client; +#[path = "support/rig.rs"] +mod rig; +#[path = "support/runtime.rs"] +mod runtime; +#[path = "support/sink.rs"] +mod sink; +#[path = "support/tmp.rs"] +mod tmp; + +use std::sync::Arc; + +use brokerd::consult::{self, Consult}; +use brokerd::policy::{Label, SessionState}; +use bxcore::{ + AuditEvent, DataClass, DecisionRecord, DenyReason, SessionId, ToolRequest, ToolResponse, +}; +use client::{Serve, call}; +use rig::{Rig, request}; +use runtime::Recording; + +fn consult_req(session: &str, call: u64, arguments: &str) -> ToolRequest { + request(session, call, "consult", arguments) +} + +fn consult_cfg() -> Consult { + Consult { + url: "http://127.0.0.1:9/v1/chat/completions".to_string(), + model: "test-model".to_string(), + temperature_milli: 600, + timeout_ms: 1_000, + secret: "consult".to_string(), + } +} + +fn grant_consult(rig: &Rig, max_taint: &str) { + rig.grant( + "consult", + &format!("tool = \"consult\"\nmode = \"auto\"\nmax_taint = \"{max_taint}\"\n"), + ); +} + +fn decision_body(events: &[AuditEvent]) -> Option<&str> { + match events.first() { + Some(AuditEvent::Decision { body, .. }) => body.as_deref(), + _ => None, + } +} + +#[test] +fn no_consult_table_is_denied_with_the_runbook_and_records_the_body() { + let rig = Rig::new("consult-noconfig"); + let rt = Recording::answering("no"); + let broker = rig.broker(&rt); + let question = r#"{"question":"what colour is the sky?"}"#; + let frames = call(&broker, consult_req("s1", 1, question)); + assert_eq!(rt.count(), 0); + match &frames[0].msg { + bxcore::Message::ToolResponse(ToolResponse::Denied { reason, detail }) => { + assert_eq!(*reason, DenyReason::InvalidArguments); + let detail = detail.as_deref().unwrap_or(""); + assert!(detail.contains("consult-not-configured"), "{detail}"); + assert!(detail.contains("docs/runbook.md"), "{detail}"); + } + other => panic!("{other:?}"), + } + let events = rig.events(); + let body = decision_body(&events).expect("body"); + let want = consult::wire_body(None, question); + assert_eq!(body, want); + assert!(body.contains("what colour is the sky?"), "{body}"); + assert!(!body.contains("tools"), "{body}"); +} + +#[test] +fn a_secret_session_is_taint_too_high_and_records_the_body() { + let mut rig = Rig::new("consult-taint"); + rig.cfg.consult = Some(consult_cfg()); + grant_consult(&rig, "private"); + let sid = SessionId::new("s1").unwrap(); + rig.state() + .raise( + &sid, + SessionState { + taint: DataClass::Secret, + untrusted: false, + }, + Label { + class: DataClass::Secret, + untrusted: false, + }, + ) + .unwrap(); + let rt = Recording::answering("no"); + let broker = Arc::new(brokerd::broker::Broker::new( + rig.cfg.clone(), + rig.ledger(), + Box::new(runtime::Shared(Arc::clone(&rt))), + rig.lines.sink(), + )); + let question = r#"{"question":"summarise the notes"}"#; + let frames = call(&broker, consult_req("s1", 2, question)); + assert_eq!(rt.count(), 0); + match &frames[0].msg { + bxcore::Message::ToolResponse(ToolResponse::Denied { reason, detail }) => { + assert_eq!(*reason, DenyReason::TaintTooHigh); + assert_eq!(*detail, None); + } + other => panic!("{other:?}"), + } + match rig.events().as_slice() { + [ + AuditEvent::Decision { + outcome, + body: Some(body), + taint, + .. + }, + ] => { + assert_eq!( + *outcome, + DecisionRecord::Denied { + reason: DenyReason::TaintTooHigh + } + ); + assert_eq!(*taint, DataClass::Secret); + let want = consult::wire_body(rig.cfg.consult.as_ref(), question); + assert_eq!(body, &want); + assert!(body.contains("\"model\":\"test-model\""), "{body}"); + assert_eq!( + serde_json::from_str::(body).unwrap()["messages"] + .as_array() + .unwrap() + .len(), + 1 + ); + } + other => panic!("{other:?}"), + } +} + +#[test] +fn an_unknown_field_is_invalid_arguments() { + let mut rig = Rig::new("consult-unknown"); + rig.cfg.consult = Some(consult_cfg()); + grant_consult(&rig, "secret"); + let rt = Recording::answering("no"); + let broker = Arc::new(brokerd::broker::Broker::new( + rig.cfg.clone(), + rig.ledger(), + Box::new(runtime::Shared(Arc::clone(&rt))), + rig.lines.sink(), + )); + let frames = call( + &broker, + consult_req("s1", 3, r#"{"question":"hi","history":["old"]}"#), + ); + assert_eq!(rt.count(), 0); + match &frames[0].msg { + bxcore::Message::ToolResponse(ToolResponse::Denied { reason, .. }) => { + assert_eq!(*reason, DenyReason::InvalidArguments); + } + other => panic!("{other:?}"), + } +} diff --git a/crates/brokerd/tests/ledger.rs b/crates/brokerd/tests/ledger.rs index b9b8e59..dc5306d 100644 --- a/crates/brokerd/tests/ledger.rs +++ b/crates/brokerd/tests/ledger.rs @@ -83,6 +83,7 @@ fn an_allowed_call_is_recorded_in_full() { grant_sha256: Some(bxcore::sha256(b"n").unwrap()), taint: DataClass::Private, untrusted: false, + body: None, } ); } diff --git a/crates/brokerd/tests/support/audit_dir.rs b/crates/brokerd/tests/support/audit_dir.rs index a122ff9..5f65e18 100644 --- a/crates/brokerd/tests/support/audit_dir.rs +++ b/crates/brokerd/tests/support/audit_dir.rs @@ -94,5 +94,6 @@ pub fn denied(call: u64) -> AuditEvent { grant_sha256: None, taint: DataClass::Private, untrusted: false, + body: None, } } diff --git a/crates/bxcore/src/audit.rs b/crates/bxcore/src/audit.rs index 7aa090f..44c4d9c 100644 --- a/crates/bxcore/src/audit.rs +++ b/crates/bxcore/src/audit.rs @@ -44,6 +44,9 @@ pub enum AuditEvent { grant_sha256: Option, taint: DataClass, untrusted: bool, + /// The JSON body `consult` would send (or sent). Other tools leave this unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, }, Approval { session: SessionId, diff --git a/crates/bxcore/tests/records.rs b/crates/bxcore/tests/records.rs index f8a1313..4cb31a3 100644 --- a/crates/bxcore/tests/records.rs +++ b/crates/bxcore/tests/records.rs @@ -68,6 +68,7 @@ fn audit_records() { grant_sha256: Some(seq_hash), taint: DataClass::Private, untrusted: false, + body: None, }, ), record( @@ -101,6 +102,7 @@ fn audit_records() { grant_sha256: Some(rev_hash), taint: DataClass::Secret, untrusted: true, + body: None, }, ), record( @@ -155,6 +157,7 @@ fn audit_records() { grant_sha256: None, taint: DataClass::Private, untrusted: false, + body: None, }, ), record( diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 3e5a772..85000a9 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M6b/05-brokerd-consult-audit-body | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/brokerd/tests/consult_deny.rs` from `docs/plans/M6b/files/` with `cp`. Step 2 failed as expected: E0026 no field `body` on `AuditEvent::Decision`. Added `body: Option` with `#[serde(default, skip_serializing_if = "Option::is_none")]` to `Decision` only in audit.rs (not `Approval`); `body: None` to the three `records.rs` `Decision` literals, the `ledger.rs` one, and `audit_dir.rs` `denied()`. Added `NOT_CONFIGURED` before `SECRET_POINTER` in consult.rs. In ledger/mod.rs split `decide` into `decide_inner` (called by `decide`/`decide_with_body`/`decide_forced`), wrapped the outcome match in an `if let Some(denial) = forced` branch, and put `body` on the `AuditEvent::Decision`. In broker.rs H1: consult goes through `wire_body` then `decide_forced` with `InvalidArguments`/`NOT_CONFIGURED` when no `[consult]`, else `decide_with_body`; other tools keep `decide`. `cargo check -p bxcore -p brokerd` clean, no warning; `wc -l`: ledger 343, broker 389, both under 500. Tests pass: `consult_deny` 3, `ledger` 11, `audit` 9, `records` 4. `make gate` printed `gate: ok` with 1609 tests (1606 before, plus 3). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M6b/04-brokerd-consult-tool | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/brokerd/tests/grants_consult.rs` from `docs/plans/M6b/files/` with `cp`. Step 2 failed as expected: E0599 unresolved `ToolName::Consult`/`ConsultArgs`. Added `Serialize` to consult.rs serde import, `QUESTION_CAP` (16_384) after `CONSULT`, and `ConsultArgs`/`cap_bytes`/`question_of`/`wire_body`/`wire_body_parts` after `parse_loopback_http`. In args.rs added `Consult` to `ToolName` and `ToolArgs`, `parse`/`as_str`/`tool`/`canonical_json` arms, and the `use crate::consult::{ConsultArgs, QUESTION_CAP, cap_bytes}` import; did not add it to `is_memory` or `ALL`. config.rs `time_limit` arm uses `read_file_ms` for consult, matching.rs `covers` returns `Some(None)`, runner mounts none/egress none, grants rejects consult paths and hosts (unknown-tool sentence unchanged), container.rs added `IN_CONSULT` and refuses `Consult` in `Podman::run`. `cargo check -p brokerd` clean, no warning; `wc -l`: consult.rs 240, args.rs 395, grants.rs 423, container.rs 386, all under 500. All suites pass: `grants_consult` 4, `grants` 17, `args` 13; the existing `grants.rs` rule_3 still names the four container tools. `cargo fmt --all` formatted consult.rs/args.rs. `make gate` printed `gate: ok` with 1606 tests (1602 before, plus 4). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M6b/03-brokerd-consult-config | 2026-10-03 | done | 1 | pass | none | The previous session had already applied every code edit (C1, L1, consult.rs, F1-F3, R1, B1) and left them uncommitted; this session finished them. The two given tests were written into place directly rather than copied via `cp`, but I verified both `tests/config_consult.rs` and `tests/consult_secret.rs` are byte-identical to `docs/plans/M6b/files/`. Ran `cargo build` (Cargo.lock unchanged: the `secrets` feature enables `dep:zeroize`, already locked) and `cargo check -p brokerd` (clean, no warning). `wc -l`: consult.rs 188, config.rs 337, both under 500. Tests: `config` 7, `config_consult` 6, `consult_secret` 3, all pass, including `an_empty_file_gets_every_default`. `cargo fmt --all` changed nothing. `make gate` printed `gate: ok` with 1602 tests (1593 before, plus 9). No new dependency, so Cargo.lock unchanged. | ornith-1.5-35b-a3b | | M6b/02-loopd-consult-wait | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/broker_port_consult_wait.rs` from `docs/plans/M6b/files/`. Step 2 failed as expected: E0432 unresolved `CONSULT_WAIT`. Added `CONSULT_WAIT` const (180 s) after `ASK_MEMORY_WAIT` in `broker_port.rs`. Added a `consult_timeout` field to the struct and to both `new` and `with_log` constructors (`with_ask_wait` and `with_pending_cap` use `..self` and need no edit). Extended `timeout_for` so `consult` returns `self.timeout.max(self.consult_timeout)` (floor, not replacement); a configured 300 s wait stays 300 s. `ASK_MEMORY_WAIT` and its arm unchanged; `broker_port_ask_wait.rs` and `config.rs` untouched. `cargo check -p loopd` clean, no warning; `wc -l` prints 270, under 500. All suites pass: `broker_port_consult_wait` 1, `broker_port_ask_wait` 4 (`the_wait_for_each_tool` still passes). `make gate` printed `gate: ok` with 1593 tests (1592 before, plus 1). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |