1use std::time::{SystemTime, UNIX_EPOCH};
8
9use anyhow::Result;
10use globset::Glob;
11
12use super::db::Store;
13use super::enforcement::{record_event, ControlChangeKind, EnforcementEventType, SubjectKind};
14use super::record::{
15 Category, ConfidenceScore, PolicyRecord, PolicyStage, QualityScore, Record, RecordLifecycle,
16 RecordSource, RecordVersion, StalenessScore, TombstoneReason,
17};
18
19#[derive(Debug, thiserror::Error)]
23pub enum PolicyOpError {
24 #[error("invalid policy key '{key}'; expected policy:<slug>")]
25 InvalidKey { key: String },
26 #[error("record not found: {key}")]
27 NotFound { key: String },
28 #[error("policy key '{key}' already exists; edit the existing record instead")]
29 AlreadyExists { key: String },
30 #[error("policy '{key}' is not active")]
31 NotActive { key: String },
32 #[error("'{key}' is not a policy record")]
33 NotAPolicy { key: String },
34 #[error("'{key}' has no valid PolicyRecord payload")]
35 InvalidPayload { key: String },
36 #[error("invalid {field} '{pattern}': {source}")]
37 InvalidGlob {
38 field: &'static str,
39 pattern: String,
40 source: globset::Error,
41 },
42 #[error(
43 "trigger has no predicate; an empty trigger matches every governed action. \
44 Name at least one of tool ({tools}), host_glob, target_path_glob, or command_glob"
45 )]
46 EmptyTrigger { tools: String },
47}
48
49pub fn validate_trigger(trigger: &super::record::PolicyTrigger) -> Result<()> {
56 if trigger.tool.is_none()
57 && trigger.host_glob.is_none()
58 && trigger.target_path_glob.is_none()
59 && trigger.command_glob.is_none()
60 {
61 return Err(PolicyOpError::EmptyTrigger {
62 tools: crate::hooks::decide::KNOWN_ACTION_TOOLS.join(", "),
63 }
64 .into());
65 }
66 if let Some(pattern) = trigger.host_glob.as_deref() {
67 Glob::new(pattern).map_err(|source| PolicyOpError::InvalidGlob {
68 field: "host_glob",
69 pattern: pattern.to_string(),
70 source,
71 })?;
72 }
73 if let Some(pattern) = trigger.target_path_glob.as_deref() {
74 Glob::new(pattern).map_err(|source| PolicyOpError::InvalidGlob {
75 field: "target_path_glob",
76 pattern: pattern.to_string(),
77 source,
78 })?;
79 }
80 if let Some(pattern) = trigger.command_glob.as_deref() {
81 Glob::new(pattern).map_err(|source| PolicyOpError::InvalidGlob {
82 field: "command_glob",
83 pattern: pattern.to_string(),
84 source,
85 })?;
86 }
87 Ok(())
88}
89
90pub const WRITABLE_KEY_PREFIXES: &[&str] = &["gotcha:", "decision:", "dev_note:", "policy:"];
95
96pub const ADAPTER_POLICY_TOOLS: &[&str] = &["db_client", "path"];
98
99pub fn key_is_writable(key: &str) -> bool {
101 WRITABLE_KEY_PREFIXES
102 .iter()
103 .any(|prefix| key.starts_with(prefix) && key.len() > prefix.len())
104}
105
106pub fn author_warnings(policy: &PolicyRecord, has_backing_record: bool) -> Vec<String> {
107 let mut warnings = Vec::new();
108 if policy.mode == super::record::PolicyMode::Block && policy.requires.via.is_empty() {
109 warnings.push(
110 "warning: block policy has an empty requires.via; no receipt source can satisfy it."
111 .into(),
112 );
113 } else if policy.mode == super::record::PolicyMode::Block
114 && !policy
115 .requires
116 .via
117 .iter()
118 .any(|source| super::record::CODEX_PRODUCIBLE_SOURCES.contains(source))
119 {
120 warnings.push(
121 "warning: Codex cannot produce any accepted receipt source for this block policy; "
122 .to_string()
123 + "Codex will steer it with a diagnostic instead of denying an unsatisfiable action."
124 );
125 }
126 if policy.trigger.tool.as_deref() == Some("file_read") {
127 warnings.push(
128 "warning: policy trigger tool 'file_read' has no enforcing adapter; Bash file reads are governed by gotchas, so this policy will never be evaluated. Use a gotcha for reads or a db_client/path policy for an adapter-backed action.".into(),
129 );
130 }
131 if let Some(tool) = policy
132 .trigger
133 .tool
134 .as_deref()
135 .filter(|tool| !crate::hooks::decide::is_known_action_tool(tool))
136 {
137 warnings.push(format!(
138 "warning: policy trigger tool '{tool}' is not a recognized governable category (known: {}); this policy will never match until such a category is supported.",
139 crate::hooks::decide::KNOWN_ACTION_TOOLS.join(", ")
140 ));
141 }
142 if policy
143 .requires
144 .via
145 .contains(&super::record::ReceiptSource::MemGet)
146 && !has_backing_record
147 {
148 let key = &policy.requires.key;
149 if key_is_writable(key) {
150 warnings.push(format!(
151 "warning: requires.key '{key}' has no backing record; a mem_get on it would mint a receipt without the agent learning anything. Store a record at that key (e.g. the schema doc) so consultation is substantive."
152 ));
153 } else {
154 warnings.push(format!(
158 "warning: requires.key '{key}' can never hold a record: mem_set writes only {} prefixes. A mem_get on it mints a receipt that teaches the agent nothing. Use a writable key, such as 'decision:{}'.",
159 WRITABLE_KEY_PREFIXES.join(", "),
160 key.rsplit(':').next().unwrap_or("the-doc")
161 ));
162 }
163 }
164 if policy.requires.freshness.fingerprint
165 && !policy
166 .requires
167 .via
168 .contains(&super::record::ReceiptSource::MemGet)
169 {
170 warnings.push("warning: requires.freshness.fingerprint is unsatisfiable without MemGet; DbIntrospection receipts do not carry a content fingerprint.".into());
171 }
172 if policy.mode == super::record::PolicyMode::Block && policy.requires.key.is_empty() {
173 warnings.push(
174 "warning: block policy has an empty requires.key; nothing could ever unlock it.".into(),
175 );
176 }
177 warnings
178}
179
180fn now_secs() -> u64 {
181 SystemTime::now()
182 .duration_since(UNIX_EPOCH)
183 .unwrap_or_default()
184 .as_secs()
185}
186
187fn ensure_policy_key(key: &str) -> Result<()> {
188 if !key.starts_with("policy:") || key.len() == "policy:".len() {
189 return Err(PolicyOpError::InvalidKey {
190 key: key.to_string(),
191 }
192 .into());
193 }
194 Ok(())
195}
196
197fn audit_kind(kind: ControlChangeKind) -> &'static str {
198 match kind {
199 ControlChangeKind::Created => "control_created",
200 ControlChangeKind::Updated => "control_updated",
201 ControlChangeKind::Deleted => "control_deleted",
202 ControlChangeKind::Confirmed => "control_updated",
203 }
204}
205
206async fn audit(store: &Store, key: &str, kind: ControlChangeKind) {
207 if let Err(error) = record_event(
208 store,
209 EnforcementEventType::ControlChanged { change_kind: kind },
210 SubjectKind::Control,
211 key.to_string(),
212 "developer".to_string(),
213 None,
214 audit_kind(kind).to_string(),
215 None,
216 )
217 .await
218 {
219 tracing::warn!("policy_ops: enforcement event recording failed for {key}: {error}");
220 }
221}
222
223pub fn record_for(key: &str, policy: &PolicyRecord) -> Result<Record> {
225 ensure_policy_key(key)?;
226 let now = now_secs();
227 let source = RecordSource::DeveloperManual;
228 Ok(Record {
229 key: key.to_string(),
230 value: policy.rule.clone(),
231 category: Category::Policy,
232 priority: policy.severity.clone(),
233 tags: vec![],
234 created_at: now,
235 updated_at: now,
236 ref_url: None,
237 staleness: StalenessScore::fresh(),
238 lifecycle: RecordLifecycle::Active,
239 version: RecordVersion {
240 device_id: crate::store::stable_device_id(),
241 logical_clock: 1,
242 wall_clock: now,
243 },
244 quality: QualityScore::developer_entry_default(),
245 access_count: 0,
246 last_accessed: 0,
247 source: source.clone(),
248 confidence: ConfidenceScore::for_new_record(&source),
249 gap_analysis_score: 0.0,
250 payload: Some(serde_json::to_value(policy)?),
251 })
252}
253
254pub async fn create(store: &Store, key: &str, policy: &PolicyRecord) -> Result<()> {
259 validate_trigger(&policy.trigger)?;
260 let record = record_for(key, policy)?;
261 if let Some(existing) = store.get(key).await? {
262 if matches!(existing.lifecycle, RecordLifecycle::Active) {
263 return Err(PolicyOpError::AlreadyExists {
264 key: key.to_string(),
265 }
266 .into());
267 }
268 }
269 store.put(key, &record).await?;
270 audit(store, key, ControlChangeKind::Created).await;
271 Ok(())
272}
273
274pub async fn edit(store: &Store, key: &str, policy: &PolicyRecord) -> Result<()> {
276 validate_trigger(&policy.trigger)?;
277 ensure_policy_key(key)?;
278 let mut record = store
279 .get(key)
280 .await?
281 .ok_or_else(|| PolicyOpError::NotFound {
282 key: key.to_string(),
283 })?;
284 if record.category != Category::Policy {
285 return Err(PolicyOpError::NotAPolicy {
286 key: key.to_string(),
287 }
288 .into());
289 }
290 if !matches!(record.lifecycle, RecordLifecycle::Active) {
291 return Err(PolicyOpError::NotActive {
292 key: key.to_string(),
293 }
294 .into());
295 }
296 record.value = policy.rule.clone();
297 record.priority = policy.severity.clone();
298 record.updated_at = now_secs();
299 record.version.logical_clock += 1;
300 record.version.wall_clock = record.updated_at;
301 record.payload = Some(serde_json::to_value(policy)?);
302 store.put(key, &record).await?;
303 audit(store, key, ControlChangeKind::Updated).await;
304 Ok(())
305}
306
307pub async fn set_stage(store: &Store, key: &str, stage: PolicyStage) -> Result<()> {
309 ensure_policy_key(key)?;
310 let mut record = store
311 .get(key)
312 .await?
313 .ok_or_else(|| PolicyOpError::NotFound {
314 key: key.to_string(),
315 })?;
316 let mut policy =
317 record
318 .payload_as::<PolicyRecord>()
319 .ok_or_else(|| PolicyOpError::InvalidPayload {
320 key: key.to_string(),
321 })?;
322 if !matches!(record.lifecycle, RecordLifecycle::Active) {
323 return Err(PolicyOpError::NotActive {
324 key: key.to_string(),
325 }
326 .into());
327 }
328 policy.stage = stage;
329 record.payload = Some(serde_json::to_value(&policy)?);
330 record.updated_at = now_secs();
331 record.version.logical_clock += 1;
332 record.version.wall_clock = record.updated_at;
333 store.put(key, &record).await?;
334 audit(store, key, ControlChangeKind::Updated).await;
335 Ok(())
336}
337
338pub async fn delete(store: &Store, key: &str) -> Result<()> {
340 ensure_policy_key(key)?;
341 let mut record = store
342 .get(key)
343 .await?
344 .ok_or_else(|| PolicyOpError::NotFound {
345 key: key.to_string(),
346 })?;
347 if !matches!(record.lifecycle, RecordLifecycle::Active) {
348 return Err(PolicyOpError::NotActive {
349 key: key.to_string(),
350 }
351 .into());
352 }
353 let now = now_secs();
354 record.lifecycle = RecordLifecycle::Tombstoned {
355 reason: TombstoneReason::ManualDeletion,
356 at: now,
357 };
358 record.updated_at = now;
359 record.version.logical_clock += 1;
360 record.version.wall_clock = now;
361 store.put(key, &record).await?;
362 audit(store, key, ControlChangeKind::Deleted).await;
363 Ok(())
364}
365
366pub async fn list(store: &Store) -> Result<Vec<Record>> {
367 Ok(store
368 .scan_prefix("policy:")
369 .await?
370 .into_iter()
371 .filter(|record| matches!(record.lifecycle, RecordLifecycle::Active))
372 .collect())
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378 use tempfile::TempDir;
379
380 fn sample() -> PolicyRecord {
381 PolicyRecord {
382 name: "Query safety".into(),
383 rule: "Consult the schema first.".into(),
384 reason: "Schemas drift because production changes independently.".into(),
385 scope: "repo".into(),
386 mode: super::super::record::PolicyMode::Block,
387 trigger: super::super::record::PolicyTrigger {
388 tool: Some("db_client".into()),
389 ..Default::default()
390 },
391 requires: super::super::record::PolicyRequires {
392 key: "schema:orders".into(),
393 via: vec![super::super::record::ReceiptSource::MemGet],
394 freshness: super::super::record::PolicyFreshness {
395 ttl_secs: 900,
396 fingerprint: false,
397 },
398 },
399 stage: PolicyStage::Enforce,
400 severity: super::super::record::Priority::High,
401 created_by: "developer".into(),
402 }
403 }
404
405 #[tokio::test]
406 async fn lifecycle_emits_control_events_and_tombstones() {
407 let dir = TempDir::new().unwrap();
408 let store = Store::open(dir.path()).await.unwrap();
409 let key = "policy:query-safety";
410 create(&store, key, &sample()).await.unwrap();
411 assert_eq!(list(&store).await.unwrap().len(), 1);
412 set_stage(&store, key, PolicyStage::Off).await.unwrap();
413 set_stage(&store, key, PolicyStage::Enforce).await.unwrap();
414 delete(&store, key).await.unwrap();
415 assert!(list(&store).await.unwrap().is_empty());
416 let events = super::super::enforcement::scan_enforcement_events(&store, 0, u64::MAX)
417 .await
418 .unwrap();
419 assert_eq!(events.len(), 4);
420 assert!(events
421 .iter()
422 .all(|event| event.subject_kind == SubjectKind::Control && event.subject_key == key));
423 let kinds: Vec<_> = events
424 .iter()
425 .filter_map(|event| match event.event_type {
426 EnforcementEventType::ControlChanged { change_kind } => Some(change_kind),
427 _ => None,
428 })
429 .collect();
430 assert_eq!(
431 kinds,
432 vec![
433 ControlChangeKind::Created,
434 ControlChangeKind::Updated,
435 ControlChangeKind::Updated,
436 ControlChangeKind::Deleted
437 ]
438 );
439 }
440
441 #[tokio::test]
442 async fn canonical_failure_happens_before_event() {
443 let dir = TempDir::new().unwrap();
444 let store = Store::open(dir.path()).await.unwrap();
445 let error = delete(&store, "policy:missing").await.unwrap_err();
446 assert!(error.to_string().contains("record not found"));
447 assert!(
448 super::super::enforcement::scan_enforcement_events(&store, 0, u64::MAX)
449 .await
450 .unwrap()
451 .is_empty()
452 );
453 }
454
455 #[tokio::test]
456 async fn edit_updates_payload_and_bumps_clock() {
457 let dir = TempDir::new().unwrap();
458 let store = Store::open(dir.path()).await.unwrap();
459 let key = "policy:query-safety";
460 create(&store, key, &sample()).await.unwrap();
461
462 let mut revised = sample();
463 revised.rule = "Always consult the schema before any write.".into();
464 revised.severity = super::super::record::Priority::Critical;
465 edit(&store, key, &revised).await.unwrap();
466
467 let record = store.get(key).await.unwrap().unwrap();
468 let policy = record.payload_as::<PolicyRecord>().unwrap();
469 assert_eq!(policy.rule, "Always consult the schema before any write.");
470 assert_eq!(record.priority, super::super::record::Priority::Critical);
471 assert_eq!(record.version.logical_clock, 2);
472
473 assert!(edit(&store, "policy:missing", &revised).await.is_err());
475 }
476
477 #[tokio::test]
478 async fn create_rejects_active_duplicate_but_resurrects_tombstone() {
479 let dir = TempDir::new().unwrap();
480 let store = Store::open(dir.path()).await.unwrap();
481 let key = "policy:dup";
482 create(&store, key, &sample()).await.unwrap();
483 assert!(create(&store, key, &sample()).await.is_err());
485 delete(&store, key).await.unwrap();
487 create(&store, key, &sample()).await.unwrap();
488 assert_eq!(list(&store).await.unwrap().len(), 1);
489 }
490
491 #[tokio::test]
492 async fn create_rejects_malformed_trigger_before_persisting() {
493 let dir = TempDir::new().unwrap();
494 let store = Store::open(dir.path()).await.unwrap();
495 let key = "policy:invalid-glob";
496 let mut policy = sample();
497 policy.trigger.host_glob = Some("[".into());
498
499 let error = create(&store, key, &policy).await.unwrap_err();
500
501 assert!(error.to_string().contains("invalid host_glob"));
502 assert!(store.get(key).await.unwrap().is_none());
503 }
504
505 #[test]
508 fn trigger_json_rejects_unknown_fields() {
509 let error = serde_json::from_str::<super::super::record::PolicyTrigger>(
510 r#"{"tooool":"db_client"}"#,
511 )
512 .unwrap_err();
513 assert!(error.to_string().contains("unknown field `tooool`"));
514 assert!(serde_json::from_str::<super::super::record::PolicyTrigger>(
515 r#"{"tool":"db_client"}"#
516 )
517 .is_ok());
518 }
519
520 #[tokio::test]
521 async fn create_rejects_empty_trigger_before_persisting() {
522 let dir = TempDir::new().unwrap();
523 let store = Store::open(dir.path()).await.unwrap();
524 let key = "policy:matches-everything";
525 let mut policy = sample();
526 policy.trigger = Default::default();
527
528 let error = create(&store, key, &policy).await.unwrap_err();
529
530 assert!(error.to_string().contains("no predicate"));
531 assert!(store.get(key).await.unwrap().is_none());
532 }
533
534 #[test]
535 fn validate_trigger_accepts_any_single_predicate() {
536 for trigger in [
537 super::super::record::PolicyTrigger {
538 tool: Some("db_client".into()),
539 ..Default::default()
540 },
541 super::super::record::PolicyTrigger {
542 host_glob: Some("*prod*".into()),
543 ..Default::default()
544 },
545 super::super::record::PolicyTrigger {
546 target_path_glob: Some("**/*.sql".into()),
547 command_glob: None,
548 ..Default::default()
549 },
550 super::super::record::PolicyTrigger {
551 command_glob: Some("terraform destroy*".into()),
552 ..Default::default()
553 },
554 ] {
555 validate_trigger(&trigger).expect("one predicate is enough");
556 }
557 assert!(validate_trigger(&Default::default()).is_err());
558 }
559}