1use 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#[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#[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
94pub 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
108pub struct PolicyStoreGate {
119 store: PolicyStore,
120 ref_name: String,
121}
122
123impl PolicyStoreGate {
124 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 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#[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
295fn 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
317fn 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
344fn 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
408pub 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#[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}