Skip to main content

wm_dispatch/
capability_gate.rs

1//! Dispatch-side capability authorization (PLAN_F F-1, dispatch half).
2//!
3//! Tools declare the capabilities they invoke (`EffectRow::invokes`, wm-core
4//! vocabulary). This module maps that vocabulary onto the governance
5//! [`CapabilitySet`] (wm-governance) and validates a presented engagement
6//! credential before dispatch proceeds:
7//!
8//! - A credential presented under `args["_engagement"]`
9//!   (`{ "token": <EngagementToken>, "issuer_public_key": "<hex>" }`) is
10//!   **always** verified when present: Ed25519 signature → revocation →
11//!   expiry → scope-derived capability coverage. A failing credential
12//!   refuses the call in every mode — presented evidence must be valid.
13//! - Without a credential, behavior depends on the gate mode:
14//!   - [`CapabilityGateMode::Advisory`] (default): dispatch proceeds; the
15//!     unmet requirement is logged at debug level for observability.
16//!   - [`CapabilityGateMode::Strict`] (`WM_REQUIRE_CAPABILITIES=1`): tools
17//!     whose `invokes` map to a non-empty capability set are refused with an
18//!     actionable error.
19//!
20//! The credential key is removed from the args before execution, so tokens
21//! never reach tool bodies, the write-audit digest, or the flight recorder.
22//!
23//! Scope note (v1): Ed25519 engagement tokens are the only verifiable
24//! evidence path. `AdminGovernance`, `MemoryDelete`, and `SealAdapt` are not
25//! grantable by any current [`EngagementScope`], so strict mode refuses
26//! tools that declare them until a signed-grant path exists — that is the
27//! intended conservative default, not a bug.
28//!
29//! Trust-anchor limitation (v1): the issuer key is presented alongside the
30//! token, so a valid credential proves integrity + scope coverage, not
31//! issuer *authority* — dispatch has no bound-identity anchor the way the
32//! mesh transport binds issuer keys to peer keys. Pinning issuer keys
33//! (configured allowlist or bound peers) is the next hardening step.
34//!
35//! [`EngagementScope`]: wm_governance::engagement_tokens::EngagementScope
36
37use serde::{Deserialize, Serialize};
38use wm_core::{Capability as CoreCapability, EffectRow};
39use wm_governance::capabilities::{
40    Capability, CapabilitySet, assert_engagement_token_capabilities,
41};
42use wm_governance::engagement_tokens::EngagementToken;
43
44/// Key under which a dispatch caller presents an engagement credential.
45pub const ENGAGEMENT_KEY: &str = "_engagement";
46
47/// Credential shape — mirrors the mesh transport's `EngagementCredential`
48/// without taking a dependency on `wm-sangha`.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct DispatchEngagementCredential {
51    /// Scope-of-engagement token issued by an Ed25519 key.
52    pub token: EngagementToken,
53    /// Issuer public key (hex) the token signature is checked against.
54    pub issuer_public_key: String,
55}
56
57/// How the pipeline treats a missing credential for a capability-requiring
58/// tool.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum CapabilityGateMode {
61    /// Log-only: requirements are observed, never enforced.
62    Advisory,
63    /// Enforce: uncredentialed capability-requiring dispatches are refused.
64    Strict,
65}
66
67impl CapabilityGateMode {
68    /// Read the mode from `WM_REQUIRE_CAPABILITIES` (`1`/`true`/`strict` =
69    /// strict; anything else = advisory).
70    #[must_use]
71    pub fn from_env() -> Self {
72        match std::env::var("WM_REQUIRE_CAPABILITIES").as_deref() {
73            Ok("1" | "true" | "strict") => Self::Strict,
74            _ => Self::Advisory,
75        }
76    }
77
78    /// Whether this mode enforces the requirement.
79    #[must_use]
80    pub const fn is_strict(self) -> bool {
81        matches!(self, Self::Strict)
82    }
83
84    /// Canonical label for logs and diagnostics.
85    #[must_use]
86    pub const fn label(self) -> &'static str {
87        match self {
88            Self::Advisory => "advisory",
89            Self::Strict => "strict",
90        }
91    }
92}
93
94/// Map a tool's wm-core `invokes` list onto the governance capability
95/// vocabulary.
96///
97/// Mapping decisions:
98/// - `Search` / `VectorSearch` are memory reads.
99/// - `Embed` / `LlmInfer` are model invocations.
100/// - `Execute` is process spawning; `NetworkRequest` is outbound network.
101/// - `Dream` consolidates memories (write); `CittaUpdate` is internal state
102///   and maps to no external capability.
103/// - `Delegate` (tool-to-tool delegation) is conservatively mapped to
104///   `AdminGovernance` — delegation is a governed act; no current tool
105///   declares it.
106#[must_use]
107pub fn required_capabilities(effects: &EffectRow) -> CapabilitySet {
108    let mut set = CapabilitySet::EMPTY;
109    for cap in &effects.invokes {
110        let mapped = match cap {
111            CoreCapability::MemoryRead | CoreCapability::Search | CoreCapability::VectorSearch => {
112                Some(Capability::MemoryRead)
113            }
114            CoreCapability::MemoryWrite => Some(Capability::MemoryWrite),
115            CoreCapability::MemoryDelete => Some(Capability::MemoryDelete),
116            CoreCapability::Embed | CoreCapability::LlmInfer => Some(Capability::ModelInvoke),
117            CoreCapability::Execute => Some(Capability::IpcSpawn),
118            CoreCapability::NetworkRequest => Some(Capability::NetOutbound),
119            CoreCapability::Delegate => Some(Capability::AdminGovernance),
120            CoreCapability::Dream => Some(Capability::MemoryWrite),
121            CoreCapability::CittaUpdate => None,
122        };
123        if let Some(c) = mapped {
124            set.insert(c);
125        }
126    }
127    set
128}
129
130/// Outcome of a capability-gate evaluation.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum GateOutcome {
133    /// The tool declares no governance-mapped capabilities.
134    NotRequired,
135    /// A valid credential covered the required set.
136    Satisfied { required: CapabilitySet },
137    /// No credential was presented and the mode is advisory.
138    AdvisoryMissing { required: CapabilitySet },
139}
140
141/// Evaluate the capability gate for one dispatch.
142///
143/// Mutates `args` by removing the credential key when present (it must not
144/// reach tool bodies or audit digests). Returns `Err(reason)` when a
145/// presented credential is malformed or fails verification, or when strict
146/// mode finds a requirement with no credential.
147pub fn evaluate(
148    effects: &EffectRow,
149    args: &mut serde_json::Value,
150    mode: CapabilityGateMode,
151    now: i64,
152) -> std::result::Result<GateOutcome, String> {
153    let required = required_capabilities(effects);
154
155    let credential_value = args
156        .as_object_mut()
157        .and_then(|object| object.remove(ENGAGEMENT_KEY));
158
159    if let Some(value) = credential_value {
160        let credential: DispatchEngagementCredential = serde_json::from_value(value)
161            .map_err(|e| format!("malformed engagement credential: {e}"))?;
162        assert_engagement_token_capabilities(
163            &credential.token,
164            &credential.issuer_public_key,
165            required,
166            now,
167        )
168        .map_err(|e| format!("engagement rejected: {e}"))?;
169        return Ok(GateOutcome::Satisfied { required });
170    }
171
172    if required.is_empty() {
173        return Ok(GateOutcome::NotRequired);
174    }
175
176    if mode.is_strict() {
177        return Err(format!(
178            "capability required ({}), none granted — present a signed engagement \
179             credential under args[\"{}\"] (WM_REQUIRE_CAPABILITIES=1)",
180            required.labels().join(", "),
181            ENGAGEMENT_KEY
182        ));
183    }
184
185    Ok(GateOutcome::AdvisoryMissing { required })
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use wm_core::Capability as CoreCapability;
192    use wm_governance::engagement_tokens::{EngagementIssuer, EngagementScope};
193    use wm_governance::network_profile::AgentKeypair;
194
195    fn effects_with(caps: Vec<CoreCapability>) -> EffectRow {
196        EffectRow {
197            invokes: caps,
198            ..Default::default()
199        }
200    }
201
202    fn credential_args(scope: EngagementScope) -> (serde_json::Value, EngagementToken) {
203        let mut issuer = EngagementIssuer::with_keypair(AgentKeypair::from_seed([42u8; 32]));
204        let issuer_key = issuer.signer_public_key_hex();
205        let token = issuer.issue("tester", scope, "rules-hash", Some(3600));
206        let args = serde_json::json!({
207            ENGAGEMENT_KEY: {
208                "token": token,
209                "issuer_public_key": issuer_key,
210            }
211        });
212        (args, token)
213    }
214
215    #[test]
216    fn mapping_is_total_and_documented() {
217        // Every core capability either maps to a governance capability or is
218        // deliberately unmapped (CittaUpdate = internal state).
219        let all = [
220            CoreCapability::MemoryRead,
221            CoreCapability::MemoryWrite,
222            CoreCapability::MemoryDelete,
223            CoreCapability::Search,
224            CoreCapability::VectorSearch,
225            CoreCapability::Embed,
226            CoreCapability::LlmInfer,
227            CoreCapability::Delegate,
228            CoreCapability::Execute,
229            CoreCapability::NetworkRequest,
230            CoreCapability::Dream,
231            CoreCapability::CittaUpdate,
232        ];
233        let mapped = required_capabilities(&effects_with(all.to_vec()));
234        for expected in [
235            Capability::MemoryRead,
236            Capability::MemoryWrite,
237            Capability::MemoryDelete,
238            Capability::ModelInvoke,
239            Capability::IpcSpawn,
240            Capability::NetOutbound,
241            Capability::AdminGovernance,
242        ] {
243            assert!(mapped.contains(expected), "missing {expected:?}");
244        }
245        // CittaUpdate maps to nothing; a pure-Citta row has no requirement.
246        assert!(required_capabilities(&effects_with(vec![CoreCapability::CittaUpdate])).is_empty());
247        assert!(required_capabilities(&EffectRow::pure()).is_empty());
248    }
249
250    #[test]
251    fn advisory_missing_allows_and_strips_nothing() {
252        let mut args = serde_json::json!({"content": "x"});
253        let outcome = evaluate(
254            &effects_with(vec![CoreCapability::MemoryWrite]),
255            &mut args,
256            CapabilityGateMode::Advisory,
257            1_000,
258        )
259        .expect("advisory allows");
260        assert!(matches!(outcome, GateOutcome::AdvisoryMissing { .. }));
261        assert_eq!(args["content"], "x");
262    }
263
264    #[test]
265    fn strict_missing_refuses_with_actionable_error() {
266        let mut args = serde_json::json!({});
267        let err = evaluate(
268            &effects_with(vec![CoreCapability::MemoryWrite]),
269            &mut args,
270            CapabilityGateMode::Strict,
271            1_000,
272        )
273        .unwrap_err();
274        assert!(err.contains("memory:write"), "{err}");
275        assert!(err.contains(ENGAGEMENT_KEY), "{err}");
276    }
277
278    #[test]
279    fn valid_credential_satisfies_and_is_stripped() {
280        let (mut args, _token) = credential_args(EngagementScope::Poc);
281        let outcome = evaluate(
282            &effects_with(vec![CoreCapability::MemoryWrite]),
283            &mut args,
284            CapabilityGateMode::Strict,
285            1_000,
286        )
287        .expect("poc token grants memory:write");
288        assert!(matches!(outcome, GateOutcome::Satisfied { .. }));
289        assert!(
290            args.get(ENGAGEMENT_KEY).is_none(),
291            "credential must be stripped before execution"
292        );
293    }
294
295    #[test]
296    fn insufficient_scope_is_refused_even_in_advisory_mode() {
297        let (mut args, _token) = credential_args(EngagementScope::Demo);
298        let err = evaluate(
299            &effects_with(vec![CoreCapability::MemoryWrite]),
300            &mut args,
301            CapabilityGateMode::Advisory,
302            1_000,
303        )
304        .unwrap_err();
305        assert!(err.contains("Missing capabilities"), "{err}");
306    }
307
308    #[test]
309    fn forged_credential_is_refused() {
310        let (mut args, token) = credential_args(EngagementScope::Poc);
311        // Corrupt the signature in the presented copy.
312        let bogus = "0".repeat(token.signature.len());
313        args[ENGAGEMENT_KEY]["token"]["signature"] = serde_json::json!(bogus);
314        let err = evaluate(
315            &effects_with(vec![CoreCapability::MemoryWrite]),
316            &mut args,
317            CapabilityGateMode::Advisory,
318            1_000,
319        )
320        .unwrap_err();
321        assert!(err.contains("signature is invalid"), "{err}");
322    }
323
324    #[test]
325    fn malformed_credential_is_refused() {
326        let mut args = serde_json::json!({ENGAGEMENT_KEY: {"nope": true}});
327        let err = evaluate(
328            &EffectRow::pure(),
329            &mut args,
330            CapabilityGateMode::Advisory,
331            1_000,
332        )
333        .unwrap_err();
334        assert!(err.contains("malformed engagement credential"), "{err}");
335    }
336
337    #[test]
338    fn mode_env_parsing_labels() {
339        assert!(CapabilityGateMode::Strict.is_strict());
340        assert!(!CapabilityGateMode::Advisory.is_strict());
341        assert_eq!(CapabilityGateMode::Advisory.label(), "advisory");
342    }
343}