Skip to main content

lean_ctx_protocol/hive/
policy.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Filter packs.
4//!
5//! `GET /v1/policy/bundle` (`If-None-Match`) → [`PolicyBundleV1`] or 304.
6//!
7//! Filter packs carry their statement as base64 bytes, signed by a Hive
8//! key under [`FILTER_PACK_SIGNATURE_DOMAIN`].
9
10use serde::{Deserialize, Serialize};
11
12use super::{
13    FILTER_PACK_SIGNATURE_DOMAIN, require_schema, validate_b64_bounded, validate_b64_len,
14    validate_hex64, validate_id,
15};
16use crate::ValidationError;
17
18pub const POLICY_BUNDLE_SCHEMA: &str = "leanctx.hive.policy-bundle/v1";
19/// Largest policy-bundle response (a full 1 MiB pack plus envelopes).
20pub const MAX_POLICY_BUNDLE_BYTES: usize = 2 * 1024 * 1024;
21/// Largest encoded filter-pack statement.
22pub const MAX_FILTER_PACK_STATEMENT_B64: usize = 2 * 1024 * 1024;
23pub const MAX_FILTER_PACKS: usize = 64;
24
25/// A statement and its signature.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct SignedStatementV1 {
28    /// Statement bytes, base64.
29    pub statement_b64: String,
30    /// Key id within the Hive key set.
31    pub kid: String,
32    /// Ed25519 signature (base64) over `domain || statement bytes`.
33    pub signature: String,
34}
35
36impl SignedStatementV1 {
37    pub fn validate(&self, max_encoded: usize) -> Result<(), ValidationError> {
38        validate_b64_bounded(&self.statement_b64, max_encoded, "statement_b64")?;
39        validate_id(&self.kid, "kid")?;
40        validate_b64_len(&self.signature, 64, "signature")
41    }
42}
43
44/// `domain || statement`: what a filter-pack signature covers.
45pub fn filter_pack_signing_input(statement: &[u8]) -> Vec<u8> {
46    let mut bytes = FILTER_PACK_SIGNATURE_DOMAIN.as_bytes().to_vec();
47    bytes.extend_from_slice(statement);
48    bytes
49}
50
51pub const FILTER_PACK_SCHEMA: &str = "leanctx.filter-pack/v1";
52pub const MAX_FILTER_PATTERNS: usize = 64;
53/// Longest pattern id and label (`hive:<id>` must fit a 64-byte rule label).
54pub const MAX_FILTER_LABEL_BYTES: usize = 48;
55pub const MAX_FILTER_PATTERN_CHARS: usize = 1000;
56/// Compiled-size limit of one pattern (`regex` size_limit and dfa_size_limit).
57pub const MAX_FILTER_PATTERN_COMPILED_BYTES: usize = 1024 * 1024;
58
59/// What a matching pattern does.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum FilterActionV1 {
63    /// Replace each match with `[REDACTED:<label or id>]`.
64    Redact,
65    /// Refuse the content: screening drops it, egress refuses it.
66    Block,
67}
68
69/// One pattern of a filter pack. Unknown fields or values refuse the pack.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct FilterPatternV1 {
73    pub id: String,
74    /// Always `regex` in v1.
75    pub kind: String,
76    /// `regex` crate syntax.
77    pub pattern: String,
78    pub action: FilterActionV1,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub label: Option<String>,
81}
82
83/// The signed statement of a filter pack (the bytes behind `statement_b64`).
84/// Unknown top-level fields are tolerated; patterns are strict.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct FilterPackV1 {
87    pub schema: String,
88    pub pack_id: String,
89    pub version: u64,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub title: Option<String>,
92    pub patterns: Vec<FilterPatternV1>,
93}
94
95fn valid_slug(value: &str, max: usize) -> bool {
96    !value.is_empty()
97        && value.len() <= max
98        && value.as_bytes()[0].is_ascii_alphanumeric()
99        && value.bytes().all(|b| {
100            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
101        })
102}
103
104impl FilterPatternV1 {
105    /// Compile the pattern within the v1 size limits.
106    pub fn compile(&self) -> Result<regex::Regex, ValidationError> {
107        regex::RegexBuilder::new(&self.pattern)
108            .size_limit(MAX_FILTER_PATTERN_COMPILED_BYTES)
109            .dfa_size_limit(MAX_FILTER_PATTERN_COMPILED_BYTES)
110            .build()
111            .map_err(|_| ValidationError::new(format!("pattern {} does not compile", self.id)))
112    }
113
114    /// The rule label the engine uses (`hive:<label or id>`); a redacted
115    /// match reads `[REDACTED:hive:<label or id>]`.
116    pub fn rule_label(&self) -> String {
117        format!("hive:{}", self.label.as_deref().unwrap_or(&self.id))
118    }
119}
120
121impl FilterPackV1 {
122    pub fn validate(&self) -> Result<(), ValidationError> {
123        require_schema(&self.schema, FILTER_PACK_SCHEMA)?;
124        if !valid_slug(&self.pack_id, 128) || self.version == 0 {
125            return Err(ValidationError::new("pack_id or version is malformed"));
126        }
127        if let Some(title) = &self.title {
128            super::validate_text(title, 200, "title", false)?;
129        }
130        if self.patterns.is_empty() || self.patterns.len() > MAX_FILTER_PATTERNS {
131            return Err(ValidationError::new("a filter pack holds 1-64 patterns"));
132        }
133        let mut ids = std::collections::BTreeSet::new();
134        for pattern in &self.patterns {
135            if !valid_slug(&pattern.id, MAX_FILTER_LABEL_BYTES) || !ids.insert(pattern.id.as_str())
136            {
137                return Err(ValidationError::new(format!(
138                    "pattern id {:?} is malformed or repeats",
139                    pattern.id
140                )));
141            }
142            if pattern.kind != "regex" {
143                return Err(ValidationError::new(format!(
144                    "pattern {}: unknown kind",
145                    pattern.id
146                )));
147            }
148            if pattern.pattern.is_empty()
149                || pattern.pattern.chars().count() > MAX_FILTER_PATTERN_CHARS
150            {
151                return Err(ValidationError::new(format!(
152                    "pattern {}: 1-1000 characters",
153                    pattern.id
154                )));
155            }
156            if let Some(label) = &pattern.label
157                && (label.len() > MAX_FILTER_LABEL_BYTES
158                    || !valid_slug(label, MAX_FILTER_LABEL_BYTES))
159            {
160                return Err(ValidationError::new(format!(
161                    "pattern {}: label is malformed",
162                    pattern.id
163                )));
164            }
165            pattern.compile()?;
166        }
167        Ok(())
168    }
169}
170
171/// One filter pack the org enabled.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct FilterPackRefV1 {
174    pub pack_id: String,
175    /// Monotonic per pack.
176    pub version: u64,
177    /// Lowercase hex SHA-256 of the statement bytes.
178    pub sha256: String,
179    pub envelope: SignedStatementV1,
180}
181
182/// A filter pack a TOML org policy statement required: field order is the
183/// signed byte order. Hive-Protocol 2.1 sends no org policy; the engine's
184/// policy runtime still reads it from a policy it kept from before.
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct RequiredFilterPackV1 {
188    pub pack_id: String,
189    pub version: u64,
190    /// Lowercase hex SHA-256 of the pack's statement bytes, as
191    /// [`FilterPackRefV1::sha256`].
192    pub sha256: String,
193}
194
195/// At most [`MAX_FILTER_PACKS`], ids strictly ascending (one encoding per
196/// set), versions from 1, digests lowercase hex. The signed org policy v2
197/// contract (`ee/contracts/team-company`) validates its required packs with it.
198pub fn validate_required_filter_packs(
199    packs: &[RequiredFilterPackV1],
200) -> Result<(), ValidationError> {
201    if packs.len() > MAX_FILTER_PACKS {
202        return Err(ValidationError::new("too many required filter packs"));
203    }
204    for (i, pack) in packs.iter().enumerate() {
205        if !valid_slug(&pack.pack_id, 128) || pack.version == 0 {
206            return Err(ValidationError::new(
207                "required filter pack id or version is malformed",
208            ));
209        }
210        validate_hex64(&pack.sha256, "sha256")?;
211        if i > 0 && packs[i - 1].pack_id >= pack.pack_id {
212            return Err(ValidationError::new(
213                "required filter packs must be in ascending pack_id order without repeats",
214            ));
215        }
216    }
217    Ok(())
218}
219
220/// Response of `GET /v1/policy/bundle`.
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222pub struct PolicyBundleV1 {
223    pub schema: String,
224    pub filter_packs: Vec<FilterPackRefV1>,
225}
226
227impl PolicyBundleV1 {
228    pub fn validate(&self) -> Result<(), ValidationError> {
229        require_schema(&self.schema, POLICY_BUNDLE_SCHEMA)?;
230        if self.filter_packs.len() > MAX_FILTER_PACKS {
231            return Err(ValidationError::new("too many filter packs"));
232        }
233        let mut seen = std::collections::BTreeSet::new();
234        for pack in &self.filter_packs {
235            validate_id(&pack.pack_id, "pack_id")?;
236            validate_hex64(&pack.sha256, "sha256")?;
237            pack.envelope.validate(MAX_FILTER_PACK_STATEMENT_B64)?;
238            if !seen.insert(pack.pack_id.as_str()) {
239                return Err(ValidationError::new("pack_id repeats"));
240            }
241        }
242        Ok(())
243    }
244}