use crate::caveats::{Caveats, Scope};
use crate::store::Verdict;
use serde::{Deserialize, Serialize};
use std::io::Write as _;
use std::path::Path;
macro_rules! permission_actions {
($($variant:ident => $wire:literal),+ $(,)?) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PermissionAction {
$(#[serde(rename = $wire)] $variant),+
}
impl PermissionAction {
pub fn as_str(self) -> &'static str {
match self { $(Self::$variant => $wire),+ }
}
}
impl AsRef<str> for PermissionAction {
fn as_ref(&self) -> &str { self.as_str() }
}
};
}
permission_actions! {
AllowOnce => "allow_once", AllowSession => "allow_session",
AllowPermanent => "allow_permanent", Deny => "deny",
DenyAlways => "deny_always", DenyPermanent => "deny_permanent",
Back => "back", Exit => "exit",
}
impl From<Verdict> for PermissionAction {
fn from(value: Verdict) -> Self {
match value {
Verdict::AllowOnce => Self::AllowOnce,
Verdict::AllowSession => Self::AllowSession,
Verdict::Deny => Self::Deny,
}
}
}
impl TryFrom<PermissionAction> for Verdict {
type Error = ();
fn try_from(value: PermissionAction) -> Result<Self, Self::Error> {
match value {
PermissionAction::AllowOnce => Ok(Self::AllowOnce),
PermissionAction::AllowSession => Ok(Self::AllowSession),
PermissionAction::Deny => Ok(Self::Deny),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum DenialKind {
Exec,
FsRead,
FsWrite,
Net,
RemoteTool,
GitWrite,
Build,
}
impl DenialKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Exec => "exec",
Self::FsRead => "fs_read",
Self::FsWrite => "fs_write",
Self::Net => "net",
Self::RemoteTool => "remote_tool",
Self::GitWrite => "git_write",
Self::Build => "build",
}
}
}
impl std::str::FromStr for DenialKind {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"exec" => Ok(Self::Exec),
"fs_read" => Ok(Self::FsRead),
"fs_write" => Ok(Self::FsWrite),
"net" => Ok(Self::Net),
"remote_tool" => Ok(Self::RemoteTool),
"git_write" => Ok(Self::GitWrite),
"build" => Ok(Self::Build),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PersistentDenial {
pub kind: String,
pub target: String,
pub ts_claim: String,
}
pub fn parse_denials(contents: &str) -> Vec<(DenialKind, String)> {
contents
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|line| {
let d: PersistentDenial = serde_json::from_str(line).ok()?;
let kind: DenialKind = d.kind.parse().ok()?;
(!d.target.is_empty()).then_some((kind, d.target))
})
.collect()
}
pub fn load_denials(path: &Path) -> Vec<(DenialKind, String)> {
match std::fs::read_to_string(path) {
Ok(body) => parse_denials(&body),
Err(_) => Vec::new(),
}
}
pub fn append_denial(path: &Path, kind: DenialKind, target: &str) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let entry = PersistentDenial {
kind: kind.as_str().to_string(),
target: target.to_string(),
ts_claim: chrono::Utc::now().to_rfc3339(),
};
let line = serde_json::to_string(&entry).map_err(std::io::Error::other)?;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(file, "{line}")
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PermissionRequest {
pub tool: String,
pub kind: DenialKind,
pub target: String,
pub reason: String,
#[serde(default, skip_deserializing)]
pub harness_bound: bool,
}
pub const BOUND_REASON_PREFIX: &str = "#2628 approval executes: ";
pub enum PermissionDecision {
Allow(Caveats),
Deny,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HumanQuestionOutcome {
Answer(String),
Unavailable,
Cancelled,
ExitRequested,
InputClosed,
InputFailed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChoiceSpec {
pub id: &'static str,
pub label: &'static str,
pub key: &'static str,
pub role: newt_interaction::SemanticRole,
}
pub trait PermissionGate {
fn refresh_caveats(&mut self, baseline: &Caveats) -> PermissionDecision {
PermissionDecision::Allow(baseline.clone())
}
fn ask(&mut self, requests: &[PermissionRequest]) -> PermissionDecision;
fn consume_pending_once(&mut self, _kind: DenialKind, _target: &str) {}
fn queue_pending_once(&mut self, _kind: DenialKind, _target: &str) {}
fn queue_command_retry(&mut self, _command: &str, _cwd: &str, _requests: &[PermissionRequest]) {
}
fn apply_command_retry(&mut self, _command: &str, _cwd: &str, base: &Caveats) -> Caveats {
base.clone()
}
fn apply_pending_once(
&mut self,
_kind: DenialKind,
_target: &str,
baseline: &Caveats,
) -> Caveats {
baseline.clone()
}
fn ask_with_caveats(
&mut self,
baseline: &Caveats,
requests: &[PermissionRequest],
) -> PermissionDecision {
match self.ask(requests) {
PermissionDecision::Allow(allowed) => {
let grants: Vec<_> = requests
.iter()
.map(|request| (request.kind, request.target.clone()))
.collect();
PermissionDecision::Allow(allowed.meet(&widen_caveats(baseline, &grants)))
}
PermissionDecision::Deny => PermissionDecision::Deny,
}
}
fn ask_dependency_fetch(
&mut self,
_prepared: &Caveats,
_request: &PermissionRequest,
) -> PermissionDecision {
PermissionDecision::Deny
}
fn ask_question(&mut self, question: &str) -> HumanQuestionOutcome;
fn ask_choice(&mut self, question: &str, _options: &[ChoiceSpec]) -> HumanQuestionOutcome {
self.ask_question(question)
}
}
pub fn widen_caveats(base: &Caveats, grants: &[(DenialKind, String)]) -> Caveats {
let mut out = base.clone();
for (kind, target) in grants {
let covered = match kind {
DenialKind::FsRead => filesystem_grant_is_covered(&base.fs_read, target),
DenialKind::FsWrite => filesystem_grant_is_covered(&base.fs_write, target),
_ => false,
};
if covered {
continue;
}
let scope = match kind {
DenialKind::Exec => &mut out.exec,
DenialKind::FsRead => &mut out.fs_read,
DenialKind::FsWrite => &mut out.fs_write,
DenialKind::Net => &mut out.net,
DenialKind::RemoteTool => continue,
DenialKind::GitWrite | DenialKind::Build => continue,
};
if let Scope::Only(set) = scope {
#[cfg(target_os = "macos")]
let target = if matches!(kind, DenialKind::FsRead | DenialKind::FsWrite) {
crate::fs_aliases::rewrite(target).into_owned()
} else {
target.clone()
};
set.insert(target.clone());
}
}
out
}
fn filesystem_grant_is_covered(scope: &Scope<String>, target: &str) -> bool {
let Scope::Only(roots) = scope else {
return true;
};
let target = dunce::simplified(Path::new(target));
let exact = |path: &Path| {
path.canonicalize()
.is_ok_and(|resolved| dunce::simplified(&resolved).as_os_str() == path.as_os_str())
};
if !roots.iter().any(|root| {
let root = dunce::simplified(Path::new(root));
exact(root) && target.starts_with(root)
}) {
return false;
}
match target.canonicalize() {
Ok(resolved) => dunce::simplified(&resolved).as_os_str() == target.as_os_str(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
crate::config::normalize_path(target)
.is_ok_and(|path| path.as_os_str() == target.as_os_str())
&& crate::config::resolve_uncreated_path(target)
.is_ok_and(|path| dunce::simplified(&path).as_os_str() == target.as_os_str())
}
Err(_) => false,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PermissionRecord {
pub ts_claim: String,
pub conversation_id: String,
pub tool: String,
pub kind: String,
pub target: String,
pub decision: String,
pub scope: String,
}
impl PermissionRecord {
pub fn new(
conversation_id: &str,
tool: &str,
kind: DenialKind,
target: &str,
decision: &str,
scope: &str,
) -> Self {
Self {
ts_claim: chrono::Utc::now().to_rfc3339(),
conversation_id: conversation_id.to_string(),
tool: tool.to_string(),
kind: kind.as_str().to_string(),
target: target.to_string(),
decision: decision.to_string(),
scope: scope.to_string(),
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn missing_grant_filter_is_shared_and_keeps_axis_and_alias_boundaries() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
let covered = root.join("covered");
std::fs::create_dir(&covered).unwrap();
let base = Caveats {
fs_read: Scope::none(),
fs_write: Scope::only([covered.to_str().unwrap().into()]),
..Caveats::top()
};
let dead = covered.join("missing/plan.md").to_str().unwrap().to_owned();
assert_eq!(
widen_caveats(&base, &[(DenialKind::FsWrite, dead.clone())]),
base
);
assert_ne!(
widen_caveats(&base, &[(DenialKind::FsRead, dead)]),
base,
"read is not covered by write"
);
let outside = root.join("outside");
std::fs::create_dir(&outside).unwrap();
let names = vec![
outside.join("dead"),
Path::new(&format!("{}/../outside/dead", covered.display())).to_path_buf(),
];
#[cfg(unix)]
let names = {
let mut names = names;
let alias = covered.join("alias");
std::os::unix::fs::symlink(&outside, &alias).unwrap();
names.push(alias.join("missing"));
names.push(alias);
names
};
for path in names {
assert_ne!(
widen_caveats(
&base,
&[(DenialKind::FsWrite, path.to_str().unwrap().into())]
),
base,
"uncovered/alias/traversal: {path:?}"
);
}
}
#[cfg(windows)]
#[test]
fn covered_windows_grants_accept_ordinary_and_verbatim_names() {
let temp = tempfile::tempdir().unwrap();
let verbatim = temp.path().canonicalize().unwrap();
let ordinary = dunce::simplified(&verbatim).to_path_buf();
assert_ne!(ordinary.as_os_str(), verbatim.as_os_str());
std::fs::write(ordinary.join("existing"), "fixture").unwrap();
for root in [&ordinary, &verbatim] {
let base = Caveats {
fs_read: Scope::only([root.to_str().unwrap().into()]),
fs_write: Scope::only([root.to_str().unwrap().into()]),
..Caveats::top()
};
for name in [&ordinary, &verbatim] {
for leaf in ["existing", "deleted\\plan.md"] {
for kind in [DenialKind::FsRead, DenialKind::FsWrite] {
let target = name.join(leaf).to_str().unwrap().to_owned();
assert_eq!(widen_caveats(&base, &[(kind, target)]), base);
}
}
let traversal = format!("{}\\deleted\\..\\missing", name.display());
assert_ne!(
widen_caveats(&base, &[(DenialKind::FsWrite, traversal)]),
base
);
}
}
}
#[test]
fn command_retry_default_does_not_queue_legacy_authority() {
#[derive(Default)]
struct LegacyGate(Vec<(DenialKind, String)>);
impl PermissionGate for LegacyGate {
fn ask(&mut self, _: &[PermissionRequest]) -> PermissionDecision {
PermissionDecision::Deny
}
fn ask_question(&mut self, _: &str) -> HumanQuestionOutcome {
HumanQuestionOutcome::Unavailable
}
fn queue_pending_once(&mut self, kind: DenialKind, target: &str) {
self.0.push((kind, target.into()));
}
}
let mut gate = LegacyGate::default();
let request = PermissionRequest {
tool: "run_command".into(),
kind: DenialKind::Exec,
target: "/bin/bash".into(),
reason: "denied".into(),
harness_bound: true,
};
gate.queue_command_retry("bash script.sh", "/workspace", &[request]);
assert!(
gate.0.is_empty(),
"invocation grant degraded into transferable target authority"
);
}
use super::*;
use crate::caveats::{CaveatsExt as _, CountBound};
fn base() -> 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::AtMost(7),
valid_for_generation: Scope::All,
}
}
#[test]
fn denial_kind_strings_are_stable() {
assert_eq!(DenialKind::Exec.as_str(), "exec");
assert_eq!(DenialKind::FsRead.as_str(), "fs_read");
assert_eq!(DenialKind::FsWrite.as_str(), "fs_write");
assert_eq!(DenialKind::Net.as_str(), "net");
}
#[test]
fn permission_actions_have_stable_wire_values_and_verdict_conversions() {
let actions = [
PermissionAction::AllowOnce,
PermissionAction::AllowSession,
PermissionAction::AllowPermanent,
PermissionAction::Deny,
PermissionAction::DenyAlways,
PermissionAction::DenyPermanent,
PermissionAction::Back,
PermissionAction::Exit,
];
assert_eq!(
serde_json::to_string(&actions).unwrap(),
r#"["allow_once","allow_session","allow_permanent","deny","deny_always","deny_permanent","back","exit"]"#
);
assert_eq!(
PermissionAction::from(Verdict::AllowOnce),
PermissionAction::AllowOnce
);
assert_eq!(
Verdict::try_from(PermissionAction::AllowSession),
Ok(Verdict::AllowSession)
);
assert_eq!(Verdict::try_from(PermissionAction::Deny), Ok(Verdict::Deny));
assert!(Verdict::try_from(PermissionAction::AllowPermanent).is_err());
}
#[test]
fn widen_adds_each_grant_to_its_axis_only() {
let widened = widen_caveats(
&base(),
&[
(DenialKind::Exec, "npm".to_string()),
(DenialKind::Net, "docs.rs".to_string()),
(DenialKind::FsRead, "/etc/hosts".to_string()),
(DenialKind::FsWrite, "/tmp/out".to_string()),
],
);
assert!(widened.permits_exec("npm"));
assert!(widened.permits_exec("cargo"), "existing grants kept");
assert!(widened.permits_net("docs.rs"));
assert!(widened.permits_fs_read("/etc/hosts"));
assert!(widened.permits_fs_write("/tmp/out"));
assert!(!widened.permits_exec("rm"));
assert!(!widened.permits_net("evil.example.com"));
assert_eq!(widened.max_calls, CountBound::AtMost(7));
}
#[test]
fn widen_of_an_exec_grant_never_touches_fs_read_even_for_a_build_tool() {
let widened = widen_caveats(&base(), &[(DenialKind::Exec, "cargo".to_string())]);
assert!(widened.permits_exec("cargo"));
assert_eq!(
widened.fs_read,
base().fs_read,
"an exec grant — build tool or not — must never widen fs_read; \
that axis feeds every tool the model can call this session, \
not just the confined child this grant was asked for"
);
}
#[test]
fn widen_leaves_base_unchanged_and_all_stays_all() {
let b = base();
let _ = widen_caveats(&b, &[(DenialKind::Exec, "npm".to_string())]);
assert_eq!(b, base(), "widen builds a NEW policy; base is immutable");
let all = Caveats::top();
let widened = widen_caveats(&all, &[(DenialKind::Exec, "npm".to_string())]);
assert_eq!(widened, all, "Scope::All has nothing to add");
}
#[test]
fn widen_with_no_grants_is_identity() {
assert_eq!(widen_caveats(&base(), &[]), base());
}
#[test]
fn record_serializes_the_issue_shape() {
let rec = PermissionRecord::new(
"conv-1",
"run_command",
DenialKind::Exec,
"npm",
"allow",
"session",
);
let json: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&rec).unwrap()).unwrap();
assert_eq!(json["conversation_id"], "conv-1");
assert_eq!(json["tool"], "run_command");
assert_eq!(json["kind"], "exec");
assert_eq!(json["target"], "npm");
assert_eq!(json["decision"], "allow");
assert_eq!(json["scope"], "session");
let ts = json["ts_claim"].as_str().unwrap();
assert!(
chrono::DateTime::parse_from_rfc3339(ts).is_ok(),
"got: {ts}"
);
}
#[test]
fn denial_kind_from_str_round_trips_and_rejects_unknown() {
for k in [
DenialKind::Exec,
DenialKind::FsRead,
DenialKind::FsWrite,
DenialKind::Net,
] {
assert_eq!(k.as_str().parse::<DenialKind>(), Ok(k));
}
assert!("nope".parse::<DenialKind>().is_err());
assert!("".parse::<DenialKind>().is_err());
}
#[test]
fn parse_denials_is_pure_and_skips_bad_lines() {
let body = "\
{\"kind\":\"net\",\"target\":\"github.com\",\"ts_claim\":\"2026-07-04T00:00:00Z\"}
{\"kind\":\"exec\",\"target\":\"rm\",\"ts_claim\":\"2026-07-04T00:00:00Z\"}
not json at all
{\"kind\":\"bogus\",\"target\":\"x\",\"ts_claim\":\"2026-07-04T00:00:00Z\"}
{\"kind\":\"net\",\"target\":\"\",\"ts_claim\":\"2026-07-04T00:00:00Z\"}
";
let got = parse_denials(body);
assert_eq!(
got,
vec![
(DenialKind::Net, "github.com".to_string()),
(DenialKind::Exec, "rm".to_string()),
]
);
}
#[test]
fn load_denials_missing_file_is_empty_not_error() {
let dir = tempfile::TempDir::new().unwrap();
let missing = dir.path().join("nope").join("permission-denials.jsonl");
assert!(load_denials(&missing).is_empty());
}
#[test]
fn append_denial_then_load_round_trips_and_creates_dirs() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("nested").join("permission-denials.jsonl");
append_denial(&path, DenialKind::Net, "github.com").unwrap();
append_denial(&path, DenialKind::Exec, "curl").unwrap();
let loaded = load_denials(&path);
assert_eq!(
loaded,
vec![
(DenialKind::Net, "github.com".to_string()),
(DenialKind::Exec, "curl".to_string()),
]
);
}
}