brokerd: memory calls to memoryd with the session's state; labels only stricter
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -53,6 +53,14 @@ pub struct Gateway {
|
||||
pub socket: PathBuf,
|
||||
}
|
||||
|
||||
/// `[memory]`: where `memoryd` takes the decided `recall` and `memory` calls. The memory tools are
|
||||
/// always on, so the section is optional; an empty path means the default under `home`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct Memory {
|
||||
pub socket: PathBuf,
|
||||
}
|
||||
|
||||
fn default_podman() -> PathBuf {
|
||||
PathBuf::from("podman")
|
||||
}
|
||||
@@ -141,6 +149,8 @@ pub struct Config {
|
||||
pub runner: Option<Runner>,
|
||||
#[serde(default)]
|
||||
pub gateway: Option<Gateway>,
|
||||
#[serde(default)]
|
||||
pub memory: Memory,
|
||||
}
|
||||
|
||||
/// The longest an approval may wait: a day, the longest `loopd` waits after a pending frame.
|
||||
@@ -283,6 +293,14 @@ impl Config {
|
||||
Some(gateway.socket.clone())
|
||||
}
|
||||
}
|
||||
/// `memoryd`'s socket for `brokerd`.
|
||||
pub fn memory_socket(&self) -> PathBuf {
|
||||
if self.memory.socket.as_os_str().is_empty() {
|
||||
self.paths.home.join("run/broker-memory/memory.sock")
|
||||
} else {
|
||||
self.memory.socket.clone()
|
||||
}
|
||||
}
|
||||
pub fn audit_dir(&self) -> PathBuf {
|
||||
self.paths.home.join("audit")
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ pub const EGRESS_POLL: Duration = Duration::from_millis(20);
|
||||
/// A log sink. Call it as `(self.log)("a line")`.
|
||||
pub type Log = Arc<dyn Fn(&str) + Send + Sync>;
|
||||
|
||||
/// A memory tool that reached the container runtime: fixed, it reaches the model.
|
||||
pub const IN_MEMORYD: &str = "the memory tools run in memoryd, not in a container";
|
||||
|
||||
pub struct Podman {
|
||||
runner: Runner,
|
||||
egress_dir: PathBuf,
|
||||
@@ -240,6 +243,10 @@ impl Runtime for Podman {
|
||||
/// an egress starts the proxy first and gives the tool the directory. `run_container` is step 3
|
||||
/// to 7 for either. Already written.
|
||||
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
|
||||
// `Routed` sends the memory tools to `memoryd`; one here is refused, never run.
|
||||
if spec.tool().is_memory() {
|
||||
return Err(RunError::Unavailable(IN_MEMORYD.to_string()));
|
||||
}
|
||||
let n = self.next.fetch_add(1, Ordering::SeqCst);
|
||||
let name = podman::container_name(spec.session(), spec.call(), n);
|
||||
let input = spec.arguments().canonical_json();
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod container;
|
||||
pub mod courier;
|
||||
pub mod grants;
|
||||
pub mod ledger;
|
||||
pub mod memory_port;
|
||||
pub mod pipes;
|
||||
pub mod podman;
|
||||
pub mod policy;
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::sync::Arc;
|
||||
use brokerd::audit::{AuditError, RECOVERED_NOTICE};
|
||||
use brokerd::config::Config;
|
||||
use brokerd::container::Podman;
|
||||
use brokerd::memory_port::{MEMORY_WAIT, MemoryPort, Routed};
|
||||
use brokerd::runner::{Refusing, Runtime};
|
||||
use brokerd::serve::{self, ServeError};
|
||||
|
||||
@@ -66,6 +67,12 @@ fn main() -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
// The memory tools go to `memoryd`, whatever runs the others (M5a spec, section 8).
|
||||
let runtime: Box<dyn Runtime> = Box::new(Routed {
|
||||
memory: MemoryPort::new(cfg.memory_socket(), MEMORY_WAIT),
|
||||
other: runtime,
|
||||
});
|
||||
|
||||
let started = match serve::start(cfg, accept_break, runtime, log) {
|
||||
Ok(started) => started,
|
||||
Err(ServeError::Audit(e @ AuditError::NothingToAccept)) => {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Where decided `recall` and `memory` calls go: to `memoryd` over its `brokerd` socket, with the
|
||||
//! session's state, instead of a container (M5a spec, section 8). `Routed` sends every other call
|
||||
//! to the configured runtime.
|
||||
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use proto::{
|
||||
Envelope, MemoryCall, Message, PROTOCOL_VERSION, ToolRequest, ToolResponse, read_frame,
|
||||
write_frame,
|
||||
};
|
||||
|
||||
use crate::policy::Label;
|
||||
use crate::runner::{RunError, RunOutput, RunSpec, Runtime};
|
||||
|
||||
/// The sentence when `memoryd` cannot be reached or answers wrongly: fixed, it reaches the model.
|
||||
pub const UNREACHABLE: &str =
|
||||
"memory is unavailable: memoryd did not answer (see docs/runbook.md#memoryd-unreachable)";
|
||||
|
||||
/// How long `brokerd` waits for `memoryd` to answer one call.
|
||||
pub const MEMORY_WAIT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// A client of `memoryd`'s `brokerd` socket. One connection per call.
|
||||
pub struct MemoryPort {
|
||||
socket: PathBuf,
|
||||
wait: Duration,
|
||||
}
|
||||
|
||||
impl MemoryPort {
|
||||
pub fn new(socket: PathBuf, wait: Duration) -> MemoryPort {
|
||||
MemoryPort { socket, wait }
|
||||
}
|
||||
|
||||
fn ask(&self, spec: &RunSpec) -> Option<ToolResponse> {
|
||||
// `None` at the first thing that fails. 1. A MemoryCall: request with the spec's session,
|
||||
// call, `spec.tool().as_str()`, and `spec.arguments().canonical_json()`; taint and
|
||||
// untrusted from `spec.state()`. 2. `UnixStream::connect(&self.socket)`, then read and
|
||||
// write timeouts of `self.wait`. 3. `write_frame` an Envelope { v: PROTOCOL_VERSION, id:
|
||||
// spec.call().0, final: true, msg: Message::MemoryCall(call) }. 4. `read_frame`; a
|
||||
// Message::ToolResponse is Some(it), any other message None.
|
||||
let call = MemoryCall {
|
||||
request: ToolRequest {
|
||||
session: spec.session().clone(),
|
||||
call: spec.call(),
|
||||
tool: spec.tool().as_str().to_string(),
|
||||
arguments: spec.arguments().canonical_json(),
|
||||
},
|
||||
taint: spec.state().taint,
|
||||
untrusted: spec.state().untrusted,
|
||||
};
|
||||
let mut stream = UnixStream::connect(&self.socket).ok()?;
|
||||
stream.set_read_timeout(Some(self.wait)).ok()?;
|
||||
stream.set_write_timeout(Some(self.wait)).ok()?;
|
||||
write_frame(
|
||||
&mut stream,
|
||||
&Envelope {
|
||||
v: PROTOCOL_VERSION,
|
||||
id: spec.call().0,
|
||||
r#final: true,
|
||||
msg: Message::MemoryCall(call),
|
||||
},
|
||||
)
|
||||
.ok()?;
|
||||
let env = read_frame(&mut stream).ok()?;
|
||||
match env.msg {
|
||||
Message::ToolResponse(response) => Some(response),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Runtime for MemoryPort {
|
||||
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
|
||||
self.run_labelled(spec).map(|(output, _)| output)
|
||||
}
|
||||
|
||||
fn run_labelled(&self, spec: &RunSpec) -> Result<(RunOutput, Option<Label>), RunError> {
|
||||
// `self.ask(spec)`: Some(Result { content, class, untrusted, truncated }) -> Ok((RunOutput
|
||||
// { content, truncated }, Some(Label { class, untrusted }))); Some(Failed { message }) ->
|
||||
// Err(RunError::Failed(message)); anything else, None included ->
|
||||
// Err(RunError::Unavailable(UNREACHABLE.to_string())).
|
||||
match self.ask(spec) {
|
||||
Some(ToolResponse::Result {
|
||||
content,
|
||||
class,
|
||||
untrusted,
|
||||
truncated,
|
||||
}) => Ok((
|
||||
RunOutput { content, truncated },
|
||||
Some(Label { class, untrusted }),
|
||||
)),
|
||||
Some(ToolResponse::Failed { message }) => Err(RunError::Failed(message)),
|
||||
_ => Err(RunError::Unavailable(UNREACHABLE.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The memory tools to `memoryd`, everything else to `other`.
|
||||
pub struct Routed {
|
||||
pub memory: MemoryPort,
|
||||
pub other: Box<dyn Runtime>,
|
||||
}
|
||||
|
||||
impl Runtime for Routed {
|
||||
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
|
||||
self.run_labelled(spec).map(|(output, _)| output)
|
||||
}
|
||||
|
||||
fn run_labelled(&self, spec: &RunSpec) -> Result<(RunOutput, Option<Label>), RunError> {
|
||||
// `spec.tool().is_memory()` -> `self.memory.run_labelled(spec)`; otherwise
|
||||
// `self.other.run_labelled(spec)`.
|
||||
if spec.tool().is_memory() {
|
||||
self.memory.run_labelled(spec)
|
||||
} else {
|
||||
self.other.run_labelled(spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,8 @@ pub struct Decision {
|
||||
request: ToolRequest,
|
||||
args: ToolArgs,
|
||||
matched: Matched,
|
||||
/// The session's state when the call was decided; `memoryd` needs it (M5a spec, section 8).
|
||||
state: SessionState,
|
||||
}
|
||||
|
||||
/// A call the broker left for the owner: built only in this module, re-decided by `redecide`.
|
||||
@@ -165,6 +167,9 @@ impl Decision {
|
||||
pub fn request(&self) -> &ToolRequest {
|
||||
&self.request
|
||||
}
|
||||
pub fn state(&self) -> SessionState {
|
||||
self.state
|
||||
}
|
||||
pub fn args(&self) -> &ToolArgs {
|
||||
&self.args
|
||||
}
|
||||
@@ -278,6 +283,7 @@ pub fn decide(
|
||||
request,
|
||||
args,
|
||||
matched,
|
||||
state,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -315,6 +321,7 @@ pub fn redecide(
|
||||
request: ask.request().clone(),
|
||||
args: ask.args().clone(),
|
||||
matched,
|
||||
state,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
//! ```
|
||||
|
||||
use crate::args::{ToolArgs, ToolName};
|
||||
use crate::policy::Decision;
|
||||
use crate::policy::{Decision, Label, SessionState};
|
||||
use proto::{CallId, SessionId, ToolResponse};
|
||||
|
||||
/// A directory mounted for one call: a path and whether the runtime may write to it.
|
||||
@@ -42,6 +42,7 @@ pub struct RunSpec {
|
||||
arguments: ToolArgs,
|
||||
mounts: Vec<Mount>,
|
||||
egress: Option<Vec<String>>,
|
||||
state: SessionState,
|
||||
}
|
||||
|
||||
impl RunSpec {
|
||||
@@ -63,6 +64,10 @@ impl RunSpec {
|
||||
pub fn egress(&self) -> Option<&[String]> {
|
||||
self.egress.as_deref()
|
||||
}
|
||||
/// The session's state when the call was decided.
|
||||
pub fn state(&self) -> SessionState {
|
||||
self.state
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Runtime`'s answer to one call. Its text reaches the model as a `failed` result labelled
|
||||
@@ -83,6 +88,13 @@ pub struct RunOutput {
|
||||
/// Runs one call. `Send + Sync` so one `Runtime` can serve several brokers at once.
|
||||
pub trait Runtime: Send + Sync {
|
||||
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError>;
|
||||
|
||||
/// `run`, and a label for what came back when the runtime knows one (`memoryd` does: a
|
||||
/// `recall` hit from an untrusted session). The runner keeps the stricter of this and the
|
||||
/// grant's label. Container runtimes know none.
|
||||
fn run_labelled(&self, spec: &RunSpec) -> Result<(RunOutput, Option<Label>), RunError> {
|
||||
self.run(spec).map(|output| (output, None))
|
||||
}
|
||||
}
|
||||
|
||||
/// The production runtime for M3a. It runs nothing; every call is `Unavailable`.
|
||||
@@ -126,14 +138,25 @@ pub fn run(decision: Decision, runtime: &dyn Runtime) -> proto::ToolResponse {
|
||||
arguments: args.clone(),
|
||||
mounts,
|
||||
egress,
|
||||
state: decision.state(),
|
||||
};
|
||||
match runtime.run(&spec) {
|
||||
Ok(output) => ToolResponse::Result {
|
||||
content: output.content,
|
||||
class: label.class,
|
||||
untrusted: label.untrusted,
|
||||
truncated: output.truncated,
|
||||
},
|
||||
match runtime.run_labelled(&spec) {
|
||||
Ok((output, found)) => {
|
||||
// Labels only get stricter: the higher class, and untrusted if either says so.
|
||||
let (class, untrusted) = match found {
|
||||
Some(found) => (
|
||||
label.class.max(found.class),
|
||||
label.untrusted || found.untrusted,
|
||||
),
|
||||
None => (label.class, label.untrusted),
|
||||
};
|
||||
ToolResponse::Result {
|
||||
content: output.content,
|
||||
class,
|
||||
untrusted,
|
||||
truncated: output.truncated,
|
||||
}
|
||||
}
|
||||
Err(RunError::Failed(message) | RunError::Unavailable(message)) => {
|
||||
ToolResponse::Failed { message }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Decided memory calls go to `memoryd` with the session's state, everything else to the other
|
||||
//! runtime; `memoryd`'s label can only make a result stricter; and a container runtime never runs
|
||||
//! a memory tool (M5a spec, section 8). Do not edit.
|
||||
|
||||
#[path = "support/build.rs"]
|
||||
mod build;
|
||||
#[path = "support/fake_podman.rs"]
|
||||
mod fake_podman;
|
||||
#[path = "support/runtime.rs"]
|
||||
mod runtime;
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use brokerd::container::{IN_MEMORYD, Podman};
|
||||
use brokerd::memory_port::{MemoryPort, Routed, UNREACHABLE};
|
||||
use brokerd::policy::{Outcome, SessionState, decide};
|
||||
use brokerd::runner::run;
|
||||
use build::{grant, now, read, request, set};
|
||||
use fake_podman::{Fake, Lines, serial};
|
||||
use proto::{
|
||||
DataClass, Envelope, MemoryCall, Message, Mode, ToolRequest, ToolResponse, read_frame,
|
||||
write_frame,
|
||||
};
|
||||
use runtime::{Recording, Shared};
|
||||
use tmp::TempDir;
|
||||
|
||||
/// A fake `memoryd`: answers one call with `answer` and hands back what it was sent.
|
||||
fn fake_memoryd(socket: &Path, answer: ToolResponse) -> mpsc::Receiver<MemoryCall> {
|
||||
let listener = UnixListener::bind(socket).unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let env = read_frame(&mut stream).unwrap();
|
||||
let Message::MemoryCall(call) = env.msg else {
|
||||
panic!("not a memory call: {:?}", env.msg)
|
||||
};
|
||||
tx.send(call).unwrap();
|
||||
let reply = Envelope {
|
||||
v: 1,
|
||||
id: env.id,
|
||||
r#final: true,
|
||||
msg: Message::ToolResponse(answer),
|
||||
};
|
||||
write_frame(&mut stream, &reply).unwrap();
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
fn result(class: DataClass, untrusted: bool) -> ToolResponse {
|
||||
ToolResponse::Result {
|
||||
content: "[1] memory · …".to_string(),
|
||||
class,
|
||||
untrusted,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn decide_in(req: ToolRequest, state: SessionState) -> brokerd::policy::Decision {
|
||||
let grants = set(vec![grant("notes", "read_file", Mode::Auto).paths(&["/n"])]);
|
||||
match decide(req, &grants, state, now()) {
|
||||
Outcome::Allowed(d) => d,
|
||||
other => panic!("not allowed: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn routed(socket: &Path, other: &std::sync::Arc<Recording>) -> Routed {
|
||||
Routed {
|
||||
memory: MemoryPort::new(socket.to_path_buf(), Duration::from_secs(5)),
|
||||
other: Box::new(Shared(std::sync::Arc::clone(other))),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_memory_call_goes_to_memoryd_with_the_session_state() {
|
||||
let dir = TempDir::new("mp-route");
|
||||
let socket = dir.path().join("memory.sock");
|
||||
let seen = fake_memoryd(&socket, result(DataClass::Private, false));
|
||||
let other = Recording::answering("container");
|
||||
let state = SessionState {
|
||||
taint: DataClass::Secret,
|
||||
untrusted: true,
|
||||
};
|
||||
let got = run(
|
||||
decide_in(request("memory", r#"{"text":"x","action":"note"}"#), state),
|
||||
&routed(&socket, &other),
|
||||
);
|
||||
assert!(matches!(got, ToolResponse::Result { .. }), "{got:?}");
|
||||
let call = seen.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!((call.taint, call.untrusted), (DataClass::Secret, true));
|
||||
assert_eq!(call.request.tool, "memory");
|
||||
assert_eq!(
|
||||
call.request.arguments, r#"{"action":"note","text":"x"}"#,
|
||||
"canonical"
|
||||
);
|
||||
assert_eq!(other.count(), 0, "nothing reached the other runtime");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_tools_go_to_the_other_runtime() {
|
||||
let dir = TempDir::new("mp-other");
|
||||
let other = Recording::answering("the file");
|
||||
let got = run(
|
||||
decide_in(read("/n/a"), SessionState::default()),
|
||||
&routed(&dir.path().join("none.sock"), &other),
|
||||
);
|
||||
assert!(matches!(got, ToolResponse::Result { ref content, .. } if content == "the file"));
|
||||
assert_eq!(other.count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memoryd_can_make_a_result_stricter_but_never_laxer() {
|
||||
let dir = TempDir::new("mp-label");
|
||||
let other = Recording::answering("unused");
|
||||
for (n, (answer, want)) in [
|
||||
((DataClass::Secret, true), (DataClass::Secret, true)),
|
||||
((DataClass::Public, false), (DataClass::Private, false)),
|
||||
((DataClass::Private, true), (DataClass::Private, true)),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let socket = dir.path().join(format!("m-{n}.sock"));
|
||||
let _seen = fake_memoryd(&socket, result(answer.0, answer.1));
|
||||
let got = run(
|
||||
decide_in(
|
||||
request("recall", r#"{"query":"x"}"#),
|
||||
SessionState::default(),
|
||||
),
|
||||
&routed(&socket, &other),
|
||||
);
|
||||
let ToolResponse::Result {
|
||||
class, untrusted, ..
|
||||
} = got
|
||||
else {
|
||||
panic!("{got:?}")
|
||||
};
|
||||
assert_eq!((class, untrusted), want, "memoryd said {answer:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_and_silence_are_failed_results() {
|
||||
let dir = TempDir::new("mp-fail");
|
||||
let socket = dir.path().join("memory.sock");
|
||||
let _seen = fake_memoryd(
|
||||
&socket,
|
||||
ToolResponse::Failed {
|
||||
message: "memory.md already has this entry".to_string(),
|
||||
},
|
||||
);
|
||||
let other = Recording::answering("unused");
|
||||
let add = r#"{"action":"add","file":"memory","text":"x"}"#;
|
||||
let got = run(
|
||||
decide_in(request("memory", add), SessionState::default()),
|
||||
&routed(&socket, &other),
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
ToolResponse::Failed {
|
||||
message: "memory.md already has this entry".to_string()
|
||||
}
|
||||
);
|
||||
let got = run(
|
||||
decide_in(request("memory", add), SessionState::default()),
|
||||
&routed(&dir.path().join("nobody.sock"), &other),
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
ToolResponse::Failed {
|
||||
message: UNREACHABLE.to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_container_runtime_never_runs_a_memory_tool() {
|
||||
let _s = serial();
|
||||
let fake = Fake::new("memory", "exit 0");
|
||||
let log = Lines::default();
|
||||
let podman = Podman::new(fake.runner(""), fake.dir.join("egress"), log.sink());
|
||||
let got = run(
|
||||
decide_in(
|
||||
request("recall", r#"{"query":"x"}"#),
|
||||
SessionState::default(),
|
||||
),
|
||||
&podman,
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
ToolResponse::Failed {
|
||||
message: IN_MEMORYD.to_string()
|
||||
}
|
||||
);
|
||||
assert!(fake.calls().is_empty(), "podman was never called");
|
||||
}
|
||||
@@ -42,6 +42,7 @@ impl Rig {
|
||||
approvals: Approvals { ttl_ms },
|
||||
runner: None,
|
||||
gateway: None,
|
||||
memory: Default::default(),
|
||||
};
|
||||
Rig {
|
||||
dir,
|
||||
|
||||
Reference in New Issue
Block a user