Skip to main content

pv_harness/
policy_push.rs

1//! Console-pushed policy bundles.
2//!
3//! The daemon never calls the console. `pvctl console-sync --watch
4//! --pull-policies`, running on the same host, pulls the org's policy
5//! bundle (`GET /api/v1/ingest/policies`) and writes it atomically to the
6//! file named by `policy_bundle_file`. The daemon loads that file at boot
7//! and polls it for changes; each new bundle is compiled into
8//! [`pv_sandbox::PushedRules`] plus token-rate rules and swapped in whole.
9//! After every attempt the daemon writes its own answer next to the
10//! bundle (`<policy_bundle_file>.status.json`): the etag it applied, the
11//! policy ids it now enforces and the ones it could not enforce, with a
12//! reason. `pvctl` reports that file to the console, so the console's
13//! "Enforced by N of M daemons" reflects what daemons actually run, not
14//! what was sent to them.
15//!
16//! Pushed rules only add restrictions (see [`pv_sandbox::pushed`]). A
17//! bundle that cannot be read or parsed leaves the rules already in
18//! force unchanged — at boot that means the daemon refuses to start,
19//! because running without rules the operator expects to be there would
20//! fail open.
21//!
22//! Kinds: `budget`, `allowlist`, `schema` (tool calls) and `rate`
23//! (per-principal token velocity on model traffic) are enforced.
24//! `guardrail` and `transform` policies document intent and attribute
25//! the daemon's own guard hits; the daemon neither enforces nor reports
26//! them. Anything else is reported unsupported.
27
28use 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
39/// Upper bound on a bundle file. The console caps policy bodies, and a
40/// bundle far larger than this is not a bundle.
41const MAX_BUNDLE_BYTES: u64 = 4 * 1024 * 1024;
42
43/// Upper bound on (rule, principal) rate windows held in memory. Idle
44/// windows are evicted first when the bound is reached.
45const MAX_RATE_WINDOWS: usize = 10_000;
46
47/// One policy entry of the console bundle (contract: `server/src/lib/
48/// policy-bundle.ts`).
49#[derive(Debug, Clone, Deserialize)]
50pub struct BundlePolicy {
51    /// Console policy id, echoed in the status report.
52    pub id: String,
53    /// Console policy name, recorded as the blocking policy.
54    pub name: String,
55    /// `budget`, `allowlist`, `rate`, `schema`, `guardrail`, `transform`, …
56    pub kind: String,
57    /// `tool.*`, `tool.<name>`, `llm.request`, …
58    pub scope: String,
59    /// Always true in a bundle; a false entry is ignored.
60    #[serde(default = "default_true")]
61    pub enabled: bool,
62    /// Parsed JSON spec for the enforceable kinds, `null` otherwise.
63    #[serde(default)]
64    pub spec: Option<Value>,
65}
66
67fn default_true() -> bool {
68    true
69}
70
71/// The console bundle file.
72#[derive(Debug, Clone, Deserialize)]
73pub struct PolicyBundle {
74    /// Bundle format version; only `1` is understood.
75    pub version: u32,
76    /// SHA-256 hex of the canonical policies array, used as the bundle id.
77    pub etag: String,
78    /// Enabled policies of the org, sorted by name.
79    pub policies: Vec<BundlePolicy>,
80}
81
82/// One policy the daemon could not enforce.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct Unsupported {
85    /// Console policy id.
86    pub id: String,
87    /// Why, in one short sentence.
88    pub reason: String,
89}
90
91/// The daemon's own answer for the last bundle it processed; the body
92/// `pvctl` posts to `POST /api/v1/ingest/policy-status`.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct PolicyStatus {
96    /// Etag of the bundle now in force.
97    pub etag: String,
98    /// When it was applied (RFC 3339, UTC).
99    pub applied_at: String,
100    /// This daemon's version.
101    pub daemon_version: String,
102    /// Policy ids now enforced.
103    pub applied: Vec<String>,
104    /// Policy ids that could not be enforced.
105    pub unsupported: Vec<Unsupported>,
106    /// Set when the newest bundle file could not be applied; the rules of
107    /// `etag` stay in force. Not sent to the console.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub error: Option<String>,
110}
111
112/// One per-principal token-rate rule.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct RateRule {
115    /// Console policy name, recorded in the refusal.
116    pub policy: String,
117    /// Tokens a principal may push through the model per window.
118    pub max_tokens: u64,
119    /// Window length in milliseconds.
120    pub window_ms: u64,
121}
122
123/// A compiled bundle, ready to swap in.
124pub struct CompiledBundle {
125    /// Tool-call rules for the sandbox.
126    pub rules: PushedRules,
127    /// Token-rate rules for model traffic.
128    pub rates: Vec<RateRule>,
129    /// Policy ids enforced.
130    pub applied: Vec<String>,
131    /// Policy ids not enforced, with reasons.
132    pub unsupported: Vec<Unsupported>,
133}
134
135/// Compile a bundle. Never fails as a whole: a policy the daemon cannot
136/// enforce is listed in `unsupported` and the rest still apply.
137pub 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
198/// True for the scopes that name every tool.
199fn 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/// A request a token-rate rule refused: which console policy, and the
328/// message the client and the audit event carry.
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub struct RateRefusal {
331    /// Console name of the token-rate policy that refused the request.
332    pub policy: String,
333    /// The refusal as the client and the stop-reason event see it.
334    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/// Per-principal token-rate limits from the pushed bundle.
344///
345/// Prompt tokens are checked and recorded at admission; completion
346/// tokens are recorded as the provider reports them. A request is
347/// refused when its prompt would push the principal's window over the
348/// cap, so a principal that ran over during a stream is refused on its
349/// next request.
350#[derive(Default)]
351pub struct RateLimits {
352    rules: RwLock<Arc<Vec<RateRule>>>,
353    windows: Mutex<HashMap<(String, String), Arc<TokenVelocity>>>,
354}
355
356impl RateLimits {
357    /// Replace the rules. Windows of rules that changed are dropped.
358    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    /// The rules currently in force.
366    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                // Every window is busy: drop an arbitrary one rather
381                // than grow without bound (its principal starts over).
382                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    /// Admit `tokens` for `principal`, or name the rule that refuses them.
393    /// Admitted tokens are recorded against every rule.
394    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    /// Record tokens that were already spent (completion tokens).
425    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
441/// Path of the status file the daemon writes next to the bundle file.
442pub 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
448/// Read and parse the bundle file. `Ok(None)` when it does not exist yet
449/// (nothing was ever pushed).
450pub 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
488/// Applies bundle files to a running daemon.
489pub struct PolicyPush {
490    path: PathBuf,
491    sandbox: Arc<Sandbox>,
492    rates: Arc<RateLimits>,
493    /// Digest of the file bytes last processed (applied or refused), so
494    /// an unchanged file is not reprocessed on every poll.
495    last_digest: Mutex<Option<String>>,
496    /// Status of the bundle in force, re-written with an error when a
497    /// newer file is refused.
498    in_force: Mutex<Option<PolicyStatus>>,
499}
500
501impl PolicyPush {
502    /// Bind to a bundle file and the enforcement points it feeds.
503    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    /// Boot-time load. A missing file is fine (nothing was pushed); a
514    /// file that exists but cannot be applied is an error, so the daemon
515    /// refuses to start rather than run without rules it was given.
516    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    /// Apply the file if its bytes changed since the last attempt.
526    /// Returns the new status when a bundle was applied.
527    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    /// Note a refused file in the status file, keeping the bundle in
564    /// force (if any) as the reported etag.
565    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    /// Poll the file for changes forever (the task ends with the
588    /// runtime at process exit).
589    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        // The first tick fires at once; boot already applied the file.
593        ticker.tick().await;
594        loop {
595            ticker.tick().await;
596            let push = Arc::clone(&self);
597            // File I/O and schema compilation are blocking work. A
598            // refusal is already logged and written to the status file.
599            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        // Two budgets merge, stricter wins.
704        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        // New rules start fresh windows.
745        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        // A broken newer file keeps the rules in force and says why.
792        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        // A newer version and a group-writable file are refused too.
810        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        // An empty bundle clears every pushed rule.
821        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        // At boot, a file that cannot be applied stops the daemon.
827        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}