From 726ce1f7668ffaef2b2959aabee31824b6e61203 Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Sat, 19 Sep 2026 03:08:46 -0700 Subject: [PATCH] Keep each session's taint and untrusted flag in a file Implemented-By: OpenCode session (model recorded in docs/implementer-log.md) --- crates/brokerd/src/lib.rs | 1 + crates/brokerd/src/state.rs | 149 ++++++++++++++++++ crates/brokerd/tests/state.rs | 281 ++++++++++++++++++++++++++++++++++ docs/implementer-log.md | 1 + 4 files changed, 432 insertions(+) create mode 100644 crates/brokerd/src/state.rs create mode 100644 crates/brokerd/tests/state.rs diff --git a/crates/brokerd/src/lib.rs b/crates/brokerd/src/lib.rs index dd2a3ef..1f809ee 100644 --- a/crates/brokerd/src/lib.rs +++ b/crates/brokerd/src/lib.rs @@ -5,3 +5,4 @@ pub mod config; pub mod grants; pub mod policy; pub mod runner; +pub mod state; diff --git a/crates/brokerd/src/state.rs b/crates/brokerd/src/state.rs new file mode 100644 index 0000000..67e276b --- /dev/null +++ b/crates/brokerd/src/state.rs @@ -0,0 +1,149 @@ +//! Where the broker keeps what it knows about a session: one JSON line per session in +//! `/.json`. A read that fails is an error, never "a new session". Neither value ever +//! goes down: `raise` only ever raises, and it writes atomically so a crash leaves the old file. +//! +//! The file's format is private (`StateFile`), so `SessionState` stays free of serde. + +use crate::policy::{Label, SessionState}; +use proto::{DataClass, SessionId}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::{self, Write}; +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; +use std::path::{Path, PathBuf}; + +/// Where the owner looks when a session's state file cannot be read or written. +pub const RUNBOOK: &str = "see docs/runbook.md#broker-state-damaged"; + +/// A state file that exists but cannot be read, or a write that could not reach disk. +#[derive(Debug)] +pub enum StateError { + /// The file exists and cannot be read, or is not a state. + Unreadable(PathBuf, String), + /// The new state could not be put on disk. + Write(PathBuf, io::Error), +} + +impl std::fmt::Display for StateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StateError::Unreadable(path, detail) => write!( + f, + "cannot read the session state {}: {}; {}", + path.display(), + detail, + RUNBOOK + ), + StateError::Write(path, err) => write!( + f, + "cannot write the session state {}: {}; {}", + path.display(), + err, + RUNBOOK + ), + } + } +} + +impl std::error::Error for StateError {} + +/// The directory that holds one `.json` per session. +#[derive(Debug, Clone)] +pub struct StateStore { + dir: PathBuf, +} + +impl StateStore { + /// A store for `dir`. This does not touch the disk. + pub fn new(dir: &Path) -> StateStore { + StateStore { + dir: dir.to_path_buf(), + } + } + + /// The path of the file for this session: `/.json`. + pub fn path(&self, session: &SessionId) -> PathBuf { + self.dir.join(format!("{}.json", session.as_str())) + } + + /// The session's current state. A missing file is a fresh session; anything else that cannot + /// be read is an error. + pub fn read(&self, session: &SessionId) -> Result { + let path = self.path(session); + let text = match fs::read_to_string(&path) { + Ok(text) => text, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(SessionState::default()), + Err(err) => return Err(StateError::Unreadable(path, err.to_string())), + }; + let file = match serde_json::from_str::(&text) { + Ok(file) => file, + Err(err) => return Err(StateError::Unreadable(path, err.to_string())), + }; + if file.taint == DataClass::Public { + return Err(StateError::Unreadable( + path, + "a session's taint is never below private".to_string(), + )); + } + Ok(SessionState { + taint: file.taint, + untrusted: file.untrusted, + }) + } + + /// The raised state and whether its results are untrusted. It trusts `current`, never reads the + /// file again, and always writes, so the file exists from the first result on. + pub fn raise( + &self, + session: &SessionId, + current: SessionState, + label: Label, + ) -> Result { + let next = SessionState { + taint: current.taint.max(label.class).max(DataClass::Private), + untrusted: current.untrusted || label.untrusted, + }; + let path = self.path(session); + if let Err(err) = Self::persist(&self.dir, &path, next.taint, next.untrusted) { + return Err(StateError::Write(path, err)); + } + Ok(next) + } + + /// Write the new state to `` atomically, in six steps. Any failure maps to an `io` error + /// the caller turns into `Write`; an error leaves the old file untouched. + fn persist(dir: &Path, path: &Path, taint: DataClass, untrusted: bool) -> io::Result<()> { + // 1. The directory and any missing parents, mode 0700. + fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir)?; + // 2. Serialise and append the final newline. + let json = + serde_json::to_string(&StateFile { taint, untrusted }).map_err(io::Error::other)?; + let bytes = format!("{json}\n"); + // 3. A fresh temporary file; `truncate` replaces a leftover one. + let tmp = path.with_extension("json.tmp"); + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&tmp)?; + // 4. Write it and force it to disk. + file.write_all(bytes.as_bytes())?; + file.sync_all()?; + // 5. Swap it into place. + fs::rename(&tmp, path)?; + // 6. Pin the rename itself to disk. + fs::File::open(dir)?.sync_all() + } +} + +/// One line on disk: the taint and the untrusted flag. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct StateFile { + taint: DataClass, + untrusted: bool, +} diff --git a/crates/brokerd/tests/state.rs b/crates/brokerd/tests/state.rs new file mode 100644 index 0000000..dd16a3c --- /dev/null +++ b/crates/brokerd/tests/state.rs @@ -0,0 +1,281 @@ +//! Tests for the session state files. Do not edit. + +#[path = "support/tmp.rs"] +mod tmp; + +use brokerd::policy::{Label, SessionState}; +use brokerd::state::{RUNBOOK, StateError, StateStore}; +use proto::{DataClass, SessionId}; +use std::os::unix::fs::PermissionsExt; +use tmp::TempDir; + +fn id(text: &str) -> SessionId { + SessionId::new(text).unwrap() +} + +fn label(class: DataClass, untrusted: bool) -> Label { + Label { class, untrusted } +} + +fn state(taint: DataClass, untrusted: bool) -> SessionState { + SessionState { taint, untrusted } +} + +/// The store's directory is two levels below the temporary one and does not exist yet, as on a +/// fresh install. +fn store(home: &TempDir) -> StateStore { + StateStore::new(&home.path().join("broker/sessions")) +} + +#[test] +fn a_session_with_no_file_is_private_and_trusted() { + let home = TempDir::new("state"); + let store = store(&home); + let fresh = store.read(&id("chat-1")).unwrap(); + assert_eq!(fresh, state(DataClass::Private, false)); + assert_eq!(fresh, SessionState::default()); + // Reading creates nothing. + assert!(!home.path().join("broker").exists()); +} + +#[test] +fn the_first_result_creates_the_file_and_its_directory() { + let home = TempDir::new("state"); + let store = store(&home); + let session = id("chat-1"); + let next = store + .raise( + &session, + SessionState::default(), + label(DataClass::Private, false), + ) + .unwrap(); + assert_eq!(next, state(DataClass::Private, false)); + + let path = home.path().join("broker/sessions/chat-1.json"); + assert_eq!(store.path(&session), path); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "{\"taint\":\"private\",\"untrusted\":false}\n" + ); + let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(&path), 0o600); + assert_eq!(mode(&home.path().join("broker/sessions")), 0o700); + assert_eq!(mode(&home.path().join("broker")), 0o700); + // No temporary file is left behind. + assert!(!home.path().join("broker/sessions/chat-1.json.tmp").exists()); + assert_eq!(store.read(&session).unwrap(), next); +} + +#[test] +fn taint_and_the_untrusted_flag_only_go_up() { + let home = TempDir::new("state"); + let store = store(&home); + let session = id("s"); + let steps = [ + ( + label(DataClass::Public, false), + state(DataClass::Private, false), + ), + ( + label(DataClass::Private, true), + state(DataClass::Private, true), + ), + ( + label(DataClass::Secret, false), + state(DataClass::Secret, true), + ), + ( + label(DataClass::Public, false), + state(DataClass::Secret, true), + ), + ( + label(DataClass::Private, false), + state(DataClass::Secret, true), + ), + ]; + let mut current = store.read(&session).unwrap(); + for (result, want) in steps { + current = store.raise(&session, current, result).unwrap(); + assert_eq!(current, want); + assert_eq!(store.read(&session).unwrap(), want, "what is on disk"); + } + assert_eq!( + std::fs::read_to_string(store.path(&session)).unwrap(), + "{\"taint\":\"secret\",\"untrusted\":true}\n" + ); +} + +#[test] +fn sessions_do_not_share_state() { + let home = TempDir::new("state"); + let store = store(&home); + store + .raise( + &id("a"), + SessionState::default(), + label(DataClass::Secret, true), + ) + .unwrap(); + assert_eq!(store.read(&id("b")).unwrap(), SessionState::default()); + assert_eq!( + store.read(&id("a")).unwrap(), + state(DataClass::Secret, true) + ); +} + +/// A file that exists but does not hold a valid state is an error, never "no file". +#[test] +fn a_damaged_file_is_an_error_that_names_the_file_and_the_runbook() { + let home = TempDir::new("state"); + let store = store(&home); + std::fs::create_dir_all(home.path().join("broker/sessions")).unwrap(); + let session = id("hurt"); + for text in [ + "", + "{", + "null", + "[]", + "{\"taint\":\"secret\"}", + "{\"untrusted\":false}", + "{\"taint\":\"internal\",\"untrusted\":false}", + "{\"taint\":\"secret\",\"untrusted\":\"no\"}", + "{\"taint\":\"secret\",\"untrusted\":false,\"note\":1}", + "{\"taint\":\"secret\",\"untrusted\":false} trailing", + // A session is never below private, so this file was not written by brokerd. + "{\"taint\":\"public\",\"untrusted\":false}", + ] { + std::fs::write(store.path(&session), text).unwrap(); + let err = store.read(&session).expect_err(text); + assert!( + matches!(err, StateError::Unreadable(..)), + "{text:?}: {err:?}" + ); + let shown = err.to_string(); + assert!(shown.contains("hurt.json"), "{shown}"); + assert!(shown.ends_with(RUNBOOK), "{shown}"); + } + assert_eq!(RUNBOOK, "see docs/runbook.md#broker-state-damaged"); + // A good file with or without its final newline reads fine. + for text in [ + "{\"taint\":\"secret\",\"untrusted\":true}\n", + "{\"taint\":\"secret\",\"untrusted\":true}", + ] { + std::fs::write(store.path(&session), text).unwrap(); + assert_eq!( + store.read(&session).unwrap(), + state(DataClass::Secret, true) + ); + } + // Not valid UTF-8, and a directory where the file should be. + std::fs::write(store.path(&session), b"\xff\xfe").unwrap(); + assert!(matches!( + store.read(&session), + Err(StateError::Unreadable(..)) + )); + std::fs::remove_file(store.path(&session)).unwrap(); + std::fs::create_dir(store.path(&session)).unwrap(); + assert!(matches!( + store.read(&session), + Err(StateError::Unreadable(..)) + )); +} + +#[test] +fn a_file_without_read_permission_is_an_error_not_a_fresh_session() { + if tmp::running_as_root("a_file_without_read_permission_is_an_error_not_a_fresh_session") { + return; + } + let home = TempDir::new("state"); + let store = store(&home); + let session = id("locked"); + store + .raise( + &session, + SessionState::default(), + label(DataClass::Secret, false), + ) + .unwrap(); + let path = store.path(&session); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap(); + assert!(matches!( + store.read(&session), + Err(StateError::Unreadable(..)) + )); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); +} + +#[test] +fn a_failed_write_is_an_error_and_leaves_the_old_state() { + if tmp::running_as_root("a_failed_write_is_an_error_and_leaves_the_old_state") { + return; + } + let home = TempDir::new("state"); + let store = store(&home); + let session = id("s"); + let before = store + .raise( + &session, + SessionState::default(), + label(DataClass::Private, true), + ) + .unwrap(); + + let dir = home.path().join("broker/sessions"); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap(); + let err = store + .raise(&session, before, label(DataClass::Secret, false)) + .expect_err("the directory is read-only"); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + + assert!(matches!(err, StateError::Write(..)), "{err:?}"); + let shown = err.to_string(); + assert!(shown.contains("s.json"), "{shown}"); + assert!(shown.ends_with(RUNBOOK), "{shown}"); + assert_eq!(store.read(&session).unwrap(), before); +} + +/// A `.tmp` file beside the state is a write that did not finish. It is not the state, it does +/// not stop the next write, and the next write replaces it. +#[test] +fn a_leftover_tmp_file_is_neither_read_nor_in_the_way() { + let home = TempDir::new("state"); + let store = store(&home); + let session = id("s"); + let dir = home.path().join("broker/sessions"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("s.json.tmp"), "{\"taint\":\"secret\",\"untr").unwrap(); + + assert_eq!(store.read(&session).unwrap(), SessionState::default()); + let next = store + .raise( + &session, + SessionState::default(), + label(DataClass::Secret, false), + ) + .unwrap(); + assert_eq!(store.read(&session).unwrap(), next); + assert!(!dir.join("s.json.tmp").exists()); +} + +#[test] +fn raise_trusts_the_state_it_is_given_not_the_file() { + // The caller read the state under the ledger lock a moment ago; `raise` does not read again. + let home = TempDir::new("state"); + let store = store(&home); + let session = id("s"); + let given = state(DataClass::Secret, true); + let next = store + .raise(&session, given, label(DataClass::Public, false)) + .unwrap(); + assert_eq!(next, given); + // Even a state below private is lifted to private on the way to disk. + let low = store + .raise( + &id("low"), + state(DataClass::Public, false), + label(DataClass::Public, false), + ) + .unwrap(); + assert_eq!(low, state(DataClass::Private, false)); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 6009d16..a016039 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M3a/08-brokerd-state | 2026-09-18 | done | 1 | pass | none | Wrote crates/brokerd/src/state.rs: RUNBOOK, StateError (Unreadable/Write with hand-written Display ending in RUNBOOK and std::error::Error), StateStore (new does not touch disk, path joins /.json, read, raise) and the private StateFile with deny_unknown_fields. read has exactly one default path (ErrorKind::NotFound); Public taint is Unreadable; raise computes max(taint,label,Private) and ORs untrusted, always writes atomically in six steps mapping any error to Write(path, err). `cargo fmt` put `state` after `runner` in lib.rs. 9 tests pass; `make gate` prints `gate: ok`. | Laguna S 2.1 | | M1/01-workspace-and-gate | 2026-09-17 | done | 1 | pass | none | Crate skeletons, Cargo files and the given Makefile/deny.toml/test-gate-scripts.sh were already present untracked from a prior attempt; I verified them against the plan and created only the missing gate scripts, dependencies.md, egress.md and this log row. | Laguna S 2.1 | | M1/02-proto-values | 2026-09-17 | done | 1 | pass | none | Implemented ValueError, SessionId, Epoch, CallId, Hash32 and Timestamp in crates/proto/src/ids.rs and DataClass in class.rs, using serde try_from/into for string-backed JSON validation, a hand-written hex encoder and humantime for RFC 3339 parsing with canonical re-serialization. | Laguna S 2.1 | | M1/03-proto-wire | 2026-09-17 | done | 2 | pass | none | Added Envelope, Message, WireError, ErrorCode, ToolRequest, ToolResponse and DenyReason in crates/proto/src/wire.rs, re-exported from lib.rs; all 9 fixture tests pass and `make gate` prints `gate: ok`. | Laguna S 2.1 |