use std::collections::BTreeSet;
use comfy_table::{ContentArrangement, Table};
use serde::Deserialize;
use crate::client;
use crate::config::ResolvedContext;
use crate::error::CliError;
use crate::output::OutputFormat;
#[derive(Debug, Clone, Deserialize)]
pub struct PermissionSource {
pub scope: String,
pub allow: Vec<String>,
pub deny: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EffectivePermissions {
pub allow: Vec<String>,
pub deny: Vec<String>,
pub sources: Vec<PermissionSource>,
}
pub async fn fetch_effective_permissions(
ctx: &ResolvedContext,
agent_id: &str,
) -> Result<EffectivePermissions, CliError> {
let path = format!("/api/v1/agents/{agent_id}/capabilities");
client::get_json(ctx, &path).await
}
pub fn render(perms: &EffectivePermissions, output: OutputFormat) {
let mut stdout = std::io::stdout().lock();
render_to(perms, output, &mut stdout).expect("write permissions to stdout");
}
pub fn render_to<W: std::io::Write>(
perms: &EffectivePermissions,
output: OutputFormat,
w: &mut W,
) -> std::io::Result<()> {
match output {
OutputFormat::Json => render_json(perms, w),
OutputFormat::Yaml => render_yaml(perms, w),
OutputFormat::Table => render_text(perms, w),
}
}
fn as_serde_value(perms: &EffectivePermissions) -> serde_json::Value {
serde_json::json!({
"allow": perms.allow,
"deny": perms.deny,
"sources": perms.sources.iter().map(|s| {
serde_json::json!({
"scope": s.scope,
"allow": s.allow,
"deny": s.deny,
})
}).collect::<Vec<_>>(),
})
}
fn render_json<W: std::io::Write>(perms: &EffectivePermissions, w: &mut W) -> std::io::Result<()> {
let value = as_serde_value(perms);
let s = serde_json::to_string_pretty(&value).expect("serialize permissions");
writeln!(w, "{s}")
}
fn render_yaml<W: std::io::Write>(perms: &EffectivePermissions, w: &mut W) -> std::io::Result<()> {
let value = as_serde_value(perms);
let s = serde_yaml::to_string(&value).expect("serialize permissions to yaml");
write!(w, "{s}")
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum Effective {
Allow,
Deny,
Filtered,
Open,
}
impl Effective {
fn label(self) -> &'static str {
match self {
Self::Allow => "Allow",
Self::Deny => "Deny",
Self::Filtered => "—",
Self::Open => "(open)",
}
}
}
fn effective_for(cap: &str, perms: &EffectivePermissions) -> Effective {
if perms.deny.iter().any(|c| c == cap) {
Effective::Deny
} else if perms.allow.iter().any(|c| c == cap) {
Effective::Allow
} else if perms.allow.is_empty() && perms.sources.iter().all(|s| s.allow.is_empty()) {
Effective::Open
} else {
Effective::Filtered
}
}
fn render_text<W: std::io::Write>(perms: &EffectivePermissions, w: &mut W) -> std::io::Result<()> {
if perms.sources.is_empty() {
writeln!(w, "No policy in this agent's cascade declares a capabilities block.")?;
writeln!(w, "Effective: no allow-list restriction, no denies.")?;
return Ok(());
}
let mut all_caps: BTreeSet<&str> = BTreeSet::new();
for src in &perms.sources {
for c in &src.allow {
all_caps.insert(c.as_str());
}
for c in &src.deny {
all_caps.insert(c.as_str());
}
}
for c in &perms.allow {
all_caps.insert(c.as_str());
}
for c in &perms.deny {
all_caps.insert(c.as_str());
}
let mut table = Table::new();
table
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec!["Capability", "Effective", "Granted by", "Denied by"]);
for cap in &all_caps {
let granted_by: Vec<&str> = perms
.sources
.iter()
.filter(|s| s.allow.iter().any(|c| c == cap))
.map(|s| s.scope.as_str())
.collect();
let denied_by: Vec<&str> = perms
.sources
.iter()
.filter(|s| s.deny.iter().any(|c| c == cap))
.map(|s| s.scope.as_str())
.collect();
let effective = effective_for(cap, perms);
table.add_row(vec![
cap.to_string(),
effective.label().to_string(),
granted_by.join(", "),
denied_by.join(", "),
]);
}
writeln!(w, "{table}")
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> EffectivePermissions {
EffectivePermissions {
allow: vec!["file_read".to_string()],
deny: vec!["network_outbound".to_string()],
sources: vec![
PermissionSource {
scope: "global".to_string(),
allow: vec!["file_read".to_string(), "file_write".to_string()],
deny: vec![],
},
PermissionSource {
scope: "team:platform".to_string(),
allow: vec!["file_read".to_string()],
deny: vec!["network_outbound".to_string()],
},
],
}
}
#[test]
fn deserialize_response_shape() {
let json = serde_json::json!({
"allow": ["file_read"],
"deny": [],
"sources": [
{"scope": "global", "allow": ["file_read"], "deny": []}
]
});
let parsed: EffectivePermissions = serde_json::from_value(json).unwrap();
assert_eq!(parsed.allow, vec!["file_read"]);
assert_eq!(parsed.sources.len(), 1);
assert_eq!(parsed.sources[0].scope, "global");
}
#[test]
fn empty_sources_renders_explicit_no_restriction_message() {
let perms = EffectivePermissions {
allow: vec![],
deny: vec![],
sources: vec![],
};
let mut buf = Vec::new();
render_text(&perms, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("No policy"));
assert!(s.contains("Effective"));
}
#[test]
fn sample_renders_each_source_and_effective_section() {
let mut buf = Vec::new();
render_text(&sample(), &mut buf).unwrap();
render_json(&sample(), &mut buf).unwrap();
render_yaml(&sample(), &mut buf).unwrap();
}
#[test]
fn effective_for_classifies_each_case() {
let perms = sample();
assert_eq!(effective_for("file_read", &perms), Effective::Allow);
assert_eq!(effective_for("network_outbound", &perms), Effective::Deny);
assert_eq!(effective_for("file_write", &perms), Effective::Filtered);
}
#[test]
fn effective_for_returns_open_when_no_source_constrains_allow() {
let perms = EffectivePermissions {
allow: vec![],
deny: vec![],
sources: vec![PermissionSource {
scope: "global".to_string(),
allow: vec![],
deny: vec![],
}],
};
assert_eq!(effective_for("anything", &perms), Effective::Open);
}
}