1use std::path::Path;
44
45use clawft_types::config::overlay::{
46 self as overlay_merge, EffectiveOverlay, Limits, OverlayFile, ParentView,
47};
48use clawft_types::project::canon::{canonical_json, hex_encode};
49use serde_json::{Value, json};
50use sha2::{Digest, Sha256};
51
52use crate::governance::{
53 GovernanceBranch, GovernanceRule, GovernanceRuleType, OVERLAY_APPROVAL_TAG, OVERLAY_DENY_TAG,
54 RuleSeverity,
55};
56use crate::parent_policy::{ParentPolicy, ParentPolicyError, parent_rules_hash};
57
58pub const MAX_FILE_BYTES: u64 = 1024 * 1024;
60pub const OVERLAY_HASH_DOMAIN: &str = "weftos-overlay-v1\n";
62pub const EFFECTIVE_HASH_DOMAIN: &str = "weftos-effective-rules-v1\n";
64
65#[derive(Debug, Clone, PartialEq, thiserror::Error)]
67pub enum OverlayError {
68 #[error("{path}: {reason}")]
70 Io { path: String, reason: String },
71 #[error("{path}: {bytes} bytes is over the {MAX_FILE_BYTES} byte limit")]
73 TooLarge { path: String, bytes: u64 },
74 #[error("{0}")]
76 Overlay(#[from] overlay_merge::OverlayError),
77 #[error("parent policy: {0}")]
79 Parent(#[from] ParentPolicyError),
80 #[error("`{key}`: rule id {id:?} collides with another rule")]
82 IdCollision { key: String, id: String },
83 #[error("profile `project` needs a child runtime root (RootSource::Child)")]
85 NotAChild,
86 #[error("project certificate: {0}")]
88 Cert(String),
89 #[error("`{key}`: approval glob {glob:?} overlaps the parent deny {parent_glob:?}")]
92 ApprovalOverlapsDeny {
93 key: String,
94 glob: String,
95 parent_glob: String,
96 },
97 #[error("overlay.toml is missing but a non-empty overlay was applied; write an empty file to clear it")]
100 OverlayMissing,
101 #[error("{path}: {reason}")]
103 PinCorrupt { path: String, reason: String },
104 #[error("rollback pin is missing although the chain records an applied governance overlay")]
106 PinMissing,
107 #[error("project revoked ({0})")]
109 Revoked(String),
110}
111
112impl OverlayError {
113 pub fn key(&self) -> String {
116 use overlay_merge::OverlayError as E;
117 match self {
118 Self::Io { path, .. } | Self::TooLarge { path, .. } => path.clone(),
119 Self::Overlay(e) => match e {
120 E::Parse(_) => "overlay.toml".into(),
121 E::Schema(_) => "schema".into(),
122 E::Forbidden(k) | E::UnknownKey(k) => k.clone(),
123 E::MalformedId { key, .. }
124 | E::DuplicateId { key, .. }
125 | E::ShadowsParent { key, .. }
126 | E::EmptyActions { key }
127 | E::MalformedGlob { key, .. }
128 | E::InvalidLimit { key }
129 | E::Relaxes { key, .. } => key.clone(),
130 },
131 Self::Parent(e) => e.key(),
132 Self::IdCollision { key, .. } => key.clone(),
133 Self::NotAChild => "kernel.profile".into(),
134 Self::Cert(_) => "project.cert.json".into(),
135 Self::ApprovalOverlapsDeny { key, .. } => key.clone(),
136 Self::OverlayMissing => "overlay.toml".into(),
137 Self::PinCorrupt { path, .. } => path.clone(),
138 Self::PinMissing => format!("state/{}", crate::overlay_trust::VERSION_PIN_FILE),
139 Self::Revoked(_) => "revoked".into(),
140 }
141 }
142
143 pub fn boot_message(&self) -> String {
145 format!(
146 "governance overlay refused at `{}`: {self}; fix it and start again (a project never starts with fewer rules than its parent)",
147 self.key()
148 )
149 }
150}
151
152#[derive(Debug, Clone, PartialEq)]
154pub struct Overlay {
155 pub file: OverlayFile,
157 pub hash: [u8; 32],
159}
160
161impl Overlay {
162 pub fn empty() -> Self {
164 Self::from_file(OverlayFile::default())
165 }
166
167 pub fn from_file(file: OverlayFile) -> Self {
169 let hash = overlay_hash(&file);
170 Self { file, hash }
171 }
172
173 pub fn from_toml(s: &str) -> Result<Self, OverlayError> {
175 Ok(Self::from_file(OverlayFile::from_toml(s)?))
176 }
177}
178
179pub(crate) fn read_capped(path: &Path) -> Result<Option<String>, OverlayError> {
181 let io = |reason: String| OverlayError::Io {
182 path: path.display().to_string(),
183 reason,
184 };
185 let meta = match std::fs::metadata(path) {
186 Ok(m) => m,
187 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
188 Err(e) => return Err(io(e.to_string())),
189 };
190 if !meta.is_file() {
191 return Err(io("not a regular file".into()));
192 }
193 if meta.len() > MAX_FILE_BYTES {
194 return Err(OverlayError::TooLarge {
195 path: path.display().to_string(),
196 bytes: meta.len(),
197 });
198 }
199 use std::io::Read;
201 let mut buf = String::new();
202 std::fs::File::open(path)
203 .and_then(|f| f.take(MAX_FILE_BYTES + 1).read_to_string(&mut buf))
204 .map_err(|e| io(e.to_string()))?;
205 if buf.len() as u64 > MAX_FILE_BYTES {
206 return Err(OverlayError::TooLarge {
207 path: path.display().to_string(),
208 bytes: buf.len() as u64,
209 });
210 }
211 Ok(Some(buf))
212}
213
214pub fn load_overlay(path: &Path) -> Result<Overlay, OverlayError> {
217 match read_capped(path)? {
218 None => Ok(Overlay::empty()),
219 Some(text) => Overlay::from_toml(&text),
220 }
221}
222
223pub(crate) fn limits_value(l: &Limits) -> Value {
225 json!({
226 "risk_threshold_bits": l.risk_threshold.map(|r| format!("{:016x}", r.to_bits())),
227 "max_processes": l.max_processes,
228 "spawn_budget": l.spawn_budget,
229 "human_approval_required": l.human_approval_required,
230 })
231}
232
233pub(crate) fn norm_id(id: &str) -> String {
234 id.trim().to_ascii_lowercase()
235}
236
237pub(crate) fn sorted_rule_values(rules: &[GovernanceRule]) -> Vec<Value> {
240 let mut keyed: Vec<(String, String, Value)> = rules
241 .iter()
242 .map(|r| {
243 let v = serde_json::to_value(r).unwrap_or(Value::Null);
244 (norm_id(&r.id), canonical_json(&v), v)
245 })
246 .collect();
247 keyed.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
248 keyed.into_iter().map(|(_, _, v)| v).collect()
249}
250
251pub(crate) fn sha256_domain(domain: &str, body: &Value) -> [u8; 32] {
252 let mut h = Sha256::new();
253 h.update(domain.as_bytes());
254 h.update(canonical_json(body).as_bytes());
255 h.finalize().into()
256}
257
258fn overlay_hash(f: &OverlayFile) -> [u8; 32] {
259 let mut deny: Vec<Value> = f
260 .deny
261 .iter()
262 .map(|d| {
263 let mut actions = d.actions.clone();
264 actions.sort();
265 json!({"id": norm_id(&d.id), "actions": actions, "reason": d.reason})
266 })
267 .collect();
268 deny.sort_by_key(canonical_json);
269 let mut approval: Vec<Value> = f
270 .require_approval
271 .iter()
272 .map(|a| {
273 let mut actions = a.actions.clone();
274 actions.sort();
275 json!({"id": a.id.as_deref().map(norm_id), "actions": actions, "reason": a.reason})
276 })
277 .collect();
278 approval.sort_by_key(canonical_json);
279 sha256_domain(
280 OVERLAY_HASH_DOMAIN,
281 &json!({
282 "schema": f.schema,
283 "deny": deny,
284 "require_approval": approval,
285 "limits": limits_value(&f.limits),
286 }),
287 )
288}
289
290#[derive(Debug, Clone)]
292pub struct Effective {
293 pub rules: Vec<GovernanceRule>,
295 pub limits: Limits,
297 pub deny_actions: Vec<String>,
299 pub require_approval_actions: Vec<String>,
301 pub parent_hash: [u8; 32],
303 pub overlay_hash: [u8; 32],
305 pub effective_hash: [u8; 32],
307 pub parent_version: u64,
309}
310
311impl Effective {
312 pub fn into_rules(self) -> Vec<GovernanceRule> {
314 self.rules
315 }
316
317 pub fn risk_threshold(&self, default: f64) -> f64 {
319 self.limits.risk_threshold.unwrap_or(default)
320 }
321
322 pub fn human_approval(&self, default: bool) -> bool {
324 self.limits.human_approval_required.unwrap_or(default)
325 }
326
327 pub fn effective_hash_hex(&self) -> String {
329 hex_encode(&self.effective_hash)
330 }
331}
332
333fn selector_rule(
334 id: String,
335 glob: &str,
336 reason: &str,
337 severity: RuleSeverity,
338 tag: &str,
339) -> GovernanceRule {
340 GovernanceRule {
341 id,
342 description: reason.to_owned(),
343 branch: GovernanceBranch::Judicial,
344 severity,
345 active: true,
346 reference_url: None,
347 sop_category: Some(tag.to_owned()),
348 rule_type: GovernanceRuleType::General,
349 action_selector: Some(glob.to_owned()),
350 tool_selector: None,
351 force_on_match: true,
352 }
353}
354
355fn expand(
357 base: &str,
358 actions: &[String],
359 reason: &str,
360 tag: &str,
361 out: &mut Vec<GovernanceRule>,
362) {
363 for (n, glob) in actions.iter().enumerate() {
364 let id = if actions.len() == 1 {
365 base.to_owned()
366 } else {
367 format!("{base}[{n}]")
368 };
369 out.push(selector_rule(id, glob, reason, RuleSeverity::Blocking, tag));
370 }
371}
372
373pub(crate) fn parent_view(p: &ParentPolicy) -> ParentView {
375 let selector = |r: &GovernanceRule| {
376 r.active
377 && matches!(r.severity, RuleSeverity::Blocking | RuleSeverity::Critical)
378 && r.force_on_match
379 && r.rule_type == GovernanceRuleType::General
380 && r.tool_selector.is_none()
381 };
382 let mut deny = Vec::new();
383 let mut approval = Vec::new();
384 for r in p.rules.iter().filter(|r| selector(r)) {
385 let Some(g) = r.action_selector.clone() else {
386 continue;
387 };
388 if r.sop_category.as_deref() == Some(OVERLAY_APPROVAL_TAG) {
389 approval.push(g);
390 } else {
391 deny.push(g);
392 }
393 }
394 ParentView {
395 rule_ids: p.rules.iter().map(|r| r.id.clone()).collect(),
396 deny_actions: deny,
397 require_approval_actions: approval,
398 limits: p.limits,
399 }
400}
401
402pub(crate) fn globs_overlap(a: &str, b: &str) -> bool {
404 match (a.strip_suffix('*'), b.strip_suffix('*')) {
405 (Some(pa), Some(pb)) => pa.starts_with(pb) || pb.starts_with(pa),
406 (Some(pa), None) => b.starts_with(pa),
407 (None, Some(pb)) => a.starts_with(pb),
408 (None, None) => a == b,
409 }
410}
411
412pub fn merge(parent: &ParentPolicy, overlay: &Overlay) -> Result<Effective, OverlayError> {
416 let mut view = parent_view(parent);
420 view.limits
421 .risk_threshold
422 .get_or_insert(crate::overlay_runtime::DEFAULT_RISK_THRESHOLD);
423 let EffectiveOverlay {
424 deny_actions,
425 require_approval_actions,
426 deny_rules,
427 approval_rules,
428 limits,
429 } = overlay_merge::merge(&view, &overlay.file)?;
430
431 if limits.max_processes == Some(0) {
433 return Err(OverlayError::Overlay(overlay_merge::OverlayError::InvalidLimit {
434 key: "limits.max_processes".into(),
435 }));
436 }
437 for (i, a) in approval_rules.iter().enumerate() {
438 for (j, g) in a.actions.iter().enumerate() {
439 if let Some(pg) = view.deny_actions.iter().find(|pg| globs_overlap(g, pg)) {
440 return Err(OverlayError::ApprovalOverlapsDeny {
441 key: format!("require_approval[{i}].actions[{j}]"),
442 glob: g.clone(),
443 parent_glob: pg.clone(),
444 });
445 }
446 }
447 }
448 let mut rules = parent.rules.clone();
449 if limits.human_approval_required == Some(true) && parent.limits.human_approval_required != Some(true) {
457 for r in rules.iter_mut().filter(|r| {
458 matches!(r.severity, RuleSeverity::Blocking | RuleSeverity::Critical)
459 && r.sop_category.as_deref() != Some(OVERLAY_APPROVAL_TAG)
460 }) {
461 r.sop_category = Some(OVERLAY_DENY_TAG.to_owned());
462 }
463 }
464 let parent_ids: std::collections::BTreeSet<String> =
465 parent.rules.iter().map(|r| norm_id(&r.id)).collect();
466 let mut added: Vec<GovernanceRule> = Vec::new();
467 for d in &deny_rules {
468 expand(
469 &d.id,
470 &d.actions,
471 d.reason.as_deref().unwrap_or("denied by the project overlay"),
472 OVERLAY_DENY_TAG,
473 &mut added,
474 );
475 }
476 for (i, a) in approval_rules.iter().enumerate() {
477 let base = a
478 .id
479 .clone()
480 .unwrap_or_else(|| format!("overlay.approval[{i}]"));
481 expand(
482 &base,
483 &a.actions,
484 a.reason
485 .as_deref()
486 .unwrap_or("human approval required by the project overlay"),
487 OVERLAY_APPROVAL_TAG,
488 &mut added,
489 );
490 }
491 let mut seen = std::collections::BTreeSet::new();
492 for r in &added {
493 let n = norm_id(&r.id);
494 if parent_ids.contains(&n) || !seen.insert(n) {
495 return Err(OverlayError::IdCollision {
496 key: "overlay".into(),
497 id: r.id.clone(),
498 });
499 }
500 }
501 rules.extend(added);
502
503 let parent_hash = parent_rules_hash(&parent.rules, &parent.limits);
504 let effective_hash = sha256_domain(
505 EFFECTIVE_HASH_DOMAIN,
506 &json!({
507 "parent_hash": hex_encode(&parent_hash),
508 "overlay_hash": hex_encode(&overlay.hash),
509 "rules": sorted_rule_values(&rules),
510 "limits": limits_value(&limits),
511 }),
512 );
513 Ok(Effective {
514 rules,
515 limits,
516 deny_actions,
517 require_approval_actions,
518 parent_hash,
519 overlay_hash: overlay.hash,
520 effective_hash,
521 parent_version: parent.version,
522 })
523}