Files
2026-10-15 02:00:16 -03:00

178 lines
8.0 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, &params_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, &registry, "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"),
}
}