brokerd: consult decisions record the body
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<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 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 {
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,5 +94,6 @@ pub fn denied(call: u64) -> AuditEvent {
|
||||
grant_sha256: None,
|
||||
taint: DataClass::Private,
|
||||
untrusted: false,
|
||||
body: None,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user