diff --git a/Cargo.lock b/Cargo.lock index 63bcf0e..86e8082 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,6 +16,12 @@ dependencies = [ "proto", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "gatewayd" version = "0.1.0" @@ -23,12 +29,28 @@ dependencies = [ "proto", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "humantime" version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "inferproxy" version = "0.1.0" @@ -71,6 +93,7 @@ dependencies = [ "humantime", "serde", "serde_json", + "toml", ] [[package]] @@ -125,6 +148,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "syn" version = "3.0.6" @@ -136,6 +168,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "toolkit" version = "0.1.0" @@ -149,6 +220,12 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 5061dd6..47dfb17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,4 +23,5 @@ proto = { path = "crates/proto" } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" humantime = "2.4.0" +toml = "1.1.6" diff --git a/crates/proto/Cargo.toml b/crates/proto/Cargo.toml index 44847a4..3776b51 100644 --- a/crates/proto/Cargo.toml +++ b/crates/proto/Cargo.toml @@ -13,3 +13,6 @@ serde.workspace = true serde_json.workspace = true humantime.workspace = true +[dev-dependencies] +toml.workspace = true + diff --git a/crates/proto/src/grant.rs b/crates/proto/src/grant.rs new file mode 100644 index 0000000..f37610d --- /dev/null +++ b/crates/proto/src/grant.rs @@ -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, + #[serde(default)] + pub hosts: Vec, + #[serde(default)] + pub patterns: Vec, +} + +#[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, + #[serde(default)] + pub secret: Option, + #[serde(default)] + pub constraints: Constraints, +} + +fn default_result_class() -> DataClass { + DataClass::Private +} + +fn default_untrusted() -> bool { + true +} diff --git a/crates/proto/src/lib.rs b/crates/proto/src/lib.rs index e388ae9..d7df0fd 100644 --- a/crates/proto/src/lib.rs +++ b/crates/proto/src/lib.rs @@ -2,11 +2,13 @@ pub mod class; pub mod frame; +pub mod grant; pub mod ids; pub mod wire; pub use class::DataClass; pub use frame::{FrameError, MAX_FRAME, read_frame, write_frame}; +pub use grant::{Constraints, Grant, Mode}; pub use ids::{CallId, Epoch, Hash32, SessionId, Timestamp, ValueError}; pub use wire::{ DenyReason, Envelope, ErrorCode, Message, PROTOCOL_VERSION, ToolRequest, ToolResponse, diff --git a/crates/proto/tests/fixtures/grant/bad_mode.toml b/crates/proto/tests/fixtures/grant/bad_mode.toml new file mode 100644 index 0000000..6b32325 --- /dev/null +++ b/crates/proto/tests/fixtures/grant/bad_mode.toml @@ -0,0 +1,3 @@ +tool = "read_file" +mode = "always" +max_taint = "private" diff --git a/crates/proto/tests/fixtures/grant/full.toml b/crates/proto/tests/fixtures/grant/full.toml new file mode 100644 index 0000000..7bcc968 --- /dev/null +++ b/crates/proto/tests/fixtures/grant/full.toml @@ -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 "] diff --git a/crates/proto/tests/fixtures/grant/minimal.toml b/crates/proto/tests/fixtures/grant/minimal.toml new file mode 100644 index 0000000..03e3920 --- /dev/null +++ b/crates/proto/tests/fixtures/grant/minimal.toml @@ -0,0 +1,3 @@ +tool = "read_file" +mode = "auto" +max_taint = "private" diff --git a/crates/proto/tests/fixtures/grant/missing_max_taint.toml b/crates/proto/tests/fixtures/grant/missing_max_taint.toml new file mode 100644 index 0000000..5535611 --- /dev/null +++ b/crates/proto/tests/fixtures/grant/missing_max_taint.toml @@ -0,0 +1,2 @@ +tool = "read_file" +mode = "auto" diff --git a/crates/proto/tests/fixtures/grant/unknown_constraint.toml b/crates/proto/tests/fixtures/grant/unknown_constraint.toml new file mode 100644 index 0000000..5d9a414 --- /dev/null +++ b/crates/proto/tests/fixtures/grant/unknown_constraint.toml @@ -0,0 +1,6 @@ +tool = "read_file" +mode = "auto" +max_taint = "private" + +[constraints] +path = ["/etc/**"] diff --git a/crates/proto/tests/fixtures/grant/unknown_field.toml b/crates/proto/tests/fixtures/grant/unknown_field.toml new file mode 100644 index 0000000..aca9ced --- /dev/null +++ b/crates/proto/tests/fixtures/grant/unknown_field.toml @@ -0,0 +1,4 @@ +tool = "read_file" +mode = "auto" +max_taint = "private" +max_tiant = "secret" diff --git a/crates/proto/tests/grant.rs b/crates/proto/tests/grant.rs new file mode 100644 index 0000000..088b40b --- /dev/null +++ b/crates/proto/tests/grant.rs @@ -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 { + 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::::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}\"")); + } +} diff --git a/docs/dependencies.md b/docs/dependencies.md index 722214f..183f098 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -7,3 +7,4 @@ Every external crate has a row here. `scripts/check-dep-docs.sh` enforces it. | `serde` | 1.0.229 | `proto` | Derives serialization for every shared type. MIT OR Apache-2.0. | | `serde_json` | 1.0.151 | `proto` | JSON for frames and log files. MIT OR Apache-2.0. | | `humantime` | 2.4.0 | `proto` | RFC 3339 timestamps. No dependencies of its own. MIT OR Apache-2.0. | +| `toml` | 1.1.6 | `proto` tests; `brokerd` and `bxctl` from M3 | Parses grant files. MIT OR Apache-2.0. | diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 2e88861..60ae1d4 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -9,5 +9,6 @@ reviewer adds findings under "Reviews" once per milestone. | M1/02-proto-values | 2026-09-17 | done | 1 | pass | none | Implemented ValueError, SessionId, Epoch, CallId, Hash32 and Timestamp in crates/proto/src/ids.rs and DataClass in class.rs, using serde try_from/into for string-backed JSON validation, a hand-written hex encoder and humantime for RFC 3339 parsing with canonical re-serialization. | | M1/03-proto-wire | 2026-09-17 | done | 2 | pass | none | Added Envelope, Message, WireError, ErrorCode, ToolRequest, ToolResponse and DenyReason in crates/proto/src/wire.rs, re-exported from lib.rs; all 9 fixture tests pass and `make gate` prints `gate: ok`. | | M1/04-proto-frame | 2026-09-17 | done | 2 | fail | none | Added crates/proto/src/frame.rs (MAX_FRAME, FrameError, write_frame, read_frame) re-exported from lib.rs; 13 fixture tests pass. Two compile fixes: mapped read_bytes io::Error to FrameError::Io and annotated serde_json::from_slice::; cargo-fmt reordered the lib.rs re-export lines; `make gate` prints `gate: ok`. | +| M1/05-proto-grant | 2026-09-17 | done | 2 | fail | none | Added crates/proto/src/grant.rs (Mode, Constraints with Default, Grant with serde defaults + deny_unknown_fields) re-exported from lib.rs and toml 1.1.6 as a proto dev-dependency (workspace dep + dependencies.md row); 4 fixture tests pass. cargo-fmt reordered the lib.rs re-exports before the gate. | ## Reviews