Skip to main content

agentstatedeveloper_cli/commands/
policy.rs

1//! `asd policy …` — introspect the loaded policy file.
2//!
3//! All subcommands require a policy file path (via `--policy` / `ASD_POLICY`).
4//! Without one, the CLI errors out with a clear message — introspection
5//! doesn't make sense against the permissive default.
6
7use anyhow::{Context, Result, anyhow};
8use clap::{Args, Subcommand};
9use serde_json::json;
10
11use agentstatedeveloper_core::{Decision, FilePolicyGate, PolicyFile, PolicyGate, Situation};
12
13use crate::config::Config;
14
15#[derive(Debug, Subcommand)]
16pub enum PolicyCmd {
17    /// List loaded policy rules. Optionally filter by path prefix.
18    List(ListArgs),
19
20    /// Show a specific policy rule by `path`.
21    Show(ShowArgs),
22
23    /// Evaluate a hypothetical action against the loaded policy file.
24    /// Useful for "would this be allowed if I tried?" previews.
25    Evaluate(EvaluateArgs),
26}
27
28#[derive(Debug, Args)]
29pub struct ListArgs {
30    /// Only list rules whose path starts with this prefix.
31    #[arg(long)]
32    pub prefix: Option<String>,
33}
34
35#[derive(Debug, Args)]
36pub struct ShowArgs {
37    /// Full policy path (as written in the policy file, e.g.
38    /// `/policies/code/hazard-requires-human`).
39    pub path: String,
40}
41
42#[derive(Debug, Args)]
43pub struct EvaluateArgs {
44    /// Action to evaluate, e.g. `asd.ledger.append.hazard`.
45    pub action: String,
46
47    /// Agent id to attribute to the query. Defaults to the CLI's default.
48    #[arg(long, default_value = "asd-cli-user")]
49    pub agent_id: String,
50
51    /// Optional human-readable situation description (not evaluated).
52    #[arg(long)]
53    pub description: Option<String>,
54}
55
56pub fn run(cfg: &Config, cmd: PolicyCmd) -> Result<()> {
57    let path = cfg.policy_path.as_ref().ok_or_else(|| {
58        anyhow!("no policy file configured — pass --policy <path> or set ASD_POLICY")
59    })?;
60
61    match cmd {
62        PolicyCmd::List(args) => list(path, args),
63        PolicyCmd::Show(args) => show(path, args),
64        PolicyCmd::Evaluate(args) => evaluate(path, args),
65    }
66}
67
68fn list(path: &std::path::Path, args: ListArgs) -> Result<()> {
69    let file =
70        PolicyFile::load(path).with_context(|| format!("load policy file {}", path.display()))?;
71    let filtered: Vec<_> = file
72        .policies
73        .iter()
74        .filter(|r| args.prefix.as_deref().is_none_or(|p| r.path.starts_with(p)))
75        .collect();
76
77    let out = json!({
78        "source": path.display().to_string(),
79        "strict": file.strict,
80        "count": filtered.len(),
81        "policies": filtered.iter().map(|r| json!({
82            "path": r.path,
83            "version": r.version,
84            "description": r.description,
85            "match_action": r.match_action,
86            "deny": r.deny,
87            "require_approval": r.require_approval,
88            "agent_id": r.agent_id,
89        })).collect::<Vec<_>>(),
90    });
91    println!("{}", serde_json::to_string_pretty(&out)?);
92    Ok(())
93}
94
95fn show(path: &std::path::Path, args: ShowArgs) -> Result<()> {
96    let file = PolicyFile::load(path)?;
97    let rule = file
98        .policies
99        .iter()
100        .find(|r| r.path == args.path)
101        .ok_or_else(|| anyhow!("policy not found: {}", args.path))?;
102    println!("{}", serde_json::to_string_pretty(rule)?);
103    Ok(())
104}
105
106fn evaluate(path: &std::path::Path, args: EvaluateArgs) -> Result<()> {
107    let gate = FilePolicyGate::from_file(path)?;
108    let situation = Situation {
109        description: args
110            .description
111            .unwrap_or_else(|| format!("dry-run evaluation of {}", args.action)),
112        qualifiers: serde_json::Value::Null,
113    };
114    let decision = gate.evaluate(&situation, &args.action, &args.agent_id)?;
115
116    let out = match &decision {
117        Decision::Allow { matched_policy } => json!({
118            "status": "allowed",
119            "matched_policy": matched_policy,
120        }),
121        Decision::Deny {
122            matched_policy,
123            reason,
124        } => json!({
125            "status": "denied",
126            "matched_policy": matched_policy,
127            "reason": reason,
128        }),
129        Decision::RequireApproval {
130            matched_policy,
131            approvers,
132            reason,
133        } => json!({
134            "status": "awaiting-approval",
135            "matched_policy": matched_policy,
136            "approvers": approvers,
137            "reason": reason,
138        }),
139        Decision::NoPolicyMatch => json!({
140            "status": "no-policy-match",
141            "matched_policy": serde_json::Value::Null,
142        }),
143    };
144    println!("{}", serde_json::to_string_pretty(&out)?);
145    Ok(())
146}