batuta/agent/org_policy.rs
1//! Managed organisation policy loader (Claude-Code parity).
2//!
3//! PMAT-CODE-ORG-POLICY-001: Claude Code honours a highest-tier
4//! "enforced settings" file that an organisation admin drops in
5//! `/etc/claude-code/CLAUDE.md`. Its content is treated as
6//! instructions whose precedence sits ABOVE project and user
7//! scopes, so corporate security/compliance rules cannot be
8//! overridden by a developer-level `~/.claude/CLAUDE.md` or a
9//! repo-level `CLAUDE.md`.
10//!
11//! This module ships the pure file-load primitive that the REPL
12//! prompt builder layers on top of the existing
13//! `load_project_instructions` / user-scope ladder. Canonical
14//! paths are injected by the caller so tests can use
15//! `tempfile::tempdir()` without touching `/etc`.
16//!
17//! # Claude-Code compatibility
18//!
19//! Two canonical paths are consulted in this order, first-wins:
20//! 1. `/etc/apr-code/CLAUDE.md` — native path
21//! 2. `/etc/claude-code/CLAUDE.md` — Claude-Code cross-compat
22//!
23//! The tier wins over project and user scopes. Missing files are
24//! not an error — the policy is simply absent.
25//!
26//! # Example
27//!
28//! ```rust,ignore
29//! use aprender_orchestrate::agent::org_policy::load_org_policy;
30//! use std::path::Path;
31//!
32//! let roots = [
33//! Path::new("/etc/apr-code"),
34//! Path::new("/etc/claude-code"),
35//! ];
36//! if let Some(policy) = load_org_policy(&roots, "CLAUDE.md", 65_536) {
37//! // prepend to prompt at the enforced tier
38//! }
39//! ```
40
41use std::path::{Path, PathBuf};
42
43/// Precedence tier at which the loaded policy content belongs.
44///
45/// Managed org policy always wins — this enum exists so the
46/// prompt builder can tag each instruction block with its tier
47/// and the merge step is total-ordered.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
49pub enum PolicyTier {
50 /// Loaded from `/etc/...` (or the injected admin root) —
51 /// highest precedence, overrides project and user scopes.
52 Enforced,
53}
54
55/// A loaded org-policy document and its source.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct OrgPolicy {
58 /// Absolute path the content was read from.
59 pub source: PathBuf,
60 /// Policy markdown contents (possibly truncated).
61 pub content: String,
62 /// Precedence tier — always [`PolicyTier::Enforced`] today,
63 /// reserved for a future Standard tier.
64 pub tier: PolicyTier,
65}
66
67/// Canonical system-wide roots in Claude-Code-parity precedence.
68///
69/// Returned as owned `PathBuf`s so callers can freely pass them
70/// into [`load_org_policy`] or test-time shadows. The order is
71/// `apr-code` first (native), `claude-code` second (cross-compat)
72/// so a site that installs both sees the `apr-code` file win.
73pub fn canonical_system_roots() -> [PathBuf; 2] {
74 [PathBuf::from("/etc/apr-code"), PathBuf::from("/etc/claude-code")]
75}
76
77/// Load the first existing `<root>/<filename>` from `roots`.
78///
79/// - First-wins: `roots` is consulted in order and the first file
80/// that exists and reads successfully is returned.
81/// - `max_bytes == 0` disables the loader (returns `None`).
82/// - `max_bytes > 0` truncates on a UTF-8 char boundary and
83/// annotates with a `(truncated from N bytes)` tail so the
84/// prompt builder can surface budget pressure to the user.
85/// - Missing file or I/O error on a given root is silently
86/// skipped — the loader does not panic or propagate errors so
87/// a world-readable `/etc` cannot ransom the REPL on boot.
88///
89/// Callers that want the canonical system roots can pass
90/// [`canonical_system_roots`] directly; tests inject `tempdir()`
91/// paths so no global state is touched.
92pub fn load_org_policy<P>(roots: &[P], filename: &str, max_bytes: usize) -> Option<OrgPolicy>
93where
94 P: AsRef<Path>,
95{
96 if max_bytes == 0 {
97 return None;
98 }
99 for root in roots {
100 let path = root.as_ref().join(filename);
101 if !path.is_file() {
102 continue;
103 }
104 let Ok(content) = std::fs::read_to_string(&path) else { continue };
105 let truncated = truncate_on_char_boundary(&content, max_bytes);
106 return Some(OrgPolicy { source: path, content: truncated, tier: PolicyTier::Enforced });
107 }
108 None
109}
110
111/// Truncate `content` on a UTF-8 char boundary, appending a
112/// `(truncated from N bytes)` tail when the original exceeded
113/// `max_bytes`. Used by [`load_org_policy`] so the budget check
114/// is deterministic even in the face of multi-byte characters.
115fn truncate_on_char_boundary(content: &str, max_bytes: usize) -> String {
116 if content.len() <= max_bytes {
117 return content.to_string();
118 }
119 let end = content
120 .char_indices()
121 .take_while(|(i, _)| *i < max_bytes)
122 .last()
123 .map(|(i, c)| i + c.len_utf8())
124 .unwrap_or(max_bytes.min(content.len()));
125 format!("{}...\n(truncated from {} bytes)", &content[..end], content.len())
126}
127
128#[cfg(test)]
129mod tests;