use std::io;
use std::time::{Duration, Instant};
use crate::danger;
use crate::mint_operating_key;
use newt_core::agentic::{newt_line, print_newt};
use newt_core::tty::{PromptWindow, Terminal};
pub(crate) fn permission_prompting_configured(
env_flag: bool,
tui: Option<&newt_core::TuiConfig>,
) -> bool {
env_flag || tui.is_some_and(|t| t.permissions.prompt)
}
const INTERACTIVE_PROMPT_DEFAULT: bool = true;
pub(crate) fn should_prompt_permissions(
configured_on: bool,
explicit_off: bool,
interactive: bool,
headless: bool,
) -> bool {
if headless || !interactive {
return false;
}
if explicit_off {
return false;
}
configured_on || INTERACTIVE_PROMPT_DEFAULT
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PromptChoice {
AllowOnce,
AllowSession,
Deny,
DenyAlways,
DenyPermanent,
AllowPermanent,
}
pub(crate) fn parse_permission_choice(input: &str) -> PromptChoice {
match input.trim() {
"a" => PromptChoice::AllowOnce,
"s" => PromptChoice::AllowSession,
"A" => PromptChoice::AllowPermanent,
"D" => PromptChoice::DenyAlways,
"P" => PromptChoice::DenyPermanent,
_ => PromptChoice::Deny,
}
}
#[cfg(unix)]
pub(crate) use newt_core::tty::try_watch_stdin;
pub(crate) fn reason_is_model_authored(req: &newt_core::PermissionRequest) -> bool {
req.tool == "request_permissions"
}
pub(crate) fn permission_prompt_text(
req: &newt_core::PermissionRequest,
danger: &danger::DangerTable,
) -> String {
use newt_core::DenialKind;
let (verb, axis) = match req.kind {
DenialKind::Exec => ("run", "outside the granted exec allowlist"),
DenialKind::FsRead => ("read", "outside the granted fs_read scope"),
DenialKind::FsWrite => ("write", "outside the granted fs_write scope"),
DenialKind::Net => ("reach", "outside the granted net allowlist"),
DenialKind::RemoteTool => ("call", "not in the active persona's tool allow-list"),
DenialKind::GitWrite => (
"commit/stage via git",
"outside the granted git-write authority",
),
};
let tier = danger.classify(req.kind, &req.target);
let blast = match danger.blast_radius(req.kind, &req.target) {
Some(line) => format!("{line}\n"),
None => String::new(),
};
let reason = if req.reason.is_empty() {
String::new()
} else if reason_is_model_authored(req) {
format!(
" model says (model-authored, unverified): \"{}\"\n",
req.reason
)
} else {
format!(" ({})\n", req.reason)
};
let menu = match tier {
danger::DangerTier::High => {
"[a]llow once [d]eny (default) [D]eny always [P]ermanently deny \
(high-danger: [s]ession allow refused — [k]ey allow / step-up is the future path, P3) > "
.to_string()
}
danger::DangerTier::Low if req.kind == DenialKind::Net => {
"[a]llow once [s]ession allow [A]llow permanently (adds host to config) \
[d]eny (default) [D]eny always [P]ermanently deny > "
.to_string()
}
danger::DangerTier::Low => {
"[a]llow once [s]ession allow [d]eny (default) [D]eny always [P]ermanently deny > "
.to_string()
}
};
format!(
"⊘ {} wants to {verb} `{}` — {axis}.\n{blast}{reason} {menu}",
req.tool, req.target
)
}
pub(crate) fn production_danger_table() -> danger::DangerTable {
let mut table = danger::DangerTable::builtin();
if let Some(home) = std::env::var_os("HOME") {
table = table.with_fs_root(std::path::PathBuf::from(home));
}
if let Ok(cwd) = std::env::current_dir() {
table = table.with_fs_root(cwd);
}
table
}
pub fn ocap_high_danger_predicate() -> impl Fn(newt_core::ocap_store::CapabilityClass, &str) -> bool
{
let table = production_danger_table();
move |class, target| {
let kind = match class {
newt_core::ocap_store::CapabilityClass::Exec => newt_core::DenialKind::Exec,
newt_core::ocap_store::CapabilityClass::Fs => newt_core::DenialKind::FsWrite,
newt_core::ocap_store::CapabilityClass::Net => newt_core::DenialKind::Net,
};
table.classify(kind, target) == danger::DangerTier::High
}
}
pub(crate) fn prompt_permission_choice(w: &PromptWindow, prompt_text: &str) -> PromptChoice {
w.ask(prompt_text).ok();
match w.read_line() {
Ok(answer) => parse_permission_choice(&answer),
Err(_) => PromptChoice::Deny,
}
}
pub(crate) fn interpret_user_line(read: io::Result<usize>, buf: &str) -> Option<String> {
match read {
Err(_) => None, Ok(_) => Some(buf.trim().to_string()), }
}
pub(crate) fn prompt_user_input(w: &PromptWindow, question: &str) -> Option<String> {
w.ask(&format!("? {question}\n> ")).ok();
let mut answer = String::new();
let read = w.read_line_into(&mut answer);
let line = interpret_user_line(read, &answer)?;
if is_slash_command_at_prompt(&line) {
w.notice(
"(slash commands aren't answers to this question - Ctrl-C to return \
to the prompt, then use /exit, /model, ...)",
)
.ok();
return None;
}
Some(line)
}
fn is_slash_command_at_prompt(answer: &str) -> bool {
answer.trim_start().starts_with('/')
}
#[cfg(test)]
mod slash_prompt_tests {
use super::is_slash_command_at_prompt;
#[test]
fn refuses_slash_commands_as_tool_answers() {
assert!(is_slash_command_at_prompt("/exit"));
assert!(is_slash_command_at_prompt(" /quit"));
assert!(is_slash_command_at_prompt("/model qwen2.5-coder:7b"));
assert!(!is_slash_command_at_prompt("qwen2.5-coder:7b"));
assert!(!is_slash_command_at_prompt("use a/b testing"));
assert!(!is_slash_command_at_prompt(""));
}
}
#[derive(Default)]
pub(crate) struct PermissionPromptState {
pub(crate) web_store: Option<newt_core::ConversationStore>,
session_grants: std::collections::BTreeSet<(newt_core::DenialKind, String)>,
session_denials: std::collections::BTreeSet<(newt_core::DenialKind, String)>,
persistent_denials: std::collections::BTreeSet<(newt_core::DenialKind, String)>,
pending_once_grants: std::collections::BTreeSet<(newt_core::DenialKind, String)>,
pub(crate) decisions: Vec<newt_core::PermissionRecord>,
pub(crate) ocap_policy: newt_core::ocap_store::PolicySet,
}
pub(crate) fn exec_grant_basename(cmd: &str) -> &str {
cmd.rsplit(['/', '\\'])
.find(|p| !p.is_empty())
.unwrap_or(cmd)
}
pub(crate) fn session_grant_covers(
grants: &std::collections::BTreeSet<(newt_core::DenialKind, String)>,
req: &newt_core::PermissionRequest,
) -> bool {
if grants.contains(&(req.kind, req.target.clone())) {
return true;
}
req.kind == newt_core::DenialKind::Exec
&& grants.contains(&(
newt_core::DenialKind::Exec,
exec_grant_basename(&req.target).to_string(),
))
}
pub(crate) fn take_pending_once(
pending: &mut std::collections::BTreeSet<(newt_core::DenialKind, String)>,
req: &newt_core::PermissionRequest,
) -> Option<(newt_core::DenialKind, String)> {
let exact = (req.kind, req.target.clone());
if pending.remove(&exact) {
return Some(exact);
}
if req.kind == newt_core::DenialKind::Exec {
let base = (
newt_core::DenialKind::Exec,
exec_grant_basename(&req.target).to_string(),
);
if pending.remove(&base) {
return Some(base);
}
}
None
}
impl PermissionPromptState {
pub(crate) fn with_persistent_denials(path: Option<&std::path::Path>) -> Self {
let persistent_denials = path
.map(|p| newt_core::load_denials(p).into_iter().collect())
.unwrap_or_default();
Self {
persistent_denials,
..Self::default()
}
}
}
pub(crate) struct PromptPermissionGate<'a, F: FnMut(&PromptWindow, &str) -> PromptChoice> {
pub(crate) state: &'a mut PermissionPromptState,
pub(crate) base: newt_core::Caveats,
pub(crate) key_path: Option<std::path::PathBuf>,
pub(crate) conversation_id: String,
pub(crate) log_path: Option<std::path::PathBuf>,
pub(crate) denials_path: Option<std::path::PathBuf>,
pub(crate) config_path: Option<std::path::PathBuf>,
pub(crate) preset_clamp: Option<newt_core::Caveats>,
pub(crate) danger: danger::DangerTable,
pub(crate) color: bool,
pub(crate) verbose: bool,
pub(crate) web_decision_timeout: Duration,
pub(crate) ask_human: F,
}
impl<F: FnMut(&PromptWindow, &str) -> PromptChoice> PromptPermissionGate<'_, F> {
fn record(&mut self, req: &newt_core::PermissionRequest, decision: &str, scope: &str) {
let rec = newt_core::PermissionRecord::new(
&self.conversation_id,
&req.tool,
req.kind,
&req.target,
decision,
scope,
);
if let Some(path) = self.log_path.as_deref() {
if let Err(e) = rec.append_jsonl(path) {
print_newt(
&format!("warning: permission log write failed: {e}"),
self.color,
self.verbose,
);
}
}
self.state.decisions.push(rec);
}
fn mint(&self, once_grants: &[(newt_core::DenialKind, String)]) -> newt_core::Caveats {
let mut grants: Vec<(newt_core::DenialKind, String)> =
self.state.session_grants.iter().cloned().collect();
grants.extend(once_grants.iter().cloned());
let mut policy = newt_core::widen_caveats(&self.base, &grants);
if let Some(clamp) = &self.preset_clamp {
policy = policy.meet(clamp);
}
match self
.key_path
.as_deref()
.and_then(|p| mint_operating_key(p, &policy).ok())
{
Some(key) => newt_identity::enforced_caveats(&key).unwrap_or(policy),
None => policy,
}
}
fn await_web_decision(
&self,
store: &newt_core::ConversationStore,
w: &newt_core::tty::PromptWindow,
req: &newt_core::PermissionRequest,
) -> PromptChoice {
let requests_json = serde_json::to_string(req).unwrap_or_default();
let tier = if self.danger.classify(req.kind, &req.target) == danger::DangerTier::High {
"\"high\""
} else {
"\"low\""
};
let request_id =
match store.publish_permission_request(&self.conversation_id, &requests_json, tier) {
Ok(id) => id,
Err(_) => return PromptChoice::Deny,
};
w.notice(&newt_line(
&format!("awaiting a decision from the web for `{}`…", req.target),
self.color,
self.verbose,
))
.ok();
let deadline = Instant::now() + self.web_decision_timeout;
loop {
match store.take_permission_decision(&self.conversation_id, &request_id) {
Ok(Some(verdict)) => return verdict_to_choice(verdict),
Ok(None) if Instant::now() >= deadline => {
match store.resolve_permission_request(
&self.conversation_id,
&request_id,
"expired",
) {
Ok(true) => return PromptChoice::Deny,
Ok(false) => match store
.take_permission_decision(&self.conversation_id, &request_id)
{
Ok(Some(verdict)) => return verdict_to_choice(verdict),
Ok(None) | Err(_) => return PromptChoice::Deny,
},
Err(_) => return PromptChoice::Deny,
}
}
Ok(None) => {
let remaining = deadline.saturating_duration_since(Instant::now());
std::thread::sleep(remaining.min(Duration::from_millis(200)));
}
Err(_) => return PromptChoice::Deny,
}
}
}
}
fn verdict_to_choice(v: newt_core::Verdict) -> PromptChoice {
match v {
newt_core::Verdict::AllowOnce => PromptChoice::AllowOnce,
newt_core::Verdict::AllowSession => PromptChoice::AllowSession,
newt_core::Verdict::Deny => PromptChoice::Deny,
}
}
impl<F: FnMut(&PromptWindow, &str) -> PromptChoice> newt_core::PermissionGate
for PromptPermissionGate<'_, F>
{
fn ask(&mut self, requests: &[newt_core::PermissionRequest]) -> newt_core::PermissionDecision {
use newt_core::PermissionDecision::{Allow, Deny};
if requests.is_empty() {
return Deny;
}
if requests.iter().any(|r| {
let key = (r.kind, r.target.clone());
self.state.session_denials.contains(&key)
|| self.state.persistent_denials.contains(&key)
|| newt_core::ocap_store::evaluate_request(
&self.state.ocap_policy,
r.kind,
&r.target,
) == Some(newt_core::ocap_store::Verdict::Deny)
}) {
return Deny;
}
let mut once_grants: Vec<(newt_core::DenialKind, String)> = Vec::new();
let web = self.state.web_store.clone();
for req in requests {
if req.kind == newt_core::DenialKind::GitWrite {
if let Some(clamp) = &self.preset_clamp {
if !newt_core::git_caveats::GitCaveats::from_session(clamp).permits_commit() {
self.record(req, "deny", "preset-floor-git-readonly");
return Deny;
}
}
}
if session_grant_covers(&self.state.session_grants, req) {
continue;
}
if newt_core::ocap_store::evaluate_request(
&self.state.ocap_policy,
req.kind,
&req.target,
) == Some(newt_core::ocap_store::Verdict::Approve)
&& self.danger.classify(req.kind, &req.target) != danger::DangerTier::High
{
self.record(req, "allow", "ocap-approve");
once_grants.push((req.kind, req.target.clone()));
continue;
}
if req.tool != "request_permissions" {
if let Some(key) = take_pending_once(&mut self.state.pending_once_grants, req) {
self.record(req, "allow", "once");
once_grants.push(key);
continue;
}
}
let w = Terminal::suspend_for_prompt();
let choice = match &web {
Some(store) => self.await_web_decision(store, &w, req),
None => (self.ask_human)(&w, &permission_prompt_text(req, &self.danger)),
};
match choice {
PromptChoice::AllowOnce => {
self.record(req, "allow", "once");
once_grants.push((req.kind, req.target.clone()));
if req.tool == "request_permissions" {
let key = if req.kind == newt_core::DenialKind::Exec {
(req.kind, exec_grant_basename(&req.target).to_string())
} else {
(req.kind, req.target.clone())
};
self.state.pending_once_grants.insert(key);
}
}
PromptChoice::AllowSession => {
if self.danger.classify(req.kind, &req.target) == danger::DangerTier::High {
self.record(req, "deny", "session-allow-refused-high-danger");
w.notice(&newt_line(
&format!(
"session allow refused for high-danger `{}` — \
allow once per op or deny (step-up is the future path)",
req.target
),
self.color,
self.verbose,
))
.ok();
return Deny;
}
self.record(req, "allow", "session");
self.state
.session_grants
.insert((req.kind, req.target.clone()));
}
PromptChoice::AllowPermanent => {
if req.kind != newt_core::DenialKind::Net {
self.record(req, "allow", "session");
self.state
.session_grants
.insert((req.kind, req.target.clone()));
continue;
}
if self.danger.classify(req.kind, &req.target) == danger::DangerTier::High {
self.record(req, "deny", "permanent-allow-refused-high-danger");
w.notice(&newt_line(
&format!("permanent allow refused for high-danger `{}`", req.target),
self.color,
self.verbose,
))
.ok();
return Deny;
}
self.record(req, "allow", "permanent");
match self.config_path.as_deref() {
Some(path) => {
if let Err(e) =
newt_core::Config::append_permission_net_host(path, &req.target)
{
w.notice(&newt_line(
&format!(
"warning: could not persist net grant to config: {e} \
(granted for this session only)"
),
self.color,
self.verbose,
))
.ok();
} else {
w.notice(&newt_line(
&format!(
"added `{}` to [tui.permissions] net — future sessions \
will not prompt for it",
req.target
),
self.color,
self.verbose,
))
.ok();
}
}
None => {
w.notice(&newt_line(
"no config path this session — net grant is session-only",
self.color,
self.verbose,
))
.ok();
}
}
self.state
.session_grants
.insert((req.kind, req.target.clone()));
}
PromptChoice::Deny => {
self.record(req, "deny", "once");
return Deny;
}
PromptChoice::DenyAlways => {
self.record(req, "deny", "session");
self.state
.session_denials
.insert((req.kind, req.target.clone()));
return Deny;
}
PromptChoice::DenyPermanent => {
self.record(req, "deny", "permanent");
if let Some(path) = self.denials_path.as_deref() {
if let Err(e) = newt_core::append_denial(path, req.kind, &req.target) {
print_newt(
&format!("warning: permission denylist write failed: {e}"),
self.color,
self.verbose,
);
}
}
self.state
.persistent_denials
.insert((req.kind, req.target.clone()));
return Deny;
}
}
}
Allow(self.mint(&once_grants))
}
fn ask_question(&mut self, question: &str) -> Option<String> {
let w = Terminal::suspend_for_prompt();
prompt_user_input(&w, question)
}
}
#[cfg(test)]
mod permission_prompt_tests {
use super::*;
use crate::mcp::Mcp;
use crate::{close_out_message, help_lines, permissions_command_lines, ActivePosture};
use newt_core::caveats::{Caveats, CountBound, Scope};
use newt_core::{CaveatsExt as _, DenialKind, PermissionGate as _, PermissionRequest};
use std::cell::Cell;
use std::rc::Rc;
fn base_caveats(ws: &str) -> Caveats {
Caveats {
fs_read: Scope::only([ws.to_string()]),
fs_write: Scope::only([ws.to_string()]),
exec: Scope::only(["cargo".to_string()]),
net: Scope::none(),
max_calls: CountBound::Unlimited,
valid_for_generation: Scope::All,
}
}
fn exec_request(target: &str) -> PermissionRequest {
PermissionRequest {
tool: "run_command".to_string(),
kind: DenialKind::Exec,
target: target.to_string(),
reason: format!("exec of \"{target}\" is not within the granted authority"),
}
}
#[test]
fn web_decisions_publish_and_consume_a_web_verdict_without_the_tty() {
let root = tempfile::tempdir().unwrap();
let ws = tempfile::tempdir().unwrap();
let store = newt_core::ConversationStore::new(root.path(), ws.path(), 100).unwrap();
let conv = store.create("s", None).unwrap();
let answerer_store = store.clone();
let answer_conv = conv.clone();
let answerer = std::thread::spawn(move || {
for _ in 0..500 {
if let Ok(Some(p)) = answerer_store.pending_permission_request(&answer_conv) {
answerer_store
.answer_permission_request(
&answer_conv,
&p.request_id,
newt_core::Verdict::AllowOnce,
)
.unwrap();
return;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
panic!("the gate never published a permission request");
});
let mut state = PermissionPromptState {
web_store: Some(store.clone()),
..Default::default()
};
let mut gate = PromptPermissionGate {
state: &mut state,
base: Caveats::default(),
key_path: None,
conversation_id: conv.clone(),
log_path: None,
denials_path: None,
config_path: None,
preset_clamp: None,
danger: danger::DangerTable::builtin(),
color: false,
verbose: false,
web_decision_timeout: Duration::from_secs(2),
ask_human: |_w: &PromptWindow, _p: &str| {
panic!("the TTY must not be read when web decisions are enabled")
},
};
let decision = gate.ask(&[exec_request("bash")]);
answerer.join().unwrap();
assert!(
matches!(decision, newt_core::PermissionDecision::Allow(_)),
"a web allow-once verdict must produce Allow"
);
}
#[test]
fn web_decision_timeout_resolves_and_denies_without_hanging() {
let root = tempfile::tempdir().unwrap();
let ws = tempfile::tempdir().unwrap();
let store = newt_core::ConversationStore::new(root.path(), ws.path(), 100).unwrap();
let conv = store.create("s", None).unwrap();
let mut state = PermissionPromptState {
web_store: Some(store.clone()),
..Default::default()
};
let mut gate = PromptPermissionGate {
state: &mut state,
base: Caveats::default(),
key_path: None,
conversation_id: conv.clone(),
log_path: None,
denials_path: None,
config_path: None,
preset_clamp: None,
danger: danger::DangerTable::builtin(),
color: false,
verbose: false,
web_decision_timeout: Duration::from_millis(50),
ask_human: |_w: &PromptWindow, _p: &str| {
panic!("the TTY must not be read when web decisions are enabled")
},
};
let started = Instant::now();
let decision = gate.ask(&[exec_request("bash")]);
assert!(started.elapsed() < Duration::from_secs(1));
assert!(matches!(decision, newt_core::PermissionDecision::Deny));
assert_eq!(store.pending_permission_request(&conv).unwrap(), None);
}
fn scripted_gate<'a>(
state: &'a mut PermissionPromptState,
base: Caveats,
key_path: Option<std::path::PathBuf>,
log_path: Option<std::path::PathBuf>,
script: Vec<PromptChoice>,
prompts: Rc<Cell<usize>>,
) -> PromptPermissionGate<'a, impl FnMut(&PromptWindow, &str) -> PromptChoice> {
let mut script = script.into_iter();
PromptPermissionGate {
state,
base,
key_path,
conversation_id: "conv-test".to_string(),
log_path,
denials_path: None,
config_path: None,
preset_clamp: None,
danger: danger::DangerTable::builtin(),
color: false,
verbose: false,
web_decision_timeout: Duration::from_secs(2),
ask_human: move |_w: &PromptWindow, _prompt: &str| {
prompts.set(prompts.get() + 1);
script.next().expect("script exhausted — unexpected prompt")
},
}
}
#[allow(clippy::too_many_arguments)]
fn scripted_gate_with_clamp<'a>(
state: &'a mut PermissionPromptState,
base: Caveats,
key_path: Option<std::path::PathBuf>,
log_path: Option<std::path::PathBuf>,
preset_clamp: Caveats,
script: Vec<PromptChoice>,
prompts: Rc<Cell<usize>>,
) -> PromptPermissionGate<'a, impl FnMut(&PromptWindow, &str) -> PromptChoice> {
let mut script = script.into_iter();
PromptPermissionGate {
state,
base,
key_path,
conversation_id: "conv-test".to_string(),
log_path,
denials_path: None,
config_path: None,
preset_clamp: Some(preset_clamp),
danger: danger::DangerTable::builtin(),
color: false,
verbose: false,
web_decision_timeout: Duration::from_secs(2),
ask_human: move |_w: &PromptWindow, _prompt: &str| {
prompts.set(prompts.get() + 1);
script.next().expect("script exhausted — unexpected prompt")
},
}
}
#[test]
fn parse_choice_maps_the_sketch_keys_and_defaults_to_deny() {
assert_eq!(parse_permission_choice("a"), PromptChoice::AllowOnce);
assert_eq!(parse_permission_choice(" a \n"), PromptChoice::AllowOnce);
assert_eq!(parse_permission_choice("s"), PromptChoice::AllowSession);
assert_eq!(parse_permission_choice("d"), PromptChoice::Deny);
assert_eq!(parse_permission_choice("D"), PromptChoice::DenyAlways);
assert_eq!(parse_permission_choice("P"), PromptChoice::DenyPermanent);
assert_eq!(parse_permission_choice("A"), PromptChoice::AllowPermanent);
assert_eq!(parse_permission_choice(""), PromptChoice::Deny);
assert_eq!(parse_permission_choice("yes"), PromptChoice::Deny);
assert_eq!(parse_permission_choice("S"), PromptChoice::Deny);
}
#[test]
fn interpret_user_line_maps_eof_to_empty_and_errors_to_none() {
assert_eq!(interpret_user_line(Ok(0), ""), Some(String::new()));
assert_eq!(
interpret_user_line(Err(io::Error::from(io::ErrorKind::Other)), ""),
None
);
assert_eq!(interpret_user_line(Ok(5), "hi\n"), Some("hi".to_string()));
}
#[test]
fn prompt_text_names_tool_target_axis_and_choices() {
let danger = danger::DangerTable::builtin();
let text = permission_prompt_text(&exec_request("npm"), &danger);
assert!(
text.contains("run_command wants to run `npm`"),
"got: {text}"
);
assert!(
text.contains("outside the granted exec allowlist"),
"got: {text}"
);
assert!(
text.contains("not within the granted authority"),
"reason shown: {text}"
);
assert!(
text.contains("[a]llow once [s]ession allow [d]eny (default) [D]eny always"),
"got: {text}"
);
let read = permission_prompt_text(
&PermissionRequest {
tool: "read_file".to_string(),
kind: DenialKind::FsRead,
target: "/etc/hosts".to_string(),
reason: String::new(),
},
&danger,
);
assert!(read.contains("wants to read `/etc/hosts`"), "got: {read}");
assert!(read.contains("fs_read scope"), "got: {read}");
assert!(!read.contains("()"), "no empty reason parens: {read}");
let net = permission_prompt_text(
&PermissionRequest {
tool: "web_fetch".to_string(),
kind: DenialKind::Net,
target: "docs.rs".to_string(),
reason: String::new(),
},
&danger,
);
assert!(net.contains("wants to reach `docs.rs`"), "got: {net}");
let write = permission_prompt_text(
&PermissionRequest {
tool: "edit_file".to_string(),
kind: DenialKind::FsWrite,
target: "/ws/f".to_string(),
reason: String::new(),
},
&danger,
);
assert!(write.contains("wants to write `/ws/f`"), "got: {write}");
}
#[test]
fn high_danger_prompt_shows_blast_radius_and_labels_reason_untrusted() {
let danger = danger::DangerTable::builtin();
let req = PermissionRequest {
tool: "request_permissions".to_string(),
kind: DenialKind::Exec,
target: "bash".to_string(),
reason: "list the files in this directory".to_string(),
};
let text = permission_prompt_text(&req, &danger);
assert!(
text.contains("⚠") && text.contains("interpreter"),
"expected a blast-radius warning, got: {text}"
);
assert!(
text.contains("arbitrary command execution"),
"expected the exec blast radius, got: {text}"
);
assert!(
text.contains("list the files in this directory"),
"the model reason is still shown (as context), got: {text}"
);
assert!(
text.contains("model-authored, unverified"),
"the reason must be labelled untrusted model text, got: {text}"
);
assert!(
!text.contains("[a]llow once [s]ession allow [d]eny"),
"a high-danger grant must NOT offer the plain session-allow menu, got: {text}"
);
assert!(
text.contains("[s]ession allow refused"),
"the prompt must explain the session-allow refusal, got: {text}"
);
let fs_req = PermissionRequest {
tool: "request_permissions".to_string(),
kind: DenialKind::FsWrite,
target: "/".to_string(),
reason: "just save one small file".to_string(),
};
let fs_text = permission_prompt_text(&fs_req, &danger);
assert!(
fs_text.contains("filesystem root") && fs_text.contains("write access to everything"),
"expected the fs-root blast radius, got: {fs_text}"
);
assert!(
!fs_text.contains("[s]ession allow [d]eny"),
"fs-root grant must not offer plain session-allow, got: {fs_text}"
);
}
#[test]
fn high_danger_target_is_not_session_allowable_but_allow_once_works() {
let base = base_caveats("/ws");
let mut state = PermissionPromptState::default();
let prompts = Rc::new(Cell::new(0));
{
let mut gate = scripted_gate(
&mut state,
base.clone(),
None,
None,
vec![PromptChoice::AllowSession],
prompts.clone(),
);
assert!(
matches!(
gate.ask(&[exec_request("bash")]),
newt_core::PermissionDecision::Deny
),
"session-allow of an interpreter must be refused (deny)"
);
}
assert!(
!state
.session_grants
.contains(&(DenialKind::Exec, "bash".to_string())),
"a refused session-allow must leave NO standing grant"
);
assert_eq!(state.decisions.len(), 1);
assert_eq!(state.decisions[0].decision, "deny");
assert!(
state.decisions[0].scope.contains("refused"),
"the record must mark the high-danger refusal, got: {}",
state.decisions[0].scope
);
let mut once_state = PermissionPromptState::default();
let once_prompts = Rc::new(Cell::new(0));
let mut once_gate = scripted_gate(
&mut once_state,
base,
None,
None,
vec![PromptChoice::AllowOnce],
once_prompts,
);
match once_gate.ask(&[exec_request("bash")]) {
newt_core::PermissionDecision::Allow(c) => {
assert!(
c.permits_exec("bash"),
"allow-once grants the target for this op"
);
}
newt_core::PermissionDecision::Deny => {
panic!("allow-once of a high-danger target must still be permitted")
}
}
drop(once_gate);
assert!(once_state.session_grants.is_empty());
}
fn ocap(
verdict: newt_core::ocap_store::Verdict,
toml: &str,
) -> newt_core::ocap_store::PolicySet {
newt_core::ocap_store::build_store(&[(verdict, Some(toml.to_string()))]).0
}
#[test]
fn durable_ocap_approve_allows_without_prompting_and_grants_authority() {
let mut state = PermissionPromptState {
ocap_policy: ocap(
newt_core::ocap_store::Verdict::Approve,
"[[exec]]\ntarget = \"git\"\n",
),
..Default::default()
};
let prompts = Rc::new(Cell::new(0));
let mut gate = scripted_gate(
&mut state,
base_caveats("/ws"),
None,
None,
vec![], prompts.clone(),
);
match gate.ask(&[exec_request("git")]) {
newt_core::PermissionDecision::Allow(c) => assert!(
c.permits_exec("git"),
"a durable approve must fold `git` into the minted authority"
),
newt_core::PermissionDecision::Deny => panic!("durable approve must allow"),
}
assert_eq!(prompts.get(), 0, "durable approve must NOT prompt");
drop(gate);
assert_eq!(state.decisions.len(), 1);
assert_eq!(state.decisions[0].decision, "allow");
assert_eq!(state.decisions[0].scope, "ocap-approve");
assert!(state.session_grants.is_empty());
}
#[test]
fn durable_ocap_deny_refuses_without_prompting() {
let mut state = PermissionPromptState {
ocap_policy: ocap(
newt_core::ocap_store::Verdict::Deny,
"[[exec]]\ntarget = \"git\"\n",
),
..Default::default()
};
let prompts = Rc::new(Cell::new(0));
let mut gate = scripted_gate(
&mut state,
base_caveats("/ws"),
None,
None,
vec![],
prompts.clone(),
);
assert!(
matches!(
gate.ask(&[exec_request("git")]),
newt_core::PermissionDecision::Deny
),
"a durable deny must refuse"
);
assert_eq!(prompts.get(), 0, "durable deny must NOT prompt");
}
#[test]
fn durable_ocap_approve_of_high_danger_still_prompts() {
let mut state = PermissionPromptState {
ocap_policy: ocap(
newt_core::ocap_store::Verdict::Approve,
"[[exec]]\ntarget = \"bash\"\n",
),
..Default::default()
};
let prompts = Rc::new(Cell::new(0));
let mut gate = scripted_gate(
&mut state,
base_caveats("/ws"),
None,
None,
vec![PromptChoice::Deny], prompts.clone(),
);
assert!(
matches!(
gate.ask(&[exec_request("bash")]),
newt_core::PermissionDecision::Deny
),
"a durable approve must not bypass the danger prompt for an interpreter"
);
assert_eq!(
prompts.get(),
1,
"high-danger falls through to the human even with a durable approve"
);
}
#[test]
fn permanently_deny_persists_and_reloads_without_reprompting() {
let dir = tempfile::TempDir::new().unwrap();
let denials = dir.path().join("permission-denials.jsonl");
let base = base_caveats("/ws");
let net_req = newt_core::PermissionRequest {
tool: "web_fetch".to_string(),
kind: DenialKind::Net,
target: "evil.example.com".to_string(),
reason: "net does not permit 'evil.example.com'".to_string(),
};
let mut state = PermissionPromptState::default();
{
let mut script = vec![PromptChoice::DenyPermanent].into_iter();
let mut gate = PromptPermissionGate {
state: &mut state,
base: base.clone(),
key_path: None,
conversation_id: "conv-904".to_string(),
log_path: None,
denials_path: Some(denials.clone()),
config_path: None,
preset_clamp: None,
danger: danger::DangerTable::builtin(),
color: false,
verbose: false,
web_decision_timeout: Duration::from_secs(2),
ask_human: move |_w: &PromptWindow, _p: &str| {
script.next().expect("script exhausted")
},
};
assert!(matches!(
gate.ask(std::slice::from_ref(&net_req)),
newt_core::PermissionDecision::Deny
));
}
assert_eq!(state.decisions.len(), 1);
assert_eq!(state.decisions[0].decision, "deny");
assert_eq!(state.decisions[0].scope, "permanent");
assert_eq!(
newt_core::load_denials(&denials),
vec![(DenialKind::Net, "evil.example.com".to_string())],
"the permanent deny was written to disk"
);
let mut fresh = PermissionPromptState::with_persistent_denials(Some(&denials));
{
let mut gate = PromptPermissionGate {
state: &mut fresh,
base,
key_path: None,
conversation_id: "conv-904b".to_string(),
log_path: None,
denials_path: Some(denials.clone()),
config_path: None,
preset_clamp: None,
danger: danger::DangerTable::builtin(),
color: false,
verbose: false,
web_decision_timeout: Duration::from_secs(2),
ask_human: |_w: &PromptWindow, _p: &str| {
panic!("must NOT prompt: target was permanently denied")
},
};
assert!(matches!(
gate.ask(std::slice::from_ref(&net_req)),
newt_core::PermissionDecision::Deny
));
}
assert!(fresh.decisions.is_empty());
}
#[test]
fn parse_permission_choice_maps_permanent_deny() {
assert_eq!(parse_permission_choice("P"), PromptChoice::DenyPermanent);
assert_eq!(parse_permission_choice("D"), PromptChoice::DenyAlways);
assert_eq!(parse_permission_choice("a"), PromptChoice::AllowOnce);
assert_eq!(parse_permission_choice("s"), PromptChoice::AllowSession);
assert_eq!(parse_permission_choice("p"), PromptChoice::Deny);
assert_eq!(parse_permission_choice(""), PromptChoice::Deny);
}
#[test]
fn permanent_allow_offered_for_net_only() {
let danger = danger::DangerTable::builtin();
let net = permission_prompt_text(
&PermissionRequest {
tool: "web_fetch".to_string(),
kind: DenialKind::Net,
target: "github.com".to_string(),
reason: String::new(),
},
&danger,
);
let exec = permission_prompt_text(&exec_request("npm"), &danger);
assert!(
net.contains("[A]llow permanently"),
"net must offer it: {net}"
);
assert!(
!exec.contains("[A]llow permanently"),
"exec must NOT: {exec}"
);
assert!(net.contains("[P]ermanently deny") && exec.contains("[P]ermanently deny"));
}
#[test]
fn allow_permanently_grants_now_and_persists_host_to_config() {
let dir = tempfile::TempDir::new().unwrap();
let config = dir.path().join("config.toml");
std::fs::write(&config, "# my config\n[tui.permissions]\nnet = []\n").unwrap();
let base = base_caveats("/ws");
let net_req = newt_core::PermissionRequest {
tool: "web_fetch".to_string(),
kind: DenialKind::Net,
target: "github.com".to_string(),
reason: "net does not permit 'github.com'".to_string(),
};
let mut state = PermissionPromptState::default();
{
let mut script = vec![PromptChoice::AllowPermanent].into_iter();
let mut gate = PromptPermissionGate {
state: &mut state,
base,
key_path: None,
conversation_id: "conv-904a".to_string(),
log_path: None,
denials_path: None,
config_path: Some(config.clone()),
preset_clamp: None,
danger: danger::DangerTable::builtin(),
color: false,
verbose: false,
web_decision_timeout: Duration::from_secs(2),
ask_human: move |_w: &PromptWindow, _p: &str| {
script.next().expect("script exhausted")
},
};
match gate.ask(std::slice::from_ref(&net_req)) {
newt_core::PermissionDecision::Allow(c) => {
assert!(c.permits_net("github.com"), "granted this session");
}
newt_core::PermissionDecision::Deny => {
panic!("permanent-allow of a net host must be granted")
}
}
}
assert!(state
.session_grants
.contains(&(DenialKind::Net, "github.com".to_string())));
assert_eq!(state.decisions[0].scope, "permanent");
let written = std::fs::read_to_string(&config).unwrap();
assert!(written.contains("# my config"), "comment lost: {written}");
assert!(
written.contains("github.com"),
"host not persisted: {written}"
);
let reloaded = newt_core::Config::load(&config).unwrap();
assert!(
reloaded
.tui
.unwrap()
.permissions
.net
.contains(&"github.com".to_string()),
"a fresh session reads the durable net grant"
);
}
#[test]
fn allow_once_grants_one_call_and_reprompts_next_time() {
let mut state = PermissionPromptState::default();
let prompts = Rc::new(Cell::new(0));
let base = base_caveats("/ws");
let mut gate = scripted_gate(
&mut state,
base.clone(),
None,
None,
vec![PromptChoice::AllowOnce, PromptChoice::AllowOnce],
prompts.clone(),
);
let req = [exec_request("npm")];
match gate.ask(&req) {
newt_core::PermissionDecision::Allow(c) => {
assert!(c.permits_exec("npm"), "the grant covers the target");
assert!(c.permits_exec("cargo"), "baseline grants kept");
assert!(!c.permits_exec("rm"), "nothing else widened");
}
newt_core::PermissionDecision::Deny => panic!("expected allow"),
}
assert_eq!(prompts.get(), 1);
assert!(matches!(
gate.ask(&req),
newt_core::PermissionDecision::Allow(_)
));
assert_eq!(prompts.get(), 2, "allow-once re-prompts on the next call");
drop(gate);
assert!(state.session_grants.is_empty());
assert_eq!(state.decisions.len(), 2);
assert_eq!(state.decisions[0].decision, "allow");
assert_eq!(state.decisions[0].scope, "once");
}
#[test]
fn request_permissions_allow_once_carries_to_the_run_command_retry() {
let mut state = PermissionPromptState::default();
let prompts = Rc::new(Cell::new(0));
let base = base_caveats("/ws");
let mut gate = scripted_gate(
&mut state,
base,
None,
None,
vec![PromptChoice::AllowOnce, PromptChoice::AllowOnce],
prompts.clone(),
);
let ask = PermissionRequest {
tool: "request_permissions".to_string(),
kind: DenialKind::Exec,
target: "python3".to_string(),
reason: "need to run the tests".to_string(),
};
assert!(matches!(
gate.ask(&[ask]),
newt_core::PermissionDecision::Allow(_)
));
assert_eq!(prompts.get(), 1);
match gate.ask(&[exec_request("/usr/bin/python3")]) {
newt_core::PermissionDecision::Allow(c) => {
assert!(
c.permits_exec("python3"),
"the carried grant widened the caveats so the retry runs"
);
}
newt_core::PermissionDecision::Deny => panic!("carried grant should cover the retry"),
}
assert_eq!(
prompts.get(),
1,
"no second prompt — the pending grant covered the /usr/bin/python3 retry"
);
assert!(matches!(
gate.ask(&[exec_request("/usr/bin/python3")]),
newt_core::PermissionDecision::Allow(_)
));
assert_eq!(
prompts.get(),
2,
"the one-shot pending grant was consumed; the next op re-prompts"
);
}
#[test]
fn session_grant_exec_matches_by_basename() {
let mut state = PermissionPromptState::default();
let prompts = Rc::new(Cell::new(0));
let mut gate = scripted_gate(
&mut state,
base_caveats("/ws"),
None,
None,
vec![PromptChoice::AllowSession, PromptChoice::AllowSession],
prompts.clone(),
);
assert!(matches!(
gate.ask(&[exec_request("mytool")]),
newt_core::PermissionDecision::Allow(_)
));
assert_eq!(prompts.get(), 1);
assert!(matches!(
gate.ask(&[exec_request("/opt/bin/mytool")]),
newt_core::PermissionDecision::Allow(_)
));
assert_eq!(prompts.get(), 1, "basename covers the resolved path");
assert!(matches!(
gate.ask(&[exec_request("othertool")]),
newt_core::PermissionDecision::Allow(_)
));
assert_eq!(prompts.get(), 2, "a different program is not covered");
}
#[test]
fn full_path_session_grant_does_not_cover_a_bare_name() {
let mut state = PermissionPromptState::default();
let prompts = Rc::new(Cell::new(0));
let mut gate = scripted_gate(
&mut state,
base_caveats("/ws"),
None,
None,
vec![PromptChoice::AllowSession, PromptChoice::AllowSession],
prompts.clone(),
);
assert!(matches!(
gate.ask(&[exec_request("/opt/bin/mytool")]),
newt_core::PermissionDecision::Allow(_)
));
assert_eq!(prompts.get(), 1);
assert!(matches!(
gate.ask(&[exec_request("mytool")]),
newt_core::PermissionDecision::Allow(_)
));
assert_eq!(
prompts.get(),
2,
"full-path grant must not widen to a bare name (pin-exact)"
);
}
#[test]
fn git_write_grant_refused_under_readonly_preset() {
let mut state = PermissionPromptState::default();
let prompts = Rc::new(Cell::new(0));
let clamp = newt_core::NamedPermissionPreset {
readonly: true,
..Default::default()
}
.clamp();
let base = base_caveats("/ws").meet(&clamp);
let mut gate = scripted_gate_with_clamp(
&mut state,
base,
None,
None,
clamp,
vec![PromptChoice::AllowOnce],
prompts.clone(),
);
let req = PermissionRequest {
tool: "git".to_string(),
kind: DenialKind::GitWrite,
target: "commit".to_string(),
reason: "commit the work".to_string(),
};
assert!(
matches!(gate.ask(&[req]), newt_core::PermissionDecision::Deny),
"a readonly preset must refuse a git-write grant"
);
assert_eq!(prompts.get(), 0, "the floor refuses WITHOUT prompting");
}
#[test]
fn git_write_grant_allowed_without_a_preset() {
let mut state = PermissionPromptState::default();
let prompts = Rc::new(Cell::new(0));
let mut gate = scripted_gate(
&mut state,
base_caveats("/ws"),
None,
None,
vec![PromptChoice::AllowOnce],
prompts.clone(),
);
let req = PermissionRequest {
tool: "git".to_string(),
kind: DenialKind::GitWrite,
target: "commit".to_string(),
reason: "commit the work".to_string(),
};
assert!(matches!(
gate.ask(&[req]),
newt_core::PermissionDecision::Allow(_)
));
assert_eq!(prompts.get(), 1);
}
#[test]
fn session_grant_cannot_pierce_the_preset_floor() {
let mut state = PermissionPromptState::default();
let prompts = Rc::new(Cell::new(0));
let clamp = newt_core::NamedPermissionPreset {
readonly: true,
..Default::default()
}
.clamp();
let base = base_caveats("/ws").meet(&clamp);
assert!(
!base.permits_exec("cargo"),
"the preset clamped exec to none"
);
let mut gate = scripted_gate_with_clamp(
&mut state,
base.clone(),
None,
None,
clamp.clone(),
vec![PromptChoice::AllowOnce, PromptChoice::AllowSession],
prompts.clone(),
);
match gate.ask(&[exec_request("rm")]) {
newt_core::PermissionDecision::Allow(c) => {
assert!(
!c.permits_exec("rm"),
"a once-grant must not pierce the preset floor: {c:?}"
);
assert!(!c.permits_exec("cargo"), "floor keeps exec denied");
}
newt_core::PermissionDecision::Deny => panic!("the gate allowed-once"),
}
match gate.ask(&[exec_request("rm")]) {
newt_core::PermissionDecision::Allow(c) => {
assert!(
!c.permits_exec("rm"),
"a SESSION grant must not pierce the floor either: {c:?}"
);
}
newt_core::PermissionDecision::Deny => panic!("the gate allowed-session"),
}
drop(gate);
assert!(state
.session_grants
.contains(&(DenialKind::Exec, "rm".to_string())));
}
#[test]
fn allow_session_never_reprompts_until_restart() {
let prompts = Rc::new(Cell::new(0));
let base = base_caveats("/ws");
let mut state = PermissionPromptState::default();
{
let mut gate = scripted_gate(
&mut state,
base.clone(),
None,
None,
vec![PromptChoice::AllowSession],
prompts.clone(),
);
let req = [exec_request("npm")];
assert!(matches!(
gate.ask(&req),
newt_core::PermissionDecision::Allow(_)
));
assert_eq!(prompts.get(), 1);
assert!(matches!(
gate.ask(&req),
newt_core::PermissionDecision::Allow(_)
));
}
{
let mut gate = scripted_gate(
&mut state,
base.clone(),
None,
None,
vec![],
prompts.clone(),
);
match gate.ask(&[exec_request("npm")]) {
newt_core::PermissionDecision::Allow(c) => assert!(c.permits_exec("npm")),
newt_core::PermissionDecision::Deny => panic!("session grant must hold"),
}
}
assert_eq!(prompts.get(), 1, "exactly one prompt for the whole session");
assert_eq!(state.decisions.len(), 1, "re-uses are not re-recorded");
let mut fresh = PermissionPromptState::default();
let mut gate = scripted_gate(
&mut fresh,
base,
None,
None,
vec![PromptChoice::Deny],
prompts.clone(),
);
assert!(matches!(
gate.ask(&[exec_request("npm")]),
newt_core::PermissionDecision::Deny
));
assert_eq!(prompts.get(), 2, "the grant did not survive the restart");
}
#[test]
fn deny_always_short_circuits_later_asks() {
let prompts = Rc::new(Cell::new(0));
let mut state = PermissionPromptState::default();
let mut gate = scripted_gate(
&mut state,
base_caveats("/ws"),
None,
None,
vec![PromptChoice::DenyAlways],
prompts.clone(),
);
let req = [exec_request("rm")];
assert!(matches!(
gate.ask(&req),
newt_core::PermissionDecision::Deny
));
assert!(matches!(
gate.ask(&req),
newt_core::PermissionDecision::Deny
));
assert_eq!(prompts.get(), 1, "second ask auto-denied without a prompt");
drop(gate);
assert_eq!(state.decisions.len(), 1);
assert_eq!(state.decisions[0].decision, "deny");
assert_eq!(state.decisions[0].scope, "session");
}
#[test]
fn batch_deny_and_empty_requests_deny() {
let prompts = Rc::new(Cell::new(0));
let mut state = PermissionPromptState::default();
let mut gate = scripted_gate(
&mut state,
base_caveats("/ws"),
None,
None,
vec![PromptChoice::AllowOnce, PromptChoice::Deny],
prompts.clone(),
);
let reqs = [exec_request("npm"), exec_request("rm")];
assert!(matches!(
gate.ask(&reqs),
newt_core::PermissionDecision::Deny
));
assert_eq!(prompts.get(), 2, "asked per target until the deny");
assert!(matches!(gate.ask(&[]), newt_core::PermissionDecision::Deny));
assert_eq!(prompts.get(), 2, "empty batch never prompts");
}
#[serial_test::serial(real_fs)]
#[test]
fn decisions_are_recorded_to_the_session_log() {
let dir = tempfile::TempDir::new().unwrap();
let log = dir.path().join("permission-log.jsonl");
let prompts = Rc::new(Cell::new(0));
let mut state = PermissionPromptState::default();
let mut gate = scripted_gate(
&mut state,
base_caveats("/ws"),
None,
Some(log.clone()),
vec![
PromptChoice::AllowOnce,
PromptChoice::AllowSession,
PromptChoice::Deny,
],
prompts.clone(),
);
let _ = gate.ask(&[exec_request("npm")]);
let _ = gate.ask(&[PermissionRequest {
tool: "web_fetch".to_string(),
kind: DenialKind::Net,
target: "docs.rs".to_string(),
reason: String::new(),
}]);
let _ = gate.ask(&[exec_request("rm")]);
let body = std::fs::read_to_string(&log).unwrap();
let records: Vec<newt_core::PermissionRecord> = body
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(records.len(), 3);
assert!(records.iter().all(|r| r.conversation_id == "conv-test"));
assert_eq!(
(
records[0].tool.as_str(),
records[0].kind.as_str(),
records[0].target.as_str()
),
("run_command", "exec", "npm")
);
assert_eq!(
(records[0].decision.as_str(), records[0].scope.as_str()),
("allow", "once")
);
assert_eq!(
(records[1].kind.as_str(), records[1].scope.as_str()),
("net", "session")
);
assert_eq!(
(records[2].decision.as_str(), records[2].scope.as_str()),
("deny", "once")
);
assert_eq!(state.decisions, records);
}
#[serial_test::serial(real_fs)]
#[test]
fn allow_remints_from_the_user_root_and_never_widens_the_baseline() {
let dir = tempfile::TempDir::new().unwrap();
let key_path = dir.path().join("identity.pem");
let prompts = Rc::new(Cell::new(0));
let base = base_caveats("/ws");
let mut state = PermissionPromptState::default();
let mut gate = scripted_gate(
&mut state,
base.clone(),
Some(key_path.clone()),
None,
vec![PromptChoice::AllowSession],
prompts.clone(),
);
let minted = match gate.ask(&[exec_request("npm")]) {
newt_core::PermissionDecision::Allow(c) => c,
newt_core::PermissionDecision::Deny => panic!("expected allow"),
};
assert!(
key_path.exists(),
"the user root key was used for the re-mint"
);
assert!(minted.permits_exec("npm"));
assert!(minted.permits_exec("cargo"));
assert!(!minted.permits_exec("rm"));
drop(gate);
assert_eq!(base, base_caveats("/ws"));
let policy = newt_core::widen_caveats(&base, &[(DenialKind::Exec, "npm".to_string())]);
let key = mint_operating_key(&key_path, &policy).unwrap();
assert_eq!(newt_identity::enforced_caveats(&key).unwrap(), minted);
}
#[serial_test::serial(real_fs)]
#[tokio::test]
async fn execute_tool_with_tui_gate_allow_once_then_reprompt() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("outside.txt"), "gated contents").unwrap();
let caveats = base_caveats("/elsewhere");
let prompts = Rc::new(Cell::new(0));
let mut state = PermissionPromptState::default();
let mut gate = scripted_gate(
&mut state,
caveats.clone(),
None,
None,
vec![PromptChoice::AllowOnce, PromptChoice::Deny],
prompts.clone(),
);
let args = serde_json::json!({"path": "outside.txt"});
let out = newt_core::agentic::execute_tool(
"read_file",
&args,
&ws.path().to_string_lossy(),
false,
20,
&caveats,
&mut Mcp::empty(),
None,
None,
None,
None, Some(&mut gate),
None,
None, None, None, None,
None, None, None, )
.await;
assert_eq!(out, "gated contents", "allow-once executed the real read");
assert_eq!(prompts.get(), 1);
let out = newt_core::agentic::execute_tool(
"read_file",
&args,
&ws.path().to_string_lossy(),
false,
20,
&caveats,
&mut Mcp::empty(),
None,
None,
None,
None, Some(&mut gate),
None,
None, None, None, None,
None, None, None, )
.await;
assert!(
out.starts_with("capability denied: fs_read does not permit 'outside.txt'"),
"got: {out}"
);
assert!(out.contains("request_permissions"), "got: {out}");
assert_eq!(prompts.get(), 2, "allow-once does not stick");
drop(gate);
assert_eq!(state.decisions.len(), 2);
}
#[serial_test::serial(real_fs)]
#[tokio::test]
async fn execute_tool_with_tui_gate_session_allow_holds_across_turns() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("outside.txt"), "gated contents").unwrap();
let caveats = base_caveats("/elsewhere");
let prompts = Rc::new(Cell::new(0));
let mut state = PermissionPromptState::default();
let args = serde_json::json!({"path": "outside.txt"});
for _turn in 0..2 {
let mut gate = scripted_gate(
&mut state,
caveats.clone(),
None,
None,
vec![PromptChoice::AllowSession],
prompts.clone(),
);
let out = newt_core::agentic::execute_tool(
"read_file",
&args,
&ws.path().to_string_lossy(),
false,
20,
&caveats,
&mut Mcp::empty(),
None,
None,
None,
None, Some(&mut gate),
None,
None, None, None, None,
None, None, None, )
.await;
assert_eq!(out, "gated contents");
}
assert_eq!(prompts.get(), 1, "one prompt for the whole session");
assert_eq!(state.decisions.len(), 1);
assert_eq!(state.decisions[0].scope, "session");
}
#[test]
fn prompting_configured_from_flag_or_config_off_by_default() {
assert!(!permission_prompting_configured(false, None));
let mut tui = newt_core::TuiConfig::default();
assert!(!permission_prompting_configured(false, Some(&tui)));
assert!(permission_prompting_configured(true, None));
tui.permissions.prompt = true;
assert!(permission_prompting_configured(false, Some(&tui)));
assert!(permission_prompting_configured(true, Some(&tui)));
}
#[test]
fn should_prompt_permissions_defaults_on_interactive_and_off_headless() {
assert!(should_prompt_permissions(false, false, true, false));
assert!(should_prompt_permissions(true, false, true, false));
assert!(!should_prompt_permissions(true, false, true, true));
assert!(!should_prompt_permissions(true, false, false, false));
assert!(!should_prompt_permissions(false, false, false, false));
assert!(!should_prompt_permissions(false, true, true, false));
assert!(!should_prompt_permissions(true, true, true, false));
}
#[serial_test::serial(prompt_stdin)]
#[test]
fn headless_and_piped_sessions_never_construct_a_prompt_window() {
let before = newt_core::tty::prompt_windows_constructed();
for configured_on in [false, true] {
for explicit_off in [false, true] {
for interactive in [false, true] {
assert!(
!should_prompt_permissions(configured_on, explicit_off, interactive, true),
"headless prompted (configured_on={configured_on} \
explicit_off={explicit_off} interactive={interactive})"
);
}
assert!(
!should_prompt_permissions(configured_on, explicit_off, false, false),
"a non-interactive session prompted (configured_on={configured_on} \
explicit_off={explicit_off})"
);
}
}
assert_eq!(
newt_core::tty::prompt_windows_constructed(),
before,
"a default-denied session must reach its denial without the terminal \
ever being suspended for a question"
);
}
#[test]
fn permissions_command_lists_decisions_and_log_location() {
let mut state = PermissionPromptState::default();
let lines = permissions_command_lines(&state, false, None, None);
assert!(lines[0].contains("OFF"), "got: {lines:?}");
assert!(lines
.iter()
.any(|l| l.contains("no prompted permission decisions")));
state.decisions.push(newt_core::PermissionRecord::new(
"conv-1",
"run_command",
DenialKind::Exec,
"npm",
"allow",
"session",
));
let log = std::path::PathBuf::from("/home/u/.newt/permission-log.jsonl");
let lines = permissions_command_lines(&state, true, Some(&log), None);
assert!(lines
.iter()
.any(|l| l.contains("exec:npm") && l.contains("run_command")));
assert!(lines.iter().any(|l| l.contains("permission-log.jsonl")));
assert!(lines.iter().any(|l| l.contains("never authority")));
assert!(!lines[0].contains("OFF"));
}
#[test]
fn permissions_command_reflects_the_active_posture() {
let state = PermissionPromptState::default();
let preset = newt_core::NamedPermissionPreset {
fs_read: None,
readonly: true,
exec_allow: vec!["git".to_string()],
deny: vec!["*".to_string()],
max_calls: Some(40),
};
let posture = ActivePosture {
name: "triage".to_string(),
preset_name: "readonly-triage".to_string(),
clamp: preset.clamp(),
clamp_summary: preset.summary(),
skill_body: None,
framing: None,
};
let lines = permissions_command_lines(&state, false, None, Some(&posture));
assert!(
lines[0].contains("active permission posture: triage")
&& lines[0].contains("readonly-triage")
&& lines[0].contains("readonly"),
"got: {lines:?}"
);
assert!(
lines.iter().any(|l| l.contains("WINS over --disable-ocap")),
"the floor property is surfaced: {lines:?}"
);
}
#[test]
fn help_lists_the_permissions_command() {
assert!(help_lines().iter().any(|l| l.contains("/permissions")));
}
#[test]
fn help_lists_the_mode_and_posture_commands() {
assert!(help_lines().iter().any(|l| l.contains("/mode")));
assert!(help_lines().iter().any(|l| l.contains("/posture")));
}
#[test]
fn help_lists_the_start_and_rename_commands() {
assert!(help_lines().iter().any(|l| l.contains("/start")));
assert!(help_lines().iter().any(|l| l.contains("/rename")));
}
#[test]
fn close_out_message_reflects_the_rotation_kind() {
assert_eq!(close_out_message("new", "NEW", true), "NEW");
assert!(close_out_message("start", "NEW", true).contains("stays open"));
assert!(close_out_message("start", "NEW", true).contains("/resume"));
let end = close_out_message("end", "NEW", true);
assert!(end.starts_with("Conversation ended"), "{end}");
assert!(end.contains("/resume to reopen"), "{end}");
assert!(
!end.starts_with("NEW"),
"end must not headline the new conversation: {end}"
);
assert!(close_out_message("restart", "NEW", true).contains("/resume to reopen"));
assert_eq!(close_out_message("start", "NEW", false), "NEW");
let end_empty = close_out_message("end", "NEW", false);
assert!(end_empty.starts_with("Conversation ended"), "{end_empty}");
assert!(
!end_empty.contains("/resume"),
"nothing to reopen: {end_empty}"
);
}
}