Skip to main content

lean_ctx/core/policy/
runtime.rs

1//! Runtime view of the active context policy pack (GL #673 / #489 enforcement).
2//!
3//! [`active`] refreshes bounded policy and organization trust bytes on every
4//! admission. Identical snapshots reuse prepared rules; changed or unreadable
5//! inputs never reuse a stale authorization. One bounded worker serializes file
6//! resolution and compilation without accumulating timed-out reader threads.
7//!
8//! **Opt-in & backward-compatible:** with no project pack present, [`active`]
9//! returns `None` and nothing is gated — existing behavior is preserved
10//! exactly. An invalid local pack activates a deny-all policy so enforcement
11//! never fails open.
12//!
13//! **Local-Free Invariant:** enforcement derived from this view only ever
14//! constrains the *agent* pipeline; it never gates a human's own local reads.
15
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, Ordering};
19#[cfg(any(test, feature = "test-support"))]
20use std::sync::{OnceLock, RwLock};
21
22use regex::Regex;
23
24use super::ResolvedPolicy;
25#[cfg(test)]
26use super::{parse_file, resolve};
27use crate::core::input_filters::{FilterAction, FilterConfig};
28
29mod refresh;
30/// Process-scoped pin set by explicit protected launchers; absent in Community.
31pub const REQUIRED_POLICY_ROOT_ENV: &str = "LEAN_CTX_REQUIRED_POLICY_ROOT";
32pub const REQUIRED_POLICY_DIGEST_ENV: &str = "LEAN_CTX_REQUIRED_POLICY_DIGEST";
33pub use refresh::{protected_authority_paths, protected_policy_digest};
34#[cfg(any(test, feature = "test-support"))]
35pub mod held_horizon_support;
36#[cfg(test)]
37pub(crate) mod held_horizon_tests;
38#[cfg(test)]
39mod refresh_tests;
40
41tokio::task_local! {
42    /// Per-request project authority; never mutate process CWD for MCP roots.
43    pub static REQUEST_PROJECT: std::cell::RefCell<Option<PathBuf>>;
44    /// Immutable policy view for one synchronous source-admission operation.
45    pub(crate) static SOURCE_VIEW: std::cell::RefCell<Option<SourceView>>;
46}
47
48#[derive(Clone)]
49pub struct SourceView {
50    project: PathBuf,
51    policy: Option<Arc<ActivePolicy>>,
52    fingerprint: blake3::Hash,
53    role: crate::core::roles::Role,
54    role_fingerprint: blake3::Hash,
55    /// Set when the pinned policy rests on a verifier's decision.
56    ///
57    /// Two different bounds, deliberately: the request deadline bounded the
58    /// decision itself — asking, recording, handing out — and ended with the
59    /// refresh that produced this view. The operation running on the view is
60    /// bounded by the signed horizon instead, checked on a fresh clock at
61    /// every use of the held policy, at every nested admission and last,
62    /// after all closing waits, before anything is published. A lifted
63    /// requirement has no horizon: it is durable marker state, and the view
64    /// stays bound to it through the fingerprint.
65    lifetime: Option<super::org::Lifetime>,
66    /// What the verifier's answer stated, from the same verification the
67    /// pinned policy rests on. Never read again for this view.
68    observation: Option<super::org::Observation>,
69    valid: Arc<AtomicBool>,
70    live: Arc<AtomicBool>,
71}
72
73impl SourceView {
74    /// Whether the signed horizon still holds; a view past it is invalid for
75    /// good, like one whose authority changed.
76    fn within_horizon(&self) -> bool {
77        let holds = self
78            .lifetime
79            .is_none_or(|lifetime| lifetime.check_horizon().is_ok());
80        if !holds {
81            self.valid.store(false, Ordering::Release);
82        }
83        holds
84    }
85
86    /// Invalidate the view for its owner — but only while it is still live.
87    /// A straggler that merely finds the view ended (a timed-out worker whose
88    /// result its caller already discarded) must not poison the owner's
89    /// closing check: that turned complete, unchanged `ctx_compose` results
90    /// into "source authority changed" refusals (~3 % of calls, focus
91    /// analysis 2026-10-15). Inconsistencies seen during the view's life
92    /// still invalidate it.
93    fn invalidate_if_live(&self) {
94        if self.live.load(Ordering::Acquire) {
95            self.valid.store(false, Ordering::Release);
96        }
97    }
98}
99
100struct SourceViewLifetime(Arc<AtomicBool>);
101
102/// The stored form of a [`PublicationAuthority`]: project root, policy and
103/// role fingerprints and the lifetime of the view it was captured from.
104/// Content-free.
105#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
106#[serde(deny_unknown_fields)]
107pub struct StoredPublicationAuthority {
108    project: PathBuf,
109    fingerprint: [u8; 32],
110    role_fingerprint: [u8; 32],
111    lifetime: Option<StoredLifetime>,
112}
113
114/// The stored lifetime of a view: its signed horizon, `None` for a view bound
115/// to a lifted requirement, which has none.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
117#[serde(deny_unknown_fields)]
118pub struct StoredLifetime {
119    horizon: Option<chrono::DateTime<chrono::Utc>>,
120}
121
122/// Revalidate buffered CLI data at its output and persistence boundaries.
123/// This does not lock policy files against a concurrent administrator write.
124#[derive(Debug)]
125pub struct PublicationAuthority {
126    project: PathBuf,
127    fingerprint: blake3::Hash,
128    role_fingerprint: blake3::Hash,
129    /// The horizon of the view the buffered data was produced under.
130    lifetime: Option<super::org::Lifetime>,
131    valid: Arc<AtomicBool>,
132}
133
134impl PublicationAuthority {
135    pub fn capture() -> Result<Self, String> {
136        let view = current_source_view().ok_or("source authority unavailable")?;
137        if !view.within_horizon() {
138            return Err("source authority unavailable".into());
139        }
140        Ok(Self {
141            project: view.project,
142            fingerprint: view.fingerprint,
143            role_fingerprint: view.role_fingerprint,
144            lifetime: view.lifetime,
145            valid: view.valid,
146        })
147    }
148
149    /// The content-free form a holder that outlives this process keeps (the
150    /// proxy's register of provider-sealed items, #2177). `None` once the
151    /// view this authority was captured from was invalidated: such an
152    /// authority never verifies again, so nothing of it is kept. The flag is
153    /// read at this moment; what can still invalidate the view afterwards is
154    /// its signed horizon, which the stored form carries and checks.
155    pub fn stored(&self) -> Option<StoredPublicationAuthority> {
156        self.valid
157            .load(Ordering::Acquire)
158            .then(|| StoredPublicationAuthority {
159                project: self.project.clone(),
160                fingerprint: *self.fingerprint.as_bytes(),
161                role_fingerprint: *self.role_fingerprint.as_bytes(),
162                lifetime: self.lifetime.map(|lifetime| StoredLifetime {
163                    horizon: lifetime.horizon(),
164                }),
165            })
166    }
167
168    /// An authority from its stored form. It proves nothing by itself:
169    /// [`Self::verify`] re-verifies the project policy and role afresh and
170    /// checks the signed horizon, exactly as for an authority captured in
171    /// this process — only the in-process invalidation of the original view
172    /// is not carried over (an invalidated authority is never stored).
173    #[must_use]
174    pub fn restore(stored: StoredPublicationAuthority) -> Self {
175        Self {
176            project: stored.project,
177            fingerprint: blake3::Hash::from_bytes(stored.fingerprint),
178            role_fingerprint: blake3::Hash::from_bytes(stored.role_fingerprint),
179            lifetime: stored
180                .lifetime
181                .map(|lifetime| super::org::Lifetime::restored(lifetime.horizon)),
182            valid: Arc::new(AtomicBool::new(true)),
183        }
184    }
185
186    pub fn verify(&self) -> Result<(), String> {
187        if !self.valid.load(Ordering::Acquire)
188            || REQUEST_PROJECT
189                .try_with(|slot| {
190                    slot.borrow()
191                        .as_ref()
192                        .is_some_and(|root| root != &self.project)
193                })
194                .unwrap_or(false)
195        {
196            return Err("source authority changed before publication".into());
197        }
198        REQUEST_PROJECT.sync_scope(std::cell::RefCell::new(Some(self.project.clone())), || {
199            SOURCE_VIEW.sync_scope(std::cell::RefCell::new(None), || {
200                let latest = refresh::current_verified(self.project.clone())?;
201                let role = role_fingerprint(&crate::core::roles::active_role())?;
202                if latest.fingerprint != self.fingerprint || role != self.role_fingerprint {
203                    return Err("source authority changed before publication".into());
204                }
205                // Last, after the waits above: the buffered data was made
206                // under a floor signed up to a horizon, and is not published
207                // past it even though the authority verifies afresh.
208                if let Some(lifetime) = &self.lifetime {
209                    lifetime.check_horizon()?;
210                }
211                Ok(())
212            })
213        })
214    }
215
216    /// Fingerprint used to bind derived decisions to this exact project and
217    /// policy snapshot without exposing any policy contents.
218    pub fn fingerprint_bytes(&self) -> [u8; 32] {
219        *self.fingerprint.as_bytes()
220    }
221
222    /// Role authorization is a separate part of source authority and must be
223    /// bound independently into persisted provenance metadata.
224    #[cfg(any(test, feature = "http-server"))]
225    pub fn role_fingerprint_bytes(&self) -> [u8; 32] {
226        *self.role_fingerprint.as_bytes()
227    }
228}
229
230impl Drop for SourceViewLifetime {
231    fn drop(&mut self) {
232        self.0.store(false, Ordering::Release);
233    }
234}
235
236fn current_source_view() -> Option<SourceView> {
237    SOURCE_VIEW
238        .try_with(|slot| slot.borrow().clone())
239        .ok()
240        .flatten()
241}
242
243/// Capture the pinned view when propagating a synchronous worker scope.
244pub fn inherited_source_view() -> Option<SourceView> {
245    current_source_view()
246}
247
248/// Resolve the project root for a detached worker without retaining request-
249/// lifetime policy or capture state. The worker must reopen a fresh source view
250/// from this root when it stores output.
251pub fn detached_project_root(fallback: &Path) -> Result<PathBuf, String> {
252    let request_project = REQUEST_PROJECT
253        .try_with(|slot| slot.borrow().clone())
254        .ok()
255        .flatten();
256    if let Some(view) = current_source_view() {
257        if !view.live.load(Ordering::Acquire)
258            || !view.valid.load(Ordering::Acquire)
259            || request_project.as_ref() != Some(&view.project)
260        {
261            view.invalidate_if_live();
262            return Err("source policy view is no longer active".into());
263        }
264        return Ok(view.project);
265    }
266    Ok(request_project.unwrap_or_else(|| fallback.to_path_buf()))
267}
268
269/// Restore a previously captured source view in a detached worker.
270pub(crate) fn with_inherited_source_view<T>(
271    view: Option<SourceView>,
272    operation: impl FnOnce() -> T,
273) -> T {
274    match view {
275        Some(view) => SOURCE_VIEW.sync_scope(std::cell::RefCell::new(Some(view)), operation),
276        None => operation(),
277    }
278}
279
280/// The role paired with the active source view. A closed or rebound inherited
281/// view returns a deny-all role instead of exposing its stale privileges.
282pub(crate) fn pinned_role() -> Option<crate::core::roles::Role> {
283    current_source_view().map(|view| {
284        let project = REQUEST_PROJECT
285            .try_with(|slot| slot.borrow().clone())
286            .ok()
287            .flatten();
288        if !view.live.load(Ordering::Acquire)
289            || !view.valid.load(Ordering::Acquire)
290            || project.as_ref() != Some(&view.project)
291        {
292            view.invalidate_if_live();
293            crate::core::roles::closed_source_view_role()
294        } else {
295            view.role
296        }
297    })
298}
299
300/// Run `operation` as part of a request for `project`.
301pub fn in_project<T>(project: impl Into<PathBuf>, operation: impl FnOnce() -> T) -> T {
302    REQUEST_PROJECT.sync_scope(std::cell::RefCell::new(Some(project.into())), operation)
303}
304
305/// Start `operation` on its own thread as part of a request for `project`.
306/// A spawned thread inherits no task-local: without this it reads the
307/// configuration and admission state of the process's own project (AP04-G3).
308pub fn spawn_for_project<T: Send + 'static>(
309    project: impl Into<PathBuf>,
310    operation: impl FnOnce() -> T + Send + 'static,
311) -> std::thread::JoinHandle<T> {
312    let project = project.into();
313    std::thread::spawn(move || in_project(project, operation))
314}
315
316pub fn bind_request_project(project: Option<&str>) {
317    if current_source_view().is_some() {
318        return;
319    }
320    let _ = REQUEST_PROJECT.try_with(|slot| {
321        *slot.borrow_mut() = project.map(PathBuf::from);
322    });
323}
324
325/// The one spelling of a request project: resolved, and on Windows without
326/// the verbatim `\\?\` prefix (`pathutil::canonicalize_secure`), as the proxy
327/// (`request_project`) and the policy refresh resolve it. A source view
328/// pinned to another spelling of the same root no longer matched the request
329/// project and fell to deny-all on every governed Windows request.
330fn canonical_project_root(project: &Path) -> Result<PathBuf, String> {
331    let project = crate::core::pathutil::canonicalize_secure(project)
332        .map_err(|_| "policy project root unavailable")?;
333    if !project.is_dir() {
334        return Err("policy project root unavailable".into());
335    }
336    Ok(project)
337}
338
339/// Whether the bound request project (if any) is `root`, a root already in
340/// the one spelling of `canonical_project_root`. The bound side is resolved
341/// the same way first: a caller may have bound another spelling of the same
342/// directory (on Windows the verbatim `\\?\` form of `std::fs::canonicalize`).
343/// No bound request project: `true`, the operation's own root governs.
344pub(crate) fn request_project_is(root: &Path) -> bool {
345    match REQUEST_PROJECT.try_with(|slot| slot.borrow().clone()) {
346        Ok(Some(bound)) => canonical_project_root(&bound).is_ok_and(|bound| bound == root),
347        _ => true,
348    }
349}
350
351/// Run a source operation with an explicit fallback project root. An existing
352/// request project remains authoritative; the fallback only fills an unbound
353/// request scope.
354pub fn with_project_source_view<T>(
355    project_root: &str,
356    operation: impl FnOnce() -> T,
357) -> Result<T, String> {
358    let project = REQUEST_PROJECT
359        .try_with(|slot| slot.borrow().clone())
360        .ok()
361        .flatten()
362        .or_else(|| current_source_view().map(|view| view.project))
363        .unwrap_or_else(|| PathBuf::from(project_root));
364    let project = canonical_project_root(&project)?;
365    REQUEST_PROJECT.sync_scope(std::cell::RefCell::new(Some(project)), || {
366        with_source_view(operation)
367    })
368}
369
370/// Run an egress preparation for one explicitly selected project. Unlike the
371/// source-view fallback helper, this rejects a conflicting trusted request root
372/// and revalidates authority after the operation before its result is published.
373pub fn with_explicit_project_source_view<T>(
374    project_root: &Path,
375    operation: impl FnOnce(&PublicationAuthority) -> T,
376) -> Result<T, String> {
377    let project = canonical_project_root(project_root)?;
378
379    let bound = REQUEST_PROJECT
380        .try_with(|slot| slot.borrow().clone())
381        .ok()
382        .flatten();
383    if let Some(bound) = bound {
384        let bound = crate::core::pathutil::canonicalize_secure(&bound)
385            .map_err(|_| "request project authority unavailable")?;
386        if bound != project {
387            return Err("request project authority does not match egress root".into());
388        }
389    }
390    if let Some(view) = current_source_view()
391        && (!view.live.load(Ordering::Acquire)
392            || !view.valid.load(Ordering::Acquire)
393            || view.project != project)
394    {
395        view.invalidate_if_live();
396        return Err("source policy view does not match egress root".into());
397    }
398
399    REQUEST_PROJECT.sync_scope(std::cell::RefCell::new(Some(project)), || {
400        with_source_view(|| {
401            let authority = PublicationAuthority::capture()?;
402            let result = operation(&authority);
403            authority.verify()?;
404            Ok(result)
405        })?
406    })
407}
408
409/// Run a synchronous source operation against one freshly verified policy
410/// snapshot. Publish the returned value only if the same authority still holds.
411/// Nested calls reuse the parent snapshot; they never refresh into a weaker view.
412pub fn with_source_view<T>(operation: impl FnOnce() -> T) -> Result<T, String> {
413    with_source_view_when(|_, _| true, operation)?
414        .ok_or_else(|| "source policy view is no longer active".into())
415}
416
417/// [`with_source_view`], entered only when the verification the view is
418/// pinned to established something to act under: a policy, or an observed
419/// organization decision (a signed unpin leaves no policy and is one all the
420/// same). `Ok(None)`: it established neither, no view was opened and
421/// `operation` did not run.
422///
423/// The decision whether to enter and the view are made of ONE verification:
424/// there is no probe first and a second resolution after, so what the caller
425/// branched on is what its view holds. Everything else is `with_source_view`
426/// — the nested-view rules, the closing recheck and the horizon. Inside,
427/// [`is_active`] and `active_observed` read the held view.
428#[cfg(feature = "http-server")]
429pub fn with_governed_source_view<T>(operation: impl FnOnce() -> T) -> Result<Option<T>, String> {
430    with_source_view_when(|policy, observed| policy || observed, operation)
431}
432
433/// `enter(policy present, organization decision observed)` is asked once, of
434/// the view that would be used; `false` leaves without running `operation`.
435fn with_source_view_when<T>(
436    enter: impl FnOnce(bool, bool) -> bool,
437    operation: impl FnOnce() -> T,
438) -> Result<Option<T>, String> {
439    if let Some(view) = current_source_view() {
440        let project = REQUEST_PROJECT
441            .try_with(|slot| slot.borrow().clone())
442            .ok()
443            .flatten()
444            .ok_or("source policy view is no longer active")?;
445        let project = canonical_project_root(&project)
446            .map_err(|_| "source policy view is no longer active")?;
447        if !view.live.load(Ordering::Acquire)
448            || !view.valid.load(Ordering::Acquire)
449            || project != view.project
450            || !view.within_horizon()
451        {
452            view.invalidate_if_live();
453            return Err("source policy view is no longer active".into());
454        }
455        if !enter(view.policy.is_some(), view.observation.is_some()) {
456            return Ok(None);
457        }
458        return REQUEST_PROJECT
459            .sync_scope(std::cell::RefCell::new(Some(view.project.clone())), || {
460                Ok(Some(operation()))
461            });
462    }
463
464    // A request bound to no project gets no source view: its project is not
465    // invented from the process's working directory (AP01 §3). Only work
466    // outside any request (the CLI) acts for the directory it runs in.
467    let project = match REQUEST_PROJECT.try_with(|slot| slot.borrow().clone()) {
468        Ok(project) => project.ok_or("project_unresolved")?,
469        Err(_) => std::env::current_dir().map_err(|_| "policy project root unavailable")?,
470    };
471    let project = canonical_project_root(&project)?;
472    REQUEST_PROJECT.sync_scope(std::cell::RefCell::new(Some(project.clone())), || {
473        let verified = refresh::current_verified_settled(&project)?;
474        let lifetime = verified.lifetime;
475        let observation = verified.observation;
476        let role = crate::core::roles::active_role();
477        let pinned_role_fingerprint = role_fingerprint(&role)?;
478        let view = SourceView {
479            project: project.clone(),
480            policy: source_view_policy(verified.policy),
481            fingerprint: verified.fingerprint,
482            role,
483            role_fingerprint: pinned_role_fingerprint,
484            lifetime,
485            observation,
486            valid: Arc::new(AtomicBool::new(true)),
487            live: Arc::new(AtomicBool::new(true)),
488        };
489
490        // The reads since the verification took time: the operation is not
491        // entered on a floor whose horizon has already passed.
492        if !view.within_horizon() {
493            return Err("source policy view is no longer active".into());
494        }
495        // Asked of this very verification, before anything runs on it.
496        if !enter(view.policy.is_some(), view.observation.is_some()) {
497            return Ok(None);
498        }
499        let view_lifetime = SourceViewLifetime(view.live.clone());
500        let value = SOURCE_VIEW.sync_scope(std::cell::RefCell::new(Some(view.clone())), operation);
501        drop(view_lifetime);
502
503        // SOURCE_VIEW has ended, while REQUEST_PROJECT still identifies the
504        // same request root; re-resolve the complete role before publishing.
505        let project_unchanged = REQUEST_PROJECT
506            .try_with(|slot| slot.borrow().as_ref() == Some(&view.project))
507            .unwrap_or(false);
508        let latest_role = crate::core::roles::active_role();
509        let latest_role_fingerprint = role_fingerprint(&latest_role)?;
510        let latest = refresh::current_verified_settled(&project)?;
511        if !project_unchanged
512            || latest.fingerprint != view.fingerprint
513            || latest_role_fingerprint != view.role_fingerprint
514            || !view.valid.load(Ordering::Acquire)
515        {
516            return Err("source policy view changed during operation".into());
517        }
518        // Last, after every closing wait: the operation ran on a floor signed
519        // up to a horizon. If that passed in the meantime its result is not
520        // published, whatever the fresh verification above said.
521        if !view.within_horizon() {
522            return Err("source policy view changed during operation".into());
523        }
524        Ok(Some(value))
525    })
526}
527
528pub(crate) fn role_fingerprint(role: &crate::core::roles::Role) -> Result<blake3::Hash, String> {
529    serde_json::to_vec(role)
530        .map(|bytes| blake3::hash(&bytes))
531        .map_err(|_| "source role view unavailable".into())
532}
533
534#[cfg(any(test, feature = "test-support"))]
535fn source_view_policy(fresh: Option<Arc<ActivePolicy>>) -> Option<Arc<ActivePolicy>> {
536    let state = test_override()
537        .read()
538        .unwrap_or_else(std::sync::PoisonError::into_inner);
539    match &*state {
540        OverrideState::Live => fresh,
541        OverrideState::Fixed(policy) => policy.clone(),
542    }
543}
544
545#[cfg(not(any(test, feature = "test-support")))]
546fn source_view_policy(fresh: Option<Arc<ActivePolicy>>) -> Option<Arc<ActivePolicy>> {
547    fresh
548}
549
550/// Project-local pack location, relative to the working directory (matches the
551/// `lean-ctx policy` CLI's `PROJECT_PACK_PATH`).
552const PROJECT_PACK_PATH: &str = ".lean-ctx/policy.toml";
553
554/// A resolved policy plus its precompiled redaction regexes — the cached,
555/// hot-path-ready form.
556pub struct ActivePolicy {
557    pub resolved: ResolvedPolicy,
558    /// `(label, compiled regex)` — labels are the pack's `[redaction]` keys.
559    /// Invalid/over-budget compilation makes the whole policy fail closed.
560    pub redaction: Vec<(String, Regex)>,
561    pub(crate) blocked_patterns: Vec<(String, Regex)>,
562    /// How many of `redaction` + `blocked_patterns` a Hive filter pack
563    /// added; they have their own budget next to the pack's.
564    pub(crate) hive_rules: usize,
565    pub content_valid: bool,
566    /// Compiled inbound content filters (GL #675), built once from the pack's
567    /// `[filters]` section.
568    pub filters: FilterConfig,
569    /// Compiled egress/output DLP config (GL #676), built once from the pack's
570    /// `[egress]` section.
571    pub egress: crate::core::egress::EgressConfig,
572    /// The signed instant this policy's organization floor runs out at, set
573    /// before the policy is shared and never changed. `None` for every policy
574    /// that rests on no verifier decision: local, version one, or a lifted
575    /// requirement. The fields above describe the policy; they authorize
576    /// nothing once this instant has passed — see [`Self::within_horizon`].
577    horizon: Option<chrono::DateTime<chrono::Utc>>,
578    /// Set the first time the horizon is observed to have passed, and never
579    /// cleared. It lives in the policy itself, so every holder of the same
580    /// shared policy sees it: once expired, a clock set back revives nothing.
581    expired: AtomicBool,
582}
583
584impl ActivePolicy {
585    /// Whether this policy may still be acted on: read on the real clock at
586    /// every use, so a policy that is kept — in a view, a snapshot or a
587    /// caller's variable — ends with its signed horizon and not with the
588    /// request that obtained it. Exclusive, like the verifier's own check.
589    /// An expiry once observed is final for this policy.
590    #[must_use]
591    pub fn within_horizon(&self) -> bool {
592        if self.expired.load(Ordering::Acquire) {
593            return false;
594        }
595        let holds = self
596            .horizon
597            .is_none_or(|horizon| chrono::Utc::now() < horizon);
598        if !holds {
599            self.expired.store(true, Ordering::Release);
600        }
601        holds
602    }
603
604    /// This policy, ending at `horizon`. Only for a policy not yet shared.
605    fn until(mut self, horizon: Option<chrono::DateTime<chrono::Utc>>) -> Self {
606        self.horizon = horizon;
607        self
608    }
609
610    pub fn from_resolved(resolved: ResolvedPolicy) -> Self {
611        Self::from_resolved_with_hive(
612            resolved,
613            &crate::core::policy::hive_filters::FilterRules::default(),
614        )
615    }
616
617    /// As [`Self::from_resolved`], with the rules of the installed Hive
618    /// filter packs added under their own budget. A Hive rule over its
619    /// bounds fails the policy closed like a pack rule; installation
620    /// refuses such sets, so this is defensive.
621    pub(crate) fn from_resolved_with_hive(
622        mut resolved: ResolvedPolicy,
623        hive: &crate::core::policy::hive_filters::FilterRules,
624    ) -> Self {
625        use super::content::{MAX_REDACTION_RULES, MAX_RULE_LABEL_BYTES, MAX_RULE_PATTERN_BYTES};
626        let hive_rules = hive.redaction.len() + hive.blocked.len();
627        let hive_bounded = hive_rules <= crate::core::policy::hive_filters::HIVE_MAX_RULES
628            && hive
629                .redaction
630                .iter()
631                .chain(&hive.blocked)
632                .all(|(label, pattern)| {
633                    label.starts_with(super::HIVE_LABEL_PREFIX)
634                        && label.len() <= MAX_RULE_LABEL_BYTES
635                        && !label.chars().any(char::is_control)
636                        && pattern.len() <= MAX_RULE_PATTERN_BYTES
637                });
638        let bounded = hive_bounded
639            && resolved.redaction.len() + resolved.filters.blocked_patterns.len()
640                <= MAX_REDACTION_RULES
641            && resolved.egress.forbidden_patterns.len() <= MAX_REDACTION_RULES
642            && resolved
643                .egress
644                .forbidden_patterns
645                .iter()
646                .all(|pattern| pattern.len() <= MAX_RULE_PATTERN_BYTES)
647            && resolved
648                .redaction
649                .iter()
650                .chain(&resolved.filters.blocked_patterns)
651                .all(|(label, pattern)| {
652                    !label.is_empty()
653                        && label.len() <= MAX_RULE_LABEL_BYTES
654                        && !label.chars().any(char::is_control)
655                        && pattern.len() <= MAX_RULE_PATTERN_BYTES
656                });
657        // Pack labels never start with the Hive prefix (pack validation), so
658        // a Hive rule neither replaces nor is replaced by a pack rule.
659        resolved.redaction.extend(hive.redaction.iter().cloned());
660        resolved
661            .filters
662            .blocked_patterns
663            .extend(hive.blocked.iter().cloned());
664        let compiled = bounded.then(|| {
665            resolved
666                .redaction
667                .iter()
668                .map(|(label, pattern)| {
669                    regex::RegexBuilder::new(pattern)
670                        .size_limit(1024 * 1024)
671                        .dfa_size_limit(1024 * 1024)
672                        .build()
673                        .map(|re| (label.clone(), re))
674                })
675                .collect::<Result<Vec<_>, _>>()
676        });
677        let content_valid = matches!(&compiled, Some(Ok(_)));
678        let redaction = compiled.and_then(Result::ok).unwrap_or_default();
679        let compiled_blocks = bounded.then(|| {
680            resolved
681                .filters
682                .blocked_patterns
683                .iter()
684                .map(|(label, pattern)| {
685                    regex::RegexBuilder::new(pattern)
686                        .size_limit(1024 * 1024)
687                        .dfa_size_limit(1024 * 1024)
688                        .build()
689                        .map(|re| (label.clone(), re))
690                })
691                .collect::<Result<Vec<_>, _>>()
692        });
693        let content_valid = content_valid && matches!(&compiled_blocks, Some(Ok(_)));
694        let blocked_patterns = compiled_blocks.and_then(Result::ok).unwrap_or_default();
695        let filters = FilterConfig::new(
696            filter_action(resolved.filters.pii.as_ref()),
697            filter_action(resolved.filters.classification.as_ref()),
698            filter_action(resolved.filters.injection.as_ref()),
699            &resolved.filters.blocked_labels,
700        );
701        let egress = crate::core::egress::EgressConfig::new(
702            &resolved.egress.forbidden_patterns,
703            resolved.egress.block_secrets.unwrap_or(false),
704            resolved.egress.max_writes_per_min,
705        );
706        Self {
707            resolved,
708            redaction,
709            blocked_patterns,
710            hive_rules,
711            content_valid,
712            filters,
713            egress,
714            horizon: None,
715            expired: AtomicBool::new(false),
716        }
717    }
718
719    /// Block every tool after a local policy pack fails to load.
720    pub(crate) fn deny_all() -> Self {
721        let mut active = Self::from_resolved(ResolvedPolicy {
722            name: "invalid-local-policy".into(),
723            version: "0.0.0".into(),
724            description: "deny all because the local policy pack is invalid".into(),
725            chain: Vec::new(),
726            default_read_mode: None,
727            allow_tools: Some(Vec::new()),
728            deny_tools: Vec::new(),
729            max_context_tokens: None,
730            audit_retention_days: None,
731            redaction: std::collections::BTreeMap::new(),
732            filters: crate::core::policy::FilterRules::default(),
733            egress: crate::core::policy::EgressRules::default(),
734            routing: crate::core::policy::RoutingPolicyRules::default(),
735            budgets: crate::core::policy::BudgetRules::default(),
736        });
737        active.content_valid = false;
738        active
739    }
740
741    /// Whether `tool` is permitted by this policy's allow/deny lists.
742    /// `deny_tools` always wins; an `allow_tools` allowlist, when set, is
743    /// exclusive (only listed tools pass).
744    #[must_use]
745    pub fn tool_allowed(&self, tool: &str) -> bool {
746        if !self.content_valid || !self.within_horizon() {
747            return false;
748        }
749        if self.resolved.deny_tools.iter().any(|t| t == tool) {
750            return false;
751        }
752        match &self.resolved.allow_tools {
753            Some(allow) => allow.iter().any(|t| t == tool),
754            None => true,
755        }
756    }
757}
758
759/// Map a resolved `[filters]` action string to a [`FilterAction`]. Absent or
760/// (defensively) unparseable ⇒ `Off`; validation already rejects bad tokens.
761fn filter_action(opt: Option<&String>) -> FilterAction {
762    opt.map(String::as_str)
763        .and_then(FilterAction::parse)
764        .unwrap_or(FilterAction::Off)
765}
766
767#[cfg(test)]
768fn load_from_disk() -> Option<Arc<ActivePolicy>> {
769    let local = match load_local_pack() {
770        Ok(local) => local,
771        Err(msg) => {
772            tracing::error!(
773                "policy: failed to load invalid {PROJECT_PACK_PATH} ({msg}); enforcing deny-all"
774            );
775            return Some(Arc::new(ActivePolicy::deny_all()));
776        }
777    };
778    apply_org_floor(
779        local,
780        crate::core::policy::org::active_resolved_checked(),
781        None,
782        &crate::core::policy::hive_filters::FilterRules::default(),
783        None,
784    )
785}
786
787/// `horizon` is the signed end of the organization floor, when a verifier
788/// decided it; it is attached here, before the policy is shared.
789/// `hive_floor` is the Hive effective policy (Release B): folded under
790/// everything else, so nothing local can loosen it.
791fn apply_org_floor(
792    local: Option<ResolvedPolicy>,
793    org: Result<Option<ResolvedPolicy>, String>,
794    horizon: Option<chrono::DateTime<chrono::Utc>>,
795    hive: &crate::core::policy::hive_filters::FilterRules,
796    hive_floor: Option<&ResolvedPolicy>,
797) -> Option<Arc<ActivePolicy>> {
798    // Invalid configured org policy must not silently remove the org floor.
799    let effective = match org {
800        Ok(Some(org)) => Some(crate::core::policy::floor::merge_floor(
801            &org,
802            local.as_ref(),
803        )),
804        Ok(None) => local,
805        Err(error) => {
806            tracing::error!("org policy rejected; enforcing deny-all: {error}");
807            return Some(Arc::new(ActivePolicy::deny_all()));
808        }
809    };
810    let effective = match hive_floor {
811        Some(floor) => Some(crate::core::policy::hive_floor::apply(
812            floor,
813            effective.as_ref(),
814        )),
815        None => effective,
816    };
817    let effective = match effective {
818        Some(effective) => effective,
819        None if hive.redaction.is_empty() && hive.blocked.is_empty() => return None,
820        // Hive filter packs alone: content rules only, no tool or path rule.
821        None => hive_filters_only(),
822    };
823    Some(Arc::new(
824        ActivePolicy::from_resolved_with_hive(effective, hive).until(horizon),
825    ))
826}
827
828/// The policy that carries nothing but the rules of Hive filter packs.
829fn hive_filters_only() -> ResolvedPolicy {
830    ResolvedPolicy {
831        name: "hive-filters".into(),
832        version: "1.0.0".into(),
833        description: "content rules of the installed Hive filter packs".into(),
834        chain: Vec::new(),
835        default_read_mode: None,
836        allow_tools: None,
837        deny_tools: Vec::new(),
838        max_context_tokens: None,
839        audit_retention_days: None,
840        redaction: std::collections::BTreeMap::new(),
841        filters: crate::core::policy::FilterRules::default(),
842        egress: crate::core::policy::EgressRules::default(),
843        routing: crate::core::policy::RoutingPolicyRules::default(),
844        budgets: crate::core::policy::BudgetRules::default(),
845    }
846}
847
848/// The project-local pack (`.lean-ctx/policy.toml`), resolved. `None` only when
849/// the file is absent; malformed packs return an error so callers fail closed.
850#[cfg(test)]
851fn load_local_pack() -> Result<Option<ResolvedPolicy>, String> {
852    let path = PathBuf::from(PROJECT_PACK_PATH);
853    load_local_pack_at(&path)
854}
855
856#[cfg(test)]
857fn load_local_pack_at(path: &Path) -> Result<Option<ResolvedPolicy>, String> {
858    match std::fs::symlink_metadata(path) {
859        Ok(_) => {}
860        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
861        Err(error) => return Err(format!("{}: {error}", path.display())),
862    }
863    match parse_file(path).and_then(|p| resolve(&p)) {
864        Ok(resolved) => Ok(Some(resolved)),
865        Err(e) => Err(format!("{}: {e}", path.display())),
866    }
867}
868
869/// Resolve the explicit runtime project without using the process-global cache.
870/// Configured local/org errors remain errors rather than granting admission.
871pub fn for_project(project_root: &Path) -> Result<Option<Arc<ActivePolicy>>, String> {
872    refresh::current(project_root.to_path_buf())
873}
874
875/// The active resolved policy, or `None` when no project or org pack exists.
876/// Fresh source bytes are mandatory even when compiled rules are cached.
877#[must_use]
878pub fn active() -> Option<Arc<ActivePolicy>> {
879    active_observed().0
880}
881
882/// [`active`], together with the organization decision the verifier stated
883/// in the very verification that policy comes from — one read, one answer,
884/// no second look at policy, trust store or marker. For whoever records what
885/// it decided under: the observation it files is the one of its own
886/// snapshot, never a later one.
887///
888/// The observation is `None` whenever no verifier was asked (no organization
889/// policy, a version-one one) and whenever the policy is the deny-all that
890/// stands for an authority that could not be established: a refusal records
891/// no organization decision it did not get. A lifted requirement is observed
892/// although it leaves no floor, and so possibly no policy at all.
893#[must_use]
894pub(crate) fn active_observed() -> (Option<Arc<ActivePolicy>>, Option<super::org::Observation>) {
895    let deny_all = || (Some(Arc::new(ActivePolicy::deny_all())), None);
896    if let Some(view) = current_source_view() {
897        if !view.live.load(Ordering::Acquire) || !view.valid.load(Ordering::Acquire) {
898            return deny_all();
899        }
900        let project = REQUEST_PROJECT
901            .try_with(|slot| slot.borrow().clone())
902            .ok()
903            .flatten();
904        if project.as_ref() != Some(&view.project) || !view.within_horizon() {
905            view.invalidate_if_live();
906            return deny_all();
907        }
908        return (view.policy, view.observation);
909    }
910    #[cfg(any(test, feature = "test-support"))]
911    {
912        let state = test_override()
913            .read()
914            .unwrap_or_else(std::sync::PoisonError::into_inner);
915        if let OverrideState::Fixed(policy) = &*state {
916            return (policy.clone(), None);
917        }
918    }
919    let scoped = REQUEST_PROJECT
920        .try_with(|slot| slot.borrow().clone())
921        .ok()
922        .flatten();
923    match scoped
924        .map_or_else(std::env::current_dir, Ok)
925        .map_err(|_| "policy project root unavailable".into())
926        .and_then(refresh::current_verified)
927    {
928        Ok(verified) => (verified.policy, verified.observation),
929        Err(_) => deny_all(),
930    }
931}
932
933/// Run `operation` under a pinned view of `project` whose verification
934/// observed `observation` and left no policy — the state after a signed
935/// unpin on an endpoint without a project pack. Tests only: a real view comes
936/// from `with_source_view` and nowhere else.
937#[cfg(any(test, feature = "test-support"))]
938pub fn with_observed_view_for_test<T>(
939    project: &Path,
940    observation: Option<super::org::Observation>,
941    operation: impl FnOnce() -> T,
942) -> T {
943    let project = canonical_project_root(project).expect("test project root");
944    let role = crate::core::roles::active_role();
945    // The authority as it really verifies for this root (no organization
946    // artifact in a test's data directory), so that a holder's own closing
947    // verification finds the same fingerprint; only the observation is given.
948    let verified = refresh::current_verified(project.clone()).expect("test authority");
949    let view = SourceView {
950        project: project.clone(),
951        policy: verified.policy,
952        fingerprint: verified.fingerprint,
953        role_fingerprint: role_fingerprint(&role).expect("test role"),
954        role,
955        lifetime: None,
956        observation,
957        valid: Arc::new(AtomicBool::new(true)),
958        live: Arc::new(AtomicBool::new(true)),
959    };
960    REQUEST_PROJECT.sync_scope(std::cell::RefCell::new(Some(project)), || {
961        SOURCE_VIEW.sync_scope(std::cell::RefCell::new(Some(view)), operation)
962    })
963}
964
965/// What a source view that is already held says about the organization.
966pub(crate) enum HeldObservation {
967    /// No usable view is held here: nothing can be said, and nothing is.
968    NotHeld,
969    /// The held view's own observation (`None`: its verification asked no
970    /// verifier).
971    Held(Option<super::org::Observation>),
972}
973
974/// The organization observation of the source view the caller already runs
975/// under — and nothing else. It never resolves, refreshes or reads: without a
976/// held view that is live, valid, of the request's own project and within its
977/// signed horizon, the answer is [`HeldObservation::NotHeld`]. For a path
978/// that must not start a verification of its own but may record the decision
979/// its caller's view was verified with.
980pub(crate) fn held_observation() -> HeldObservation {
981    let Some(view) = current_source_view() else {
982        return HeldObservation::NotHeld;
983    };
984    let project = REQUEST_PROJECT
985        .try_with(|slot| slot.borrow().clone())
986        .ok()
987        .flatten();
988    if !view.live.load(Ordering::Acquire)
989        || !view.valid.load(Ordering::Acquire)
990        || project.as_ref() != Some(&view.project)
991        || !view.within_horizon()
992    {
993        return HeldObservation::NotHeld;
994    }
995    HeldObservation::Held(view.observation)
996}
997
998/// Current "is a policy active?" probe; refresh failure requires protection.
999#[must_use]
1000pub fn is_active() -> bool {
1001    active().is_some()
1002}
1003
1004/// Re-read the project pack (e.g. after a `policy` edit). Idempotent.
1005pub fn reload() {
1006    let _ = active();
1007}
1008
1009#[cfg(any(test, feature = "test-support"))]
1010enum OverrideState {
1011    Live,
1012    Fixed(Option<Arc<ActivePolicy>>),
1013}
1014
1015#[cfg(any(test, feature = "test-support"))]
1016fn test_override() -> &'static RwLock<OverrideState> {
1017    static OVERRIDE: OnceLock<RwLock<OverrideState>> = OnceLock::new();
1018    OVERRIDE.get_or_init(|| RwLock::new(OverrideState::Live))
1019}
1020
1021#[cfg(any(test, feature = "test-support"))]
1022fn test_override_lock() -> &'static std::sync::Mutex<()> {
1023    static LOCK: OnceLock<std::sync::Mutex<()>> = OnceLock::new();
1024    LOCK.get_or_init(|| std::sync::Mutex::new(()))
1025}
1026
1027/// The policy-override lock together with the test environment lock, in the
1028/// one order every test takes them: environment first, then policy. Held as
1029/// one value so that neither outlives the other; the fields are declared in
1030/// release order — policy before environment.
1031#[cfg(any(test, feature = "test-support"))]
1032pub struct TestOverrideGuard {
1033    _policy: std::sync::MutexGuard<'static, ()>,
1034    _environment: crate::core::data_dir::TestEnvGuard,
1035}
1036
1037#[cfg(any(test, feature = "test-support"))]
1038pub fn lock_test_override() -> TestOverrideGuard {
1039    let environment = crate::core::data_dir::test_env_lock();
1040    let policy = test_override_lock()
1041        .lock()
1042        .unwrap_or_else(std::sync::PoisonError::into_inner);
1043    TestOverrideGuard {
1044        _policy: policy,
1045        _environment: environment,
1046    }
1047}
1048
1049/// Process-wide test override held under a shared lock for its whole lifetime.
1050#[cfg(any(test, feature = "test-support"))]
1051pub struct TestPolicyOverride {
1052    previous: OverrideState,
1053    _lock: TestOverrideGuard,
1054}
1055
1056#[cfg(any(test, feature = "test-support"))]
1057impl TestPolicyOverride {
1058    pub fn set(resolved: Option<ResolvedPolicy>) -> Self {
1059        let lock = lock_test_override();
1060        let previous = std::mem::replace(
1061            &mut *test_override()
1062                .write()
1063                .unwrap_or_else(std::sync::PoisonError::into_inner),
1064            OverrideState::Fixed(
1065                resolved.map(|policy| Arc::new(ActivePolicy::from_resolved(policy))),
1066            ),
1067        );
1068        Self {
1069            previous,
1070            _lock: lock,
1071        }
1072    }
1073}
1074
1075#[cfg(any(test, feature = "test-support"))]
1076impl Drop for TestPolicyOverride {
1077    fn drop(&mut self) {
1078        *test_override()
1079            .write()
1080            .unwrap_or_else(std::sync::PoisonError::into_inner) =
1081            std::mem::replace(&mut self.previous, OverrideState::Live);
1082    }
1083}
1084
1085#[cfg(test)]
1086#[path = "runtime/tests.rs"]
1087mod tests;