//! An invalid call is denied with the parser's reason: `ArgsError::detail` makes it one short //! line, `policy::decide` keeps it, and `broker.sock` sends it. The audit record is unchanged. //! Do not edit. #[path = "support/build.rs"] mod build; #[path = "support/client.rs"] mod client; #[path = "support/rig.rs"] mod rig; #[path = "support/runtime.rs"] mod runtime; #[path = "support/sink.rs"] mod sink; #[path = "support/tmp.rs"] mod tmp; use brokerd::args::{ArgsError, MAX_DETAIL}; use brokerd::policy::decide; use build::{denied, fetch, grant, now, private, read, set, shell}; use bxcore::{AuditEvent, DecisionRecord, DenyReason, Envelope, Message, Mode, ToolResponse}; use client::{Serve, call}; use rig::{Rig, grant_text}; use runtime::Recording; #[test] fn the_detail_is_the_error_text() { assert_eq!(MAX_DETAIL, 300); let cases = [ ( ArgsError::Shape("missing field `path`".to_string()), "invalid argument shape: missing field `path`", ), ( ArgsError::Path("notes/a.md".to_string()), "invalid path: notes/a.md", ), ( ArgsError::Url("http://example.com/".to_string()), "invalid url: http://example.com/", ), ]; for (error, want) in cases { assert_eq!(error.detail(), want); assert_eq!( error.detail(), error.to_string(), "a short clean text is kept as it is" ); } } /// Every control character, and U+2028 and U+2029 (the line and paragraph separators), becomes /// one space each; other characters, spaces and non-ASCII letters included, are kept. #[test] fn the_detail_is_one_line() { let error = ArgsError::Path( "a\nb\r\nc\td\u{7}e\u{1b}[31mf\u{7f}g\u{85}h\u{2028}i\u{2029}j k \u{e9}\u{65e5}" .to_string(), ); assert_eq!( error.detail(), "invalid path: a b c d e [31mf g h i j k \u{e9}\u{65e5}" ); let error = ArgsError::Shape("line one\nline two\n".to_string()); assert_eq!(error.detail(), "invalid argument shape: line one line two "); } /// At most `MAX_DETAIL` characters, counted as characters, not bytes: a cut never splits one. #[test] fn the_detail_is_cut_to_300_characters() { // "invalid path: " is 14 characters. let exact = ArgsError::Path("a".repeat(286)); assert_eq!( exact.detail(), exact.to_string(), "300 characters are kept whole" ); let over = ArgsError::Path("a".repeat(287)); assert_eq!(over.detail(), format!("invalid path: {}", "a".repeat(286))); let wide = ArgsError::Path("\u{e9}".repeat(1000)); let detail = wide.detail(); assert_eq!(detail.chars().count(), 300); assert_eq!(detail, format!("invalid path: {}", "\u{e9}".repeat(286))); // Newlines past the cut do not matter; newlines before it are spaces. let long = ArgsError::Shape(format!("{}\n{}", "x".repeat(10), "y\n".repeat(500))); let detail = long.detail(); assert_eq!(detail.chars().count(), 300); assert!(!detail.contains('\n'), "{detail:?}"); assert!(detail.starts_with("invalid argument shape: xxxxxxxxxx y y")); } /// Each kind of `ArgsError`, from the model's own argument text, reaches the denial as its detail, /// with no grant and with a grant that would cover the call: arguments are checked first. (The /// memory tools take no grant: their policy is built in.) #[test] fn each_kind_of_invalid_arguments_carries_its_detail() { let covering = set(vec![ grant("r", "read_file", Mode::Auto).paths(&["/n"]), grant("s", "shell", Mode::Auto), grant("f", "http_fetch", Mode::Auto).hosts(&["example.com"]), ]); let none = set(vec![]); let cases = [ ( build::request( "memory", r#"{"action":"note","file":"notes.md","text":"x"}"#, ), "invalid argument shape: unknown field `file`, expected `text`", ), ( build::request("read_file", r#"{"path":"/n/a","file":"x"}"#), "invalid argument shape: unknown field `file`, expected `path` at line 1 column 21", ), ( build::request("read_file", "not json"), "invalid argument shape: expected ident at line 1 column 2", ), ( build::request("read_file", "{}"), "invalid argument shape: missing field `path` at line 1 column 2", ), ( build::request("recall", r#"{"query":""}"#), "invalid argument shape: query must have 1 to 500 characters", ), (read("n/a"), "invalid path: n/a"), (shell(Some("a\\nb")), "invalid path: a b"), ( fetch("http://example.com/"), "invalid url: http://example.com/", ), ]; for grants in [&none, &covering] { for (req, want) in &cases { let text = req.arguments.clone(); let denial = denied(decide(req.clone(), grants, private(), now())); assert_eq!(denial.reason, DenyReason::InvalidArguments, "{text}"); assert_eq!(denial.detail.as_deref(), Some(*want), "{text}"); assert_eq!(denial.grant, None, "{text}"); } } } #[test] fn a_long_invalid_path_is_cut_in_the_denial() { let path = format!("/{}", "a".repeat(5000)); let denial = denied(decide(read(&path), &set(vec![]), private(), now())); assert_eq!(denial.reason, DenyReason::InvalidArguments); let detail = denial.detail.unwrap(); assert_eq!(detail.chars().count(), 300); assert_eq!(detail, format!("invalid path: /{}", "a".repeat(285))); } /// Only an invalid-arguments denial has a detail. #[test] fn other_denials_have_no_detail() { let grants = set(vec![ grant("no", "read_file", Mode::Deny).paths(&["/n"]), grant("old", "shell", Mode::Auto).expires("2026-09-01T00:00:00.000Z"), ]); let cases = [ (build::request("echo", "not json"), DenyReason::NoGrant), (fetch("https://example.com/"), DenyReason::NoGrant), (read("/n/a"), DenyReason::DeniedByGrant), (shell(None), DenyReason::GrantExpired), ]; for (req, reason) in cases { let text = req.arguments.clone(); let denial = denied(decide(req, &grants, private(), now())); assert_eq!(denial.reason, reason, "{text}"); assert_eq!(denial.detail, None, "{text}"); } } fn only(frames: &[Envelope]) -> &ToolResponse { assert_eq!(frames.len(), 1, "{frames:?}"); assert!(frames[0].r#final); match &frames[0].msg { Message::ToolResponse(r) => r, other => panic!("{other:?}"), } } #[test] fn broker_sock_sends_the_detail_and_the_audit_record_does_not_hold_it() { let rig = Rig::new("broker-invalid-detail"); rig.grant( "notes", &grant_text("read_file", "auto", "", "paths = [\"/n\"]"), ); let rt = Recording::answering("x"); let broker = rig.broker(&rt); let arguments = r#"{"path":"/n/a","file":"x"}"#; let frames = call(&broker, rig::request("s1", 5, "read_file", arguments)); let detail = "invalid argument shape: unknown field `file`, expected `path` at line 1 column 21"; assert_eq!( only(&frames), &ToolResponse::Denied { reason: DenyReason::InvalidArguments, detail: Some(detail.to_string()), } ); assert_eq!(rt.count(), 0, "nothing ran"); match rig.events().as_slice() { [ AuditEvent::Decision { outcome, arguments: recorded, .. }, ] => { assert_eq!( *outcome, DecisionRecord::Denied { reason: DenyReason::InvalidArguments } ); assert_eq!(recorded, arguments, "the record keeps the arguments"); } other => panic!("{other:?}"), } let audit = rig.cfg.audit_dir(); let mut logs = 0; for entry in std::fs::read_dir(&audit).unwrap() { let path = entry.unwrap().path(); if path.extension().is_none_or(|e| e != "jsonl") { continue; } logs += 1; let text = std::fs::read_to_string(&path).unwrap(); assert!(!text.contains("unknown field"), "{text}"); assert!(!text.contains("\"detail\""), "{text}"); } assert_eq!(logs, 1, "one audit log file"); } #[test] fn a_call_no_grant_allows_is_denied_on_broker_sock_without_a_detail() { let rig = Rig::new("broker-no-detail"); let rt = Recording::answering("x"); let broker = rig.broker(&rt); let frames = call( &broker, rig::request("s1", 6, "read_file", r#"{"path":"/n/a"}"#), ); assert_eq!( only(&frames), &ToolResponse::Denied { reason: DenyReason::NoGrant, detail: None, } ); }