Skip to main content

lean_ctx_protocol/hive/
policy.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Org policy and filter packs.
4//!
5//! `GET /v1/policy/bundle?since=<policy_version>` (`If-None-Match`) →
6//! [`PolicyBundleV1`] or 304.
7//!
8//! `org_policy` is an `OrgPolicyArtifactV2`
9//! `{statement, public_key, signature, pack_toml, unpin?}`:
10//! - `statement` is `OrgPolicyStatementV2` (`leanctx.org-policy/v2`), signed
11//!   as `"leanctx.org-policy.v2\n" || serde_json::to_vec(statement)` in field
12//!   declaration order (schema, organization_id, key_id, policy_version,
13//!   issued_at, not_before, not_after, grace_seconds, pack_digest,
14//!   required_filter_packs), by a key the lease pins in
15//!   `policy_signer_keys`;
16//! - `required_filter_packs` (C 1.8) lists the filter packs the policy puts
17//!   in force as [`RequiredFilterPackV1`], ascending by `pack_id`; it is left
18//!   out when empty, so a statement without it keeps its 1.7 bytes. Every
19//!   listed pack must be in the same bundle with that version and statement
20//!   digest ([`unmet_required_packs`]): Hive signs filter packs with its
21//!   own key, and only this list binds them to the organization's signature.
22//!   Signers sort the list before signing. Hive includes the field only when
23//!   the org requires packs; engines below 1.8 refuse such a statement as a
24//!   whole (unknown field), so their last good bundle stays. A received
25//!   statement with an explicit `"required_filter_packs": []` verifies
26//!   against the bytes without the field: same meaning, other wire bytes,
27//!   harmless. Packs the list does not name may still arrive; a filter pack
28//!   can only tighten (`redact`, `block`), never loosen, so an extra pack
29//!   is harmless;
30//! - `pack_toml` is the pack itself (UTF-8 TOML, at most 1 MiB) and
31//!   `statement.pack_digest` = `"sha256:" + hex(sha256(pack_toml))`;
32//! - encodings follow `team_context`: `public_key`/`signature` base64url
33//!   without padding, `key_id` = `"key:ed25519-"` + the first 32 hex chars of
34//!   sha256(public key), timestamps RFC 3339 `Z`.
35//!
36//! Top-level `unpin` is the signed unpin envelope
37//! (`leanctx.org-policy-unpin.v2\n`) when no policy artifact is current.
38//! Both stay raw JSON here and are verified by the engine's org-policy
39//! verifier; a failed verification never replaces the last good bundle.
40//! Fixture `policy_bundle.signed.response` and `vectors.org_policy` pin the
41//! exact bytes.
42//!
43//! Filter packs carry their statement as base64 bytes, signed by a Hive
44//! key under [`FILTER_PACK_SIGNATURE_DOMAIN`].
45
46use serde::{Deserialize, Serialize};
47use serde_json::Value;
48
49use super::{
50    FILTER_PACK_SIGNATURE_DOMAIN, require_schema, validate_b64_bounded, validate_b64_len,
51    validate_hex64, validate_id,
52};
53use crate::ValidationError;
54
55pub const POLICY_BUNDLE_SCHEMA: &str = "leanctx.hive.policy-bundle/v1";
56/// Largest policy-bundle response (a full 1 MiB pack plus envelopes).
57pub const MAX_POLICY_BUNDLE_BYTES: usize = 2 * 1024 * 1024;
58/// Largest encoded filter-pack statement.
59pub const MAX_FILTER_PACK_STATEMENT_B64: usize = 2 * 1024 * 1024;
60pub const MAX_FILTER_PACKS: usize = 64;
61
62/// A statement and its signature.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct SignedStatementV1 {
65    /// Statement bytes, base64.
66    pub statement_b64: String,
67    /// Key id within the Hive key set.
68    pub kid: String,
69    /// Ed25519 signature (base64) over `domain || statement bytes`.
70    pub signature: String,
71}
72
73impl SignedStatementV1 {
74    pub fn validate(&self, max_encoded: usize) -> Result<(), ValidationError> {
75        validate_b64_bounded(&self.statement_b64, max_encoded, "statement_b64")?;
76        validate_id(&self.kid, "kid")?;
77        validate_b64_len(&self.signature, 64, "signature")
78    }
79}
80
81/// `domain || statement`: what a filter-pack signature covers.
82pub fn filter_pack_signing_input(statement: &[u8]) -> Vec<u8> {
83    let mut bytes = FILTER_PACK_SIGNATURE_DOMAIN.as_bytes().to_vec();
84    bytes.extend_from_slice(statement);
85    bytes
86}
87
88pub const FILTER_PACK_SCHEMA: &str = "leanctx.filter-pack/v1";
89pub const MAX_FILTER_PATTERNS: usize = 64;
90/// Longest pattern id and label (`hive:<id>` must fit a 64-byte rule label).
91pub const MAX_FILTER_LABEL_BYTES: usize = 48;
92pub const MAX_FILTER_PATTERN_CHARS: usize = 1000;
93/// Compiled-size limit of one pattern (`regex` size_limit and dfa_size_limit).
94pub const MAX_FILTER_PATTERN_COMPILED_BYTES: usize = 1024 * 1024;
95
96/// What a matching pattern does.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum FilterActionV1 {
100    /// Replace each match with `[REDACTED:<label or id>]`.
101    Redact,
102    /// Refuse the content: screening drops it, egress refuses it.
103    Block,
104}
105
106/// One pattern of a filter pack. Unknown fields or values refuse the pack.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct FilterPatternV1 {
110    pub id: String,
111    /// Always `regex` in v1.
112    pub kind: String,
113    /// `regex` crate syntax.
114    pub pattern: String,
115    pub action: FilterActionV1,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub label: Option<String>,
118}
119
120/// The signed statement of a filter pack (the bytes behind `statement_b64`).
121/// Unknown top-level fields are tolerated; patterns are strict.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct FilterPackV1 {
124    pub schema: String,
125    pub pack_id: String,
126    pub version: u64,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub title: Option<String>,
129    pub patterns: Vec<FilterPatternV1>,
130}
131
132fn valid_slug(value: &str, max: usize) -> bool {
133    !value.is_empty()
134        && value.len() <= max
135        && value.as_bytes()[0].is_ascii_alphanumeric()
136        && value.bytes().all(|b| {
137            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
138        })
139}
140
141impl FilterPatternV1 {
142    /// Compile the pattern within the v1 size limits.
143    pub fn compile(&self) -> Result<regex::Regex, ValidationError> {
144        regex::RegexBuilder::new(&self.pattern)
145            .size_limit(MAX_FILTER_PATTERN_COMPILED_BYTES)
146            .dfa_size_limit(MAX_FILTER_PATTERN_COMPILED_BYTES)
147            .build()
148            .map_err(|_| ValidationError::new(format!("pattern {} does not compile", self.id)))
149    }
150
151    /// The rule label the engine uses (`hive:<label or id>`); a redacted
152    /// match reads `[REDACTED:hive:<label or id>]`.
153    pub fn rule_label(&self) -> String {
154        format!("hive:{}", self.label.as_deref().unwrap_or(&self.id))
155    }
156}
157
158impl FilterPackV1 {
159    pub fn validate(&self) -> Result<(), ValidationError> {
160        require_schema(&self.schema, FILTER_PACK_SCHEMA)?;
161        if !valid_slug(&self.pack_id, 128) || self.version == 0 {
162            return Err(ValidationError::new("pack_id or version is malformed"));
163        }
164        if let Some(title) = &self.title {
165            super::validate_text(title, 200, "title", false)?;
166        }
167        if self.patterns.is_empty() || self.patterns.len() > MAX_FILTER_PATTERNS {
168            return Err(ValidationError::new("a filter pack holds 1-64 patterns"));
169        }
170        let mut ids = std::collections::BTreeSet::new();
171        for pattern in &self.patterns {
172            if !valid_slug(&pattern.id, MAX_FILTER_LABEL_BYTES) || !ids.insert(pattern.id.as_str())
173            {
174                return Err(ValidationError::new(format!(
175                    "pattern id {:?} is malformed or repeats",
176                    pattern.id
177                )));
178            }
179            if pattern.kind != "regex" {
180                return Err(ValidationError::new(format!(
181                    "pattern {}: unknown kind",
182                    pattern.id
183                )));
184            }
185            if pattern.pattern.is_empty()
186                || pattern.pattern.chars().count() > MAX_FILTER_PATTERN_CHARS
187            {
188                return Err(ValidationError::new(format!(
189                    "pattern {}: 1-1000 characters",
190                    pattern.id
191                )));
192            }
193            if let Some(label) = &pattern.label
194                && (label.len() > MAX_FILTER_LABEL_BYTES
195                    || !valid_slug(label, MAX_FILTER_LABEL_BYTES))
196            {
197                return Err(ValidationError::new(format!(
198                    "pattern {}: label is malformed",
199                    pattern.id
200                )));
201            }
202            pattern.compile()?;
203        }
204        Ok(())
205    }
206}
207
208/// One filter pack the org enabled.
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210pub struct FilterPackRefV1 {
211    pub pack_id: String,
212    /// Monotonic per pack.
213    pub version: u64,
214    /// Lowercase hex SHA-256 of the statement bytes.
215    pub sha256: String,
216    pub envelope: SignedStatementV1,
217}
218
219/// A filter pack an org policy requires: field order is the signed byte order.
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(deny_unknown_fields)]
222pub struct RequiredFilterPackV1 {
223    pub pack_id: String,
224    pub version: u64,
225    /// Lowercase hex SHA-256 of the pack's statement bytes, as
226    /// [`FilterPackRefV1::sha256`].
227    pub sha256: String,
228}
229
230/// At most [`MAX_FILTER_PACKS`], ids strictly ascending (one encoding per
231/// set), versions from 1, digests lowercase hex.
232pub fn validate_required_filter_packs(
233    packs: &[RequiredFilterPackV1],
234) -> Result<(), ValidationError> {
235    if packs.len() > MAX_FILTER_PACKS {
236        return Err(ValidationError::new("too many required filter packs"));
237    }
238    for (i, pack) in packs.iter().enumerate() {
239        if !valid_slug(&pack.pack_id, 128) || pack.version == 0 {
240            return Err(ValidationError::new(
241                "required filter pack id or version is malformed",
242            ));
243        }
244        validate_hex64(&pack.sha256, "sha256")?;
245        if i > 0 && packs[i - 1].pack_id >= pack.pack_id {
246            return Err(ValidationError::new(
247                "required filter packs must be in ascending pack_id order without repeats",
248            ));
249        }
250    }
251    Ok(())
252}
253
254/// Why `available` does not satisfy `required`, one line per unmet pack
255/// (empty when every required pack is there with its version and digest).
256pub fn unmet_required_packs(
257    required: &[RequiredFilterPackV1],
258    available: &[FilterPackRefV1],
259) -> Vec<String> {
260    required
261        .iter()
262        .filter_map(
263            |r| match available.iter().find(|p| p.pack_id == r.pack_id) {
264                None => Some(format!("required filter pack {} is missing", r.pack_id)),
265                Some(p) if p.version != r.version => Some(format!(
266                    "required filter pack {} is version {}, the policy requires {}",
267                    r.pack_id, p.version, r.version
268                )),
269                Some(p) if p.sha256 != r.sha256 => Some(format!(
270                    "required filter pack {} version {} is not the one the policy signed",
271                    r.pack_id, r.version
272                )),
273                Some(_) => None,
274            },
275        )
276        .collect()
277}
278
279/// Response of `GET /v1/policy/bundle`.
280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
281pub struct PolicyBundleV1 {
282    pub schema: String,
283    /// Highest version in the org's policy sequence (0 = never published).
284    pub policy_version: u64,
285    pub org_policy: Option<Value>,
286    pub unpin: Option<Value>,
287    pub filter_packs: Vec<FilterPackRefV1>,
288}
289
290impl PolicyBundleV1 {
291    pub fn validate(&self) -> Result<(), ValidationError> {
292        require_schema(&self.schema, POLICY_BUNDLE_SCHEMA)?;
293        for envelope in [&self.org_policy, &self.unpin].into_iter().flatten() {
294            if !envelope.is_object() {
295                return Err(ValidationError::new("policy envelopes must be objects"));
296            }
297        }
298        if self.filter_packs.len() > MAX_FILTER_PACKS {
299            return Err(ValidationError::new("too many filter packs"));
300        }
301        let mut seen = std::collections::BTreeSet::new();
302        for pack in &self.filter_packs {
303            validate_id(&pack.pack_id, "pack_id")?;
304            validate_hex64(&pack.sha256, "sha256")?;
305            pack.envelope.validate(MAX_FILTER_PACK_STATEMENT_B64)?;
306            if !seen.insert(pack.pack_id.as_str()) {
307                return Err(ValidationError::new("pack_id repeats"));
308            }
309        }
310        Ok(())
311    }
312}