1use 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
44pub const ENGAGEMENT_KEY: &str = "_engagement";
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct DispatchEngagementCredential {
51 pub token: EngagementToken,
53 pub issuer_public_key: String,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum CapabilityGateMode {
61 Advisory,
63 Strict,
65}
66
67impl CapabilityGateMode {
68 #[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 #[must_use]
80 pub const fn is_strict(self) -> bool {
81 matches!(self, Self::Strict)
82 }
83
84 #[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum GateOutcome {
133 NotRequired,
135 Satisfied { required: CapabilitySet },
137 AdvisoryMissing { required: CapabilitySet },
139}
140
141pub 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 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 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 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}