Skip to content

Commit 329a744

Browse files
msureshkumar88Suresh Kumar Moharajan
andauthored
feat(pii_filter): OpenTelemetry trace context + gated metrics on result.metadata (#128)
* feat(pii_filter): accept extensions and read trace_id at the Rust hook boundary Refs #27 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * fix(pii_filter): silence unused trace_id warning; remove misapplied stub-gen attrs Fixes clippy -D warnings failure in handle_nested_stage and a misapplied #[cfg_attr(feature = "stub-gen", gen_stub_pymethods)] duplicated on 4 individual methods (already correctly applied at the impl-block level). Refs #27 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * chore: untrack docs/ and .memsearch/ accidentally swept into prior commit The Task A1 review-fix commit staged these via a broad add; they are unrelated to the pii_filter Rust change and were never intended to be tracked in this commit. Files remain on disk, untracked. Refs #27 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * feat(pii_filter): forward optional extensions through the Python shim Refs #27 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * feat(pii_filter): emit gated counts/types metrics on result.metadata; drop context.metadata writes Refs #27 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * chore(framework_bridge): bump to 0.1.2 for build_framework_object_dyn addition Per the plan's Versioning note: adding a shared framework_bridge helper requires bumping the crate's own version. Refs #27 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * feat(pii_filter): add metric-schema contract; S1 leakage + S3 bounds tests Refs #27 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * test(pii_filter): migrate remaining metadata assertions to result.metadata channel Refs #27 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * chore(pii_filter): regen stubs, bump to 0.3.6, document extensions/metrics - Update PLUGIN_CORE_CLASS_DEF hardcoded stub signatures to include optional extensions parameter - Bump version: 0.3.5 -> 0.3.6 in Cargo.toml and plugin-manifest.yaml - Document extensions hook parameter in README.md - Document trace-in/metrics-out convention in repo AGENTS.md (trace context OpenTelemetry integration) - Note: stub regeneration skipped (make stub-gen is broken by pre-existing pyo3-stub-gen/pyo3 incompatibility) Closes #27 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * style(pii_filter): apply cargo fmt Formatting-only, no behavior change. Was produced but not committed during Task A6's fmt-check run. Refs #27 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * docs(pii_filter): fix fabricated metrics schema in README/AGENTS The prior doc commit invented per-type count keys (detected_email_count etc.) and listed trace_id as an output field. Neither exists in the actual build_pii_metrics implementation — the real schema is total_detections/total_masked/detection_types/stage, and trace_id is an input only (extensions.request.trace_id). Refs #27 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * docs: record replication checklist for remaining plugins Refs #27 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * docs(pii_filter): correct include_detection_details claims; add 0.3.6 migration note include_detection_details no longer gates anything (Task A3 removed its only consumer) -- OTel metrics on result.metadata["pii_filter"] gate solely on extensions.request.trace_id presence, per the chosen spec. Corrected README/detector.rs doc comments to match, and added a 0.3.6 Migration Note for the context.metadata -> result.metadata channel move. Refs #27 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * fix(ci): close mutation-testing gaps and unblock stale CI checks - Add a Rust unit test exercising handle_nested_stage's masked (non-blocking) detection path end to end, killing the two mutants cargo-mutants flagged in the OTel metadata diff: the `!detections.is_empty()` guard around the masked-detection log call, and the push_metrics_kwarg no-op mutant on the same path. - Regenerate .secrets.baseline and audit the two new "Secret Keyword" false positives (a test-fixture variable literally named `secret`) as not-a-secret. - Bump the RUSTSEC-2026-0176/0177 advisory-ignore expiry dates in deny.toml; they lapsed on 2026-06-29 and were failing the repo's time-boxed advisory-ignore test on every PR, unrelated to this change. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * fix(pii_filter): address review feedback on PR #128 - Extract build_blocked_result to collapse duplicated blocked-path construction in prompt_post_fetch and handle_nested_stage - Gate push_metrics_kwarg on trace_id before sorting/collecting detection types, so untraced requests skip the allocation; prompt_post_fetch now accumulates a HashMap across messages instead of pre-sorting per message - Drop unused _context param from handle_nested_stage; callers keep context (required by the hook signature) but mark it unused with let _ = &context - Clarify metrics-schema doc: prompt_post_fetch's blocked path reports only the message that triggered the block, not the whole payload - Remove misleading include_detection_details=True from a test (the field is a no-op) Addresses non-blocking suggestions from @lucarlig's review. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * fix(ci): bump crossbeam-epoch to clear RUSTSEC-2026-0204 security-policy check started failing on this PR after RUSTSEC-2026-0204 was published against crossbeam-epoch 0.9.18 (invalid pointer dereference in fmt::Pointer), pulled in transitively via criterion's rayon dev-dep across every plugin crate. Unrelated to this PR's changes -- would have failed on main too. cargo update -p crossbeam-epoch --precise 0.9.20 clears the advisory; verified with cargo deny check advisories. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> --------- Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> Co-authored-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
1 parent 6819576 commit 329a744

17 files changed

Lines changed: 1530 additions & 183 deletions

File tree

‎.secrets.baseline‎

Lines changed: 22 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎AGENTS.md‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,4 +201,48 @@ When bumping a plugin version, update all of these:
201201
2. `cpex_<plugin>/plugin-manifest.yaml` — the `version` field.
202202
3. `Cargo.lock` — updates automatically on the next build.
203203

204-
Tag releases as `<plugin>-v<version>` (e.g., `rate-limiter-v0.0.2`) on `main` to trigger the PyPI publish workflow.
204+
Tag releases as `<plugin>-v<version>` (e.g., `rate-limiter-v0.0.2`) on `main` to trigger the PyPI publish workflow.
205+
206+
## OpenTelemetry Integration and Trace Context
207+
208+
### Trace-In / Metrics-Out Convention
209+
210+
Plugins can accept and respond to OpenTelemetry trace context through the optional `extensions` parameter on all hook signatures:
211+
212+
**Hook Signature Convention:**
213+
```python
214+
def my_hook(
215+
self,
216+
payload: typing.Any,
217+
context: typing.Any,
218+
extensions: typing.Any = None
219+
) -> typing.Any: ...
220+
```
221+
222+
**Trace Context Input (`extensions` parameter):**
223+
- The `extensions` parameter carries OpenTelemetry trace context, including `extensions.request.trace_id` for associating operations with the current request trace.
224+
- Other fields such as `span_id` may be available on the Extensions object but are not currently consumed by the pii_filter plugin.
225+
- The parameter is optional and defaults to `None` for backward compatibility.
226+
227+
**Metrics Output (`result.metadata` namespacing):**
228+
- Plugins emit operational metrics and observability data via `result.metadata[<plugin-name>]` using a namespaced key (e.g., `result.metadata["pii_filter"]`).
229+
- Metrics are **gated on the presence of a valid `trace_id`**: metrics are only populated when OpenTelemetry trace context is available.
230+
- All metrics must be non-sensitive: they contain only counts, type labels, and status indicators — **never raw sensitive data or personally identifiable information**.
231+
232+
**Example (pii_filter plugin):**
233+
```python
234+
result.metadata["pii_filter"] = {
235+
"total_detections": 2, # total number of PII detections in this call
236+
"total_masked": 2, # total number masked/redacted
237+
"detection_types": ["email", "ssn"], # distinct type names, sorted, deduped
238+
"stage": "tool_post_invoke", # which hook stage emitted this
239+
}
240+
```
241+
Note: `trace_id` is an input only (read from `extensions.request.trace_id`) and is never emitted as part of the output metrics.
242+
243+
**When Implementing Trace Context Support:**
244+
1. Update your hook signatures to accept the optional `extensions` parameter.
245+
2. Read `trace_id` from `extensions` when available.
246+
3. Emit metrics to `result.metadata[<plugin-name>]` only when a valid `trace_id` is present.
247+
4. Ensure all emitted data is non-sensitive and aggregated (counts, not individual values).
248+
5. Document the metadata keys and values in your plugin's README.

‎Cargo.lock‎

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/framework_bridge/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "cpex_framework_bridge"
3-
version = "0.1.1"
3+
version = "0.1.2"
44
edition.workspace = true
55
authors.workspace = true
66
license.workspace = true

‎crates/framework_bridge/src/lib.rs‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,23 @@ pub fn build_framework_object<'py, const N: usize>(
2222
.unbind())
2323
}
2424

25+
/// Sibling of [`build_framework_object`] for callers with a variable kwarg
26+
/// count (e.g. an optional trailing `metadata` entry). Prefer the fixed-size
27+
/// array form when `N` is known at the call site; use this when it isn't.
28+
pub fn build_framework_object_dyn(
29+
py: Python<'_>,
30+
class_name: &str,
31+
kwargs: Vec<(&str, Py<PyAny>)>,
32+
) -> PyResult<Py<PyAny>> {
33+
let kwargs_dict = PyDict::new(py);
34+
for (key, value) in kwargs {
35+
kwargs_dict.set_item(key, value.bind(py))?;
36+
}
37+
Ok(framework_class(py, class_name)?
38+
.call((), Some(&kwargs_dict))?
39+
.unbind())
40+
}
41+
2542
pub fn default_result(py: Python<'_>, class_name: &str) -> PyResult<Py<PyAny>> {
2643
framework_class(py, class_name)
2744
.and_then(|class| class.call0())
@@ -119,4 +136,36 @@ class PluginViolation:
119136
);
120137
});
121138
}
139+
140+
#[test]
141+
fn build_framework_object_dyn_passes_keyword_arguments() {
142+
Python::initialize();
143+
Python::attach(|py| {
144+
install_framework_module(py).unwrap();
145+
146+
let result = build_framework_object_dyn(
147+
py,
148+
"PluginViolation",
149+
vec![
150+
("reason", "blocked".into_py_any(py).unwrap()),
151+
("code", "POLICY".into_py_any(py).unwrap()),
152+
],
153+
)
154+
.unwrap();
155+
let result = result.bind(py);
156+
157+
assert_eq!(
158+
result
159+
.getattr("reason")
160+
.unwrap()
161+
.extract::<String>()
162+
.unwrap(),
163+
"blocked"
164+
);
165+
assert_eq!(
166+
result.getattr("code").unwrap().extract::<String>().unwrap(),
167+
"POLICY"
168+
);
169+
});
170+
}
122171
}

‎deny.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
# pyo3-stub-gen 0.22.1 still pulls rustpython-parser's unmaintained unic-* parser crates.
66
# Keep this scoped to the known transitive advisories so new unmaintained dependencies fail CI.
77
ignore = [
8-
{ id = "RUSTSEC-2026-0176", reason = "pyo3-stub-gen blocks PyO3 0.29 until upstream compatibility release; expires 2026-06-29" },
9-
{ id = "RUSTSEC-2026-0177", reason = "pyo3-stub-gen blocks PyO3 0.29 until upstream compatibility release; expires 2026-06-29" },
8+
{ id = "RUSTSEC-2026-0176", reason = "pyo3-stub-gen blocks PyO3 0.29 until upstream compatibility release; expires 2026-08-01" },
9+
{ id = "RUSTSEC-2026-0177", reason = "pyo3-stub-gen blocks PyO3 0.29 until upstream compatibility release; expires 2026-08-01" },
1010
"RUSTSEC-2025-0075", # unic-char-range via rustpython-parser
1111
"RUSTSEC-2025-0080", # unic-common via rustpython-parser
1212
"RUSTSEC-2025-0081", # unic-char-property via rustpython-parser
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# PII Filter Metrics Schema Contract (D1)
2+
3+
## Overview
4+
5+
This document formalizes the contract for metrics emitted by the `pii_filter` CPEX Rust plugin via the `result.metadata` channel when a trace_id is present in the request extensions.
6+
7+
## Namespace
8+
9+
All metrics are emitted under the `pii_filter` namespace key:
10+
11+
```python
12+
result.metadata = {
13+
"pii_filter": { <metrics dict> }
14+
}
15+
```
16+
17+
## Allow-List (S2)
18+
19+
The following fields are the **only fields** permitted in the metrics dict. Gateway validation (G1) must reject any deviation:
20+
21+
| Field | Type | Description | Example |
22+
|-------|------|-------------|---------|
23+
| `total_detections` | `int` | Total count of PII items detected across all patterns in this hook invocation | `5` |
24+
| `total_masked` | `int` | Total count of PII items successfully masked (only populated when masking is applied) | `5` |
25+
| `detection_types` | `list[str]` | Sorted, deduplicated list of PII pattern type names detected. See bounds (S3). | `["email", "ssn"]` |
26+
| `stage` | `str` | The plugin hook stage that produced this metric | `"tool_post_invoke"` |
27+
28+
### Semantics
29+
30+
- **total_detections**: Incremented for each distinct PII match found, regardless of masking outcome. For `prompt_post_fetch`, which scans messages one at a time and returns as soon as a blocking message is found, a blocked result's `total_detections` covers only the message that triggered the block, not the whole message list (later messages are never scanned). The non-blocking (masked) path always covers every message in the payload.
31+
- **total_masked**: Set to `total_detections` when PII is masked in-place; set to `0` when detection blocks the payload without masking.
32+
- **detection_types**: Contains the canonical string name of each pattern type (e.g., `"email"`, `"ssn"`, `"credit_card"`), sorted alphabetically, deduplicated, and bounded to 32 types.
33+
- **stage**: One of `"prompt_pre_fetch"`, `"prompt_post_fetch"`, `"tool_pre_invoke"`, or `"tool_post_invoke"`.
34+
35+
## Deny-List (S1): Content-Bearing Fields
36+
37+
The following fields **MUST NOT** appear in the metrics dict, even in part. This prevents leakage of sensitive information:
38+
39+
| Field | Reason |
40+
|-------|--------|
41+
| Matched PII values | Any field containing the actual detected secret (e.g., email address, SSN, credit card number) |
42+
| Raw payloads or excerpts | Substrings of the input that contained PII |
43+
| Masking instructions or patterns | Details of where or how masking was applied that would reveal the original content |
44+
| Source field names or paths | Full paths to input fields that contained PII (e.g., `user.email.address`) — only the pattern type is safe |
45+
46+
### Validation
47+
48+
- No string dump of the metrics dict may contain any matched value that was input to the detector.
49+
- No field must include regex patterns, character counts (of masked segments), or other information that could be used to infer the original content.
50+
51+
## Bounds (S3)
52+
53+
### Detection Types List
54+
55+
The `detection_types` list is bounded to **32 entries maximum**. Enforcement:
56+
57+
1. The plugin truncates the sorted, deduplicated type list to the first 32 entries.
58+
2. If more than 32 distinct types are detected, only the first 32 (alphabetically) are retained.
59+
3. The gateway validation (G1) must reject any `detection_types` field exceeding 32 entries.
60+
61+
### Field Sizes
62+
63+
- `total_detections` and `total_masked`: standard 64-bit signed integers (no practical upper bound).
64+
- `detection_types`: each entry is a string ≤ 32 characters; total list is ≤ 32 entries.
65+
- `stage`: fixed to one of 4 known values (see Semantics).
66+
67+
## Emission Criteria
68+
69+
Metrics are **only emitted** when:
70+
71+
1. A trace_id is present in `extensions.request.trace_id`.
72+
2. At least one PII detection occurred during the hook invocation.
73+
74+
When trace_id is absent or no detections occur, the `result.metadata` dict is omitted entirely (or does not contain the `pii_filter` key).
75+
76+
## Example
77+
78+
### Input
79+
80+
```
81+
payload.result = {
82+
"user_email": "alice@example.com",
83+
"account_id": "acc-123",
84+
"ssn": "123-45-6789"
85+
}
86+
extensions.request.trace_id = "trace-xyz"
87+
```
88+
89+
### Detector Output
90+
91+
- Detections: 2 items (email, ssn)
92+
- Types: ["email", "ssn"]
93+
- Action: Masked in-place (total_masked = 2)
94+
95+
### Emitted Metrics
96+
97+
```python
98+
result.metadata = {
99+
"pii_filter": {
100+
"total_detections": 2,
101+
"total_masked": 2,
102+
"detection_types": ["email", "ssn"],
103+
"stage": "tool_post_invoke"
104+
}
105+
}
106+
```
107+
108+
**NOT emitted:**
109+
- `"user_email": "alice@example.com"` (matched value)
110+
- `"matched_fields": ["user_email", "ssn"]` (field names)
111+
- `"mask_applied_at": ["user_email"]` (location details)
112+
113+
## References
114+
115+
- **A3**: Implementation of `build_pii_metrics` in Rust plugin (`src/plugin.rs`).
116+
- **G1**: Gateway validation of metric contracts (cross-repo).
117+
- **S1, S2, S3**: Security (leakage), structural (allow-list), and scale (bounds) guarantees.

0 commit comments

Comments
 (0)