1use 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
34pub const PARENT_POLICY_DOMAIN: &str = "weftos-parent-policy-v1\n";
36pub const PARENT_RULES_DOMAIN: &str = "weftos-parent-rules-v1\n";
38pub const VERSION_SLACK_SECS: u64 = 86_400;
40pub const PARENT_SCHEMA: u32 = 1;
42
43#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
45pub enum ParentPolicyError {
46 #[error("`schema` is {0}, expected {PARENT_SCHEMA}")]
48 Schema(u32),
49 #[error("`user_key_id` does not match the trusted user key")]
51 UserKey,
52 #[error("`issued_at` is not a canonical UTC timestamp")]
54 IssuedAt,
55 #[error("`rules[{0}].id` is malformed")]
57 RuleId(usize),
58 #[error("`limits.{0}` is invalid")]
60 Limit(&'static str),
61 #[error("`rule_hash` does not match the rules and limits")]
63 RuleHash,
64 #[error("`sig` is malformed")]
66 SigFormat,
67 #[error("`sig` does not verify")]
69 BadSignature,
70 #[error("{0}")]
72 File(String),
73 #[error("`version` {have} is older than the accepted version {pinned}")]
75 Rollback { have: u64, pinned: u64 },
76}
77
78impl ParentPolicyError {
79 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#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct ParentPolicy {
100 pub schema: u32,
102 pub user_key_id: String,
104 pub version: u64,
106 pub issued_at: String,
108 pub rules: Vec<GovernanceRule>,
110 pub limits: Limits,
112 pub rule_hash: String,
114 #[serde(default)]
116 pub sig: String,
117}
118
119pub 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 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 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
167pub 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
200pub 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
212pub 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
235pub 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
273pub 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
279pub 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
310pub 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
329pub 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 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}