proto becomes bxcore, the one shared crate (M5b task 00)

Done the way the owner asked and the spike mapped: the crate and the
manifests renamed, then what the build reported fixed. Layers: 138
"proto unresolved" errors across 169 files; cargo fmt reordering
imports; six fixture paths built as strings in brokerd's and bxctl's
tests, which the compiler cannot see, and comments naming
crates/proto/; the crate-dependency check and its self-test. One more
the spike did not reach: a `\nproto =` key inside a printf format in
the self-test, which a word-boundary replacement skips because `n` and
`p` are both word characters.

make gate: gate: ok, 944 tests; no new entry in /tmp.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 01:48:38 -07:00
co-authored by Claude Opus 5.5
parent 727e027fcf
commit 2b3ae080b4
311 changed files with 426 additions and 410 deletions
+112
View File
@@ -0,0 +1,112 @@
//! Audit log record types. One JSON object per line, hash-chained by the exact bytes of each line.
use serde::{Deserialize, Serialize};
use crate::{CallId, DataClass, DenyReason, Hash32, SessionId, Timestamp};
pub use crate::chain::{ChainFailure, ChainReport, ChainVerifier, Location, TornTail};
// JSON: {"outcome":"denied","reason":"no_grant"} ; the tag sits beside the fields
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
pub enum DecisionRecord {
Allowed {},
Ask {},
Denied { reason: DenyReason },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalAnswer {
Approved,
Refused,
Expired,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResultStatus {
Result,
Failed,
}
// JSON: {"type":"decision","session":"…",…} ; the tag sits beside the fields
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum AuditEvent {
Decision {
session: SessionId,
call: CallId,
tool: String,
arguments: String,
outcome: DecisionRecord,
grant: Option<String>,
grant_sha256: Option<Hash32>,
taint: DataClass,
untrusted: bool,
},
Approval {
session: SessionId,
call: CallId,
decision: u64,
answer: ApprovalAnswer,
by: Option<String>,
post: Option<String>,
reason: Option<String>,
outcome: DecisionRecord,
grant: Option<String>,
grant_sha256: Option<Hash32>,
taint: DataClass,
untrusted: bool,
},
Result {
session: SessionId,
call: CallId,
decision: u64,
status: ResultStatus,
class: DataClass,
untrusted: bool,
truncated: bool,
bytes: u64,
sha256: Hash32,
taint_after: DataClass,
},
Recovery {
torn_bytes: u64,
torn_sha256: Hash32,
},
AcceptedBreak {
file: String,
line: u64,
last_good: Hash32,
},
}
// JSON: {"seq":0,"time":"…","prev":"…","event":{"type":"decision",…}} ; `event` is a nested object.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuditRecord {
pub seq: u64,
pub time: Timestamp,
pub prev: Hash32,
pub event: AuditEvent,
}
/// True if `name` is an audit log file: `YYYY-MM-DD.jsonl`, ASCII digits with `-` at positions 4
/// and 7, a month from 01 to 12 and a day from 01 to 31. `brokerd` and `bxctl audit verify` both
/// decide with this, so they read the same files.
pub fn is_audit_log_name(name: &str) -> bool {
let Some(date) = name.strip_suffix(".jsonl") else {
return false;
};
let shaped = date.len() == 10
&& date.bytes().enumerate().all(|(i, b)| match i {
4 | 7 => b == b'-',
_ => b.is_ascii_digit(),
});
if !shaped {
return false;
}
let number = |range: std::ops::Range<usize>| date.get(range).and_then(|s| s.parse::<u8>().ok());
matches!(number(5..7), Some(1..=12)) && matches!(number(8..10), Some(1..=31))
}
+411
View File
@@ -0,0 +1,411 @@
//! Verifies the audit log's hash chain. A pure state machine: the caller reads the files and feeds
//! it lines. Shared by `brokerd`'s startup check and `bxctl audit verify`.
//!
//! Each line is held back until the next one has been seen, because a `Recovery` record says
//! that the line before it is not a record at all.
use std::collections::BTreeSet;
use crate::{AuditEvent, AuditRecord, DecisionRecord, Hash32, Timestamp, sha256};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Location {
pub file: String,
pub line: u64,
}
/// The first failure since the last accepted break, with what an `AcceptedBreak` record appended
/// now would have to carry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChainFailure {
pub file: String,
pub line: u64,
pub what: String,
pub last_good: Hash32,
pub break_prev: Hash32,
pub break_seq: u64,
/// The last line fed has no newline; it must be ended before a break record is appended.
pub tail_torn: bool,
}
/// The last line of the last file when it needs recovery, with what its `Recovery` record
/// would have to carry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TornTail {
pub at: Location,
pub has_newline: bool,
pub bytes: u64,
pub sha256: Hash32,
pub recovery_prev: Hash32,
pub recovery_seq: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChainReport {
pub records: u64,
pub head: Option<Hash32>,
pub next_seq: u64,
pub failure: Option<ChainFailure>,
pub recoveries: Vec<Location>,
pub accepted_breaks: Vec<Location>,
/// `seq` of `ask` decisions with no `Approval` after them.
pub abandoned: Vec<u64>,
/// `seq` of decisions that were allowed, at once or by approval, with no `Result` after them.
pub unfinished: Vec<u64>,
pub clock_warnings: Vec<Location>,
pub torn_tail: Option<TornTail>,
}
#[derive(Debug)]
struct Held {
at: Location,
hash: Hash32,
bytes: u64,
has_newline: bool,
record: Option<AuditRecord>,
}
#[derive(Debug)]
struct Broken {
file: String,
line: u64,
what: String,
last_good: Hash32,
expected_seq: u64,
/// Lines from the failing line up to the last one judged, the failing line included.
lines: u64,
}
#[derive(Debug)]
pub struct ChainVerifier {
resumed: bool,
first_file: Option<String>,
file: String,
line_no: u64,
next_seq: u64,
prev: Hash32,
head: Option<Hash32>,
/// Hash of the line before the held one, whether or not it verified.
before_held: Hash32,
held: Option<Held>,
/// The held line is a `Recovery` record that described the line before it.
held_recovers: bool,
broken: Option<Broken>,
last_time: Option<Timestamp>,
records: u64,
open_asks: BTreeSet<u64>,
open_runs: BTreeSet<u64>,
recoveries: Vec<Location>,
accepted_breaks: Vec<Location>,
clock_warnings: Vec<Location>,
}
impl Default for ChainVerifier {
fn default() -> Self {
Self::new()
}
}
impl ChainVerifier {
/// Expects `seq` 0 and an all-zero `prev`.
pub fn new() -> Self {
Self::start(false, 0, Hash32::ZERO)
}
/// Continues from a known point: the `seq` after, and the hash of, the last line of the file
/// before the first one fed.
pub fn resume(next_seq: u64, prev: Hash32) -> Self {
Self::start(true, next_seq, prev)
}
fn start(resumed: bool, next_seq: u64, prev: Hash32) -> Self {
ChainVerifier {
resumed,
first_file: None,
file: String::new(),
line_no: 0,
next_seq,
prev,
head: None,
before_held: prev,
held: None,
held_recovers: false,
broken: None,
last_time: None,
records: 0,
open_asks: BTreeSet::new(),
open_runs: BTreeSet::new(),
recoveries: Vec::new(),
accepted_breaks: Vec::new(),
clock_warnings: Vec::new(),
}
}
/// Starts the next file. Files are fed in name order.
pub fn file(&mut self, name: &str) {
if self.first_file.is_none() {
self.first_file = Some(name.to_string());
}
self.file = name.to_string();
self.line_no = 0;
}
/// Feeds a whole file: `file(name)`, then every line of `content`.
pub fn feed(&mut self, name: &str, content: &[u8]) {
self.file(name);
let mut rest = content;
while !rest.is_empty() {
match rest.iter().position(|b| *b == b'\n') {
Some(end) => {
let (line, tail) = rest.split_at(end);
self.line(line, true);
rest = tail.get(1..).unwrap_or_default();
}
None => {
self.line(rest, false);
rest = &[];
}
}
}
}
/// One line without its newline. `has_newline` is false only for a file's last line.
pub fn line(&mut self, bytes: &[u8], has_newline: bool) {
self.line_no = self.line_no.saturating_add(1);
let at = Location {
file: self.file.clone(),
line: self.line_no,
};
let Ok(hash) = sha256(bytes) else {
// The hasher fails only on an input too long to count; such a line is no record.
self.judge_held(None);
self.fail(&at, "the line is too long to hash".to_string());
return;
};
let current = Held {
at,
hash,
bytes: u64::try_from(bytes.len()).unwrap_or(u64::MAX),
has_newline,
record: serde_json::from_slice(bytes).ok(),
};
self.judge_held(Some(&current));
self.held = Some(current);
}
/// Judges the held line now that the line after it (if any) is known.
fn judge_held(&mut self, next: Option<&Held>) {
let Some(held) = self.held.take() else {
return;
};
let recovers = std::mem::replace(&mut self.held_recovers, false);
if self.broken.is_some() {
self.judge_in_region(&held);
} else if self.is_recovered_by(&held, next) {
self.recoveries.push(held.at.clone());
self.held_recovers = true;
} else {
self.judge_record(&held, recovers);
}
self.before_held = held.hash;
}
/// True if `next` is a `Recovery` record that describes `held` and chains from the line
/// before it. Whether `held` parses makes no difference.
fn is_recovered_by(&self, held: &Held, next: Option<&Held>) -> bool {
let Some(AuditRecord {
seq,
prev,
event:
AuditEvent::Recovery {
torn_bytes,
torn_sha256,
},
..
}) = next.and_then(|n| n.record.as_ref())
else {
return false;
};
*torn_bytes == held.bytes
&& *torn_sha256 == held.hash
&& *prev == self.prev
&& *seq == self.next_seq
}
fn judge_record(&mut self, held: &Held, recovers: bool) {
let Some(record) = &held.record else {
self.fail(&held.at, "does not parse as an audit record".to_string());
return;
};
if let AuditEvent::AcceptedBreak { file, .. } = &record.event {
// Only a resumed verifier may meet a break with no failure before it: the failure
// is in a file it was not given. The full verification judges the rest.
if self.names_earlier_file(file) && record.prev == self.prev {
self.accept_break(held, record);
} else {
self.fail(
&held.at,
"an accepted break with no failure before it".to_string(),
);
}
return;
}
if record.seq != self.next_seq {
let what = format!("seq is {}, expected {}", record.seq, self.next_seq);
self.fail(&held.at, what);
return;
}
if record.prev != self.prev {
let what = if held.at.line == 1 && (self.resumed || self.head.is_some()) {
"does not chain from the last line of the file before"
} else {
"prev is not the hash of the line before"
};
self.fail(&held.at, what.to_string());
return;
}
if matches!(record.event, AuditEvent::Recovery { .. }) && !recovers {
self.fail(
&held.at,
"a recovery record that does not describe the line before it".to_string(),
);
return;
}
self.advance(held, record);
}
/// A line after a failure. It is read only to see whether it is the break record.
fn judge_in_region(&mut self, held: &Held) {
let Some(broken) = &mut self.broken else {
return;
};
if let Some(record) = &held.record
&& let AuditEvent::AcceptedBreak {
file,
line,
last_good,
} = &record.event
&& record.prev == self.before_held
{
let names_this = *file == broken.file
&& *line == broken.line
&& *last_good == broken.last_good
&& broken.expected_seq.checked_add(broken.lines) == Some(record.seq);
if names_this
|| (self.resumed
&& self
.first_file
.as_deref()
.is_some_and(|f| file.as_str() < f))
{
self.broken = None;
self.accept_break(held, record);
return;
}
}
broken.lines = broken.lines.saturating_add(1);
}
fn names_earlier_file(&self, file: &str) -> bool {
self.resumed && self.first_file.as_deref().is_some_and(|first| file < first)
}
fn accept_break(&mut self, held: &Held, record: &AuditRecord) {
self.accepted_breaks.push(held.at.clone());
self.advance(held, record);
}
/// `held` is a verified record: it becomes the head.
fn advance(&mut self, held: &Held, record: &AuditRecord) {
self.records = self.records.saturating_add(1);
self.head = Some(held.hash);
self.prev = held.hash;
self.next_seq = record.seq.saturating_add(1);
if self.last_time.is_some_and(|last| record.time < last) {
self.clock_warnings.push(held.at.clone());
}
self.last_time = Some(record.time);
match &record.event {
AuditEvent::Decision { outcome, .. } => match outcome {
DecisionRecord::Allowed {} => {
self.open_runs.insert(record.seq);
}
DecisionRecord::Ask {} => {
self.open_asks.insert(record.seq);
}
DecisionRecord::Denied { .. } => {}
},
AuditEvent::Approval {
decision, outcome, ..
} => {
self.open_asks.remove(decision);
if matches!(outcome, DecisionRecord::Allowed {}) {
self.open_runs.insert(*decision);
}
}
AuditEvent::Result { decision, .. } => {
self.open_runs.remove(decision);
}
AuditEvent::Recovery { .. } | AuditEvent::AcceptedBreak { .. } => {}
}
}
fn fail(&mut self, at: &Location, what: String) {
if self.broken.is_some() {
return;
}
self.broken = Some(Broken {
file: at.file.clone(),
line: at.line,
what,
last_good: self.prev,
expected_seq: self.next_seq,
lines: 1,
});
}
pub fn finish(mut self) -> ChainReport {
let mut torn_tail = None;
let mut tail_torn = false;
let mut last_line = self.before_held;
if let Some(held) = self.held.take() {
last_line = held.hash;
tail_torn = !held.has_newline;
let needs_recovery = !held.has_newline || held.record.is_none();
if self.broken.is_none() && needs_recovery {
torn_tail = Some(TornTail {
at: held.at.clone(),
has_newline: held.has_newline,
bytes: held.bytes,
sha256: held.hash,
recovery_prev: self.prev,
recovery_seq: self.next_seq,
});
} else {
self.held = Some(held);
self.judge_held(None);
}
}
let failure = self.broken.map(|b| ChainFailure {
file: b.file,
line: b.line,
what: b.what,
last_good: b.last_good,
break_prev: last_line,
break_seq: b.expected_seq.saturating_add(b.lines),
tail_torn,
});
ChainReport {
records: self.records,
head: self.head,
next_seq: self.next_seq,
failure,
recoveries: self.recoveries,
accepted_breaks: self.accepted_breaks,
abandoned: self.open_asks.into_iter().collect(),
unfinished: self.open_runs.into_iter().collect(),
clock_warnings: self.clock_warnings,
torn_tail,
}
}
}
+9
View File
@@ -0,0 +1,9 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DataClass {
Public,
Private,
Secret,
}
+45
View File
@@ -0,0 +1,45 @@
//! Text from outside (model output, tool arguments, server errors) made safe to show: control
//! characters, zero-width and bidirectional marks, line separators and the BOM become `\uXXXX`.
//! Used by `bxctl` for the terminal and by `gatewayd` for Mattermost, so both show the same.
// The control bytes, the zero-width and Unicode line/paragraph separators, and the BOM. A code
// point in any of these ranges must never reach the terminal raw.
const ESCAPE_RANGES: &[(u32, u32)] = &[
(0x0000, 0x001f),
(0x007f, 0x009f),
(0x200b, 0x200f),
(0x2028, 0x202e),
(0x2060, 0x2069),
(0xfeff, 0xfeff),
];
pub fn escape_json_text(s: &str) -> String {
escape(s, false)
}
pub fn escape_model_text(s: &str) -> String {
escape(s, true)
}
fn escape(s: &str, model_text: bool) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
let cp = u32::from(c);
if should_escape(c, model_text) {
out.push_str(&format!("\\u{:04x}", cp));
} else {
out.push(c);
}
}
out
}
// A control point is escaped, except that model text keeps \n and \t.
fn should_escape(c: char, model_text: bool) -> bool {
let cp = u32::from(c);
if ESCAPE_RANGES.iter().any(|(lo, hi)| cp >= *lo && cp <= *hi) {
!(model_text && (cp == 0x09 || cp == 0x0a))
} else {
false
}
}
+98
View File
@@ -0,0 +1,98 @@
//! Length-prefixed frame codec: one envelope per big-endian 4-byte length.
use std::io::Read;
use crate::{Envelope, PROTOCOL_VERSION};
pub const MAX_FRAME: usize = 1_048_576;
#[derive(Debug)]
pub enum FrameError {
Closed,
Empty,
TooLarge(usize),
BadVersion(u32),
Json(serde_json::Error),
Io(std::io::Error),
}
impl std::fmt::Display for FrameError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FrameError::Closed => write!(f, "stream closed before a frame"),
FrameError::Empty => write!(f, "frame length was zero"),
FrameError::TooLarge(n) => write!(f, "frame of {n} bytes exceeds the maximum"),
FrameError::BadVersion(v) => write!(f, "unsupported protocol version {v}"),
FrameError::Json(e) => write!(f, "invalid JSON envelope: {e}"),
FrameError::Io(e) => write!(f, "frame I/O error: {e}"),
}
}
}
impl std::error::Error for FrameError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
FrameError::Json(e) => Some(e),
FrameError::Io(e) => Some(e),
_ => None,
}
}
}
pub fn write_frame<W: std::io::Write>(w: &mut W, env: &Envelope) -> Result<(), FrameError> {
let body = serde_json::to_vec(env).map_err(FrameError::Json)?;
let len = body.len();
if len > MAX_FRAME {
return Err(FrameError::TooLarge(len));
}
w.write_all(&(len as u32).to_be_bytes())
.map_err(FrameError::Io)?;
w.write_all(&body).map_err(FrameError::Io)?;
w.flush().map_err(FrameError::Io)
}
pub fn read_frame<R: std::io::Read>(r: &mut R) -> Result<Envelope, FrameError> {
let mut buf = [0u8; 4];
let mut filled = 0;
while filled < 4 {
let n = read_bytes(r, &mut buf[filled..]).map_err(FrameError::Io)?;
if n == 0 {
return Err(if filled == 0 {
FrameError::Closed
} else {
FrameError::Io(std::io::Error::from(std::io::ErrorKind::UnexpectedEof))
});
}
filled += n;
}
let len = u32::from_be_bytes(buf);
if len == 0 {
return Err(FrameError::Empty);
}
if len > MAX_FRAME as u32 {
return Err(FrameError::TooLarge(len as usize));
}
let len = len as usize;
let mut body = vec![0u8; len];
r.read_exact(&mut body).map_err(FrameError::Io)?;
let env = serde_json::from_slice::<Envelope>(&body).map_err(FrameError::Json)?;
if env.v != PROTOCOL_VERSION {
return Err(FrameError::BadVersion(env.v));
}
Ok(env)
}
/// Reads into `buf`, retrying on `Interrupted`. Returns `Ok(0)` on a clean end of stream.
fn read_bytes<R: Read>(r: &mut R, buf: &mut [u8]) -> std::io::Result<usize> {
loop {
match r.read(buf) {
Ok(0) => return Ok(0),
Ok(n) => return Ok(n),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
}
+90
View File
@@ -0,0 +1,90 @@
//! The messages on `gateway.sock`, between `brokerd` and `gatewayd`: one connection carries one
//! pending approval to Mattermost and its answer back (M4b spec, section 4).
use serde::{Deserialize, Serialize};
use crate::wire::DenyReason;
use crate::wire::PendingApproval;
/// The most characters a refusal's reason may have.
pub const MAX_REASON: usize = 500;
/// `brokerd` → `gatewayd`, first: the approval to post.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovalRequest {
pub approval: PendingApproval,
}
/// `gatewayd` → `brokerd`: it is posted, in these posts, first part first.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovalPosted {
pub posts: Vec<String>,
}
/// `gatewayd` → `brokerd`: an approver answered, with this post.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovalReply {
pub approval: u64,
pub approved: bool,
pub by: String,
pub post: String,
pub reason: Option<String>,
}
/// `brokerd` → `gatewayd`, last: what came of the answer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovalOutcome {
pub outcome: GatewayOutcome,
}
// JSON: {"outcome":"denied","reason":"no_grant"} ; the tag sits beside the fields
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
pub enum GatewayOutcome {
/// Approved, and the grants as they are now still allow it.
Runs {},
/// Approved, but denied on the re-check, or the answer could not be recorded.
Denied { reason: DenyReason },
/// The refusal is recorded.
Refused {},
/// It was no longer pending when the answer came.
Gone {},
}
/// A Mattermost id: 26 characters of `a-z0-9`.
pub fn is_mattermost_id(id: &str) -> bool {
// `id.len() == 26` and every byte `b.is_ascii_lowercase() || b.is_ascii_digit()`.
id.len() == 26
&& id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
}
impl ApprovalReply {
/// `by` and `post` are Mattermost ids and the reason is at most `MAX_REASON` characters; the
/// first problem otherwise.
pub fn check(&self) -> Result<(), String> {
// In this order, each an Err with exactly this text:
// 1. `!is_mattermost_id(&self.by)`: "the answer's `by` is not a Mattermost id".
// 2. `!is_mattermost_id(&self.post)`: "the answer's `post` is not a Mattermost id".
// 3. a reason with more than `MAX_REASON` characters (`r.chars().count()`, not bytes):
// `format!("the reason is over {MAX_REASON} characters")`.
// 4. Ok(()).
if !is_mattermost_id(&self.by) {
return Err("the answer's `by` is not a Mattermost id".to_string());
}
if !is_mattermost_id(&self.post) {
return Err("the answer's `post` is not a Mattermost id".to_string());
}
if let Some(reason) = &self.reason
&& reason.chars().count() > MAX_REASON
{
return Err(format!("the reason is over {MAX_REASON} characters"));
}
Ok(())
}
}
+50
View File
@@ -0,0 +1,50 @@
//! Grant file schema. A grant is TOML written by the owner to allow one kind of tool call.
use serde::{Deserialize, Serialize};
use crate::{DataClass, Timestamp};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
Auto,
Ask,
Deny,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase", deny_unknown_fields)]
pub struct Constraints {
#[serde(default)]
pub paths: Vec<String>,
#[serde(default)]
pub hosts: Vec<String>,
#[serde(default)]
pub patterns: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Grant {
pub tool: String,
pub mode: Mode,
pub max_taint: DataClass,
#[serde(default = "default_result_class")]
pub result_class: DataClass,
#[serde(default = "default_untrusted")]
pub untrusted: bool,
#[serde(default)]
pub expires: Option<Timestamp>,
#[serde(default)]
pub secret: Option<String>,
#[serde(default)]
pub constraints: Constraints,
}
fn default_result_class() -> DataClass {
DataClass::Private
}
fn default_untrusted() -> bool {
true
}
+43
View File
@@ -0,0 +1,43 @@
use crate::Hash32;
use emsha::Hash as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HashError;
impl std::fmt::Display for HashError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "sha256 error")
}
}
impl std::error::Error for HashError {}
pub struct Sha256(emsha::sha256::SHA256);
impl Sha256 {
pub fn new() -> Self {
Sha256(emsha::sha256::SHA256::new())
}
pub fn update(&mut self, data: &[u8]) -> Result<(), HashError> {
self.0.update(data).map_err(|_| HashError)
}
pub fn finish(mut self) -> Result<Hash32, HashError> {
let mut out = [0u8; emsha::sha256::SIZE];
self.0.finalize(&mut out).map_err(|_| HashError)?;
Ok(Hash32::from_bytes(out))
}
}
impl Default for Sha256 {
fn default() -> Self {
Self::new()
}
}
pub fn sha256(data: &[u8]) -> Result<Hash32, HashError> {
let mut h = Sha256::new();
h.update(data)?;
h.finish()
}
+61
View File
@@ -0,0 +1,61 @@
//! Host names and patterns, shared by `brokerd` and the egress proxy so both check hosts with the
//! same rules.
/// A host label is 1 to 63 bytes of `a-z`, `0-9` or `-`, and neither starts nor ends with `-`.
fn valid_label(label: &str) -> bool {
if !(1..=63).contains(&label.len()) {
return false;
}
let bytes = label.as_bytes();
if bytes.iter().next() == Some(&b'-') || bytes.iter().last() == Some(&b'-') {
return false;
}
bytes
.iter()
.all(|&byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-'))
}
/// A host is 1 to 253 bytes of dot-separated labels, each a whole non-dot name, and the last label
/// starts with a letter. That last rule keeps out every spelling of an IPv4 address.
pub fn valid_host(host: &str) -> bool {
if !(1..=253).contains(&host.len()) {
return false;
}
let labels: Vec<&str> = host.split('.').collect();
if labels.len() < 2 {
return false;
}
for label in &labels {
if !valid_label(label) {
return false;
}
}
match labels.iter().last() {
Some(last) => matches!(last.bytes().next(), Some(byte) if byte.is_ascii_lowercase()),
None => false,
}
}
/// A host, or `*.` followed by a host. Nothing else.
pub fn valid_host_pattern(pattern: &str) -> bool {
match pattern.strip_prefix("*.") {
Some(base) => valid_host(base),
None => valid_host(pattern),
}
}
/// Does `pattern` match `host`? Without `*.` the strings must be equal; with `*.base` the host must
/// end in `.base` with something before the dot.
pub fn host_matches(pattern: &str, host: &str) -> bool {
let base = match pattern.strip_prefix("*.") {
Some(base) => base,
None => return pattern == host,
};
match host.strip_suffix(base) {
Some(before) => match before.strip_suffix('.') {
Some(prefix) => !prefix.is_empty(),
None => false,
},
None => false,
}
}
+193
View File
@@ -0,0 +1,193 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValueError {
SessionId,
Hash32,
Timestamp,
}
impl std::fmt::Display for ValueError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ValueError::SessionId => write!(f, "invalid session id"),
ValueError::Hash32 => write!(f, "invalid hash32"),
ValueError::Timestamp => write!(f, "invalid timestamp"),
}
}
}
impl std::error::Error for ValueError {}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct SessionId(String);
impl SessionId {
pub fn new(s: &str) -> Result<Self, ValueError> {
if s.is_empty() || s.len() > 64 {
return Err(ValueError::SessionId);
}
if !s
.bytes()
.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'-'))
{
return Err(ValueError::SessionId);
}
Ok(SessionId(s.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Default for SessionId {
fn default() -> Self {
// Not the empty string, which `new` rejects: a fallback must still be a valid id.
SessionId("chat-0-0".to_string())
}
}
impl TryFrom<String> for SessionId {
type Error = ValueError;
fn try_from(s: String) -> Result<Self, Self::Error> {
SessionId::new(&s)
}
}
impl From<SessionId> for String {
fn from(id: SessionId) -> String {
id.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Epoch(pub u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct CallId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Hash32([u8; 32]);
impl Hash32 {
pub const ZERO: Hash32 = Hash32([0; 32]);
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Hash32(bytes)
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
pub fn to_hex(&self) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(64);
for &byte in &self.0 {
out.push(char::from(HEX[usize::from(byte >> 4)]));
out.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
out
}
pub fn from_hex(s: &str) -> Result<Self, ValueError> {
if s.len() != 64 {
return Err(ValueError::Hash32);
}
let mut out = [0u8; 32];
for (i, pair) in s.as_bytes().chunks(2).enumerate() {
let hi = hex_val(pair[0])?;
let lo = hex_val(pair[1])?;
out[i] = (hi << 4) | lo;
}
Ok(Hash32(out))
}
}
impl TryFrom<String> for Hash32 {
type Error = ValueError;
fn try_from(s: String) -> Result<Self, Self::Error> {
Hash32::from_hex(&s)
}
}
impl From<Hash32> for String {
fn from(h: Hash32) -> String {
h.to_hex()
}
}
fn hex_val(c: u8) -> Result<u8, ValueError> {
match c {
b'0'..=b'9' => Ok(c - b'0'),
b'a'..=b'f' => Ok(c - b'a' + 10),
_ => Err(ValueError::Hash32),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Timestamp(u64);
impl Timestamp {
/// 9999-12-31T23:59:59.999Z, the last instant RFC 3339 can spell with a four-digit year.
pub const MAX: Timestamp = Timestamp(253_402_300_799_999);
pub fn from_unix_millis(ms: u64) -> Result<Self, ValueError> {
if ms > Self::MAX.0 {
return Err(ValueError::Timestamp);
}
Ok(Timestamp(ms))
}
pub fn unix_millis(&self) -> u64 {
self.0
}
pub fn now() -> Self {
let dur = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let ms = u64::try_from(dur.as_millis()).unwrap_or(u64::MAX);
Timestamp::from_unix_millis(ms).unwrap_or(Timestamp::MAX)
}
pub fn to_rfc3339(&self) -> String {
let t = std::time::UNIX_EPOCH
.checked_add(std::time::Duration::from_millis(self.0))
.unwrap_or(std::time::UNIX_EPOCH);
humantime::format_rfc3339_millis(t).to_string()
}
pub fn parse(s: &str) -> Result<Self, ValueError> {
let t = humantime::parse_rfc3339(s).map_err(|_| ValueError::Timestamp)?;
let dur = t
.duration_since(std::time::UNIX_EPOCH)
.map_err(|_| ValueError::Timestamp)?;
let ms = u64::try_from(dur.as_millis()).map_err(|_| ValueError::Timestamp)?;
let parsed = Timestamp::from_unix_millis(ms)?;
if parsed.to_rfc3339() != s {
return Err(ValueError::Timestamp);
}
Ok(parsed)
}
}
impl TryFrom<String> for Timestamp {
type Error = ValueError;
fn try_from(s: String) -> Result<Self, Self::Error> {
Timestamp::parse(&s)
}
}
impl From<Timestamp> for String {
fn from(t: Timestamp) -> String {
t.to_rfc3339()
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Shared data types and the frame codec for Boxmaker. No policy and no I/O beyond frames.
pub mod audit;
pub mod chain;
pub mod class;
pub mod escape;
pub mod frame;
pub mod gateway;
pub mod grant;
pub mod hash;
pub mod hosts;
pub mod ids;
pub mod log;
pub mod memory;
pub mod sha1;
pub mod tools;
pub mod wire;
pub use audit::{
ApprovalAnswer, AuditEvent, AuditRecord, DecisionRecord, ResultStatus, is_audit_log_name,
};
pub use chain::{ChainFailure, ChainReport, ChainVerifier, Location, TornTail};
pub use class::DataClass;
pub use frame::{FrameError, MAX_FRAME, read_frame, write_frame};
pub use gateway::{
ApprovalOutcome, ApprovalPosted, ApprovalReply, ApprovalRequest, GatewayOutcome, MAX_REASON,
is_mattermost_id,
};
pub use grant::{Constraints, Grant, Mode};
pub use hash::{HashError, Sha256, sha256};
pub use ids::{CallId, Epoch, Hash32, SessionId, Timestamp, ValueError};
pub use log::{LogRecord, ToolCall, Usage};
pub use memory::{
ConsolidateEntry, EntryAction, MemoryAdmin, MemoryAdminReply, MemoryArgs, MemoryBlockReply,
MemoryCall, MemoryCommand, MemoryFile, RecallArgs, Reindexed, SessionUpdated,
};
pub use wire::{
ApprovalList, Approve, ApproveResult, DenyReason, Empty, Envelope, ErrorCode, GrantProblem,
GrantsReport, Message, PROTOCOL_VERSION, PendingApproval, Refuse, ToolRequest, ToolResponse,
Turn, TurnDone, TurnEvent, WireError,
};
+74
View File
@@ -0,0 +1,74 @@
//! Session log record types. One JSON object per line; assistant messages are stored verbatim.
use serde::{Deserialize, Serialize};
use crate::{CallId, DataClass, Epoch, Hash32, SessionId, Timestamp};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: String,
}
/// What one completion cost. The same five numbers as `LogRecord::Usage`, without the time.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Usage {
pub cache_n: u64,
pub prompt_n: u64,
pub predicted_n: u64,
pub reasoning_tokens: u64,
pub thinking_capped: bool,
}
// JSON: {"type":"user","time":"…","content":"…"} ; the tag sits beside the fields; types are snake_case
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum LogRecord {
SessionStart {
time: Timestamp,
session: SessionId,
epoch: Epoch,
slot: u32,
baseline: Hash32,
},
User {
time: Timestamp,
content: String,
},
Assistant {
time: Timestamp,
content: Option<String>,
reasoning_content: Option<String>,
tool_calls: Vec<ToolCall>,
},
ToolResult {
time: Timestamp,
call: CallId,
tool_call_id: String,
content: String,
class: DataClass,
untrusted: bool,
truncated: bool,
},
Usage {
time: Timestamp,
cache_n: u64,
prompt_n: u64,
predicted_n: u64,
reasoning_tokens: u64,
thinking_capped: bool,
},
CacheLoss {
time: Timestamp,
expected: u64,
got: u64,
},
EpochEnd {
time: Timestamp,
next: Epoch,
summary: String,
},
}
+282
View File
@@ -0,0 +1,282 @@
//! Memory: the arguments of `recall` and `memory`, their shape checks, and the messages `memoryd`
//! exchanges with `loopd`, `brokerd` and `bxctl` (M5a spec, sections 5 and 7). Content rules (an
//! entry matching the file, a file's limit, the check on characters) are `memoryd`'s.
use serde::{Deserialize, Serialize};
use crate::class::DataClass;
use crate::ids::SessionId;
use crate::wire::ToolRequest;
/// The tool names.
pub const RECALL: &str = "recall";
pub const MEMORY: &str = "memory";
/// The most characters a `recall` query may have.
pub const MAX_QUERY: usize = 500;
/// The most hits a `recall` may ask for, and how many it gets when it does not say.
pub const MAX_K: u64 = 10;
pub const DEFAULT_K: u64 = 5;
/// The most characters a `note` may have.
pub const MAX_NOTE: usize = 1_000;
/// The most characters of any other entry: the larger file's limit.
pub const MAX_ENTRY: usize = 2_200;
/// The line that separates entries in `memory.md`, `user.md` and `notes.md`.
pub const SEPARATOR: &str = "§";
/// The arguments for `recall`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RecallArgs {
pub query: String,
#[serde(default = "default_k")]
pub k: u64,
}
fn default_k() -> u64 {
DEFAULT_K
}
/// Which identity file an edit is for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemoryFile {
Memory,
User,
}
impl MemoryFile {
/// The file's name under `memory/`.
pub fn name(self) -> &'static str {
match self {
MemoryFile::Memory => "memory.md",
MemoryFile::User => "user.md",
}
}
/// The file's limit in characters.
pub fn limit(self) -> usize {
match self {
MemoryFile::Memory => 2_200,
MemoryFile::User => 1_375,
}
}
}
/// The arguments for `memory`: one action. `note` records experience; the others edit identity.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
pub enum MemoryArgs {
Add {
file: MemoryFile,
text: String,
},
Replace {
file: MemoryFile,
old: String,
text: String,
},
Remove {
file: MemoryFile,
old: String,
},
Consolidate {
file: MemoryFile,
entries: Vec<ConsolidateEntry>,
},
Note {
text: String,
},
}
/// One line of a `consolidate`: an existing entry (`old`) with exactly one of `keep`, `text`,
/// `move`, `drop`; or a new entry (`text` alone).
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConsolidateEntry {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub old: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub keep: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, rename = "move", skip_serializing_if = "std::ops::Not::not")]
pub moved: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub drop: bool,
}
/// What a `consolidate` line does.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EntryAction {
Keep(String),
Rewrite { old: String, text: String },
Move(String),
Drop(String),
New(String),
}
impl ConsolidateEntry {
/// The line's action, or why it has none.
pub fn action(&self) -> Result<EntryAction, &'static str> {
let flags = usize::from(self.keep) + usize::from(self.moved) + usize::from(self.drop);
match (&self.old, &self.text, flags) {
(None, Some(text), 0) => Ok(EntryAction::New(text.clone())),
(None, _, _) => Err("an entry without old must have text and nothing else"),
(Some(old), Some(text), 0) => Ok(EntryAction::Rewrite {
old: old.clone(),
text: text.clone(),
}),
(Some(old), None, 1) => {
if self.keep {
Ok(EntryAction::Keep(old.clone()))
} else if self.moved {
Ok(EntryAction::Move(old.clone()))
} else {
Ok(EntryAction::Drop(old.clone()))
}
}
(Some(_), _, _) => {
Err("an entry with old must have exactly one of keep, text, move or drop")
}
}
}
}
/// Why an entry's text is not a well-formed entry: empty once trimmed, too long, or holding a line
/// that starts with the separator, spaces aside. In `notes.md` such a line would read as a header,
/// and a header says where an entry came from. `None` when it is fine.
pub fn entry_problem(text: &str, max: usize) -> Option<String> {
if text.trim().is_empty() {
return Some("an entry cannot be empty".to_string());
}
let chars = text.chars().count();
if chars > max {
return Some(format!(
"an entry has at most {max} characters; this has {chars}"
));
}
for line in text.lines() {
if line.trim_start().starts_with(SEPARATOR) {
return Some(format!(
"an entry cannot hold a line that starts with {SEPARATOR}"
));
}
}
None
}
/// `entry_problem` as a `Result`, so a check can use `?`.
fn entry(text: &str, max: usize) -> Result<(), String> {
match entry_problem(text, max) {
Some(problem) => Err(problem),
None => Ok(()),
}
}
impl RecallArgs {
/// The shape rules of `recall` (M5a spec, section 5).
pub fn check(&self) -> Result<(), String> {
if self.query.trim().is_empty() || self.query.chars().count() > MAX_QUERY {
return Err(format!("query must have 1 to {MAX_QUERY} characters"));
}
if self.k == 0 || self.k > MAX_K {
return Err(format!("k must be 1 to {MAX_K}"));
}
Ok(())
}
}
impl MemoryArgs {
/// The shape rules of `memory` (M5a spec, section 5): every text is a well-formed entry, and
/// every `consolidate` line has one action and the list is not empty.
pub fn check(&self) -> Result<(), String> {
match self {
MemoryArgs::Add { text, .. } => entry(text, MAX_ENTRY),
MemoryArgs::Replace { old, text, .. } => {
entry(old, MAX_ENTRY)?;
entry(text, MAX_ENTRY)
}
MemoryArgs::Remove { old, .. } => entry(old, MAX_ENTRY),
MemoryArgs::Note { text } => entry(text, MAX_NOTE),
MemoryArgs::Consolidate { entries, .. } => {
if entries.is_empty() {
return Err("consolidate needs every entry of the file".to_string());
}
for line in entries {
match line.action().map_err(str::to_string)? {
EntryAction::Keep(old)
| EntryAction::Move(old)
| EntryAction::Drop(old) => entry(&old, MAX_ENTRY)?,
EntryAction::Rewrite { old, text } => {
entry(&old, MAX_ENTRY)?;
entry(&text, MAX_ENTRY)?;
}
EntryAction::New(text) => entry(&text, MAX_ENTRY)?,
}
}
Ok(())
}
}
}
}
/// `memoryd` → `loopd`: the memory block for a new session's baseline.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryBlockReply {
pub text: String,
}
/// `loopd` → `memoryd`: this session's log has new records.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionUpdated {
pub session: SessionId,
}
/// `brokerd` → `memoryd`: a decided `recall` or `memory` call, with the session's state when it
/// was decided.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryCall {
pub request: ToolRequest,
pub taint: DataClass,
pub untrusted: bool,
}
/// `memoryd` → `bxctl`: what a reindex read.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Reindexed {
pub logs: u64,
pub records: u64,
pub notes: u64,
}
/// The owner's memory commands (M5a spec, section 10).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemoryCommand {
Log,
Undo,
Pending,
Accept,
Reject,
}
/// `bxctl` → `memoryd`: one command; `arg` is the count for `log`, the edit or pending id for
/// `undo`, `accept` and `reject`, and empty otherwise.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryAdmin {
pub command: MemoryCommand,
pub arg: String,
}
/// `memoryd` → `bxctl`: the text to print.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryAdminReply {
pub text: String,
}
+141
View File
@@ -0,0 +1,141 @@
//! SHA-1 (FIPS 180-4), used only to check the `Sec-WebSocket-Accept` header of a WebSocket
//! handshake (RFC 6455, section 4.2.2). Never use it for anything that needs to resist attack.
/// The digest of `data`.
pub fn sha1(data: &[u8]) -> [u8; 20] {
let mut h = Sha1::new();
h.update(data);
h.finish()
}
/// SHA-1 fed in pieces.
#[derive(Debug, Clone)]
pub struct Sha1 {
state: [u32; 5],
block: [u8; 64],
filled: usize,
length: u64,
}
impl Default for Sha1 {
fn default() -> Self {
Self::new()
}
}
impl Sha1 {
pub fn new() -> Sha1 {
Sha1 {
state: [
0x6745_2301,
0xEFCD_AB89,
0x98BA_DCFE,
0x1032_5476,
0xC3D2_E1F0,
],
block: [0; 64],
filled: 0,
length: 0,
}
}
pub fn update(&mut self, mut data: &[u8]) {
// Add 8 * data.len() to `length` (wrapping; `u64::try_from`, never `as`). Copy bytes into
// `block` from `filled` on; each time it is full (64), `compress` it and set `filled` to 0.
// Use `split_at` and `get_mut(..)`, no indexing that can go out of bounds.
self.length = self
.length
.wrapping_add(8u64.wrapping_mul(u64::try_from(data.len()).unwrap_or(u64::MAX)));
while !data.is_empty() {
let space = 64 - self.filled;
let take = data.len().min(space);
let (left, right) = data.split_at(take);
if let Some(dst) = self.block.get_mut(self.filled..self.filled + take) {
dst.copy_from_slice(left);
}
self.filled += take;
if self.filled == 64 {
let block = self.block;
self.compress(&block);
self.filled = 0;
}
data = right;
}
}
pub fn finish(mut self) -> [u8; 20] {
// Save `length`. Feed 0x80 then zeros so that 56 bytes of the block are filled (56 -
// filled, or 120 - filled when filled >= 56), then the saved length as 8 big-endian bytes,
// through `update`. `update` adds to `length`: put the saved value back after. Then the
// five state words, big-endian.
let saved = self.length;
let zeros = if self.filled < 56 {
56 - self.filled
} else {
120 - self.filled
};
let total = zeros + 8;
let mut pad = [0u8; 128];
pad[0] = 0x80;
pad[zeros..total].copy_from_slice(&saved.to_be_bytes());
self.update(&pad[..total]);
self.length = saved;
let mut out = [0u8; 20];
for (i, word) in self.state.iter().enumerate() {
out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
}
out
}
fn compress(&mut self, block: &[u8; 64]) {
// FIPS 180-4, section 6.1.2: w[0..16] are the block as big-endian u32s; w[i] = (w[i-3] ^
// w[i-8] ^ w[i-14] ^ w[i-16]).rotate_left(1) for 16..80. Eighty rounds with f and k by
// range: 0..=19 (b & c) | (!b & d), 0x5A827999; 20..=39 b ^ c ^ d, 0x6ED9EBA1; 40..=59 (b &
// c) | (b & d) | (c & d), 0x8F1BBCDC; 60..=79 b ^ c ^ d, 0xCA62C1D6. All additions
// wrapping. Add a..e into state.
let mut w: [u32; 80] = [0; 80];
let (chunks, _) = block.as_chunks::<4>();
for (i, chunk) in chunks.iter().enumerate() {
w[i] = u32::from_be_bytes(*chunk);
}
for i in 16..80 {
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
}
let (mut a, mut b, mut c, mut d, mut e) = (
self.state[0],
self.state[1],
self.state[2],
self.state[3],
self.state[4],
);
for (round, &ww) in w.iter().enumerate() {
let (f, k) = if round <= 19 {
((b & c) | (!b & d), 0x5A82_7999)
} else if round <= 39 {
(b ^ c ^ d, 0x6ED9_EBA1)
} else if round <= 59 {
((b & c) | (b & d) | (c & d), 0x8F1B_BCDC)
} else {
(b ^ c ^ d, 0xCA62_C1D6)
};
let temp = a
.rotate_left(5)
.wrapping_add(f)
.wrapping_add(e)
.wrapping_add(k);
e = d;
d = c;
c = b.rotate_left(30);
b = a;
a = temp.wrapping_add(ww);
}
self.state[0] = self.state[0].wrapping_add(a);
self.state[1] = self.state[1].wrapping_add(b);
self.state[2] = self.state[2].wrapping_add(c);
self.state[3] = self.state[3].wrapping_add(d);
self.state[4] = self.state[4].wrapping_add(e);
}
}
+36
View File
@@ -0,0 +1,36 @@
//! The four tools' arguments, decoded from the model's JSON then re-serialised. Shared by
//! `brokerd` (which validates them) and `toolkit` (inside the container, which runs them), so the
//! two must agree on one definition.
use serde::{Deserialize, Serialize};
/// The arguments for `read_file`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReadFileArgs {
pub path: String,
}
/// The arguments for `write_file`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WriteFileArgs {
pub path: String,
pub content: String,
}
/// The arguments for `shell`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ShellArgs {
pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
}
/// The arguments for `http_fetch`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HttpFetchArgs {
pub url: String,
}
+250
View File
@@ -0,0 +1,250 @@
use serde::{Deserialize, Serialize};
use crate::gateway::{ApprovalOutcome, ApprovalPosted, ApprovalReply, ApprovalRequest};
use crate::memory::{
MemoryAdmin, MemoryAdminReply, MemoryBlockReply, MemoryCall, Reindexed, SessionUpdated,
};
use crate::{CallId, DataClass, DecisionRecord, SessionId, Timestamp, Usage};
pub const PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Envelope {
pub v: u32,
pub id: u64,
pub r#final: bool,
pub msg: Message,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
content = "body",
deny_unknown_fields,
rename_all = "snake_case"
)]
pub enum Message {
ToolRequest(ToolRequest),
ToolResponse(ToolResponse),
Error(WireError),
Turn(Turn),
TurnEvent(TurnEvent),
TurnDone(TurnDone),
Approvals(Empty),
ApprovalList(ApprovalList),
Approve(Approve),
ApproveResult(ApproveResult),
Refuse(Refuse),
Ok(Empty),
CheckGrants(Empty),
GrantsReport(GrantsReport),
ApprovalRequest(ApprovalRequest),
ApprovalPosted(ApprovalPosted),
ApprovalReply(ApprovalReply),
ApprovalOutcome(ApprovalOutcome),
MemoryBlock(Empty),
MemoryBlockReply(MemoryBlockReply),
SessionUpdated(SessionUpdated),
MemoryCall(MemoryCall),
Reindex(Empty),
Reindexed(Reindexed),
MemoryAdmin(MemoryAdmin),
MemoryAdminReply(MemoryAdminReply),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WireError {
pub code: ErrorCode,
pub detail: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
BadFrame,
BadVersion,
BadMessage,
Internal,
SessionFull,
TurnLimit,
SessionBusy,
NoSuchSession,
SessionExists,
Inference,
Forbidden,
NoSuchApproval,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolRequest {
pub session: SessionId,
pub call: CallId,
pub tool: String,
pub arguments: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", deny_unknown_fields, rename_all = "snake_case")]
pub enum ToolResponse {
PendingApproval {
approval: u64,
expires: Timestamp,
},
Result {
content: String,
class: DataClass,
untrusted: bool,
truncated: bool,
},
Failed {
message: String,
},
Denied {
reason: DenyReason,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DenyReason {
NoGrant,
GrantExpired,
TaintTooHigh,
DeniedByGrant,
ApprovalRefused,
ApprovalExpired,
GrantsInvalid,
AuditUnavailable,
InvalidArguments,
StateUnreadable,
}
// JSON: {"kind":"turn","body":{"session":"…","content":"…","resume":false}}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Turn {
pub session: SessionId,
pub content: String,
pub resume: bool,
}
// JSON: {"event":"content","text":"…"} — the tag sits beside the fields, snake_case
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "event", deny_unknown_fields, rename_all = "snake_case")]
pub enum TurnEvent {
Queued {
ahead: u64,
},
Waiting {
slot_busy: bool,
},
Progress {
total: u64,
cache: u64,
processed: u64,
},
Reasoning {
text: String,
},
Content {
text: String,
},
ToolCallStarted {
name: String,
},
ToolResult {
name: String,
class: DataClass,
truncated: bool,
},
ThinkingCapped {
tokens: u64,
},
Retrying {
attempt: u32,
after_ms: u64,
error: String,
},
CacheLoss {
expected: u64,
got: u64,
},
ApprovalPending {
approval: u64,
tool: String,
expires: Timestamp,
},
ToolDenied {
name: String,
reason: DenyReason,
},
}
// JSON: {"kind":"turn_done","body":{"content":"…","usage":{…}}}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TurnDone {
pub content: String,
pub usage: Usage,
}
// The admin messages on admin.sock. JSON: {"kind":"approvals","body":{}}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Empty {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PendingApproval {
pub approval: u64,
pub session: SessionId,
pub call: CallId,
pub tool: String,
pub arguments: String,
pub grant: String,
pub taint: DataClass,
pub created: Timestamp,
pub expires: Timestamp,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovalList {
pub items: Vec<PendingApproval>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Approve {
pub approval: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApproveResult {
pub outcome: DecisionRecord,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Refuse {
pub approval: u64,
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GrantProblem {
pub file: String,
pub line: Option<u64>,
pub problem: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GrantsReport {
pub problems: Vec<GrantProblem>,
}