use std::io;
#[cfg(feature = "rich-tui")]
use std::io::IsTerminal as _;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use crate::danger;
use crate::mint_operating_key;
use newt_core::agentic::{newt_line, print_newt};
use newt_core::interaction_adapter::{action_for_option, role_of, DECISION_CONTROL};
use newt_core::interaction_surface::SurfaceInteraction;
use newt_core::markup::plain;
use newt_core::tty::{
modal_prompt_controls, read_prompt_window_line, ControlReader, Echo, PromptLine as ModalLine,
PromptWindow, Terminal, MODAL_CONTROL_HINT, MODAL_INPUT_GLYPH,
};
use newt_core::HumanQuestionOutcome;
#[path = "permission_workflow.rs"]
mod workflow;
pub(crate) use newt_core::PermissionAction as PromptChoice;
pub(crate) use workflow::{load_session_grants, PermissionWorkflow};
use newt_interaction::{
Audience, ChoiceOption, Control, ControlId, ControlKind, InteractionDefinition,
InteractionKind, OptionId, Requirement,
};
const ANSWER_CONTROL: &str = "answer";
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)
}
pub(crate) fn durable_permission_config_target(
pinned: Option<std::path::PathBuf>,
ambient_base_exists: bool,
user: Option<std::path::PathBuf>,
) -> Option<std::path::PathBuf> {
pinned.or(if ambient_base_exists { None } else { user })
}
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
}
#[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" && !req.harness_bound
}
pub(crate) const WEB_DECISION_TIMEOUT: Duration = Duration::from_secs(4 * 60);
struct OfferedAction {
action: PromptChoice,
key: &'static str,
label: &'static str,
}
fn permission_policy(
req: &newt_core::PermissionRequest,
danger: &danger::DangerTable,
audience: Audience,
) -> (Vec<OfferedAction>, Option<String>) {
use newt_core::DenialKind;
let tier = danger.classify(req.kind, &req.target);
let terminal = matches!(audience, Audience::Terminal);
let mut actions = vec![OfferedAction {
action: PromptChoice::AllowOnce,
key: "a",
label: "allow once",
}];
let one_shot = req.tool == "lifecycle" && req.kind == DenialKind::Net;
if !one_shot && (tier == danger::DangerTier::Low || req.kind == DenialKind::Build) {
actions.push(OfferedAction {
action: PromptChoice::AllowSession,
key: "s",
label: "session allow",
});
if terminal
&& matches!(
req.kind,
DenialKind::Exec | DenialKind::FsRead | DenialKind::FsWrite | DenialKind::Net
)
{
actions.push(OfferedAction {
action: PromptChoice::AllowPermanent,
key: "A",
label: "Allow permanently (signs a durable grant)",
});
}
}
actions.push(OfferedAction {
action: PromptChoice::Deny,
key: "d",
label: "deny",
});
if terminal {
actions.push(OfferedAction {
action: PromptChoice::DenyAlways,
key: "D",
label: "Deny always",
});
actions.push(OfferedAction {
action: PromptChoice::DenyPermanent,
key: "P",
label: "Permanently deny",
});
}
let note = match (tier, audience) {
(danger::DangerTier::High, Audience::Terminal) if req.kind == DenialKind::Build => Some(
format!("confined build authority: session allow covers build/test/check for this workspace this session\n{MODAL_CONTROL_HINT}"),
),
(danger::DangerTier::High, Audience::Web) if req.kind == DenialKind::Build => Some(
format!("Confined build authority: session authorization is available.\n{MODAL_CONTROL_HINT}"),
),
(danger::DangerTier::High, Audience::Terminal) => Some(format!(
"high-danger: session allow refused; key allow / step-up is the future path, P3\n{MODAL_CONTROL_HINT}"
)),
(danger::DangerTier::High, Audience::Web) => Some(format!(
"High danger: session authorization is unavailable.\n{MODAL_CONTROL_HINT}"
)),
_ => Some(MODAL_CONTROL_HINT.into()),
};
(actions, note)
}
pub(crate) fn permission_definition(
req: &newt_core::PermissionRequest,
danger: &danger::DangerTable,
audience: Audience,
) -> InteractionDefinition {
permission_definition_with_default(req, danger, audience, PromptChoice::Deny).0
}
pub(crate) fn permission_interaction(
req: &newt_core::PermissionRequest,
danger: &danger::DangerTable,
configured_default: PromptChoice,
) -> SurfaceInteraction {
let configured_default = if req.tool == "mcp connect" && req.kind == newt_core::DenialKind::Net
|| matches!(
configured_default,
PromptChoice::AllowOnce | PromptChoice::Deny
) {
configured_default
} else {
PromptChoice::Deny
};
let (definition, default) = if configured_default == PromptChoice::Deny {
(
permission_definition(req, danger, Audience::Terminal),
PromptChoice::Deny,
)
} else {
permission_definition_with_default(req, danger, Audience::Terminal, configured_default)
};
SurfaceInteraction::blocking(definition)
.with_default_option(OptionId::new(default.as_str()).expect(WIRE_NAMES_ARE_OPTION_IDS))
}
fn permission_definition_with_default(
req: &newt_core::PermissionRequest,
danger: &danger::DangerTable,
audience: Audience,
default: PromptChoice,
) -> (InteractionDefinition, PromptChoice) {
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 if req.tool == "mcp connect" => {
("reach", "MCP connection requires a named host grant")
}
DenialKind::Net => ("reach", "outside the granted net allowlist"),
DenialKind::RemoteTool => ("call", "requires operator permission"),
DenialKind::Build => ("build/test", "requires confined build authority"),
DenialKind::GitWrite => (
"commit/stage via git",
"outside the granted git-write authority",
),
};
let axis = if req.tool == "request_permissions" {
"model requested permission"
} else {
axis
};
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 (offered, note) = permission_policy(req, danger, audience);
let default = if offered.iter().any(|offer| offer.action == default) {
default
} else {
PromptChoice::Deny
};
let options = offered
.into_iter()
.map(|offer| ChoiceOption {
id: OptionId::new(offer.action.as_str()).expect(WIRE_NAMES_ARE_OPTION_IDS),
role: role_of(offer.action),
label: if offer.action == default {
format!("{} (default)", offer.label)
} else {
offer.label.to_string()
},
key: offer.key.to_string(),
aliases: Vec::new(),
})
.collect();
let controls = vec![Control {
id: ControlId::new(DECISION_CONTROL).expect(WIRE_NAMES_ARE_OPTION_IDS),
kind: ControlKind::Choice { options },
label: String::new(),
requirement: Requirement::Required,
}];
let kind = if newt_interaction::controls_are_decision_shaped(&controls) {
InteractionKind::Confirm
} else {
InteractionKind::Choice
};
let mut definition = InteractionDefinition::new(
kind,
format!(
"\u{2298} {} wants to {verb} `{}` \u{2014} {axis}.\n{blast}{reason}",
req.tool, req.target
)
.trim_end(),
controls,
);
definition.note = note;
(definition, default)
}
const WIRE_NAMES_ARE_OPTION_IDS: &str =
"every PermissionAction wire name is a valid option id (frozen set; see \
b0a::every_offered_action_is_a_valid_option_id)";
#[cfg(test)]
pub(crate) fn offered_actions(definition: &InteractionDefinition) -> Vec<PromptChoice> {
definition
.controls
.iter()
.find_map(|c| match &c.kind {
ControlKind::Choice { options } => Some(options),
_ => None,
})
.map(|options| {
options
.iter()
.filter_map(|o| action_for_option(o.id.as_str()))
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
pub(crate) fn offers(definition: &InteractionDefinition, action: PromptChoice) -> bool {
definition.controls.iter().any(|c| match &c.kind {
ControlKind::Choice { options } => options.iter().any(|o| o.id.as_str() == action.as_str()),
_ => false,
})
}
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,
interaction: &SurfaceInteraction,
) -> PromptChoice {
let definition = &interaction.definition;
let prompt = format!("{}\n{MODAL_INPUT_GLYPH}", plain::render(definition));
match read_prompt_window_line(w, &prompt, Echo::Chars) {
Ok(ModalLine::Line(answer)) => {
decode_answer(definition, interaction.answer_or_default(&answer))
}
Ok(ModalLine::Back) => PromptChoice::Back,
Ok(ModalLine::Exit) => PromptChoice::Exit,
Ok(ModalLine::Eof) | Err(_) => PromptChoice::Deny,
}
}
fn resolve_answer(definition: &InteractionDefinition, answer: &str) -> Option<PromptChoice> {
let options = definition.controls.iter().find_map(|c| match &c.kind {
newt_core::ControlKind::Choice { options } => Some(options),
_ => None,
})?;
newt_interaction::binding::resolve_typed(options, answer)
.and_then(|option| action_for_option(option.as_str()))
}
fn decode_answer(definition: &InteractionDefinition, answer: &str) -> PromptChoice {
resolve_answer(definition, answer).unwrap_or(PromptChoice::Deny)
}
struct WebWaitIo<'a, 'w> {
reacquire: &'a mut dyn FnMut() -> io::Result<Box<dyn ControlReader + 'w>>,
now: &'a dyn Fn() -> Instant,
sleep: &'a mut dyn FnMut(Duration),
notify: &'a mut dyn FnMut(&str),
}
fn lost_to_web_message(mine: PromptChoice, winner: PromptChoice) -> String {
format!(
"the web answered first — `{}` was applied, not your `{}`",
winner.as_str(),
mine.as_str()
)
}
fn approve_entry_for(
kind: newt_core::DenialKind,
target: &str,
) -> Option<Result<newt_core::ocap_store::ApproveEntry, String>> {
use newt_core::ocap_store::ApproveEntry;
match kind {
newt_core::DenialKind::Exec => {
let is_path = target.contains('/') || target.contains('\\');
if is_path && !std::path::Path::new(target).is_absolute() {
return Some(Err(format!(
"permanent allow refused: `{target}` is a relative path — grant a \
bare command name or an absolute path instead"
)));
}
Some(Ok(ApproveEntry::Exec {
target: target.to_string(),
}))
}
newt_core::DenialKind::FsRead => Some(Ok(ApproveEntry::Fs {
path: target.to_string(),
write: false,
})),
newt_core::DenialKind::FsWrite => Some(Ok(ApproveEntry::Fs {
path: target.to_string(),
write: true,
})),
newt_core::DenialKind::Net => Some(Ok(ApproveEntry::Net {
host: target.to_string(),
})),
newt_core::DenialKind::RemoteTool
| newt_core::DenialKind::GitWrite
| newt_core::DenialKind::Build => None,
}
}
fn normalized_danger_target(kind: newt_core::DenialKind, target: &str) -> String {
match kind {
newt_core::DenialKind::FsRead | newt_core::DenialKind::FsWrite => {
newt_core::ocap_store::normalize_fs_path(target).unwrap_or_else(|_| target.to_string())
}
_ => target.to_string(),
}
}
fn no_key_notice(key_path: Option<&std::path::Path>) -> String {
let key_path_desc = key_path
.map(|p| p.display().to_string())
.unwrap_or_else(|| "no key_path configured".to_string());
format!(
"permanent allow unavailable (no root key at `{key_path_desc}` in this \
session): granted for this session only — run `newt doctor` or restore \
~/.newt/identity.pem"
)
}
fn axis_label(kind: newt_core::DenialKind) -> &'static str {
match kind {
newt_core::DenialKind::Exec => "exec",
newt_core::DenialKind::FsRead => "fs (read)",
newt_core::DenialKind::FsWrite => "fs (write)",
newt_core::DenialKind::Net => "net",
newt_core::DenialKind::RemoteTool => "remote tool",
newt_core::DenialKind::GitWrite => "git write",
newt_core::DenialKind::Build => "build",
}
}
fn decision_scope(choice: PromptChoice) -> &'static str {
match choice {
PromptChoice::AllowOnce => "once",
PromptChoice::AllowSession => "session",
PromptChoice::AllowPermanent => "permanent",
PromptChoice::Deny => "once",
PromptChoice::DenyAlways => "session",
PromptChoice::DenyPermanent => "permanent",
PromptChoice::Back | PromptChoice::Exit => "control",
}
}
pub(crate) fn choice_form(
question: &str,
options: &[newt_core::agentic::ChoiceSpec],
) -> SurfaceInteraction {
let choices: Vec<ChoiceOption> = options
.iter()
.map(|option| ChoiceOption {
id: OptionId::new(option.id).expect(WIRE_NAMES_ARE_OPTION_IDS),
role: option.role,
label: option.label.to_string(),
key: option.key.to_string(),
aliases: Vec::new(),
})
.collect();
let controls = vec![Control {
id: ControlId::new(DECISION_CONTROL).expect(WIRE_NAMES_ARE_OPTION_IDS),
kind: ControlKind::Choice { options: choices },
label: String::new(),
requirement: Requirement::Required,
}];
let kind = if newt_interaction::controls_are_decision_shaped(&controls) {
InteractionKind::Confirm
} else {
InteractionKind::Choice
};
let definition = InteractionDefinition {
note: Some(MODAL_CONTROL_HINT.into()),
..InteractionDefinition::new(kind, question.to_string(), controls)
};
let interaction = SurfaceInteraction::blocking(definition);
match options.first() {
Some(first) => interaction
.with_default_option(OptionId::new(first.id).expect(WIRE_NAMES_ARE_OPTION_IDS)),
None => interaction,
}
}
pub(crate) fn free_text_form(question: &str) -> InteractionDefinition {
InteractionDefinition {
note: Some(MODAL_CONTROL_HINT.into()),
..InteractionDefinition::new(
InteractionKind::Prompt,
format!("Prompt — {question}"),
vec![Control {
id: ControlId::new(ANSWER_CONTROL).expect(
"`answer` is a valid control id (non-empty, drawn from \
[A-Za-z0-9_-]); it is a const, so this cannot vary at \
runtime",
),
kind: ControlKind::Text,
label: String::new(),
requirement: Requirement::Required,
}],
)
}
}
pub(crate) fn present_on_terminal(
w: &PromptWindow,
interaction: &SurfaceInteraction,
) -> HumanQuestionOutcome {
let outcome = newt_core::interaction_terminal::present_on_terminal(w, interaction);
let (outcome, notice) = apply_chat_prompt_policy(outcome);
if let Some(notice) = notice {
w.notice(notice).ok();
}
outcome
}
#[cfg(all(unix, feature = "rich-tui"))]
pub(crate) fn present_on_terminal_with_width(
w: &PromptWindow,
interaction: &SurfaceInteraction,
width: usize,
) -> (HumanQuestionOutcome, Option<&'static str>) {
let outcome =
newt_core::interaction_terminal::present_on_terminal_with_width(w, interaction, width);
apply_chat_prompt_policy(outcome)
}
pub(crate) const SLASH_COMMAND_PROMPT_NOTICE: &str =
"(slash commands aren't answers; press Esc, then use the command at the chat prompt)";
pub(crate) fn apply_chat_prompt_policy(
outcome: HumanQuestionOutcome,
) -> (HumanQuestionOutcome, Option<&'static str>) {
if let HumanQuestionOutcome::Answer(line) = &outcome {
if is_slash_command_at_prompt(line) {
return (
HumanQuestionOutcome::Cancelled,
Some(SLASH_COMMAND_PROMPT_NOTICE),
);
}
}
(outcome, None)
}
fn is_slash_command_at_prompt(answer: &str) -> bool {
answer.trim_start().starts_with('/')
}
#[cfg(test)]
#[path = "permissions_tests/slash_prompt_tests.rs"]
mod slash_prompt_tests;
#[cfg(test)]
#[path = "permissions_tests/build_grant_tests.rs"]
mod build_grant_tests;
#[cfg(test)]
#[path = "permissions_tests/workspace_protection.rs"]
mod workspace_protection_tests;
#[derive(Default)]
pub(crate) struct PermissionPromptState {
pub(crate) protection:
Option<std::sync::Arc<newt_core::workspace_protection::WorkspaceProtection>>,
pub(crate) prompt_default: Option<PromptChoice>,
pub(crate) mcp_net_prompt_default: Option<PromptChoice>,
pub(crate) web_store: Option<newt_core::ConversationStore>,
pub(crate) workspace_key: String,
session_grants: std::collections::BTreeSet<(newt_core::DenialKind, String)>,
pub(crate) durable_grants: newt_core::durable_grants::GrantSet,
rejected_durable_grants: std::cell::RefCell<newt_core::durable_grants::GrantSet>,
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)
}
fn ceiling_permits(
ceiling: &newt_core::Caveats,
kind: newt_core::DenialKind,
target: &str,
) -> bool {
use newt_core::caveats::CaveatsExt as _;
match kind {
newt_core::DenialKind::Exec => ceiling.permits_exec(target),
newt_core::DenialKind::FsRead => ceiling.permits_fs_read(target),
newt_core::DenialKind::FsWrite => ceiling.permits_fs_write(target),
newt_core::DenialKind::Net => ceiling.permits_net(target),
newt_core::DenialKind::GitWrite => {
newt_core::git_caveats::GitCaveats::from_session(ceiling).permits_commit()
}
newt_core::DenialKind::RemoteTool => false,
newt_core::DenialKind::Build => {
newt_core::confined_exec::build_tool_caveats(std::path::Path::new(target)).leq(ceiling)
}
}
}
pub(crate) fn session_grant_covers(
grants: &std::collections::BTreeSet<(newt_core::DenialKind, String)>,
req: &newt_core::PermissionRequest,
) -> bool {
grants.contains(&(req.kind, req.target.clone()))
|| (req.kind == newt_core::DenialKind::Exec
&& newt_core::confined_exec::is_build_tool_exec(&req.target)
&& grants
.iter()
.any(|(kind, _)| *kind == newt_core::DenialKind::Build))
}
fn covering_build_grant_workspace<'a>(
grants: &'a std::collections::BTreeSet<(newt_core::DenialKind, String)>,
baseline: &newt_core::Caveats,
) -> Option<&'a str> {
let cwd_roots: Vec<&str> = match &baseline.fs_write {
newt_core::Scope::Only(set) => set.iter().map(String::as_str).collect(),
newt_core::Scope::All => Vec::new(),
};
let build_workspaces = || {
grants
.iter()
.filter(|(kind, _)| *kind == newt_core::DenialKind::Build)
.map(|(_, ws)| ws.as_str())
};
build_workspaces()
.find(|ws| {
cwd_roots
.iter()
.any(|root| std::path::Path::new(root).starts_with(ws))
})
.or_else(|| build_workspaces().next())
}
pub(crate) fn take_pending_once(
pending: &mut std::collections::BTreeSet<(newt_core::DenialKind, String)>,
req: &newt_core::PermissionRequest,
) -> Option<(newt_core::DenialKind, String)> {
pending.take(&(req.kind, req.target.clone()))
}
impl PermissionPromptState {
fn denied(&self, kind: newt_core::DenialKind, target: &str) -> bool {
let denied = |target: &str| {
let key = (kind, target.to_string());
self.rejected_durable_grants.borrow().contains(&key)
|| self.session_denials.contains(&key)
|| self.persistent_denials.contains(&key)
|| newt_core::ocap_store::evaluate_request(&self.ocap_policy, kind, target)
== Some(newt_core::ocap_store::Verdict::Deny)
};
denied(target)
|| (kind == newt_core::DenialKind::Exec && denied(exec_grant_basename(target)))
}
fn recall_conflicts_with_denial(&self, kind: newt_core::DenialKind, target: &str) -> bool {
use newt_core::{ocap_store::Verdict, DenialKind};
let overlaps = |denied: &str| match kind {
DenialKind::FsRead | DenialKind::FsWrite => newt_core::caveats::permits_path(
&newt_core::caveats::Scope::only([target.to_string()]),
denied,
),
DenialKind::Exec => {
target == exec_grant_basename(denied) || denied == exec_grant_basename(target)
}
_ => target == denied,
};
self.denied(kind, target)
|| self
.session_denials
.iter()
.chain(&self.persistent_denials)
.any(|(denied_kind, denied)| *denied_kind == kind && overlaps(denied))
|| self
.ocap_policy
.files
.get(&Verdict::Deny)
.is_some_and(|policy| match kind {
DenialKind::FsRead | DenialKind::FsWrite => {
policy.fs.iter().any(|entry| overlaps(&entry.path))
}
DenialKind::Exec => policy.exec.iter().any(|entry| overlaps(&entry.target)),
_ => false,
})
}
fn recalled_grants<'a>(
&'a self,
danger: &'a danger::DangerTable,
) -> impl Iterator<Item = &'a (newt_core::DenialKind, String)> {
self.session_grants
.iter()
.chain(&self.durable_grants)
.filter(|(kind, target)| {
!self.recall_conflicts_with_denial(*kind, target)
&& danger.classify(*kind, target) != danger::DangerTier::High
&& self.protection.as_ref().is_none_or(|guard| {
match guard.validate_request(*kind, target) {
Ok(()) => true,
Err(error) => {
self.reject_durable_grant(*kind, target, &error);
false
}
}
})
})
}
pub(crate) fn fold_ocap_approvals(&mut self) -> usize {
let grants = newt_core::ocap_store::approved_grants(&self.ocap_policy);
let count = grants.len();
self.durable_grants.extend(grants);
count
}
pub(crate) fn recalled_caveats(
&self,
base: &newt_core::Caveats,
ceiling: Option<&newt_core::Caveats>,
) -> anyhow::Result<newt_core::Caveats> {
let clamp = |policy: newt_core::Caveats| {
ceiling.map_or_else(|| policy.clone(), |ceiling| policy.meet(ceiling))
};
let mut policy = clamp(base.clone());
self.validate_caveats(&policy)?;
for (kind, target) in self.recalled_grants(&production_danger_table()) {
let candidate = clamp(newt_core::widen_caveats(
&policy,
&[(*kind, target.clone())],
));
match self.validate_caveats(&candidate) {
Ok(()) => policy = candidate,
Err(error) if self.reject_durable_grant(*kind, target, &error) => {}
Err(error) => return Err(error),
}
}
Ok(policy)
}
fn reject_durable_grant(
&self,
kind: newt_core::DenialKind,
target: &str,
error: &anyhow::Error,
) -> bool {
let grant = (kind, target.to_owned());
if !self.durable_grants.contains(&grant) {
return false;
}
let mut rejected = self.rejected_durable_grants.borrow_mut();
let reported = rejected.iter().any(|(_, path)| path == target);
rejected.insert(grant);
if !reported {
let reason = error.to_string();
let suffix = format!("; {}", newt_core::ocap_store::REPAIR_HINT);
let reason = reason.strip_suffix(&suffix).unwrap_or(&reason);
eprintln!("warning: dropped durable grant {target:?} for this session: {reason:?}; inspect ~/.newt/ocap/approve.toml; {}", newt_core::ocap_store::REPAIR_HINT);
}
true
}
fn widen_caveats(
&self,
base: &newt_core::Caveats,
grants: &[(newt_core::DenialKind, String)],
) -> newt_core::Caveats {
newt_core::widen_caveats(base, grants)
}
fn validate_caveats(&self, caveats: &newt_core::Caveats) -> anyhow::Result<()> {
if let Some(protection) = &self.protection {
protection.validate_caveats(caveats)?;
}
Ok(())
}
pub(crate) fn retained_net_hosts(&self) -> Vec<String> {
self.recalled_grants(&production_danger_table())
.filter(|(kind, _)| *kind == newt_core::DenialKind::Net)
.map(|(_, target)| target.clone())
.collect()
}
pub(crate) fn session_allow_count(&self) -> usize {
self.session_grants.len()
}
pub(crate) fn terminal_default(&self) -> PromptChoice {
self.prompt_default.unwrap_or(PromptChoice::AllowOnce)
}
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, &SurfaceInteraction) -> PromptChoice,
> {
pub(crate) pending_command_retries: std::collections::BTreeMap<
(newt_identity::RawContentId, newt_identity::RawContentId),
Vec<(newt_core::DenialKind, String)>,
>,
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) delegation: Option<&'a newt_identity::VerifiedDelegation>,
pub(crate) danger: danger::DangerTable,
pub(crate) color: bool,
pub(crate) verbose: bool,
pub(crate) authorization_prompts_enabled: bool,
pub(crate) web_decision_timeout: Duration,
pub(crate) cancel: Option<&'a AtomicBool>,
pub(crate) exit: Option<&'a AtomicBool>,
pub(crate) ask_human: F,
pub(crate) ask_surface: Option<&'a dyn Fn(&SurfaceInteraction) -> HumanQuestionOutcome>,
#[cfg(feature = "rich-tui")]
pub(crate) open_panel: Option<&'a dyn Fn(u16) -> Option<crate::session_worker::PanelWindow>>,
}
fn direct_authorization_window() -> PromptWindow {
Terminal::suspend_for_prompt(newt_core::tty::TerminalTaker::PermissionAuthorization)
}
impl<F: FnMut(&PromptWindow, &SurfaceInteraction) -> PromptChoice> PromptPermissionGate<'_, F> {
#[cfg(feature = "rich-tui")]
pub(crate) fn retained_net_hosts(&self) -> Vec<String> {
self.state.retained_net_hosts()
}
pub(crate) fn ask_mcp_net_grant(
&mut self,
request: &newt_core::PermissionRequest,
) -> Option<(newt_core::Caveats, Vec<String>, bool)> {
if request.kind != newt_core::DenialKind::Net {
return None;
}
match newt_core::PermissionGate::ask(self, std::slice::from_ref(request)) {
newt_core::PermissionDecision::Allow(caveats) => {
use newt_core::CaveatsExt as _;
if !caveats.permits_net(&request.target) {
return None;
}
let mut hosts: Vec<_> = self
.state
.recalled_grants(&self.danger)
.filter(|(kind, _)| *kind == newt_core::DenialKind::Net)
.filter(|(_, host)| caveats.permits_net(host))
.map(|(_, host)| host.clone())
.collect();
if !hosts.contains(&request.target) {
hosts.push(request.target.clone());
}
Some((
caveats,
hosts,
session_grant_covers(&self.state.session_grants, request)
|| self
.state
.durable_grants
.contains(&(request.kind, request.target.clone())),
))
}
newt_core::PermissionDecision::Deny => None,
}
}
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) = newt_core::permission_journal::append_record(path, rec.clone()) {
print_newt(
&format!("warning: permission log write failed: {e}"),
self.color,
self.verbose,
);
}
}
self.state.decisions.push(rec);
}
fn policy_with_grants(
&self,
base: &newt_core::Caveats,
once_grants: &[(newt_core::DenialKind, String)],
) -> anyhow::Result<newt_core::Caveats> {
let mut grants: Vec<(newt_core::DenialKind, String)> =
self.state.recalled_grants(&self.danger).cloned().collect();
grants.extend(once_grants.iter().cloned());
let mut policy = self.state.widen_caveats(base, &grants);
if let Some(clamp) = &self.preset_clamp {
policy = policy.meet(clamp);
}
if let Some(d) = self.delegation {
policy = policy.meet(d.caveats());
}
self.state.validate_caveats(&policy)?;
Ok(policy)
}
fn mint(
&self,
base: &newt_core::Caveats,
once_grants: &[(newt_core::DenialKind, String)],
) -> anyhow::Result<newt_core::Caveats> {
let policy = self.policy_with_grants(base, once_grants)?;
if self.delegation.is_some() {
return Ok(policy);
}
let policy = 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,
};
self.state.validate_caveats(&policy)?;
Ok(policy)
}
pub(crate) fn current_caveats(&self) -> anyhow::Result<newt_core::Caveats> {
self.mint(&self.base, &[])
}
fn await_web_decision(
&self,
store: &newt_core::ConversationStore,
req: &newt_core::PermissionRequest,
) -> (PromptChoice, &'static str) {
let definition = permission_definition(req, &self.danger, Audience::Web);
let tier = if self.danger.classify(req.kind, &req.target) == danger::DangerTier::High {
newt_core::interaction_offer::OfferDanger::High
} else {
newt_core::interaction_offer::OfferDanger::Low
};
let request_id = match store.publish_interaction_offer(
&self.conversation_id,
&definition,
tier,
&[Audience::Terminal, Audience::Web],
) {
Ok(id) => id,
Err(_) => return (PromptChoice::Deny, "web-unavailable"),
};
#[cfg(feature = "rich-tui")]
let interaction = {
let mut displayed = definition.clone();
let context = "You or the web may answer; whoever answers first decides.";
displayed.note = Some(match displayed.note.take() {
Some(note) => format!("{context}\n{note}"),
None => context.into(),
});
SurfaceInteraction::blocking(displayed)
};
#[cfg(feature = "rich-tui")]
let panel = self.open_panel.and_then(|open| {
let cols = crossterm::terminal::size().map_or(80, |(cols, _)| cols);
open(crate::interaction_view::requested_rows(&interaction, cols))
});
#[cfg(feature = "rich-tui")]
if self.open_panel.is_some() && panel.is_none() {
return self
.web_abort_choice(
store,
&self.conversation_id,
&request_id,
PromptChoice::Deny,
)
.unwrap_or((PromptChoice::Deny, "web-unavailable"));
}
#[cfg(feature = "rich-tui")]
let window = match panel.as_ref() {
Some(panel) => match panel.prompt_window() {
Ok(window) => window,
Err(_) => {
return self
.web_abort_choice(
store,
&self.conversation_id,
&request_id,
PromptChoice::Deny,
)
.unwrap_or((PromptChoice::Deny, "web-unavailable"));
}
},
None => direct_authorization_window(),
};
#[cfg(not(feature = "rich-tui"))]
let window = direct_authorization_window();
let w = &window;
#[cfg(feature = "rich-tui")]
if panel.is_some() || (io::stdin().is_terminal() && io::stdout().is_terminal()) {
let mut notices = Vec::new();
let result = self.run_web_wait(
store,
&request_id,
&definition,
w,
&mut WebWaitIo {
reacquire: &mut || {
match panel.as_ref() {
Some(panel) => panel.terminal().and_then(|terminal| {
crate::interaction_view::reader_for_shared_decision(
terminal,
&interaction,
)
}),
None => {
crate::interaction_view::reader_for_shared_decision_on_terminal(
&interaction,
)
}
}
.map(|reader| Box::new(reader) as Box<dyn ControlReader>)
},
now: &Instant::now,
sleep: &mut std::thread::sleep,
notify: &mut |message| notices.push(message.to_string()),
},
);
for message in notices {
self.notice(panel.is_none().then_some(w), &message);
}
return result;
}
let note = definition
.note
.as_deref()
.map_or(MODAL_CONTROL_HINT.to_string(), |note| {
if note.contains(MODAL_CONTROL_HINT) {
note.to_string()
} else {
format!("{note}\n{MODAL_CONTROL_HINT}")
}
});
w.notice(&newt_line(
&format!(
"{}\n`{}` — you or the web; whoever answers first decides.\n{note}",
plain::render(&definition),
req.target
),
self.color,
self.verbose,
))
.ok();
self.run_web_wait(
store,
&request_id,
&definition,
w,
&mut WebWaitIo {
reacquire: &mut move || {
modal_prompt_controls(w).map(|r| Box::new(r) as Box<dyn ControlReader + '_>)
},
now: &Instant::now,
sleep: &mut std::thread::sleep,
notify: &mut |message| {
w.notice(&newt_line(message, self.color, self.verbose)).ok();
},
},
)
}
fn run_web_wait<'w>(
&self,
store: &newt_core::ConversationStore,
request_id: &str,
definition: &InteractionDefinition,
w: &'w newt_core::tty::PromptWindow,
io_: &mut WebWaitIo<'_, 'w>,
) -> (PromptChoice, &'static str) {
let WebWaitIo {
reacquire,
now,
sleep,
notify,
} = io_;
enum ReaderState<'a> {
Live(Box<dyn ControlReader + 'a>),
Retrying { next_attempt: Instant },
}
let control_poll_timeout = Duration::from_millis(200);
let reacquire_backoff = Duration::from_millis(200);
let deadline = now() + self.web_decision_timeout;
let mut warned = false;
let mut state: ReaderState<'w> = match reacquire() {
Ok(reader) => ReaderState::Live(reader),
Err(_) => {
warned = true;
self.notice_control_warning(w);
ReaderState::Retrying {
next_attempt: now() + reacquire_backoff,
}
}
};
loop {
let poll_result = match &mut state {
ReaderState::Live(reader) => Some(reader.poll(control_poll_timeout)),
_ => None,
};
let mut blocked = false;
match poll_result {
Some(Ok(Some(ModalLine::Back))) => {
return self
.web_abort_choice(
store,
&self.conversation_id,
request_id,
PromptChoice::Back,
)
.unwrap_or((PromptChoice::Back, "web-aborted"));
}
Some(Ok(Some(ModalLine::Exit))) => {
return self
.web_abort_choice(
store,
&self.conversation_id,
request_id,
PromptChoice::Exit,
)
.unwrap_or((PromptChoice::Exit, "web-aborted"));
}
Some(Ok(Some(ModalLine::Line(answer)))) => {
if let Some(action) = resolve_answer(definition, &answer) {
return self.terminal_answer_choice(store, notify, request_id, action);
}
blocked = true;
}
Some(Ok(_)) => blocked = true,
Some(Err(e)) => {
if e.kind() == io::ErrorKind::Interrupted {
} else {
if !warned {
warned = true;
self.notice_control_warning(w);
}
state = ReaderState::Retrying {
next_attempt: now() + reacquire_backoff,
};
}
}
None => {
if let ReaderState::Retrying { next_attempt } = &mut state {
if now() >= *next_attempt {
match reacquire() {
Ok(reader) => state = ReaderState::Live(reader),
Err(_) => *next_attempt = now() + reacquire_backoff,
}
}
}
}
}
match store.take_interaction_decision(&self.conversation_id, request_id) {
Ok(Some(action)) => return (action, decision_scope(action)),
Ok(None) if now() >= deadline => {
return match store.cancel_interaction_offer(&self.conversation_id, request_id) {
Ok(true) => (PromptChoice::Deny, "web-timeout"),
Ok(false) => match store
.take_interaction_decision(&self.conversation_id, request_id)
{
Ok(Some(action)) => (action, decision_scope(action)),
Ok(None) | Err(_) => (PromptChoice::Deny, "web-timeout"),
},
Err(_) => (PromptChoice::Deny, "web-timeout"),
};
}
Ok(None) => {}
Err(_) => return (PromptChoice::Deny, "web-store-error"),
}
if !blocked {
let remaining = deadline.saturating_duration_since(now());
sleep(remaining.min(Duration::from_millis(200)));
}
}
}
fn notice_control_warning(&self, w: &newt_core::tty::PromptWindow) {
w.notice(&newt_line(
"terminal controls temporarily unavailable — retrying; the web decision and timeout still apply",
self.color,
self.verbose,
))
.ok();
}
fn authorize(
&self,
definition: &InteractionDefinition,
audience: Audience,
decoded: PromptChoice,
) -> Result<PromptChoice, newt_interaction::Refusal> {
if matches!(decoded, PromptChoice::Back | PromptChoice::Exit) {
return Ok(decoded);
}
let Ok((instance, lifecycle)) = newt_core::interaction_gate::mint_offer(
definition,
&self.state.workspace_key,
&self.conversation_id,
std::slice::from_ref(&audience),
newt_core::interaction_gate::now_tick(),
) else {
return Err(newt_interaction::Refusal::MissingRequiredControl {
control: "decision".to_string(),
});
};
newt_core::interaction_gate::authorize_action(
definition,
&instance,
&lifecycle,
&self.state.workspace_key,
&newt_core::interaction_gate::permission_registry(audience.clone()),
decoded,
audience,
)
}
fn note_refusal(
&mut self,
w: Option<&PromptWindow>,
req: &newt_core::PermissionRequest,
decoded: PromptChoice,
refusal: &newt_interaction::Refusal,
) {
let high = self.danger.classify(req.kind, &req.target) == danger::DangerTier::High;
let (scope, message) = match decoded {
PromptChoice::AllowSession if high => (
"session-allow-refused-high-danger",
Some(format!(
"session allow refused for high-danger `{}` — \
allow once per op or deny (step-up is the future path)",
req.target
)),
),
PromptChoice::AllowPermanent if high => (
"permanent-allow-refused-high-danger",
Some(format!(
"permanent allow refused for high-danger `{}`",
req.target
)),
),
_ => ("unauthorized", None),
};
let _ = refusal;
self.record(req, "deny", scope);
if let Some(message) = message {
self.notice(w, &message);
}
}
fn notice(&self, window: Option<&PromptWindow>, message: &str) {
match window {
Some(window) => {
window
.notice(&newt_line(message, self.color, self.verbose))
.ok();
}
None => print_newt(message, self.color, self.verbose),
}
}
fn web_abort_choice(
&self,
store: &newt_core::ConversationStore,
conversation_id: &str,
request_id: &str,
fallback: PromptChoice,
) -> Option<(PromptChoice, &'static str)> {
if store
.cancel_interaction_offer(conversation_id, request_id)
.ok()?
{
return Some((fallback, decision_scope(fallback)));
}
store
.take_interaction_decision(conversation_id, request_id)
.ok()
.and_then(|action| action.map(|a| (a, decision_scope(a))))
}
fn terminal_answer_choice(
&self,
store: &newt_core::ConversationStore,
notify: &mut dyn FnMut(&str),
request_id: &str,
action: PromptChoice,
) -> (PromptChoice, &'static str) {
let outcome = store.answer_interaction_offer(
&self.conversation_id,
request_id,
action,
Audience::Terminal,
);
match outcome {
Ok(newt_core::store::AnswerOutcome::Answered) => (action, decision_scope(action)),
Ok(_) => {
match store.take_interaction_decision(&self.conversation_id, request_id) {
Ok(Some(winner)) => {
notify(&lost_to_web_message(action, winner));
(winner, decision_scope(winner))
}
_ => (PromptChoice::Deny, "web-raced"),
}
}
Err(_) => (PromptChoice::Deny, "web-store-error"),
}
}
fn apply_control(&self, action: PromptChoice) {
if let Some(cancel) = self.cancel {
cancel.store(true, Ordering::Relaxed);
}
if action == PromptChoice::Exit {
if let Some(exit) = self.exit {
exit.store(true, Ordering::Relaxed);
}
}
}
}
impl<F: FnMut(&PromptWindow, &SurfaceInteraction) -> PromptChoice> newt_core::PermissionGate
for PromptPermissionGate<'_, F>
{
fn refresh_caveats(&mut self, baseline: &newt_core::Caveats) -> newt_core::PermissionDecision {
self.mint(baseline, &[]).map_or(
newt_core::PermissionDecision::Deny,
newt_core::PermissionDecision::Allow,
)
}
fn ask(&mut self, requests: &[newt_core::PermissionRequest]) -> newt_core::PermissionDecision {
self.ask_with_caveats(&self.base.clone(), requests)
}
fn consume_pending_once(&mut self, kind: newt_core::DenialKind, target: &str) {
self.state
.pending_once_grants
.remove(&(kind, target.to_string()));
}
fn queue_pending_once(&mut self, kind: newt_core::DenialKind, target: &str) {
self.state
.pending_once_grants
.insert((kind, target.to_string()));
}
fn queue_command_retry(
&mut self,
command: &str,
cwd: &str,
requests: &[newt_core::PermissionRequest],
) {
let key = (
newt_identity::RawContentId::from_content(command.as_bytes()),
newt_identity::RawContentId::from_content(cwd.as_bytes()),
);
self.pending_command_retries.insert(
key,
requests
.iter()
.map(|request| (request.kind, request.target.clone()))
.collect(),
);
}
fn apply_command_retry(
&mut self,
command: &str,
cwd: &str,
base: &newt_core::Caveats,
) -> newt_core::Caveats {
let key = (
newt_identity::RawContentId::from_content(command.as_bytes()),
newt_identity::RawContentId::from_content(cwd.as_bytes()),
);
let Some(grants) = self.pending_command_retries.get(&key) else {
return base.clone();
};
if grants
.iter()
.any(|(kind, target)| self.state.denied(*kind, target))
{
self.pending_command_retries.remove(&key);
return base.clone();
}
let Ok(policy) = self.mint(base, grants) else {
return base.clone();
};
use newt_core::CaveatsExt as _;
if !grants.iter().all(|(kind, target)| {
*kind == newt_core::DenialKind::Exec && policy.permits_exec(target)
}) {
return base.clone();
}
self.pending_command_retries.remove(&key);
policy
}
fn apply_pending_once(
&mut self,
kind: newt_core::DenialKind,
target: &str,
baseline: &newt_core::Caveats,
) -> newt_core::Caveats {
let key = (kind, target.to_string());
if !self.state.pending_once_grants.contains(&key) {
return baseline.clone();
}
match self.mint(baseline, std::slice::from_ref(&key)) {
Ok(policy) => {
self.state.pending_once_grants.remove(&key);
policy
}
Err(_) => baseline.clone(),
}
}
fn ask_with_caveats(
&mut self,
baseline: &newt_core::Caveats,
requests: &[newt_core::PermissionRequest],
) -> newt_core::PermissionDecision {
self.ask_scoped(baseline, requests, false)
}
fn ask_dependency_fetch(
&mut self,
prepared: &newt_core::Caveats,
request: &newt_core::PermissionRequest,
) -> newt_core::PermissionDecision {
self.ask_scoped(prepared, std::slice::from_ref(request), true)
}
fn ask_question(&mut self, question: &str) -> HumanQuestionOutcome {
let interaction = SurfaceInteraction::blocking(free_text_form(question));
let outcome = match self.ask_surface {
Some(ask) => ask(&interaction),
None => {
let w =
Terminal::suspend_for_prompt(newt_core::tty::TerminalTaker::PermissionQuestion);
present_on_terminal(&w, &interaction)
}
};
match outcome {
HumanQuestionOutcome::Cancelled => {
self.apply_control(PromptChoice::Back);
HumanQuestionOutcome::Cancelled
}
HumanQuestionOutcome::ExitRequested => {
self.apply_control(PromptChoice::Exit);
HumanQuestionOutcome::ExitRequested
}
other => other,
}
}
fn ask_choice(
&mut self,
question: &str,
options: &[newt_core::agentic::ChoiceSpec],
) -> HumanQuestionOutcome {
let interaction = choice_form(question, options);
let outcome = match self.ask_surface {
Some(ask) => ask(&interaction),
None => {
let w =
Terminal::suspend_for_prompt(newt_core::tty::TerminalTaker::PermissionQuestion);
present_on_terminal(&w, &interaction)
}
};
match outcome {
HumanQuestionOutcome::Answer(answer) => {
HumanQuestionOutcome::Answer(interaction.answer_or_default(&answer).to_string())
}
HumanQuestionOutcome::Cancelled => {
self.apply_control(PromptChoice::Back);
HumanQuestionOutcome::Cancelled
}
HumanQuestionOutcome::ExitRequested => {
self.apply_control(PromptChoice::Exit);
HumanQuestionOutcome::ExitRequested
}
other => other,
}
}
}
impl<F: FnMut(&PromptWindow, &SurfaceInteraction) -> PromptChoice> PromptPermissionGate<'_, F> {
fn ask_scoped(
&mut self,
baseline: &newt_core::Caveats,
requests: &[newt_core::PermissionRequest],
operation_only: bool,
) -> newt_core::PermissionDecision {
use newt_core::PermissionDecision::{Allow, Deny};
if operation_only
&& (requests.len() != 1
|| requests[0].tool != "lifecycle"
|| requests[0].kind != newt_core::DenialKind::Net
|| baseline.net != newt_core::Scope::All)
{
return Deny;
}
if requests.is_empty() {
return Deny;
}
if let Some(protection) = &self.state.protection {
let proposed = requests
.iter()
.map(|request| (request.kind, request.target.clone()))
.collect::<Vec<_>>();
if protection.validate_caveats(baseline).is_err()
|| requests.iter().any(|request| {
protection
.validate_request(request.kind, &request.target)
.is_err()
})
|| self.policy_with_grants(baseline, &proposed).is_err()
{
return Deny;
}
}
if (operation_only
|| requests
.iter()
.any(|request| request.kind == newt_core::DenialKind::Build))
&& (self
.preset_clamp
.as_ref()
.is_some_and(|ceiling| !baseline.leq(ceiling))
|| self
.delegation
.is_some_and(|delegation| !baseline.leq(delegation.caveats())))
{
return Deny;
}
let filesystem_batch = requests.iter().any(|request| {
matches!(
request.kind,
newt_core::DenialKind::FsRead | newt_core::DenialKind::FsWrite
)
});
if let Some(clamp) = &self.preset_clamp {
if filesystem_batch
&& requests.iter().any(|request| {
matches!(
request.kind,
newt_core::DenialKind::FsRead
| newt_core::DenialKind::FsWrite
| newt_core::DenialKind::Exec
| newt_core::DenialKind::Net
) && !ceiling_permits(clamp, request.kind, &request.target)
})
{
return Deny;
}
}
if let Some(d) = self.delegation {
let ceiling = d.caveats();
if requests
.iter()
.any(|r| !ceiling_permits(ceiling, r.kind, &r.target))
{
for r in requests {
self.record(r, "deny", "delegated-ceiling");
}
return Deny;
}
}
if requests
.iter()
.any(|r| self.state.denied(r.kind, &r.target))
{
return Deny;
}
let mut once_grants: Vec<(newt_core::DenialKind, String)> = Vec::new();
let mut build_covered_clamp: Option<newt_core::Caveats> = None;
let web = self.state.web_store.clone();
for req in requests {
if req.kind == newt_core::DenialKind::Build
&& self
.preset_clamp
.as_ref()
.is_some_and(|floor| !ceiling_permits(floor, req.kind, &req.target))
{
self.record(req, "deny", "preset-floor-build");
return Deny;
}
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 req.tool == "request_permissions"
&& !req.harness_bound
&& matches!(
req.kind,
newt_core::DenialKind::Exec
| newt_core::DenialKind::Net
| newt_core::DenialKind::FsRead
| newt_core::DenialKind::FsWrite
)
&& self
.policy_with_grants(baseline, &[])
.is_ok_and(|policy| ceiling_permits(&policy, req.kind, &req.target))
{
continue;
}
if !operation_only && session_grant_covers(&self.state.session_grants, req) {
if !self
.state
.session_grants
.contains(&(req.kind, req.target.clone()))
{
once_grants.push((req.kind, req.target.clone()));
if let Some(workspace) =
covering_build_grant_workspace(&self.state.session_grants, baseline)
{
let mut fence = newt_core::confined_exec::build_tool_caveats(
std::path::Path::new(workspace),
);
fence.net = baseline.net.clone();
if self.state.validate_caveats(&fence).is_err() {
return Deny;
}
build_covered_clamp = Some(match build_covered_clamp.take() {
Some(existing) => existing.meet(&fence),
None => fence,
});
}
}
continue;
}
if !operation_only
&& self
.state
.durable_grants
.contains(&(req.kind, req.target.clone()))
&& self.danger.classify(req.kind, &req.target) != danger::DangerTier::High
{
self.record(req, "allow", "encrypted-permanent");
continue;
}
if !operation_only
&& 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 == "run_command"
&& req.kind == newt_core::DenialKind::Exec
&& std::path::Path::new(&req.target).is_absolute()
&& once_grants
.iter()
.any(|(kind, target)| *kind == req.kind && target == &req.target)
{
self.record(req, "allow", "once");
continue;
}
if !operation_only && 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;
}
}
if !self.authorization_prompts_enabled {
self.record(req, "deny", "authorization-prompts-disabled");
return Deny;
}
let mut window = None;
let (choice, scope) = match &web {
Some(store) => self.await_web_decision(store, req),
None => {
let interaction = permission_interaction(
req,
&self.danger,
self.state.prompt_default.unwrap_or_else(|| {
if req.tool == "mcp connect" && req.kind == newt_core::DenialKind::Net {
self.state.mcp_net_prompt_default.unwrap_or(
newt_core::ToolPermissions::default().mcp_net_prompt_default,
)
} else {
PromptChoice::AllowOnce
}
}),
);
let decoded = match self.ask_surface {
Some(ask) => match ask(&interaction) {
HumanQuestionOutcome::Answer(answer) => decode_answer(
&interaction.definition,
interaction.answer_or_default(&answer),
),
HumanQuestionOutcome::Cancelled => PromptChoice::Back,
HumanQuestionOutcome::ExitRequested => PromptChoice::Exit,
HumanQuestionOutcome::InputClosed
| HumanQuestionOutcome::InputFailed
| HumanQuestionOutcome::Unavailable => PromptChoice::Deny,
},
None => (self.ask_human)(
window.insert(direct_authorization_window()),
&interaction,
),
};
match self.authorize(&interaction.definition, Audience::Terminal, decoded) {
Ok(choice) => (choice, decision_scope(choice)),
Err(refusal) => {
self.note_refusal(window.as_ref(), req, decoded, &refusal);
return Deny;
}
}
}
};
match choice {
PromptChoice::AllowOnce => {
self.record(req, "allow", scope);
once_grants.push((req.kind, req.target.clone()));
if req.tool == "request_permissions" {
self.state
.pending_once_grants
.insert((req.kind, req.target.clone()));
}
}
PromptChoice::AllowSession => {
if self.danger.classify(req.kind, &req.target) == danger::DangerTier::High
&& req.kind != newt_core::DenialKind::Build
{
self.record(req, "deny", "session-allow-refused-high-danger");
self.notice(
window.as_ref(),
&format!(
"session allow refused for high-danger `{}` — \
allow once per op or deny (step-up is the future path)",
req.target
),
);
return Deny;
}
self.record(req, "allow", scope);
self.state
.session_grants
.insert((req.kind, req.target.clone()));
}
PromptChoice::AllowPermanent => {
let danger_target = normalized_danger_target(req.kind, &req.target);
if self.danger.classify(req.kind, &danger_target) == danger::DangerTier::High {
self.record(req, "deny", "permanent-allow-refused-high-danger");
self.notice(
window.as_ref(),
&format!("permanent allow refused for high-danger `{danger_target}`"),
);
return Deny;
}
let entry = match approve_entry_for(req.kind, &req.target) {
None => {
self.record(req, "allow", scope);
self.state
.session_grants
.insert((req.kind, req.target.clone()));
continue;
}
Some(Err(reason)) => {
self.record(req, "deny", "permanent-allow-refused-relative-exec-path");
self.notice(window.as_ref(), &reason);
return Deny;
}
Some(Ok(entry)) => entry,
};
let persistent_scope = if self.delegation.is_some() {
self.notice(
window.as_ref(),
"permanent allow unavailable (a delegated session never \
establishes its own root key): granted for this session only",
);
"session"
} else if self.config_path.is_none() {
self.notice(
window.as_ref(),
"permanent allow unavailable (no trusted config path for this \
session): granted for this session only",
);
"session-no-config"
} else {
let root = self
.key_path
.as_deref()
.and_then(|p| newt_identity::load_user_key(p).ok());
match (root, self.config_path.as_deref()) {
(Some(root), Some(config_path)) => {
match newt_core::ocap_store::persist_approve(
config_path,
entry,
ocap_high_danger_predicate(),
|payload| root.sign(payload).to_bytes(),
) {
Ok(had_comments) => {
let root_vk = Some(root.public().as_bytes());
let (policy, warnings) =
newt_core::ocap_store::load_store(config_path, root_vk);
self.state.ocap_policy = policy;
for w in &warnings {
self.notice(
window.as_ref(),
&format!("warning: OCAP policy: {w}"),
);
}
self.state.fold_ocap_approvals();
let approve_path = config_path.with_file_name("ocap").join(
newt_core::ocap_store::Verdict::Approve.filename(),
);
self.notice(
window.as_ref(),
&format!(
"permanently allowed: {} `{}` (signed → {})",
axis_label(req.kind),
req.target,
approve_path.display()
),
);
if had_comments {
self.notice(
window.as_ref(),
"note: re-saving approve.toml dropped any \
hand-written comments it had (TOML \
comments are not preserved across a write)",
);
}
"permanent"
}
Err(e) => {
self.notice(
window.as_ref(),
&format!(
"warning: could not persist permanent grant: {e} \
(granted for this session only)"
),
);
"permanent-persist-failed"
}
}
}
_ => {
self.notice(
window.as_ref(),
&no_key_notice(self.key_path.as_deref()),
);
"session-no-key"
}
}
};
self.record(req, "allow", persistent_scope);
self.state
.session_grants
.insert((req.kind, req.target.clone()));
}
PromptChoice::Deny => {
self.record(req, "deny", scope);
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;
}
control @ (PromptChoice::Back | PromptChoice::Exit) => {
self.apply_control(control);
return Deny;
}
}
}
let Ok(minted) = self.mint(baseline, &once_grants) else {
return Deny;
};
let allowed = match build_covered_clamp {
Some(fence) => minted.meet(&fence),
None => minted,
};
if self.state.validate_caveats(&allowed).is_err() {
return Deny;
}
if operation_only {
if !baseline.leq(&allowed) {
return Deny;
}
Allow(allowed.meet(baseline))
} else {
Allow(allowed)
}
}
}
#[cfg(test)]
#[path = "permissions_tests/permission_prompt_tests.rs"]
mod permission_prompt_tests;
#[cfg(test)]
#[path = "permissions_tests/session_promotion.rs"]
mod session_promotion;
#[cfg(test)]
#[path = "permissions_tests/ocap_grant_fold.rs"]
mod ocap_grant_fold;
#[cfg(test)]
#[path = "permissions_tests/permanent_grant_writer.rs"]
mod permanent_grant_writer;
#[cfg(test)]
#[path = "permissions_tests/a0_freeze_goldens.rs"]
mod a0_freeze_goldens;
#[cfg(test)]
#[path = "permissions_tests/c0a.rs"]
mod c0a;
#[cfg(test)]
#[path = "permissions_tests/b0a.rs"]
mod b0a;
#[cfg(test)]
#[path = "permissions_tests/b0b.rs"]
mod b0b;