178 lines
8.0 KiB
Rust
178 lines
8.0 KiB
Rust
//! 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"),
|
||
}
|
||
}
|