Skip to content

Commit a220991

Browse files
authored
feat(a2a-redaction): introduce scaffold with redactor trait, identifiers, and Tier-3 sentinel (#388)
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent a792f41 commit a220991

13 files changed

Lines changed: 607 additions & 20 deletions

File tree

rsworkspace/Cargo.lock

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

rsworkspace/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ a2a-bridge = { path = "crates/a2a-bridge" }
1919
a2a-identity-types = { path = "crates/a2a-identity-types" }
2020
a2a-nats = { path = "crates/a2a-nats" }
2121
a2a-pack = { path = "crates/a2a-pack" }
22+
a2a-redaction = { path = "crates/a2a-redaction" }
2223
acp-nats = { path = "crates/acp-nats" }
2324
trogon-telemetry = { path = "crates/trogon-telemetry" }
2425
mcp-nats = { path = "crates/mcp-nats" }
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[package]
2+
name = "a2a-redaction"
3+
version = "0.1.0"
4+
edition = "2024"
5+
license = "Apache-2.0"
6+
description = "A2A redaction primitives, redactor trait, and Tier-3 refusal sentinel"
7+
publish = false
8+
9+
[lib]
10+
name = "a2a_redaction"
11+
path = "src/lib.rs"
12+
13+
[lints]
14+
workspace = true
15+
16+
[dependencies]
17+
a2a = { workspace = true }
18+
serde = { workspace = true, features = ["derive"] }
19+
serde_json = { workspace = true }
20+
thiserror = { workspace = true }
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
use std::fmt;
2+
use std::str::FromStr;
3+
4+
use serde::{Deserialize, Serialize};
5+
6+
/// A2A wire methods. The serde wire form is the canonical slash-delimited
7+
/// string (e.g. `message/send`), matching `as_str` / `Display` / `FromStr`
8+
/// so the same value round-trips across JSON and string parsing without
9+
/// silently switching representation.
10+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11+
pub enum A2aMethod {
12+
#[serde(rename = "message/send")]
13+
MessageSend,
14+
#[serde(rename = "message/stream")]
15+
MessageStream,
16+
#[serde(rename = "tasks/get")]
17+
TasksGet,
18+
#[serde(rename = "tasks/list")]
19+
TasksList,
20+
#[serde(rename = "tasks/cancel")]
21+
TasksCancel,
22+
#[serde(rename = "tasks/resubscribe")]
23+
TasksResubscribe,
24+
#[serde(rename = "tasks/pushNotificationConfig/set")]
25+
PushNotificationSet,
26+
#[serde(rename = "tasks/pushNotificationConfig/get")]
27+
PushNotificationGet,
28+
#[serde(rename = "tasks/pushNotificationConfig/list")]
29+
PushNotificationList,
30+
#[serde(rename = "tasks/pushNotificationConfig/delete")]
31+
PushNotificationDelete,
32+
#[serde(rename = "agent/getAuthenticatedExtendedCard")]
33+
AgentCard,
34+
}
35+
36+
impl A2aMethod {
37+
pub fn as_str(&self) -> &'static str {
38+
match self {
39+
Self::MessageSend => "message/send",
40+
Self::MessageStream => "message/stream",
41+
Self::TasksGet => "tasks/get",
42+
Self::TasksList => "tasks/list",
43+
Self::TasksCancel => "tasks/cancel",
44+
Self::TasksResubscribe => "tasks/resubscribe",
45+
Self::PushNotificationSet => "tasks/pushNotificationConfig/set",
46+
Self::PushNotificationGet => "tasks/pushNotificationConfig/get",
47+
Self::PushNotificationList => "tasks/pushNotificationConfig/list",
48+
Self::PushNotificationDelete => "tasks/pushNotificationConfig/delete",
49+
Self::AgentCard => "agent/getAuthenticatedExtendedCard",
50+
}
51+
}
52+
53+
pub fn all() -> &'static [Self] {
54+
&[
55+
Self::MessageSend,
56+
Self::MessageStream,
57+
Self::TasksGet,
58+
Self::TasksList,
59+
Self::TasksCancel,
60+
Self::TasksResubscribe,
61+
Self::PushNotificationSet,
62+
Self::PushNotificationGet,
63+
Self::PushNotificationList,
64+
Self::PushNotificationDelete,
65+
Self::AgentCard,
66+
]
67+
}
68+
}
69+
70+
impl fmt::Display for A2aMethod {
71+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72+
f.write_str(self.as_str())
73+
}
74+
}
75+
76+
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
77+
#[error("unknown A2A method: {value}")]
78+
pub struct ParseA2aMethodError {
79+
pub value: String,
80+
}
81+
82+
impl FromStr for A2aMethod {
83+
type Err = ParseA2aMethodError;
84+
85+
fn from_str(value: &str) -> Result<Self, Self::Err> {
86+
Self::all()
87+
.iter()
88+
.copied()
89+
.find(|method| method.as_str() == value)
90+
.ok_or_else(|| ParseA2aMethodError {
91+
value: value.to_owned(),
92+
})
93+
}
94+
}
95+
96+
#[cfg(test)]
97+
mod tests {
98+
use super::*;
99+
100+
#[test]
101+
fn roundtrip_display_parse() {
102+
for method in A2aMethod::all() {
103+
let parsed = method.as_str().parse::<A2aMethod>().unwrap();
104+
assert_eq!(parsed, *method);
105+
}
106+
}
107+
108+
#[test]
109+
fn serde_uses_canonical_wire_strings() {
110+
let method = A2aMethod::MessageSend;
111+
let wire = serde_json::to_string(&method).expect("serialize");
112+
assert_eq!(wire, "\"message/send\"");
113+
let back: A2aMethod = serde_json::from_str(&wire).expect("deserialize");
114+
assert_eq!(back, method);
115+
}
116+
117+
#[test]
118+
fn parse_error_includes_unknown_value() {
119+
let err = "not/real".parse::<A2aMethod>().unwrap_err();
120+
assert_eq!(err.value, "not/real");
121+
assert!(err.to_string().contains("not/real"));
122+
}
123+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#[derive(Debug, thiserror::Error)]
2+
pub enum RedactionError {
3+
#[error("wasm engine initialization failed: {0}")]
4+
WasmEngine(String),
5+
#[error("wasm module compile failed: {0}")]
6+
WasmModule(String),
7+
#[error("wasm module instantiation failed: {0}")]
8+
WasmInstance(String),
9+
#[error("wasm redaction abi mismatch: {0}")]
10+
WasmAbi(String),
11+
#[error("wasm redact_part call failed: {0}")]
12+
WasmCall(String),
13+
#[error("wasm linear memory access failed: {0}")]
14+
WasmMemory(String),
15+
/// JSON encode/decode of a `Part` payload failed. Preserves the typed
16+
/// `serde_json::Error` as `source()` so error chains survive past this
17+
/// boundary instead of being flattened to a String.
18+
#[error("json serialization for redaction failed: {0}")]
19+
Json(#[from] serde_json::Error),
20+
}
21+
22+
#[cfg(test)]
23+
mod tests {
24+
use super::*;
25+
26+
#[test]
27+
fn wasm_engine_variant_carries_underlying_message() {
28+
let inner = "module init failed";
29+
let wrapped = RedactionError::WasmEngine(inner.to_string());
30+
assert!(
31+
wrapped.to_string().contains("wasm engine initialization failed"),
32+
"{wrapped}"
33+
);
34+
assert!(wrapped.to_string().contains(inner));
35+
}
36+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
//! A2A redaction primitives: typed identifiers, redactor trait, no-op
2+
//! implementation, and Tier-3 refusal sentinel.
3+
//!
4+
//! Subsequent slices add the skill manifest, signed-bundle verification, and
5+
//! the wasm redactor host on top of these foundations.
6+
#![cfg_attr(test, allow(clippy::expect_used, clippy::panic, clippy::unwrap_used))]
7+
8+
pub mod a2a_method;
9+
pub mod error;
10+
pub mod noop;
11+
pub mod redactor;
12+
pub mod skill_id;
13+
pub mod tier3_sentinel;
14+
pub mod wasm_bundle_path;
15+
16+
pub use a2a_method::{A2aMethod, ParseA2aMethodError};
17+
pub use error::RedactionError;
18+
pub use noop::NoopRedactor;
19+
pub use redactor::Redactor;
20+
pub use skill_id::{SkillId, SkillIdError};
21+
pub use tier3_sentinel::{TIER3_REFUSE_SENTINEL, output_is_tier3_refusal, tier3_refusal_reason_tag};
22+
pub use wasm_bundle_path::WasmBundlePath;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
use crate::redactor::Redactor;
2+
3+
pub struct NoopRedactor;
4+
5+
impl Redactor for NoopRedactor {}
6+
7+
#[cfg(test)]
8+
mod tests {
9+
use super::*;
10+
use a2a::types::{Artifact, Message, Role};
11+
12+
use crate::skill_id::SkillId;
13+
14+
#[test]
15+
fn message_identity() {
16+
let r = NoopRedactor;
17+
let msg = Message {
18+
message_id: "mid".into(),
19+
context_id: None,
20+
task_id: None,
21+
role: Role::User,
22+
parts: vec![a2a::types::Part {
23+
content: a2a::types::PartContent::Text("hello".into()),
24+
filename: None,
25+
media_type: None,
26+
metadata: None,
27+
}],
28+
metadata: None,
29+
extensions: None,
30+
reference_task_ids: None,
31+
};
32+
let out = r
33+
.redact_message(msg.clone(), &SkillId::new("skill").expect("valid"))
34+
.unwrap();
35+
assert_eq!(serde_json::to_value(out).unwrap(), serde_json::to_value(msg).unwrap());
36+
}
37+
38+
#[test]
39+
fn artifact_identity() {
40+
let r = NoopRedactor;
41+
let art = Artifact {
42+
artifact_id: "a".into(),
43+
name: None,
44+
description: None,
45+
parts: vec![],
46+
metadata: None,
47+
extensions: None,
48+
};
49+
let out = r
50+
.redact_artifact(art.clone(), &SkillId::new("skill").expect("valid"))
51+
.unwrap();
52+
assert_eq!(serde_json::to_value(out).unwrap(), serde_json::to_value(art).unwrap());
53+
}
54+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
use a2a::types::{Artifact, Message, Part};
2+
3+
use crate::error::RedactionError;
4+
use crate::skill_id::SkillId;
5+
6+
pub trait Redactor {
7+
fn redact_message(&self, message: Message, _skill: &SkillId) -> Result<Message, RedactionError> {
8+
Ok(message)
9+
}
10+
11+
fn redact_artifact(&self, artifact: Artifact, _skill: &SkillId) -> Result<Artifact, RedactionError> {
12+
Ok(artifact)
13+
}
14+
}
15+
16+
#[allow(dead_code)]
17+
pub(crate) fn redact_message_parts_with(
18+
mut message: Message,
19+
mut transform_part_json: impl FnMut(&[u8]) -> Result<Vec<u8>, RedactionError>,
20+
) -> Result<Message, RedactionError> {
21+
let mut next_parts = Vec::with_capacity(message.parts.len());
22+
for part in core::mem::take(&mut message.parts) {
23+
let wire = serde_json::to_vec(&part)?;
24+
let out = transform_part_json(&wire)?;
25+
let parsed: Part = serde_json::from_slice(&out)?;
26+
next_parts.push(parsed);
27+
}
28+
message.parts = next_parts;
29+
Ok(message)
30+
}
31+
32+
#[allow(dead_code)]
33+
pub(crate) fn redact_artifact_parts_with(
34+
mut artifact: Artifact,
35+
mut transform_part_json: impl FnMut(&[u8]) -> Result<Vec<u8>, RedactionError>,
36+
) -> Result<Artifact, RedactionError> {
37+
let mut next_parts = Vec::with_capacity(artifact.parts.len());
38+
for part in core::mem::take(&mut artifact.parts) {
39+
let wire = serde_json::to_vec(&part)?;
40+
let out = transform_part_json(&wire)?;
41+
let parsed: Part = serde_json::from_slice(&out)?;
42+
next_parts.push(parsed);
43+
}
44+
artifact.parts = next_parts;
45+
Ok(artifact)
46+
}
47+
48+
#[cfg(test)]
49+
mod tests {
50+
use super::*;
51+
use a2a::types::{PartContent, Role};
52+
53+
struct DefaultRedactorByTrait;
54+
55+
impl Redactor for DefaultRedactorByTrait {}
56+
57+
#[test]
58+
fn default_redact_message_identity() {
59+
let r = DefaultRedactorByTrait;
60+
let msg = Message {
61+
message_id: "m".into(),
62+
context_id: None,
63+
task_id: None,
64+
role: Role::User,
65+
parts: vec![],
66+
metadata: None,
67+
extensions: None,
68+
reference_task_ids: None,
69+
};
70+
assert_eq!(
71+
serde_json::to_value(
72+
r.redact_message(msg.clone(), &SkillId::new("s").expect("valid"))
73+
.unwrap()
74+
)
75+
.unwrap(),
76+
serde_json::to_value(&msg).unwrap()
77+
);
78+
}
79+
80+
#[test]
81+
fn part_loop_identity_preserves_shapes() {
82+
let msg_in = Message {
83+
message_id: "m".into(),
84+
context_id: None,
85+
task_id: None,
86+
role: Role::Agent,
87+
parts: vec![a2a::types::Part {
88+
content: PartContent::Text("secret".into()),
89+
filename: None,
90+
media_type: None,
91+
metadata: None,
92+
}],
93+
metadata: None,
94+
extensions: None,
95+
reference_task_ids: None,
96+
};
97+
let msg_out = redact_message_parts_with(msg_in.clone(), |b| Ok(b.to_vec())).expect("identity json transform");
98+
assert_eq!(
99+
serde_json::to_value(&msg_out).unwrap(),
100+
serde_json::to_value(&msg_in).unwrap()
101+
);
102+
}
103+
}

0 commit comments

Comments
 (0)