Skip to main content

lean_ctx_protocol/hive/
effective_policy.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Unified Control: the signed effective policy of one engine (Release B,
4//! C0 2.x `schemas/policy.schema.json`).
5//!
6//! `GET /v1/policy/bundle` with `Hive-Protocol: 2.x` keeps every 1.x member
7//! and adds `effective_policy` ([`EffectivePolicyBundleV1`], or `null` when
8//! the server has no lease signing key). Hive merges the policy hierarchy
9//! (org → workspace → vault → engine) itself; the engine never merges, it
10//! verifies and applies the result:
11//!
12//! - signature: Ed25519 by a key-set v3 key of purpose `policy` over
13//!   [`EFFECTIVE_POLICY_SIGNATURE_DOMAIN`] ‖ canonical_json(bundle without
14//!   `signature`), 128 lowercase hex characters;
15//! - `digest` = `"sha256:" + hex(sha256(canonical_json(policy)))`
16//!   ([`policy_digest`]), the value the engine acks;
17//! - every member carries `x-enforcement`: `engine` members map onto the
18//!   gateway policy pack ([`ENGINE_MEMBERS`]), `server` members are enforced
19//!   by Hive itself at the point their `x-server` names (`403 policy_denied
20//!   {member, level}`, [`SERVER_MEMBERS`]), `advisory` members are
21//!   distributed and shown only. The ack's `unenforced` reports what is
22//!   enforced nowhere: every member it received that this engine does not
23//!   enforce and Hive does not enforce either ([`unenforced_members`]:
24//!   advisory members and members this engine does not know yet), never
25//!   pretending to enforce them. Server members are left out: Hive
26//!   enforces them whatever the engine does ([`server_members`], shown
27//!   apart in status).
28//!
29//! The bundle, the effective policy and its sections accept unknown members
30//! and unknown sections (they are covered by the signature and reported as
31//! unenforced); a known member with a value outside the contract refuses the
32//! whole bundle, so the engine keeps its last valid one.
33//!
34//! ent2 (rules for agents and models) adds the optional `agents` section and
35//! the `security` members `allowed_providers`, `require_proxy`,
36//! `readable_roots` and `never_send`. This engine enforces `readable_roots`
37//! and `never_send` (ent2-3: `io_boundary` and the native hooks); it
38//! validates the others but does not enforce them yet
39//! ([`PLANNED_ENGINE_MEMBERS`]): its ack reports them as
40//! unenforced until the enforcement lands and moves each one into
41//! [`ENGINE_MEMBERS`].
42
43use std::collections::{BTreeMap, BTreeSet};
44
45use serde::{Deserialize, Serialize};
46use serde_json::Value;
47use sha2::{Digest, Sha256};
48
49use super::{canonical_json, require_schema, signing_input_without_signature};
50use crate::{UtcTimestamp, ValidationError};
51
52pub const EFFECTIVE_POLICY_SCHEMA: &str = "leanctx.hive.effective-policy/v1";
53pub const POLICY_DOCUMENT_SCHEMA: &str = "leanctx.hive.policy/v1";
54pub const POLICY_ACK_SCHEMA: &str = "leanctx.hive.policy-ack/v1";
55/// Domain of the effective-policy signature (key-set v3 purpose `policy`).
56pub const EFFECTIVE_POLICY_SIGNATURE_DOMAIN: &str = "leanctx.hive.effective-policy.v1\n";
57/// Largest number of `sources` and per-vault entries of one bundle.
58pub const MAX_POLICY_SOURCES: usize = 35;
59pub const MAX_POLICY_VAULTS: usize = 32;
60/// Largest `unenforced` list of an ack.
61pub const MAX_UNENFORCED: usize = 64;
62/// Error code of an ack for a version/digest this engine was not issued.
63pub const POLICY_MISMATCH: &str = "policy_mismatch";
64
65/// Bound of every set member of an effective section.
66const MAX_SET: usize = 256;
67const MAX_MODEL_GROUPS: usize = 16;
68const MAX_MODEL_GROUP: usize = 32;
69
70/// `section.member` → the gateway policy pack field that enforces it, for
71/// every member the schema marks `x-enforcement: engine` (its `x-engine`).
72pub const ENGINE_MEMBERS: &[(&str, &str)] = &[
73    ("context.allowed_tools", "context.allow_tools"),
74    ("context.denied_tools", "context.deny_tools"),
75    ("context.injection_filter", "filters.injection"),
76    ("context.max_context_tokens", "context.max_context_tokens"),
77    (
78        "context.max_cost_cents_per_person_per_day",
79        "budgets.max_cost_usd_per_person_per_day",
80    ),
81    (
82        "context.max_cost_cents_per_project_per_month",
83        "budgets.max_cost_usd_per_project_per_month",
84    ),
85    (
86        "context.max_requests_per_minute_per_person",
87        "budgets.max_requests_per_minute_per_person",
88    ),
89    ("context.max_writes_per_min", "egress.max_writes_per_min"),
90    ("context.read_mode", "context.default_read_mode"),
91    ("security.allowed_models", "routing.model_ceiling_groups"),
92    ("security.block_secrets", "egress.block_secrets"),
93    ("security.blocked_labels", "filters.blocked_labels"),
94    ("security.classification", "filters.classification"),
95    ("security.never_send", "io.never_send"),
96    ("security.pii", "filters.pii"),
97    ("security.readable_roots", "io.readable_roots"),
98];
99
100/// `section.member` → its `x-instance` gateway pack field, for every member
101/// the schema marks `x-enforcement: instance` that this engine validates but
102/// does not enforce yet (ent2). The ack reports them as unenforced; each one
103/// moves into [`ENGINE_MEMBERS`] with its enforcement (ent2-2 … ent2-4).
104pub const PLANNED_ENGINE_MEMBERS: &[(&str, &str)] = &[
105    ("agents.allowed", "agents.allowed_agents"),
106    ("security.allowed_providers", "routing.allowed_providers"),
107    ("security.require_proxy", "routing.require_proxy"),
108];
109
110/// Every member the schema marks `x-enforcement: server`: Hive enforces it
111/// (its `x-server` point), this engine does not.
112pub const SERVER_MEMBERS: &[&str] = &[
113    "security.excluded_paths",
114    "sharing.allowed_vaults",
115    "sharing.consent_required_for",
116    "sharing.cross_team_sharing",
117    "sharing.personal_workspace",
118    // Also honoured by the engine's metrics upload (it sends and buffers
119    // nothing when off); Hive refuses the upload whatever the engine does.
120    "sharing.share_instance_metrics",
121    "sharing.task_claim_min_role",
122];
123
124/// Every member the schema marks `x-enforcement: advisory`.
125pub const ADVISORY_MEMBERS: &[&str] = &[
126    // Desktop pre-checks it at session start; the instance cannot see
127    // whether an agent asks for approval.
128    "agents.require_approvals",
129    "context.allowed_sources",
130    "context.auto_extraction",
131    "context.extraction_requires_source_anchor",
132    "context.max_source_age_days",
133    "context.ranking_profile",
134    "security.confidential_out_of_sharing",
135    "security.external_model_excluded_classes",
136];
137
138/// Filter action, loosest first (`x-merge: rank`).
139#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum PolicyActionV1 {
142    Off,
143    Warn,
144    Redact,
145    Block,
146}
147
148impl PolicyActionV1 {
149    pub fn as_str(self) -> &'static str {
150        match self {
151            Self::Off => "off",
152            Self::Warn => "warn",
153            Self::Redact => "redact",
154            Self::Block => "block",
155        }
156    }
157}
158
159const READ_MODES: &[&str] = &[
160    "auto",
161    "full",
162    "map",
163    "signatures",
164    "diff",
165    "task",
166    "reference",
167    "aggressive",
168    "entropy",
169];
170const DATA_CLASSES: &[&str] = &[
171    "secrets",
172    "pii",
173    "confidential",
174    "customer_data",
175    "source_code",
176];
177const CLAIM_ROLES: &[&str] = &["reader", "contributor", "maintainer", "admin", "owner"];
178/// `sharing.personal_workspace`, loosest first.
179pub const PERSONAL_WORKSPACE_MODES: &[&str] = &["allow", "forbid"];
180const CONSENT_ACTIONS: &[&str] = &[
181    "trust_boundary",
182    "permission_expansion",
183    "security_command",
184    "destructive_change",
185    "team_sharing",
186    "external_model",
187];
188
189/// `security` of an effective policy. Absent member = no level sets it.
190#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
191pub struct SecurityPolicyV1 {
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub excluded_paths: Option<Vec<String>>,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub block_secrets: Option<bool>,
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub pii: Option<PolicyActionV1>,
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub classification: Option<PolicyActionV1>,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub blocked_labels: Option<Vec<String>>,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub allowed_models: Option<Vec<Vec<String>>>,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub external_model_excluded_classes: Option<Vec<String>>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub confidential_out_of_sharing: Option<bool>,
208    /// ent2: upstream providers the proxy may forward to (`anthropic`,
209    /// `openai`, `bedrock`, `local`, …); `x-merge: intersect`.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub allowed_providers: Option<Vec<String>>,
212    /// ent2: agent model traffic must go through the LeanCTX proxy;
213    /// `x-merge: or`.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub require_proxy: Option<bool>,
216    /// ent2: path globs (`{project}` and `~` allowed) LeanCTX tools and
217    /// hooked native reads may read; `x-merge: intersect`.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub readable_roots: Option<Vec<String>>,
220    /// ent2: gitignore globs whose content is never read into context nor
221    /// sent to a model or Hive; `x-merge: union`.
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub never_send: Option<Vec<String>>,
224    /// Members this crate does not know (reported as unenforced).
225    #[serde(flatten)]
226    pub other: BTreeMap<String, Value>,
227}
228
229/// `context` of an effective policy.
230#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
231pub struct ContextPolicyV1 {
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub max_context_tokens: Option<u64>,
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub injection_filter: Option<PolicyActionV1>,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub read_mode: Option<String>,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub ranking_profile: Option<String>,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub allowed_tools: Option<Vec<String>>,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub denied_tools: Option<Vec<String>>,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub allowed_sources: Option<Vec<String>>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub max_writes_per_min: Option<u64>,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub max_cost_cents_per_person_per_day: Option<u64>,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub max_cost_cents_per_project_per_month: Option<u64>,
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub max_requests_per_minute_per_person: Option<u64>,
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub auto_extraction: Option<bool>,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub extraction_requires_source_anchor: Option<bool>,
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub max_source_age_days: Option<u64>,
260    #[serde(flatten)]
261    pub other: BTreeMap<String, Value>,
262}
263
264/// `sharing` of an effective policy (enforced by Hive, not the engine).
265#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
266pub struct SharingPolicyV1 {
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub allowed_vaults: Option<Vec<String>>,
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub cross_team_sharing: Option<bool>,
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub task_claim_min_role: Option<String>,
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub consent_required_for: Option<Vec<String>>,
275    /// `allow` | `forbid` (Hive C line): `forbid` keeps the team's project
276    /// knowledge out of Personal workspaces (Hive enforces W11; this engine
277    /// refuses a personal vault for a project it worked on in the team,
278    /// `core::hive::company_bound`).
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub personal_workspace: Option<String>,
281    #[serde(flatten)]
282    pub other: BTreeMap<String, Value>,
283}
284
285/// `agents` of an effective policy (ent2). Absent when no level sets one of
286/// its members, so a policy without them keeps its digest.
287#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
288pub struct AgentsPolicyV1 {
289    /// Agent ids allowed to run under LeanCTX (`claude-code`, `codex`,
290    /// `acp:<id>`, `*`); `x-merge: intersect`.
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub allowed: Option<Vec<String>>,
293    /// Only agents that ask for approval may run; `x-merge: or`.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub require_approvals: Option<bool>,
296    #[serde(flatten)]
297    pub other: BTreeMap<String, Value>,
298}
299
300/// The merged policy in normal form (schema `$defs/effective`).
301#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
302pub struct EffectivePolicyV1 {
303    pub schema: String,
304    pub security: SecurityPolicyV1,
305    pub context: ContextPolicyV1,
306    pub sharing: SharingPolicyV1,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub agents: Option<AgentsPolicyV1>,
309    /// Sections this crate does not know: covered by the signature, every
310    /// member reported as unenforced. Accepting them keeps a later section
311    /// from making this engine refuse the whole bundle.
312    #[serde(flatten)]
313    pub other: BTreeMap<String, Value>,
314}
315
316/// The effective policy of one vault with a vault-level document.
317#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
318#[serde(deny_unknown_fields)]
319pub struct VaultEffectivePolicyV1 {
320    pub vault_id: String,
321    pub effective: EffectivePolicyV1,
322}
323
324/// `policy` of a bundle: the default for every vault without an entry, and
325/// the per-vault entries ascending by `vault_id`.
326#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
327#[serde(deny_unknown_fields)]
328pub struct BundlePolicyV1 {
329    pub effective: EffectivePolicyV1,
330    pub vaults: Vec<VaultEffectivePolicyV1>,
331}
332
333impl BundlePolicyV1 {
334    /// The policy that applies to `vault_id` (`None`: no linked vault).
335    pub fn for_vault(&self, vault_id: Option<&str>) -> &EffectivePolicyV1 {
336        vault_id
337            .and_then(|id| self.vaults.iter().find(|v| v.vault_id == id))
338            .map_or(&self.effective, |v| &v.effective)
339    }
340}
341
342/// A document that went into the bundle.
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344#[serde(deny_unknown_fields)]
345pub struct PolicySourceV1 {
346    pub level: String,
347    pub scope: String,
348    pub version: u64,
349    pub digest: String,
350}
351
352/// `effective_policy` of `GET /v1/policy/bundle` (2.x).
353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
354pub struct EffectivePolicyBundleV1 {
355    pub schema: String,
356    pub org_id: String,
357    pub instance_id: String,
358    pub version: u64,
359    pub issued_at: UtcTimestamp,
360    pub not_after: UtcTimestamp,
361    pub sources: Vec<PolicySourceV1>,
362    pub policy: BundlePolicyV1,
363    pub digest: String,
364    pub key_id: String,
365    pub signature: String,
366}
367
368/// `POST /v1/policy/ack`.
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370#[serde(deny_unknown_fields)]
371pub struct PolicyAckRequestV1 {
372    pub version: u64,
373    pub digest: String,
374    #[serde(default, skip_serializing_if = "Vec::is_empty")]
375    pub unenforced: Vec<String>,
376    /// SERVER-B4: the engine received this bundle (`version`, `digest` as
377    /// received) but refused it; `unenforced` is then empty.
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub refused: Option<PolicyAckRefusalV1>,
380}
381
382/// Why an engine refused a received bundle (C0 `$defs/refusal_reason`).
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(rename_all = "snake_case")]
385pub enum PolicyRefusalReasonV1 {
386    /// Does not parse or validate.
387    InvalidBundle,
388    /// Issued for another org or engine.
389    WrongTarget,
390    /// No verified key set v3, or no valid policy key for `key_id`.
391    KeyUnavailable,
392    SignatureInvalid,
393    /// `digest` is not the policy's.
394    DigestMismatch,
395    /// An older version, or the same version with another digest.
396    Downgrade,
397    /// `not_after` already passed.
398    Expired,
399    /// Verified but could not be stored.
400    InstallFailed,
401}
402
403/// Longest `detail` of a refusal.
404pub const MAX_REFUSAL_DETAIL: usize = 300;
405
406/// `refused` of an ack (C0 `$defs/ack_refusal`): the bundle the engine
407/// keeps applying (`applied_*` both `null` when it holds none).
408#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
409#[serde(deny_unknown_fields)]
410pub struct PolicyAckRefusalV1 {
411    pub reason_code: PolicyRefusalReasonV1,
412    /// The engine's diagnostic line, no content: 1..=300 chars, no control
413    /// characters ([`refusal_detail`]).
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub detail: Option<String>,
416    pub applied_version: Option<u64>,
417    pub applied_digest: Option<String>,
418}
419
420/// A diagnostic line as `detail` takes it: control characters become
421/// spaces, every place on the machine (a path, a home folder, a file URL)
422/// becomes `<path>` (K2 M2: the data directory names the user), at most
423/// [`MAX_REFUSAL_DETAIL`] characters, `None` when empty.
424///
425/// A path may hold spaces (`/Users/John Smith/…`), so the whole run is
426/// scrubbed: from the first word that names a place up to and including
427/// the first word that ends with `:` (the `in <dir>: <reason>` shape), or
428/// to the end of the line when no word does.
429pub fn refusal_detail(line: &str) -> Option<String> {
430    let clean: String = line
431        .chars()
432        .map(|c| if c.is_control() { ' ' } else { c })
433        .collect();
434    let place = |word: &str| {
435        word.contains('/')
436            || word.contains('\\')
437            || word.starts_with('~')
438            || (word.len() > 2
439                && word.as_bytes()[1] == b':'
440                && word.as_bytes()[0].is_ascii_alphabetic())
441    };
442    let mut words: Vec<&str> = Vec::new();
443    let mut run = false;
444    for word in clean.split(' ') {
445        if !run && !place(word) {
446            words.push(word);
447            continue;
448        }
449        run = true;
450        if word.ends_with(':') {
451            words.push("<path>:");
452            run = false;
453        }
454    }
455    if run {
456        words.push("<path>");
457    }
458    let text: String = words.join(" ").chars().take(MAX_REFUSAL_DETAIL).collect();
459    let text = text.trim();
460    (!text.is_empty()).then(|| text.to_string())
461}
462
463impl PolicyAckRequestV1 {
464    /// The refusal report of a received bundle.
465    pub fn refusal(version: u64, digest: String, refused: PolicyAckRefusalV1) -> Self {
466        Self {
467            version,
468            digest,
469            unenforced: Vec::new(),
470            refused: Some(refused),
471        }
472    }
473}
474
475/// Answer of `POST /v1/policy/ack` to a refusal (C0
476/// `$defs/ack_refused_response`).
477#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
478pub struct PolicyAckRefusedResponseV1 {
479    pub schema: String,
480    pub version: u64,
481    pub digest: String,
482    /// Always `refused`.
483    pub state: String,
484    pub reason_code: PolicyRefusalReasonV1,
485    pub applied_version: Option<u64>,
486    pub applied_digest: Option<String>,
487    pub refused_at: UtcTimestamp,
488}
489
490impl PolicyAckRefusedResponseV1 {
491    /// The answer records exactly the refusal `request`.
492    pub fn validate_for(&self, request: &PolicyAckRequestV1) -> Result<(), ValidationError> {
493        require_schema(&self.schema, POLICY_ACK_SCHEMA)?;
494        let Some(refused) = &request.refused else {
495            return Err(ValidationError::new("not a refusal request"));
496        };
497        if self.state != "refused"
498            || self.version != request.version
499            || self.digest != request.digest
500            || self.reason_code != refused.reason_code
501            || self.applied_version != refused.applied_version
502            || self.applied_digest != refused.applied_digest
503        {
504            return Err(ValidationError::new(
505                "the refusal answer names another bundle or reason",
506            ));
507        }
508        Ok(())
509    }
510}
511
512/// State of an ack: `outdated` when a newer bundle was issued since.
513#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
514#[serde(rename_all = "snake_case")]
515pub enum PolicyAckStateV1 {
516    Acked,
517    Outdated,
518}
519
520/// Answer of `POST /v1/policy/ack`.
521#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
522pub struct PolicyAckResponseV1 {
523    pub schema: String,
524    pub version: u64,
525    pub digest: String,
526    pub state: PolicyAckStateV1,
527    pub acked_at: UtcTimestamp,
528}
529
530impl PolicyAckResponseV1 {
531    /// The answer acknowledges exactly `request`.
532    pub fn validate_for(&self, request: &PolicyAckRequestV1) -> Result<(), ValidationError> {
533        require_schema(&self.schema, POLICY_ACK_SCHEMA)?;
534        if self.version != request.version || self.digest != request.digest {
535            return Err(ValidationError::new(
536                "the ack answer names another policy version or digest",
537            ));
538        }
539        Ok(())
540    }
541}
542
543fn hex(bytes: &[u8]) -> String {
544    use std::fmt::Write as _;
545    bytes.iter().fold(String::new(), |mut out, b| {
546        let _ = write!(out, "{b:02x}");
547        out
548    })
549}
550
551/// `"sha256:" + hex(sha256(canonical_json(value)))`.
552pub fn policy_digest(value: &Value) -> Result<String, ValidationError> {
553    Ok(format!(
554        "sha256:{}",
555        hex(&Sha256::digest(canonical_json(value)?))
556    ))
557}
558
559/// The exact bytes the `policy` key signs: the raw bundle as received,
560/// without its `signature`.
561pub fn signing_input_raw(raw: &Value) -> Result<Vec<u8>, ValidationError> {
562    signing_input_without_signature(EFFECTIVE_POLICY_SIGNATURE_DOMAIN, raw)
563}
564
565fn is_lower_hex(value: &str, len: usize) -> bool {
566    value.len() == len
567        && value
568            .bytes()
569            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
570}
571
572fn validate_digest(value: &str, field: &str) -> Result<(), ValidationError> {
573    match value.strip_prefix("sha256:") {
574        Some(hex) if is_lower_hex(hex, 64) => Ok(()),
575        _ => Err(ValidationError::new(format!(
576            "{field} must be sha256: and 64 lowercase hex characters"
577        ))),
578    }
579}
580
581fn prefixed_hex32(value: &str, prefix: &str) -> bool {
582    value
583        .strip_prefix(prefix)
584        .is_some_and(|rest| is_lower_hex(rest, 32))
585}
586
587fn valid_name(value: &str) -> bool {
588    let bytes = value.as_bytes();
589    !bytes.is_empty()
590        && bytes.len() <= 64
591        && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit())
592        && bytes.iter().all(|b| {
593            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b':' | b'-')
594        })
595}
596
597/// 1..=`max` characters without C0/C1 control characters or DEL.
598fn valid_pattern(value: &str, max: usize) -> bool {
599    let count = value.chars().count();
600    (1..=max).contains(&count)
601        && !value
602            .chars()
603            .any(|c| matches!(c, '\u{0}'..='\u{1f}' | '\u{7f}'..='\u{9f}'))
604}
605
606/// A member or section name this crate does not know: 1..=64 of `a-z`, `_`.
607fn valid_member_name(name: &str) -> bool {
608    !name.is_empty()
609        && name.len() <= 64
610        && name.bytes().all(|b| b.is_ascii_lowercase() || b == b'_')
611}
612
613fn invalid(field: &str) -> ValidationError {
614    ValidationError::new(format!("invalid_policy: {field}"))
615}
616
617/// A set in normal form: at most [`MAX_SET`] items, strictly ascending
618/// (bytewise; sorted and without repeats), each one valid.
619fn check_set(
620    set: Option<&Vec<String>>,
621    field: &str,
622    item_ok: impl Fn(&str) -> bool,
623) -> Result<(), ValidationError> {
624    let Some(set) = set else { return Ok(()) };
625    if set.len() > MAX_SET
626        || !set.windows(2).all(|w| w[0] < w[1])
627        || !set.iter().all(|item| item_ok(item))
628    {
629        return Err(invalid(field));
630    }
631    Ok(())
632}
633
634fn check_range(value: Option<u64>, min: u64, max: u64, field: &str) -> Result<(), ValidationError> {
635    match value {
636        Some(v) if !(min..=max).contains(&v) => Err(invalid(field)),
637        _ => Ok(()),
638    }
639}
640
641fn check_enum(
642    value: Option<&String>,
643    allowed: &[&str],
644    field: &str,
645) -> Result<(), ValidationError> {
646    match value {
647        Some(v) if !allowed.contains(&v.as_str()) => Err(invalid(field)),
648        _ => Ok(()),
649    }
650}
651
652impl EffectivePolicyV1 {
653    /// Schema, value ranges and normal form of every known member.
654    pub fn validate(&self) -> Result<(), ValidationError> {
655        require_schema(&self.schema, POLICY_DOCUMENT_SCHEMA)?;
656        let s = &self.security;
657        check_set(s.excluded_paths.as_ref(), "security.excluded_paths", |p| {
658            valid_pattern(p, 256)
659        })?;
660        check_set(
661            s.blocked_labels.as_ref(),
662            "security.blocked_labels",
663            valid_name,
664        )?;
665        check_set(
666            s.external_model_excluded_classes.as_ref(),
667            "security.external_model_excluded_classes",
668            |c| DATA_CLASSES.contains(&c),
669        )?;
670        if let Some(groups) = &s.allowed_models {
671            let field = "security.allowed_models";
672            if groups.is_empty()
673                || groups.len() > MAX_MODEL_GROUPS
674                || !groups.windows(2).all(|w| w[0] < w[1])
675            {
676                return Err(invalid(field));
677            }
678            for group in groups {
679                if group.len() > MAX_MODEL_GROUP {
680                    return Err(invalid(field));
681                }
682                check_set(Some(group), field, |p| valid_pattern(p, 128))?;
683            }
684        }
685        check_set(
686            s.allowed_providers.as_ref(),
687            "security.allowed_providers",
688            valid_name,
689        )?;
690        check_set(s.readable_roots.as_ref(), "security.readable_roots", |p| {
691            valid_pattern(p, 256)
692        })?;
693        check_set(s.never_send.as_ref(), "security.never_send", |p| {
694            valid_pattern(p, 256)
695        })?;
696        if let Some(agents) = &self.agents {
697            check_set(agents.allowed.as_ref(), "agents.allowed", |a| {
698                a == "*" || valid_name(a)
699            })?;
700        }
701        let c = &self.context;
702        check_range(
703            c.max_context_tokens,
704            1,
705            10_000_000,
706            "context.max_context_tokens",
707        )?;
708        check_enum(c.read_mode.as_ref(), READ_MODES, "context.read_mode")?;
709        if c.ranking_profile.as_deref().is_some_and(|p| !valid_name(p)) {
710            return Err(invalid("context.ranking_profile"));
711        }
712        check_set(
713            c.allowed_tools.as_ref(),
714            "context.allowed_tools",
715            valid_name,
716        )?;
717        check_set(c.denied_tools.as_ref(), "context.denied_tools", valid_name)?;
718        check_set(
719            c.allowed_sources.as_ref(),
720            "context.allowed_sources",
721            valid_name,
722        )?;
723        check_range(
724            c.max_writes_per_min,
725            0,
726            10_000,
727            "context.max_writes_per_min",
728        )?;
729        check_range(
730            c.max_cost_cents_per_person_per_day,
731            0,
732            100_000_000,
733            "context.max_cost_cents_per_person_per_day",
734        )?;
735        check_range(
736            c.max_cost_cents_per_project_per_month,
737            0,
738            100_000_000,
739            "context.max_cost_cents_per_project_per_month",
740        )?;
741        check_range(
742            c.max_requests_per_minute_per_person,
743            1,
744            1_000_000,
745            "context.max_requests_per_minute_per_person",
746        )?;
747        check_range(
748            c.max_source_age_days,
749            1,
750            3650,
751            "context.max_source_age_days",
752        )?;
753        let h = &self.sharing;
754        check_set(h.allowed_vaults.as_ref(), "sharing.allowed_vaults", |v| {
755            is_lower_hex(v, 32)
756        })?;
757        check_enum(
758            h.task_claim_min_role.as_ref(),
759            CLAIM_ROLES,
760            "sharing.task_claim_min_role",
761        )?;
762        check_set(
763            h.consent_required_for.as_ref(),
764            "sharing.consent_required_for",
765            |a| CONSENT_ACTIONS.contains(&a),
766        )?;
767        check_enum(
768            h.personal_workspace.as_ref(),
769            PERSONAL_WORKSPACE_MODES,
770            "sharing.personal_workspace",
771        )?;
772        let empty = BTreeMap::new();
773        let agents_other = self.agents.as_ref().map_or(&empty, |a| &a.other);
774        for (section, other) in [
775            ("security", &s.other),
776            ("context", &c.other),
777            ("sharing", &h.other),
778            ("agents", agents_other),
779        ] {
780            if let Some(name) = other.keys().find(|k| !valid_member_name(k)) {
781                return Err(invalid(&format!("{section}.{name}")));
782            }
783        }
784        // An unknown section is an object of members like a known one.
785        for (section, value) in &self.other {
786            let ok = valid_member_name(section)
787                && value
788                    .as_object()
789                    .is_some_and(|members| members.keys().all(|k| valid_member_name(k)));
790            if !ok {
791                return Err(invalid(section));
792            }
793        }
794        Ok(())
795    }
796
797    /// Every member this policy sets, as `section.member`, sorted.
798    pub fn members(&self) -> BTreeSet<String> {
799        let section = |name: &str, value: Value| -> Vec<String> {
800            value
801                .as_object()
802                .map(|fields| fields.keys().map(|k| format!("{name}.{k}")).collect())
803                .unwrap_or_default()
804        };
805        let mut out = BTreeSet::new();
806        out.extend(section(
807            "security",
808            serde_json::to_value(&self.security).unwrap_or(Value::Null),
809        ));
810        out.extend(section(
811            "context",
812            serde_json::to_value(&self.context).unwrap_or(Value::Null),
813        ));
814        out.extend(section(
815            "sharing",
816            serde_json::to_value(&self.sharing).unwrap_or(Value::Null),
817        ));
818        if let Some(agents) = &self.agents {
819            out.extend(section(
820                "agents",
821                serde_json::to_value(agents).unwrap_or(Value::Null),
822            ));
823        }
824        for (name, value) in &self.other {
825            out.extend(section(name, value.clone()));
826        }
827        out
828    }
829}
830
831/// Whether the engine enforces `member` (`section.member`).
832pub fn is_engine_member(member: &str) -> bool {
833    ENGINE_MEMBERS.iter().any(|(m, _)| *m == member)
834}
835
836/// Whether Hive enforces `member` itself (`x-enforcement: server`).
837pub fn is_server_member(member: &str) -> bool {
838    SERVER_MEMBERS.contains(&member)
839}
840
841/// Every member `policy` sets anywhere (default or any vault), sorted.
842fn set_members(policy: &BundlePolicyV1) -> BTreeSet<String> {
843    let mut all = policy.effective.members();
844    for vault in &policy.vaults {
845        all.extend(vault.effective.members());
846    }
847    all
848}
849
850/// The members `policy` sets that neither this engine nor Hive enforces
851/// (advisory and unknown members), sorted bytewise: what an ack reports.
852pub fn unenforced_members(policy: &BundlePolicyV1) -> Vec<String> {
853    set_members(policy)
854        .into_iter()
855        .filter(|m| !is_engine_member(m) && !is_server_member(m))
856        .collect()
857}
858
859/// The members `policy` sets that Hive enforces itself, sorted bytewise.
860pub fn server_members(policy: &BundlePolicyV1) -> Vec<String> {
861    set_members(policy)
862        .into_iter()
863        .filter(|m| is_server_member(m))
864        .collect()
865}
866
867impl EffectivePolicyBundleV1 {
868    /// Everything but the signature and the policy digest, which need the
869    /// raw bytes ([`signing_input_raw`], [`policy_digest`]).
870    pub fn validate(&self) -> Result<(), ValidationError> {
871        require_schema(&self.schema, EFFECTIVE_POLICY_SCHEMA)?;
872        if !prefixed_hex32(&self.org_id, "org_") {
873            return Err(ValidationError::new("org_id must be org_ and 32 hex"));
874        }
875        if !prefixed_hex32(&self.instance_id, "inst_") {
876            return Err(ValidationError::new("instance_id must be inst_ and 32 hex"));
877        }
878        if self.version == 0 {
879            return Err(ValidationError::new("version starts at 1"));
880        }
881        if self.not_after <= self.issued_at {
882            return Err(ValidationError::new("not_after must follow issued_at"));
883        }
884        if self.sources.len() > MAX_POLICY_SOURCES {
885            return Err(ValidationError::new("too many policy sources"));
886        }
887        for source in &self.sources {
888            // `orgn_` names an organisation's own org-level document (a
889            // workspace linked to an organisation, C0 `$defs/source`): only
890            // at level `org`.
891            let scope_ok = prefixed_hex32(&source.scope, "org_")
892                || (source.level == "org" && prefixed_hex32(&source.scope, "orgn_"))
893                || prefixed_hex32(&source.scope, "inst_")
894                || is_lower_hex(&source.scope, 32);
895            if !matches!(
896                source.level.as_str(),
897                "org" | "workspace" | "vault" | "instance"
898            ) || !scope_ok
899                || source.version == 0
900            {
901                return Err(ValidationError::new("a policy source is malformed"));
902            }
903            validate_digest(&source.digest, "sources.digest")?;
904        }
905        if self.policy.vaults.len() > MAX_POLICY_VAULTS
906            || !self
907                .policy
908                .vaults
909                .windows(2)
910                .all(|w| w[0].vault_id < w[1].vault_id)
911        {
912            return Err(ValidationError::new(
913                "policy.vaults must be at most 32, ascending by vault_id",
914            ));
915        }
916        self.policy.effective.validate()?;
917        for vault in &self.policy.vaults {
918            if !is_lower_hex(&vault.vault_id, 32) {
919                return Err(ValidationError::new("policy.vaults.vault_id is malformed"));
920            }
921            vault.effective.validate()?;
922        }
923        validate_digest(&self.digest, "digest")?;
924        if !is_lower_hex(&self.key_id, 16) {
925            return Err(ValidationError::new("key_id must be 16 lowercase hex"));
926        }
927        if !is_lower_hex(&self.signature, 128) {
928            return Err(ValidationError::new("signature must be 128 lowercase hex"));
929        }
930        Ok(())
931    }
932
933    /// The ack for this bundle as applied. The wire carries at most
934    /// [`MAX_UNENFORCED`] members (the schema bound, more than the contract
935    /// defines); the full list stays in the engine's own status.
936    pub fn ack(&self) -> PolicyAckRequestV1 {
937        let mut unenforced = unenforced_members(&self.policy);
938        unenforced.truncate(MAX_UNENFORCED);
939        PolicyAckRequestV1 {
940            version: self.version,
941            digest: self.digest.clone(),
942            unenforced,
943            refused: None,
944        }
945    }
946}
947
948#[cfg(test)]
949#[path = "effective_policy_tests.rs"]
950mod tests;