Skip to main content

clawft_kernel/workload_governance/
policy.rs

1//! Workload governance actions, the default-deny rule set and permit rules
2//! (ADR-099 section 4, ADR-092 distribution).
3
4use serde::{Deserialize, Serialize};
5
6use super::effect::{NetworkPolicy, NodeTrustTier, PackageTrust, WorkloadEffect};
7use crate::governance::{
8    GovernanceBranch, GovernanceRule, GovernanceRuleType, RuleSeverity, selector_matches,
9};
10
11/// Prefix shared by every workload governance action.
12pub const WORKLOAD_ACTION_PREFIX: &str = "workload.";
13
14/// Install a package on a node.
15pub const ACTION_INSTALL: &str = "workload.install";
16/// Place an instance on a node.
17pub const ACTION_PLACE: &str = "workload.place";
18/// Load an instance into a runtime.
19pub const ACTION_LOAD: &str = "workload.load";
20/// Start a loaded instance.
21pub const ACTION_START: &str = "workload.start";
22/// Stop a running instance.
23pub const ACTION_STOP: &str = "workload.stop";
24/// Unload an instance.
25pub const ACTION_UNLOAD: &str = "workload.unload";
26/// Migrate an instance to another node.
27pub const ACTION_MIGRATE: &str = "workload.migrate";
28/// Revoke a package, signer key or artifact hash.
29pub const ACTION_REVOKE: &str = "workload.revoke";
30/// Bind a fleet identity to a mesh node.
31pub const ACTION_NODE_BIND: &str = "workload.node.bind";
32
33/// Every action the workload gate governs. Any other `workload.*` string is
34/// refused as unknown.
35pub const GOVERNED_ACTIONS: &[&str] = &[
36    ACTION_INSTALL,
37    ACTION_PLACE,
38    ACTION_LOAD,
39    ACTION_START,
40    ACTION_STOP,
41    ACTION_UNLOAD,
42    ACTION_MIGRATE,
43    ACTION_REVOKE,
44    ACTION_NODE_BIND,
45];
46
47/// Rule id of the `workload.*` default-deny rule.
48pub const DEFAULT_DENY_RULE_ID: &str = "WORKLOAD-DEFAULT-DENY";
49/// Rule id of the effect-magnitude ceiling that still applies after a permit.
50pub const EFFECT_CEILING_RULE_ID: &str = "WORKLOAD-EFFECT-CEILING";
51/// SOP category marking rules that a matching permit rule lifts.
52pub const DEFAULT_DENY_CATEGORY: &str = "workload.default_deny";
53
54/// Whether `action` is one of [`GOVERNED_ACTIONS`].
55pub fn is_governed_action(action: &str) -> bool {
56    GOVERNED_ACTIONS.contains(&action)
57}
58
59/// The workload rules shipped in the governance rule set.
60///
61/// - [`DEFAULT_DENY_RULE_ID`]: blocks every `workload.*` action regardless of
62///   effect magnitude (`force_on_match`). This closes the gap where
63///   `CapabilityGate` and a plain `GovernanceGate` permit unknown actions.
64/// - [`EFFECT_CEILING_RULE_ID`]: blocks a `workload.*` action whose effect
65///   magnitude exceeds the engine threshold, even when a permit rule matched.
66///
67/// Both are ordinary [`GovernanceRule`]s, so they travel through
68/// [`crate::rule_distribution::RuleDistribution`] like any other rule.
69pub fn default_rules() -> Vec<GovernanceRule> {
70    let rule = |id: &str, desc: &str, category: &str, force: bool| GovernanceRule {
71        id: id.into(),
72        description: desc.into(),
73        branch: GovernanceBranch::Legislative,
74        severity: RuleSeverity::Blocking,
75        active: true,
76        reference_url: None,
77        sop_category: Some(category.into()),
78        rule_type: GovernanceRuleType::General,
79        action_selector: Some(format!("{WORKLOAD_ACTION_PREFIX}*")),
80        tool_selector: None,
81        force_on_match: force,
82    };
83    vec![
84        rule(
85            DEFAULT_DENY_RULE_ID,
86            "workload.* actions are denied unless an explicit workload permit rule matches (ADR-099 s4)",
87            DEFAULT_DENY_CATEGORY,
88            true,
89        ),
90        rule(
91            EFFECT_CEILING_RULE_ID,
92            "workload.* actions above the effect threshold are denied even when permitted",
93            "workload.effect_ceiling",
94            false,
95        ),
96    ]
97}
98
99/// Seed the workload rules into a cluster rule store (ADR-092).
100pub fn install_default_rules(
101    dist: &mut crate::rule_distribution::RuleDistribution,
102    now_unix: u64,
103) {
104    for rule in default_rules() {
105        dist.upsert_local(rule, now_unix);
106    }
107}
108
109fn default_min_trust() -> PackageTrust {
110    PackageTrust::PinnedSigner
111}
112fn default_min_tier() -> NodeTrustTier {
113    NodeTrustTier::Paired
114}
115fn default_max_network() -> NetworkPolicy {
116    NetworkPolicy::Lan
117}
118fn default_max_cost() -> f64 {
119    1.0
120}
121
122/// An explicit allowance that lifts the `workload.*` default deny.
123///
124/// Every condition must hold. Defaults are the strict ones from ADR-099
125/// section 8: pinned signer, paired node, LAN at most, no secrets, no
126/// emulation, no accelerator.
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128pub struct WorkloadPermitRule {
129    /// Unique id (recorded in the chain event when it matches).
130    pub id: String,
131    /// Why this permit exists.
132    #[serde(default)]
133    pub description: String,
134    /// Action selectors (`workload.place`, or `workload.*`).
135    pub actions: Vec<String>,
136    /// Workload kinds allowed (`cog`, `inference`; `*` for any).
137    pub kinds: Vec<String>,
138    /// Least package trust allowed.
139    #[serde(default = "default_min_trust")]
140    pub min_package_trust: PackageTrust,
141    /// Least node trust tier allowed.
142    #[serde(default = "default_min_tier")]
143    pub min_node_tier: NodeTrustTier,
144    /// Most network exposure allowed.
145    #[serde(default = "default_max_network")]
146    pub max_network: NetworkPolicy,
147    /// Whether secrets may be delivered.
148    #[serde(default)]
149    pub allow_secrets: bool,
150    /// Whether emulated placement is allowed (operator opt-in).
151    #[serde(default)]
152    pub allow_emulated: bool,
153    /// Accelerator id selectors allowed (`accel.gpu.*`). Empty: none.
154    #[serde(default)]
155    pub accelerators: Vec<String>,
156    /// Highest resource cost allowed.
157    #[serde(default = "default_max_cost")]
158    pub max_resource_cost: f64,
159    /// Verified project ids this permit applies to (ADR-103 A6). Empty: any.
160    /// Matched against the gate's verified attestation, never against
161    /// anything the request carries; with a non-empty list and no verified
162    /// project the rule does not match.
163    #[serde(default, skip_serializing_if = "Vec::is_empty")]
164    pub projects: Vec<String>,
165    /// Principals (the gate's `agent_id`) this permit applies to. Empty:
166    /// any. With a non-empty list a request whose principal is unknown, or
167    /// not listed, does not match. The `project` kind uses it to limit its
168    /// permit to [`SUPERVISOR_PRINCIPAL`].
169    #[serde(default, skip_serializing_if = "Vec::is_empty")]
170    pub principals: Vec<String>,
171}
172
173/// The principal the daemon's catalog verbs (`workload install` / `unload`)
174/// decide as. The catalog verifies nothing, so those requests are `unsigned`;
175/// a permit that accepts unsigned packages has to be limited to this
176/// principal (`validate` refuses one that names none), so it cannot also
177/// admit an unsigned placement.
178pub const CATALOG_PRINCIPAL: &str = "catalog";
179
180/// The internal principal the user-daemon project supervisor acts as. Never
181/// accepted from a request: the supervisor builds its own gate and host with
182/// this id, so an operator-facing placement path (whose gate has no such
183/// permit) cannot start a `project` workload.
184pub const SUPERVISOR_PRINCIPAL: &str = "weftos.project-supervisor";
185
186/// The permit that lets the supervisor, and only the supervisor, run the
187/// `project` kind on this node. It does not reach any other kind.
188pub fn project_supervisor_permit() -> WorkloadPermitRule {
189    let mut rule = WorkloadPermitRule::new(
190        "PROJECT-SUPERVISOR-PERMIT",
191        [ACTION_LOAD, ACTION_START, ACTION_STOP, ACTION_UNLOAD],
192        ["project"],
193    );
194    rule.description = "the project supervisor may run per-project child kernels (ADR-103 A6)".into();
195    rule.min_package_trust = PackageTrust::ProjectCert;
196    rule.min_node_tier = NodeTrustTier::Paired;
197    rule.max_network = NetworkPolicy::None;
198    rule.principals = vec![SUPERVISOR_PRINCIPAL.into()];
199    rule
200}
201
202impl WorkloadPermitRule {
203    /// Strict permit for `actions` on workloads of `kinds`.
204    pub fn new(
205        id: impl Into<String>,
206        actions: impl IntoIterator<Item = impl Into<String>>,
207        kinds: impl IntoIterator<Item = impl Into<String>>,
208    ) -> Self {
209        Self {
210            id: id.into(),
211            description: String::new(),
212            actions: actions.into_iter().map(Into::into).collect(),
213            kinds: kinds.into_iter().map(Into::into).collect(),
214            min_package_trust: default_min_trust(),
215            min_node_tier: default_min_tier(),
216            max_network: default_max_network(),
217            allow_secrets: false,
218            allow_emulated: false,
219            accelerators: Vec::new(),
220            max_resource_cost: default_max_cost(),
221            projects: Vec::new(),
222            principals: Vec::new(),
223        }
224    }
225
226    /// Reject rules that are empty, malformed, or reach outside `workload.*`.
227    pub fn validate(&self) -> Result<(), String> {
228        if self.id.trim().is_empty() {
229            return Err("permit rule id must not be empty".into());
230        }
231        if self.actions.is_empty() || self.kinds.is_empty() {
232            return Err(format!("permit rule '{}' needs at least one action and one kind", self.id));
233        }
234        for a in &self.actions {
235            let known = is_governed_action(a)
236                || a == "workload.*"
237                || (a.ends_with('*') && a.starts_with(WORKLOAD_ACTION_PREFIX));
238            if !known {
239                return Err(format!(
240                    "permit rule '{}': action selector '{a}' is not a governed workload action",
241                    self.id
242                ));
243            }
244        }
245        if self.kinds.iter().any(|k| k.is_empty()) || self.accelerators.iter().any(|a| a.is_empty()) {
246            return Err(format!("permit rule '{}': empty kind or accelerator selector", self.id));
247        }
248        if self.min_package_trust == PackageTrust::Unsigned && self.principals.is_empty() {
249            return Err(format!(
250                "permit rule '{}' accepts unsigned packages, so it must name the principals it \
251                 applies to (e.g. [\"{CATALOG_PRINCIPAL}\"] for the catalog verbs)",
252                self.id
253            ));
254        }
255        if self.principals.iter().any(|p| p.is_empty()) {
256            return Err(format!("permit rule '{}': empty principal selector", self.id));
257        }
258        if self.projects.iter().any(|p| p.is_empty()) {
259            return Err(format!("permit rule '{}': empty project selector", self.id));
260        }
261        if !self.max_resource_cost.is_finite() || !(0.0..=1.0).contains(&self.max_resource_cost) {
262            return Err(format!("permit rule '{}': max_resource_cost must be in [0, 1]", self.id));
263        }
264        Ok(())
265    }
266
267    /// Whether this rule permits `action` with `effect`.
268    pub fn matches(&self, action: &str, effect: &WorkloadEffect) -> bool {
269        self.matches_in(action, effect, None)
270    }
271
272    /// [`matches`](Self::matches) for a request attributed to `project`
273    /// (the verified project id, or `None` outside any project).
274    pub fn matches_in(&self, action: &str, effect: &WorkloadEffect, project: Option<&str>) -> bool {
275        self.matches_for(action, effect, project, None)
276    }
277
278    /// [`matches_in`](Self::matches_in) for a request made by `principal`
279    /// (the gate's agent id; `None` when unknown, which never satisfies a
280    /// rule that lists principals).
281    pub fn matches_for(
282        &self,
283        action: &str,
284        effect: &WorkloadEffect,
285        project: Option<&str>,
286        principal: Option<&str>,
287    ) -> bool {
288        let principal_ok =
289            self.principals.is_empty() || principal.is_some_and(|p| self.principals.iter().any(|x| x == p));
290        let project_ok = self.projects.is_empty() || project.is_some_and(|p| self.projects.iter().any(|x| x == p));
291        let action_ok = self.actions.iter().any(|s| selector_matches(s, action));
292        let kind_ok = self.kinds.iter().any(|k| k == "*" || k == &effect.kind);
293        let accel_ok = match &effect.accelerator {
294            None => true,
295            Some(acc) => self.accelerators.iter().any(|s| selector_matches(s, acc)),
296        };
297        principal_ok
298            && project_ok
299            && action_ok
300            && kind_ok
301            && accel_ok
302            && effect.package_trust >= self.min_package_trust
303            && effect.node_tier >= self.min_node_tier
304            && effect.network <= self.max_network
305            && (self.allow_secrets || !effect.secrets)
306            && (self.allow_emulated || !effect.emulated)
307            && effect.resource_cost <= self.max_resource_cost
308    }
309}