brokerd: consult decisions record the body

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-10-03 22:28:32 -07:00
co-authored by ornith-1.5-35b-a3b
parent d488910351
commit 2c939ea310
9 changed files with 280 additions and 31 deletions
+24 -1
View File
@@ -252,7 +252,30 @@ pub fn handle(mut stream: UnixStream, broker: &Broker) {
let now = Timestamp::now(); let now = Timestamp::now();
let grants = broker.grants(); 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 { Decided::Denied(denial) => Some(ToolResponse::Denied {
reason: denial.reason, reason: denial.reason,
detail: denial.detail, detail: denial.detail,
+3
View File
@@ -18,6 +18,9 @@ pub const DEFAULT_TIMEOUT_MS: u64 = 180_000;
pub const DEFAULT_TEMPERATURE_MILLI: u16 = 600; pub const DEFAULT_TEMPERATURE_MILLI: u16 = 600;
/// Start failure when the named secret cannot be read. /// Start failure when the named secret cannot be read.
pub const SECRET_POINTER: &str = "see docs/runbook.md#consult-secret-unreadable"; 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. /// `[consult]` in `brokerd.toml`. Unknown keys are errors.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
+76 -30
View File
@@ -203,6 +203,40 @@ impl Ledger {
/// Decide and record a call. /// Decide and record a call.
pub fn decide(&self, request: ToolRequest, grants: &Grants, now: Timestamp) -> Decided { 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<String>,
) -> 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<String>,
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<String>,
forced: Option<crate::policy::Denial>,
) -> Decided {
let session = request.session.clone(); let session = request.session.clone();
let call = request.call; let call = request.call;
let tool = request.tool.clone(); let tool = request.tool.clone();
@@ -225,47 +259,58 @@ impl Ledger {
}; };
// 3. The outcome, first that applies. // 3. The outcome, first that applies.
let (outcome, grant, grant_sha256, payload) = match grants { let (outcome, grant, grant_sha256, payload) = if let Some(denial) = forced {
Err(_) => ( (
DecisionRecord::Denied { DecisionRecord::Denied {
reason: DenyReason::GrantsInvalid, reason: denial.reason,
}, },
None, denial.grant.clone(),
None, denial.grant_sha256,
RecordPayload::Simple(DenyReason::GrantsInvalid), RecordPayload::Denial(denial),
), )
Ok(set) => match state { } else {
Err(()) => ( match grants {
Err(_) => (
DecisionRecord::Denied { DecisionRecord::Denied {
reason: DenyReason::StateUnreadable, reason: DenyReason::GrantsInvalid,
}, },
None, None,
None, None,
RecordPayload::Simple(DenyReason::StateUnreadable), RecordPayload::Simple(DenyReason::GrantsInvalid),
), ),
Ok(state) => match crate::policy::decide(request, set, state, now) { Ok(set) => match state {
crate::policy::Outcome::Allowed(decision) => ( Err(()) => (
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 { DecisionRecord::Denied {
reason: denial.reason, reason: DenyReason::StateUnreadable,
}, },
denial.grant.clone(), None,
denial.grant_sha256, None,
RecordPayload::Denial(denial), 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. // 4. Write the record, then return the verdict.
@@ -279,6 +324,7 @@ impl Ledger {
grant_sha256, grant_sha256,
taint: recorded.taint, taint: recorded.taint,
untrusted: recorded.untrusted, untrusted: recorded.untrusted,
body,
}; };
match self.append(&mut guard, now, event) { match self.append(&mut guard, now, event) {
Ok(seq) => match payload { Ok(seq) => match payload {
+168
View File
@@ -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::<serde_json::Value>(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:?}"),
}
}
+1
View File
@@ -83,6 +83,7 @@ fn an_allowed_call_is_recorded_in_full() {
grant_sha256: Some(bxcore::sha256(b"n").unwrap()), grant_sha256: Some(bxcore::sha256(b"n").unwrap()),
taint: DataClass::Private, taint: DataClass::Private,
untrusted: false, untrusted: false,
body: None,
} }
); );
} }
@@ -94,5 +94,6 @@ pub fn denied(call: u64) -> AuditEvent {
grant_sha256: None, grant_sha256: None,
taint: DataClass::Private, taint: DataClass::Private,
untrusted: false, untrusted: false,
body: None,
} }
} }
+3
View File
@@ -44,6 +44,9 @@ pub enum AuditEvent {
grant_sha256: Option<Hash32>, grant_sha256: Option<Hash32>,
taint: DataClass, taint: DataClass,
untrusted: bool, 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<String>,
}, },
Approval { Approval {
session: SessionId, session: SessionId,
+3
View File
@@ -68,6 +68,7 @@ fn audit_records() {
grant_sha256: Some(seq_hash), grant_sha256: Some(seq_hash),
taint: DataClass::Private, taint: DataClass::Private,
untrusted: false, untrusted: false,
body: None,
}, },
), ),
record( record(
@@ -101,6 +102,7 @@ fn audit_records() {
grant_sha256: Some(rev_hash), grant_sha256: Some(rev_hash),
taint: DataClass::Secret, taint: DataClass::Secret,
untrusted: true, untrusted: true,
body: None,
}, },
), ),
record( record(
@@ -155,6 +157,7 @@ fn audit_records() {
grant_sha256: None, grant_sha256: None,
taint: DataClass::Private, taint: DataClass::Private,
untrusted: false, untrusted: false,
body: None,
}, },
), ),
record( record(
+1
View File
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | | 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<String>` 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/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/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 | | 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 |