use super::{ActivePolicy, CallToolResult, ContentBlock, runtime};
use lean_ctx::core::policy::content::{
MAX_PROTECTED_CONTENT_BYTES, ReleaseCheck, evaluate_text, release_check,
};
use serde_json::Value;
mod gateway;
mod origin;
pub fn release_outcome(
tool: &str,
outcome: Result<CallToolResult, rmcp::ErrorData>,
) -> Result<CallToolResult, rmcp::ErrorData> {
outcome
.map(|result| release_result(tool, result))
.map_err(gateway::release_error)
}
pub fn release_error(error: rmcp::ErrorData) -> rmcp::ErrorData {
gateway::release_error(error)
}
pub fn release_result(tool: &str, result: CallToolResult) -> CallToolResult {
let Some(policy) = runtime::active() else {
return gateway::release(result);
};
let Some(mask_pending) = policy_release(&policy, tool, &result, true) else {
return withheld();
};
let mut released = gateway::release(result);
if !mask_pending {
return released;
}
for block in &mut released.content {
if let Some(text) = block.as_text() {
let masked = lean_ctx::core::redaction::redact_text(&text.text);
if masked != text.text {
*block = ContentBlock::text(masked);
}
}
}
match policy_release(&policy, tool, &released, false) {
Some(false) => released,
_ => withheld(),
}
}
fn withheld() -> CallToolResult {
CallToolResult::error(vec![ContentBlock::text(
"[POLICY BLOCKED] Result withheld by the current policy; a fresh authorized result is required.",
)])
}
fn policy_release(
policy: &ActivePolicy,
tool: &str,
result: &CallToolResult,
maskable: bool,
) -> Option<bool> {
let mut inspection = Inspection {
policy,
bytes: 0,
nodes: 0,
};
let bound = result.structured_content.is_some() || result.meta.is_some();
let mut mask_pending = false;
let permitted = policy.tool_allowed(tool)
&& result.content.iter().all(|block| {
block.as_text().is_some_and(|text| {
let plain = maskable && !bound && text.meta.is_none() && text.annotations.is_none();
(if plain {
inspection.plain_text(&text.text, &mut mask_pending)
} else {
inspection.text(&text.text)
}) && text
.meta
.as_ref()
.is_none_or(|meta| inspection.metadata(meta))
&& text.annotations.as_ref().is_none_or(|annotations| {
annotations
.audience
.as_ref()
.is_none_or(|roles| roles.len() <= 10_000)
&& serde_json::to_value(annotations)
.is_ok_and(|value| inspection.value(&value, 0))
})
})
})
&& result
.structured_content
.as_ref()
.is_none_or(|value| inspection.value(value, 0))
&& result
.meta
.as_ref()
.is_none_or(|meta| inspection.metadata(meta));
permitted.then_some(mask_pending)
}
struct Inspection<'a> {
policy: &'a ActivePolicy,
bytes: usize,
nodes: usize,
}
impl Inspection<'_> {
fn metadata(&mut self, meta: &rmcp::model::MetaObject) -> bool {
meta.0
.iter()
.all(|(key, value)| self.text(key) && self.value(value, 0))
}
fn bounded(&mut self, text: &str) -> bool {
self.bytes = self.bytes.saturating_add(text.len());
self.nodes += 1;
self.bytes <= MAX_PROTECTED_CONTENT_BYTES && self.nodes <= 10_000
}
fn text(&mut self, text: &str) -> bool {
if !self.bounded(text) {
return false;
}
let outcome = evaluate_text(text, self.policy);
!outcome.blocked && outcome.text == text
}
fn plain_text(&mut self, text: &str, mask_pending: &mut bool) -> bool {
if !self.bounded(text) {
return false;
}
match release_check(text, self.policy) {
ReleaseCheck::Unchanged => true,
ReleaseCheck::BuiltinSecretOnly => {
*mask_pending = true;
true
}
ReleaseCheck::Refused => false,
}
}
fn value(&mut self, value: &Value, depth: usize) -> bool {
self.nodes += 1;
if self.nodes > 10_000 || depth > 64 {
return false;
}
match value {
Value::String(text) => self.text(text),
Value::Array(values) => values.iter().all(|value| self.value(value, depth + 1)),
Value::Object(values) => values
.iter()
.all(|(key, value)| self.text(key) && self.value(value, depth + 1)),
_ => self.text(&value.to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use lean_ctx::core::policy::{parse, resolve};
fn mask() -> runtime::TestPolicyOverride {
let source = "name = 'release'\nversion = '1.0.0'\ndescription = 'test'\n[redaction]\ncustomer = 'CUS-[0-9]{4}'\n";
runtime::TestPolicyOverride::set(Some(resolve(&parse(source).unwrap()).unwrap()))
}
#[test]
fn stale_finalized_text_is_withheld_and_safe_result_preserved() {
let _policy = mask();
let unsafe_result = CallToolResult::success(vec![ContentBlock::text("CUS-1234")]);
let blocked = release_result("ctx_read", unsafe_result);
assert_eq!(blocked.is_error, Some(true));
assert!(
!serde_json::to_string(&blocked)
.unwrap()
.contains("CUS-1234")
);
let safe = CallToolResult::success(vec![ContentBlock::text("[REDACTED:customer]")]);
assert_eq!(
serde_json::to_value(release_result("ctx_read", safe.clone())).unwrap(),
serde_json::to_value(safe).unwrap()
);
}
const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
fn config(toml: &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"), toml).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 a_built_in_secret_in_plain_text_is_masked_under_a_pack_never_withheld() {
let raw = format!("aws_key = {AWS_KEY}\nrest of the output\n");
let masked = "aws_key = [REDACTED:AWS key]\nrest of the output\n";
for toml in [
"",
"[context_gateway]\nmode = \"governed\"\n",
"[context_gateway]\nenabled = false\n",
] {
let _data = config(toml);
let _policy = mask();
let released = release_result(
"ctx_graph",
CallToolResult::success(vec![ContentBlock::text(&raw)]),
);
assert_ne!(released.is_error, Some(true), "{toml}: withheld");
let delivered = text_of(&released);
assert!(delivered.ends_with(masked), "{toml}: {delivered}");
assert_eq!(delivered.matches("[REDACTED:").count(), 1, "{delivered}");
assert_eq!(
text_of(&release_result("ctx_graph", released)),
delivered,
"{toml}"
);
let own_rule = release_result(
"ctx_graph",
CallToolResult::success(vec![ContentBlock::text(format!("{raw}CUS-1234\n"))]),
);
assert_eq!(own_rule.is_error, Some(true), "{toml}");
assert!(!text_of(&own_rule).contains(AWS_KEY));
let mut bound = CallToolResult::success(vec![ContentBlock::text("ok")]);
bound.structured_content = Some(serde_json::json!({ "rows": [raw] }));
let bound = release_result("ctx_graph", bound);
assert_eq!(bound.is_error, Some(true), "{toml}");
assert!(bound.structured_content.is_none());
}
let _data = config("[context_gateway]\nmode = \"governed\"\nsecrets = \"block\"\n");
let _policy = mask();
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 structured_and_metadata_copies_cannot_bypass_release_inspection() {
let _policy = mask();
for metadata in [false, true] {
let mut result = CallToolResult::success(vec![ContentBlock::text("safe")]);
let extra = serde_json::json!({"nested": [{"record": "line one\nCUS-1234"}]});
if metadata {
let mut meta = rmcp::model::MetaObject::new();
meta.0.insert("fixture".into(), extra);
result.meta = Some(meta);
} else {
result.structured_content = Some(extra);
}
let blocked = release_result("ctx_read", result);
assert_eq!(blocked.is_error, Some(true));
assert!(blocked.meta.is_none() && blocked.structured_content.is_none());
assert!(
!serde_json::to_string(&blocked)
.unwrap()
.contains("CUS-1234")
);
}
}
#[test]
fn text_block_metadata_cannot_bypass_release_inspection() {
let _policy = mask();
let mut meta = rmcp::model::MetaObject::new();
meta.0
.insert("customer".into(), serde_json::json!("CUS-1234"));
let block = ContentBlock::Text(rmcp::model::TextContent::new("safe").with_meta(meta));
let blocked = release_result("ctx_read", CallToolResult::success(vec![block]));
assert_eq!(blocked.is_error, Some(true));
assert!(
!serde_json::to_string(&blocked)
.unwrap()
.contains("CUS-1234")
);
}
#[test]
fn current_tool_revocation_denies_previously_safe_results() {
let source = "name = 'release'\nversion = '1.0.0'\ndescription = 'test'\n[context]\ndeny_tools = ['ctx_read']\n";
let _policy =
runtime::TestPolicyOverride::set(Some(resolve(&parse(source).unwrap()).unwrap()));
assert_eq!(
release_result(
"ctx_read",
CallToolResult::success(vec![ContentBlock::text("safe")])
)
.is_error,
Some(true)
);
}
}