Skip to main content

cpex_hosts_python/
extensions.rs

1// Location: ./crates/cpex-hosts-python/src/extensions.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Ted Habeck
5//
6// Extensions wire contract for out-of-process Python plugins.
7//
8// # Why this exists
9//
10// `worker.py` calls `execute_plugin` without an `extensions` argument, so every
11// out-of-process hook sees `extensions=None`. A plugin using the 3-arg
12// `(payload, context, extensions)` signature — the form `_accepts_extensions`
13// detects in `cpex/framework/base.py` — silently loses all extension context
14// when it runs out-of-process: security labels, agent lineage, HTTP headers,
15// MCP metadata. In-process plugins see all of it. That gap is what this module
16// closes, in both directions.
17//
18// # Two directions, one contract
19//
20// Outbound (`attach_extensions`): serialize the capability-filtered
21// `&Extensions` the executor handed the adapter and attach it to the task. The
22// filtered view is used deliberately rather than the full extensions, so a
23// plugin sees only the slots its declared capabilities permit and this host
24// never re-derives filtering the executor already did.
25//
26// Inbound (`parse_returned_extensions`): read the worker's returned extensions
27// and rebuild an `OwnedExtensions` for the executor's existing copy-on-write
28// merge, which enforces the mutability tiers. This module adds no tier logic.
29//
30// # Why the return path rebuilds instead of deserializing
31//
32// `Extensions::validate_immutable` enforces the immutable tier with
33// `Arc::ptr_eq` — pointer identity, not value equality. A JSON round trip
34// allocates a fresh `Arc` for every slot, so an `OwnedExtensions` deserialized
35// wholesale from the wire would fail that check on *every* immutable slot and
36// the executor would reject the whole return with a spurious "violated
37// immutable tier" warning, even for a plugin that only touched `custom`.
38//
39// So the return path reuses the inbound `Arc`s for immutable slots — exactly
40// what `cow_copy()` does — and takes only the mutable, monotonic, and guarded
41// slots (`http`, `security`, `delegation`, `custom`) from the wire. Two
42// consequences, both intended:
43//
44//   1. Honest plugins pass `validate_immutable`, because the immutable slots
45//      are the same pointers the executor issued.
46//   2. A plugin that tries to forge an immutable slot has that edit dropped
47//      here rather than rejected downstream. The spec outcome ("immutable slots
48//      do not change") holds structurally, without trusting the wire.
49//
50// # Credentials are not on this channel
51//
52// This channel carries non-secret context only. `raw_credentials` is never
53// serialized here — raw tokens travel the capability-gated DTO in the
54// `credentials` module instead. Sensitive headers are stripped in both
55// directions per `docs/specs/cmf-message-spec.md` §3.5; a plugin that needs a
56// bearer token uses the credential path, not `http.request_headers`.
57
58use std::collections::HashMap;
59
60use cpex_core::extensions::container::{chain_extends, OwnedExtensions};
61use cpex_core::extensions::delegation::DelegationExtension;
62use cpex_core::extensions::guarded::Guarded;
63use cpex_core::extensions::http::HttpExtension;
64use cpex_core::extensions::security::SecurityExtension;
65use cpex_core::extensions::Extensions;
66use serde_json::Value;
67use tracing::warn;
68
69use crate::error::HostError;
70
71/// Task field the inbound extensions ride on. Contract with `worker.py`, which
72/// reads it and passes the reconstructed object as `extensions=` to
73/// `execute_plugin`; both sides must agree verbatim.
74pub const EXTENSIONS_FIELD: &str = "extensions";
75
76/// Response field the returned extensions ride on.
77///
78/// Deliberately *not* the same name as [`EXTENSIONS_FIELD`]. The worker's
79/// response is a serialized Python `PluginResult`, and that model already
80/// carries `modified_extensions: Optional[Extensions]` (`cpex/framework/
81/// models.py`) — the same field the in-process Python manager accumulates. This
82/// host reads the field the model already produces rather than asking the worker
83/// to invent a second one, so an out-of-process plugin returns extensions the
84/// exact same way its in-process equivalent does.
85pub const MODIFIED_EXTENSIONS_FIELD: &str = "modified_extensions";
86
87/// Headers stripped in both directions (spec §3.5).
88///
89/// Matched case-insensitively: HTTP header names are case-insensitive, and
90/// `HttpExtension`'s own accessors look up that way, so a plugin sending
91/// `authorization` must not slip past a case-sensitive compare.
92/// `set-cookie` is included because the doc comment on `sanitize_http` promises
93/// it and the response map is the one that carries it. It was previously absent
94/// while both strip tests asserted through `is_sensitive` itself — a tautology
95/// that passed either way, which is what let the gap survive.
96const SENSITIVE_HEADERS: &[&str] = &["authorization", "cookie", "set-cookie", "x-api-key"];
97
98/// True when `name` is a header this channel must not carry.
99fn is_sensitive(name: &str) -> bool {
100    SENSITIVE_HEADERS
101        .iter()
102        .any(|s| name.eq_ignore_ascii_case(s))
103}
104
105/// Drop sensitive entries from a header map, leaving the rest untouched.
106fn strip_sensitive(headers: &HashMap<String, String>) -> HashMap<String, String> {
107    headers
108        .iter()
109        .filter(|(name, _)| !is_sensitive(name))
110        .map(|(name, value)| (name.clone(), value.clone()))
111        .collect()
112}
113
114/// Copy an `HttpExtension` with sensitive headers removed from both maps.
115///
116/// Both maps are scrubbed: `response_headers` can carry a `Set-Cookie` or an
117/// upstream `Authorization` echo just as `request_headers` carries the inbound
118/// credential. The request line is copied through unchanged — it is not
119/// credential material, and the plugin needs it to make routing decisions.
120fn sanitize_http(http: &HttpExtension) -> HttpExtension {
121    HttpExtension {
122        request_headers: strip_sensitive(&http.request_headers),
123        response_headers: strip_sensitive(&http.response_headers),
124        method: http.method.clone(),
125        path: http.path.clone(),
126        host: http.host.clone(),
127        scheme: http.scheme.clone(),
128    }
129}
130
131/// Build the returned `http` value: worker headers, canonical request line.
132///
133/// The worker's return contributes header maps only. `method`, `path`, `host`,
134/// and `scheme` are taken from `inbound` — the value the executor issued —
135/// because policies gate on them (CHANGELOG 0.2.2) and `write_headers` does not
136/// authorize rewriting request identity. `host` especially must trace back to a
137/// validated authority, which a worker's JSON is not.
138///
139/// This also fixes a plain correctness bug: the Python `HttpExtension` models a
140/// single `headers` field, so a round trip through the worker returns no
141/// `method`/`path`/`host`/`scheme` at all. Taking them from the return value
142/// blanked all four on every merge, silently turning host- and path-gated
143/// policies into no-ops downstream.
144fn returned_http(returned: &HttpExtension, inbound: Option<&HttpExtension>) -> HttpExtension {
145    HttpExtension {
146        request_headers: strip_sensitive(&returned.request_headers),
147        response_headers: strip_sensitive(&returned.response_headers),
148        method: inbound.and_then(|h| h.method.clone()),
149        path: inbound.and_then(|h| h.path.clone()),
150        host: inbound.and_then(|h| h.host.clone()),
151        scheme: inbound.and_then(|h| h.scheme.clone()),
152    }
153}
154
155/// Serialize the filtered extensions and attach them to `task`.
156///
157/// `extensions` is the capability-filtered view the executor produced for this
158/// plugin, so slot visibility is already correct — an absent slot means the
159/// plugin's capabilities excluded it, and it stays absent on the wire.
160///
161/// `raw_credentials` is excluded outright. Its token fields are `#[serde(skip)]`
162/// so they would serialize empty anyway, but sending a hollow slot invites a
163/// plugin to read it as "no credential present" rather than "not on this
164/// channel". Omitting it says the latter unambiguously.
165///
166/// Attaching nothing is valid: extensions with no visible slots produce no
167/// field, and the worker passes `None` to `execute_plugin` exactly as today.
168pub fn attach_extensions(task: &mut Value, extensions: &Extensions) -> Result<(), HostError> {
169    let wire = to_wire(extensions)?;
170
171    // An empty object carries no information the worker can act on; omitting
172    // the field keeps the "no extensions" path byte-identical to a task built
173    // before this feature existed.
174    if wire.as_object().is_none_or(serde_json::Map::is_empty) {
175        return Ok(());
176    }
177
178    let object = task.as_object_mut().ok_or_else(|| HostError::Protocol {
179        message: "task must be a JSON object to carry extensions".into(),
180    })?;
181    object.insert(EXTENSIONS_FIELD.to_string(), wire);
182
183    Ok(())
184}
185
186/// Build the wire JSON for a filtered extensions view.
187///
188/// Slots serialize through their own serde impls — those are the source of
189/// truth for sub-field shape, so this function stays correct as the extension
190/// structs evolve. Only `http` is rewritten on the way out, to strip sensitive
191/// headers.
192fn to_wire(extensions: &Extensions) -> Result<Value, HostError> {
193    let mut map = serde_json::Map::new();
194
195    let mut put = |key: &str, value: Result<Value, serde_json::Error>| -> Result<(), HostError> {
196        let value = value.map_err(|e| HostError::Protocol {
197            message: format!("could not serialize the '{key}' extension for the worker: {e}"),
198        })?;
199        map.insert(key.to_string(), value);
200        Ok(())
201    };
202
203    if let Some(request) = &extensions.request {
204        put("request", serde_json::to_value(request))?;
205    }
206    if let Some(agent) = &extensions.agent {
207        put("agent", serde_json::to_value(agent))?;
208    }
209    if let Some(http) = &extensions.http {
210        put("http", serde_json::to_value(sanitize_http(http)))?;
211    }
212    if let Some(security) = &extensions.security {
213        put("security", serde_json::to_value(security))?;
214    }
215    if let Some(delegation) = &extensions.delegation {
216        put("delegation", serde_json::to_value(delegation))?;
217    }
218    if let Some(mcp) = &extensions.mcp {
219        put("mcp", serde_json::to_value(mcp))?;
220    }
221    if let Some(completion) = &extensions.completion {
222        put("completion", serde_json::to_value(completion))?;
223    }
224    if let Some(provenance) = &extensions.provenance {
225        put("provenance", serde_json::to_value(provenance))?;
226    }
227    if let Some(llm) = &extensions.llm {
228        put("llm", serde_json::to_value(llm))?;
229    }
230    if let Some(framework) = &extensions.framework {
231        put("framework", serde_json::to_value(framework))?;
232    }
233    if let Some(meta) = &extensions.meta {
234        put("meta", serde_json::to_value(meta))?;
235    }
236    if let Some(custom) = &extensions.custom {
237        put("custom", serde_json::to_value(custom))?;
238    }
239    // `raw_credentials` deliberately absent — see the module docs.
240
241    Ok(Value::Object(map))
242}
243
244/// Rebuild an `OwnedExtensions` from the worker's response, or `None`.
245///
246/// `None` means "no modified extensions" and is the documented no-change
247/// signal: the worker omits the field. Omission rather than an echo is
248/// deliberate — a JSON round trip allocates new `Arc`s, so an echoed immutable
249/// slot is indistinguishable from a forged one under `validate_immutable`'s
250/// pointer check. Omitting is the only representation that reads cleanly as
251/// "nothing changed".
252///
253/// `inbound` is the filtered view this plugin was sent. Its `Arc`s are reused
254/// for immutable slots so the executor's pointer-identity check passes; only
255/// the tiers a plugin may legitimately write are taken from the wire.
256///
257/// This function does not decide whether a change is *allowed* — the executor's
258/// merge does, via `validate_immutable`, the monotonic label check, and the
259/// write tokens carried on `inbound`. Malformed slot JSON is an error: a plugin
260/// that returned a `security` object the host cannot parse has not made "no
261/// change", and silently dropping it would hide the failure.
262pub fn parse_returned_extensions(
263    response: &Value,
264    inbound: &Extensions,
265) -> Result<Option<OwnedExtensions>, HostError> {
266    let Some(field) = response.get(MODIFIED_EXTENSIONS_FIELD) else {
267        return Ok(None);
268    };
269    owned_from_returned_slot(field, inbound)
270}
271
272/// Rebuild an `OwnedExtensions` from an already-extracted
273/// `modified_extensions` value.
274///
275/// Split out so the response-conversion path can call it with the raw slot it
276/// already looked up, without re-walking the response object.
277pub fn owned_from_returned_slot(
278    field: &Value,
279    inbound: &Extensions,
280) -> Result<Option<OwnedExtensions>, HostError> {
281    // Explicit null is the same statement as an omitted field. `worker.py`
282    // serializing `None` should not read as "clear every writable slot".
283    if field.is_null() {
284        return Ok(None);
285    }
286
287    let object = field.as_object().ok_or_else(|| HostError::Protocol {
288        message: format!(
289            "the worker returned a '{MODIFIED_EXTENSIONS_FIELD}' field that is not a JSON object"
290        ),
291    })?;
292
293    // Start from the inbound view: immutable slots keep their original `Arc`
294    // pointers, and the write tokens the executor issued are carried over.
295    let mut owned = inbound.cow_copy();
296
297    // Whether any slot was actually taken from the wire. Pydantic serializes
298    // unset Optionals as `null`, so a plugin that touched nothing still sends
299    // every key — that must read as "no modification" rather than a no-op merge
300    // the executor has to validate.
301    let mut applied = false;
302
303    fn slot<T: serde::de::DeserializeOwned>(
304        object: &serde_json::Map<String, Value>,
305        key: &str,
306    ) -> Result<Option<T>, HostError> {
307        match object.get(key) {
308            None | Some(Value::Null) => Ok(None),
309            Some(value) => serde_json::from_value(value.clone())
310                .map(Some)
311                .map_err(|e| HostError::Protocol {
312                    message: format!(
313                        "could not deserialize the '{key}' extension returned by the worker: {e}"
314                    ),
315                }),
316        }
317    }
318
319    // The three writable-but-gated slots are honored only when the executor
320    // issued the matching write token on the inbound view. The token is the
321    // host's *only* evidence that the plugin declared the write capability —
322    // `WriteToken::new()` is `pub(crate)` to `cpex-core`, so this crate cannot
323    // mint one, and a token can never be forged out of worker JSON. An edit
324    // without the token is dropped and the inbound value stands.
325    //
326    // Within a gated slot, only the *fields* the capability covers are taken
327    // from the wire; the rest are kept from `inbound`. A token authorizes a
328    // field, not the slot it lives in. `owned` starts as `inbound.cow_copy()`,
329    // which is a copy of the capability-*filtered* view, so any field left
330    // untouched here already holds the filtered value — the executor's
331    // `merge_owned` re-merges it against canonical state field by field, and a
332    // filtered-away field never overwrites what the plugin could not see.
333
334    // Guarded — `write_headers`. Headers only: the request line
335    // (`method`/`path`/`host`/`scheme`) is preserved from the inbound value,
336    // which policies gate on and this capability does not cover.
337    if let Some(http) = slot::<HttpExtension>(object, "http")? {
338        if inbound.http_write_token.is_some() {
339            // Strip on the way back too. A plugin cannot inject a credential
340            // header into the pipeline through its return value.
341            owned.http = Some(Guarded::new(returned_http(&http, inbound.http.as_deref())));
342            applied = true;
343        }
344    }
345
346    // Monotonic — `append_labels`. Labels *only*. Everything else on the slot
347    // is Immutable with `write_cap: None` in the tier model, so a labels token
348    // must not carry `subject`, `auth_method`, `client`, or either workload
349    // identity — otherwise `append_labels` alone would let a plugin rewrite the
350    // authenticated principal it was authenticated as. The executor re-checks
351    // `before ⊆ after` and drops a removal.
352    if let Some(security) = slot::<SecurityExtension>(object, "security")? {
353        if inbound.labels_write_token.is_some() {
354            let mut merged = inbound
355                .security
356                .as_deref()
357                .cloned()
358                .unwrap_or_else(SecurityExtension::default);
359            // Append-only, and only into the set the plugin was shown. Assigning
360            // the returned set would let a shorter one read as a removal.
361            for label in security.labels.iter() {
362                merged.add_label(label.clone());
363            }
364            owned.security = Some(merged);
365            applied = true;
366        }
367    }
368
369    // Monotonic — `append_delegation`. Validated rather than accepted blind: the
370    // returned chain must extend the inbound one, with every hop the plugin was
371    // shown left intact. A shortened or rewritten chain forges lineage — dropping
372    // the hop that recorded a scope narrowing widens effective authority — so the
373    // whole edit is refused and the inbound chain stands. `depth` and `delegated`
374    // are recomputed by the executor from the merged chain, never trusted here.
375    if let Some(delegation) = slot::<DelegationExtension>(object, "delegation")? {
376        if inbound.delegation_write_token.is_some() {
377            let inbound_chain = inbound
378                .delegation
379                .as_deref()
380                .map(|d| d.chain.as_slice())
381                .unwrap_or_default();
382            if chain_extends(inbound_chain, &delegation.chain) {
383                owned.delegation = Some(delegation);
384                applied = true;
385            } else {
386                warn!(
387                    inbound_hops = inbound_chain.len(),
388                    returned_hops = delegation.chain.len(),
389                    "dropping a returned delegation chain that does not extend the \
390                     one the plugin was given — an append-only chain cannot be \
391                     shortened or rewritten"
392                );
393            }
394        }
395    }
396
397    // Mutable — `AccessPolicy::Unrestricted`, so accepted without a token. It
398    // sets `applied` on its own, which is correct *because* the gated slots
399    // above are now merged per field: a `custom` write can no longer drag a
400    // capability-filtered `security` or `http` view into the merge behind it.
401    if let Some(custom) = slot::<HashMap<String, Value>>(object, "custom")? {
402        owned.custom = Some(custom);
403        applied = true;
404    }
405
406    // Immutable slots are ignored on purpose: `owned` already holds the
407    // inbound `Arc`s, so a forged edit here is dropped rather than merged.
408
409    if !applied {
410        return Ok(None);
411    }
412
413    Ok(Some(owned))
414}
415
416/// Reuse the inbound `Arc` for a slot the wire must not change.
417///
418/// Kept as a named helper so the intent reads at the call site in tests.
419#[cfg(test)]
420fn same_arc<T>(a: &Option<std::sync::Arc<T>>, b: &Option<std::sync::Arc<T>>) -> bool {
421    match (a, b) {
422        (Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
423        (None, None) => true,
424        _ => false,
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use std::collections::HashSet;
431    use std::sync::Arc;
432
433    use cpex_core::extensions::agent::AgentExtension;
434    use cpex_core::extensions::monotonic::MonotonicSet;
435
436    use super::*;
437
438    fn http_with_headers() -> HttpExtension {
439        let mut http = HttpExtension::default();
440        http.set_request_header("Authorization", "Bearer secret-token");
441        http.set_request_header("Cookie", "session=abc");
442        http.set_request_header("X-API-Key", "key-123");
443        http.set_request_header("X-Request-Id", "req-1");
444        http.set_response_header("Set-Cookie", "s=1");
445        http.set_response_header("Authorization", "Bearer echoed");
446        http.set_response_header("Content-Type", "application/json");
447        http
448    }
449
450    fn security_with_labels(labels: &[&str]) -> SecurityExtension {
451        let set: HashSet<String> = labels.iter().map(|s| s.to_string()).collect();
452        SecurityExtension {
453            labels: MonotonicSet::from_set(set),
454            ..Default::default()
455        }
456    }
457
458    fn populated() -> Extensions {
459        Extensions {
460            agent: Some(Arc::new(AgentExtension::default())),
461            http: Some(Arc::new(http_with_headers())),
462            security: Some(Arc::new(security_with_labels(&["PII"]))),
463            ..Default::default()
464        }
465    }
466
467    // -- Outbound: U1 --
468
469    #[test]
470    fn present_slots_land_on_the_wire() {
471        let mut task = serde_json::json!({"task_type": "run"});
472        attach_extensions(&mut task, &populated()).expect("serializing a populated view");
473
474        let wire = task
475            .get(EXTENSIONS_FIELD)
476            .expect("the field is attached")
477            .as_object()
478            .expect("the field is an object");
479
480        assert!(wire.contains_key("agent"), "agent slot present");
481        assert!(wire.contains_key("http"), "http slot present");
482        assert!(wire.contains_key("security"), "security slot present");
483    }
484
485    /// Assert a header map contains none of `names`, case-insensitively.
486    ///
487    /// Takes **literal** names rather than calling `is_sensitive`. The tests
488    /// below previously asserted `!is_sensitive(name)` over the surviving keys —
489    /// the same function under test on both sides, so the assertion held no
490    /// matter what `SENSITIVE_HEADERS` contained. That is what let `set-cookie`
491    /// be missing from the list while the comment claimed it was there, with
492    /// both strip tests green.
493    fn assert_absent(headers: &serde_json::Map<String, Value>, names: &[&str]) {
494        for name in names {
495            assert!(
496                !headers.keys().any(|k| k.eq_ignore_ascii_case(name)),
497                "header '{name}' must not cross the process boundary; got {:?}",
498                headers.keys().collect::<Vec<_>>()
499            );
500        }
501    }
502
503    #[test]
504    fn sensitive_request_headers_are_stripped_but_others_survive() {
505        let mut task = serde_json::json!({});
506        attach_extensions(&mut task, &populated()).expect("serializing");
507
508        let headers = task[EXTENSIONS_FIELD]["http"]["request_headers"]
509            .as_object()
510            .expect("request_headers is an object")
511            .clone();
512
513        assert_absent(&headers, &["Authorization", "Cookie", "X-API-Key"]);
514        assert_eq!(
515            headers.get("X-Request-Id").and_then(Value::as_str),
516            Some("req-1"),
517            "a non-sensitive header is preserved verbatim"
518        );
519    }
520
521    #[test]
522    fn sensitive_response_headers_are_stripped_too() {
523        let mut task = serde_json::json!({});
524        attach_extensions(&mut task, &populated()).expect("serializing");
525
526        let headers = task[EXTENSIONS_FIELD]["http"]["response_headers"]
527            .as_object()
528            .expect("response_headers is an object")
529            .clone();
530
531        // `Set-Cookie` is the one the module comment always claimed to strip.
532        // This assertion fails until it is actually in `SENSITIVE_HEADERS`.
533        assert_absent(&headers, &["Set-Cookie", "Authorization"]);
534        assert_eq!(
535            headers.get("Content-Type").and_then(Value::as_str),
536            Some("application/json"),
537        );
538    }
539
540    #[test]
541    fn set_cookie_is_stripped_in_both_directions_and_under_any_casing() {
542        // A session cookie is credential material. It must not reach a plugin
543        // outbound, nor be injectable by one on the return.
544        let mut http = HttpExtension::default();
545        http.set_response_header("set-cookie", "session=lower");
546        http.set_response_header("SET-COOKIE", "session=upper");
547        http.set_request_header("Set-Cookie", "session=req");
548
549        let sanitized = sanitize_http(&http);
550        assert!(
551            sanitized.response_headers.is_empty(),
552            "no casing of set-cookie survives outbound: {:?}",
553            sanitized.response_headers
554        );
555        assert!(sanitized.request_headers.is_empty());
556
557        let returned = returned_http(&http, None);
558        assert!(
559            returned.response_headers.is_empty() && returned.request_headers.is_empty(),
560            "nor inbound on the return path"
561        );
562    }
563
564    #[test]
565    fn lowercase_sensitive_headers_are_stripped() {
566        // HTTP header names are case-insensitive; a case-sensitive filter would
567        // leak the token whenever the host populated it in lower case.
568        let mut http = HttpExtension::default();
569        http.set_request_header("authorization", "Bearer sneaky");
570        http.set_request_header("x-api-key", "k");
571        let extensions = Extensions {
572            http: Some(Arc::new(http)),
573            ..Default::default()
574        };
575
576        let mut task = serde_json::json!({});
577        attach_extensions(&mut task, &extensions).expect("serializing");
578
579        let headers = task[EXTENSIONS_FIELD]["http"]["request_headers"]
580            .as_object()
581            .expect("request_headers is an object");
582        assert!(
583            headers.is_empty(),
584            "lower-cased sensitive headers must be stripped too, got {headers:?}"
585        );
586    }
587
588    #[test]
589    fn a_filtered_out_slot_is_absent_from_the_wire() {
590        // A plugin without `read_agent` gets a view with `agent: None`. The
591        // wire must not resurrect it.
592        let extensions = Extensions {
593            security: Some(Arc::new(security_with_labels(&["PII"]))),
594            ..Default::default()
595        };
596
597        let mut task = serde_json::json!({});
598        attach_extensions(&mut task, &extensions).expect("serializing");
599
600        let wire = task[EXTENSIONS_FIELD].as_object().expect("an object");
601        assert!(
602            !wire.contains_key("agent"),
603            "a slot the capabilities excluded stays excluded"
604        );
605        assert!(wire.contains_key("security"));
606    }
607
608    #[test]
609    fn raw_credentials_never_appear_on_the_wire() {
610        use cpex_core::extensions::raw_credentials::{
611            RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
612        };
613
614        let mut inbound_tokens = HashMap::new();
615        inbound_tokens.insert(
616            TokenRole::User,
617            RawInboundToken::new("super-secret", "Authorization", TokenKind::Jwt),
618        );
619
620        let extensions = Extensions {
621            raw_credentials: Some(Arc::new(RawCredentialsExtension {
622                inbound_tokens,
623                ..Default::default()
624            })),
625            security: Some(Arc::new(security_with_labels(&["PII"]))),
626            ..Default::default()
627        };
628
629        let mut task = serde_json::json!({});
630        attach_extensions(&mut task, &extensions).expect("serializing");
631
632        let wire = task[EXTENSIONS_FIELD].as_object().expect("an object");
633        assert!(
634            !wire.contains_key("raw_credentials"),
635            "the credential slot belongs to the credential DTO, not this channel"
636        );
637        let serialized = serde_json::to_string(&task).expect("re-serializing the task");
638        assert!(
639            !serialized.contains("super-secret"),
640            "no token bytes anywhere in the task JSON"
641        );
642    }
643
644    #[test]
645    fn empty_extensions_attach_no_field() {
646        let mut task = serde_json::json!({"task_type": "run"});
647        attach_extensions(&mut task, &Extensions::default()).expect("serializing an empty view");
648        assert!(
649            task.get(EXTENSIONS_FIELD).is_none(),
650            "a view with no visible slots leaves the task shape unchanged"
651        );
652    }
653
654    // -- Inbound: U4 --
655
656    #[test]
657    fn an_absent_field_means_no_change() {
658        let response = serde_json::json!({"payload": {}});
659        let parsed = parse_returned_extensions(&response, &populated()).expect("parsing");
660        assert!(
661            parsed.is_none(),
662            "an omitted field is the documented no-change signal"
663        );
664    }
665
666    #[test]
667    fn an_explicit_null_means_no_change() {
668        let response = serde_json::json!({"modified_extensions": null});
669        let parsed = parse_returned_extensions(&response, &populated()).expect("parsing");
670        assert!(parsed.is_none(), "null reads the same as omitted");
671    }
672
673    #[test]
674    fn a_non_object_field_is_a_protocol_error() {
675        let response = serde_json::json!({"modified_extensions": "nope"});
676        let err = parse_returned_extensions(&response, &populated());
677        assert!(err.is_err(), "a non-object field cannot be interpreted");
678    }
679
680    #[test]
681    fn immutable_slots_keep_their_inbound_arcs() {
682        // The executor's `validate_immutable` compares by pointer. If the
683        // return path allocated a fresh Arc here, every out-of-process
684        // extension return would be rejected as tampering.
685        let inbound = populated();
686        let response = serde_json::json!({
687            "modified_extensions": {"custom": {"k": "v"}}
688        });
689
690        let owned = parse_returned_extensions(&response, &inbound)
691            .expect("parsing")
692            .expect("a returned field yields modifications");
693
694        assert!(
695            same_arc(&inbound.agent, &owned.agent),
696            "the agent slot must be the very same Arc the executor issued"
697        );
698        assert!(inbound.validate_immutable(&owned), "the merge accepts it");
699    }
700
701    #[test]
702    fn a_forged_immutable_slot_is_dropped_not_merged() {
703        let inbound = populated();
704        // A plugin returning a different `agent` must not change it.
705        let response = serde_json::json!({
706            "modified_extensions": {
707                "agent": {"agent_id": "forged"},
708                "custom": {"ok": true}
709            }
710        });
711
712        let owned = parse_returned_extensions(&response, &inbound)
713            .expect("parsing")
714            .expect("modifications present");
715
716        assert!(
717            same_arc(&inbound.agent, &owned.agent),
718            "the forged agent edit is dropped, not merged"
719        );
720        assert!(
721            inbound.validate_immutable(&owned),
722            "so the rest of the return still merges cleanly"
723        );
724        assert!(
725            owned.custom.is_some(),
726            "the legitimate custom edit survives"
727        );
728    }
729
730    #[test]
731    fn a_custom_change_is_taken_as_is() {
732        let response = serde_json::json!({
733            "modified_extensions": {"custom": {"verdict": "clean", "score": 3}}
734        });
735        let owned = parse_returned_extensions(&response, &populated())
736            .expect("parsing")
737            .expect("modifications present");
738
739        let custom = owned.custom.expect("custom present");
740        assert_eq!(custom.get("verdict").and_then(Value::as_str), Some("clean"));
741        assert_eq!(custom.get("score").and_then(Value::as_i64), Some(3));
742    }
743
744    // The three gated slots below can only be *accepted* when the executor
745    // issued a write token, and `WriteToken::new()` is `pub(crate)` to
746    // `cpex-core` — this crate cannot mint one even in a test. That is the
747    // security property, so these tests assert the deny side here and the
748    // accept side is covered end-to-end through the real executor in
749    // `tests/extensions_merge_e2e.rs`, where tokens come from capabilities.
750
751    #[test]
752    fn a_label_append_without_the_token_is_dropped() {
753        let inbound = populated(); // labels: {PII}
754        assert!(
755            inbound.labels_write_token.is_none(),
756            "no append_labels capability was granted"
757        );
758
759        let response = serde_json::json!({
760            "modified_extensions": {"security": {"labels": ["PII", "SCANNED"]}}
761        });
762        let parsed = parse_returned_extensions(&response, &inbound).expect("parsing");
763
764        assert!(
765            parsed.is_none(),
766            "an unauthorized label append yields no modification, so the \
767             pipeline's labels are untouched"
768        );
769    }
770
771    #[test]
772    fn a_label_removal_without_the_token_cannot_strip_labels() {
773        // An out-of-process plugin must not be able to launder a
774        // declassification through this host by returning a shorter label set.
775        let inbound = populated(); // labels: {PII}
776        let response = serde_json::json!({
777            "modified_extensions": {"security": {"labels": []}}
778        });
779
780        let parsed = parse_returned_extensions(&response, &inbound).expect("parsing");
781        assert!(
782            parsed.is_none(),
783            "the removal never reaches the merge, so PII cannot be stripped"
784        );
785    }
786
787    #[test]
788    fn an_http_change_needs_the_write_token() {
789        // No token issued — the executor withheld `write_headers`.
790        let inbound = populated();
791        assert!(inbound.http_write_token.is_none());
792
793        let response = serde_json::json!({
794            "modified_extensions": {"http": {"request_headers": {"X-Added": "1"}}}
795        });
796        let parsed = parse_returned_extensions(&response, &inbound).expect("parsing");
797
798        assert!(
799            parsed.is_none(),
800            "an http edit without the capability is dropped entirely"
801        );
802    }
803
804    #[test]
805    fn a_delegation_change_without_the_token_is_dropped() {
806        let inbound = populated();
807        assert!(inbound.delegation_write_token.is_none());
808
809        let response = serde_json::json!({
810            "modified_extensions": {"delegation": {"hops": []}}
811        });
812        let parsed = parse_returned_extensions(&response, &inbound).expect("parsing");
813
814        assert!(
815            parsed.is_none(),
816            "the wire cannot add a delegation slot without the capability"
817        );
818    }
819
820    #[test]
821    fn an_unauthorized_gated_write_does_not_suppress_a_legitimate_custom_write() {
822        // A plugin can return both. The gated slot is dropped for want of a
823        // token; `custom` is mutable and must still land.
824        let inbound = populated();
825        let response = serde_json::json!({
826            "modified_extensions": {
827                "security": {"labels": ["PII", "SCANNED"]},
828                "custom": {"verdict": "clean"}
829            }
830        });
831
832        let owned = parse_returned_extensions(&response, &inbound)
833            .expect("parsing")
834            .expect("the custom write is a real modification");
835
836        assert_eq!(
837            owned.custom.as_ref().expect("custom present")["verdict"],
838            "clean"
839        );
840        let security = owned.security.as_ref().expect("the inbound slot stands");
841        assert!(
842            !security.labels.contains(&"SCANNED".to_string()),
843            "the unauthorized label append is still dropped"
844        );
845    }
846
847    // -- Per-field gating on the return path (review finding A) --
848
849    #[test]
850    fn a_custom_write_does_not_carry_a_filtered_security_view_into_the_merge() {
851        // The headline finding, at this layer. A plugin with no security
852        // capability returns only `custom`. `owned` starts from
853        // `inbound.cow_copy()`, so before the fix the resulting
854        // `OwnedExtensions` carried the plugin's *filtered* security value —
855        // and `merge_owned`'s slot swap then wrote it over canonical state.
856        //
857        // Here the inbound view is what a plugin with no `read_labels` gets:
858        // an empty label set. The merged result must not present that as an
859        // instruction to clear labels.
860        let inbound = Extensions {
861            security: Some(Arc::new(security_with_labels(&[]))),
862            ..Default::default()
863        };
864        assert!(inbound.labels_write_token.is_none());
865
866        let response = serde_json::json!({
867            "modified_extensions": {"custom": {"verdict": "clean"}}
868        });
869        let owned = parse_returned_extensions(&response, &inbound)
870            .expect("parsing")
871            .expect("the custom write is a real modification");
872
873        // No labels token, so the security slot must merge as a no-op against
874        // canonical state. Drive that through the real merge to prove it.
875        let mut canonical = Extensions {
876            security: Some(Arc::new(security_with_labels(&["PII", "HIPAA"]))),
877            ..Default::default()
878        };
879        canonical.merge_owned(owned);
880
881        let merged = canonical.security.as_ref().expect("slot present");
882        assert!(
883            merged.labels.contains(&"PII".to_string()),
884            "a capability-less custom write must not wipe canonical labels"
885        );
886        assert!(merged.labels.contains(&"HIPAA".to_string()));
887        assert_eq!(
888            canonical.custom.as_ref().expect("custom merged")["verdict"],
889            "clean",
890            "and the legitimate custom write still lands"
891        );
892    }
893
894    #[test]
895    fn the_returned_http_slot_preserves_the_inbound_request_line() {
896        // Policies gate on method/path/host/scheme (CHANGELOG 0.2.2). The
897        // Python `HttpExtension` models only `headers`, so a worker round trip
898        // returns none of the four — taking them from the return blanked all of
899        // them on every merge. A hostile worker could also rewrite `host`,
900        // which must trace to a validated authority.
901        let mut inbound_http = HttpExtension::default();
902        inbound_http.method = Some("POST".into());
903        inbound_http.path = Some("/api/v1/transfer".into());
904        inbound_http.host = Some("bank.internal".into());
905        inbound_http.scheme = Some("https".into());
906
907        // What a worker actually returns: headers only, request line absent.
908        let returned_absent = HttpExtension {
909            request_headers: [("X-Scanned".to_string(), "1".to_string())].into(),
910            ..Default::default()
911        };
912        let merged = returned_http(&returned_absent, Some(&inbound_http));
913        assert_eq!(merged.method.as_deref(), Some("POST"));
914        assert_eq!(merged.path.as_deref(), Some("/api/v1/transfer"));
915        assert_eq!(merged.host.as_deref(), Some("bank.internal"));
916        assert_eq!(merged.scheme.as_deref(), Some("https"));
917        assert_eq!(
918            merged.request_headers.get("X-Scanned").map(String::as_str),
919            Some("1")
920        );
921
922        // And a worker that *does* send a request line cannot override it.
923        let returned_hostile = HttpExtension {
924            method: Some("GET".into()),
925            path: Some("/healthz".into()),
926            host: Some("evil.example".into()),
927            scheme: Some("http".into()),
928            ..Default::default()
929        };
930        let merged = returned_http(&returned_hostile, Some(&inbound_http));
931        assert_eq!(
932            merged.host.as_deref(),
933            Some("bank.internal"),
934            "the worker cannot re-point the validated authority"
935        );
936        assert_eq!(merged.method.as_deref(), Some("POST"));
937        assert_eq!(merged.path.as_deref(), Some("/api/v1/transfer"));
938        assert_eq!(merged.scheme.as_deref(), Some("https"));
939    }
940
941    #[test]
942    fn a_returned_delegation_chain_is_validated_not_accepted_blind() {
943        // Without a token the slot is dropped regardless, so the validation
944        // itself is asserted directly against the shared `chain_extends`
945        // predicate the merge uses — the accept path needs a real executor
946        // token and lives in `tests/extensions_merge_e2e.rs`.
947        use cpex_core::extensions::delegation::DelegationHop;
948
949        let hop = |id: &str, scopes: Vec<&str>| DelegationHop {
950            subject_id: id.into(),
951            scopes_granted: scopes.into_iter().map(str::to_string).collect(),
952            ..Default::default()
953        };
954
955        let canonical = vec![
956            hop("user-1", vec!["read_hr"]),
957            hop("svc-a", vec!["read_hr"]),
958        ];
959
960        assert!(
961            chain_extends(&canonical, &canonical),
962            "an unchanged chain is a valid (empty) append"
963        );
964        let mut appended = canonical.clone();
965        appended.push(hop("svc-b", vec!["read_hr"]));
966        assert!(
967            chain_extends(&canonical, &appended),
968            "a real append is valid"
969        );
970
971        assert!(
972            !chain_extends(&canonical, &canonical[..1]),
973            "truncation drops the hop that recorded a narrowing — not an append"
974        );
975        let mut widened = canonical.clone();
976        widened[0].scopes_granted = vec!["admin".into()];
977        assert!(
978            !chain_extends(&canonical, &widened),
979            "widening an existing hop's scopes is not an append"
980        );
981        let mut reseated = canonical.clone();
982        reseated[1].subject_id = "attacker".into();
983        assert!(
984            !chain_extends(&canonical, &reseated),
985            "rewriting an existing hop's subject is not an append"
986        );
987    }
988
989    #[test]
990    fn malformed_slot_json_is_an_error_not_a_silent_drop() {
991        let response = serde_json::json!({
992            "modified_extensions": {"security": "not-an-object"}
993        });
994        let err = parse_returned_extensions(&response, &populated());
995        assert!(
996            err.is_err(),
997            "unparseable slot JSON must surface, not read as no-change"
998        );
999    }
1000}