Skip to main content

tf_types/
simulation.rs

1#![allow(clippy::unnecessary_to_owned)]
2//! TrustForge simulation harness — Rust mirror of
3//! `tools/tf-types-ts/src/core/simulation.ts`.
4//!
5//! Models the 12 scenarios DECISIONS.md asks the runtime to be able to
6//! execute headlessly. Every scenario uses only TrustForge primitives
7//! already implemented in this crate; no external IO. Designed for
8//! deterministic conformance + spec-validation runs.
9
10use 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); // replay
229            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            // Parallel hybrid composition: independent ed25519 + ml-dsa-65
424            // signatures over the same transcript. Forging one without the
425            // other must fail the hybrid verifier.
426            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            // Forge by replacing the PQ signature with garbage. Hybrid
459            // verifier (AND) must reject.
460            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            // With a danger_tag, E5 must flip allow to non-allow.
492            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
518/// Run every scenario and return the full report set.
519pub fn run_all_scenarios() -> Vec<ScenarioResult> {
520    ALL_SCENARIOS.iter().cloned().map(run_scenario).collect()
521}
522
523/// Shadow-mode helper: take an arbitrary GuardDecision and return what
524/// the daemon would do at EnforcementLevel E0 (record-only).
525pub 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}