1#![allow(clippy::unnecessary_to_owned)]
2use rand::rngs::OsRng;
11use rand::RngCore;
12use serde::{Deserialize, Serialize};
13
14use crate::crypto::{ed25519_verify, Ed25519Signer};
15use crate::crypto_pq::{ml_dsa_65_generate, ml_dsa_65_sign, ml_dsa_65_verify};
16use crate::guard::{
17 apply_enforcement_level, AgentGuard, EnforcementLevel, GuardDecision, GuardQuery,
18 NegativeCapability,
19};
20use crate::packet::{sign_packet, verify_packet, SignPacketArgs};
21use crate::policy_engine::{NativePolicyEngine, PolicyManifest, PolicyQuery, PolicyRule};
22use crate::quorum::{QuorumApprovalCollector, QuorumConfig, QuorumSignature};
23use crate::relay::{
24 sign_relay_authority, RelayAuthority, RelayFrame, RelayHandler, SignatureEnvelope,
25};
26use crate::session_migration::{migrate_session, verify_session_migration, TransportBinding};
27
28#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
29#[serde(rename_all = "kebab-case")]
30pub enum ScenarioName {
31 PartialTrustDomainMerge,
32 AiBoundaryBreach,
33 RelayLoss,
34 QuorumFailure,
35 FrameReplay,
36 ExpiredToken,
37 RevokedActorMidSession,
38 ForgedSignature,
39 HopCapExceeded,
40 EmergencyWithoutFollowup,
41 PqVerifierRejectsClassicalForgery,
42 ContinuousReauthDuringStream,
43}
44
45pub const ALL_SCENARIOS: [ScenarioName; 12] = [
46 ScenarioName::PartialTrustDomainMerge,
47 ScenarioName::AiBoundaryBreach,
48 ScenarioName::RelayLoss,
49 ScenarioName::QuorumFailure,
50 ScenarioName::FrameReplay,
51 ScenarioName::ExpiredToken,
52 ScenarioName::RevokedActorMidSession,
53 ScenarioName::ForgedSignature,
54 ScenarioName::HopCapExceeded,
55 ScenarioName::EmergencyWithoutFollowup,
56 ScenarioName::PqVerifierRejectsClassicalForgery,
57 ScenarioName::ContinuousReauthDuringStream,
58];
59
60#[derive(Clone, Debug, Serialize, Deserialize)]
61pub struct ScenarioResult {
62 pub name: ScenarioName,
63 pub ok: bool,
64 pub observations: Vec<String>,
65 pub failures: Vec<String>,
66}
67
68fn fresh_seed() -> [u8; 32] {
69 let mut seed = [0u8; 32];
70 OsRng.fill_bytes(&mut seed);
71 seed
72}
73
74fn empty_binding(kind: &str) -> TransportBinding {
75 TransportBinding {
76 binding_version: "1".into(),
77 kind: kind.into(),
78 endpoint: None,
79 exporter_key: None,
80 peer_cert_fingerprint: None,
81 tls_alpn: None,
82 established_at: None,
83 metadata: None,
84 }
85}
86
87pub fn run_scenario(name: ScenarioName) -> ScenarioResult {
88 let mut obs: Vec<String> = Vec::new();
89 let mut fails: Vec<String> = Vec::new();
90 match name.clone() {
91 ScenarioName::PartialTrustDomainMerge => {
92 let contract_a = serde_json::json!({
93 "actions": [{"name": "data.read", "risk": "R1"}],
94 "forbidden": [{"action": "data.delete", "reason": "domain A forbids deletion"}],
95 });
96 let contract_b = serde_json::json!({
97 "actions": [
98 {"name": "data.read", "risk": "R1"},
99 {"name": "data.delete", "risk": "R3"},
100 ],
101 });
102 let guard_a = AgentGuard::from_contract(&contract_a);
103 let guard_b = AgentGuard::from_contract(&contract_b);
104 let q = GuardQuery {
105 actor: None,
106 actor_claim: None,
107 action: "data.delete".into(),
108 target: Some("row/1".into()),
109 };
110 let a = guard_a.check(&q);
111 let b = guard_b.check(&q);
112 obs.push(format!("domain A: {}", a.kind()));
113 obs.push(format!("domain B: {}", b.kind()));
114 if a.kind() != "deny" || b.kind() == "deny" {
115 fails.push("expected A to deny and B to allow data.delete".into());
116 }
117 }
118 ScenarioName::AiBoundaryBreach => {
119 let contract = serde_json::json!({
120 "actions": [{"name": "file.read", "risk": "R0"}],
121 });
122 let guard = AgentGuard::from_contract(&contract);
123 let decision = guard.check(&GuardQuery {
124 actor: None,
125 actor_claim: None,
126 action: "shell.exec".into(),
127 target: Some("rm -rf /".into()),
128 });
129 obs.push(format!("shell.exec → {}", decision.kind()));
130 if decision.kind() != "deny" {
131 fails.push("agent boundary not enforced".into());
132 }
133 }
134 ScenarioName::RelayLoss => {
135 let issuer_seed = fresh_seed();
136 let issuer = Ed25519Signer::from_bytes(&issuer_seed);
137 let issuer_pub = issuer.public_key_bytes();
138 let mut authority = RelayAuthority {
139 relay_authority_version: "1".into(),
140 relay: "tf:actor:relay:example.com/edge".into(),
141 trust_domain: "example.com".into(),
142 kinds: vec!["forward-only".into()],
143 max_hop_count: Some(4),
144 rate_limit_per_minute: None,
145 valid_from: "2026-04-24T00:00:00Z".into(),
146 valid_until: Some("2026-04-25T00:00:00Z".into()),
147 issuer: "tf:actor:service:example.com/tf-daemon".into(),
148 constraints: None,
149 signature: SignatureEnvelope {
150 algorithm: "ed25519".into(),
151 signer: String::new(),
152 signature: String::new(),
153 },
154 };
155 authority = sign_relay_authority(authority, &issuer_seed);
156 let relay = RelayHandler::new(authority, issuer_pub);
157 let frame = RelayFrame {
158 ciphertext: vec![0u8; 16],
159 destination: "tf:actor:agent:example.com/x".into(),
160 priority: None,
161 hop_count: 0,
162 expires_at: Some("2026-04-24T11:00:00Z".into()),
163 source: None,
164 };
165 match relay.forward(&frame, "2026-04-24T12:00:00Z") {
166 Ok(_) => fails.push("expired frame should have been dropped".into()),
167 Err(e) => obs.push(format!("expired frame dropped: {}", e)),
168 }
169 }
170 ScenarioName::QuorumFailure => {
171 let collector = QuorumApprovalCollector::new(QuorumConfig {
172 min_approvers: 2,
173 of: vec![
174 "tf:actor:human:example.com/a".into(),
175 "tf:actor:human:example.com/b".into(),
176 ],
177 })
178 .expect("config");
179 let handle = collector.push("req-q", "2026-04-24T12:00:00Z");
180 handle.respond_as(
181 "tf:actor:human:example.com/a",
182 "approve",
183 QuorumSignature {
184 algorithm: "ed25519".into(),
185 signer: "tf:actor:human:example.com/a".into(),
186 signature: "AAA".into(),
187 },
188 );
189 handle.respond_as(
190 "tf:actor:human:example.com/b",
191 "deny",
192 QuorumSignature {
193 algorithm: "ed25519".into(),
194 signer: "tf:actor:human:example.com/b".into(),
195 signature: "BBB".into(),
196 },
197 );
198 match handle.outcome() {
199 Some(o) => {
200 obs.push(format!(
201 "quorum decision={} approvers={}",
202 o.decision,
203 o.approvers.len()
204 ));
205 if o.decision != "deny" {
206 fails.push("quorum should have denied".into());
207 }
208 }
209 None => fails.push("quorum did not produce an outcome".into()),
210 }
211 }
212 ScenarioName::FrameReplay => {
213 let priv_seed = fresh_seed();
214 let pair = Ed25519Signer::from_bytes(&priv_seed);
215 let pub_bytes = pair.public_key_bytes();
216 let m1 = migrate_session(
217 "s",
218 1,
219 empty_binding("websocket"),
220 empty_binding("quic"),
221 false,
222 None,
223 "tf:actor:agent:example.com/x",
224 &priv_seed,
225 Some("2026-04-24T12:00:00Z"),
226 );
227 let v1 = verify_session_migration(&m1, &pub_bytes, Some(0), None);
228 let v2 = verify_session_migration(&m1, &pub_bytes, Some(1), None); obs.push(format!("first migration ok={}, replay ok={}", v1.ok, v2.ok));
230 if !v1.ok || v2.ok {
231 fails.push("replay protection not triggered".into());
232 }
233 }
234 ScenarioName::ExpiredToken => {
235 let priv_seed = fresh_seed();
236 let pair = Ed25519Signer::from_bytes(&priv_seed);
237 let pub_bytes = pair.public_key_bytes();
238 match sign_packet(SignPacketArgs {
239 packet_id: "pkt-x".into(),
240 source: "tf:actor:agent:example.com/x".into(),
241 destination: "tf:actor:service:example.com/d".into(),
242 priority: "P3".into(),
243 payload: b"hi",
244 encoding: None,
245 compression: None,
246 emergency: false,
247 expires_at: Some("2026-04-23T00:00:00Z".into()),
248 ttl_hops: None,
249 route_constraints: None,
250 session_ref: None,
251 private_key: priv_seed,
252 signer: "tf:actor:agent:example.com/x".into(),
253 created_at: Some("2026-04-22T00:00:00Z".into()),
254 }) {
255 Ok(p) => {
256 let v = verify_packet(&p, &pub_bytes, "2026-04-25T00:00:00Z");
257 obs.push(format!(
258 "expired-token verify: ok={} reason={}",
259 v.ok,
260 v.reason.clone().unwrap_or_default()
261 ));
262 if v.ok {
263 fails.push("expired packet accepted".into());
264 }
265 }
266 Err(e) => fails.push(format!("sign failed: {}", e)),
267 }
268 }
269 ScenarioName::RevokedActorMidSession => {
270 let policy = PolicyManifest {
271 policy_version: "1".into(),
272 trust_domain: "example.com".into(),
273 engine_hint: None,
274 rules: vec![PolicyRule {
275 id: "deny.shell".into(),
276 effect: "deny".into(),
277 action: Some("shell.exec".into()),
278 action_pattern: None,
279 subject_pattern: None,
280 target_patterns: None,
281 approval: None,
282 proof_required: None,
283 constraints: None,
284 reason: None,
285 }],
286 negative_capabilities: Vec::new(),
287 continuous_reevaluation: None,
288 quorum_defaults: None,
289 };
290 let engine = NativePolicyEngine::new(policy);
291 let before = engine.evaluate(&PolicyQuery {
292 subject: "tf:actor:agent:example.com/x".into(),
293 instance: None,
294 action: "shell.exec".into(),
295 target: None,
296 context: Default::default(),
297 negative_capabilities: Vec::new(),
298 enforcement_level: None,
299 now: Some("2026-04-24T12:00:00Z".into()),
300 });
301 let after = engine.evaluate(&PolicyQuery {
302 subject: "tf:actor:agent:example.com/x".into(),
303 instance: None,
304 action: "file.delete".into(),
305 target: Some("/etc/passwd".into()),
306 context: Default::default(),
307 negative_capabilities: vec![NegativeCapability {
308 name: "file.delete".into(),
309 target: None,
310 reason: Some("actor revoked".into()),
311 overrides: None,
312 }],
313 enforcement_level: None,
314 now: Some("2026-04-24T12:00:00Z".into()),
315 });
316 obs.push(format!("pre={}, post={}", before.decision, after.decision));
317 if after.decision != "deny" {
318 fails.push("revoked actor still allowed".into());
319 }
320 }
321 ScenarioName::ForgedSignature => {
322 let real_seed = fresh_seed();
323 let other_seed = fresh_seed();
324 let other = Ed25519Signer::from_bytes(&other_seed);
325 let pub_other = other.public_key_bytes();
326 match sign_packet(SignPacketArgs {
327 packet_id: "pkt-forge".into(),
328 source: "tf:actor:agent:example.com/x".into(),
329 destination: "tf:actor:service:example.com/d".into(),
330 priority: "P3".into(),
331 payload: b"real",
332 encoding: None,
333 compression: None,
334 emergency: false,
335 expires_at: None,
336 ttl_hops: None,
337 route_constraints: None,
338 session_ref: None,
339 private_key: real_seed,
340 signer: "tf:actor:agent:example.com/x".into(),
341 created_at: Some("2026-04-24T12:00:00Z".into()),
342 }) {
343 Ok(p) => {
344 let v = verify_packet(&p, &pub_other, "2026-04-24T12:00:00Z");
345 obs.push(format!("forged check ok={}", v.ok));
346 if v.ok {
347 fails.push("packet verified under wrong public key".into());
348 }
349 }
350 Err(e) => fails.push(format!("sign failed: {}", e)),
351 }
352 }
353 ScenarioName::HopCapExceeded => {
354 let issuer_seed = fresh_seed();
355 let issuer = Ed25519Signer::from_bytes(&issuer_seed);
356 let issuer_pub = issuer.public_key_bytes();
357 let mut authority = RelayAuthority {
358 relay_authority_version: "1".into(),
359 relay: "tf:actor:relay:example.com/edge".into(),
360 trust_domain: "example.com".into(),
361 kinds: vec!["forward-only".into()],
362 max_hop_count: Some(2),
363 rate_limit_per_minute: None,
364 valid_from: "2026-04-24T00:00:00Z".into(),
365 valid_until: Some("2026-04-25T00:00:00Z".into()),
366 issuer: "tf:actor:service:example.com/tf-daemon".into(),
367 constraints: None,
368 signature: SignatureEnvelope {
369 algorithm: "ed25519".into(),
370 signer: String::new(),
371 signature: String::new(),
372 },
373 };
374 authority = sign_relay_authority(authority, &issuer_seed);
375 let relay = RelayHandler::new(authority, issuer_pub);
376 let frame = RelayFrame {
377 ciphertext: vec![0u8; 8],
378 destination: "tf:actor:agent:example.com/x".into(),
379 priority: None,
380 hop_count: 5,
381 expires_at: None,
382 source: None,
383 };
384 match relay.forward(&frame, "2026-04-24T12:00:00Z") {
385 Ok(_) => fails.push("hop cap not enforced".into()),
386 Err(e) => obs.push(format!("hop cap blocked: {}", e)),
387 }
388 }
389 ScenarioName::EmergencyWithoutFollowup => {
390 let priv_seed = fresh_seed();
391 match sign_packet(SignPacketArgs {
392 packet_id: "pkt-emerg".into(),
393 source: "tf:actor:human:example.com/alice".into(),
394 destination: "tf:actor:service:example.com/d".into(),
395 priority: "P0".into(),
396 payload: b"emergency",
397 encoding: None,
398 compression: None,
399 emergency: true,
400 expires_at: None,
401 ttl_hops: None,
402 route_constraints: None,
403 session_ref: None,
404 private_key: priv_seed,
405 signer: "tf:actor:human:example.com/alice".into(),
406 created_at: Some("2026-04-24T12:00:00Z".into()),
407 }) {
408 Ok(p) => {
409 let is_emergency = p.emergency.unwrap_or(false);
410 obs.push(format!("emergency packet={}", is_emergency));
411 if is_emergency {
412 obs.push(
413 "emergency invocation flagged incomplete (no follow-up review)".into(),
414 );
415 } else {
416 fails.push("emergency invocation should require follow-up review".into());
417 }
418 }
419 Err(e) => fails.push(format!("sign failed: {}", e)),
420 }
421 }
422 ScenarioName::PqVerifierRejectsClassicalForgery => {
423 let classical_seed = fresh_seed();
427 let classical = Ed25519Signer::from_bytes(&classical_seed);
428 let (pq_sk, pq_pk) = match ml_dsa_65_generate() {
429 Ok(p) => p,
430 Err(e) => {
431 fails.push(format!("ml-dsa-65 keygen: {}", e));
432 return ScenarioResult {
433 name,
434 ok: false,
435 observations: obs,
436 failures: fails,
437 };
438 }
439 };
440 let msg = b"hello hybrid";
441 let classical_sig = classical.sign(msg);
442 let pq_sig = match ml_dsa_65_sign(&pq_sk, msg) {
443 Ok(s) => s,
444 Err(e) => {
445 fails.push(format!("ml-dsa-65 sign: {}", e));
446 return ScenarioResult {
447 name,
448 ok: false,
449 observations: obs,
450 failures: fails,
451 };
452 }
453 };
454 let classical_ok =
455 ed25519_verify(&classical.public_key_bytes(), msg, &classical_sig).is_ok();
456 let pq_ok = ml_dsa_65_verify(&pq_pk, msg, &pq_sig);
457 let hybrid_ok = classical_ok && pq_ok;
458 let mut bad_pq = pq_sig.clone();
461 bad_pq[0] ^= 0xff;
462 let pq_forged_ok = ml_dsa_65_verify(&pq_pk, msg, &bad_pq);
463 let hybrid_after_forge = classical_ok && pq_forged_ok;
464 obs.push(format!(
465 "hybrid pre-forge ok={}, post-forge ok={}",
466 hybrid_ok, hybrid_after_forge
467 ));
468 if !hybrid_ok {
469 fails.push("hybrid signature did not verify in honest path".into());
470 }
471 if hybrid_after_forge {
472 fails.push("hybrid verifier accepted classical-only signature".into());
473 }
474 }
475 ScenarioName::ContinuousReauthDuringStream => {
476 let contract = serde_json::json!({
477 "actions": [{"name": "session.stream", "risk": "R2"}],
478 });
479 let guard = AgentGuard::from_contract(&contract);
480 let before = guard.check(&GuardQuery {
481 actor: None,
482 actor_claim: None,
483 action: "session.stream".into(),
484 target: None,
485 });
486 let after = apply_enforcement_level(before.clone(), EnforcementLevel::E5);
487 obs.push(format!("before={} after={}", before.kind(), after.kind()));
488 if before.kind() != "allow" {
489 fails.push("expected initial allow".into());
490 }
491 let contract2 = serde_json::json!({
493 "actions": [{"name": "session.stream", "risk": "R2", "danger_tags": ["privacy"]}],
494 });
495 let guard2 = AgentGuard::from_contract(&contract2);
496 let raw = guard2.check_raw(&GuardQuery {
497 actor: None,
498 actor_claim: None,
499 action: "session.stream".into(),
500 target: None,
501 });
502 let tightened = apply_enforcement_level(raw.clone(), EnforcementLevel::E5);
503 obs.push(format!("tightened={}", tightened.kind()));
504 if tightened.kind() == "allow" {
505 fails.push("E5 should deny allow-with-danger-tags after reauth".into());
506 }
507 }
508 }
509 let ok = fails.is_empty();
510 ScenarioResult {
511 name,
512 ok,
513 observations: obs,
514 failures: fails,
515 }
516}
517
518pub fn run_all_scenarios() -> Vec<ScenarioResult> {
520 ALL_SCENARIOS.iter().cloned().map(run_scenario).collect()
521}
522
523pub fn as_shadow_decision(d: GuardDecision) -> GuardDecision {
526 apply_enforcement_level(d, EnforcementLevel::E0)
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 #[test]
534 fn each_scenario_runs() {
535 for s in ALL_SCENARIOS.iter().cloned() {
536 let r = run_scenario(s.clone());
537 assert!(r.ok, "scenario {:?} failed: {:?}", s, r.failures);
538 }
539 }
540
541 #[test]
542 fn run_all_returns_twelve() {
543 let results = run_all_scenarios();
544 assert_eq!(results.len(), 12);
545 for r in results {
546 assert!(r.ok, "scenario {:?} failed: {:?}", r.name, r.failures);
547 }
548 }
549}