1use std::collections::{BTreeMap, BTreeSet};
44
45use serde::{Deserialize, Serialize};
46use serde_json::Value;
47use sha2::{Digest, Sha256};
48
49use super::{canonical_json, require_schema, signing_input_without_signature};
50use crate::{UtcTimestamp, ValidationError};
51
52pub const EFFECTIVE_POLICY_SCHEMA: &str = "leanctx.hive.effective-policy/v1";
53pub const POLICY_DOCUMENT_SCHEMA: &str = "leanctx.hive.policy/v1";
54pub const POLICY_ACK_SCHEMA: &str = "leanctx.hive.policy-ack/v1";
55pub const EFFECTIVE_POLICY_SIGNATURE_DOMAIN: &str = "leanctx.hive.effective-policy.v1\n";
57pub const MAX_POLICY_SOURCES: usize = 35;
59pub const MAX_POLICY_VAULTS: usize = 32;
60pub const MAX_UNENFORCED: usize = 64;
62pub const POLICY_MISMATCH: &str = "policy_mismatch";
64
65const MAX_SET: usize = 256;
67const MAX_MODEL_GROUPS: usize = 16;
68const MAX_MODEL_GROUP: usize = 32;
69
70pub const ENGINE_MEMBERS: &[(&str, &str)] = &[
73 ("context.allowed_tools", "context.allow_tools"),
74 ("context.denied_tools", "context.deny_tools"),
75 ("context.injection_filter", "filters.injection"),
76 ("context.max_context_tokens", "context.max_context_tokens"),
77 (
78 "context.max_cost_cents_per_person_per_day",
79 "budgets.max_cost_usd_per_person_per_day",
80 ),
81 (
82 "context.max_cost_cents_per_project_per_month",
83 "budgets.max_cost_usd_per_project_per_month",
84 ),
85 (
86 "context.max_requests_per_minute_per_person",
87 "budgets.max_requests_per_minute_per_person",
88 ),
89 ("context.max_writes_per_min", "egress.max_writes_per_min"),
90 ("context.read_mode", "context.default_read_mode"),
91 ("security.allowed_models", "routing.model_ceiling_groups"),
92 ("security.block_secrets", "egress.block_secrets"),
93 ("security.blocked_labels", "filters.blocked_labels"),
94 ("security.classification", "filters.classification"),
95 ("security.never_send", "io.never_send"),
96 ("security.pii", "filters.pii"),
97 ("security.readable_roots", "io.readable_roots"),
98];
99
100pub const PLANNED_ENGINE_MEMBERS: &[(&str, &str)] = &[
105 ("agents.allowed", "agents.allowed_agents"),
106 ("security.allowed_providers", "routing.allowed_providers"),
107 ("security.require_proxy", "routing.require_proxy"),
108];
109
110pub const SERVER_MEMBERS: &[&str] = &[
113 "security.excluded_paths",
114 "sharing.allowed_vaults",
115 "sharing.consent_required_for",
116 "sharing.cross_team_sharing",
117 "sharing.personal_workspace",
118 "sharing.share_instance_metrics",
121 "sharing.task_claim_min_role",
122];
123
124pub const ADVISORY_MEMBERS: &[&str] = &[
126 "agents.require_approvals",
129 "context.allowed_sources",
130 "context.auto_extraction",
131 "context.extraction_requires_source_anchor",
132 "context.max_source_age_days",
133 "context.ranking_profile",
134 "security.confidential_out_of_sharing",
135 "security.external_model_excluded_classes",
136];
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum PolicyActionV1 {
142 Off,
143 Warn,
144 Redact,
145 Block,
146}
147
148impl PolicyActionV1 {
149 pub fn as_str(self) -> &'static str {
150 match self {
151 Self::Off => "off",
152 Self::Warn => "warn",
153 Self::Redact => "redact",
154 Self::Block => "block",
155 }
156 }
157}
158
159const READ_MODES: &[&str] = &[
160 "auto",
161 "full",
162 "map",
163 "signatures",
164 "diff",
165 "task",
166 "reference",
167 "aggressive",
168 "entropy",
169];
170const DATA_CLASSES: &[&str] = &[
171 "secrets",
172 "pii",
173 "confidential",
174 "customer_data",
175 "source_code",
176];
177const CLAIM_ROLES: &[&str] = &["reader", "contributor", "maintainer", "admin", "owner"];
178pub const PERSONAL_WORKSPACE_MODES: &[&str] = &["allow", "forbid"];
180const CONSENT_ACTIONS: &[&str] = &[
181 "trust_boundary",
182 "permission_expansion",
183 "security_command",
184 "destructive_change",
185 "team_sharing",
186 "external_model",
187];
188
189#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
191pub struct SecurityPolicyV1 {
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub excluded_paths: Option<Vec<String>>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub block_secrets: Option<bool>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub pii: Option<PolicyActionV1>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub classification: Option<PolicyActionV1>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub blocked_labels: Option<Vec<String>>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub allowed_models: Option<Vec<Vec<String>>>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub external_model_excluded_classes: Option<Vec<String>>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub confidential_out_of_sharing: Option<bool>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub allowed_providers: Option<Vec<String>>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub require_proxy: Option<bool>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub readable_roots: Option<Vec<String>>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub never_send: Option<Vec<String>>,
224 #[serde(flatten)]
226 pub other: BTreeMap<String, Value>,
227}
228
229#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
231pub struct ContextPolicyV1 {
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub max_context_tokens: Option<u64>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub injection_filter: Option<PolicyActionV1>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub read_mode: Option<String>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub ranking_profile: Option<String>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub allowed_tools: Option<Vec<String>>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub denied_tools: Option<Vec<String>>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub allowed_sources: Option<Vec<String>>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub max_writes_per_min: Option<u64>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub max_cost_cents_per_person_per_day: Option<u64>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub max_cost_cents_per_project_per_month: Option<u64>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub max_requests_per_minute_per_person: Option<u64>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub auto_extraction: Option<bool>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub extraction_requires_source_anchor: Option<bool>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub max_source_age_days: Option<u64>,
260 #[serde(flatten)]
261 pub other: BTreeMap<String, Value>,
262}
263
264#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
266pub struct SharingPolicyV1 {
267 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub allowed_vaults: Option<Vec<String>>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub cross_team_sharing: Option<bool>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub task_claim_min_role: Option<String>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub consent_required_for: Option<Vec<String>>,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub personal_workspace: Option<String>,
281 #[serde(flatten)]
282 pub other: BTreeMap<String, Value>,
283}
284
285#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
288pub struct AgentsPolicyV1 {
289 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub allowed: Option<Vec<String>>,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub require_approvals: Option<bool>,
296 #[serde(flatten)]
297 pub other: BTreeMap<String, Value>,
298}
299
300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
302pub struct EffectivePolicyV1 {
303 pub schema: String,
304 pub security: SecurityPolicyV1,
305 pub context: ContextPolicyV1,
306 pub sharing: SharingPolicyV1,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub agents: Option<AgentsPolicyV1>,
309 #[serde(flatten)]
313 pub other: BTreeMap<String, Value>,
314}
315
316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
318#[serde(deny_unknown_fields)]
319pub struct VaultEffectivePolicyV1 {
320 pub vault_id: String,
321 pub effective: EffectivePolicyV1,
322}
323
324#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
327#[serde(deny_unknown_fields)]
328pub struct BundlePolicyV1 {
329 pub effective: EffectivePolicyV1,
330 pub vaults: Vec<VaultEffectivePolicyV1>,
331}
332
333impl BundlePolicyV1 {
334 pub fn for_vault(&self, vault_id: Option<&str>) -> &EffectivePolicyV1 {
336 vault_id
337 .and_then(|id| self.vaults.iter().find(|v| v.vault_id == id))
338 .map_or(&self.effective, |v| &v.effective)
339 }
340}
341
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344#[serde(deny_unknown_fields)]
345pub struct PolicySourceV1 {
346 pub level: String,
347 pub scope: String,
348 pub version: u64,
349 pub digest: String,
350}
351
352#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
354pub struct EffectivePolicyBundleV1 {
355 pub schema: String,
356 pub org_id: String,
357 pub instance_id: String,
358 pub version: u64,
359 pub issued_at: UtcTimestamp,
360 pub not_after: UtcTimestamp,
361 pub sources: Vec<PolicySourceV1>,
362 pub policy: BundlePolicyV1,
363 pub digest: String,
364 pub key_id: String,
365 pub signature: String,
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370#[serde(deny_unknown_fields)]
371pub struct PolicyAckRequestV1 {
372 pub version: u64,
373 pub digest: String,
374 #[serde(default, skip_serializing_if = "Vec::is_empty")]
375 pub unenforced: Vec<String>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub refused: Option<PolicyAckRefusalV1>,
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(rename_all = "snake_case")]
385pub enum PolicyRefusalReasonV1 {
386 InvalidBundle,
388 WrongTarget,
390 KeyUnavailable,
392 SignatureInvalid,
393 DigestMismatch,
395 Downgrade,
397 Expired,
399 InstallFailed,
401}
402
403pub const MAX_REFUSAL_DETAIL: usize = 300;
405
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
409#[serde(deny_unknown_fields)]
410pub struct PolicyAckRefusalV1 {
411 pub reason_code: PolicyRefusalReasonV1,
412 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub detail: Option<String>,
416 pub applied_version: Option<u64>,
417 pub applied_digest: Option<String>,
418}
419
420pub fn refusal_detail(line: &str) -> Option<String> {
430 let clean: String = line
431 .chars()
432 .map(|c| if c.is_control() { ' ' } else { c })
433 .collect();
434 let place = |word: &str| {
435 word.contains('/')
436 || word.contains('\\')
437 || word.starts_with('~')
438 || (word.len() > 2
439 && word.as_bytes()[1] == b':'
440 && word.as_bytes()[0].is_ascii_alphabetic())
441 };
442 let mut words: Vec<&str> = Vec::new();
443 let mut run = false;
444 for word in clean.split(' ') {
445 if !run && !place(word) {
446 words.push(word);
447 continue;
448 }
449 run = true;
450 if word.ends_with(':') {
451 words.push("<path>:");
452 run = false;
453 }
454 }
455 if run {
456 words.push("<path>");
457 }
458 let text: String = words.join(" ").chars().take(MAX_REFUSAL_DETAIL).collect();
459 let text = text.trim();
460 (!text.is_empty()).then(|| text.to_string())
461}
462
463impl PolicyAckRequestV1 {
464 pub fn refusal(version: u64, digest: String, refused: PolicyAckRefusalV1) -> Self {
466 Self {
467 version,
468 digest,
469 unenforced: Vec::new(),
470 refused: Some(refused),
471 }
472 }
473}
474
475#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
478pub struct PolicyAckRefusedResponseV1 {
479 pub schema: String,
480 pub version: u64,
481 pub digest: String,
482 pub state: String,
484 pub reason_code: PolicyRefusalReasonV1,
485 pub applied_version: Option<u64>,
486 pub applied_digest: Option<String>,
487 pub refused_at: UtcTimestamp,
488}
489
490impl PolicyAckRefusedResponseV1 {
491 pub fn validate_for(&self, request: &PolicyAckRequestV1) -> Result<(), ValidationError> {
493 require_schema(&self.schema, POLICY_ACK_SCHEMA)?;
494 let Some(refused) = &request.refused else {
495 return Err(ValidationError::new("not a refusal request"));
496 };
497 if self.state != "refused"
498 || self.version != request.version
499 || self.digest != request.digest
500 || self.reason_code != refused.reason_code
501 || self.applied_version != refused.applied_version
502 || self.applied_digest != refused.applied_digest
503 {
504 return Err(ValidationError::new(
505 "the refusal answer names another bundle or reason",
506 ));
507 }
508 Ok(())
509 }
510}
511
512#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
514#[serde(rename_all = "snake_case")]
515pub enum PolicyAckStateV1 {
516 Acked,
517 Outdated,
518}
519
520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
522pub struct PolicyAckResponseV1 {
523 pub schema: String,
524 pub version: u64,
525 pub digest: String,
526 pub state: PolicyAckStateV1,
527 pub acked_at: UtcTimestamp,
528}
529
530impl PolicyAckResponseV1 {
531 pub fn validate_for(&self, request: &PolicyAckRequestV1) -> Result<(), ValidationError> {
533 require_schema(&self.schema, POLICY_ACK_SCHEMA)?;
534 if self.version != request.version || self.digest != request.digest {
535 return Err(ValidationError::new(
536 "the ack answer names another policy version or digest",
537 ));
538 }
539 Ok(())
540 }
541}
542
543fn hex(bytes: &[u8]) -> String {
544 use std::fmt::Write as _;
545 bytes.iter().fold(String::new(), |mut out, b| {
546 let _ = write!(out, "{b:02x}");
547 out
548 })
549}
550
551pub fn policy_digest(value: &Value) -> Result<String, ValidationError> {
553 Ok(format!(
554 "sha256:{}",
555 hex(&Sha256::digest(canonical_json(value)?))
556 ))
557}
558
559pub fn signing_input_raw(raw: &Value) -> Result<Vec<u8>, ValidationError> {
562 signing_input_without_signature(EFFECTIVE_POLICY_SIGNATURE_DOMAIN, raw)
563}
564
565fn is_lower_hex(value: &str, len: usize) -> bool {
566 value.len() == len
567 && value
568 .bytes()
569 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
570}
571
572fn validate_digest(value: &str, field: &str) -> Result<(), ValidationError> {
573 match value.strip_prefix("sha256:") {
574 Some(hex) if is_lower_hex(hex, 64) => Ok(()),
575 _ => Err(ValidationError::new(format!(
576 "{field} must be sha256: and 64 lowercase hex characters"
577 ))),
578 }
579}
580
581fn prefixed_hex32(value: &str, prefix: &str) -> bool {
582 value
583 .strip_prefix(prefix)
584 .is_some_and(|rest| is_lower_hex(rest, 32))
585}
586
587fn valid_name(value: &str) -> bool {
588 let bytes = value.as_bytes();
589 !bytes.is_empty()
590 && bytes.len() <= 64
591 && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit())
592 && bytes.iter().all(|b| {
593 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b':' | b'-')
594 })
595}
596
597fn valid_pattern(value: &str, max: usize) -> bool {
599 let count = value.chars().count();
600 (1..=max).contains(&count)
601 && !value
602 .chars()
603 .any(|c| matches!(c, '\u{0}'..='\u{1f}' | '\u{7f}'..='\u{9f}'))
604}
605
606fn valid_member_name(name: &str) -> bool {
608 !name.is_empty()
609 && name.len() <= 64
610 && name.bytes().all(|b| b.is_ascii_lowercase() || b == b'_')
611}
612
613fn invalid(field: &str) -> ValidationError {
614 ValidationError::new(format!("invalid_policy: {field}"))
615}
616
617fn check_set(
620 set: Option<&Vec<String>>,
621 field: &str,
622 item_ok: impl Fn(&str) -> bool,
623) -> Result<(), ValidationError> {
624 let Some(set) = set else { return Ok(()) };
625 if set.len() > MAX_SET
626 || !set.windows(2).all(|w| w[0] < w[1])
627 || !set.iter().all(|item| item_ok(item))
628 {
629 return Err(invalid(field));
630 }
631 Ok(())
632}
633
634fn check_range(value: Option<u64>, min: u64, max: u64, field: &str) -> Result<(), ValidationError> {
635 match value {
636 Some(v) if !(min..=max).contains(&v) => Err(invalid(field)),
637 _ => Ok(()),
638 }
639}
640
641fn check_enum(
642 value: Option<&String>,
643 allowed: &[&str],
644 field: &str,
645) -> Result<(), ValidationError> {
646 match value {
647 Some(v) if !allowed.contains(&v.as_str()) => Err(invalid(field)),
648 _ => Ok(()),
649 }
650}
651
652impl EffectivePolicyV1 {
653 pub fn validate(&self) -> Result<(), ValidationError> {
655 require_schema(&self.schema, POLICY_DOCUMENT_SCHEMA)?;
656 let s = &self.security;
657 check_set(s.excluded_paths.as_ref(), "security.excluded_paths", |p| {
658 valid_pattern(p, 256)
659 })?;
660 check_set(
661 s.blocked_labels.as_ref(),
662 "security.blocked_labels",
663 valid_name,
664 )?;
665 check_set(
666 s.external_model_excluded_classes.as_ref(),
667 "security.external_model_excluded_classes",
668 |c| DATA_CLASSES.contains(&c),
669 )?;
670 if let Some(groups) = &s.allowed_models {
671 let field = "security.allowed_models";
672 if groups.is_empty()
673 || groups.len() > MAX_MODEL_GROUPS
674 || !groups.windows(2).all(|w| w[0] < w[1])
675 {
676 return Err(invalid(field));
677 }
678 for group in groups {
679 if group.len() > MAX_MODEL_GROUP {
680 return Err(invalid(field));
681 }
682 check_set(Some(group), field, |p| valid_pattern(p, 128))?;
683 }
684 }
685 check_set(
686 s.allowed_providers.as_ref(),
687 "security.allowed_providers",
688 valid_name,
689 )?;
690 check_set(s.readable_roots.as_ref(), "security.readable_roots", |p| {
691 valid_pattern(p, 256)
692 })?;
693 check_set(s.never_send.as_ref(), "security.never_send", |p| {
694 valid_pattern(p, 256)
695 })?;
696 if let Some(agents) = &self.agents {
697 check_set(agents.allowed.as_ref(), "agents.allowed", |a| {
698 a == "*" || valid_name(a)
699 })?;
700 }
701 let c = &self.context;
702 check_range(
703 c.max_context_tokens,
704 1,
705 10_000_000,
706 "context.max_context_tokens",
707 )?;
708 check_enum(c.read_mode.as_ref(), READ_MODES, "context.read_mode")?;
709 if c.ranking_profile.as_deref().is_some_and(|p| !valid_name(p)) {
710 return Err(invalid("context.ranking_profile"));
711 }
712 check_set(
713 c.allowed_tools.as_ref(),
714 "context.allowed_tools",
715 valid_name,
716 )?;
717 check_set(c.denied_tools.as_ref(), "context.denied_tools", valid_name)?;
718 check_set(
719 c.allowed_sources.as_ref(),
720 "context.allowed_sources",
721 valid_name,
722 )?;
723 check_range(
724 c.max_writes_per_min,
725 0,
726 10_000,
727 "context.max_writes_per_min",
728 )?;
729 check_range(
730 c.max_cost_cents_per_person_per_day,
731 0,
732 100_000_000,
733 "context.max_cost_cents_per_person_per_day",
734 )?;
735 check_range(
736 c.max_cost_cents_per_project_per_month,
737 0,
738 100_000_000,
739 "context.max_cost_cents_per_project_per_month",
740 )?;
741 check_range(
742 c.max_requests_per_minute_per_person,
743 1,
744 1_000_000,
745 "context.max_requests_per_minute_per_person",
746 )?;
747 check_range(
748 c.max_source_age_days,
749 1,
750 3650,
751 "context.max_source_age_days",
752 )?;
753 let h = &self.sharing;
754 check_set(h.allowed_vaults.as_ref(), "sharing.allowed_vaults", |v| {
755 is_lower_hex(v, 32)
756 })?;
757 check_enum(
758 h.task_claim_min_role.as_ref(),
759 CLAIM_ROLES,
760 "sharing.task_claim_min_role",
761 )?;
762 check_set(
763 h.consent_required_for.as_ref(),
764 "sharing.consent_required_for",
765 |a| CONSENT_ACTIONS.contains(&a),
766 )?;
767 check_enum(
768 h.personal_workspace.as_ref(),
769 PERSONAL_WORKSPACE_MODES,
770 "sharing.personal_workspace",
771 )?;
772 let empty = BTreeMap::new();
773 let agents_other = self.agents.as_ref().map_or(&empty, |a| &a.other);
774 for (section, other) in [
775 ("security", &s.other),
776 ("context", &c.other),
777 ("sharing", &h.other),
778 ("agents", agents_other),
779 ] {
780 if let Some(name) = other.keys().find(|k| !valid_member_name(k)) {
781 return Err(invalid(&format!("{section}.{name}")));
782 }
783 }
784 for (section, value) in &self.other {
786 let ok = valid_member_name(section)
787 && value
788 .as_object()
789 .is_some_and(|members| members.keys().all(|k| valid_member_name(k)));
790 if !ok {
791 return Err(invalid(section));
792 }
793 }
794 Ok(())
795 }
796
797 pub fn members(&self) -> BTreeSet<String> {
799 let section = |name: &str, value: Value| -> Vec<String> {
800 value
801 .as_object()
802 .map(|fields| fields.keys().map(|k| format!("{name}.{k}")).collect())
803 .unwrap_or_default()
804 };
805 let mut out = BTreeSet::new();
806 out.extend(section(
807 "security",
808 serde_json::to_value(&self.security).unwrap_or(Value::Null),
809 ));
810 out.extend(section(
811 "context",
812 serde_json::to_value(&self.context).unwrap_or(Value::Null),
813 ));
814 out.extend(section(
815 "sharing",
816 serde_json::to_value(&self.sharing).unwrap_or(Value::Null),
817 ));
818 if let Some(agents) = &self.agents {
819 out.extend(section(
820 "agents",
821 serde_json::to_value(agents).unwrap_or(Value::Null),
822 ));
823 }
824 for (name, value) in &self.other {
825 out.extend(section(name, value.clone()));
826 }
827 out
828 }
829}
830
831pub fn is_engine_member(member: &str) -> bool {
833 ENGINE_MEMBERS.iter().any(|(m, _)| *m == member)
834}
835
836pub fn is_server_member(member: &str) -> bool {
838 SERVER_MEMBERS.contains(&member)
839}
840
841fn set_members(policy: &BundlePolicyV1) -> BTreeSet<String> {
843 let mut all = policy.effective.members();
844 for vault in &policy.vaults {
845 all.extend(vault.effective.members());
846 }
847 all
848}
849
850pub fn unenforced_members(policy: &BundlePolicyV1) -> Vec<String> {
853 set_members(policy)
854 .into_iter()
855 .filter(|m| !is_engine_member(m) && !is_server_member(m))
856 .collect()
857}
858
859pub fn server_members(policy: &BundlePolicyV1) -> Vec<String> {
861 set_members(policy)
862 .into_iter()
863 .filter(|m| is_server_member(m))
864 .collect()
865}
866
867impl EffectivePolicyBundleV1 {
868 pub fn validate(&self) -> Result<(), ValidationError> {
871 require_schema(&self.schema, EFFECTIVE_POLICY_SCHEMA)?;
872 if !prefixed_hex32(&self.org_id, "org_") {
873 return Err(ValidationError::new("org_id must be org_ and 32 hex"));
874 }
875 if !prefixed_hex32(&self.instance_id, "inst_") {
876 return Err(ValidationError::new("instance_id must be inst_ and 32 hex"));
877 }
878 if self.version == 0 {
879 return Err(ValidationError::new("version starts at 1"));
880 }
881 if self.not_after <= self.issued_at {
882 return Err(ValidationError::new("not_after must follow issued_at"));
883 }
884 if self.sources.len() > MAX_POLICY_SOURCES {
885 return Err(ValidationError::new("too many policy sources"));
886 }
887 for source in &self.sources {
888 let scope_ok = prefixed_hex32(&source.scope, "org_")
892 || (source.level == "org" && prefixed_hex32(&source.scope, "orgn_"))
893 || prefixed_hex32(&source.scope, "inst_")
894 || is_lower_hex(&source.scope, 32);
895 if !matches!(
896 source.level.as_str(),
897 "org" | "workspace" | "vault" | "instance"
898 ) || !scope_ok
899 || source.version == 0
900 {
901 return Err(ValidationError::new("a policy source is malformed"));
902 }
903 validate_digest(&source.digest, "sources.digest")?;
904 }
905 if self.policy.vaults.len() > MAX_POLICY_VAULTS
906 || !self
907 .policy
908 .vaults
909 .windows(2)
910 .all(|w| w[0].vault_id < w[1].vault_id)
911 {
912 return Err(ValidationError::new(
913 "policy.vaults must be at most 32, ascending by vault_id",
914 ));
915 }
916 self.policy.effective.validate()?;
917 for vault in &self.policy.vaults {
918 if !is_lower_hex(&vault.vault_id, 32) {
919 return Err(ValidationError::new("policy.vaults.vault_id is malformed"));
920 }
921 vault.effective.validate()?;
922 }
923 validate_digest(&self.digest, "digest")?;
924 if !is_lower_hex(&self.key_id, 16) {
925 return Err(ValidationError::new("key_id must be 16 lowercase hex"));
926 }
927 if !is_lower_hex(&self.signature, 128) {
928 return Err(ValidationError::new("signature must be 128 lowercase hex"));
929 }
930 Ok(())
931 }
932
933 pub fn ack(&self) -> PolicyAckRequestV1 {
937 let mut unenforced = unenforced_members(&self.policy);
938 unenforced.truncate(MAX_UNENFORCED);
939 PolicyAckRequestV1 {
940 version: self.version,
941 digest: self.digest.clone(),
942 unenforced,
943 refused: None,
944 }
945 }
946}
947
948#[cfg(test)]
949#[path = "effective_policy_tests.rs"]
950mod tests;