M5b plan: task 01, the invalid-arguments detail
Task file and 13 given files (2 new: the wire fixture with a detail and brokerd/tests/invalid_detail.rs; 11 new versions of existing tests, most only adding `detail: None`). Checked against a reference in a scratch worktree with its own target/, on titan (rustc 1.98.1, clippy 0.1.98): make gate prints gate: ok with 956 tests (944 + 12 new); /tmp has no new entries after the gate. All 21 quoted before-blocks occur once in master, and applying the edits to master gives the reference byte for byte. The given files are the reference's, formatted, none over 500 lines; no chat-template tag in the plan. Before the edits, strict's envelopes_reject_unknown_keys_at_every_depth fails and ten test targets do not compile, as step 2 lists. Mutations: 21 one-line breaks of the reference; 20 caught by the given tests. The one not caught (dropping `default` from the detail attribute) cannot change behaviour: serde reads a missing Option field as None. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
@@ -0,0 +1 @@
|
||||
{"v":1,"id":9,"final":true,"msg":{"kind":"tool_response","body":{"status":"denied","reason":"invalid_arguments","detail":"invalid argument shape: unknown field `file`, expected `text`"}}}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Every JSON object in every fixture must reject an unknown key. Do not edit.
|
||||
//!
|
||||
//! The other test files check unknown fields in a few hand-picked places. This one checks all of
|
||||
//! them: it walks each fixture, adds one unknown key to one object at a time, at every depth, and
|
||||
//! requires that the result no longer decodes.
|
||||
|
||||
use bxcore::{AuditRecord, Envelope, Grant, LogRecord};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Every copy of `value` that has exactly one extra key in exactly one object.
|
||||
fn with_one_unknown_key(value: &Value) -> Vec<Value> {
|
||||
let mut out = Vec::new();
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let mut extended = map.clone();
|
||||
extended.insert("zz_unknown".to_string(), Value::Bool(true));
|
||||
out.push(Value::Object(extended));
|
||||
for (key, child) in map {
|
||||
for changed in with_one_unknown_key(child) {
|
||||
let mut copy = map.clone();
|
||||
copy.insert(key.clone(), changed);
|
||||
out.push(Value::Object(copy));
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for (i, child) in items.iter().enumerate() {
|
||||
for changed in with_one_unknown_key(child) {
|
||||
let mut copy = items.clone();
|
||||
copy[i] = changed;
|
||||
out.push(Value::Array(copy));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Returns how many variations were tried, so callers can check the walk reached nested objects.
|
||||
fn check<T: DeserializeOwned>(what: &str, text: &str) -> usize {
|
||||
let value: Value = serde_json::from_str(text).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert!(
|
||||
serde_json::from_value::<T>(value.clone()).is_ok(),
|
||||
"{what}: fixture must decode"
|
||||
);
|
||||
let variations = with_one_unknown_key(&value);
|
||||
for changed in &variations {
|
||||
assert!(
|
||||
serde_json::from_value::<T>(changed.clone()).is_err(),
|
||||
"{what}: accepted an unknown key: {changed}"
|
||||
);
|
||||
}
|
||||
variations.len()
|
||||
}
|
||||
|
||||
fn fixture(path: &str) -> String {
|
||||
let full = format!("{}/tests/fixtures/{path}", env!("CARGO_MANIFEST_DIR"));
|
||||
std::fs::read_to_string(&full).unwrap_or_else(|e| panic!("{full}: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelopes_reject_unknown_keys_at_every_depth() {
|
||||
for name in [
|
||||
"tool_request.json",
|
||||
"tool_response_pending.json",
|
||||
"tool_response_result.json",
|
||||
"tool_response_failed.json",
|
||||
"tool_response_denied.json",
|
||||
"tool_response_denied_detail.json",
|
||||
"error.json",
|
||||
"turn.json",
|
||||
"turn_event_tool_result.json",
|
||||
"turn_event_content.json",
|
||||
"turn_event_retrying.json",
|
||||
"turn_done.json",
|
||||
"error_session_full.json",
|
||||
"approvals.json",
|
||||
"approval_list.json",
|
||||
"approval_list_empty.json",
|
||||
"approve.json",
|
||||
"approve_result_allowed.json",
|
||||
"approve_result_denied.json",
|
||||
"refuse.json",
|
||||
"refuse_no_reason.json",
|
||||
"ok.json",
|
||||
"check_grants.json",
|
||||
"grants_report.json",
|
||||
"grants_report_ok.json",
|
||||
"error_forbidden.json",
|
||||
"error_no_such_approval.json",
|
||||
"turn_event_approval_pending.json",
|
||||
"turn_event_tool_denied.json",
|
||||
] {
|
||||
// Envelope, msg and body: three objects. Some bodies hold more: turn_done a usage
|
||||
// object, approval_list one item, approve_result an outcome, grants_report two problems.
|
||||
let want = match name {
|
||||
"turn_done.json" | "approval_list.json" => 4,
|
||||
"approve_result_allowed.json" | "approve_result_denied.json" => 4,
|
||||
"grants_report.json" => 5,
|
||||
_ => 3,
|
||||
};
|
||||
assert_eq!(
|
||||
check::<Envelope>(name, &fixture(&format!("wire/{name}"))),
|
||||
want,
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_records_reject_unknown_keys_at_every_depth() {
|
||||
for (i, line) in fixture("records/audit.jsonl").lines().enumerate() {
|
||||
// The record and its event: two objects. Decision and approval events also hold an
|
||||
// outcome object.
|
||||
let want = if line.contains("\"outcome\"") { 3 } else { 2 };
|
||||
assert_eq!(
|
||||
check::<AuditRecord>(&format!("audit.jsonl:{}", i + 1), line),
|
||||
want
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_records_reject_unknown_keys_at_every_depth() {
|
||||
let mut tried = 0;
|
||||
for (i, line) in fixture("records/session.jsonl").lines().enumerate() {
|
||||
tried += check::<LogRecord>(&format!("session.jsonl:{}", i + 1), line);
|
||||
}
|
||||
// Seven records, plus the one tool call inside the first assistant record.
|
||||
assert_eq!(tried, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_log_records_reject_unknown_keys_at_every_depth() {
|
||||
let mut tried = 0;
|
||||
for (i, line) in fixture("records/session_usage.jsonl").lines().enumerate() {
|
||||
tried += check::<LogRecord>(&format!("session_usage.jsonl:{}", i + 1), line);
|
||||
}
|
||||
// Seven records, plus the one tool call inside the first assistant record.
|
||||
assert_eq!(tried, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grants_reject_unknown_keys_at_every_depth() {
|
||||
let grant: Grant = toml::from_str(&fixture("grant/full.toml")).unwrap();
|
||||
let text = serde_json::to_string(&grant).unwrap();
|
||||
// The grant and its constraints: two objects.
|
||||
assert_eq!(check::<Grant>("full.toml as JSON", &text), 2);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Tests for IPC messages against byte-exact fixtures. Do not edit these or the fixtures.
|
||||
|
||||
use bxcore::{
|
||||
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: 41,
|
||||
expires: Timestamp::parse("2026-09-17T08:35:00.000Z").unwrap(),
|
||||
};
|
||||
check("tool_response_pending.json", response(7, false, body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_approval_id_is_a_number_not_a_string() {
|
||||
let good = fixture("tool_response_pending.json");
|
||||
let bad = good.replacen("\"approval\":41", "\"approval\":\"41\"", 1);
|
||||
assert_ne!(good, bad);
|
||||
assert!(serde_json::from_str::<Envelope>(&good).is_ok());
|
||||
assert!(serde_json::from_str::<Envelope>(&bad).is_err());
|
||||
let negative = good.replacen("\"approval\":41", "\"approval\":-1", 1);
|
||||
assert!(serde_json::from_str::<Envelope>(&negative).is_err());
|
||||
}
|
||||
|
||||
#[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,
|
||||
detail: None,
|
||||
};
|
||||
check("tool_response_denied.json", response(9, true, body));
|
||||
}
|
||||
|
||||
/// A detail is written only when there is one, so the fixture above has no `detail` key.
|
||||
#[test]
|
||||
fn tool_response_denied_with_a_detail() {
|
||||
let body = ToolResponse::Denied {
|
||||
reason: DenyReason::InvalidArguments,
|
||||
detail: Some("invalid argument shape: unknown field `file`, expected `text`".to_string()),
|
||||
};
|
||||
check("tool_response_denied_detail.json", response(9, true, body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_detail_must_be_a_string() {
|
||||
let good = fixture("tool_response_denied_detail.json");
|
||||
assert!(serde_json::from_str::<Envelope>(&good).is_ok());
|
||||
let at = good.find("\"detail\":").unwrap();
|
||||
for value in ["7", "[\"a\"]", "{}", "true"] {
|
||||
let bad = format!("{}\"detail\":{value}}}}}}}", &good[..at]);
|
||||
assert!(
|
||||
serde_json::from_str::<Envelope>(&bad).is_err(),
|
||||
"accepted {bad}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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"),
|
||||
(DenyReason::GrantsInvalid, "grants_invalid"),
|
||||
(DenyReason::AuditUnavailable, "audit_unavailable"),
|
||||
(DenyReason::InvalidArguments, "invalid_arguments"),
|
||||
(DenyReason::StateUnreadable, "state_unreadable"),
|
||||
];
|
||||
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"),
|
||||
(ErrorCode::Forbidden, "forbidden"),
|
||||
(ErrorCode::NoSuchApproval, "no_such_approval"),
|
||||
];
|
||||
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