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),
#[command(subcommand)]
Token(TokenCommand),
}
#[derive(Subcommand, Debug, Clone)]
pub(crate) enum TokenCommand {
Mint(TokenMintArgs),
Revoke(TokenRevokeArgs),
List(TokenListArgs),
}
#[derive(Args, Debug, Clone)]
pub(crate) struct TokenMintArgs {
pub principal: String,
pub resource: String,
#[arg(long)]
pub permission: Option<String>,
#[arg(long)]
pub ttl: Option<String>,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct TokenRevokeArgs {
pub token_id: String,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct TokenListArgs {
pub principal: String,
}
#[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,
CapsCommand::Token(cmd) => match cmd {
TokenCommand::Mint(args) => run_token_mint(args).await,
TokenCommand::Revoke(args) => run_token_revoke(args).await,
TokenCommand::List(args) => run_token_list(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))
}
fn success_value(body: AdminResponseBody) -> Result<serde_json::Value> {
match body {
AdminResponseBody::Success(v) => Ok(v),
other => anyhow::bail!("unexpected response from kernel: {other:?}"),
}
}
async fn run_token_mint(args: TokenMintArgs) -> Result<ExitCode> {
let principal = PrincipalId::new(&args.principal).context("invalid agent name")?;
let ttl_secs = match args.ttl.as_deref() {
Some(s) => Some(
crate::commands::quota::parse_duration(s)
.context("invalid --ttl")?
.as_secs(),
),
None => None,
};
let mut client = crate::admin_client::connect_as_active_agent().await?;
let body = client
.request(AdminRequestKind::CapsTokenMint {
principal,
resource: args.resource.clone(),
permission: args.permission.clone(),
ttl_secs,
})
.await?;
let value = success_value(into_result(body)?)?;
let token_id = value
.get("token_id")
.and_then(|v| v.as_str())
.unwrap_or("?");
let resource = value
.get("resource")
.and_then(|v| v.as_str())
.unwrap_or(&args.resource);
let permission = value
.get("permission")
.and_then(|v| v.as_str())
.unwrap_or("invoke");
let expiry = value
.get("expires_at")
.and_then(|v| v.as_str())
.map_or_else(|| "never".to_string(), ToString::to_string);
println!(
"{}",
Theme::success(&format!(
"Minted token {token_id} for agent '{}'",
args.principal
))
);
println!(" resource: {resource}");
println!(" permission: {permission}");
println!(" expires: {expiry}");
Ok(ExitCode::SUCCESS)
}
async fn run_token_revoke(args: TokenRevokeArgs) -> Result<ExitCode> {
let mut client = crate::admin_client::connect_as_active_agent().await?;
let body = client
.request(AdminRequestKind::CapsTokenRevoke {
token_id: args.token_id.clone(),
})
.await?;
let _ = success_value(into_result(body)?)?;
println!(
"{}",
Theme::success(&format!("Revoked token {}", args.token_id))
);
Ok(ExitCode::SUCCESS)
}
async fn run_token_list(args: TokenListArgs) -> Result<ExitCode> {
let principal = PrincipalId::new(&args.principal).context("invalid agent name")?;
let mut client = crate::admin_client::connect_as_active_agent().await?;
let body = client
.request(AdminRequestKind::CapsTokenList { principal })
.await?;
let value = success_value(into_result(body)?)?;
let tokens = value
.get("tokens")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
if tokens.is_empty() {
let principal = &args.principal;
println!("{}", Theme::info(&format!("(no tokens for '{principal}')")));
return Ok(ExitCode::SUCCESS);
}
println!(
" {} {} {} {}",
format!("{:<38}", "TOKEN ID").bold(),
format!("{:<32}", "RESOURCE").bold(),
format!("{:<10}", "SCOPE").bold(),
"EXPIRES".bold()
);
for t in &tokens {
let id = t.get("token_id").and_then(|v| v.as_str()).unwrap_or("?");
let resource = t.get("resource").and_then(|v| v.as_str()).unwrap_or("?");
let scope = t.get("scope").and_then(|v| v.as_str()).unwrap_or("?");
let perms = t
.get("permissions")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|p| p.as_str())
.collect::<Vec<_>>()
.join(",")
})
.unwrap_or_default();
let expires = t
.get("expires_at")
.and_then(|v| v.as_str())
.unwrap_or("never");
let id = format!("{id:<38}");
let resource = format!("{resource:<32}");
let scope = format!("{scope:<10}");
println!(" {id} {resource} {scope} {expires} [{perms}]");
}
Ok(ExitCode::SUCCESS)
}
#[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");
}
}