1use std::collections::{HashMap, HashSet};
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31use std::time::Duration;
32
33use parking_lot::{Mutex, RwLock};
34use pv_sandbox::{PushedAllowlist, PushedRules, PushedSchema, Sandbox, ToolPattern};
35use pv_state::{BudgetSpec, TokenVelocity};
36use serde::{Deserialize, Serialize};
37use serde_json::Value;
38
39const MAX_BUNDLE_BYTES: u64 = 4 * 1024 * 1024;
42
43const MAX_RATE_WINDOWS: usize = 10_000;
46
47#[derive(Debug, Clone, Deserialize)]
50pub struct BundlePolicy {
51 pub id: String,
53 pub name: String,
55 pub kind: String,
57 pub scope: String,
59 #[serde(default = "default_true")]
61 pub enabled: bool,
62 #[serde(default)]
64 pub spec: Option<Value>,
65}
66
67fn default_true() -> bool {
68 true
69}
70
71#[derive(Debug, Clone, Deserialize)]
73pub struct PolicyBundle {
74 pub version: u32,
76 pub etag: String,
78 pub policies: Vec<BundlePolicy>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct Unsupported {
85 pub id: String,
87 pub reason: String,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct PolicyStatus {
96 pub etag: String,
98 pub applied_at: String,
100 pub daemon_version: String,
102 pub applied: Vec<String>,
104 pub unsupported: Vec<Unsupported>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub error: Option<String>,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct RateRule {
115 pub policy: String,
117 pub max_tokens: u64,
119 pub window_ms: u64,
121}
122
123pub struct CompiledBundle {
125 pub rules: PushedRules,
127 pub rates: Vec<RateRule>,
129 pub applied: Vec<String>,
131 pub unsupported: Vec<Unsupported>,
133}
134
135pub fn compile(bundle: &PolicyBundle) -> CompiledBundle {
138 let mut rules = PushedRules {
139 etag: Some(bundle.etag.clone()),
140 ..PushedRules::default()
141 };
142 let mut rates = Vec::new();
143 let mut applied = Vec::new();
144 let mut unsupported = Vec::new();
145 for policy in bundle.policies.iter().filter(|policy| policy.enabled) {
146 let outcome = match policy.kind.as_str() {
147 "guardrail" | "transform" => continue,
148 "budget" => compile_budget(policy, &mut rules),
149 "allowlist" => compile_allowlist(policy, &mut rules),
150 "schema" => compile_schema(policy, &mut rules),
151 "rate" => compile_rate(policy).map(|rule| rates.push(rule)),
152 other => Err(format!("this daemon does not enforce {other:?} policies")),
153 };
154 match outcome {
155 Ok(()) => applied.push(policy.id.clone()),
156 Err(reason) => unsupported.push(Unsupported {
157 id: policy.id.clone(),
158 reason,
159 }),
160 }
161 }
162 CompiledBundle {
163 rules,
164 rates,
165 applied,
166 unsupported,
167 }
168}
169
170fn spec_object(policy: &BundlePolicy) -> Result<&serde_json::Map<String, Value>, String> {
171 policy
172 .spec
173 .as_ref()
174 .and_then(Value::as_object)
175 .ok_or_else(|| format!("{} policy has no JSON spec", policy.kind))
176}
177
178fn refuse_unknown_fields(spec: &serde_json::Map<String, Value>, known: &[&str]) -> Result<(), String> {
179 match spec.keys().find(|key| !known.contains(&key.as_str())) {
180 Some(key) => Err(format!(
181 "unknown spec field {key:?} (this daemon may be older than the console)"
182 )),
183 None => Ok(()),
184 }
185}
186
187fn spec_u64(spec: &serde_json::Map<String, Value>, field: &str) -> Result<Option<u64>, String> {
188 match spec.get(field) {
189 None | Some(Value::Null) => Ok(None),
190 Some(value) => value
191 .as_u64()
192 .filter(|n| *n <= pv_core::error::JCS_SAFE_MAX)
193 .map(Some)
194 .ok_or_else(|| format!("{field} must be a non-negative integer")),
195 }
196}
197
198fn covers_all_tools(scope: &str) -> bool {
200 matches!(scope.trim(), "tool" | "tool.*" | "*")
201}
202
203fn compile_budget(policy: &BundlePolicy, rules: &mut PushedRules) -> Result<(), String> {
204 let spec = spec_object(policy)?;
205 refuse_unknown_fields(
206 spec,
207 &[
208 "max_total_tool_calls",
209 "max_payout_usd_micros",
210 "max_tokens",
211 "per_tool_max_calls",
212 ],
213 )?;
214 if !covers_all_tools(&policy.scope) {
215 return Err("budget policies need scope tool.* (per-tool caps already name their tools)".to_owned());
216 }
217 let mut pushed = BudgetSpec {
218 max_tokens: spec_u64(spec, "max_tokens")?,
219 max_payout_usd_micros: spec_u64(spec, "max_payout_usd_micros")?,
220 max_total_tool_calls: spec_u64(spec, "max_total_tool_calls")?,
221 ..BudgetSpec::default()
222 };
223 match spec.get("per_tool_max_calls") {
224 None | Some(Value::Null) => {}
225 Some(Value::Object(per_tool)) => {
226 for (tool, cap) in per_tool {
227 let cap = cap
228 .as_u64()
229 .ok_or_else(|| format!("per_tool_max_calls.{tool} must be a non-negative integer"))?;
230 pushed.max_tool_calls.insert(tool.clone(), cap);
231 }
232 }
233 Some(_) => return Err("per_tool_max_calls must be an object".to_owned()),
234 }
235 if !pushed.binds_anything() {
236 return Err("budget spec sets no limit".to_owned());
237 }
238 rules.budget = rules.budget.tightened_by(&pushed);
239 rules.budget_policies.push(policy.name.clone());
240 Ok(())
241}
242
243fn tool_names(spec: &serde_json::Map<String, Value>, field: &str) -> Result<Option<HashSet<String>>, String> {
244 match spec.get(field) {
245 None | Some(Value::Null) => Ok(None),
246 Some(Value::Array(items)) => items
247 .iter()
248 .map(|item| {
249 item.as_str()
250 .filter(|name| !name.is_empty())
251 .map(str::to_owned)
252 .ok_or_else(|| format!("{field} entries must be tool names"))
253 })
254 .collect::<Result<HashSet<_>, _>>()
255 .map(Some),
256 Some(_) => Err(format!("{field} must be a list of tool names")),
257 }
258}
259
260fn compile_allowlist(policy: &BundlePolicy, rules: &mut PushedRules) -> Result<(), String> {
261 let spec = spec_object(policy)?;
262 refuse_unknown_fields(spec, &["allow_only", "deny_tools"])?;
263 let scope = ToolPattern::from_scope(&policy.scope)
264 .ok_or_else(|| format!("scope {:?} does not name tools", policy.scope))?;
265 let allow_only = tool_names(spec, "allow_only")?.filter(|set| !set.is_empty());
266 let deny = tool_names(spec, "deny_tools")?.unwrap_or_default();
267 if allow_only.is_none() && deny.is_empty() {
268 return Err("allowlist spec names no tools".to_owned());
269 }
270 rules.allowlists.push(PushedAllowlist {
271 policy: policy.name.clone(),
272 scope,
273 allow_only,
274 deny,
275 });
276 Ok(())
277}
278
279fn compile_schema(policy: &BundlePolicy, rules: &mut PushedRules) -> Result<(), String> {
280 let spec = spec_object(policy)?;
281 refuse_unknown_fields(spec, &["tool", "json_schema"])?;
282 let tool = spec
283 .get("tool")
284 .and_then(Value::as_str)
285 .filter(|tool| !tool.is_empty())
286 .ok_or_else(|| "schema spec needs a tool name".to_owned())?;
287 let scope = ToolPattern::from_scope(&policy.scope)
288 .ok_or_else(|| format!("scope {:?} does not name tools", policy.scope))?;
289 if !scope.matches(tool) {
290 return Err(format!(
291 "tool {tool:?} is outside the policy scope {:?}",
292 policy.scope
293 ));
294 }
295 let schema = spec
296 .get("json_schema")
297 .filter(|schema| schema.is_object())
298 .ok_or_else(|| "json_schema must be a JSON Schema object".to_owned())?;
299 rules
300 .schemas
301 .push(PushedSchema::compile(&policy.name, tool, schema)?);
302 Ok(())
303}
304
305fn compile_rate(policy: &BundlePolicy) -> Result<RateRule, String> {
306 let spec = spec_object(policy)?;
307 refuse_unknown_fields(spec, &["max_tokens_per_window", "window_s"])?;
308 let scope = policy.scope.trim();
309 if !(scope == "llm" || scope.starts_with("llm.") || covers_all_tools(scope)) {
310 return Err(format!(
311 "rate policies limit model tokens; scope {scope:?} is not llm.request"
312 ));
313 }
314 let max_tokens = spec_u64(spec, "max_tokens_per_window")?
315 .filter(|n| *n > 0)
316 .ok_or_else(|| "max_tokens_per_window must be at least 1".to_owned())?;
317 let window_s = spec_u64(spec, "window_s")?
318 .filter(|n| (1..=30 * 24 * 3600).contains(n))
319 .ok_or_else(|| "window_s must be between 1 and 2592000".to_owned())?;
320 Ok(RateRule {
321 policy: policy.name.clone(),
322 max_tokens,
323 window_ms: window_s.saturating_mul(1000),
324 })
325}
326
327#[derive(Debug, Clone, PartialEq, Eq)]
330pub struct RateRefusal {
331 pub policy: String,
333 pub message: String,
335}
336
337impl std::fmt::Display for RateRefusal {
338 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339 f.write_str(&self.message)
340 }
341}
342
343#[derive(Default)]
351pub struct RateLimits {
352 rules: RwLock<Arc<Vec<RateRule>>>,
353 windows: Mutex<HashMap<(String, String), Arc<TokenVelocity>>>,
354}
355
356impl RateLimits {
357 pub fn set_rules(&self, rules: Vec<RateRule>) {
359 let rules = Arc::new(rules);
360 let keep: HashSet<String> = rules.iter().map(rule_key).collect();
361 self.windows.lock().retain(|(rule, _), _| keep.contains(rule));
362 *self.rules.write() = rules;
363 }
364
365 pub fn rules(&self) -> Arc<Vec<RateRule>> {
367 Arc::clone(&self.rules.read())
368 }
369
370 fn window(&self, rule: &RateRule, principal: &str) -> Arc<TokenVelocity> {
371 let mut windows = self.windows.lock();
372 let key = (rule_key(rule), principal.to_owned());
373 if let Some(window) = windows.get(&key) {
374 return Arc::clone(window);
375 }
376 if windows.len() >= MAX_RATE_WINDOWS {
377 let now = pv_core::time::now_ms();
378 windows.retain(|_, window| window.current_at(now) > 0);
379 if windows.len() >= MAX_RATE_WINDOWS {
380 if let Some(evict) = windows.keys().next().cloned() {
383 windows.remove(&evict);
384 }
385 }
386 }
387 let window = Arc::new(TokenVelocity::new(rule.window_ms));
388 windows.insert(key, Arc::clone(&window));
389 window
390 }
391
392 pub fn admit(&self, principal: &str, tokens: u64) -> Result<(), RateRefusal> {
395 let rules = self.rules();
396 if rules.is_empty() {
397 return Ok(());
398 }
399 let now = pv_core::time::now_ms();
400 let windows: Vec<(RateRule, Arc<TokenVelocity>)> = rules
401 .iter()
402 .map(|rule| (rule.clone(), self.window(rule, principal)))
403 .collect();
404 for (rule, window) in &windows {
405 let used = window.current_at(now);
406 if used.saturating_add(tokens) > rule.max_tokens {
407 return Err(RateRefusal {
408 policy: rule.policy.clone(),
409 message: format!(
410 "policy {:?}: token rate limit exceeded ({used} of {} tokens used in the last {} s)",
411 rule.policy,
412 rule.max_tokens,
413 rule.window_ms / 1000
414 ),
415 });
416 }
417 }
418 for (_, window) in &windows {
419 window.record_at(now, tokens);
420 }
421 Ok(())
422 }
423
424 pub fn record(&self, principal: &str, tokens: u64) {
426 if tokens == 0 {
427 return;
428 }
429 let rules = self.rules();
430 let now = pv_core::time::now_ms();
431 for rule in rules.iter() {
432 self.window(rule, principal).record_at(now, tokens);
433 }
434 }
435}
436
437fn rule_key(rule: &RateRule) -> String {
438 format!("{}|{}|{}", rule.policy, rule.max_tokens, rule.window_ms)
439}
440
441pub fn status_path(bundle_file: &Path) -> PathBuf {
443 let mut name = bundle_file.as_os_str().to_owned();
444 name.push(".status.json");
445 PathBuf::from(name)
446}
447
448pub fn read_bundle(path: &Path) -> Result<Option<(PolicyBundle, String)>, String> {
451 let metadata = match std::fs::metadata(path) {
452 Ok(metadata) => metadata,
453 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
454 Err(error) => return Err(format!("cannot stat policy bundle {}: {error}", path.display())),
455 };
456 #[cfg(unix)]
457 {
458 use std::os::unix::fs::PermissionsExt as _;
459 if metadata.permissions().mode() & 0o022 != 0 {
460 return Err(format!(
461 "policy bundle {} is writable by group or others; it must be owner-only (chmod 600)",
462 path.display()
463 ));
464 }
465 }
466 #[cfg(not(unix))]
467 let _ = metadata;
468 let bytes = pv_core::fsutil::read_capped(path, MAX_BUNDLE_BYTES)
469 .map_err(|error| format!("cannot read policy bundle {}: {error}", path.display()))?;
470 let digest = pv_core::digest::sha256_hex(&bytes);
471 let bundle: PolicyBundle = serde_json::from_slice(&bytes)
472 .map_err(|error| format!("policy bundle {} is not valid JSON: {error}", path.display()))?;
473 if bundle.version != 1 {
474 return Err(format!(
475 "policy bundle version {} is not supported by this daemon (expected 1)",
476 bundle.version
477 ));
478 }
479 if bundle.etag.is_empty()
480 || bundle.etag.len() > 128
481 || !bundle.etag.chars().all(|c| c.is_ascii_hexdigit())
482 {
483 return Err("policy bundle etag must be a hex hash".to_owned());
484 }
485 Ok(Some((bundle, digest)))
486}
487
488pub struct PolicyPush {
490 path: PathBuf,
491 sandbox: Arc<Sandbox>,
492 rates: Arc<RateLimits>,
493 last_digest: Mutex<Option<String>>,
496 in_force: Mutex<Option<PolicyStatus>>,
499}
500
501impl PolicyPush {
502 pub fn new(path: PathBuf, sandbox: Arc<Sandbox>, rates: Arc<RateLimits>) -> Self {
504 Self {
505 path,
506 sandbox,
507 rates,
508 last_digest: Mutex::new(None),
509 in_force: Mutex::new(None),
510 }
511 }
512
513 pub fn load_at_boot(&self) -> Result<Option<PolicyStatus>, String> {
517 match self.apply_if_changed() {
518 Ok(status) => Ok(status),
519 Err(error) => Err(format!(
520 "{error}. Fix or remove the file, or let pvctl console-sync --pull-policies rewrite it"
521 )),
522 }
523 }
524
525 pub fn apply_if_changed(&self) -> Result<Option<PolicyStatus>, String> {
528 let read = read_bundle(&self.path);
529 let (bundle, digest) = match read {
530 Ok(None) => return Ok(None),
531 Ok(Some(found)) => found,
532 Err(error) => {
533 self.record_refusal(&error);
534 return Err(error);
535 }
536 };
537 if self.last_digest.lock().as_deref() == Some(digest.as_str()) {
538 return Ok(None);
539 }
540 let compiled = compile(&bundle);
541 let status = PolicyStatus {
542 etag: bundle.etag.to_ascii_lowercase(),
543 applied_at: now_rfc3339(),
544 daemon_version: env!("CARGO_PKG_VERSION").to_owned(),
545 applied: compiled.applied,
546 unsupported: compiled.unsupported,
547 error: None,
548 };
549 self.sandbox.set_pushed(compiled.rules);
550 self.rates.set_rules(compiled.rates);
551 *self.last_digest.lock() = Some(digest);
552 *self.in_force.lock() = Some(status.clone());
553 self.write_status(&status)?;
554 tracing::info!(
555 etag = %status.etag,
556 applied = status.applied.len(),
557 unsupported = status.unsupported.len(),
558 "applied console policy bundle"
559 );
560 Ok(Some(status))
561 }
562
563 fn record_refusal(&self, error: &str) {
566 tracing::warn!(%error, "console policy bundle refused; the rules in force are unchanged");
567 let status = match self.in_force.lock().clone() {
568 Some(mut status) => {
569 status.error = Some(error.to_owned());
570 status
571 }
572 None => return,
573 };
574 if let Err(write_error) = self.write_status(&status) {
575 tracing::warn!(error = %write_error, "could not write the policy status file");
576 }
577 }
578
579 fn write_status(&self, status: &PolicyStatus) -> Result<(), String> {
580 let path = status_path(&self.path);
581 let bytes =
582 serde_json::to_vec_pretty(status).map_err(|error| format!("serialize policy status: {error}"))?;
583 pv_core::fsutil::write_atomic(&path, &bytes)
584 .map_err(|error| format!("cannot write policy status {}: {error}", path.display()))
585 }
586
587 pub async fn watch(self: Arc<Self>, every: Duration) {
590 let mut ticker = tokio::time::interval(every);
591 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
592 ticker.tick().await;
594 loop {
595 ticker.tick().await;
596 let push = Arc::clone(&self);
597 if let Err(error) = tokio::task::spawn_blocking(move || push.apply_if_changed()).await {
600 tracing::warn!(%error, "policy bundle poll task failed");
601 }
602 }
603 }
604}
605
606fn now_rfc3339() -> String {
607 pv_core::time::iso8601_ms(pv_core::time::now_ms())
608}
609
610#[cfg(test)]
611mod tests {
612 #![allow(
613 clippy::unwrap_used,
614 clippy::expect_used,
615 clippy::panic,
616 clippy::indexing_slicing
617 )]
618
619 use super::*;
620 use serde_json::json;
621
622 fn policy(id: &str, kind: &str, scope: &str, spec: Value) -> Value {
623 json!({"id": id, "name": format!("p.{id}"), "kind": kind, "scope": scope, "enabled": true,
624 "updatedAt": "2026-10-31T00:00:00.000Z", "spec": spec, "body": ""})
625 }
626
627 fn bundle(policies: Vec<Value>) -> PolicyBundle {
628 serde_json::from_value(json!({"version": 1, "orgId": "o", "deploymentId": "d",
629 "generatedAt": "2026-10-31T00:00:00.000Z", "etag": "ab12", "policies": policies}))
630 .unwrap()
631 }
632
633 #[test]
634 fn every_enforceable_kind_compiles_and_others_are_reported() {
635 let compiled = compile(&bundle(vec![
636 policy(
637 "b1",
638 "budget",
639 "tool.*",
640 json!({"max_total_tool_calls": 20, "per_tool_max_calls": {"create_payment": 2}}),
641 ),
642 policy(
643 "b2",
644 "budget",
645 "tool.*",
646 json!({"max_total_tool_calls": 5, "max_payout_usd_micros": 1_000_000}),
647 ),
648 policy(
649 "a1",
650 "allowlist",
651 "tool.payments.*",
652 json!({"deny_tools": ["payments.wire"]}),
653 ),
654 policy(
655 "s1",
656 "schema",
657 "tool.create_po",
658 json!({"tool": "create_po", "json_schema": {"type": "object"}}),
659 ),
660 policy(
661 "r1",
662 "rate",
663 "llm.request",
664 json!({"max_tokens_per_window": 1000, "window_s": 60}),
665 ),
666 json!({"id": "g1", "name": "p.g1", "kind": "guardrail", "scope": "llm.egress", "enabled": true, "spec": null, "body": "text"}),
667 policy("x1", "budget", "tool.create_payment", json!({"max_tokens": 1})),
668 policy("x2", "allowlist", "llm.request", json!({"deny_tools": ["x"]})),
669 policy("x3", "schema", "tool.a", json!({"tool": "b", "json_schema": {}})),
670 policy(
671 "x4",
672 "schema",
673 "tool.*",
674 json!({"tool": "b", "json_schema": {"type": 7}}),
675 ),
676 policy(
677 "x5",
678 "rate",
679 "llm.request",
680 json!({"max_tokens_per_window": 0, "window_s": 60}),
681 ),
682 policy(
683 "x6",
684 "budget",
685 "tool.*",
686 json!({"max_tokens": 1, "brand_new_field": 2}),
687 ),
688 policy("x7", "quota", "tool.*", json!({})),
689 ]));
690 assert_eq!(compiled.applied, vec!["b1", "b2", "a1", "s1", "r1"]);
691 let refused: Vec<&str> = compiled.unsupported.iter().map(|u| u.id.as_str()).collect();
692 assert_eq!(
693 refused,
694 vec!["x1", "x2", "x3", "x4", "x5", "x6", "x7"],
695 "{:?}",
696 compiled.unsupported
697 );
698 assert!(compiled.unsupported.iter().all(|u| !u.reason.is_empty()));
699 assert!(
700 !compiled.applied.contains(&"g1".to_owned()) && !refused.contains(&"g1"),
701 "guardrails are neither"
702 );
703 assert_eq!(compiled.rules.budget.max_total_tool_calls, Some(5));
705 assert_eq!(compiled.rules.budget.max_payout_usd_micros, Some(1_000_000));
706 assert_eq!(
707 compiled.rules.budget.max_tool_calls.get("create_payment"),
708 Some(&2)
709 );
710 assert_eq!(compiled.rules.budget_policies, vec!["p.b1", "p.b2"]);
711 assert_eq!(compiled.rules.allowlists.len(), 1);
712 assert_eq!(compiled.rules.schemas.len(), 1);
713 assert_eq!(
714 compiled.rates,
715 vec![RateRule {
716 policy: "p.r1".into(),
717 max_tokens: 1000,
718 window_ms: 60_000
719 }]
720 );
721 assert_eq!(compiled.rules.etag.as_deref(), Some("ab12"));
722 }
723
724 #[test]
725 fn rate_limits_refuse_over_the_window_and_count_completions() {
726 let limits = RateLimits::default();
727 assert!(limits.admit("alice", u64::MAX).is_ok(), "no rules, no limit");
728 limits.set_rules(vec![RateRule {
729 policy: "llm.velocity".into(),
730 max_tokens: 100,
731 window_ms: 60_000,
732 }]);
733 assert!(limits.admit("alice", 60).is_ok());
734 limits.record("alice", 30);
735 let refused = limits.admit("alice", 20).unwrap_err();
736 assert_eq!(refused.policy, "llm.velocity");
737 let text = refused.to_string();
738 assert!(
739 text.contains("llm.velocity") && text.contains("90 of 100"),
740 "{text}"
741 );
742 assert!(limits.admit("alice", 10).is_ok(), "exactly at the cap is allowed");
743 assert!(limits.admit("bob", 100).is_ok(), "windows are per principal");
744 limits.set_rules(vec![RateRule {
746 policy: "llm.velocity".into(),
747 max_tokens: 200,
748 window_ms: 60_000,
749 }]);
750 assert!(limits.admit("alice", 150).is_ok());
751 limits.set_rules(Vec::new());
752 assert!(limits.admit("alice", u64::MAX).is_ok());
753 }
754
755 fn write_owner_only(path: &Path, value: &Value) {
756 pv_core::fsutil::write_atomic(path, &serde_json::to_vec(value).unwrap()).unwrap();
757 }
758
759 #[test]
760 fn bundle_files_apply_once_report_status_and_refusals_keep_the_rules() {
761 let dir = tempfile::tempdir().unwrap();
762 let path = dir.path().join("policies.json");
763 let sandbox = Arc::new(Sandbox::new(pv_sandbox::SandboxConfig::default(), Vec::new()).unwrap());
764 let rates = Arc::new(RateLimits::default());
765 let push = PolicyPush::new(path.clone(), Arc::clone(&sandbox), Arc::clone(&rates));
766 assert_eq!(push.load_at_boot().unwrap(), None, "no file: nothing pushed");
767
768 write_owner_only(
769 &path,
770 &json!({"version": 1, "etag": "AB12", "policies": [
771 policy("b1", "budget", "tool.*", json!({"max_total_tool_calls": 3})),
772 policy("r1", "rate", "llm.request", json!({"max_tokens_per_window": 50, "window_s": 60})),
773 ]}),
774 );
775 let status = push.apply_if_changed().unwrap().expect("a new bundle applies");
776 assert_eq!(
777 status.etag, "ab12",
778 "etags are reported lowercase like the console stores them"
779 );
780 assert_eq!(status.applied, vec!["b1", "r1"]);
781 assert_eq!(sandbox.effective_budget().max_total_tool_calls, Some(3));
782 assert_eq!(rates.rules().len(), 1);
783 let on_disk: PolicyStatus =
784 serde_json::from_slice(&std::fs::read(status_path(&path)).unwrap()).unwrap();
785 assert_eq!(on_disk, status);
786 assert!(
787 push.apply_if_changed().unwrap().is_none(),
788 "unchanged bytes are not reapplied"
789 );
790
791 std::fs::write(&path, b"{ not json").unwrap();
793 #[cfg(unix)]
794 {
795 use std::os::unix::fs::PermissionsExt as _;
796 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
797 }
798 assert!(push.apply_if_changed().is_err());
799 assert_eq!(
800 sandbox.effective_budget().max_total_tool_calls,
801 Some(3),
802 "rules unchanged"
803 );
804 let refused: PolicyStatus =
805 serde_json::from_slice(&std::fs::read(status_path(&path)).unwrap()).unwrap();
806 assert_eq!(refused.etag, "ab12");
807 assert!(refused.error.unwrap().contains("not valid JSON"));
808
809 write_owner_only(&path, &json!({"version": 2, "etag": "cd34", "policies": []}));
811 assert!(push.apply_if_changed().unwrap_err().contains("version 2"));
812 #[cfg(unix)]
813 {
814 use std::os::unix::fs::PermissionsExt as _;
815 write_owner_only(&path, &json!({"version": 1, "etag": "cd34", "policies": []}));
816 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)).unwrap();
817 assert!(push.apply_if_changed().unwrap_err().contains("owner-only"));
818 }
819
820 write_owner_only(&path, &json!({"version": 1, "etag": "cd34", "policies": []}));
822 assert_eq!(push.apply_if_changed().unwrap().unwrap().etag, "cd34");
823 assert_eq!(sandbox.effective_budget().max_total_tool_calls, None);
824 assert!(rates.rules().is_empty());
825
826 std::fs::write(&path, b"garbage").unwrap();
828 let fresh = PolicyPush::new(path.clone(), sandbox, rates);
829 assert!(fresh
830 .load_at_boot()
831 .unwrap_err()
832 .contains("pvctl console-sync --pull-policies"));
833 }
834}