Skip to main content

agentstatedeveloper_core/
policy.rs

1//! Policy gate.
2//!
3//! Three implementations:
4//! - [`PermissivePolicyGate`] — always Allow; the solo-dev default.
5//! - [`FilePolicyGate`] — loads a JSON rule set from disk and evaluates
6//!   in-process. Kept for tests and backward compat.
7//! - [`PolicyStoreGate`] — wraps `agentstategraph_policy::PolicyStore`;
8//!   imports the JSON policy file into the ASG repo at startup and
9//!   delegates all evaluation to the real policy engine. This is the
10//!   production path when `--policy` / `ASD_POLICY` is set.
11
12use std::fs;
13use std::path::Path;
14use std::sync::Arc;
15
16use chrono::Utc;
17use serde::{Deserialize, Serialize};
18
19use agentstategraph::Repository;
20use agentstategraph_policy::{
21    PolicyStore, Situation as AsgSituation,
22    selector::Selector,
23    types::{ApprovalRule, AuthorizedAction, FallbackAction, Policy},
24};
25use agentstategraph_storage::SqliteStorage;
26
27use crate::AsdError;
28use crate::error::Result;
29
30// ---------------------------------------------------------------------------
31// ASD-local Decision + Situation + PolicyGate trait
32// ---------------------------------------------------------------------------
33
34#[derive(Debug, Clone)]
35pub enum Decision {
36    Allow {
37        matched_policy: Option<String>,
38    },
39    Deny {
40        matched_policy: String,
41        reason: String,
42    },
43    RequireApproval {
44        matched_policy: String,
45        approvers: Vec<String>,
46        reason: Option<String>,
47    },
48    NoPolicyMatch,
49}
50
51impl Decision {
52    pub fn matched_policy(&self) -> Option<String> {
53        match self {
54            Decision::Allow { matched_policy } => matched_policy.clone(),
55            Decision::Deny { matched_policy, .. } => Some(matched_policy.clone()),
56            Decision::RequireApproval { matched_policy, .. } => Some(matched_policy.clone()),
57            Decision::NoPolicyMatch => None,
58        }
59    }
60}
61
62/// Context passed to every policy evaluation call. Keys are populated by
63/// the call site so selectors can key on symbol_id, agent_id, etc.
64#[derive(Debug, Clone, Default)]
65pub struct Situation {
66    pub description: String,
67    pub qualifiers: serde_json::Value,
68}
69
70impl Situation {
71    pub fn new(description: impl Into<String>) -> Self {
72        Self {
73            description: description.into(),
74            qualifiers: serde_json::Value::Null,
75        }
76    }
77
78    pub fn with_qualifier(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
79        if !self.qualifiers.is_object() {
80            self.qualifiers = serde_json::Value::Object(serde_json::Map::new());
81        }
82        self.qualifiers
83            .as_object_mut()
84            .unwrap()
85            .insert(key.into(), serde_json::Value::String(value.into()));
86        self
87    }
88}
89
90pub trait PolicyGate: Send + Sync {
91    fn evaluate(&self, situation: &Situation, action: &str, agent_id: &str) -> Result<Decision>;
92}
93
94// ---------------------------------------------------------------------------
95// PermissivePolicyGate
96// ---------------------------------------------------------------------------
97
98pub struct PermissivePolicyGate;
99
100impl PolicyGate for PermissivePolicyGate {
101    fn evaluate(&self, _: &Situation, _: &str, _: &str) -> Result<Decision> {
102        Ok(Decision::Allow {
103            matched_policy: None,
104        })
105    }
106}
107
108// ---------------------------------------------------------------------------
109// PolicyStoreGate — production path (wraps agentstategraph-policy)
110// ---------------------------------------------------------------------------
111
112/// Wraps an `agentstategraph_policy::PolicyStore`. Rules from the JSON policy
113/// file are imported into a dedicated in-memory ASG repo at construction;
114/// all evaluation is then delegated to the real `PolicyStore` engine.
115///
116/// Using a separate in-memory repo (rather than the main ASD repo) keeps
117/// policy storage isolated and avoids requiring `Repository: Clone`.
118pub struct PolicyStoreGate {
119    store: PolicyStore,
120    ref_name: String,
121}
122
123impl PolicyStoreGate {
124    /// Load `path` as a [`PolicyFile`], import every rule into a fresh
125    /// in-memory ASG repo, and return a gate ready for evaluation.
126    pub fn from_file(path: &Path) -> Result<Self> {
127        let file = PolicyFile::load(path)?;
128
129        let storage = SqliteStorage::in_memory().map_err(|e| AsdError::Other(e.to_string()))?;
130        let repo = Repository::new(Box::new(storage));
131        repo.init()?;
132        let ref_name = "main";
133
134        let store = PolicyStore::new(Arc::new(repo), "/asd/policies", "asd-policy-import");
135
136        for rule in &file.policies {
137            let policy = rule_to_policy(rule, file.strict);
138            let handle = match store.propose(ref_name, policy) {
139                Ok(h) => h,
140                Err(agentstategraph_policy::PolicyError::AlreadyExists(_)) => continue,
141                Err(e) => return Err(AsdError::Other(e.to_string())),
142            };
143            // `handle` is "path@version" — ratify takes just the path.
144            let policy_path = handle.rsplitn(2, '@').nth(1).unwrap_or(&handle);
145            store
146                .ratify(
147                    ref_name,
148                    policy_path,
149                    "asd-policy-import",
150                    "imported from policy file",
151                )
152                .map_err(|e| AsdError::Other(e.to_string()))?;
153        }
154
155        Ok(Self {
156            store,
157            ref_name: ref_name.to_string(),
158        })
159    }
160}
161
162impl PolicyGate for PolicyStoreGate {
163    fn evaluate(&self, situation: &Situation, action: &str, agent_id: &str) -> Result<Decision> {
164        let asg_sit = asd_situation_to_asg(situation, agent_id);
165        let decision = self
166            .store
167            .evaluate(&self.ref_name, &asg_sit, action, agent_id)
168            .map_err(|e| AsdError::Other(e.to_string()))?;
169        Ok(asg_decision_to_asd(decision))
170    }
171}
172
173// ---------------------------------------------------------------------------
174// FilePolicyGate — kept for tests and backward compat
175// ---------------------------------------------------------------------------
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct PolicyRule {
179    pub path: String,
180    #[serde(default = "default_version")]
181    pub version: u32,
182    #[serde(default)]
183    pub description: Option<String>,
184    pub match_action: String,
185    #[serde(default)]
186    pub deny: bool,
187    #[serde(default)]
188    pub require_approval: Vec<String>,
189    #[serde(default)]
190    pub reason: Option<String>,
191    #[serde(default)]
192    pub agent_id: Option<String>,
193}
194
195fn default_version() -> u32 {
196    1
197}
198
199#[derive(Debug, Clone, Default, Serialize, Deserialize)]
200pub struct PolicyFile {
201    #[serde(default)]
202    pub policies: Vec<PolicyRule>,
203    #[serde(default)]
204    pub strict: bool,
205}
206
207impl PolicyFile {
208    pub fn load(path: &Path) -> Result<Self> {
209        let raw = fs::read_to_string(path)?;
210        Ok(serde_json::from_str(&raw)?)
211    }
212}
213
214pub struct FilePolicyGate {
215    file: PolicyFile,
216    source: String,
217}
218
219impl FilePolicyGate {
220    pub fn from_file(path: &Path) -> Result<Self> {
221        Ok(Self {
222            file: PolicyFile::load(path)?,
223            source: path.display().to_string(),
224        })
225    }
226
227    pub fn from_policy_file(file: PolicyFile, source: impl Into<String>) -> Self {
228        Self {
229            file,
230            source: source.into(),
231        }
232    }
233
234    pub fn source(&self) -> &str {
235        &self.source
236    }
237    pub fn rule_count(&self) -> usize {
238        self.file.policies.len()
239    }
240
241    fn matches(rule: &PolicyRule, action: &str, agent_id: &str) -> bool {
242        if let Some(pinned) = &rule.agent_id {
243            if pinned != agent_id {
244                return false;
245            }
246        }
247        if let Some(prefix) = rule.match_action.strip_suffix(".*") {
248            action == prefix || action.starts_with(&format!("{}.", prefix))
249        } else {
250            rule.match_action == action
251        }
252    }
253
254    fn decision_for(rule: &PolicyRule) -> Decision {
255        let matched = format!("{}@{}", rule.path, rule.version);
256        if !rule.require_approval.is_empty() {
257            Decision::RequireApproval {
258                matched_policy: matched,
259                approvers: rule.require_approval.clone(),
260                reason: rule.reason.clone(),
261            }
262        } else if rule.deny {
263            Decision::Deny {
264                matched_policy: matched,
265                reason: rule
266                    .reason
267                    .clone()
268                    .unwrap_or_else(|| "policy deny".to_string()),
269            }
270        } else {
271            Decision::Allow {
272                matched_policy: Some(matched),
273            }
274        }
275    }
276}
277
278impl PolicyGate for FilePolicyGate {
279    fn evaluate(&self, _: &Situation, action: &str, agent_id: &str) -> Result<Decision> {
280        for rule in &self.file.policies {
281            if Self::matches(rule, action, agent_id) {
282                return Ok(Self::decision_for(rule));
283            }
284        }
285        if self.file.strict {
286            Ok(Decision::NoPolicyMatch)
287        } else {
288            Ok(Decision::Allow {
289                matched_policy: None,
290            })
291        }
292    }
293}
294
295// ---------------------------------------------------------------------------
296// Conversion helpers
297// ---------------------------------------------------------------------------
298
299/// Convert an ASD `Situation` to an ASG `Situation` (HashMap<String,String>).
300/// Flat qualifiers are extracted; `description` and `agent_id` are added as facts.
301fn asd_situation_to_asg(situation: &Situation, agent_id: &str) -> AsgSituation {
302    let mut s = AsgSituation::new()
303        .with("description", &situation.description)
304        .with("agent_id", agent_id);
305    if let serde_json::Value::Object(map) = &situation.qualifiers {
306        for (k, v) in map {
307            if let Some(sv) = v.as_str() {
308                s = s.with(k.clone(), sv);
309            } else {
310                s = s.with(k.clone(), v.to_string());
311            }
312        }
313    }
314    s
315}
316
317/// Map an ASG `Decision` back to ASD's `Decision`.
318fn asg_decision_to_asd(d: agentstategraph_policy::Decision) -> Decision {
319    use agentstategraph_policy::Decision as D;
320    match d {
321        D::Allow { matched_policy, .. } => Decision::Allow {
322            matched_policy: Some(matched_policy),
323        },
324        D::Deny {
325            matched_policy,
326            reason,
327        } => Decision::Deny {
328            matched_policy,
329            reason,
330        },
331        D::RequireApproval {
332            matched_policy,
333            approvers,
334            ..
335        } => Decision::RequireApproval {
336            matched_policy,
337            approvers,
338            reason: None,
339        },
340        D::NoPolicyMatch => Decision::NoPolicyMatch,
341    }
342}
343
344/// Convert an ASD `PolicyRule` to an ASG `Policy` ready for `propose`.
345fn rule_to_policy(rule: &PolicyRule, _strict: bool) -> Policy {
346    let situation_selector = match &rule.agent_id {
347        Some(id) => Selector::Eq {
348            key: "agent_id".to_string(),
349            value: id.clone(),
350        },
351        None => Selector::Always,
352    };
353
354    let mut allow = Vec::new();
355    let mut deny = Vec::new();
356    let mut require_approval = Vec::new();
357
358    if !rule.require_approval.is_empty() {
359        require_approval.push(ApprovalRule {
360            action: rule.match_action.clone(),
361            approvers: rule.require_approval.clone(),
362            timeout: None,
363            fallback: FallbackAction::Block,
364        });
365    } else if rule.deny {
366        deny.push(AuthorizedAction {
367            action: rule.match_action.clone(),
368            condition: rule.reason.clone(),
369            preconditions: Vec::new(),
370        });
371    } else {
372        allow.push(AuthorizedAction {
373            action: rule.match_action.clone(),
374            condition: None,
375            preconditions: Vec::new(),
376        });
377    }
378
379    Policy {
380        path: rule.path.trim_start_matches('/').to_string(),
381        version: 1,
382        situation: rule
383            .description
384            .clone()
385            .unwrap_or_else(|| rule.path.clone()),
386        situation_selector,
387        allow,
388        deny,
389        require_approval,
390        procedure: None,
391        triggers: Vec::new(),
392        required_fields: Vec::new(),
393        severity: Default::default(),
394        proposed_by: "asd-policy-import".to_string(),
395        proposed_at: Utc::now(),
396        ratified_by: None,
397        ratified_at: None,
398        ratification_reasoning: None,
399        active_from: Utc::now(),
400        expires_at: None,
401        supersedes: None,
402        tenant_id: None,
403        external_evaluator: None,
404        signature: None,
405    }
406}
407
408// ---------------------------------------------------------------------------
409// Canonical ASD action vocabulary
410// ---------------------------------------------------------------------------
411
412pub mod actions {
413    pub const LEDGER_APPEND: &str = "asd.ledger.append";
414    pub const LEDGER_APPEND_HAZARD: &str = "asd.ledger.append.hazard";
415    pub const LEDGER_SUPERSEDE: &str = "asd.ledger.supersede";
416    pub const LEDGER_APPROVE: &str = "asd.ledger.approve";
417    pub const LEDGER_REJECT: &str = "asd.ledger.reject";
418    pub const LEDGER_WITHDRAW: &str = "asd.ledger.withdraw";
419    pub const LEDGER_REBIND: &str = "asd.ledger.rebind";
420    pub const EFFECT_DECLARE: &str = "asd.effect.declare";
421    pub const EFFECT_DECLARE_BROADENS: &str = "asd.effect.declare.broadens";
422    pub const CODE_READ: &str = "asd.code.read";
423    pub const CODE_COMMIT: &str = "asd.code.commit";
424    pub const MERGE_BRANCH_TO_MAIN: &str = "asd.merge.branch_to_main";
425    pub const RENAME_SYMBOL: &str = "asd.rename.symbol";
426    pub const RENAME_FILE: &str = "asd.rename.file";
427
428    pub fn ledger_append_action(kind: &str) -> String {
429        format!("{}.{}", LEDGER_APPEND, kind)
430    }
431}
432
433// ---------------------------------------------------------------------------
434// Tests
435// ---------------------------------------------------------------------------
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    fn make_file(rules: Vec<PolicyRule>, strict: bool) -> FilePolicyGate {
442        FilePolicyGate::from_policy_file(
443            PolicyFile {
444                policies: rules,
445                strict,
446            },
447            "test",
448        )
449    }
450
451    fn sit() -> Situation {
452        Situation::new("test")
453    }
454
455    #[test]
456    fn exact_match_require_approval() {
457        let gate = make_file(
458            vec![PolicyRule {
459                path: "/policies/code/hazard".into(),
460                version: 1,
461                description: None,
462                match_action: actions::LEDGER_APPEND_HAZARD.into(),
463                deny: false,
464                require_approval: vec!["human".into()],
465                reason: Some("audit".into()),
466                agent_id: None,
467            }],
468            false,
469        );
470        let d = gate
471            .evaluate(&sit(), actions::LEDGER_APPEND_HAZARD, "asd-mcp")
472            .unwrap();
473        match d {
474            Decision::RequireApproval {
475                matched_policy,
476                approvers,
477                reason,
478            } => {
479                assert_eq!(matched_policy, "/policies/code/hazard@1");
480                assert_eq!(approvers, vec!["human"]);
481                assert_eq!(reason.as_deref(), Some("audit"));
482            }
483            other => panic!("expected RequireApproval, got {:?}", other),
484        }
485    }
486
487    #[test]
488    fn prefix_wildcard_matches_suffix() {
489        let gate = make_file(
490            vec![PolicyRule {
491                path: "/p/any-ledger".into(),
492                version: 1,
493                description: None,
494                match_action: "asd.ledger.*".into(),
495                deny: true,
496                require_approval: vec![],
497                reason: Some("paused".into()),
498                agent_id: None,
499            }],
500            false,
501        );
502        let d = gate
503            .evaluate(&sit(), "asd.ledger.append.decision", "whoever")
504            .unwrap();
505        assert!(matches!(d, Decision::Deny { .. }));
506    }
507
508    #[test]
509    fn no_match_non_strict_is_allow() {
510        let gate = make_file(vec![], false);
511        let d = gate.evaluate(&sit(), "asd.anything", "x").unwrap();
512        assert!(matches!(
513            d,
514            Decision::Allow {
515                matched_policy: None
516            }
517        ));
518    }
519
520    #[test]
521    fn no_match_strict_is_no_policy_match() {
522        let gate = make_file(vec![], true);
523        let d = gate.evaluate(&sit(), "asd.anything", "x").unwrap();
524        assert!(matches!(d, Decision::NoPolicyMatch));
525    }
526
527    #[test]
528    fn agent_id_gated_rule_only_fires_for_matching_agent() {
529        let rule = PolicyRule {
530            path: "/p/restricted".into(),
531            version: 1,
532            description: None,
533            match_action: "asd.effect.declare".into(),
534            deny: true,
535            require_approval: vec![],
536            reason: None,
537            agent_id: Some("bot-v1".into()),
538        };
539        let gate = make_file(vec![rule], false);
540        let d1 = gate
541            .evaluate(&sit(), "asd.effect.declare", "bot-v1")
542            .unwrap();
543        assert!(matches!(d1, Decision::Deny { .. }));
544        let d2 = gate
545            .evaluate(&sit(), "asd.effect.declare", "bot-v2")
546            .unwrap();
547        assert!(matches!(d2, Decision::Allow { .. }));
548    }
549
550    #[test]
551    fn policy_store_gate_deny() {
552        use std::io::Write;
553        let mut f = tempfile::NamedTempFile::new().unwrap();
554        write!(f, r#"{{"policies":[{{"path":"/policies/test/no-tradeoffs","match_action":"asd.ledger.append.tradeoff","deny":true,"reason":"disabled"}}],"strict":false}}"#).unwrap();
555        let gate = PolicyStoreGate::from_file(f.path()).expect("from_file");
556        let sit = Situation::new("test");
557        let d = gate
558            .evaluate(&sit, "asd.ledger.append.tradeoff", "agent")
559            .unwrap();
560        assert!(
561            matches!(d, Decision::Deny { .. }),
562            "expected Deny, got {:?}",
563            d
564        );
565    }
566
567    #[test]
568    fn policy_store_gate_allow_other_action() {
569        use std::io::Write;
570        let mut f = tempfile::NamedTempFile::new().unwrap();
571        write!(f, r#"{{"policies":[{{"path":"/policies/test/no-tradeoffs","match_action":"asd.ledger.append.tradeoff","deny":true,"reason":"disabled"}}],"strict":false}}"#).unwrap();
572        let gate = PolicyStoreGate::from_file(f.path()).expect("from_file");
573        let sit = Situation::new("test");
574        let d = gate
575            .evaluate(&sit, "asd.ledger.append.decision", "agent")
576            .unwrap();
577        assert!(
578            matches!(d, Decision::Allow { .. } | Decision::NoPolicyMatch),
579            "expected Allow/NoPolicyMatch, got {:?}",
580            d
581        );
582    }
583}