openmoat_core/ir.rs
1//! The policy compiler's intermediate representation (ADR-019).
2//!
3//! [`lower()`] derives from `policy.yaml` the part of a decision an operating
4//! system layer can enforce: which paths may be read or written and which
5//! hosts may be reached. Every OS backend (a host's sandbox settings, Seatbelt,
6//! Landlock, the egress proxy) is generated from this one [`Enforcement`]; the
7//! hook backend is the engine itself ([`crate::CompiledPolicy`]), compiled from
8//! the same policy and context.
9//!
10//! An OS layer can only allow or deny, so lowering never widens: a hook `ask`
11//! becomes a deny, and whatever cannot be represented exactly is narrowed and
12//! recorded as a [`Loss`]. The IR's verdict on any read, write or host is
13//! therefore the engine's verdict with `ask` read as `deny`, or stricter,
14//! except on the paths of a `hook_only` deny group, which only the hook
15//! denies and [`Enforcement::decide_only`] lists (ADR-034).
16
17use std::fmt;
18
19use serde::Serialize;
20
21use crate::kind::Kind;
22
23mod check;
24mod lower;
25
26pub use check::Checker;
27pub use lower::{CLOUD_METADATA, CLOUD_METADATA_RULE, READ_ROOTS_RULE, lower};
28
29/// What an OS layer may enforce for one policy in one context.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
31pub struct Enforcement {
32 /// File reads and writes.
33 pub fs: Filesystem,
34 /// Outbound network.
35 pub egress: Egress,
36 /// Secrets the egress proxy and Claude Code's own broker inject for the
37 /// agent (ADR-020). One entry per policy `secrets:` item; OS backends
38 /// consume these without reading the policy again.
39 pub secrets: Vec<BrokeredSecret>,
40 /// Limits on the agent's processes. Always empty: no policy key sets one yet.
41 pub limits: Vec<ProcessLimit>,
42 /// Rules only the hook can apply, by kind. Shell semantics, environment
43 /// variables, MCP tool names and executable pins name things an OS layer
44 /// cannot see (a command line, a variable, a tool), so they are not lowered
45 /// and stay with the hook decision (ADR-018). The file paths of a `deny`
46 /// group marked `hook_only` stay with it too (ADR-034): OS layers leave
47 /// them to the other rules, so these are the one place the IR's own
48 /// verdicts may be wider than the hook's.
49 pub decide_only: Vec<DecideOnly>,
50 /// Where the IR is stricter than the hook decision, with the reason.
51 pub losses: Vec<Loss>,
52 /// Where OS layers may be wider than the hook decision, listed in the
53 /// policy (`sandbox.read_roots`). The hook keeps deciding these accesses;
54 /// deny rules still win over them. Not part of [`Filesystem`], so the IR's
55 /// own verdicts stay the hook's or stricter.
56 pub allowances: Vec<Allowance>,
57}
58
59/// File access rules. Paths are absolute, slash-separated globs with `~` and
60/// `${project}` already expanded once per spelling of each root (the home
61/// directory and project as given and with their symlinks resolved).
62#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
63pub struct Filesystem {
64 /// Whether paths compare case-insensitively (default macOS and Windows file systems).
65 pub case_insensitive: bool,
66 /// Reads.
67 pub read: Access,
68 /// Writes, creates, deletes and moves.
69 pub write: Access,
70}
71
72/// Network rules. Hosts are lowercase globs and always compare case-insensitively.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
74pub struct Egress {
75 /// Any connection, a host fetch tool's included.
76 pub net: Access,
77 /// Requests by a host's read-only fetch tool (`WebFetch`), which `fetch`
78 /// and `net` rules both govern (ADR-017).
79 pub fetch: Access,
80}
81
82/// The rules for one kind of access, applied in order: a matching `deny` rule
83/// denies; otherwise a matching `allow` rule allows; otherwise `default`.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85pub struct Access {
86 /// The effect when no rule matches.
87 pub default: Effect,
88 /// Rules that deny; an allow never overrides them.
89 pub deny: Vec<Rule>,
90 /// Rules that allow.
91 pub allow: Vec<Rule>,
92}
93
94/// One policy rule group's patterns from one list. It matches when at least
95/// one pattern matches and no `!` exclusion does, as in the policy.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
97pub struct Rule {
98 /// The policy rule group's id ([`CLOUD_METADATA_RULE`] for the built-in one).
99 pub id: String,
100 /// The policy list the patterns come from (`net` patterns also govern `fetch`).
101 pub list: Kind,
102 /// Expanded globs; a leading `!` excludes.
103 pub patterns: Vec<String>,
104}
105
106/// What an OS layer does with an access.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
108#[serde(rename_all = "lowercase")]
109pub enum Effect {
110 /// The access may proceed.
111 Allow,
112 /// The access is blocked.
113 Deny,
114}
115
116/// The policy rules of one kind that only the hook applies.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
118pub struct DecideOnly {
119 /// `shell`, `env.read`, `env.set`, `mcp` or `executables`; `fs.read` or
120 /// `fs.write` for `hook_only` groups.
121 pub kind: String,
122 /// Rule group ids, or program names for `executables`.
123 pub rules: Vec<String>,
124}
125
126/// A place where the IR denies something the hook would not deny.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
128pub struct Loss {
129 /// The access the loss applies to.
130 pub kind: Kind,
131 /// The rule group id, or `default.<kind>`/`default` for the defaults table.
132 pub rule: String,
133 /// What the hook does and what OS layers do instead.
134 pub message: String,
135}
136
137impl fmt::Display for Loss {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 write!(f, "{} `{}`: {}", self.kind, self.rule, self.message)
140 }
141}
142
143/// Accesses OS layers allow although the hook would not (ADR-019: the only
144/// way an OS layer may be wider than the hook, and always listed).
145#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
146pub struct Allowance {
147 /// The access the allowance applies to.
148 pub kind: Kind,
149 /// Where it comes from (`sandbox.read_roots`).
150 pub rule: String,
151 /// Expanded globs: each root and everything below it.
152 pub patterns: Vec<String>,
153 /// What the hook does and what OS layers do instead.
154 pub message: String,
155}
156
157impl fmt::Display for Allowance {
158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 write!(f, "{} `{}`: {}", self.kind, self.rule, self.message)
160 }
161}
162
163/// A secret the egress proxy or Claude Code's own broker injects (ADR-020).
164/// A copy of [`crate::Secret`]: the IR carries only the fields OS backends
165/// need, so a backend can read `ir.secrets` without the policy. Its `Debug`
166/// output names only the id, as for [`crate::Secret`].
167#[derive(Clone, PartialEq, Eq, Serialize)]
168pub struct BrokeredSecret {
169 /// Stable identifier (lowercase letters, digits and `-`).
170 pub id: String,
171 /// The one host whose requests receive the secret, lowercase DNS or IPv4.
172 pub host: String,
173 /// The request header the secret goes in (`Authorization`, `X-Api-Key`).
174 pub header: String,
175 /// Where OpenMoat reads the value.
176 pub source: crate::SecretSource,
177 /// Also inject into plain-HTTP requests; off unless the policy sets it.
178 pub plain_http: bool,
179}
180
181impl std::fmt::Debug for BrokeredSecret {
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 f.debug_struct("BrokeredSecret")
184 .field("id", &self.id)
185 .finish_non_exhaustive()
186 }
187}
188
189/// A process limit. No value exists until the policy schema defines one.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
191pub enum ProcessLimit {}
192
193#[cfg(test)]
194mod tests;