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:
@@ -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(),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user