apl-cpex 0.2.1

APL ↔ CPEX runtime bridge — per-hook PluginInvoker implementations.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
// Location: ./crates/apl-cpex/tests/capability_gating.rs
// Copyright 2025
// SPDX-License-Identifier: Apache-2.0
// Authors: Teryl Taylor
//
// Capability-gating end-to-end. cpex-core's executor calls
// `filter_extensions(&ext, &caps)` before every handler invoke — so the
// synthetic `AplRouteHandler` must declare a capability set wide enough
// to cover every downstream plugin it dispatches, otherwise:
//
//   - APL predicates read from a stripped attribute bag (silently wrong
//     policy decisions).
//   - Downstream plugins receive a doubly-filtered view (their own caps
//     applied on top of an already-stripped one).
//   - Write attempts (append_labels, append_delegation, write_headers)
//     fail the monotonicity check on the way back out of the handler.
//
// These tests verify the visitor computes
// `base_capabilities ∪ per-route plugin union` and sets it on the
// synthetic `PluginConfig`.

use std::sync::Arc;

use async_trait::async_trait;

use cpex_core::cmf::enums::Role;
use cpex_core::cmf::{CmfHook, Message, MessagePayload};
use cpex_core::context::PluginContext;
use cpex_core::error::PluginError as CoreError;
use cpex_core::extensions::{MetaExtension, SecurityExtension};
use cpex_core::factory::{PluginFactory, PluginInstance};
use cpex_core::hooks::adapter::TypedHandlerAdapter;
use cpex_core::hooks::payload::Extensions;
use cpex_core::hooks::trait_def::{HookHandler, PluginResult};
use cpex_core::manager::PluginManager;
use cpex_core::plugin::{Plugin, PluginConfig};

use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore};

// =====================================================================
// Fixtures
// =====================================================================

/// Plugin that records whether it saw `security.labels` populated.
/// Used to verify that `read_labels` capability propagates through the
/// synthetic handler so the inner plugin's filtered view actually
/// contains labels.
struct LabelReader {
    cfg: PluginConfig,
    observed_labels: Arc<std::sync::Mutex<Vec<String>>>,
}

#[async_trait]
impl Plugin for LabelReader {
    fn config(&self) -> &PluginConfig {
        &self.cfg
    }
}

impl HookHandler<CmfHook> for LabelReader {
    async fn handle(
        &self,
        _payload: &MessagePayload,
        extensions: &Extensions,
        _ctx: &mut PluginContext,
    ) -> PluginResult<MessagePayload> {
        let seen: Vec<String> = extensions
            .security
            .as_ref()
            .map(|s| s.labels.iter().cloned().collect())
            .unwrap_or_default();
        *self.observed_labels.lock().unwrap() = seen;
        PluginResult::allow()
    }
}

struct LabelReaderFactory {
    observed_labels: Arc<std::sync::Mutex<Vec<String>>>,
}

impl PluginFactory for LabelReaderFactory {
    fn create(&self, config: &PluginConfig) -> Result<PluginInstance, Box<CoreError>> {
        let plugin = Arc::new(LabelReader {
            cfg: config.clone(),
            observed_labels: Arc::clone(&self.observed_labels),
        });
        Ok(PluginInstance {
            plugin: plugin.clone(),
            handlers: vec![(
                "cmf.tool_pre_invoke",
                Arc::new(TypedHandlerAdapter::<CmfHook, _>::new(plugin)),
            )],
        })
    }
}

/// Plugin that appends a label via `modify_extensions`. Used to verify
/// write-cap propagation: requires both an `append_labels` declaration
/// on the plugin AND the synthetic handler to also be granted
/// `append_labels` so the executor accepts the mutation on the way
/// back out.
struct LabelWriter {
    cfg: PluginConfig,
}

#[async_trait]
impl Plugin for LabelWriter {
    fn config(&self) -> &PluginConfig {
        &self.cfg
    }
}

impl HookHandler<CmfHook> for LabelWriter {
    async fn handle(
        &self,
        _payload: &MessagePayload,
        extensions: &Extensions,
        _ctx: &mut PluginContext,
    ) -> PluginResult<MessagePayload> {
        let mut owned = extensions.cow_copy();
        let security = owned.security.get_or_insert_with(Default::default);
        security.add_label("APPENDED");
        PluginResult::modify_extensions(owned)
    }
}

struct LabelWriterFactory;
impl PluginFactory for LabelWriterFactory {
    fn create(&self, config: &PluginConfig) -> Result<PluginInstance, Box<CoreError>> {
        let plugin = Arc::new(LabelWriter {
            cfg: config.clone(),
        });
        Ok(PluginInstance {
            plugin: plugin.clone(),
            handlers: vec![(
                "cmf.tool_pre_invoke",
                Arc::new(TypedHandlerAdapter::<CmfHook, _>::new(plugin)),
            )],
        })
    }
}

// =====================================================================
// Helpers
// =====================================================================

fn cmf_payload(text: &str) -> MessagePayload {
    MessagePayload {
        message: Message::text(Role::User, text),
    }
}

fn meta_for_tool(name: &str) -> MetaExtension {
    let mut meta = MetaExtension::default();
    meta.entity_type = Some("tool".to_string());
    meta.entity_name = Some(name.to_string());
    meta
}

fn extensions_with_label(label: &str) -> Extensions {
    let mut security = SecurityExtension::default();
    security.add_label(label.to_string());
    Extensions {
        meta: Some(Arc::new(meta_for_tool("get_weather"))),
        security: Some(Arc::new(security)),
        ..Default::default()
    }
}

// =====================================================================
// Scenarios
// =====================================================================

/// Plugin declares `read_labels`; route references it; pre-existing
/// label `EXISTING` is set on the request extensions. The plugin must
/// observe the label — proving the synthetic `AplRouteHandler` got
/// `read_labels` from the per-route plugin union (cpex-core's filter
/// would otherwise strip security.labels at the handler boundary).
#[tokio::test]
async fn plugin_with_read_labels_sees_labels_through_apl_handler() {
    const YAML: &str = r#"
plugins:
  - name: label-reader
    kind: label-reader
    hooks: [cmf.tool_pre_invoke]
    capabilities: [read_labels]
routes:
  - tool: get_weather
    apl:
      pre_invocation:
        - "plugin(label-reader)"
"#;

    let observed = Arc::new(std::sync::Mutex::new(Vec::new()));
    let mgr = Arc::new(PluginManager::default());
    mgr.register_factory(
        "label-reader",
        Box::new(LabelReaderFactory {
            observed_labels: Arc::clone(&observed),
        }),
    );
    register_apl(
        &mgr,
        AplOptions {
            dispatch_cache: Arc::new(DispatchCache::new()),
            session_store: Arc::new(MemorySessionStore::new()),
            pdps: Vec::new(),
            pdp_factories: Vec::new(),
            session_store_factories: Vec::new(),
            base_capabilities: None,
        },
    );
    mgr.load_config_yaml(YAML).expect("load_config_yaml");
    mgr.initialize().await.expect("initialize");

    let ext = extensions_with_label("EXISTING");
    let (result, _bg) = mgr
        .invoke_named::<CmfHook>("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None)
        .await;
    assert!(
        result.continue_processing,
        "plugin shouldn't deny: {:?}",
        result.violation
    );

    let seen = observed.lock().unwrap().clone();
    assert_eq!(
        seen,
        vec!["EXISTING".to_string()],
        "plugin must observe the EXISTING label that the request carried; \
         empty means the synthetic AplRouteHandler stripped security.labels \
         because its cap union didn't include read_labels"
    );
}

/// Same plugin shape, but DON'T declare `read_labels` on the plugin
/// and set an empty `base_capabilities` so neither the per-route
/// union nor the baseline grants the cap. The plugin must NOT see
/// labels — confirms the negative case (capability gating actually
/// hides things when caps are missing).
#[tokio::test]
async fn plugin_without_read_labels_sees_stripped_view() {
    const YAML: &str = r#"
plugins:
  - name: label-reader
    kind: label-reader
    hooks: [cmf.tool_pre_invoke]
routes:
  - tool: get_weather
    apl:
      pre_invocation:
        - "plugin(label-reader)"
"#;

    let observed = Arc::new(std::sync::Mutex::new(Vec::new()));
    let mgr = Arc::new(PluginManager::default());
    mgr.register_factory(
        "label-reader",
        Box::new(LabelReaderFactory {
            observed_labels: Arc::clone(&observed),
        }),
    );
    // Strict mode: empty baseline → only per-plugin caps grant
    // anything, and the plugin declared none.
    register_apl(
        &mgr,
        AplOptions {
            dispatch_cache: Arc::new(DispatchCache::new()),
            session_store: Arc::new(MemorySessionStore::new()),
            pdps: Vec::new(),
            pdp_factories: Vec::new(),
            session_store_factories: Vec::new(),
            base_capabilities: Some(std::collections::HashSet::new()),
        },
    );
    mgr.load_config_yaml(YAML).expect("load_config_yaml");
    mgr.initialize().await.expect("initialize");

    let ext = extensions_with_label("EXISTING");
    let (result, _bg) = mgr
        .invoke_named::<CmfHook>("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None)
        .await;
    assert!(result.continue_processing);

    let seen = observed.lock().unwrap().clone();
    assert!(
        seen.is_empty(),
        "plugin should see no labels when neither it nor the baseline \
         grants read_labels — got: {:?}",
        seen
    );
}

/// Plugin declares `append_labels` and emits a new label via
/// `modify_extensions`. The synthetic `AplRouteHandler` must also be
/// granted `append_labels` (from the per-route union) so its outer
/// modify_extensions write doesn't get rejected on the way back out.
/// After the invoke, the appended label must be visible in the final
/// extensions.
#[tokio::test]
async fn write_capabilities_propagate_through_apl_handler() {
    const YAML: &str = r#"
plugins:
  - name: label-writer
    kind: label-writer
    hooks: [cmf.tool_pre_invoke]
    capabilities: [append_labels, read_labels]
routes:
  - tool: get_weather
    apl:
      pre_invocation:
        - "plugin(label-writer)"
"#;

    let mgr = Arc::new(PluginManager::default());
    mgr.register_factory("label-writer", Box::new(LabelWriterFactory));
    register_apl(&mgr, AplOptions::in_process());
    mgr.load_config_yaml(YAML).expect("load_config_yaml");
    mgr.initialize().await.expect("initialize");

    let ext = Extensions {
        meta: Some(Arc::new(meta_for_tool("get_weather"))),
        ..Default::default()
    };
    let (result, _bg) = mgr
        .invoke_named::<CmfHook>("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None)
        .await;
    assert!(
        result.continue_processing,
        "label-writer should allow: {:?}",
        result.violation
    );

    // The appended label should be visible on the way out via
    // `modified_extensions` — None means no plugin wrote anything,
    // which would be a failure here.
    let modified = result
        .modified_extensions
        .expect("label-writer should have modified extensions");
    let labels: Vec<String> = modified
        .security
        .as_ref()
        .map(|s| s.labels.iter().cloned().collect())
        .unwrap_or_default();
    assert!(
        labels.contains(&"APPENDED".to_string()),
        "expected APPENDED to land in final security.labels — \
         a missing label means the executor rejected the write on the \
         way out of AplRouteHandler (no append_labels cap on the synthetic). \
         Got: {:?}",
        labels
    );
}

/// Predicate-only route: no plugins, just `require(authenticated)`.
/// APL evaluates this against the attribute bag built from the
/// (capability-filtered) Extensions view the handler sees. Default
/// baseline grants `read_subject`, so `authenticated` evaluates to
/// `true` when subject is present.
#[tokio::test]
async fn predicate_only_route_uses_baseline_capabilities() {
    const YAML: &str = r#"
plugins: []
routes:
  - tool: get_weather
    apl:
      pre_invocation:
        - "require(authenticated)"
"#;
    let mgr = Arc::new(PluginManager::default());
    register_apl(&mgr, AplOptions::in_process());
    mgr.load_config_yaml(YAML).expect("load_config_yaml");
    mgr.initialize().await.expect("initialize");

    // Set subject id so `authenticated` derives true via apl-cmf.
    let mut security = SecurityExtension::default();
    security.subject = Some(cpex_core::extensions::SubjectExtension {
        id: Some("alice".to_string()),
        ..Default::default()
    });
    let ext = Extensions {
        meta: Some(Arc::new(meta_for_tool("get_weather"))),
        security: Some(Arc::new(security)),
        ..Default::default()
    };

    let (result, _bg) = mgr
        .invoke_named::<CmfHook>("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None)
        .await;
    assert!(
        result.continue_processing,
        "require(authenticated) should pass with subject.id set: violation = {:?}",
        result.violation
    );
}

/// Same predicate-only route but baseline is forcibly empty AND no
/// subject is set. With empty baseline the synthetic handler has no
/// caps, so security.subject is stripped → `authenticated` evaluates
/// false → `require(authenticated)` denies. Confirms the baseline
/// actually controls what predicates can read.
#[tokio::test]
async fn empty_baseline_strips_predicate_view() {
    const YAML: &str = r#"
plugins: []
routes:
  - tool: get_weather
    apl:
      pre_invocation:
        - "require(authenticated)"
"#;
    let mgr = Arc::new(PluginManager::default());
    register_apl(
        &mgr,
        AplOptions {
            dispatch_cache: Arc::new(DispatchCache::new()),
            session_store: Arc::new(MemorySessionStore::new()),
            pdps: Vec::new(),
            pdp_factories: Vec::new(),
            session_store_factories: Vec::new(),
            base_capabilities: Some(std::collections::HashSet::new()),
        },
    );
    mgr.load_config_yaml(YAML).expect("load_config_yaml");
    mgr.initialize().await.expect("initialize");

    // Even though subject.id IS set, the empty baseline means the
    // synthetic handler can't read subject — predicate sees missing →
    // false → require denies.
    let mut security = SecurityExtension::default();
    security.subject = Some(cpex_core::extensions::SubjectExtension {
        id: Some("alice".to_string()),
        ..Default::default()
    });
    let ext = Extensions {
        meta: Some(Arc::new(meta_for_tool("get_weather"))),
        security: Some(Arc::new(security)),
        ..Default::default()
    };

    let (result, _bg) = mgr
        .invoke_named::<CmfHook>("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None)
        .await;
    assert!(
        !result.continue_processing,
        "empty baseline should cause require(authenticated) to deny \
         even with subject set — capability gating proves it can't see"
    );
}