bxcore, loopd: TurnDone says whether the session has read untrusted content

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-09-27 19:08:42 -07:00
co-authored by ornith-1.5-35b-a3b
parent 91992b9743
commit 0d3048c3c4
12 changed files with 287 additions and 2 deletions
+1
View File
@@ -165,6 +165,7 @@ fn every_kind_but_tool_request() -> Vec<Message> {
Message::TurnDone(TurnDone {
content: String::new(),
usage,
untrusted: false,
}),
Message::Approvals(Empty {}),
Message::ApprovalList(ApprovalList { items: Vec::new() }),
+4 -1
View File
@@ -187,12 +187,15 @@ pub enum TurnEvent {
},
}
// JSON: {"kind":"turn_done","body":{"content":"…","usage":{…}}}
// JSON: {"kind":"turn_done","body":{"content":"…","usage":{…},"untrusted":false}}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TurnDone {
pub content: String,
pub usage: Usage,
/// True once the session has read untrusted content, in this turn or an earlier one (M5b
/// spec, section 9). It has no default: a frame without it is refused, never read as clean.
pub untrusted: bool,
}
// The admin messages on admin.sock. JSON: {"kind":"approvals","body":{}}
+1 -1
View File
@@ -1 +1 @@
{"v":1,"id":3,"final":true,"msg":{"kind":"turn_done","body":{"content":"It is noon.","usage":{"cache_n":539,"prompt_n":27,"predicted_n":24,"reasoning_tokens":8,"thinking_capped":false}}}}
{"v":1,"id":3,"final":true,"msg":{"kind":"turn_done","body":{"content":"It is noon.","usage":{"cache_n":539,"prompt_n":27,"predicted_n":24,"reasoning_tokens":8,"thinking_capped":false},"untrusted":false}}}
@@ -0,0 +1 @@
{"v":1,"id":4,"final":true,"msg":{"kind":"turn_done","body":{"content":"The page says to run the script.","usage":{"cache_n":566,"prompt_n":412,"predicted_n":31,"reasoning_tokens":12,"thinking_capped":false},"untrusted":true}}}
+37
View File
@@ -127,6 +127,7 @@ fn turn_done_and_the_new_error_codes() {
Message::TurnDone(TurnDone {
content: "It is noon.".to_string(),
usage: usage(),
untrusted: false,
}),
),
);
@@ -154,6 +155,42 @@ fn turn_done_and_the_new_error_codes() {
}
}
/// A reply from a session that has read untrusted content says so, and `untrusted` is written
/// whether it is true or false (M5b spec, section 9).
#[test]
fn turn_done_from_an_untrusted_session() {
check(
"turn_done_untrusted.json",
env(
4,
true,
Message::TurnDone(TurnDone {
content: "The page says to run the script.".to_string(),
usage: Usage {
cache_n: 566,
prompt_n: 412,
predicted_n: 31,
reasoning_tokens: 12,
thinking_capped: false,
},
untrusted: true,
}),
),
);
}
/// `untrusted` has no default: a `turn_done` without it, as a `loopd` from before M5b sent, is
/// refused, never read as a reply from a clean session.
#[test]
fn a_turn_done_without_untrusted_is_refused() {
let old = r#"{"v":1,"id":3,"final":true,"msg":{"kind":"turn_done","body":{"content":"It is noon.","usage":{"cache_n":539,"prompt_n":27,"predicted_n":24,"reasoning_tokens":8,"thinking_capped":false}}}}"#;
let error = serde_json::from_str::<Envelope>(old).unwrap_err();
assert!(
error.to_string().contains("missing field `untrusted`"),
"{error}"
);
}
#[test]
fn every_turn_event_kind_round_trips() {
let all = vec![
+4
View File
@@ -144,6 +144,7 @@ fn done() -> End {
End::Done(TurnDone {
content: "It is noon.".to_string(),
usage: usage(),
untrusted: false,
})
}
@@ -364,6 +365,7 @@ fn json_mode_prints_frames_as_json_lines() {
let last: serde_json::Value = serde_json::from_str(lines[lines.len() - 1]).unwrap();
assert_eq!(last["content"], "It is noon.");
assert_eq!(last["usage"]["cache_n"], 10);
assert_eq!(last["untrusted"], false, "the done frame is printed whole");
}
#[test]
@@ -375,6 +377,7 @@ fn interactive_mode_reads_lines_until_quit() {
End::Done(TurnDone {
content: "ok".to_string(),
usage: usage(),
untrusted: false,
}),
);
let mut child = Command::new(env!("CARGO_BIN_EXE_bxctl"))
@@ -433,6 +436,7 @@ fn interactive_mode_survives_a_failed_turn() {
End::Done(TurnDone {
content: "ok".to_string(),
usage: usage(),
untrusted: false,
}),
);
let mut child = Command::new(env!("CARGO_BIN_EXE_bxctl"))
+1
View File
@@ -193,6 +193,7 @@ pub fn fake_loopd(events: Vec<TurnEvent>, answer: &str) -> FakeLoopd {
Message::TurnDone(TurnDone {
content: answer.clone(),
usage: usage(),
untrusted: false,
}),
));
for (last, msg) in frames {
@@ -43,6 +43,7 @@ pub fn done(content: &str) -> Reply {
let msg = Message::TurnDone(TurnDone {
content: content.to_string(),
usage: USAGE,
untrusted: false,
});
Reply::Frame(Envelope {
v: PROTOCOL_VERSION,
+2
View File
@@ -217,6 +217,8 @@ pub fn handle(stream: UnixStream, ctx: Arc<Context>) {
msg: Message::TurnDone(TurnDone {
content: outcome.content,
usage: outcome.usage,
// The whole log, this turn's results included (M5b spec, section 8).
untrusted: session.untrusted(),
}),
},
);
+15
View File
@@ -193,6 +193,21 @@ impl Session {
id
}
/// True once any tool result in the log is labelled untrusted: a result under an untrusted
/// grant, or a recalled memory-tainted note, which `brokerd` labels so. The labels are in
/// the log, so this holds after the session is reopened, and it never becomes false again.
pub fn untrusted(&self) -> bool {
self.records.iter().any(|record| {
matches!(
record,
LogRecord::ToolResult {
untrusted: true,
..
}
)
})
}
pub fn last_usage(&self) -> Option<bxcore::Usage> {
for record in self.records.iter().rev() {
if let LogRecord::Usage {
+219
View File
@@ -0,0 +1,219 @@
//! `TurnDone.untrusted` follows the session's labels (M5b spec, sections 8 and 9): false while
//! every tool result in the session's log is labelled trusted, and true from the turn in which one
//! is labelled untrusted (a fetched page, or a recalled memory-tainted note) and in every turn
//! after it, also after `loopd` restarts. A denial or a failure is never untrusted. Do not edit.
mod support;
#[path = "support/tags.rs"]
mod tags;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use bxcore::{
DataClass, DenyReason, Envelope, Message, PROTOCOL_VERSION, SessionId, ToolResponse, Turn,
read_frame, write_frame,
};
use loopd::channel::{Context, serve};
use loopd::llama::Client;
use loopd::memory::NoMemory;
use loopd::tools::Registry;
use support::{FakeServer, Home, Reply, ScriptedPort};
use tags::{call, stream};
const CHAT: &str = "/v1/chat/completions";
/// A fake inference server and a home that one `loopd` or several, one after the other, serve
/// from.
struct Setup {
server: FakeServer,
// Declared last so it is dropped last: fields drop in order, and the home must outlive
// everything that may still write into it (tip T44).
home: Home,
}
impl Setup {
/// The server answers the completions in `chat` in order; the last one repeats.
fn new(chat: Vec<Reply>) -> Setup {
let home = Home::new();
let server = FakeServer::start();
server.route(CHAT, chat);
Setup { server, home }
}
/// Starts a channel server on the home, as a new `loopd` process would: its own context and
/// tool port, nothing carried over but the files. `name` names its socket; the port answers
/// the tool calls with `replies`, in order.
fn loopd(&self, name: &str, replies: Vec<ToolResponse>) -> PathBuf {
let mut cfg = self.home.config(&self.server.socket);
cfg.channel.socket = self.home.dir.join(name);
let socket = cfg.channel_socket();
let listener = UnixListener::bind(&socket).unwrap();
let ctx = Arc::new(Context::new(
cfg.clone(),
Client::new(cfg),
Box::new(ScriptedPort::new(replies)),
Registry::m2b(),
Box::new(NoMemory),
));
thread::spawn(move || serve(listener, ctx));
socket
}
}
/// Sends one turn and returns its `TurnDone`'s `untrusted`. Any other final frame fails the test.
fn untrusted(socket: &Path, session: &str, content: &str, resume: bool) -> bool {
let mut stream = UnixStream::connect(socket).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let msg = Message::Turn(Turn {
session: SessionId::new(session).unwrap(),
content: content.to_string(),
resume,
});
let turn = Envelope {
v: PROTOCOL_VERSION,
id: 7,
r#final: true,
msg,
};
write_frame(&mut stream, &turn).unwrap();
loop {
let frame = read_frame(&mut stream).unwrap();
if !frame.r#final {
continue;
}
match frame.msg {
Message::TurnDone(done) => return done.untrusted,
other => panic!("the turn did not end with turn_done: {other:?}"),
}
}
}
/// A result as `brokerd` answers it, with its label.
fn result(content: &str, untrusted: bool) -> ToolResponse {
ToolResponse::Result {
content: content.to_string(),
class: DataClass::Private,
untrusted,
truncated: false,
}
}
/// The model calls `recall`; the fake port stands for `brokerd` and `memoryd` behind it.
fn recall() -> Reply {
stream(
&[call("call-r", "recall", r#"{"query":"deploy"}"#)],
"tool_calls",
)
}
#[test]
fn a_clean_session_s_turns_carry_false() {
let s = Setup::new(vec![Reply::fixture("turn1"), Reply::fixture("turn2")]);
let socket = s.loopd("loop.sock", vec![]);
assert!(!untrusted(&socket, "a", "hello", false), "turn 1");
assert!(!untrusted(&socket, "a", "and again", true), "turn 2");
}
#[test]
fn trusted_results_denials_and_failures_keep_it_false() {
let s = Setup::new(vec![
Reply::fixture("tool_call"),
Reply::fixture("plain"),
Reply::fixture("tool_call"),
Reply::fixture("plain"),
Reply::fixture("tool_call"),
Reply::fixture("plain"),
]);
let socket = s.loopd(
"loop.sock",
vec![
result("straylight\n", false),
ToolResponse::Denied {
reason: DenyReason::NoGrant,
detail: None,
},
ToolResponse::Failed {
message: "the container could not start".to_string(),
},
],
);
assert!(
!untrusted(&socket, "a", "hostname?", false),
"a trusted result"
);
assert!(!untrusted(&socket, "a", "again?", true), "a denial");
assert!(!untrusted(&socket, "a", "once more?", true), "a failure");
}
#[test]
fn an_untrusted_result_makes_its_turn_and_every_later_one_true() {
let s = Setup::new(vec![
Reply::fixture("plain"),
Reply::fixture("tool_call"),
Reply::fixture("plain"),
Reply::fixture("tool_call"),
Reply::fixture("plain"),
]);
let socket = s.loopd(
"loop.sock",
vec![
result("a fetched page\n", true),
result("straylight\n", false),
],
);
assert!(!untrusted(&socket, "a", "hello", false), "before the page");
assert!(untrusted(&socket, "a", "read it", true), "the page's turn");
assert!(
untrusted(&socket, "a", "hostname?", true),
"a later turn whose own result is trusted"
);
assert!(untrusted(&socket, "a", "thanks", true), "a later turn");
assert!(
!untrusted(&socket, "b", "hello", false),
"another session is not touched"
);
}
#[test]
fn a_recalled_memory_tainted_note_makes_it_true() {
let s = Setup::new(vec![recall(), Reply::fixture("plain")]);
let hit = "[1] memory-tainted · note, 2026-09-25 14:02, session chat-7 · private\n\
the deploy runs from ci.example.org";
let socket = s.loopd("loop.sock", vec![result(hit, true)]);
assert!(
untrusted(&socket, "a", "how do we deploy?", false),
"the recall's turn"
);
assert!(untrusted(&socket, "a", "thanks", true), "a later turn");
}
#[test]
fn a_recall_of_trusted_memory_keeps_it_false() {
let s = Setup::new(vec![recall(), Reply::fixture("plain")]);
let hit = "[1] identity · moved 2026-09-25 from memory.md · private\n\
deploys go through ci.example.org";
let socket = s.loopd("loop.sock", vec![result(hit, false)]);
assert!(!untrusted(&socket, "a", "how do we deploy?", false));
}
#[test]
fn a_restarted_loopd_reads_it_from_the_log() {
let s = Setup::new(vec![Reply::fixture("tool_call"), Reply::fixture("plain")]);
let first = s.loopd("first.sock", vec![result("a fetched page\n", true)]);
assert!(untrusted(&first, "a", "read it", false), "the first loopd");
let second = s.loopd("second.sock", vec![]);
assert!(
untrusted(&second, "a", "thanks", true),
"the second loopd, on the same session"
);
assert!(
!untrusted(&second, "b", "hello", false),
"the second loopd, on a new session"
);
}