use std::process::ExitCode;
use anyhow::{Context, Result};
use astrid_core::PrincipalId;
use astrid_core::kernel_api::{AdminRequestKind, AdminResponseBody, AgentSummary};
use clap::{Args, Subcommand};
use colored::Colorize;
use serde::Serialize;
use crate::admin_client::into_result;
use crate::context;
use crate::theme::Theme;
use crate::value_formatter::{ValueFormat, emit_structured};
#[derive(Subcommand, Debug, Clone)]
pub(crate) enum CapsCommand {
Show(ShowArgs),
Grant(GrantArgs),
Revoke(RevokeArgs),
Check(CheckArgs),
}
#[derive(Args, Debug, Clone)]
pub(crate) struct ShowArgs {
pub name: Option<String>,
#[arg(long, default_value = "pretty")]
pub format: String,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct GrantArgs {
pub name: String,
pub capability: String,
#[arg(short, long, hide = true)]
pub group: bool,
#[arg(long)]
pub unsafe_admin: bool,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct RevokeArgs {
pub name: String,
pub capability: String,
#[arg(short, long, hide = true)]
pub group: bool,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct CheckArgs {
pub name: String,
pub capability: String,
}
pub(crate) async fn run(cmd: CapsCommand) -> Result<ExitCode> {
match cmd {
CapsCommand::Show(args) => run_show(args).await,
CapsCommand::Grant(args) => run_grant(args).await,
CapsCommand::Revoke(args) => run_revoke(args).await,
CapsCommand::Check(args) => run_check(args).await,
}
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct CapsRecord {
pub principal: String,
pub groups: Vec<String>,
pub grants: Vec<String>,
pub revokes: Vec<String>,
}
impl From<AgentSummary> for CapsRecord {
fn from(s: AgentSummary) -> Self {
Self {
principal: s.principal.to_string(),
groups: s.groups,
grants: s.grants,
revokes: s.revokes,
}
}
}
async fn fetch_summary(target: &PrincipalId) -> Result<AgentSummary> {
let mut client = crate::admin_client::connect_as_active_agent().await?;
let body = client.request(AdminRequestKind::AgentList).await?;
let body = into_result(body)?;
let agents = match body {
AdminResponseBody::AgentList(list) => list,
other => anyhow::bail!("unexpected response from kernel: {other:?}"),
};
agents
.into_iter()
.find(|a| a.principal == *target)
.with_context(|| format!("agent '{target}' not found"))
}
async fn run_show(args: ShowArgs) -> Result<ExitCode> {
let target = context::resolve_agent(args.name.as_deref())?;
let format = ValueFormat::parse(&args.format);
let summary = fetch_summary(&target).await?;
if !format.is_pretty() {
let record: CapsRecord = summary.into();
emit_structured(&record, format)?;
return Ok(ExitCode::SUCCESS);
}
print_caps_pretty(&summary);
Ok(ExitCode::SUCCESS)
}
fn print_caps_pretty(agent: &AgentSummary) {
println!(
" {} {} STATUS",
format!("{:<60}", "CAPABILITY").bold(),
format!("{:<24}", "SOURCE").bold()
);
for g in &agent.groups {
println!(
" {:<60} {} {}",
format!("(group: {g})"),
format!("{:<24}", "group").dimmed(),
"inherited".green()
);
}
for cap in &agent.grants {
let status = if agent
.revokes
.iter()
.any(|r| astrid_core::capability_grammar::capability_matches(r, cap))
{
"shadowed by revoke".dimmed().to_string()
} else {
"active".green().to_string()
};
println!(
" {:<60} {} {status}",
cap,
format!("{:<24}", "individual grant").dimmed(),
);
}
for cap in &agent.revokes {
println!(
" {:<60} {} {}",
cap,
format!("{:<24}", "individual revoke").dimmed(),
"revoked".yellow()
);
}
if agent.groups.is_empty() && agent.grants.is_empty() && agent.revokes.is_empty() {
println!(" {}", Theme::info("(no direct grants or revokes)"));
}
}
fn validate_grant_pattern(pattern: &str, unsafe_admin: bool) -> Result<(), &'static str> {
if pattern == "*" && !unsafe_admin {
return Err(
"refusing to grant universal `*` without --unsafe-admin: this confers admin-equivalent reach on a single agent. Re-run with --unsafe-admin if intentional.",
);
}
Ok(())
}
async fn run_grant(args: GrantArgs) -> Result<ExitCode> {
if args.group {
eprintln!(
"astrid: --group on `caps grant` requires an `admin.group.modify --add-caps` IPC topic that has not shipped yet."
);
eprintln!(
" Use `astrid group modify --add-caps` once available, or grant per-agent for now."
);
return Ok(ExitCode::from(2));
}
if let Err(msg) = validate_grant_pattern(&args.capability, args.unsafe_admin) {
eprintln!("{}", Theme::error(msg));
return Ok(ExitCode::from(2));
}
let principal = PrincipalId::new(&args.name).context("invalid agent name")?;
let mut client = crate::admin_client::connect_as_active_agent().await?;
let body = client
.request(AdminRequestKind::CapsGrant {
principal,
capabilities: vec![args.capability.clone()],
unsafe_admin: args.unsafe_admin,
})
.await?;
let _ = into_result(body)?;
println!(
"{}",
Theme::success(&format!(
"Granted '{}' to agent '{}'",
args.capability, args.name
))
);
Ok(ExitCode::SUCCESS)
}
async fn run_revoke(args: RevokeArgs) -> Result<ExitCode> {
if args.group {
eprintln!(
"astrid: --group on `caps revoke` requires an `admin.group.modify --remove-caps` IPC topic that has not shipped yet."
);
return Ok(ExitCode::from(2));
}
let principal = PrincipalId::new(&args.name).context("invalid agent name")?;
let mut client = crate::admin_client::connect_as_active_agent().await?;
let body = client
.request(AdminRequestKind::CapsRevoke {
principal,
capabilities: vec![args.capability.clone()],
})
.await?;
let _ = into_result(body)?;
println!(
"{}",
Theme::success(&format!(
"Revoked '{}' from agent '{}'",
args.capability, args.name
))
);
Ok(ExitCode::SUCCESS)
}
async fn run_check(args: CheckArgs) -> Result<ExitCode> {
use astrid_core::capability_grammar::capability_matches;
let principal = PrincipalId::new(&args.name).context("invalid agent name")?;
let mut client = crate::admin_client::connect_as_active_agent().await?;
let summary_body = client.request(AdminRequestKind::AgentList).await?;
let summary_body = into_result(summary_body)?;
let agents = match summary_body {
AdminResponseBody::AgentList(list) => list,
other => anyhow::bail!("unexpected response from kernel: {other:?}"),
};
let Some(agent) = agents.iter().find(|a| a.principal == principal) else {
eprintln!(
"{}",
Theme::error(&format!("agent '{principal}' not found"))
);
return Ok(ExitCode::from(1));
};
if let Some(pattern) = agent
.revokes
.iter()
.find(|p| capability_matches(p, &args.capability))
{
println!(
"{}: {} {} '{}' (revoke pattern: {pattern})",
"denied".red().bold(),
args.capability,
"is revoked from".dimmed(),
args.name
);
return Ok(ExitCode::from(1));
}
if let Some(pattern) = agent
.grants
.iter()
.find(|p| capability_matches(p, &args.capability))
{
println!(
"{}: '{}' {} (grant pattern: {pattern})",
"allowed".green().bold(),
args.name,
"holds a direct grant".dimmed()
);
return Ok(ExitCode::SUCCESS);
}
if !agent.groups.is_empty() {
let groups_body = client.request(AdminRequestKind::GroupList).await?;
let groups_body = into_result(groups_body)?;
let groups = match groups_body {
AdminResponseBody::GroupList(list) => list,
other => anyhow::bail!("unexpected response from kernel: {other:?}"),
};
for group_name in &agent.groups {
let Some(group) = groups.iter().find(|g| &g.name == group_name) else {
continue;
};
if let Some(pattern) = group
.capabilities
.iter()
.find(|p| capability_matches(p, &args.capability))
{
println!(
"{}: '{}' {} '{}' (pattern: {pattern})",
"allowed".green().bold(),
args.name,
"inherits from group".dimmed(),
group_name
);
return Ok(ExitCode::SUCCESS);
}
}
}
println!(
"{}: '{}' {} {}",
"denied".red().bold(),
args.name,
"has no grant matching".dimmed(),
args.capability
);
Ok(ExitCode::from(1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_grant_pattern_rejects_bare_star_without_unsafe_admin() {
let err = validate_grant_pattern("*", false).expect_err("bare `*` must be rejected");
assert!(err.contains("--unsafe-admin"), "msg={err}");
}
#[test]
fn validate_grant_pattern_accepts_bare_star_with_unsafe_admin() {
validate_grant_pattern("*", true).expect("bare `*` with --unsafe-admin must pass");
}
#[test]
fn validate_grant_pattern_accepts_scoped_wildcards() {
validate_grant_pattern("network:egress:*", false).expect("scoped wildcard");
validate_grant_pattern("self:capsule:*", false).expect("scoped wildcard");
validate_grant_pattern("agent:disable", false).expect("concrete cap");
}
#[test]
fn record_round_trips_to_json() {
let summary = AgentSummary {
principal: PrincipalId::new("alice").unwrap(),
enabled: true,
groups: vec!["agent".into()],
grants: vec!["self:capsule:install".into()],
revokes: vec!["network:egress:evil.com".into()],
};
let rec: CapsRecord = summary.into();
let json = serde_json::to_string(&rec).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["principal"], "alice");
assert_eq!(parsed["grants"][0], "self:capsule:install");
assert_eq!(parsed["revokes"][0], "network:egress:evil.com");
}
}