Add M1 plan, given tests and fixtures, AGENTS.md and implementer log
Seven task files for the implementing model under docs/plans/M1/, with the test files, byte-exact fixtures, Makefile, deny.toml and gate-script self-test they copy into place. All of it was verified against a private reference implementation: the gate passes after every task in order. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
tool = "read_file"
|
||||
mode = "always"
|
||||
max_taint = "private"
|
||||
@@ -0,0 +1,12 @@
|
||||
tool = "http_fetch"
|
||||
mode = "ask"
|
||||
max_taint = "secret"
|
||||
result_class = "public"
|
||||
untrusted = false
|
||||
expires = "2026-12-31T00:00:00.000Z"
|
||||
secret = "example-api-token"
|
||||
|
||||
[constraints]
|
||||
paths = ["/home/kyle/notes/**"]
|
||||
hosts = ["example.com", "api.example.com"]
|
||||
patterns = ["^GET "]
|
||||
@@ -0,0 +1,3 @@
|
||||
tool = "read_file"
|
||||
mode = "auto"
|
||||
max_taint = "private"
|
||||
@@ -0,0 +1,2 @@
|
||||
tool = "read_file"
|
||||
mode = "auto"
|
||||
@@ -0,0 +1,6 @@
|
||||
tool = "read_file"
|
||||
mode = "auto"
|
||||
max_taint = "private"
|
||||
|
||||
[constraints]
|
||||
path = ["/etc/**"]
|
||||
@@ -0,0 +1,4 @@
|
||||
tool = "read_file"
|
||||
mode = "auto"
|
||||
max_taint = "private"
|
||||
max_tiant = "secret"
|
||||
@@ -0,0 +1,4 @@
|
||||
{"seq":0,"time":"2026-09-17T08:05:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","session":"mm-thread-42","call":1,"tool":"read_file","arguments":"{\"path\":\"/etc/hosts\"}","session_taint":"private","decision":{"outcome":"allowed","grant":"read-etc"}}
|
||||
{"seq":1,"time":"2026-09-17T08:05:01.250Z","prev":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","session":"mm-thread-42","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","session_taint":"private","decision":{"outcome":"approved","grant":"shell-ask","approver":"u8f3k2","post":"p9x7"}}
|
||||
{"seq":2,"time":"2026-09-17T08:05:02.000Z","prev":"ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100","session":"cron-morning","call":1,"tool":"consult","arguments":"{\"question\":\"hi\"}","session_taint":"secret","decision":{"outcome":"denied","reason":"taint_too_high","grant":"consult-private"}}
|
||||
{"seq":3,"time":"2026-09-17T08:05:03.000Z","prev":"ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100","session":"cron-morning","call":2,"tool":"rm_rf","arguments":"{}","session_taint":"secret","decision":{"outcome":"denied","reason":"no_grant","grant":null}}
|
||||
@@ -0,0 +1,7 @@
|
||||
{"type":"session_start","time":"2026-09-17T08:05:00.000Z","session":"mm-thread-42","epoch":0,"slot":0,"baseline":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"}
|
||||
{"type":"user","time":"2026-09-17T08:05:01.000Z","content":"What is in /etc/hosts?"}
|
||||
{"type":"assistant","time":"2026-09-17T08:05:03.000Z","content":null,"reasoning_content":"The user wants a file.\nI will read it.","tool_calls":[{"id":"call_a1","name":"read_file","arguments":"{\"path\":\"/etc/hosts\"}"}]}
|
||||
{"type":"tool_result","time":"2026-09-17T08:05:04.000Z","call":1,"tool_call_id":"call_a1","content":"127.0.0.1 localhost\n","class":"private","untrusted":true,"truncated":false}
|
||||
{"type":"assistant","time":"2026-09-17T08:05:06.000Z","content":"It maps localhost to 127.0.0.1.","reasoning_content":null,"tool_calls":[]}
|
||||
{"type":"cache_loss","time":"2026-09-17T09:00:00.000Z","expected":30695,"got":0}
|
||||
{"type":"epoch_end","time":"2026-09-17T12:00:00.000Z","next":1,"summary":"Looked at /etc/hosts."}
|
||||
@@ -0,0 +1 @@
|
||||
{"v":1,"id":0,"final":true,"msg":{"kind":"error","body":{"code":"bad_version","detail":"expected 1"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"v":1,"id":7,"final":true,"msg":{"kind":"tool_request","body":{"session":"mm-thread-42","call":3,"tool":"read_file","arguments":"{\"path\":\"/etc/hosts\"}"}}}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"v":1,"id":9,"final":true,"msg":{"kind":"tool_response","body":{"status":"denied","reason":"taint_too_high"}}}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"v":1,"id":8,"final":true,"msg":{"kind":"tool_response","body":{"status":"failed","message":"exit status 2"}}}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"v":1,"id":7,"final":false,"msg":{"kind":"tool_response","body":{"status":"pending_approval","approval":"ap-0001","expires":"2026-09-17T08:35:00.000Z"}}}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"v":1,"id":7,"final":true,"msg":{"kind":"tool_response","body":{"status":"result","content":"127.0.0.1 localhost\n","class":"private","untrusted":true,"truncated":false}}}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Tests for the frame codec. Do not edit these or the fixtures.
|
||||
|
||||
use proto::{
|
||||
CallId, Envelope, FrameError, MAX_FRAME, Message, SessionId, ToolRequest, read_frame,
|
||||
write_frame,
|
||||
};
|
||||
use std::io::{Cursor, Read};
|
||||
|
||||
fn fixture_bytes(name: &str) -> Vec<u8> {
|
||||
let path = format!("{}/tests/fixtures/frame/{name}", env!("CARGO_MANIFEST_DIR"));
|
||||
std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}"))
|
||||
}
|
||||
|
||||
fn request(arguments: String) -> Envelope {
|
||||
let body = ToolRequest {
|
||||
session: SessionId::new("mm-thread-42").unwrap(),
|
||||
call: CallId(3),
|
||||
tool: "read_file".to_string(),
|
||||
arguments,
|
||||
};
|
||||
Envelope {
|
||||
v: 1,
|
||||
id: 7,
|
||||
r#final: true,
|
||||
msg: Message::ToolRequest(body),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves `head`, then panics if anyone reads further.
|
||||
struct HeaderOnly {
|
||||
head: Cursor<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Read for HeaderOnly {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
let n = self.head.read(buf)?;
|
||||
assert!(
|
||||
n > 0,
|
||||
"the reader asked for the body of a frame it should have rejected"
|
||||
);
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands out one byte per call, to catch codecs that assume `read` fills the buffer.
|
||||
struct OneByteAtATime(Cursor<Vec<u8>>);
|
||||
|
||||
impl Read for OneByteAtATime {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
let end = buf.len().min(1);
|
||||
self.0.read(&mut buf[..end])
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_frame_is_one_mebibyte() {
|
||||
assert_eq!(MAX_FRAME, 1_048_576);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_matches_the_fixture_byte_for_byte() {
|
||||
let mut out = Vec::new();
|
||||
write_frame(&mut out, &request(r#"{"path":"/etc/hosts"}"#.to_string())).unwrap();
|
||||
assert_eq!(out, fixture_bytes("tool_request.bin"));
|
||||
assert_eq!(&out[..4], &[0, 0, 0, 159]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_decodes_the_fixture() {
|
||||
let mut input = Cursor::new(fixture_bytes("tool_request.bin"));
|
||||
let env = read_frame(&mut input).unwrap();
|
||||
assert_eq!(env, request(r#"{"path":"/etc/hosts"}"#.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_frames_in_a_row_then_closed() {
|
||||
let mut bytes = fixture_bytes("tool_request.bin");
|
||||
bytes.extend(fixture_bytes("tool_request.bin"));
|
||||
let mut input = Cursor::new(bytes);
|
||||
assert!(read_frame(&mut input).is_ok());
|
||||
assert!(read_frame(&mut input).is_ok());
|
||||
assert!(matches!(read_frame(&mut input), Err(FrameError::Closed)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_reads_are_handled() {
|
||||
let mut input = OneByteAtATime(Cursor::new(fixture_bytes("tool_request.bin")));
|
||||
assert!(read_frame(&mut input).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_length_is_empty() {
|
||||
let mut input = Cursor::new(vec![0, 0, 0, 0]);
|
||||
assert!(matches!(read_frame(&mut input), Err(FrameError::Empty)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversize_length_is_rejected_without_reading_the_body() {
|
||||
let n = (MAX_FRAME as u32) + 1;
|
||||
let mut input = HeaderOnly {
|
||||
head: Cursor::new(n.to_be_bytes().to_vec()),
|
||||
};
|
||||
match read_frame(&mut input) {
|
||||
Err(FrameError::TooLarge(got)) => assert_eq!(got, MAX_FRAME + 1),
|
||||
other => panic!("expected TooLarge, got {other:?}"),
|
||||
}
|
||||
let mut input = HeaderOnly {
|
||||
head: Cursor::new(vec![0xff, 0xff, 0xff, 0xff]),
|
||||
};
|
||||
assert!(matches!(
|
||||
read_frame(&mut input),
|
||||
Err(FrameError::TooLarge(4_294_967_295))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_max_frame_is_accepted_by_the_length_check() {
|
||||
// A body of MAX_FRAME bytes of spaces is not valid JSON, so the error must be Json, not TooLarge.
|
||||
let mut bytes = (MAX_FRAME as u32).to_be_bytes().to_vec();
|
||||
bytes.extend(std::iter::repeat_n(b' ', MAX_FRAME));
|
||||
assert!(matches!(
|
||||
read_frame(&mut Cursor::new(bytes)),
|
||||
Err(FrameError::Json(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_header_and_body_are_io_errors() {
|
||||
let full = fixture_bytes("tool_request.bin");
|
||||
let mut header_cut = Cursor::new(full[..2].to_vec());
|
||||
assert!(matches!(
|
||||
read_frame(&mut header_cut),
|
||||
Err(FrameError::Io(_))
|
||||
));
|
||||
let mut body_cut = Cursor::new(full[..full.len() - 1].to_vec());
|
||||
assert!(matches!(read_frame(&mut body_cut), Err(FrameError::Io(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_body_is_a_json_error() {
|
||||
let mut bytes = vec![0, 0, 0, 3];
|
||||
bytes.extend(b"{{{");
|
||||
assert!(matches!(
|
||||
read_frame(&mut Cursor::new(bytes)),
|
||||
Err(FrameError::Json(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_protocol_versions_are_rejected() {
|
||||
let mut env = request("{}".to_string());
|
||||
env.v = 2;
|
||||
let mut out = Vec::new();
|
||||
write_frame(&mut out, &env).unwrap();
|
||||
assert!(matches!(
|
||||
read_frame(&mut Cursor::new(out)),
|
||||
Err(FrameError::BadVersion(2))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversize_envelopes_are_not_written() {
|
||||
let mut out = Vec::new();
|
||||
let err = write_frame(&mut out, &request("x".repeat(MAX_FRAME))).unwrap_err();
|
||||
assert!(matches!(err, FrameError::TooLarge(n) if n > MAX_FRAME));
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"nothing may be written when the envelope is too large"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_error_is_a_std_error_with_a_message() {
|
||||
let e: Box<dyn std::error::Error> = Box::new(FrameError::Empty);
|
||||
assert!(!e.to_string().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Tests for grant files. Do not edit these or the fixtures.
|
||||
|
||||
use proto::{Constraints, DataClass, Grant, Mode, Timestamp};
|
||||
|
||||
fn parse(name: &str) -> Result<Grant, toml::de::Error> {
|
||||
let path = format!("{}/tests/fixtures/grant/{name}", env!("CARGO_MANIFEST_DIR"));
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||
toml::from_str(&text)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_grant_gets_safe_defaults() {
|
||||
let want = Grant {
|
||||
tool: "read_file".to_string(),
|
||||
mode: Mode::Auto,
|
||||
max_taint: DataClass::Private,
|
||||
result_class: DataClass::Private,
|
||||
untrusted: true,
|
||||
expires: None,
|
||||
secret: None,
|
||||
constraints: Constraints::default(),
|
||||
};
|
||||
assert_eq!(parse("minimal.toml").unwrap(), want);
|
||||
assert_eq!(Constraints::default().paths, Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_grant() {
|
||||
let want = Grant {
|
||||
tool: "http_fetch".to_string(),
|
||||
mode: Mode::Ask,
|
||||
max_taint: DataClass::Secret,
|
||||
result_class: DataClass::Public,
|
||||
untrusted: false,
|
||||
expires: Some(Timestamp::parse("2026-12-31T00:00:00.000Z").unwrap()),
|
||||
secret: Some("example-api-token".to_string()),
|
||||
constraints: Constraints {
|
||||
paths: vec!["/home/kyle/notes/**".to_string()],
|
||||
hosts: vec!["example.com".to_string(), "api.example.com".to_string()],
|
||||
patterns: vec!["^GET ".to_string()],
|
||||
},
|
||||
};
|
||||
assert_eq!(parse("full.toml").unwrap(), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mistakes_in_grant_files_are_errors() {
|
||||
for name in [
|
||||
"unknown_field.toml",
|
||||
"unknown_constraint.toml",
|
||||
"bad_mode.toml",
|
||||
"missing_max_taint.toml",
|
||||
] {
|
||||
assert!(parse(name).is_err(), "{name} was accepted");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_are_lowercase() {
|
||||
for (mode, text) in [
|
||||
(Mode::Auto, "auto"),
|
||||
(Mode::Ask, "ask"),
|
||||
(Mode::Deny, "deny"),
|
||||
] {
|
||||
assert_eq!(serde_json::to_string(&mode).unwrap(), format!("\"{text}\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Tests for identifiers and primitive values. Do not edit: these define the required behaviour.
|
||||
|
||||
use proto::{CallId, DataClass, Epoch, Hash32, SessionId, Timestamp, ValueError};
|
||||
|
||||
#[test]
|
||||
fn session_id_accepts_lowercase_digits_and_hyphen() {
|
||||
for ok in ["a", "mm-thread-42", "0", "a-b-c", &"x".repeat(64)] {
|
||||
assert_eq!(SessionId::new(ok).unwrap().as_str(), ok);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_id_rejects_everything_else() {
|
||||
let too_long = "x".repeat(65);
|
||||
for bad in [
|
||||
"",
|
||||
"A",
|
||||
"a b",
|
||||
"a/b",
|
||||
"../etc",
|
||||
"a.b",
|
||||
"a_b",
|
||||
"é",
|
||||
"a\n",
|
||||
too_long.as_str(),
|
||||
] {
|
||||
assert_eq!(
|
||||
SessionId::new(bad),
|
||||
Err(ValueError::SessionId),
|
||||
"accepted {bad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_id_json_is_a_plain_string_and_is_validated() {
|
||||
let id = SessionId::new("mm-thread-42").unwrap();
|
||||
assert_eq!(serde_json::to_string(&id).unwrap(), r#""mm-thread-42""#);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SessionId>(r#""mm-thread-42""#).unwrap(),
|
||||
id
|
||||
);
|
||||
assert!(serde_json::from_str::<SessionId>(r#""../etc""#).is_err());
|
||||
assert!(serde_json::from_str::<SessionId>("42").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epoch_and_call_id_are_plain_numbers() {
|
||||
assert_eq!(serde_json::to_string(&Epoch(3)).unwrap(), "3");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&CallId(18446744073709551615)).unwrap(),
|
||||
"18446744073709551615"
|
||||
);
|
||||
assert_eq!(serde_json::from_str::<CallId>("7").unwrap(), CallId(7));
|
||||
assert!(serde_json::from_str::<CallId>("-1").is_err());
|
||||
assert!(serde_json::from_str::<CallId>("1.5").is_err());
|
||||
assert!(serde_json::from_str::<Epoch>("4294967296").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash32_hex_round_trip() {
|
||||
let mut bytes = [0u8; 32];
|
||||
for (i, b) in bytes.iter_mut().enumerate() {
|
||||
*b = i as u8;
|
||||
}
|
||||
let h = Hash32::from_bytes(bytes);
|
||||
let hex = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
|
||||
assert_eq!(h.to_hex(), hex);
|
||||
assert_eq!(Hash32::from_hex(hex).unwrap(), h);
|
||||
assert_eq!(h.as_bytes(), &bytes);
|
||||
assert_eq!(serde_json::to_string(&h).unwrap(), format!("\"{hex}\""));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Hash32>(&format!("\"{hex}\"")).unwrap(),
|
||||
h
|
||||
);
|
||||
assert_eq!(Hash32::ZERO.to_hex(), "0".repeat(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash32_rejects_wrong_length_uppercase_and_non_hex() {
|
||||
let upper = "000102030405060708090A0B0C0D0E0F101112131415161718191a1b1c1d1e1f";
|
||||
let non_hex = "g00102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
|
||||
let non_ascii = format!("é{}", "0".repeat(62));
|
||||
for bad in [
|
||||
"",
|
||||
"00",
|
||||
&"0".repeat(63),
|
||||
&"0".repeat(65),
|
||||
upper,
|
||||
non_hex,
|
||||
non_ascii.as_str(),
|
||||
] {
|
||||
assert_eq!(
|
||||
Hash32::from_hex(bad),
|
||||
Err(ValueError::Hash32),
|
||||
"accepted {bad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamp_has_one_spelling() {
|
||||
let t = Timestamp::from_unix_millis(1_789_632_300_000);
|
||||
assert_eq!(t.unix_millis(), 1_789_632_300_000);
|
||||
assert_eq!(t.to_rfc3339(), "2026-09-17T08:05:00.000Z");
|
||||
assert_eq!(Timestamp::parse("2026-09-17T08:05:00.000Z").unwrap(), t);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&t).unwrap(),
|
||||
r#""2026-09-17T08:05:00.000Z""#
|
||||
);
|
||||
assert_eq!(
|
||||
Timestamp::from_unix_millis(0).to_rfc3339(),
|
||||
"1970-01-01T00:00:00.000Z"
|
||||
);
|
||||
assert_eq!(
|
||||
Timestamp::from_unix_millis(1_789_632_300_007).to_rfc3339(),
|
||||
"2026-09-17T08:05:00.007Z"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamp_rejects_other_spellings() {
|
||||
for bad in [
|
||||
"2026-09-17T08:05:00Z",
|
||||
"2026-09-17T08:05:00.0Z",
|
||||
"2026-09-17T08:05:00.000000Z",
|
||||
"2026-09-17 08:05:00.000Z",
|
||||
"2026-09-17T08:05:00.000+00:00",
|
||||
"2026-09-17t08:05:00.000z",
|
||||
"2026-09-17",
|
||||
"",
|
||||
"now",
|
||||
] {
|
||||
assert_eq!(
|
||||
Timestamp::parse(bad),
|
||||
Err(ValueError::Timestamp),
|
||||
"accepted {bad:?}"
|
||||
);
|
||||
assert!(serde_json::from_str::<Timestamp>(&format!("\"{bad}\"")).is_err());
|
||||
}
|
||||
assert!(serde_json::from_str::<Timestamp>("1789632300000").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamp_now_is_after_2026() {
|
||||
assert!(Timestamp::now() > Timestamp::parse("2026-01-01T00:00:00.000Z").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_class_is_ordered_and_lowercase() {
|
||||
assert!(DataClass::Public < DataClass::Private);
|
||||
assert!(DataClass::Private < DataClass::Secret);
|
||||
assert_eq!(DataClass::Private.max(DataClass::Secret), DataClass::Secret);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&DataClass::Secret).unwrap(),
|
||||
r#""secret""#
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DataClass>(r#""public""#).unwrap(),
|
||||
DataClass::Public
|
||||
);
|
||||
assert!(serde_json::from_str::<DataClass>(r#""Public""#).is_err());
|
||||
assert!(serde_json::from_str::<DataClass>(r#""internal""#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_error_is_a_std_error_with_a_message() {
|
||||
let e: Box<dyn std::error::Error> = Box::new(ValueError::Hash32);
|
||||
assert!(!e.to_string().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Tests for audit and session log records against JSONL fixtures. Do not edit these or the fixtures.
|
||||
|
||||
use proto::{
|
||||
AuditRecord, CallId, DataClass, DecisionRecord, DenyReason, Epoch, Hash32, LogRecord,
|
||||
SessionId, Timestamp, ToolCall,
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use std::fmt::Debug;
|
||||
|
||||
const SEQ_HEX: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
|
||||
const REV_HEX: &str = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100";
|
||||
|
||||
fn ts(s: &str) -> Timestamp {
|
||||
Timestamp::parse(s).unwrap()
|
||||
}
|
||||
|
||||
/// Line `i` of the fixture must decode to `want[i]`, and `want[i]` must encode to exactly that line.
|
||||
fn check<T: Serialize + DeserializeOwned + PartialEq + Debug>(name: &str, want: &[T]) {
|
||||
let path = format!(
|
||||
"{}/tests/fixtures/records/{name}",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
);
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), want.len(), "{name}: number of lines");
|
||||
for (i, (line, want)) in lines.iter().zip(want).enumerate() {
|
||||
let got: T = serde_json::from_str(line).unwrap_or_else(|e| panic!("{name}:{}: {e}", i + 1));
|
||||
assert_eq!(&got, want, "{name}:{}: decoded value", i + 1);
|
||||
assert_eq!(
|
||||
&serde_json::to_string(want).unwrap(),
|
||||
line,
|
||||
"{name}:{}: encoded bytes",
|
||||
i + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A record for session `session`; each test case overrides the fields it cares about.
|
||||
fn audit(seq: u64, time: &str, prev: Hash32, session: &str) -> AuditRecord {
|
||||
AuditRecord {
|
||||
seq,
|
||||
time: ts(time),
|
||||
prev,
|
||||
session: SessionId::new(session).unwrap(),
|
||||
call: CallId(1),
|
||||
tool: String::new(),
|
||||
arguments: "{}".to_string(),
|
||||
session_taint: DataClass::Private,
|
||||
decision: DecisionRecord::Denied {
|
||||
reason: DenyReason::NoGrant,
|
||||
grant: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_records() {
|
||||
let seq_hash = Hash32::from_hex(SEQ_HEX).unwrap();
|
||||
let rev_hash = Hash32::from_hex(REV_HEX).unwrap();
|
||||
let want = [
|
||||
AuditRecord {
|
||||
tool: "read_file".to_string(),
|
||||
arguments: r#"{"path":"/etc/hosts"}"#.to_string(),
|
||||
decision: DecisionRecord::Allowed {
|
||||
grant: "read-etc".to_string(),
|
||||
},
|
||||
..audit(0, "2026-09-17T08:05:00.000Z", Hash32::ZERO, "mm-thread-42")
|
||||
},
|
||||
AuditRecord {
|
||||
call: CallId(2),
|
||||
tool: "shell".to_string(),
|
||||
arguments: r#"{"command":"df -h"}"#.to_string(),
|
||||
decision: DecisionRecord::Approved {
|
||||
grant: "shell-ask".to_string(),
|
||||
approver: "u8f3k2".to_string(),
|
||||
post: Some("p9x7".to_string()),
|
||||
},
|
||||
..audit(1, "2026-09-17T08:05:01.250Z", seq_hash, "mm-thread-42")
|
||||
},
|
||||
AuditRecord {
|
||||
tool: "consult".to_string(),
|
||||
arguments: r#"{"question":"hi"}"#.to_string(),
|
||||
session_taint: DataClass::Secret,
|
||||
decision: DecisionRecord::Denied {
|
||||
reason: DenyReason::TaintTooHigh,
|
||||
grant: Some("consult-private".to_string()),
|
||||
},
|
||||
..audit(2, "2026-09-17T08:05:02.000Z", rev_hash, "cron-morning")
|
||||
},
|
||||
AuditRecord {
|
||||
call: CallId(2),
|
||||
tool: "rm_rf".to_string(),
|
||||
session_taint: DataClass::Secret,
|
||||
..audit(3, "2026-09-17T08:05:03.000Z", rev_hash, "cron-morning")
|
||||
},
|
||||
];
|
||||
check("audit.jsonl", &want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_log_records() {
|
||||
let want = [
|
||||
LogRecord::SessionStart {
|
||||
time: ts("2026-09-17T08:05:00.000Z"),
|
||||
session: SessionId::new("mm-thread-42").unwrap(),
|
||||
epoch: Epoch(0),
|
||||
slot: 0,
|
||||
baseline: Hash32::from_hex(SEQ_HEX).unwrap(),
|
||||
},
|
||||
LogRecord::User {
|
||||
time: ts("2026-09-17T08:05:01.000Z"),
|
||||
content: "What is in /etc/hosts?".to_string(),
|
||||
},
|
||||
LogRecord::Assistant {
|
||||
time: ts("2026-09-17T08:05:03.000Z"),
|
||||
content: None,
|
||||
reasoning_content: Some("The user wants a file.\nI will read it.".to_string()),
|
||||
tool_calls: vec![ToolCall {
|
||||
id: "call_a1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
arguments: r#"{"path":"/etc/hosts"}"#.to_string(),
|
||||
}],
|
||||
},
|
||||
LogRecord::ToolResult {
|
||||
time: ts("2026-09-17T08:05:04.000Z"),
|
||||
call: CallId(1),
|
||||
tool_call_id: "call_a1".to_string(),
|
||||
content: "127.0.0.1 localhost\n".to_string(),
|
||||
class: DataClass::Private,
|
||||
untrusted: true,
|
||||
truncated: false,
|
||||
},
|
||||
LogRecord::Assistant {
|
||||
time: ts("2026-09-17T08:05:06.000Z"),
|
||||
content: Some("It maps localhost to 127.0.0.1.".to_string()),
|
||||
reasoning_content: None,
|
||||
tool_calls: vec![],
|
||||
},
|
||||
LogRecord::CacheLoss {
|
||||
time: ts("2026-09-17T09:00:00.000Z"),
|
||||
expected: 30695,
|
||||
got: 0,
|
||||
},
|
||||
LogRecord::EpochEnd {
|
||||
time: ts("2026-09-17T12:00:00.000Z"),
|
||||
next: Epoch(1),
|
||||
summary: "Looked at /etc/hosts.".to_string(),
|
||||
},
|
||||
];
|
||||
check("session.jsonl", &want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_and_types_are_rejected() {
|
||||
let user = r#"{"type":"user","time":"2026-09-17T08:05:01.000Z","content":"hi"}"#;
|
||||
assert!(serde_json::from_str::<LogRecord>(user).is_ok());
|
||||
let extra = user.replacen("\"content\"", "\"role\":\"user\",\"content\"", 1);
|
||||
assert!(serde_json::from_str::<LogRecord>(&extra).is_err());
|
||||
let unknown_type = user.replacen("\"user\"", "\"system\"", 1);
|
||||
assert!(serde_json::from_str::<LogRecord>(&unknown_type).is_err());
|
||||
|
||||
let decision = r#"{"outcome":"allowed","grant":"g"}"#;
|
||||
assert!(serde_json::from_str::<DecisionRecord>(decision).is_ok());
|
||||
let extra = decision.replacen("\"grant\"", "\"why\":\"\",\"grant\"", 1);
|
||||
assert!(serde_json::from_str::<DecisionRecord>(&extra).is_err());
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Tests for IPC messages against byte-exact fixtures. Do not edit these or the fixtures.
|
||||
|
||||
use proto::{
|
||||
CallId, DataClass, DenyReason, Envelope, ErrorCode, Message, SessionId, Timestamp, ToolRequest,
|
||||
ToolResponse, WireError,
|
||||
};
|
||||
|
||||
fn fixture(name: &str) -> String {
|
||||
let path = format!("{}/tests/fixtures/wire/{name}", env!("CARGO_MANIFEST_DIR"));
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||
text.trim_end_matches('\n').to_string()
|
||||
}
|
||||
|
||||
/// The fixture must decode to `want`, and `want` must encode to exactly the fixture's bytes.
|
||||
fn check(name: &str, want: Envelope) {
|
||||
let text = fixture(name);
|
||||
let got: Envelope = serde_json::from_str(&text).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(got, want, "{name}: decoded value");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&want).unwrap(),
|
||||
text,
|
||||
"{name}: encoded bytes"
|
||||
);
|
||||
}
|
||||
|
||||
fn response(id: u64, r#final: bool, body: ToolResponse) -> Envelope {
|
||||
Envelope {
|
||||
v: 1,
|
||||
id,
|
||||
r#final,
|
||||
msg: Message::ToolResponse(body),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_request() {
|
||||
let body = ToolRequest {
|
||||
session: SessionId::new("mm-thread-42").unwrap(),
|
||||
call: CallId(3),
|
||||
tool: "read_file".to_string(),
|
||||
arguments: r#"{"path":"/etc/hosts"}"#.to_string(),
|
||||
};
|
||||
check(
|
||||
"tool_request.json",
|
||||
Envelope {
|
||||
v: 1,
|
||||
id: 7,
|
||||
r#final: true,
|
||||
msg: Message::ToolRequest(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_response_pending() {
|
||||
let body = ToolResponse::PendingApproval {
|
||||
approval: "ap-0001".to_string(),
|
||||
expires: Timestamp::parse("2026-09-17T08:35:00.000Z").unwrap(),
|
||||
};
|
||||
check("tool_response_pending.json", response(7, false, body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_response_result() {
|
||||
let body = ToolResponse::Result {
|
||||
content: "127.0.0.1 localhost\n".to_string(),
|
||||
class: DataClass::Private,
|
||||
untrusted: true,
|
||||
truncated: false,
|
||||
};
|
||||
check("tool_response_result.json", response(7, true, body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_response_failed() {
|
||||
let body = ToolResponse::Failed {
|
||||
message: "exit status 2".to_string(),
|
||||
};
|
||||
check("tool_response_failed.json", response(8, true, body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_response_denied() {
|
||||
let body = ToolResponse::Denied {
|
||||
reason: DenyReason::TaintTooHigh,
|
||||
};
|
||||
check("tool_response_denied.json", response(9, true, body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_message() {
|
||||
let body = WireError {
|
||||
code: ErrorCode::BadVersion,
|
||||
detail: "expected 1".to_string(),
|
||||
};
|
||||
check(
|
||||
"error.json",
|
||||
Envelope {
|
||||
v: 1,
|
||||
id: 0,
|
||||
r#final: true,
|
||||
msg: Message::Error(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_reasons_and_error_codes_are_snake_case() {
|
||||
let reasons = [
|
||||
(DenyReason::NoGrant, "no_grant"),
|
||||
(DenyReason::GrantExpired, "grant_expired"),
|
||||
(DenyReason::TaintTooHigh, "taint_too_high"),
|
||||
(DenyReason::DeniedByGrant, "denied_by_grant"),
|
||||
(DenyReason::ApprovalRefused, "approval_refused"),
|
||||
(DenyReason::ApprovalExpired, "approval_expired"),
|
||||
];
|
||||
for (value, text) in reasons {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&value).unwrap(),
|
||||
format!("\"{text}\"")
|
||||
);
|
||||
}
|
||||
let codes = [
|
||||
(ErrorCode::BadFrame, "bad_frame"),
|
||||
(ErrorCode::BadVersion, "bad_version"),
|
||||
(ErrorCode::BadMessage, "bad_message"),
|
||||
(ErrorCode::Internal, "internal"),
|
||||
];
|
||||
for (value, text) in codes {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&value).unwrap(),
|
||||
format!("\"{text}\"")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_missing_fields_are_rejected() {
|
||||
let good = fixture("tool_request.json");
|
||||
assert!(serde_json::from_str::<Envelope>(&good).is_ok());
|
||||
let bad = [
|
||||
// extra field in the envelope
|
||||
good.replacen("{\"v\":1,", "{\"v\":1,\"extra\":0,", 1),
|
||||
// extra field beside kind and body
|
||||
good.replacen(
|
||||
"\"kind\":\"tool_request\",",
|
||||
"\"kind\":\"tool_request\",\"x\":1,",
|
||||
1,
|
||||
),
|
||||
// extra field in the body
|
||||
good.replacen("\"call\":3,", "\"call\":3,\"priority\":9,", 1),
|
||||
// missing field in the body
|
||||
good.replacen("\"call\":3,", "", 1),
|
||||
// missing `final`
|
||||
good.replacen("\"final\":true,", "", 1),
|
||||
// unknown kind
|
||||
good.replacen("tool_request", "tool_demand", 1),
|
||||
// invalid session id inside a message
|
||||
good.replacen("mm-thread-42", "../../etc", 1),
|
||||
];
|
||||
for text in bad {
|
||||
assert!(
|
||||
serde_json::from_str::<Envelope>(&text).is_err(),
|
||||
"accepted {text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_field_in_a_response_variant_is_rejected() {
|
||||
let good = fixture("tool_response_denied.json");
|
||||
let bad = good.replacen("\"reason\":", "\"note\":\"x\",\"reason\":", 1);
|
||||
assert!(serde_json::from_str::<Envelope>(&good).is_ok());
|
||||
assert!(serde_json::from_str::<Envelope>(&bad).is_err());
|
||||
let bad_status = good.replacen("denied", "refused", 1);
|
||||
assert!(serde_json::from_str::<Envelope>(&bad_status).is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user