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