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:
2026-09-17 01:22:43 -07:00
co-authored by Claude Fable 5.1
parent 19104a9629
commit 3e26c2e3c0
36 changed files with 2129 additions and 2 deletions
@@ -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\"}"}}}
@@ -0,0 +1 @@
{"v":1,"id":9,"final":true,"msg":{"kind":"tool_response","body":{"status":"denied","reason":"taint_too_high"}}}
@@ -0,0 +1 @@
{"v":1,"id":8,"final":true,"msg":{"kind":"tool_response","body":{"status":"failed","message":"exit status 2"}}}
@@ -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"}}}
@@ -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());
}