use super::super::{ChannelApproval, StreamMsg};
use super::support::{composed_facts, side_effecting_tool};
use crate::approval_facts::ApprovalFacts;
use crate::grant_token::TurnPrimary;
use crate::prompt_approval::TerminalApproval;
use saya_agent::{
ApprovalChoice, ApprovalDecider, ApprovalDecision, ApprovalPolicy, SessionPolicy,
};
use tokio::sync::mpsc::unbounded_channel;
#[tokio::test]
async fn a_denied_name_preempts_a_held_grant_without_asking() {
use crate::interactive::session_definitions;
for mode in [ApprovalPolicy::Ask, ApprovalPolicy::Bypass] {
let tool = session_definitions::run_command();
let arguments = serde_json::json!({"program": "curl"});
let policy = SessionPolicy::new(mode);
policy.grants().grant("command:curl");
let mut facts = composed_facts();
facts.host = Some(crate::approval_facts::HostFacts::for_tests());
facts.denied_programs = vec!["curl".to_owned()];
assert_eq!(
policy.resolve(&tool.effect, Some("command:curl")),
ApprovalDecision::Allow,
"the held grant resolves Allow under {mode:?}: deny preempts after the engine"
);
let terminal = TerminalApproval::from_session(
policy.clone(),
mode == ApprovalPolicy::Ask,
TurnPrimary::default(),
facts.clone(),
None,
);
assert!(
!terminal.approve(&tool, &arguments).await,
"a denied name refuses under {mode:?} even with the grant held"
);
assert!(
terminal
.refusal_detail(&tool, &arguments)
.is_some_and(|detail| detail.contains("deny list")
&& detail.contains("allowed programs may still invoke it")),
"the terminal decider words the denial with the typed refusal under {mode:?}"
);
let (tx, rx) = unbounded_channel();
drop(rx);
let channel = ChannelApproval::new(
tx,
policy.clone(),
TurnPrimary::default(),
facts.clone(),
None,
);
assert!(
!channel.approve(&tool, &arguments).await,
"the TUI refuses a denied name under {mode:?} even with the grant held"
);
assert!(
channel
.refusal_detail(&tool, &arguments)
.is_some_and(|detail| detail.contains("deny list")
&& detail.contains("allowed programs may still invoke it")),
"the TUI decider words the denial with the typed refusal under {mode:?}"
);
}
}
#[tokio::test]
async fn the_stdin_gate_still_denies_without_reading_stdin() {
let tool = side_effecting_tool();
let arguments = serde_json::json!({});
let no_stdin = TerminalApproval::new(
ApprovalPolicy::Ask,
false,
TurnPrimary::default(),
ApprovalFacts::default(),
);
assert!(
!no_stdin.approve(&tool, &arguments).await,
"an ungranted ask with no stdin surface denies"
);
for mode in [ApprovalPolicy::Never, ApprovalPolicy::ReadOnly] {
let decider = TerminalApproval::new(
mode,
false,
TurnPrimary::default(),
ApprovalFacts::default(),
);
assert!(
!decider.approve(&tool, &arguments).await,
"{mode:?} denies regardless of the stdin surface"
);
}
}
#[tokio::test]
async fn a_prompted_grant_journals_once_before_the_call_it_allowed_runs() {
let dir = std::env::temp_dir().join(format!("saya-tui-journal-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("state dir creates");
let journal = std::sync::Arc::new(saya_store::SessionJournal::open(&dir));
let (tx, mut rx) = unbounded_channel();
let decider = ChannelApproval::new(
tx,
SessionPolicy::new(ApprovalPolicy::Ask),
TurnPrimary::default(),
composed_facts(),
Some(journal.clone()),
);
let tool = crate::interactive::session_definitions::workspace_write();
let arguments = serde_json::json!({"path": "notes.md", "content": "hello"});
let answerer = tokio::spawn(async move {
if let Some(StreamMsg::ApprovalRequest { respond, grant, .. }) = rx.recv().await {
assert_eq!(grant.as_deref(), Some("workspace-write"));
let _ = respond.send(ApprovalChoice::AllowSession {
token: grant.expect("the ask offered a token"),
});
}
});
assert!(
decider.approve(&tool, &arguments).await,
"the user's session grant allows the call that asked"
);
answerer.await.expect("the answerer completes");
assert_eq!(
journal.read().expect("the journal reads"),
vec![saya_store::JournalEvent::Granted {
token: "workspace-write".to_owned(),
source: saya_store::GrantSource::Prompt,
}],
"one line, source prompt, written before the call it allowed runs"
);
assert!(
decider.approve(&tool, &arguments).await,
"the granted token pre-answers the next call of the same shape"
);
assert_eq!(
journal.read().expect("the journal reads").len(),
1,
"a second grant of the same token writes none"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn a_failed_journal_write_says_so_and_does_not_take_the_call_down() {
let dir = std::env::temp_dir().join(format!("saya-tui-journal-fail-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("state dir creates");
std::fs::create_dir_all(dir.join("journal.ndjson")).expect("the block is made");
let journal = std::sync::Arc::new(saya_store::SessionJournal::open(&dir));
let (tx, mut rx) = unbounded_channel();
let decider = ChannelApproval::new(
tx,
SessionPolicy::new(ApprovalPolicy::Ask),
TurnPrimary::default(),
composed_facts(),
Some(journal),
);
let tool = crate::interactive::session_definitions::workspace_write();
let arguments = serde_json::json!({"path": "notes.md", "content": "hello"});
let answerer = tokio::spawn(async move {
if let Some(StreamMsg::ApprovalRequest { respond, grant, .. }) = rx.recv().await {
assert_eq!(grant.as_deref(), Some("workspace-write"));
let _ = respond.send(ApprovalChoice::AllowSession {
token: grant.expect("the ask offered a token"),
});
}
match rx.recv().await {
Some(StreamMsg::Notice(warning)) => Some(warning),
Some(_) => panic!("the decider said something other than the journal warning"),
None => panic!("the decider said nothing"),
}
});
assert!(
decider.approve(&tool, &arguments).await,
"the consent stands: a failed audit write does not revoke it"
);
let warning = answerer
.await
.expect("the answerer completes")
.expect("the decider said the warning");
assert!(
warning.to_lowercase().contains("journal"),
"the warning names the journal: {warning}"
);
assert!(
decider.approve(&tool, &arguments).await,
"the granted token pre-answers the next call: the session carries on"
);
let _ = std::fs::remove_dir_all(&dir);
}