Skip to main content

clawft_kernel/
parent_policy.rs

1//! The signed parent policy (ADR-103 A6, D8, Phase 2 package E).
2//!
3//! The user daemon exports its governance rules and limits as a
4//! [`ParentPolicy`], signs it with the user key and writes it to
5//! `<run>/<id>/parent-policy.json`. A project kernel reads it at boot (and on
6//! `governance.reload` / `governance.parent.update`), checks the signature
7//! against the `user_pubkey` of its certificate and merges its overlay onto it
8//! ([`crate::governance_overlay::merge`]).
9//!
10//! Signed bytes: [`PARENT_POLICY_DOMAIN`] + canonical JSON of every field but
11//! `sig` (rules sorted by id, the risk threshold as the hex of its bits). The
12//! tag is distinct from the certificate, anchor and proof-of-possession tags
13//! of package A, so a signature made for one can never verify as another.
14//!
15//! `version` only moves forward: a kernel pins the highest version it has
16//! accepted and refuses an older signed policy ([`ParentPolicyError::Rollback`]),
17//! so putting an old, looser, validly signed file back does nothing.
18
19use std::path::Path;
20
21use chrono::{DateTime, SecondsFormat, Utc};
22use clawft_types::config::overlay::Limits;
23use clawft_types::project::canon::{canonical_json, hex_decode, hex_encode};
24use clawft_types::project::cert::key_id;
25use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
26use serde::{Deserialize, Serialize};
27use serde_json::{Value, json};
28
29use crate::governance::{GovernanceEngine, GovernanceRule};
30use crate::governance_overlay::{
31    OverlayError, limits_value, read_capped, sha256_domain, sorted_rule_values,
32};
33
34/// Domain tag prepended to the signed bytes of a [`ParentPolicy`].
35pub const PARENT_POLICY_DOMAIN: &str = "weftos-parent-policy-v1\n";
36/// Domain tag of [`parent_rules_hash`].
37pub const PARENT_RULES_DOMAIN: &str = "weftos-parent-rules-v1\n";
38/// How far past the clock a previous version may be and still be believed.
39pub const VERSION_SLACK_SECS: u64 = 86_400;
40/// Parent policy format version.
41pub const PARENT_SCHEMA: u32 = 1;
42
43/// Why a parent policy was refused. Every variant names its key.
44#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
45pub enum ParentPolicyError {
46    /// `schema` is not [`PARENT_SCHEMA`].
47    #[error("`schema` is {0}, expected {PARENT_SCHEMA}")]
48    Schema(u32),
49    /// `user_key_id` is not the id of the trusted user key.
50    #[error("`user_key_id` does not match the trusted user key")]
51    UserKey,
52    /// `issued_at` is not `YYYY-MM-DDTHH:MM:SSZ`.
53    #[error("`issued_at` is not a canonical UTC timestamp")]
54    IssuedAt,
55    /// A rule id is empty or has surrounding whitespace.
56    #[error("`rules[{0}].id` is malformed")]
57    RuleId(usize),
58    /// A limit is out of range.
59    #[error("`limits.{0}` is invalid")]
60    Limit(&'static str),
61    /// `rule_hash` does not match the rules and limits.
62    #[error("`rule_hash` does not match the rules and limits")]
63    RuleHash,
64    /// `sig` is not 128 lowercase hex characters.
65    #[error("`sig` is malformed")]
66    SigFormat,
67    /// The signature does not verify.
68    #[error("`sig` does not verify")]
69    BadSignature,
70    /// The file could not be read or parsed.
71    #[error("{0}")]
72    File(String),
73    /// A signed policy older than the newest one this kernel accepted.
74    #[error("`version` {have} is older than the accepted version {pinned}")]
75    Rollback { have: u64, pinned: u64 },
76}
77
78impl ParentPolicyError {
79    /// The offending key.
80    pub fn key(&self) -> String {
81        match self {
82            Self::Schema(_) => "parent-policy.schema",
83            Self::UserKey => "parent-policy.user_key_id",
84            Self::IssuedAt => "parent-policy.issued_at",
85            Self::RuleId(i) => return format!("parent-policy.rules[{i}].id"),
86            Self::Limit(k) => return format!("parent-policy.limits.{k}"),
87            Self::RuleHash => "parent-policy.rule_hash",
88            Self::SigFormat | Self::BadSignature => "parent-policy.sig",
89            Self::File(_) => "parent-policy.json",
90            Self::Rollback { .. } => "parent-policy.version",
91        }
92        .to_owned()
93    }
94}
95
96/// `<run>/<id>/parent-policy.json`.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct ParentPolicy {
100    /// [`PARENT_SCHEMA`].
101    pub schema: u32,
102    /// `key_id` of the signing user key.
103    pub user_key_id: String,
104    /// Strictly increasing across exports.
105    pub version: u64,
106    /// `YYYY-MM-DDTHH:MM:SSZ`.
107    pub issued_at: String,
108    /// The user's rules, active or not.
109    pub rules: Vec<GovernanceRule>,
110    /// The user's limits.
111    pub limits: Limits,
112    /// Hex of [`parent_rules_hash`].
113    pub rule_hash: String,
114    /// Hex Ed25519 signature by the user key.
115    #[serde(default)]
116    pub sig: String,
117}
118
119/// Hash of a rule set and its limits (the `parent_hash` of an effective
120/// merge). Covers `active`, severity and selectors through each rule's full
121/// serialized form.
122pub fn parent_rules_hash(rules: &[GovernanceRule], limits: &Limits) -> [u8; 32] {
123    sha256_domain(
124        PARENT_RULES_DOMAIN,
125        &json!({"rules": sorted_rule_values(rules), "limits": limits_value(limits)}),
126    )
127}
128
129fn check_limits(l: &Limits) -> Result<(), ParentPolicyError> {
130    if let Some(r) = l.risk_threshold
131        && !(r.is_finite() && (0.0..=1.0).contains(&r))
132    {
133        return Err(ParentPolicyError::Limit("risk_threshold"));
134    }
135    if l.max_processes == Some(0) {
136        return Err(ParentPolicyError::Limit("max_processes"));
137    }
138    Ok(())
139}
140
141impl ParentPolicy {
142    fn body(&self) -> Value {
143        json!({
144            "schema": self.schema,
145            "user_key_id": self.user_key_id,
146            "version": self.version,
147            "issued_at": self.issued_at,
148            "rules": sorted_rule_values(&self.rules),
149            "limits": limits_value(&self.limits),
150            "rule_hash": self.rule_hash,
151        })
152    }
153
154    /// The bytes the user key signs.
155    pub fn signed_bytes(&self) -> Vec<u8> {
156        let mut out = PARENT_POLICY_DOMAIN.as_bytes().to_vec();
157        out.extend_from_slice(canonical_json(&self.body()).as_bytes());
158        out
159    }
160
161    /// Parse JSON text.
162    pub fn from_json(s: &str) -> Result<Self, ParentPolicyError> {
163        serde_json::from_str(s).map_err(|e| ParentPolicyError::File(format!("does not parse: {e}")))
164    }
165}
166
167/// Check shape, hash and signature against the trusted user key.
168pub fn verify_parent_policy(
169    p: &ParentPolicy,
170    user_pubkey: &[u8; 32],
171) -> Result<(), ParentPolicyError> {
172    if p.schema != PARENT_SCHEMA {
173        return Err(ParentPolicyError::Schema(p.schema));
174    }
175    if p.user_key_id != key_id(user_pubkey) {
176        return Err(ParentPolicyError::UserKey);
177    }
178    let ts_ok = DateTime::parse_from_rfc3339(&p.issued_at)
179        .map(|t| t.with_timezone(&Utc).to_rfc3339_opts(SecondsFormat::Secs, true) == p.issued_at)
180        .unwrap_or(false);
181    if !ts_ok {
182        return Err(ParentPolicyError::IssuedAt);
183    }
184    for (i, r) in p.rules.iter().enumerate() {
185        if r.id.is_empty() || r.id != r.id.trim() {
186            return Err(ParentPolicyError::RuleId(i));
187        }
188    }
189    check_limits(&p.limits)?;
190    if p.rule_hash != hex_encode(&parent_rules_hash(&p.rules, &p.limits)) {
191        return Err(ParentPolicyError::RuleHash);
192    }
193    let sig: [u8; 64] = hex_decode(&p.sig).ok_or(ParentPolicyError::SigFormat)?;
194    VerifyingKey::from_bytes(user_pubkey)
195        .map_err(|_| ParentPolicyError::UserKey)?
196        .verify_strict(&p.signed_bytes(), &Signature::from_bytes(&sig))
197        .map_err(|_| ParentPolicyError::BadSignature)
198}
199
200/// Read and parse a parent policy file (size-capped; missing is an error:
201/// a project kernel with no parent policy must not start).
202pub fn load_parent_policy(path: &Path) -> Result<ParentPolicy, OverlayError> {
203    let text = read_capped(path)?.ok_or_else(|| {
204        OverlayError::Parent(ParentPolicyError::File(format!(
205            "{} does not exist",
206            path.display()
207        )))
208    })?;
209    Ok(ParentPolicy::from_json(&text)?)
210}
211
212/// User side: snapshot `engine`, tighten with `limits`, sign.
213///
214/// `limits` carries what the engine does not hold (`max_processes`,
215/// `spawn_budget`) and may only lower the engine's own threshold or raise its
216/// human-approval flag. Refuses to sign an out-of-range threshold.
217pub fn export(
218    engine: &GovernanceEngine,
219    limits: &Limits,
220    signing_key: &SigningKey,
221    version: u64,
222    issued_at: DateTime<Utc>,
223) -> Result<ParentPolicy, ParentPolicyError> {
224    export_rules(
225        engine.rules().to_vec(),
226        engine.risk_threshold(),
227        engine.human_approval_required(),
228        limits,
229        signing_key,
230        version,
231        issued_at,
232    )
233}
234
235/// [`export`] from an already-taken snapshot (`rules`, engine threshold and
236/// human-approval flag).
237pub fn export_rules(
238    rules: Vec<GovernanceRule>,
239    engine_threshold: f64,
240    engine_human_approval: bool,
241    limits: &Limits,
242    signing_key: &SigningKey,
243    version: u64,
244    issued_at: DateTime<Utc>,
245) -> Result<ParentPolicy, ParentPolicyError> {
246    let threshold = limits
247        .risk_threshold
248        .map_or(engine_threshold, |r| r.min(engine_threshold));
249    let limits = Limits {
250        risk_threshold: Some(threshold),
251        max_processes: limits.max_processes,
252        spawn_budget: limits.spawn_budget,
253        human_approval_required: Some(
254            engine_human_approval || limits.human_approval_required.unwrap_or(false),
255        ),
256    };
257    check_limits(&limits)?;
258    let pubkey = signing_key.verifying_key().to_bytes();
259    let mut p = ParentPolicy {
260        schema: PARENT_SCHEMA,
261        user_key_id: key_id(&pubkey),
262        version,
263        issued_at: issued_at.to_rfc3339_opts(SecondsFormat::Secs, true),
264        rule_hash: hex_encode(&parent_rules_hash(&rules, &limits)),
265        rules,
266        limits,
267        sig: String::new(),
268    };
269    p.sig = hex_encode(&signing_key.sign(&p.signed_bytes()).to_bytes());
270    Ok(p)
271}
272
273/// The next version after `prev`: past `prev` and past the clock, so a lost
274/// counter file cannot send the version backwards behind a kernel's pin.
275pub fn next_version(prev: u64, now: DateTime<Utc>) -> u64 {
276    prev.saturating_add(1).max(u64::try_from(now.timestamp()).unwrap_or(0))
277}
278
279/// Atomic write, mode 0600 on unix: a private temp file in the same
280/// directory, fsynced, then renamed over `path`.
281pub fn write_atomic_0600(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
282    use std::io::Write;
283    let dir = path.parent().unwrap_or_else(|| Path::new("."));
284    std::fs::create_dir_all(dir)?;
285    let tmp = dir.join(format!(
286        ".{}.{}.tmp",
287        path.file_name().and_then(|n| n.to_str()).unwrap_or("file"),
288        std::process::id()
289    ));
290    let _ = std::fs::remove_file(&tmp);
291    let mut opts = std::fs::OpenOptions::new();
292    opts.write(true).create_new(true);
293    #[cfg(unix)]
294    {
295        use std::os::unix::fs::OpenOptionsExt;
296        opts.mode(0o600);
297    }
298    let result = (|| {
299        let mut f = opts.open(&tmp)?;
300        f.write_all(bytes)?;
301        f.sync_all()?;
302        std::fs::rename(&tmp, path)
303    })();
304    if result.is_err() {
305        let _ = std::fs::remove_file(&tmp);
306    }
307    result
308}
309
310/// User side: export and atomically write `path` (normally
311/// `<run>/<id>/parent-policy.json`). The version continues from whatever
312/// valid policy the file already holds.
313pub fn export_to(
314    path: &Path,
315    engine: &GovernanceEngine,
316    limits: &Limits,
317    signing_key: &SigningKey,
318) -> Result<ParentPolicy, ParentPolicyError> {
319    export_rules_to(
320        path,
321        engine.rules().to_vec(),
322        engine.risk_threshold(),
323        engine.human_approval_required(),
324        limits,
325        signing_key,
326    )
327}
328
329/// [`export_to`] from an already-taken snapshot.
330pub fn export_rules_to(
331    path: &Path,
332    rules: Vec<GovernanceRule>,
333    engine_threshold: f64,
334    engine_human_approval: bool,
335    limits: &Limits,
336    signing_key: &SigningKey,
337) -> Result<ParentPolicy, ParentPolicyError> {
338    let now = Utc::now();
339    // Only a policy this key signed may set the version, and a version far
340    // ahead of the clock is not believed: a bad file cannot ratchet it up.
341    let pubkey = signing_key.verifying_key().to_bytes();
342    let slack = u64::try_from(now.timestamp()).unwrap_or(0) + VERSION_SLACK_SECS;
343    let prev = load_parent_policy(path)
344        .ok()
345        .filter(|p| verify_parent_policy(p, &pubkey).is_ok())
346        .map_or(0, |p| p.version)
347        .min(slack);
348    let p = export_rules(
349        rules,
350        engine_threshold,
351        engine_human_approval,
352        limits,
353        signing_key,
354        next_version(prev, now),
355        now,
356    )?;
357    let text = serde_json::to_vec_pretty(&p)
358        .map_err(|e| ParentPolicyError::File(format!("serialize: {e}")))?;
359    write_atomic_0600(path, &text)
360        .map_err(|e| ParentPolicyError::File(format!("{}: {e}", path.display())))?;
361    Ok(p)
362}