Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,23 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
- **BREAKING: `TokenRole::Workload` renamed to `TokenRole::CallerWorkload`.** A serde `alias = "workload"` keeps existing serialized config loading, but the Rust symbol is renamed — downstream Rust code must update. (#131)
- **BREAKING: `DelegationMode::AsGateway` renamed to `AsThisWorkload`.** A serde `alias = "as_gateway"` keeps persisted values deserializing. (#131)
- **BREAKING: `DelegationKey` is now `#[non_exhaustive]`** and gained a `client_id` field (partitioning the delegated-token cache per calling OAuth client, mirroring `workload_id`). Construct it via `DelegationKey::new(mode, audience, scopes)` + the `with_subject_id` / `with_workload_id` / `with_client_id` setters rather than a struct literal. (#131)
- **BREAKING: `PipelineResult` is now `#[non_exhaustive]`** and gained a `payload_modified` field. `modified_payload` is `Some` on every allowed pipeline, carrying the final payload whether or not a plugin touched it, so it never answered "did anything change?" — read the new flag for that. Construct via `allowed_with` / `denied` plus the `with_errors` / `with_payload_modified` builders rather than a struct literal; exhaustive destructuring must gain a `..` arm. (#151)
- **`payload_modified` is carried across the FFI to the Python and Go bindings.** `FfiPipelineResult`, `PyPipelineResult` (as a `payload_modified` property), and Go's `PipelineResult` / `TypedPipelineResult` all expose it, so non-Rust hosts can distinguish an accepted mutation from a payload the pipeline merely carried. Additive on the MessagePack wire, so the FFI ABI version is unchanged. The Go `Invoke` doc example no longer presents `ModifiedPayload != nil` as a mutation test — it is true on every allowed pipeline. (#151)
- **BREAKING: a pipeline field name reported to a plugin is now root-relative everywhere.** A `do:`-block field op passed `args.city` where the `args:` / `result:` sections passed `city`; both now pass `city`, with the phase selecting the root. The type of `PluginInvocation::Field.name` is unchanged, so this is a silent semantic change: any out-of-tree `PluginInvoker` that stripped an `args.` / `result.` prefix must drop that handling or it will mis-resolve the field. (#151)
- **A pipeline stage plugin that rewrites a field to the value it already held is treated as no change.** Previously any returned payload marked the field replaced. (#151)
- **`payload_modified` errs toward reporting a change.** It records that the executor *accepted* an edit, not that the bytes differ, so it trips on a plugin returning an untouched clone and on a field pipeline writing a field the value it already held. Both previously reported no change. Operators sizing this: inside the engine it costs a `Value` clone and a `Message` clone with no serialization, but a host that keys its wire re-encode off "did the payload change?" will now re-encode on somewhat more routes, and for FFI hosts the MessagePack step rides along. The direction is deliberate. A false positive costs one redundant re-encode; the false negative was the vulnerability fixed in this release, so a modest throughput shift on mutating routes is expected rather than a regression. (#151)

### Deprecated

- The reserved `all` group and the `global.policies:` bundle location, in favor of the top-level `groups:` section. Both still load. (#131)

### Fixed

- **Plugin payload mutations are no longer silently discarded.** A plugin that rewrote anything other than a message's text — a tool result, a tool call's arguments, a thinking block, an attachment — had its mutation dropped by the APL route handler, which decided "was this modified?" by comparing concatenated text content. Redaction and sanitisation plugins are precisely the ones that rewrite tool results, so the failure was fail-open on the path that matters most: the plugin reported a successful redaction and the host forwarded the original secret. Mutation is now reported by the executor at the point it accepts a plugin's payload (`PipelineResult.payload_modified`) and read from there, so it no longer depends on which part of the message changed. Plugins that appended a throwaway text part to force the old check to fire can drop that workaround. (#151)
- **A field pipeline no longer clobbers a plugin's edit to the same content part.** Folding an `args:` or `result:` pipeline's rewrite back into the message replaced the whole argument map / result content, discarding edits a plugin had made to other fields of it. Only the paths the pipeline actually changed are applied now, so a pipeline redacting one argument and a plugin scrubbing another both survive. (#151)
- **A plugin invoked as a pipeline stage now reports a value for the field it was pointed at.** It previously reported the message's concatenated text as the field's new value, which for a structured tool call meant an unrelated argument was overwritten with chat text. A plugin that rewrote some other part of the payload now reports no field change, and its mutation travels with the payload instead. The reported value is compared against the field as the payload held it before the plugin ran, so a `plugin(...)` stage that leaves the field alone can no longer undo an earlier `mask` / `redact` / `hash` stage in the same chain — those interim edits live only in the pipeline, never in the payload, and comparing against them handed the pre-redaction value back as if the plugin had produced it. (#151)
- **A field pipeline that conflicts with a plugin on the same path now logs the tie-break.** `args:` / `result:` pipeline edits take precedence over a plugin's edit to the same field (config-author-wins), a key the plugin removed comes back if the pipeline rewrote it, and a key the pipeline omitted goes even if the plugin had rewritten it. Every case warns with the field name instead of resolving silently. (#151)

## [0.2.2] - 2026-07-15

### Added
Expand Down
2 changes: 2 additions & 0 deletions bindings/python/python/cpex/_lib.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ class PipelineResult:
@property
def modified_payload(self) -> Optional[dict]: ...
@property
def payload_modified(self) -> bool: ...
@property
def modified_extensions(self) -> Optional[dict]: ...
@property
def violation(self) -> Optional[dict]: ...
Expand Down
13 changes: 13 additions & 0 deletions bindings/python/src/result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ use crate::conversions::{json_value_to_pyobj, serialize_payload};
pub struct PyPipelineResult {
pub continue_processing: bool,
pub modified_payload: Option<Value>,
pub payload_modified: bool,
pub modified_extensions: Option<Value>,
pub violation: Option<Value>,
pub errors: Vec<Value>,
Expand Down Expand Up @@ -56,6 +57,17 @@ impl PyPipelineResult {
}
}

/// Whether a plugin's payload modification was accepted.
///
/// `modified_payload` is set on every allowed pipeline, carrying the
/// final payload whether or not a plugin touched it, so it never
/// answered "did anything change?". Read this instead — comparing
/// payload contents cannot see mutations to non-text content parts.
#[getter]
fn payload_modified(&self) -> bool {
self.payload_modified
}

#[getter]
fn modified_extensions<'py>(&self, py: Python<'py>) -> PyResult<Option<Bound<'py, PyDict>>> {
match &self.modified_extensions {
Expand Down Expand Up @@ -198,6 +210,7 @@ pub fn pipeline_result_to_py(mut result: PipelineResult) -> PyResult<PyPipelineR
Ok(PyPipelineResult {
continue_processing: result.continue_processing,
modified_payload: modified_payload_value,
payload_modified: result.payload_modified,
modified_extensions: modified_extensions_value,
violation: violation_value,
errors: errors_value,
Expand Down
82 changes: 81 additions & 1 deletion crates/apl-core/src/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1160,7 +1160,12 @@ async fn dispatch_field_op(
let pipeline = crate::pipeline::Pipeline {
stages: stages.to_vec(),
};
let eval = evaluate_pipeline(&pipeline, &current, bag, plugins, path, phase).await;
// `subpath`, not `path`: the field name a pipeline reports to a
// plugin is relative to the args / result root, matching what the
// `args:` / `result:` section pipelines pass. The prefixed `path`
// stays in use for deny messages, where the reader wants the side
// spelled out.
let eval = evaluate_pipeline(&pipeline, &current, bag, plugins, subpath, phase).await;
taints.extend(eval.taints);
let mark_modified = |side: Side, args: &mut bool, result: &mut bool| match side {
Side::Args => *args = true,
Expand Down Expand Up @@ -1440,6 +1445,13 @@ pub async fn evaluate_pipeline(
});
},
Stage::Plugin { name } => {
// Known limitation: `current` carries the edits earlier
// stages made, but the invoker's payload does not — a
// `mask` before this stage is visible here and invisible
// to the plugin, which reads the field from the payload.
// Pushing interim pipeline state into the payload would
// change what every later plugin in the request sees, so
// it's left alone deliberately.
let invocation = PluginInvocation::Field {
name: field_name,
value: &current,
Expand Down Expand Up @@ -3668,6 +3680,74 @@ mod tests {
);
}

/// A plugin stage learns which field it's operating on from the
/// invocation's `name`. That name is relative to the args / result
/// root at every call site, so an invoker can look the field up in
/// its own payload projection without having to strip a prefix —
/// which it couldn't do safely anyway, since `args` is a legal
/// argument name.
#[tokio::test]
async fn field_op_reports_a_root_relative_field_name_to_plugins() {
/// Captures the field name the pipeline passed down.
struct NameRecorder {
seen: std::sync::Mutex<Vec<String>>,
}
#[async_trait]
impl PluginInvoker for NameRecorder {
async fn invoke(
&self,
_name: &str,
_bag: &AttributeBag,
invocation: PluginInvocation<'_>,
) -> Result<PluginOutcome, PluginError> {
if let PluginInvocation::Field { name, .. } = invocation {
self.seen.lock().unwrap().push(name.to_string());
}
Ok(PluginOutcome::allow())
}
}

let recorder = Arc::new(NameRecorder {
seen: std::sync::Mutex::new(Vec::new()),
});
let plugins: Arc<dyn PluginInvoker> = recorder.clone();

let rule = Rule {
condition: Expression::Always,
effects: vec![Effect::FieldOp {
path: "args.user.ssn".into(),
stages: vec![Stage::Plugin {
name: "scrubber".into(),
}],
}],
source: "demo.policy[0]".into(),
};
let mut bag = AttributeBag::new();
let mut payload = crate::route::RoutePayload::new(json!({
"user": {"ssn": "123-45-6789"},
}));

let _ = evaluate_effects(
&[Effect::from(rule)],
&mut bag,
&(Arc::new(FakePdp {
decision: Decision::Allow,
}) as Arc<dyn PdpResolver>),
&plugins,
&noop_delegations(),
&noop_elicitations(),
crate::step::DispatchPhase::Pre,
&mut payload,
)
.await;

assert_eq!(
recorder.seen.lock().unwrap().as_slice(),
["user.ssn".to_string()],
"the `args.` prefix belongs to the config path, not the field name"
);
}

#[tokio::test]
async fn field_op_targeting_result_in_pre_phase_is_skipped() {
// A `result.X | ...` op encountered during the Pre phase is a
Expand Down
4 changes: 3 additions & 1 deletion crates/apl-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ pub use pipeline::{FieldRule, Pipeline, ScanKind, Stage, TaintEvent, TaintScope,
pub use plugin_decl::{
CapsView, EffectivePlugin, PluginDeclaration, PluginOverride, PluginRegistry,
};
pub use route::{evaluate_post, evaluate_pre, evaluate_route, RouteDecision, RoutePayload};
pub use route::{
evaluate_post, evaluate_pre, evaluate_route, get_dotted, RouteDecision, RoutePayload,
};
pub use rules::{
CompareOp, CompiledRoute, Condition, DenyResponse, Effect, Expression, Literal, Phase,
PhaseSet, Rule,
Expand Down
10 changes: 6 additions & 4 deletions crates/apl-core/src/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,10 +317,12 @@ pub async fn evaluate_route(

/// Read `root.a.b.c` from a JSON value via dot-separated path. Returns
/// `None` if any segment is missing or the path crosses a non-object.
pub(crate) fn get_dotted<'a>(
root: &'a serde_json::Value,
path: &str,
) -> Option<&'a serde_json::Value> {
///
/// Public because host bridges read fields back out of their own payload
/// projections — a plugin dispatched from a pipeline stage reports a new
/// value for the field it was pointed at, and finding that field has to
/// use the same path semantics the evaluator used to write it.
pub fn get_dotted<'a>(root: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
let mut cur = root;
for seg in path.split('.') {
cur = cur.get(seg)?;
Expand Down
10 changes: 10 additions & 0 deletions crates/apl-core/src/step.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,11 @@ pub enum PluginInvocation<'a> {
Step { phase: DispatchPhase },
/// Called inside an `args:` / `result:` pipe chain on one field.
Field {
/// Dotted path to the field, relative to the args or result root
/// — `city`, `user.ssn`, never `args.city`. The phase says which
/// root it hangs off: Pre addresses args, Post addresses result.
/// Every call site uses this convention, so an invoker can read
/// the field back out of a payload without guessing.
name: &'a str,
value: &'a serde_json::Value,
phase: DispatchPhase,
Expand Down Expand Up @@ -845,6 +850,11 @@ pub struct PluginOutcome {
/// args/result chain, it may rewrite the field value (e.g., a PII
/// scrubber producing a redacted string). `None` means "leave value
/// unchanged"; always `None` for policy / post_policy invocations.
///
/// Scoped to the field named in [`PluginInvocation::Field`] and
/// nothing else. A plugin that rewrote some other part of the
/// payload reports `None` here — that mutation travels with the
/// payload instead, so it isn't lost.
pub modified_value: Option<serde_json::Value>,
}

Expand Down
Loading