Skip to main content

apl_cpex/
session_resolver.rs

1// Location: ./crates/apl-cpex/src/session_resolver.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// 3-tier session-id resolver. The Python apl-plugins `SessionResolver`
7// (cpex/framework/session.py) shipped a 4-tier version including a
8// client-supplied `X-CPEX-Session-Id` header tier. **That tier is
9// excluded by design here**: an authenticated client can set the
10// header to another subject's known session id and inherit their
11// accumulated taint labels, or to a new value and escape their own
12// tainted session — defeating `session.labels`-based deny policies
13// entirely. The Python comment framed the header as a feature ("lets
14// a smart client maintain its own session boundary"); under threat
15// modeling it is a privilege-escalation channel with no surviving
16// use case the other tiers don't cover. If a future deployment needs
17// client-supplied session grouping, the right shape is a subject-
18// bound hash (`sha256(subject_id : client_value)`), not the raw
19// header value.
20//
21// The resolver walks these tiers in order, returning the first hit:
22//
23//   0. `agent`      — `AgentExtension.session_id`. A *pre-resolved*
24//      value: an upstream plugin or middleware decided what the
25//      session is and wrote it here (for the FFI/AuthBridge path this
26//      is the client `X-Session-Id` header / A2A contextId). Highest
27//      priority among sources, but **subject-bound** before use
28//      (`sha256(subject_id : value)`): the raw value is attacker-chosen,
29//      so it must only scope state WITHIN the authenticated subject,
30//      never across principals. Falls through when no subject is present.
31//
32//   1. `token_claim` — explicit `session_id` claim in the inbound JWT.
33//      Strongest binding among the *derived* tiers: the auth issuer
34//      chose this session and signed it into the token. Read from
35//      `SecurityExtension.subject.claims["session_id"]` and **subject-
36//      bound** the same way (a signed claim is per-issuer and may repeat
37//      across principals, so the key must still include the subject).
38//
39//   2. `identity`   — derived: sha256(sub : caller_workload : this_workload)[:16].
40//      No special infrastructure needed; the triple is already populated
41//      by `cpex-plugin-identity-jwt`'s claim mapping. Same user + same agent +
42//      same gateway = same session, stable across token refresh (the
43//      claims are stable even when the token string isn't).
44//
45//   3. `none`       — no usable identifier; caller (CmfPluginInvoker)
46//      skips hydration / persistence. Returns `Ok(None)` so the caller
47//      can distinguish "no session" from "resolver error" if we ever
48//      add an error variant.
49//
50// Each tier reads from a typed `Extensions` field, not raw JWT/HTTP
51// payloads — those have already been mapped by upstream identity
52// plugins (cpex-plugin-identity-jwt). The resolver stays free of crypto /
53// parsing logic.
54
55use cpex_core::extensions::Extensions;
56use sha2::{Digest, Sha256};
57
58/// Which tier produced the session id. Useful for diagnostics / audit
59/// and to let downstream code branch on binding strength (e.g., only
60/// trust `token_claim`-derived sessions for the highest-stakes
61/// operations).
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum SessionSource {
64    /// Pre-resolved by an upstream plugin via `AgentExtension.session_id`.
65    /// Highest priority — represents an authoritative decision.
66    Agent,
67    /// JWT `session_id` claim — strongest binding among derived tiers.
68    TokenClaim,
69    /// Derived from the identity triple. Stable across token refresh.
70    Identity,
71}
72
73impl SessionSource {
74    pub fn as_str(self) -> &'static str {
75        match self {
76            SessionSource::Agent => "agent",
77            SessionSource::TokenClaim => "token_claim",
78            SessionSource::Identity => "identity",
79        }
80    }
81}
82
83/// 16 hex chars (64 bits) of `sha256(raw)`. Shared by the identity tier
84/// and the subject-binding of the Agent/TokenClaim tiers so all derived
85/// session ids have one keying scheme. Matches the Python implementation's
86/// `hexdigest()[:16]`.
87fn short_hash(raw: &str) -> String {
88    let mut hasher = Sha256::new();
89    hasher.update(raw.as_bytes());
90    let digest = hasher.finalize();
91    digest
92        .iter()
93        .take(8)
94        .map(|b| format!("{:02x}", b))
95        .collect()
96}
97
98/// Bind a client/upstream-supplied raw session value to the authenticated
99/// subject: `sha256(subject_id : raw)`. This is the subject-bound shape the
100/// module doc prescribes for the (previously raw) Agent and TokenClaim tiers,
101/// so a session id chosen by one principal cannot address another principal's
102/// session bucket. Returns `None` when there is no authenticated subject — a
103/// bare client value has no safe scope, consistent with Tiers 2/3, which also
104/// require a subject.
105fn subject_scoped(subject_id: Option<&str>, raw: &str) -> Option<String> {
106    let sub = subject_id?;
107    Some(short_hash(&format!("{}:{}", sub, raw)))
108}
109
110/// Resolve a session id from the request's `Extensions`. Returns
111/// `Some((id, source))` on the first tier that hits, or `None` when
112/// every tier comes up empty (anonymous request, no claims, no
113/// header, no identity).
114///
115/// Identity-tier (2) requires at minimum `security.subject.id` to be
116/// populated — without an end-user identifier there's no meaningful
117/// session boundary to hash against. The other two identity-triple
118/// components (caller_workload, this_workload) fall back to the
119/// `"-"` sentinel when absent, which keeps the hash defined but
120/// degrades to a (sub, *, *) session — usually fine for demos with
121/// a single gateway and single agent.
122pub fn resolve_session(ext: &Extensions) -> Option<(String, SessionSource)> {
123    // The authenticated subject, populated by the identity resolvers
124    // (cpex-plugin-identity-jwt) before this runs. Every client/upstream-supplied
125    // session value below is bound to it so one principal can't address
126    // another's session bucket.
127    let subject_id = ext
128        .security
129        .as_deref()
130        .and_then(|s| s.subject.as_ref())
131        .and_then(|s| s.id.as_deref());
132
133    // Tier 0: pre-resolved by an upstream plugin (for the FFI/AuthBridge
134    // path this is `X-Session-Id` / the A2A contextId). Authoritative among
135    // sources, but subject-bound here rather than trusted raw: the raw value
136    // is attacker-chosen, so it only ever scopes state WITHIN the
137    // authenticated subject. Falls through when no subject is present.
138    if let Some(agent) = ext.agent.as_deref() {
139        if let Some(sid) = agent.session_id.as_deref() {
140            if !sid.is_empty() {
141                if let Some(bound) = subject_scoped(subject_id, sid) {
142                    return Some((bound, SessionSource::Agent));
143                }
144            }
145        }
146    }
147
148    // Tier 1: explicit JWT `session_id` claim — also subject-bound. Even a
149    // signed claim is per-issuer and could repeat across principals, so the
150    // store key must still incorporate the subject.
151    if let Some(sec) = ext.security.as_deref() {
152        if let Some(subj) = sec.subject.as_ref() {
153            if let Some(sid) = subj.claims.get("session_id") {
154                if !sid.is_empty() {
155                    if let Some(bound) = subject_scoped(subject_id, sid) {
156                        return Some((bound, SessionSource::TokenClaim));
157                    }
158                }
159            }
160        }
161    }
162
163    // Tier 2: identity-derived. Hash the triple
164    // (end-user : calling agent : our gateway) — stable across token
165    // refresh because all three components survive token rotation.
166    if let Some(sec) = ext.security.as_deref() {
167        let sub = sec.subject.as_ref().and_then(|s| s.id.as_deref());
168        if let Some(sub) = sub {
169            // Fall back to `-` so a missing component degrades the
170            // session to (sub, *, *) rather than the resolver silently
171            // returning None. Important for demos where the gateway
172            // hasn't yet attested its own `this_workload` identity.
173            let actor = sec
174                .caller_workload
175                .as_ref()
176                .and_then(|w| w.client_id.as_deref())
177                .unwrap_or("-");
178            let aud = sec
179                .this_workload
180                .as_ref()
181                .and_then(|w| w.client_id.as_deref())
182                .unwrap_or("-");
183            let raw = format!("{}:{}:{}", sub, actor, aud);
184            return Some((short_hash(&raw), SessionSource::Identity));
185        }
186    }
187
188    // Tier 3: no session.
189    None
190}
191
192// =====================================================================
193// Tests — one scenario per tier, plus tier-priority assertions.
194// =====================================================================
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use cpex_core::extensions::{
200        AgentExtension, Extensions, HttpExtension, SecurityExtension, SubjectExtension,
201        WorkloadIdentity,
202    };
203    use std::sync::Arc;
204
205    fn extensions_with_security(sec: SecurityExtension) -> Extensions {
206        Extensions {
207            security: Some(Arc::new(sec)),
208            ..Default::default()
209        }
210    }
211
212    fn subject_with_claims(id: Option<&str>, claims: &[(&str, &str)]) -> SubjectExtension {
213        SubjectExtension {
214            id: id.map(String::from),
215            claims: claims
216                .iter()
217                .map(|(k, v)| (k.to_string(), v.to_string()))
218                .collect(),
219            ..Default::default()
220        }
221    }
222
223    // Build Extensions carrying both an agent.session_id and a subject id.
224    fn extensions_with_agent_and_subject(session_id: &str, subject_id: &str) -> Extensions {
225        let mut agent = AgentExtension::default();
226        agent.session_id = Some(session_id.into());
227        Extensions {
228            agent: Some(Arc::new(agent)),
229            security: Some(Arc::new(SecurityExtension {
230                subject: Some(subject_with_claims(Some(subject_id), &[])),
231                ..Default::default()
232            })),
233            ..Default::default()
234        }
235    }
236
237    // --- Tier 0: agent (pre-resolved) ---
238
239    #[test]
240    fn tier0_agent_session_id_is_subject_bound() {
241        // A pre-resolved (client-supplied) session id is hashed together
242        // with the authenticated subject, never returned raw.
243        let ext = extensions_with_agent_and_subject("sess-upstream", "alice");
244        let (sid, src) = resolve_session(&ext).expect("should resolve");
245        assert_eq!(src, SessionSource::Agent);
246        assert_eq!(sid, subject_scoped(Some("alice"), "sess-upstream").unwrap());
247        assert_ne!(sid, "sess-upstream", "raw client value must not be the key");
248    }
249
250    #[test]
251    fn tier0_same_session_id_different_subjects_are_distinct() {
252        // Guarantee: principal A reusing principal B's
253        // session id must NOT land in B's session bucket.
254        let alice = extensions_with_agent_and_subject("shared-sid", "alice");
255        let bob = extensions_with_agent_and_subject("shared-sid", "bob");
256        let (sid_a, _) = resolve_session(&alice).unwrap();
257        let (sid_b, _) = resolve_session(&bob).unwrap();
258        assert_ne!(
259            sid_a, sid_b,
260            "same client session id under different subjects must not collide",
261        );
262    }
263
264    #[test]
265    fn tier0_stable_for_same_subject_and_session_id() {
266        // Same subject + same client session id → same key, so a legit
267        // user's taint persists across their own request/response cycles.
268        let (sid1, _) = resolve_session(&extensions_with_agent_and_subject("s1", "bob")).unwrap();
269        let (sid2, _) = resolve_session(&extensions_with_agent_and_subject("s1", "bob")).unwrap();
270        assert_eq!(sid1, sid2);
271    }
272
273    #[test]
274    fn tier0_no_subject_falls_through() {
275        // A client session id with no authenticated subject has no safe
276        // scope: do not honor it (no anonymous cross-readable bucket).
277        let mut agent = AgentExtension::default();
278        agent.session_id = Some("sess-upstream".into());
279        let ext = Extensions {
280            agent: Some(Arc::new(agent)),
281            ..Default::default()
282        };
283        assert!(resolve_session(&ext).is_none());
284    }
285
286    #[test]
287    fn tier0_skips_empty_agent_session_id() {
288        // Empty agent.session_id should fall through, otherwise an
289        // upstream that accidentally cleared the slot aliases every
290        // such request to "".
291        let mut agent = AgentExtension::default();
292        agent.session_id = Some("".into());
293        let ext = Extensions {
294            agent: Some(Arc::new(agent)),
295            security: Some(Arc::new(SecurityExtension {
296                subject: Some(subject_with_claims(Some("alice"), &[])),
297                ..Default::default()
298            })),
299            ..Default::default()
300        };
301        // Empty Tier 0 falls through; identity tier (subject present) hits.
302        let (_, src) = resolve_session(&ext).expect("should fall through to identity");
303        assert_eq!(src, SessionSource::Identity);
304    }
305
306    #[test]
307    fn tier0_wins_over_token_claim() {
308        // Pre-resolved value beats a JWT claim — upstream authority — and is
309        // subject-bound rather than returned raw.
310        let mut agent = AgentExtension::default();
311        agent.session_id = Some("from-agent".into());
312        let sec = SecurityExtension {
313            subject: Some(subject_with_claims(
314                Some("alice"),
315                &[("session_id", "from-token")],
316            )),
317            ..Default::default()
318        };
319        let ext = Extensions {
320            agent: Some(Arc::new(agent)),
321            security: Some(Arc::new(sec)),
322            ..Default::default()
323        };
324
325        let (sid, src) = resolve_session(&ext).unwrap();
326        assert_eq!(src, SessionSource::Agent);
327        assert_eq!(sid, subject_scoped(Some("alice"), "from-agent").unwrap());
328    }
329
330    #[test]
331    fn tier0_wins_over_identity() {
332        // T0 (agent.session_id) must win over T2 (identity triple) when
333        // both are available. Pins the tier priority explicitly so a
334        // future refactor of the resolver's walk order regresses loudly.
335        let mut agent = AgentExtension::default();
336        agent.session_id = Some("from-agent".into());
337        let sec = SecurityExtension {
338            subject: Some(subject_with_claims(Some("alice"), &[])),
339            caller_workload: Some(WorkloadIdentity {
340                client_id: Some("agent-007".into()),
341                ..Default::default()
342            }),
343            this_workload: Some(WorkloadIdentity {
344                client_id: Some("praxis-gateway".into()),
345                ..Default::default()
346            }),
347            ..Default::default()
348        };
349        let ext = Extensions {
350            agent: Some(Arc::new(agent)),
351            security: Some(Arc::new(sec)),
352            ..Default::default()
353        };
354
355        let (sid, src) = resolve_session(&ext).unwrap();
356        assert_eq!(
357            src,
358            SessionSource::Agent,
359            "T0 must win over T2 when both are available",
360        );
361        assert_eq!(sid, subject_scoped(Some("alice"), "from-agent").unwrap());
362    }
363
364    // --- Tier 1: token_claim ---
365
366    #[test]
367    fn tier1_token_claim_hits_when_session_id_claim_present() {
368        let sec = SecurityExtension {
369            subject: Some(subject_with_claims(
370                Some("alice@corp.com"),
371                &[("session_id", "sess-from-token-789")],
372            )),
373            ..Default::default()
374        };
375        let ext = extensions_with_security(sec);
376
377        let (sid, src) = resolve_session(&ext).expect("should resolve");
378        assert_eq!(src, SessionSource::TokenClaim);
379        // Subject-bound, not the raw claim value.
380        assert_eq!(
381            sid,
382            subject_scoped(Some("alice@corp.com"), "sess-from-token-789").unwrap()
383        );
384        assert_ne!(sid, "sess-from-token-789");
385    }
386
387    #[test]
388    fn tier1_skips_empty_session_id_claim() {
389        // Empty claim values should NOT win tier 1 — they degrade to
390        // identity-derived. Otherwise an issuer accidentally putting
391        // an empty string in the claim would yield "" as the session
392        // key, which would alias every such request.
393        let sec = SecurityExtension {
394            subject: Some(subject_with_claims(Some("alice"), &[("session_id", "")])),
395            ..Default::default()
396        };
397        let ext = extensions_with_security(sec);
398
399        let (_, src) = resolve_session(&ext).expect("should fall through to identity");
400        assert_eq!(src, SessionSource::Identity);
401    }
402
403    #[test]
404    fn tier1_same_session_id_claim_different_subjects_are_distinct() {
405        // The Finding 2 guarantee for T1. An issuer that reuses a
406        // session_id value across multiple principals (multi-tenant
407        // naming conventions, counters that don't carry the subject,
408        // etc.) must NOT let one principal land in another's session
409        // bucket. Direct mirror of the T0 cross-principal test.
410        let mk = |sub: &str| -> SecurityExtension {
411            SecurityExtension {
412                subject: Some(subject_with_claims(
413                    Some(sub),
414                    &[("session_id", "issuer-shared-sid")],
415                )),
416                ..Default::default()
417            }
418        };
419        let (sid_a, _) = resolve_session(&extensions_with_security(mk("alice"))).unwrap();
420        let (sid_b, _) = resolve_session(&extensions_with_security(mk("bob"))).unwrap();
421        assert_ne!(
422            sid_a, sid_b,
423            "same JWT session_id claim under different subjects must not collide",
424        );
425    }
426
427    #[test]
428    fn tier1_stable_for_same_subject_and_session_id_claim() {
429        // Same subject + same claim value → same key. A legit user's
430        // session stays consistent across requests carrying the same
431        // claim, so accumulated taint persists where it should.
432        let mk = || -> Extensions {
433            extensions_with_security(SecurityExtension {
434                subject: Some(subject_with_claims(
435                    Some("alice"),
436                    &[("session_id", "claim-value-42")],
437                )),
438                ..Default::default()
439            })
440        };
441        let (sid1, _) = resolve_session(&mk()).unwrap();
442        let (sid2, _) = resolve_session(&mk()).unwrap();
443        assert_eq!(sid1, sid2);
444    }
445
446    #[test]
447    fn tier1_no_subject_id_falls_through() {
448        // A JWT carries a `session_id` claim but has no `sub` (subject
449        // present but `id == None`). T1 has no safe scope without a
450        // subject — must fall through. T2 also requires a subject and
451        // therefore returns None overall.
452        let sec = SecurityExtension {
453            subject: Some(SubjectExtension {
454                id: None,
455                claims: [("session_id".to_string(), "claim-value".to_string())]
456                    .into_iter()
457                    .collect(),
458                ..Default::default()
459            }),
460            ..Default::default()
461        };
462        let ext = extensions_with_security(sec);
463        assert!(
464            resolve_session(&ext).is_none(),
465            "claim with no subject id has no safe scope; must not honor",
466        );
467    }
468
469    #[test]
470    fn tier1_wins_over_identity() {
471        // Both a JWT session_id claim AND a full identity triple are
472        // present. T1 must win over T2. Pins the tier priority
473        // explicitly — the existing happy-path test happens to omit
474        // T2 inputs, so without this T1>T2 priority is only implicit.
475        let sec = SecurityExtension {
476            subject: Some(subject_with_claims(
477                Some("alice"),
478                &[("session_id", "from-claim")],
479            )),
480            caller_workload: Some(WorkloadIdentity {
481                client_id: Some("agent-007".into()),
482                ..Default::default()
483            }),
484            this_workload: Some(WorkloadIdentity {
485                client_id: Some("praxis-gateway".into()),
486                ..Default::default()
487            }),
488            ..Default::default()
489        };
490        let ext = extensions_with_security(sec);
491
492        let (sid, src) = resolve_session(&ext).unwrap();
493        assert_eq!(
494            src,
495            SessionSource::TokenClaim,
496            "T1 must win over T2 when both are available",
497        );
498        assert_eq!(sid, subject_scoped(Some("alice"), "from-claim").unwrap());
499    }
500
501    // --- Tier 2 (`X-CPEX-Session-Id` header) is intentionally absent ---
502    //
503    // The Python `SessionResolver` included a header tier; cpex Rust
504    // does not. See the module-level doc comment for the threat model.
505    // A spoofing-regression guard lives below in
506    // `header_x_cpex_session_id_is_ignored`.
507
508    // --- Tier 2: identity ---
509
510    #[test]
511    fn tier2_identity_derived_when_no_claim() {
512        let sec = SecurityExtension {
513            subject: Some(subject_with_claims(Some("alice@corp.com"), &[])),
514            caller_workload: Some(WorkloadIdentity {
515                client_id: Some("agent-007".into()),
516                ..Default::default()
517            }),
518            this_workload: Some(WorkloadIdentity {
519                client_id: Some("praxis-gateway".into()),
520                ..Default::default()
521            }),
522            ..Default::default()
523        };
524        let ext = extensions_with_security(sec);
525
526        let (sid, src) = resolve_session(&ext).expect("should resolve");
527        assert_eq!(src, SessionSource::Identity);
528        // 16 hex chars (matches Python `sha256(...)[:16]`).
529        assert_eq!(sid.len(), 16);
530        assert!(sid.chars().all(|c| c.is_ascii_hexdigit()));
531    }
532
533    #[test]
534    fn tier2_identity_is_stable_across_calls() {
535        // Same triple → same session id. Property guarantees that
536        // a token refresh (which doesn't change sub/caller/this) keeps
537        // the session intact.
538        let mk = || -> SecurityExtension {
539            SecurityExtension {
540                subject: Some(subject_with_claims(Some("alice@corp.com"), &[])),
541                caller_workload: Some(WorkloadIdentity {
542                    client_id: Some("agent-007".into()),
543                    ..Default::default()
544                }),
545                this_workload: Some(WorkloadIdentity {
546                    client_id: Some("praxis-gateway".into()),
547                    ..Default::default()
548                }),
549                ..Default::default()
550            }
551        };
552        let ext1 = extensions_with_security(mk());
553        let ext2 = extensions_with_security(mk());
554        let (sid1, _) = resolve_session(&ext1).unwrap();
555        let (sid2, _) = resolve_session(&ext2).unwrap();
556        assert_eq!(sid1, sid2);
557    }
558
559    #[test]
560    fn tier2_distinguishes_different_users() {
561        let alice = SecurityExtension {
562            subject: Some(subject_with_claims(Some("alice"), &[])),
563            ..Default::default()
564        };
565        let bob = SecurityExtension {
566            subject: Some(subject_with_claims(Some("bob"), &[])),
567            ..Default::default()
568        };
569        let (sid_a, _) = resolve_session(&extensions_with_security(alice)).unwrap();
570        let (sid_b, _) = resolve_session(&extensions_with_security(bob)).unwrap();
571        assert_ne!(sid_a, sid_b);
572    }
573
574    #[test]
575    fn tier2_distinguishes_different_agents() {
576        // Same user, two different agents → different sessions.
577        // Important so a malicious agent's accumulated taints don't
578        // affect a different agent that user runs.
579        let mk = |agent: &str| -> SecurityExtension {
580            SecurityExtension {
581                subject: Some(subject_with_claims(Some("alice"), &[])),
582                caller_workload: Some(WorkloadIdentity {
583                    client_id: Some(agent.into()),
584                    ..Default::default()
585                }),
586                ..Default::default()
587            }
588        };
589        let (sid1, _) = resolve_session(&extensions_with_security(mk("agent-a"))).unwrap();
590        let (sid2, _) = resolve_session(&extensions_with_security(mk("agent-b"))).unwrap();
591        assert_ne!(sid1, sid2);
592    }
593
594    // --- Tier 3: none ---
595
596    #[test]
597    fn tier3_no_session_when_no_data() {
598        let ext = Extensions::default();
599        assert!(resolve_session(&ext).is_none());
600    }
601
602    #[test]
603    fn tier3_no_session_when_no_subject_id() {
604        // Security exists but no subject id → identity can't hash.
605        // Claim is absent too. Should be None.
606        let sec = SecurityExtension {
607            subject: Some(SubjectExtension::default()), // id = None
608            ..Default::default()
609        };
610        let ext = extensions_with_security(sec);
611        assert!(resolve_session(&ext).is_none());
612    }
613
614    // --- Wire-format documentation ---
615
616    #[test]
617    fn separator_format_collides_when_subject_contains_colon() {
618        // Document — but do not silently ignore — the colon-separator
619        // format's known ambiguity. A colon inside subject_id collides
620        // with one inside the raw value: both
621        //   subject="alice:foo", raw="bar"
622        // and
623        //   subject="alice",     raw="foo:bar"
624        // hash the same string "alice:foo:bar" and thus produce the
625        // same session key.
626        //
627        // JWT `sub` claims are conventionally opaque URNs or emails,
628        // which in practice don't carry colons. This test asserts the
629        // collision exists so a future migration that introduces
630        // colon-bearing subject IDs (or changes the separator format
631        // unilaterally) breaks the build and forces a deliberate
632        // re-design — most likely a length-prefixed format like
633        // `{sub_len}:{sub}:{raw}`.
634        let a = subject_scoped(Some("alice:foo"), "bar").unwrap();
635        let b = subject_scoped(Some("alice"), "foo:bar").unwrap();
636        assert_eq!(
637            a, b,
638            "current format collides; if subject IDs can contain colons, switch to length-prefix",
639        );
640    }
641
642    // --- Spoofing guard (regression test for P0-2) ---
643
644    #[test]
645    fn header_x_cpex_session_id_is_ignored() {
646        // The Python apl-plugins resolver honored an `X-CPEX-Session-Id`
647        // header tier between token_claim and identity. We deliberately
648        // dropped it: an authenticated client could set the header to
649        // another subject's session id and inherit their accumulated
650        // taints, or to a random unused value and escape their own
651        // tainted session. This test pins that behaviour: the header is
652        // present, no token claim exists, and the resolver still falls
653        // through to identity-derived (or none) rather than honoring
654        // the header. If a future PR adds a header tier without
655        // subject binding, this test fails.
656        let sec = SecurityExtension {
657            subject: Some(subject_with_claims(Some("alice"), &[])),
658            caller_workload: Some(WorkloadIdentity {
659                client_id: Some("agent-007".into()),
660                ..Default::default()
661            }),
662            ..Default::default()
663        };
664        let mut http = HttpExtension::default();
665        http.request_headers
666            .insert("X-CPEX-Session-Id".into(), "sess-bob-stolen".into());
667        let ext = Extensions {
668            security: Some(Arc::new(sec)),
669            http: Some(Arc::new(http)),
670            ..Default::default()
671        };
672
673        let (sid, src) = resolve_session(&ext).expect("identity should still hit");
674        assert_eq!(
675            src,
676            SessionSource::Identity,
677            "header tier was removed; resolver must NOT honor X-CPEX-Session-Id",
678        );
679        assert_ne!(
680            sid, "sess-bob-stolen",
681            "header value must never become the session id",
682        );
683    }
684}