use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::core::input_filters::FilterAction;
use crate::core::policy::{
BudgetRules, EgressRules, FilterRules, ResolvedPolicy, RoutingPolicyRules,
};
use lean_ctx_protocol::hive::effective_policy::{BundlePolicyV1, EffectivePolicyV1};
pub const FLOOR_SCHEMA: &str = "leanctx.hive.policy-floor/v1";
pub const DIR: &str = "effective";
pub const FLOOR_FILE: &str = "floor.json";
pub const REQUIRED_FILE: &str = "required.json";
pub const FLOOR_NAME: &str = "hive-policy";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Floor {
pub schema: String,
pub version: u64,
pub digest: String,
pub not_after: String,
pub policy: BundlePolicyV1,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Required {
pub version: u64,
}
pub fn floor_dir() -> Option<PathBuf> {
let data = crate::core::paths::data_dir().ok()?;
Some(data.join("hive").join("policy").join(DIR))
}
pub fn is_policy_path(target: &Path) -> bool {
let Ok(data) = crate::core::paths::data_dir() else {
return false;
};
let dir = data.join("hive").join("policy");
let canonical =
crate::core::pathutil::canonicalize_secure(&dir).unwrap_or_else(|_| dir.clone());
target.starts_with(&dir) || target.starts_with(&canonical)
}
pub fn floor_dir_in(hive_data: &Path) -> PathBuf {
hive_data.join("policy").join(DIR)
}
pub fn check(floor: Option<&str>, required: Option<&str>) -> Result<Option<Floor>, String> {
let required = required
.map(serde_json::from_str::<Required>)
.transpose()
.map_err(|_| "the Hive policy requirement is damaged")?;
let floor = match floor {
Some(text) => Some(
serde_json::from_str::<Floor>(text)
.ok()
.filter(|f| f.schema == FLOOR_SCHEMA)
.filter(|f| {
f.policy.effective.validate().is_ok()
&& f.policy
.vaults
.iter()
.all(|v| v.effective.validate().is_ok())
})
.ok_or("the installed Hive policy is damaged")?,
),
None => None,
};
match (floor, required) {
(None, Some(_)) => Err("the Hive policy in force is missing".into()),
(Some(floor), Some(required)) if floor.version < required.version => Err(format!(
"the installed Hive policy v{} is older than v{} in force",
floor.version, required.version
)),
(floor, _) => Ok(floor),
}
}
pub fn read(dir: &Path) -> Result<(Option<String>, Option<String>), String> {
let read = |name: &str| match std::fs::read_to_string(dir.join(name)) {
Ok(text) => Ok(Some(text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(_) => Err(format!("the Hive policy file {name} is unreadable")),
};
Ok((read(FLOOR_FILE)?, read(REQUIRED_FILE)?))
}
pub fn vault_for(floor_text: &str, hive_data: &Path, project_root: &Path) -> Option<String> {
let floor: Floor = serde_json::from_str(floor_text).ok()?;
if floor.policy.vaults.is_empty() {
return None;
}
let map = crate::core::vault_map::VaultMap::load(hive_data);
let record = map.for_root(&project_root.to_string_lossy())?;
floor
.policy
.vaults
.iter()
.any(|v| v.vault_id == record.vault_id)
.then(|| record.vault_id.clone())
}
pub fn resolve(
floor: Option<&str>,
required: Option<&str>,
vault: Option<&str>,
) -> Result<Option<ResolvedPolicy>, String> {
Ok(check(floor, required)?.and_then(|floor| {
let policy = floor.policy.for_vault(vault);
(!policy.members().is_empty()).then(|| to_resolved(policy, floor.version))
}))
}
fn cap_u32(value: Option<u64>) -> Option<u32> {
value.map(|v| u32::try_from(v).unwrap_or(u32::MAX))
}
#[allow(clippy::cast_precision_loss)] fn usd(cents: Option<u64>) -> Option<f64> {
cents.map(|c| c as f64 / 100.0)
}
pub fn to_resolved(policy: &EffectivePolicyV1, version: u64) -> ResolvedPolicy {
let s = &policy.security;
let c = &policy.context;
let blocked_labels = s.blocked_labels.as_ref().map_or_else(Vec::new, |labels| {
let mut all: Vec<String> = crate::core::input_filters::classification::DEFAULT_LABELS
.iter()
.map(|l| (*l).to_string())
.collect();
all.extend(labels.iter().cloned());
all
});
ResolvedPolicy {
name: FLOOR_NAME.into(),
version: version.to_string(),
description: format!("Hive effective policy v{version}"),
chain: Vec::new(),
default_read_mode: c.read_mode.clone(),
allow_tools: c.allowed_tools.clone(),
deny_tools: c.denied_tools.clone().unwrap_or_default(),
max_context_tokens: cap_u32(c.max_context_tokens),
audit_retention_days: None,
redaction: BTreeMap::new(),
filters: FilterRules {
pii: s.pii.map(|a| a.as_str().to_string()),
classification: s.classification.map(|a| a.as_str().to_string()),
injection: c.injection_filter.map(|a| a.as_str().to_string()),
blocked_labels,
..FilterRules::default()
},
egress: EgressRules {
forbidden_patterns: Vec::new(),
block_secrets: s.block_secrets,
max_writes_per_min: cap_u32(c.max_writes_per_min),
},
routing: RoutingPolicyRules {
allowed_models: Vec::new(),
model_ceiling_groups: s.allowed_models.clone().unwrap_or_default(),
forbid_downgrade_for: Vec::new(),
},
budgets: BudgetRules {
max_cost_usd_per_person_per_day: usd(c.max_cost_cents_per_person_per_day),
max_cost_usd_per_project_per_month: usd(c.max_cost_cents_per_project_per_month),
max_requests_per_minute_per_person: cap_u32(c.max_requests_per_minute_per_person),
},
}
}
pub fn apply(floor: &ResolvedPolicy, effective: Option<&ResolvedPolicy>) -> ResolvedPolicy {
crate::core::policy::floor::merge_floor(floor, effective)
}
pub fn local_overrides(hive_data: &Path, project_root: &Path) -> Vec<String> {
let Ok((Some(text), required)) = read(&floor_dir_in(hive_data)) else {
return Vec::new();
};
let Ok(Some(floor)) = check(Some(&text), required.as_deref()) else {
return Vec::new();
};
let pack = project_root.join(".lean-ctx").join("policy.toml");
let Some(local) = crate::core::policy::parse_file(&pack)
.ok()
.and_then(|p| crate::core::policy::resolve(&p).ok())
else {
return Vec::new();
};
let vault = vault_for(&text, hive_data, project_root);
let hive = to_resolved(floor.policy.for_vault(vault.as_deref()), floor.version);
loosened_by_floor(&hive, &local)
}
fn rank(action: Option<&String>) -> Option<u8> {
action
.and_then(|a| FilterAction::parse(a))
.map(FilterAction::rank)
}
pub fn loosened_by_floor(floor: &ResolvedPolicy, local: &ResolvedPolicy) -> Vec<String> {
let mut out = Vec::new();
let mut cap = |field: &str, local: Option<f64>, hive: Option<f64>| {
if let (Some(l), Some(a)) = (local, hive)
&& l > a
{
out.push(format!("{field}: local {l}, Hive {a} applies"));
}
};
let f = |v: Option<u32>| v.map(f64::from);
cap(
"context.max_context_tokens",
f(local.max_context_tokens),
f(floor.max_context_tokens),
);
cap(
"egress.max_writes_per_min",
f(local.egress.max_writes_per_min),
f(floor.egress.max_writes_per_min),
);
cap(
"budgets.max_cost_usd_per_person_per_day",
local.budgets.max_cost_usd_per_person_per_day,
floor.budgets.max_cost_usd_per_person_per_day,
);
cap(
"budgets.max_cost_usd_per_project_per_month",
local.budgets.max_cost_usd_per_project_per_month,
floor.budgets.max_cost_usd_per_project_per_month,
);
cap(
"budgets.max_requests_per_minute_per_person",
f(local.budgets.max_requests_per_minute_per_person),
f(floor.budgets.max_requests_per_minute_per_person),
);
for (field, l, a) in [
("filters.pii", &local.filters.pii, &floor.filters.pii),
(
"filters.classification",
&local.filters.classification,
&floor.filters.classification,
),
(
"filters.injection",
&local.filters.injection,
&floor.filters.injection,
),
] {
if let (Some(lr), Some(ar)) = (rank(l.as_ref()), rank(a.as_ref()))
&& lr < ar
{
out.push(format!(
"{field}: local {}, Hive {} applies",
l.as_deref().unwrap_or_default(),
a.as_deref().unwrap_or_default()
));
}
}
if local.egress.block_secrets == Some(false) && floor.egress.block_secrets == Some(true) {
out.push("egress.block_secrets: local false, Hive true applies".into());
}
if let (Some(l), Some(a)) = (&local.default_read_mode, &floor.default_read_mode)
&& l != a
{
out.push(format!(
"context.default_read_mode: local {l}, Hive {a} applies"
));
}
if let Some(local_allow) = &local.allow_tools {
let extra: Vec<&str> = local_allow
.iter()
.filter(|t| {
floor.deny_tools.contains(t)
|| floor
.allow_tools
.as_ref()
.is_some_and(|allow| !allow.contains(t))
})
.map(String::as_str)
.collect();
if !extra.is_empty() {
out.push(format!(
"context.allow_tools: local allows {}, Hive does not",
extra.join(", ")
));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn empty_floor() -> serde_json::Value {
json!({
"schema": FLOOR_SCHEMA, "version": 1,
"digest": format!("sha256:{}", "a".repeat(64)),
"not_after": "2099-01-01T00:00:00Z",
"policy": {"effective": {
"schema": "leanctx.hive.policy/v1",
"security": {}, "context": {}, "sharing": {}
}, "vaults": []}
})
}
#[test]
fn empty_floor_is_neutral_only_after_required_policy_validation() {
let floor = empty_floor().to_string();
let required = r#"{"version":1}"#;
assert!(
resolve(Some(&floor), Some(required), None)
.unwrap()
.is_none()
);
assert!(resolve(None, Some(required), None).is_err());
assert!(resolve(Some("damaged"), Some(required), None).is_err());
assert!(resolve(Some(&floor), Some(r#"{"version":2}"#), None).is_err());
}
#[test]
fn selected_vault_and_unmapped_members_never_count_as_empty() {
for (section, member, value) in [
("security", "pii", json!("redact")),
("context", "allowed_tools", json!([])),
("context", "allowed_sources", json!([])),
("security", "future_control", json!(true)),
] {
let mut floor = empty_floor();
let mut vault_policy = floor["policy"]["effective"].clone();
vault_policy[section][member] = value;
floor["policy"]["vaults"] = json!([{
"vault_id": "restricted", "effective": vault_policy
}]);
let floor = floor.to_string();
assert!(resolve(Some(&floor), None, None).unwrap().is_none());
assert!(
resolve(Some(&floor), None, Some("restricted"))
.unwrap()
.is_some(),
"{section}.{member} must retain the selected vault's policy"
);
}
}
}