1use crate::cli::CliOutput;
59use crate::models::field_names;
60use crate::{db, models};
61use anyhow::Result;
62use models::{GovernanceDecision, GovernedAction};
63use rusqlite::Connection;
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum GovernanceOutcome {
69 Allow,
71 Pending,
74 Deny,
77}
78
79#[allow(clippy::too_many_arguments)]
86pub fn enforce(
87 conn: &Connection,
88 action: GovernedAction,
89 namespace: &str,
90 caller_agent_id: &str,
91 memory_id: Option<&str>,
92 memory_owner: Option<&str>,
93 payload: &serde_json::Value,
94 json_out: bool,
95 out: &mut CliOutput<'_>,
96) -> Result<GovernanceOutcome> {
97 match db::enforce_governance(
98 conn,
99 action,
100 namespace,
101 caller_agent_id,
102 memory_id,
103 memory_owner,
104 payload,
105 )? {
106 GovernanceDecision::Allow => Ok(GovernanceOutcome::Allow),
107 GovernanceDecision::Deny(refusal) => {
108 writeln!(
109 out.stderr,
110 "{} denied by governance: {reason}",
111 action.as_str(),
112 reason = refusal.reason,
113 )?;
114 Ok(GovernanceOutcome::Deny)
115 }
116 GovernanceDecision::Pending(pending_id) => {
117 if json_out {
128 let mut payload_obj = serde_json::json!({
129 "status": "pending",
130 (field_names::PENDING_ID): pending_id,
131 "reason": crate::errors::msg::GOVERNANCE_REQUIRES_APPROVAL,
132 "action": action.as_str(),
133 "namespace": namespace,
134 });
135 if let Some(mid) = memory_id
136 && let Some(obj) = payload_obj.as_object_mut()
137 {
138 obj.insert(
139 "memory_id".to_string(),
140 serde_json::Value::String(mid.to_string()),
141 );
142 }
143 writeln!(out.stdout, "{payload_obj}")?;
144 } else if let Some(mid) = memory_id {
145 writeln!(
146 out.stdout,
147 "{} queued for approval: pending_id={pending_id} id={mid}",
148 action.as_str()
149 )?;
150 } else {
151 writeln!(
152 out.stdout,
153 "{} queued for approval: pending_id={pending_id} ns={namespace}",
154 action.as_str()
155 )?;
156 }
157 Ok(GovernanceOutcome::Pending)
158 }
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::cli::test_utils::{TestEnv, seed_memory};
166 use crate::models::{ApproverType, CorePolicy, GovernanceLevel, GovernancePolicy};
167
168 fn pin_governance_enforce_for_test() -> std::sync::MutexGuard<'static, ()> {
174 let guard = crate::config::lock_permissions_mode_for_test();
175 crate::config::override_active_permissions_mode_for_test(
176 crate::config::PermissionsMode::Enforce,
177 );
178 guard
179 }
180
181 fn seed_governance_policy(
185 db_path: &std::path::Path,
186 namespace: &str,
187 policy: GovernancePolicy,
188 owner_agent_id: &str,
189 ) {
190 let conn = db::open(db_path).unwrap();
191 let now = chrono::Utc::now().to_rfc3339();
192 let mut metadata = models::default_metadata();
193 if let Some(obj) = metadata.as_object_mut() {
194 obj.insert(
195 "agent_id".to_string(),
196 serde_json::Value::String(owner_agent_id.to_string()),
197 );
198 obj.insert(
199 "governance".to_string(),
200 serde_json::to_value(&policy).unwrap(),
201 );
202 }
203 let standard = models::Memory {
204 id: uuid::Uuid::new_v4().to_string(),
205 tier: models::Tier::Long,
206 namespace: format!("_standards-{namespace}"),
207 title: format!("standard for {namespace}"),
208 content: "policy".to_string(),
209 tags: vec![],
210 priority: 9,
211 confidence: 1.0,
212 source: "test".to_string(),
213 access_count: 0,
214 created_at: now.clone(),
215 updated_at: now,
216 last_accessed_at: None,
217 expires_at: None,
218 metadata,
219 reflection_depth: 0,
220 memory_kind: crate::models::MemoryKind::Observation,
221 entity_id: None,
222 persona_version: None,
223 citations: Vec::new(),
224 source_uri: None,
225 source_span: None,
226 confidence_source: crate::models::ConfidenceSource::CallerProvided,
227 confidence_signals: None,
228 confidence_decayed_at: None,
229 version: 1,
230 };
231 let standard_id = db::insert(&conn, &standard).unwrap();
232 db::set_namespace_standard(&conn, namespace, &standard_id, None).unwrap();
233 }
234
235 #[test]
236 fn test_governance_allow_returns_allow_no_output() {
237 let mut env = TestEnv::fresh();
238 let db_path = env.db_path.clone();
239 let _ = seed_memory(&db_path, "ns", "x", "y");
241 let conn = db::open(&db_path).unwrap();
242 let payload = serde_json::json!({});
243 let outcome = {
244 let mut out = env.output();
245 enforce(
246 &conn,
247 GovernedAction::Store,
248 "ns-without-policy",
249 "alice",
250 None,
251 None,
252 &payload,
253 false,
254 &mut out,
255 )
256 .unwrap()
257 };
258 assert_eq!(outcome, GovernanceOutcome::Allow);
259 assert!(env.stdout_str().is_empty());
260 assert!(env.stderr_str().is_empty());
261 }
262
263 #[test]
264 fn test_governance_pending_writes_pending_status_text() {
265 let _gate = pin_governance_enforce_for_test();
266 let mut env = TestEnv::fresh();
267 let db_path = env.db_path.clone();
268 let policy = GovernancePolicy {
269 core: CorePolicy {
270 write: GovernanceLevel::Approve,
271 promote: GovernanceLevel::Any,
272 delete: GovernanceLevel::Owner,
273 approver: ApproverType::Human,
274 inherit: true,
275 max_reflection_depth: None,
276 },
277 ..Default::default()
278 };
279 seed_governance_policy(&db_path, "gov-ns", policy, "alice");
280 let conn = db::open(&db_path).unwrap();
281 let payload = serde_json::json!({"title": "t"});
282 let outcome = {
283 let mut out = env.output();
284 enforce(
285 &conn,
286 GovernedAction::Store,
287 "gov-ns",
288 "bob",
289 None,
290 None,
291 &payload,
292 false,
293 &mut out,
294 )
295 .unwrap()
296 };
297 assert_eq!(outcome, GovernanceOutcome::Pending);
298 let stdout = env.stdout_str();
299 assert!(stdout.contains("queued for approval"), "got: {stdout}");
300 assert!(stdout.contains("pending_id="), "got: {stdout}");
301 assert!(stdout.contains("ns=gov-ns"), "got: {stdout}");
302 }
303
304 #[test]
305 fn test_governance_pending_writes_pending_status_json() {
306 let _gate = pin_governance_enforce_for_test();
307 let mut env = TestEnv::fresh();
308 let db_path = env.db_path.clone();
309 let policy = GovernancePolicy {
310 core: CorePolicy {
311 write: GovernanceLevel::Any,
312 promote: GovernanceLevel::Any,
313 delete: GovernanceLevel::Approve,
314 approver: ApproverType::Human,
315 inherit: true,
316 max_reflection_depth: None,
317 },
318 ..Default::default()
319 };
320 seed_governance_policy(&db_path, "gov-ns", policy, "alice");
321 let conn = db::open(&db_path).unwrap();
322 let payload = serde_json::json!({});
323 let outcome = {
324 let mut out = env.output();
325 enforce(
326 &conn,
327 GovernedAction::Delete,
328 "gov-ns",
329 "bob",
330 Some("00000000-0000-0000-0000-000000000abc"),
331 Some("alice"),
332 &payload,
333 true,
334 &mut out,
335 )
336 .unwrap()
337 };
338 assert_eq!(outcome, GovernanceOutcome::Pending);
339 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
340 assert_eq!(v["status"].as_str().unwrap(), "pending");
341 assert_eq!(v["action"].as_str().unwrap(), "delete");
342 assert_eq!(v["namespace"].as_str().unwrap(), "gov-ns");
343 assert!(v["pending_id"].is_string());
344 assert_eq!(
345 v["memory_id"].as_str().unwrap(),
346 "00000000-0000-0000-0000-000000000abc"
347 );
348 }
349
350 #[test]
351 fn test_governance_deny_writes_reason_to_stderr() {
352 let _gate = pin_governance_enforce_for_test();
353 let mut env = TestEnv::fresh();
354 let db_path = env.db_path.clone();
355 let policy = GovernancePolicy {
356 core: CorePolicy {
357 write: GovernanceLevel::Any,
358 promote: GovernanceLevel::Any,
359 delete: GovernanceLevel::Owner,
360 approver: ApproverType::Human,
361 inherit: true,
362 max_reflection_depth: None,
363 },
364 ..Default::default()
365 };
366 seed_governance_policy(&db_path, "gov-ns", policy, "alice");
367 let conn = db::open(&db_path).unwrap();
368 let payload = serde_json::json!({});
369 let outcome = {
370 let mut out = env.output();
371 enforce(
372 &conn,
373 GovernedAction::Delete,
374 "gov-ns",
375 "bob",
376 Some("00000000-0000-0000-0000-000000000def"),
377 Some("alice"),
378 &payload,
379 false,
380 &mut out,
381 )
382 .unwrap()
383 };
384 assert_eq!(outcome, GovernanceOutcome::Deny);
385 let stderr = env.stderr_str();
386 assert!(
387 stderr.contains("delete denied by governance"),
388 "got: {stderr}"
389 );
390 assert!(stderr.contains("not the owner"), "got: {stderr}");
391 assert!(env.stdout_str().is_empty());
393 }
394
395 #[test]
396 fn test_governance_deny_returns_deny_outcome() {
397 let _gate = pin_governance_enforce_for_test();
398 let mut env = TestEnv::fresh();
399 let db_path = env.db_path.clone();
400 let policy = GovernancePolicy {
401 core: CorePolicy {
402 write: GovernanceLevel::Registered,
403 promote: GovernanceLevel::Any,
404 delete: GovernanceLevel::Owner,
405 approver: ApproverType::Human,
406 inherit: true,
407 max_reflection_depth: None,
408 },
409 ..Default::default()
410 };
411 seed_governance_policy(&db_path, "gov-ns", policy, "alice");
412 let conn = db::open(&db_path).unwrap();
413 let payload = serde_json::json!({});
414 let outcome = {
415 let mut out = env.output();
416 enforce(
417 &conn,
418 GovernedAction::Store,
419 "gov-ns",
420 "unregistered-caller",
421 None,
422 None,
423 &payload,
424 false,
425 &mut out,
426 )
427 .unwrap()
428 };
429 assert_eq!(outcome, GovernanceOutcome::Deny);
430 assert!(env.stderr_str().contains("not a registered agent"));
431 }
432
433 #[test]
434 fn test_governance_payload_serializes_correctly() {
435 let _gate = pin_governance_enforce_for_test();
436 let mut env = TestEnv::fresh();
441 let db_path = env.db_path.clone();
442 let policy = GovernancePolicy {
443 core: CorePolicy {
444 write: GovernanceLevel::Approve,
445 promote: GovernanceLevel::Any,
446 delete: GovernanceLevel::Owner,
447 approver: ApproverType::Human,
448 inherit: true,
449 max_reflection_depth: None,
450 },
451 ..Default::default()
452 };
453 seed_governance_policy(&db_path, "gov-ns", policy, "alice");
454 let conn = db::open(&db_path).unwrap();
455 let payload = serde_json::json!({"title": "hello", "priority": 7});
456 let _ = {
457 let mut out = env.output();
458 enforce(
459 &conn,
460 GovernedAction::Store,
461 "gov-ns",
462 "carol",
463 None,
464 None,
465 &payload,
466 true,
467 &mut out,
468 )
469 .unwrap()
470 };
471 let stored_payload: String = conn
474 .query_row(
475 "SELECT payload FROM pending_actions WHERE namespace = 'gov-ns' AND requested_by = 'carol' ORDER BY requested_at DESC LIMIT 1",
476 [],
477 |r| r.get(0),
478 )
479 .unwrap();
480 let v: serde_json::Value = serde_json::from_str(&stored_payload).unwrap();
481 assert_eq!(v["title"].as_str().unwrap(), "hello");
482 assert_eq!(v["priority"].as_u64().unwrap(), 7);
483 }
484}