use rmcp::model::{CallToolResult, ContentBlock};
use serde_json::Value;
use lean_ctx::core::context_admission::carrier::{Release, ReleaseGate, Withheld};
const MAX_NODES: usize = 10_000;
const MAX_DEPTH: usize = 64;
pub(super) fn release(result: CallToolResult) -> CallToolResult {
let origin = super::origin::OriginStep::read();
let released = release_detected(result);
match origin {
Some(step) => step.fence(released),
None => released,
}
}
fn release_detected(mut result: CallToolResult) -> CallToolResult {
let gate = ReleaseGate::current();
if gate.inactive() {
return result;
}
let mut check = Check {
gate: &gate,
nodes: 0,
media: false,
};
let bound = result.structured_content.is_some() || result.meta.is_some();
let verified = result
.structured_content
.as_ref()
.map_or(Ok(()), |value| check.value(value, 0, None))
.and_then(|()| {
result
.meta
.as_ref()
.map_or(Ok(()), |meta| check.object(meta.0.iter(), 0))
});
if let Err(withheld) = verified {
return blocked(&withheld);
}
for block in &mut result.content {
match check.block(block, bound) {
Ok(Some(replacement)) => *block = replacement,
Ok(None) => {}
Err(withheld) => return blocked(&withheld),
}
}
result
}
pub(super) fn release_error(error: rmcp::ErrorData) -> rmcp::ErrorData {
if super::origin::OriginStep::read().is_some() {
return rmcp::ErrorData::new(
error.code,
Withheld::new(&[super::origin::UNTRUSTED_ERROR]).notice(),
None,
);
}
let gate = ReleaseGate::current();
if gate.inactive() {
return error;
}
let mut check = Check {
gate: &gate,
nodes: 0,
media: false,
};
let verified = check.verify(&error.message).and_then(|()| {
error
.data
.as_ref()
.map_or(Ok(()), |data| check.value(data, 0, None))
});
match verified {
Ok(()) => error,
Err(withheld) => rmcp::ErrorData::new(error.code, withheld.notice(), None),
}
}
fn blocked(withheld: &Withheld) -> CallToolResult {
CallToolResult::error(vec![ContentBlock::text(withheld.notice())])
}
struct Check<'a> {
gate: &'a ReleaseGate,
nodes: usize,
media: bool,
}
impl Check<'_> {
fn block(
&mut self,
block: &ContentBlock,
bound: bool,
) -> Result<Option<ContentBlock>, Withheld> {
let Some(text) = block.as_text() else {
let value = serde_json::to_value(block)
.map_err(|_| Withheld::new(&["coverage.unsupported_media"]))?;
self.media = true;
let checked = self.value(&value, 0, None);
self.media = false;
return checked.map(|()| None);
};
let annotated = text.meta.is_some() || text.annotations.is_some();
if let Some(meta) = &text.meta {
self.object(meta.0.iter(), 0)?;
}
if let Some(annotations) = &text.annotations {
let value = serde_json::to_value(annotations)
.map_err(|_| Withheld::new(&["coverage.partial"]))?;
self.value(&value, 0, None)?;
}
match self.gate.text(&text.text) {
Release::Unchanged => Ok(None),
Release::Withheld(withheld) => Err(withheld),
Release::Redacted { .. } if bound || annotated => {
Err(Withheld::new(&["release.unadmitted_content"]))
}
Release::Redacted { text, notice } => {
Ok(Some(ContentBlock::text(format!("{notice}\n{text}"))))
}
}
}
fn object<'v>(
&mut self,
entries: impl Iterator<Item = (&'v String, &'v Value)>,
depth: usize,
) -> Result<(), Withheld> {
for (key, value) in entries {
self.verify(key)?;
self.value(value, depth + 1, Some(key))?;
}
Ok(())
}
fn value(&mut self, value: &Value, depth: usize, key: Option<&str>) -> Result<(), Withheld> {
self.nodes += 1;
if self.nodes > MAX_NODES || depth > MAX_DEPTH {
return self.gate.media(0);
}
match value {
Value::String(text) if self.media && matches!(key, Some("data" | "blob")) => {
self.gate.media(text.len() as u64)
}
Value::String(text) => self.verify(text),
Value::Array(items) => items
.iter()
.try_for_each(|item| self.value(item, depth + 1, None)),
Value::Object(map) => self.object(map.iter(), depth),
Value::Null | Value::Bool(_) | Value::Number(_) => Ok(()),
}
}
fn verify(&mut self, text: &str) -> Result<(), Withheld> {
match self.gate.text(text) {
Release::Unchanged => Ok(()),
Release::Withheld(withheld) => Err(withheld),
Release::Redacted { .. } => Err(Withheld::new(&["release.unadmitted_content"])),
}
}
}
#[cfg(test)]
mod tests {
use super::super::release_result;
use super::*;
const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
fn isolated(config: &str) -> lean_ctx::core::data_dir::IsolatedDataDir {
let data = lean_ctx::core::data_dir::isolated_data_dir();
let dir = lean_ctx::core::paths::config_dir_read_only().expect("isolated config dir");
std::fs::create_dir_all(&dir).expect("config dir");
std::fs::write(dir.join("config.toml"), config).expect("config");
data
}
fn text_of(result: &CallToolResult) -> String {
result
.content
.iter()
.filter_map(|block| block.as_text().map(|text| text.text.clone()))
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn unadmitted_tool_text_is_redacted_or_withheld_by_mode_and_admitted_text_is_untouched() {
let raw = format!("aws_key = {AWS_KEY}\nrest of the output\n");
{
let _data = isolated("");
let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
let clean = CallToolResult::success(vec![ContentBlock::text("fn main() {}\n")]);
assert_eq!(
serde_json::to_value(release_result("ctx_graph", clean.clone())).unwrap(),
serde_json::to_value(&clean).unwrap(),
"allowed content keeps its bytes"
);
let released = release_result(
"ctx_graph",
CallToolResult::success(vec![ContentBlock::text(&raw)]),
);
let delivered = text_of(&released);
assert!(!delivered.contains(AWS_KEY), "old code returned the key");
assert!(delivered.contains("secret(s) redacted"), "{delivered}");
assert!(delivered.contains("rest of the output"));
assert_eq!(text_of(&release_result("ctx_graph", released)), delivered);
let mut structured = CallToolResult::success(vec![ContentBlock::text("ok")]);
structured.structured_content = Some(serde_json::json!({"rows": [raw]}));
let blocked = release_result("ctx_graph", structured);
assert_eq!(blocked.is_error, Some(true));
assert!(blocked.structured_content.is_none());
assert!(text_of(&blocked).contains("release.unadmitted_content"));
}
let _data = isolated("[context_gateway]\nmode = \"governed\"\nsecrets = \"block\"\n");
let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
let blocked = release_result(
"ctx_graph",
CallToolResult::success(vec![ContentBlock::text(&raw)]),
);
assert_eq!(blocked.is_error, Some(true));
let notice = text_of(&blocked);
assert!(notice.contains("secret.blocked"), "{notice}");
assert!(!notice.contains(AWS_KEY) && !notice.contains("rest of the output"));
}
#[test]
fn a_secret_in_a_tool_error_is_withheld_without_a_pack() {
let _data = isolated("");
let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
let clean = rmcp::ErrorData::invalid_params("path: not found", None);
assert_eq!(
super::super::release_outcome("ctx_git_read", Err(clean.clone())),
Err(clean)
);
for (message, data) in [
(format!("ctx_git_read failed: {AWS_KEY}"), None),
(
"ctx_git_read failed".to_owned(),
Some(serde_json::json!({ "stderr": [format!("key={AWS_KEY}")] })),
),
(
"ctx_git_read failed".to_owned(),
Some(serde_json::json!({ "data": AWS_KEY, "nested": { "blob": AWS_KEY } })),
),
] {
let error = rmcp::ErrorData::internal_error(message, data);
let code = error.code;
let released = super::super::release_outcome("ctx_git_read", Err(error))
.expect_err("stays an error");
assert_eq!(released.code, code);
assert!(released.data.is_none());
assert!(!released.message.contains(AWS_KEY), "{}", released.message);
assert!(!released.message.contains("ctx_git_read failed"));
}
}
}