use crate::caveats::{Caveats, Scope};
use serde::{Deserialize, Serialize};
use std::io::Write as _;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DenialKind {
Exec,
FsRead,
FsWrite,
Net,
}
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",
}
}
}
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),
_ => 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)]
pub struct PermissionRequest {
pub tool: String,
pub kind: DenialKind,
pub target: String,
pub reason: String,
}
pub enum PermissionDecision {
Allow(Caveats),
Deny,
}
pub trait PermissionGate {
fn ask(&mut self, requests: &[PermissionRequest]) -> PermissionDecision;
fn ask_question(&mut self, question: &str) -> Option<String>;
}
pub fn widen_caveats(base: &Caveats, grants: &[(DenialKind, String)]) -> Caveats {
let mut out = base.clone();
for (kind, target) in grants {
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,
};
if let Scope::Only(set) = scope {
set.insert(target.clone());
}
}
out
}
#[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(),
}
}
pub fn append_jsonl(&self, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let line = serde_json::to_string(self).map_err(std::io::Error::other)?;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(file, "{line}")
}
}
#[cfg(test)]
mod tests {
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 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_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 append_jsonl_appends_one_line_per_record_and_creates_dirs() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("nested").join("permission-log.jsonl");
let a = PermissionRecord::new(
"conv-1",
"read_file",
DenialKind::FsRead,
"/etc/hosts",
"deny",
"once",
);
let b = PermissionRecord::new(
"conv-1",
"web_fetch",
DenialKind::Net,
"docs.rs",
"allow",
"once",
);
a.append_jsonl(&path).unwrap();
b.append_jsonl(&path).unwrap();
let body = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines.len(), 2);
let parsed: PermissionRecord = serde_json::from_str(lines[0]).unwrap();
assert_eq!(parsed, a, "round-trips losslessly");
let parsed: PermissionRecord = serde_json::from_str(lines[1]).unwrap();
assert_eq!(parsed.kind, "net");
}
#[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()),
]
);
}
}