//! The assistant gate decision table (SPEC 25, 10.3): every command class × //! `allow_write` × `allow_delete` × `review_writes` × session grant × source. //! //! The table is generated: seven command classes crossed with 24 flag //! combinations, 168 rows in all, each asserted against the outcome the SPEC //! paragraphs spell out. #![allow(dead_code)] // shared fixtures: only some helpers are used here use notescraft_engine::{AssistantGate, AssistantSettings, Decision, GateSource, Registry, Session, register_core}; use serde_json::{Value, json}; mod common; use common::TempVault; /// A vault seeded the same way for every row. fn fixture(name: &str) -> (TempVault, Session) { let vault = common::temp_vault(name); match Session::open(vault.path()) { Ok(session) => (vault, session), Err(error) => panic!("the test vault must open: {error}"), } } /// One row of the decision table: (id, params, kind-of-command). fn params_for(id: &str) -> Value { match id { "note.write" => json!({ "path": "alpha.md", "content": "replaced" }), "note.delete" | "note.read" => json!({ "path": "alpha.md" }), "note.property_set" => json!({ "path": "alpha.md", "key": "status", "value": "done" }), "folder.create" => json!({ "folder": "new-folder" }), _ => Value::Null, } } /// Whether `id` deletes (the extra flag), writes or reads, per the gate's own /// tables. fn class_of(id: &str) -> &'static str { if notescraft_engine::DELETE_COMMANDS.contains(&id) { return "delete"; } if notescraft_engine::WRITE_COMMANDS.contains(&id) { return "write"; } if id.starts_with("plugin.") || id == "agent.magic" { return "unknown"; } if id.starts_with("assistant.") { return "assistant-self"; } "read" } /// The outcome the SPEC spells out for one row, derived here independently of /// the gate code under test. fn expected(settings: &AssistantSettings, granted: bool, source: GateSource, id: &str) -> &'static str { if !source.is_gated() { return "allow"; } if id.starts_with("plugin.") { return "deny"; } match class_of(id) { "read" | "assistant-self" => "allow", "unknown" | "write" | "delete" => { if !(settings.allow_write || granted) { return "deny"; } if class_of(id) == "delete" && !settings.allow_delete { return "deny"; } if settings.review_writes { "queue" } else { "allow" } } _ => "deny", } } /// The rows: every class crossed with every flag combination. #[test] fn the_decision_table_holds_for_every_class_and_flag_combination() { let mut registry = Registry::new(); register_core(&mut registry).ok(); let classes = ["note.read", "note.write", "note.delete", "note.property_set", "folder.create", "plugin.demo.go", "assistant.status"]; let mut rows = 0usize; for allow_write in [false, true] { for allow_delete in [false, true] { for review in [false, true] { for granted in [false, true] { let config = AssistantSettings { allow_write, allow_delete, review_writes: review, plugin_tools: [("demo".to_owned(), false)].into_iter().collect(), ..AssistantSettings::default() }; for source in [GateSource::Assistant, GateSource::Cli, GateSource::Ui] { let name = "assistant-table"; let (_vault, mut session) = fixture(name); session.grant_session_write(granted); session.set_assistant_settings(config.clone()); for id in classes { let decision = AssistantGate::check(source, &session, id, ¶ms_for(id)); let want = expected(&config, granted, source, id); let got = match decision { Decision::Allow => "allow", Decision::Deny(_) => "deny", Decision::Queue(_) => "queue", }; assert_eq!( got, want, "row {id}: source={} allow_write={allow_write} allow_delete={allow_delete} review={review} granted={granted}", source.name() ); if let Decision::Deny(reason) = decision { let is_self = class_of(id) == "assistant-self"; assert!( is_self || reason == notescraft_engine::assistant_gate::DENIED_WRITES || reason == notescraft_engine::assistant_gate::DENIED_DELETES || reason.contains("off"), "the reason is the flag that is missing: {reason}" ); } rows = rows.saturating_add(1); } // The forbidden trio is refused whatever the flags say. for id in ["assistant.accept", "assistant.reject", "assistant.grant_write"] { let decision = AssistantGate::check(source, &session, id, &json!({ "id": 1 })); assert!(matches!(decision, Decision::Deny(_)) || !source.is_gated(), "{id} denied for the agent, pass-through for the user"); rows = rows.saturating_add(1); } let _ = notescraft_engine::run_as_assistant(&mut session, ®istry, "vault.info", &Value::Null).ok(); rows = rows.saturating_add(1); } } } } } assert!(rows >= 60, "the table must hold at least 60 rows, held {rows}"); } /// A plugin tool with its toggle on passes the gate; off or missing stays denied. #[test] fn a_plugin_tool_needs_its_own_toggle() { let (_vault, mut session) = fixture("assistant-plugin-toggle"); for toggle in [false, true] { let config = AssistantSettings { allow_write: true, review_writes: false, plugin_tools: [("demo".to_owned(), toggle)].into_iter().collect(), ..AssistantSettings::default() }; session.set_assistant_settings(config); let decision = AssistantGate::check(GateSource::Assistant, &session, "plugin.demo.go", &Value::Null); assert_eq!(matches!(decision, Decision::Allow), toggle, "toggle {toggle}"); } let no_entry = AssistantGate::check(GateSource::Assistant, &session, "plugin.other.go", &Value::Null); assert!(matches!(no_entry, Decision::Deny(_)), "a plugin nobody toggled is denied"); } /// A command the registry does not know is treated as a write, so a future /// engine command cannot slip past the gate unreviewed. #[test] fn an_unknown_command_is_treated_as_a_write() { let config = AssistantSettings { allow_write: true, review_writes: false, ..AssistantSettings::default() }; let (_vault, mut session) = fixture("assistant-unknown"); session.set_assistant_settings(config.clone()); let allowed = AssistantGate::check(GateSource::Assistant, &session, "agent.magic", &Value::Null); assert!(matches!(allowed, Decision::Allow)); session.set_assistant_settings(AssistantSettings { allow_write: false, ..config }); let refused = AssistantGate::check(GateSource::Assistant, &session, "agent.magic", &Value::Null); match refused { Decision::Deny(reason) => assert_eq!(reason, notescraft_engine::assistant_gate::DENIED_WRITES), _ => panic!("an unknown command is a write and needs allow_write"), } }