Skip to main content

clawft_kernel/
governance_overlay.rs

1//! Project governance overlay, kernel side (ADR-103 A6, D8, Phase 2 package E).
2//!
3//! Package A owns the file format and the tighten-only rule
4//! ([`clawft_types::config::overlay`]); this module adds what only the
5//! kernel can do:
6//!
7//! * [`load_overlay`]: read `<root>/.weftos/overlay.toml`. A missing file is
8//!   the empty overlay (nothing is added); every other failure (unreadable,
9//!   not a regular file, too large, not UTF-8, does not parse) is an error,
10//!   never an empty overlay. A broken overlay must not become fewer rules.
11//! * [`merge`]: the user's [`ParentPolicy`] plus the overlay become one
12//!   [`Effective`] rule set, limits and three hashes. The merge itself is
13//!   package A's [`overlay_merge`]; nothing here relaxes it and nothing is
14//!   clamped silently.
15//! * [`Effective::into_rules`]: the rules for `GovernanceEngine::add_rule`.
16//!
17//! # Rule mapping
18//!
19//! The parent's rules are carried over verbatim, `active` and severity
20//! included. Each overlay `deny` glob becomes one blocking rule that fires on
21//! the action alone (`force_on_match`) and is tagged
22//! [`OVERLAY_DENY_TAG`], so the engine answers `Deny` even when the
23//! engine-wide human-approval flag is on. Each `require_approval` glob is the
24//! same shape tagged [`OVERLAY_APPROVAL_TAG`] and answers `EscalateToHuman`.
25//! Globs are an exact action or a prefix ending in one `*`: `workload.place*`
26//! also matches `workload.placement.x` (documented in package A).
27//!
28//! # Hashes
29//!
30//! All three are SHA-256 over a domain tag plus canonical JSON
31//! ([`canonical_json`]); floats never appear (a risk threshold is hashed as
32//! the hex of its IEEE-754 bits).
33//!
34//! * `parent_hash`: `"weftos-parent-rules-v1\n"` + `{limits, rules}`, rules
35//!   sorted by id. Recomputed from the rules, never read from the file.
36//! * `overlay_hash`: `"weftos-overlay-v1\n"` + the parsed overlay, so
37//!   comments and whitespace do not change it.
38//! * `effective_hash`: `"weftos-effective-rules-v1\n"` + `{limits,
39//!   overlay_hash, parent_hash, rules}` with the merged rules sorted by id.
40//!   Each rule is its full serialized form, so `active`, severity and
41//!   selectors are covered.
42
43use 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
58/// Largest overlay or parent-policy file read, in bytes.
59pub const MAX_FILE_BYTES: u64 = 1024 * 1024;
60/// Domain tag of the overlay hash.
61pub const OVERLAY_HASH_DOMAIN: &str = "weftos-overlay-v1\n";
62/// Domain tag of the effective-rules hash.
63pub const EFFECTIVE_HASH_DOMAIN: &str = "weftos-effective-rules-v1\n";
64
65/// Why an overlay, a parent policy or their merge was refused.
66#[derive(Debug, Clone, PartialEq, thiserror::Error)]
67pub enum OverlayError {
68    /// A file could not be read.
69    #[error("{path}: {reason}")]
70    Io { path: String, reason: String },
71    /// A file is over [`MAX_FILE_BYTES`].
72    #[error("{path}: {bytes} bytes is over the {MAX_FILE_BYTES} byte limit")]
73    TooLarge { path: String, bytes: u64 },
74    /// The overlay broke the tighten-only rule (package A's error).
75    #[error("{0}")]
76    Overlay(#[from] overlay_merge::OverlayError),
77    /// The parent policy is not acceptable.
78    #[error("parent policy: {0}")]
79    Parent(#[from] ParentPolicyError),
80    /// Two rules the merge would emit share an id.
81    #[error("`{key}`: rule id {id:?} collides with another rule")]
82    IdCollision { key: String, id: String },
83    /// `profile = "project"` but the kernel is not a child kernel.
84    #[error("profile `project` needs a child runtime root (RootSource::Child)")]
85    NotAChild,
86    /// The project certificate is missing or does not verify.
87    #[error("project certificate: {0}")]
88    Cert(String),
89    /// An overlay approval glob overlaps a glob the parent denies; an
90    /// approval must never turn a deny into a question.
91    #[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    /// `overlay.toml` is gone although a non-empty overlay is in force.
98    /// Clear an overlay with an empty file, never by deleting it.
99    #[error("overlay.toml is missing but a non-empty overlay was applied; write an empty file to clear it")]
100    OverlayMissing,
101    /// The rollback pin exists but cannot be read.
102    #[error("{path}: {reason}")]
103    PinCorrupt { path: String, reason: String },
104    /// No rollback pin, yet the chain records an applied overlay.
105    #[error("rollback pin is missing although the chain records an applied governance overlay")]
106    PinMissing,
107    /// The project was revoked by its user daemon.
108    #[error("project revoked ({0})")]
109    Revoked(String),
110}
111
112impl OverlayError {
113    /// The offending key (what `weaver doctor` prints and `BootRefused`
114    /// names).
115    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    /// The message `BootRefused` carries.
144    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/// A parsed overlay plus its hash.
153#[derive(Debug, Clone, PartialEq)]
154pub struct Overlay {
155    /// The parsed file.
156    pub file: OverlayFile,
157    /// `overlay_hash`.
158    pub hash: [u8; 32],
159}
160
161impl Overlay {
162    /// The overlay that adds nothing (what a missing file means).
163    pub fn empty() -> Self {
164        Self::from_file(OverlayFile::default())
165    }
166
167    /// Wrap a parsed file and hash it.
168    pub fn from_file(file: OverlayFile) -> Self {
169        let hash = overlay_hash(&file);
170        Self { file, hash }
171    }
172
173    /// Parse overlay TOML text.
174    pub fn from_toml(s: &str) -> Result<Self, OverlayError> {
175        Ok(Self::from_file(OverlayFile::from_toml(s)?))
176    }
177}
178
179/// Read a size-capped regular file; `Ok(None)` when it does not exist.
180pub(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    // Read through a bounded reader: the file may grow after `metadata`.
200    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
214/// Load `overlay.toml`. A missing file is [`Overlay::empty`]; any other
215/// failure is an error (fail closed, ADR-103 A6 decision 2).
216pub 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
223/// `limits` as a canonical JSON value: no floats.
224pub(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
237/// Rules as canonical JSON values, sorted by normalized id then by content
238/// so equal sets hash equally whatever their order.
239pub(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/// The merged result a project kernel runs with.
291#[derive(Debug, Clone)]
292pub struct Effective {
293    /// Parent rules (verbatim) then the overlay's rules.
294    pub rules: Vec<GovernanceRule>,
295    /// Effective limits: `min` for numbers, `OR` for flags.
296    pub limits: Limits,
297    /// Every action glob denied by a parent or overlay rule.
298    pub deny_actions: Vec<String>,
299    /// Every action glob forced to human approval.
300    pub require_approval_actions: Vec<String>,
301    /// Hash of the parent's rules and limits.
302    pub parent_hash: [u8; 32],
303    /// Hash of the overlay.
304    pub overlay_hash: [u8; 32],
305    /// Hash of the merged rule set; the chain's `rule_hash`.
306    pub effective_hash: [u8; 32],
307    /// The parent policy version this was merged from.
308    pub parent_version: u64,
309}
310
311impl Effective {
312    /// The rules, to feed `GovernanceEngine::add_rule` one by one.
313    pub fn into_rules(self) -> Vec<GovernanceRule> {
314        self.rules
315    }
316
317    /// Engine threshold: the effective limit, else `default`.
318    pub fn risk_threshold(&self, default: f64) -> f64 {
319        self.limits.risk_threshold.unwrap_or(default)
320    }
321
322    /// Engine human-approval flag: the effective limit, else `default`.
323    pub fn human_approval(&self, default: bool) -> bool {
324        self.limits.human_approval_required.unwrap_or(default)
325    }
326
327    /// `effective_hash` as lowercase hex.
328    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
355/// One rule per action glob; `id` for one glob, `id[n]` for several.
356fn 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
373/// What the merge needs to know about the parent's rules.
374pub(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
402/// True when some action matches both globs (exact, or a prefix ending `*`).
403pub(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
412/// Merge `overlay` onto `parent` (tighten-only). The parent's signature is
413/// NOT checked here (see [`crate::parent_policy::verify_parent_policy`]);
414/// its rule hash is recomputed rather than trusted.
415pub fn merge(parent: &ParentPolicy, overlay: &Overlay) -> Result<Effective, OverlayError> {
416    // A parent with no threshold runs the engine default, so that default is
417    // the ceiling an overlay may not raise (otherwise 0.9 over "none" would
418    // loosen a 0.7 engine).
419    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    // `max_processes = 0` would leave no slot for the kernel's own process.
432    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    // Review M1: the engine-wide `human_approval_required` escalates every
450    // blocking verdict that is not a hard deny. Raised by the overlay (not by
451    // the parent), it would turn the parent's denies into approvable
452    // prompts. Keep it tighten-only: the parent's blocking rules become hard
453    // denies (`OVERLAY_DENY_TAG`, which the engine never escalates), so only
454    // actions the parent permits ask for approval. Parent approval rules keep
455    // their tag. The tag replaces the rule's `sop_category` in the child.
456    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}