use std::path::Path;
use clawft_types::config::overlay::{
self as overlay_merge, EffectiveOverlay, Limits, OverlayFile, ParentView,
};
use clawft_types::project::canon::{canonical_json, hex_encode};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use crate::governance::{
GovernanceBranch, GovernanceRule, GovernanceRuleType, OVERLAY_APPROVAL_TAG, OVERLAY_DENY_TAG,
RuleSeverity,
};
use crate::parent_policy::{ParentPolicy, ParentPolicyError, parent_rules_hash};
pub const MAX_FILE_BYTES: u64 = 1024 * 1024;
pub const OVERLAY_HASH_DOMAIN: &str = "weftos-overlay-v1\n";
pub const EFFECTIVE_HASH_DOMAIN: &str = "weftos-effective-rules-v1\n";
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum OverlayError {
#[error("{path}: {reason}")]
Io { path: String, reason: String },
#[error("{path}: {bytes} bytes is over the {MAX_FILE_BYTES} byte limit")]
TooLarge { path: String, bytes: u64 },
#[error("{0}")]
Overlay(#[from] overlay_merge::OverlayError),
#[error("parent policy: {0}")]
Parent(#[from] ParentPolicyError),
#[error("`{key}`: rule id {id:?} collides with another rule")]
IdCollision { key: String, id: String },
#[error("profile `project` needs a child runtime root (RootSource::Child)")]
NotAChild,
#[error("project certificate: {0}")]
Cert(String),
#[error("`{key}`: approval glob {glob:?} overlaps the parent deny {parent_glob:?}")]
ApprovalOverlapsDeny {
key: String,
glob: String,
parent_glob: String,
},
#[error("overlay.toml is missing but a non-empty overlay was applied; write an empty file to clear it")]
OverlayMissing,
#[error("{path}: {reason}")]
PinCorrupt { path: String, reason: String },
#[error("rollback pin is missing although the chain records an applied governance overlay")]
PinMissing,
#[error("project revoked ({0})")]
Revoked(String),
}
impl OverlayError {
pub fn key(&self) -> String {
use overlay_merge::OverlayError as E;
match self {
Self::Io { path, .. } | Self::TooLarge { path, .. } => path.clone(),
Self::Overlay(e) => match e {
E::Parse(_) => "overlay.toml".into(),
E::Schema(_) => "schema".into(),
E::Forbidden(k) | E::UnknownKey(k) => k.clone(),
E::MalformedId { key, .. }
| E::DuplicateId { key, .. }
| E::ShadowsParent { key, .. }
| E::EmptyActions { key }
| E::MalformedGlob { key, .. }
| E::InvalidLimit { key }
| E::Relaxes { key, .. } => key.clone(),
},
Self::Parent(e) => e.key(),
Self::IdCollision { key, .. } => key.clone(),
Self::NotAChild => "kernel.profile".into(),
Self::Cert(_) => "project.cert.json".into(),
Self::ApprovalOverlapsDeny { key, .. } => key.clone(),
Self::OverlayMissing => "overlay.toml".into(),
Self::PinCorrupt { path, .. } => path.clone(),
Self::PinMissing => format!("state/{}", crate::overlay_trust::VERSION_PIN_FILE),
Self::Revoked(_) => "revoked".into(),
}
}
pub fn boot_message(&self) -> String {
format!(
"governance overlay refused at `{}`: {self}; fix it and start again (a project never starts with fewer rules than its parent)",
self.key()
)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Overlay {
pub file: OverlayFile,
pub hash: [u8; 32],
}
impl Overlay {
pub fn empty() -> Self {
Self::from_file(OverlayFile::default())
}
pub fn from_file(file: OverlayFile) -> Self {
let hash = overlay_hash(&file);
Self { file, hash }
}
pub fn from_toml(s: &str) -> Result<Self, OverlayError> {
Ok(Self::from_file(OverlayFile::from_toml(s)?))
}
}
pub(crate) fn read_capped(path: &Path) -> Result<Option<String>, OverlayError> {
let io = |reason: String| OverlayError::Io {
path: path.display().to_string(),
reason,
};
let meta = match std::fs::metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(io(e.to_string())),
};
if !meta.is_file() {
return Err(io("not a regular file".into()));
}
if meta.len() > MAX_FILE_BYTES {
return Err(OverlayError::TooLarge {
path: path.display().to_string(),
bytes: meta.len(),
});
}
use std::io::Read;
let mut buf = String::new();
std::fs::File::open(path)
.and_then(|f| f.take(MAX_FILE_BYTES + 1).read_to_string(&mut buf))
.map_err(|e| io(e.to_string()))?;
if buf.len() as u64 > MAX_FILE_BYTES {
return Err(OverlayError::TooLarge {
path: path.display().to_string(),
bytes: buf.len() as u64,
});
}
Ok(Some(buf))
}
pub fn load_overlay(path: &Path) -> Result<Overlay, OverlayError> {
match read_capped(path)? {
None => Ok(Overlay::empty()),
Some(text) => Overlay::from_toml(&text),
}
}
pub(crate) fn limits_value(l: &Limits) -> Value {
json!({
"risk_threshold_bits": l.risk_threshold.map(|r| format!("{:016x}", r.to_bits())),
"max_processes": l.max_processes,
"spawn_budget": l.spawn_budget,
"human_approval_required": l.human_approval_required,
})
}
pub(crate) fn norm_id(id: &str) -> String {
id.trim().to_ascii_lowercase()
}
pub(crate) fn sorted_rule_values(rules: &[GovernanceRule]) -> Vec<Value> {
let mut keyed: Vec<(String, String, Value)> = rules
.iter()
.map(|r| {
let v = serde_json::to_value(r).unwrap_or(Value::Null);
(norm_id(&r.id), canonical_json(&v), v)
})
.collect();
keyed.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
keyed.into_iter().map(|(_, _, v)| v).collect()
}
pub(crate) fn sha256_domain(domain: &str, body: &Value) -> [u8; 32] {
let mut h = Sha256::new();
h.update(domain.as_bytes());
h.update(canonical_json(body).as_bytes());
h.finalize().into()
}
fn overlay_hash(f: &OverlayFile) -> [u8; 32] {
let mut deny: Vec<Value> = f
.deny
.iter()
.map(|d| {
let mut actions = d.actions.clone();
actions.sort();
json!({"id": norm_id(&d.id), "actions": actions, "reason": d.reason})
})
.collect();
deny.sort_by_key(canonical_json);
let mut approval: Vec<Value> = f
.require_approval
.iter()
.map(|a| {
let mut actions = a.actions.clone();
actions.sort();
json!({"id": a.id.as_deref().map(norm_id), "actions": actions, "reason": a.reason})
})
.collect();
approval.sort_by_key(canonical_json);
sha256_domain(
OVERLAY_HASH_DOMAIN,
&json!({
"schema": f.schema,
"deny": deny,
"require_approval": approval,
"limits": limits_value(&f.limits),
}),
)
}
#[derive(Debug, Clone)]
pub struct Effective {
pub rules: Vec<GovernanceRule>,
pub limits: Limits,
pub deny_actions: Vec<String>,
pub require_approval_actions: Vec<String>,
pub parent_hash: [u8; 32],
pub overlay_hash: [u8; 32],
pub effective_hash: [u8; 32],
pub parent_version: u64,
}
impl Effective {
pub fn into_rules(self) -> Vec<GovernanceRule> {
self.rules
}
pub fn risk_threshold(&self, default: f64) -> f64 {
self.limits.risk_threshold.unwrap_or(default)
}
pub fn human_approval(&self, default: bool) -> bool {
self.limits.human_approval_required.unwrap_or(default)
}
pub fn effective_hash_hex(&self) -> String {
hex_encode(&self.effective_hash)
}
}
fn selector_rule(
id: String,
glob: &str,
reason: &str,
severity: RuleSeverity,
tag: &str,
) -> GovernanceRule {
GovernanceRule {
id,
description: reason.to_owned(),
branch: GovernanceBranch::Judicial,
severity,
active: true,
reference_url: None,
sop_category: Some(tag.to_owned()),
rule_type: GovernanceRuleType::General,
action_selector: Some(glob.to_owned()),
tool_selector: None,
force_on_match: true,
}
}
fn expand(
base: &str,
actions: &[String],
reason: &str,
tag: &str,
out: &mut Vec<GovernanceRule>,
) {
for (n, glob) in actions.iter().enumerate() {
let id = if actions.len() == 1 {
base.to_owned()
} else {
format!("{base}[{n}]")
};
out.push(selector_rule(id, glob, reason, RuleSeverity::Blocking, tag));
}
}
pub(crate) fn parent_view(p: &ParentPolicy) -> ParentView {
let selector = |r: &GovernanceRule| {
r.active
&& matches!(r.severity, RuleSeverity::Blocking | RuleSeverity::Critical)
&& r.force_on_match
&& r.rule_type == GovernanceRuleType::General
&& r.tool_selector.is_none()
};
let mut deny = Vec::new();
let mut approval = Vec::new();
for r in p.rules.iter().filter(|r| selector(r)) {
let Some(g) = r.action_selector.clone() else {
continue;
};
if r.sop_category.as_deref() == Some(OVERLAY_APPROVAL_TAG) {
approval.push(g);
} else {
deny.push(g);
}
}
ParentView {
rule_ids: p.rules.iter().map(|r| r.id.clone()).collect(),
deny_actions: deny,
require_approval_actions: approval,
limits: p.limits,
}
}
pub(crate) fn globs_overlap(a: &str, b: &str) -> bool {
match (a.strip_suffix('*'), b.strip_suffix('*')) {
(Some(pa), Some(pb)) => pa.starts_with(pb) || pb.starts_with(pa),
(Some(pa), None) => b.starts_with(pa),
(None, Some(pb)) => a.starts_with(pb),
(None, None) => a == b,
}
}
pub fn merge(parent: &ParentPolicy, overlay: &Overlay) -> Result<Effective, OverlayError> {
let mut view = parent_view(parent);
view.limits
.risk_threshold
.get_or_insert(crate::overlay_runtime::DEFAULT_RISK_THRESHOLD);
let EffectiveOverlay {
deny_actions,
require_approval_actions,
deny_rules,
approval_rules,
limits,
} = overlay_merge::merge(&view, &overlay.file)?;
if limits.max_processes == Some(0) {
return Err(OverlayError::Overlay(overlay_merge::OverlayError::InvalidLimit {
key: "limits.max_processes".into(),
}));
}
for (i, a) in approval_rules.iter().enumerate() {
for (j, g) in a.actions.iter().enumerate() {
if let Some(pg) = view.deny_actions.iter().find(|pg| globs_overlap(g, pg)) {
return Err(OverlayError::ApprovalOverlapsDeny {
key: format!("require_approval[{i}].actions[{j}]"),
glob: g.clone(),
parent_glob: pg.clone(),
});
}
}
}
let mut rules = parent.rules.clone();
if limits.human_approval_required == Some(true) && parent.limits.human_approval_required != Some(true) {
for r in rules.iter_mut().filter(|r| {
matches!(r.severity, RuleSeverity::Blocking | RuleSeverity::Critical)
&& r.sop_category.as_deref() != Some(OVERLAY_APPROVAL_TAG)
}) {
r.sop_category = Some(OVERLAY_DENY_TAG.to_owned());
}
}
let parent_ids: std::collections::BTreeSet<String> =
parent.rules.iter().map(|r| norm_id(&r.id)).collect();
let mut added: Vec<GovernanceRule> = Vec::new();
for d in &deny_rules {
expand(
&d.id,
&d.actions,
d.reason.as_deref().unwrap_or("denied by the project overlay"),
OVERLAY_DENY_TAG,
&mut added,
);
}
for (i, a) in approval_rules.iter().enumerate() {
let base = a
.id
.clone()
.unwrap_or_else(|| format!("overlay.approval[{i}]"));
expand(
&base,
&a.actions,
a.reason
.as_deref()
.unwrap_or("human approval required by the project overlay"),
OVERLAY_APPROVAL_TAG,
&mut added,
);
}
let mut seen = std::collections::BTreeSet::new();
for r in &added {
let n = norm_id(&r.id);
if parent_ids.contains(&n) || !seen.insert(n) {
return Err(OverlayError::IdCollision {
key: "overlay".into(),
id: r.id.clone(),
});
}
}
rules.extend(added);
let parent_hash = parent_rules_hash(&parent.rules, &parent.limits);
let effective_hash = sha256_domain(
EFFECTIVE_HASH_DOMAIN,
&json!({
"parent_hash": hex_encode(&parent_hash),
"overlay_hash": hex_encode(&overlay.hash),
"rules": sorted_rule_values(&rules),
"limits": limits_value(&limits),
}),
);
Ok(Effective {
rules,
limits,
deny_actions,
require_approval_actions,
parent_hash,
overlay_hash: overlay.hash,
effective_hash,
parent_version: parent.version,
})
}