jcp: hand the caller its receipt, in MCP's namespaced _meta slot #69
1 changed files with 193 additions and 3 deletions
|
|
@ -274,6 +274,14 @@ impl Gateway {
|
|||
self.log.head(),
|
||||
);
|
||||
|
||||
// The receipt for THIS call is the one just appended, so the caller
|
||||
// receives evidence for its own request rather than having to trust
|
||||
// that the gateway logged something somewhere.
|
||||
let response = match self.log.entries().last() {
|
||||
Some(r) => attach_receipt(&response, r, measured_uj, decision.budget_remaining_uj),
|
||||
None => response,
|
||||
};
|
||||
|
||||
Ok(Relayed::Allowed {
|
||||
response,
|
||||
measured_uj,
|
||||
|
|
@ -295,13 +303,106 @@ impl Gateway {
|
|||
let d = self.runtime.authorize(&frame, &anchor, self.caller, self.now);
|
||||
self.log.append(&frame, &grant, &d);
|
||||
}
|
||||
// A denial the caller cannot see is an enforcement nobody can audit, so
|
||||
// the sealed refusal rides back in `error.data._meta` — the same
|
||||
// namespace as an allow, because a receipt's value does not depend on
|
||||
// the verdict going the caller's way.
|
||||
let response = match self.log.entries().last() {
|
||||
Some(r) => attach_denial_receipt(id, -32601, &format!("JCP denied: {reason} `{tool}`"), r),
|
||||
None => jsonrpc_error(id, -32601, &format!("JCP denied: {reason} `{tool}`")),
|
||||
};
|
||||
Relayed::Denied {
|
||||
response: jsonrpc_error(id, -32601, &format!("JCP denied: {reason} `{tool}`")),
|
||||
response,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `_meta` namespace this gateway reads and writes.
|
||||
///
|
||||
/// MCP's 2026-07-28 revision made `_meta` a first-class, namespaced extension
|
||||
/// point and put a 12-month deprecation policy behind it, so a policy object
|
||||
/// carried here has a defined place to live rather than an invented one. The
|
||||
/// prefix is reverse-domain, as the spec requires for third-party keys.
|
||||
pub const JCP_META_NS: &str = "science.transaction/jcp";
|
||||
|
||||
/// Read this gateway's side-data out of a request's `params`.
|
||||
///
|
||||
/// Prefers the namespaced `_meta` slot and falls back to the flat
|
||||
/// `params._jcp_witness` this gateway used before MCP had one. The fallback is
|
||||
/// deliberate: a client that has not been updated must keep working, and a
|
||||
/// silent behaviour change at an enforcement point is worse than a legacy key.
|
||||
fn jcp_meta(params: &Value) -> Option<&Value> {
|
||||
params.get("_meta").and_then(|m| m.get(JCP_META_NS))
|
||||
}
|
||||
|
||||
/// Attach the sealed receipt for this call to a JSON-RPC response's
|
||||
/// `result._meta`, so the caller receives the artifact for its own request.
|
||||
///
|
||||
/// Until now the receipt existed only in the gateway's own log: the client got
|
||||
/// its tool result and no evidence, which makes the receipt an artifact the
|
||||
/// enforcing party keeps about itself. A receipt the subject never sees cannot
|
||||
/// be presented, disputed, or forwarded, and that is most of what a receipt is
|
||||
/// for. Returns the response unchanged if it is not a JSON-RPC result object —
|
||||
/// this must never mangle an upstream payload it does not understand.
|
||||
fn attach_receipt(response: &str, receipt: &[u8], measured_uj: u64, remaining_uj: u64) -> String {
|
||||
let Ok(mut v) = serde_json::from_str::<Value>(response) else {
|
||||
return response.to_string();
|
||||
};
|
||||
let Some(result) = v.get_mut("result").and_then(Value::as_object_mut) else {
|
||||
return response.to_string();
|
||||
};
|
||||
let meta = result
|
||||
.entry("_meta")
|
||||
.or_insert_with(|| json!({}))
|
||||
.as_object_mut();
|
||||
let Some(meta) = meta else { return response.to_string() };
|
||||
meta.insert(
|
||||
JCP_META_NS.to_string(),
|
||||
json!({
|
||||
"receipt_b64": b64(receipt),
|
||||
"measured_uj": measured_uj,
|
||||
"budget_remaining_uj": remaining_uj,
|
||||
}),
|
||||
);
|
||||
v.to_string()
|
||||
}
|
||||
|
||||
/// A JSON-RPC error carrying the sealed refusal in `error.data._meta`.
|
||||
///
|
||||
/// JSON-RPC reserves `error.data` for implementation-defined detail, which is
|
||||
/// where a refusal's evidence belongs: the caller is told not merely that it was
|
||||
/// denied but handed the signed record of the denial, which it can present to a
|
||||
/// third party without the gateway's cooperation.
|
||||
fn attach_denial_receipt(id: Option<&Value>, code: i64, message: &str, receipt: &[u8]) -> String {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id.cloned().unwrap_or(Value::Null),
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"data": { "_meta": { JCP_META_NS: { "receipt_b64": b64(receipt) } } }
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Standard base64 (RFC 4648 §4, padded) — the receipt is a COSE_Sign1 envelope
|
||||
/// and JSON has no byte type.
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||
for c in bytes.chunks(3) {
|
||||
let b = [c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0)];
|
||||
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
|
||||
out.push(T[(n >> 18 & 63) as usize] as char);
|
||||
out.push(T[(n >> 12 & 63) as usize] as char);
|
||||
out.push(if c.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });
|
||||
out.push(if c.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The digest of a grant's flow ceiling, for the receipt seal. `None` when the
|
||||
/// grant carries no flow caveat.
|
||||
fn ceiling_digest(grant: &Grant) -> Option<ContentId> {
|
||||
|
|
@ -312,8 +413,9 @@ fn ceiling_digest(grant: &Grant) -> Option<ContentId> {
|
|||
/// of hex content ids), if present — an explicit "this argument came from these
|
||||
/// values" the harness can assert beyond what the scan finds.
|
||||
fn declared_witness_ids(params: &Value) -> BTreeSet<ContentId> {
|
||||
params
|
||||
.get("_jcp_witness")
|
||||
jcp_meta(params)
|
||||
.and_then(|m| m.get("witness"))
|
||||
.or_else(|| params.get("_jcp_witness"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
|
|
@ -383,6 +485,94 @@ mod tests {
|
|||
Gateway::new("files", &[("read_file", 50_000_000), ("search", 50_000_000)], SigningKey::from_bytes(&[7u8; 32]))
|
||||
}
|
||||
|
||||
/// The caller receives the receipt for its OWN call. Until now the receipt
|
||||
/// existed only in the gateway's log — the enforcing party keeping an
|
||||
/// artifact about itself, which the subject could never present.
|
||||
#[test]
|
||||
fn the_caller_gets_its_receipt_back() {
|
||||
let mut g = gw();
|
||||
let mut up = MockServer { calls: 0 };
|
||||
let Relayed::Allowed { response, measured_uj, budget_remaining_uj } =
|
||||
g.relay(&call("read_file", 1), &mut up).unwrap()
|
||||
else {
|
||||
panic!("expected Allowed")
|
||||
};
|
||||
|
||||
let v: Value = serde_json::from_str(&response).unwrap();
|
||||
let meta = v.pointer(&format!("/result/_meta/{}", JCP_META_NS.replace('/', "~1")))
|
||||
.expect("receipt must ride in result._meta under the namespaced key");
|
||||
assert_eq!(meta["measured_uj"].as_u64(), Some(measured_uj));
|
||||
assert_eq!(meta["budget_remaining_uj"].as_u64(), Some(budget_remaining_uj));
|
||||
|
||||
// The returned receipt is the same sealed bytes the gateway logged —
|
||||
// not a summary of them.
|
||||
let b64_returned = meta["receipt_b64"].as_str().expect("receipt_b64");
|
||||
assert_eq!(b64_returned, b64(g.receipts().last().unwrap()));
|
||||
|
||||
// And the upstream's own payload survived untouched beside it.
|
||||
assert_eq!(v["result"]["ok"].as_bool(), Some(true));
|
||||
}
|
||||
|
||||
/// A refusal the caller cannot see is an enforcement nobody can audit, so a
|
||||
/// denial carries its sealed record too.
|
||||
#[test]
|
||||
fn a_denial_hands_back_its_sealed_refusal() {
|
||||
let mut g = gw();
|
||||
let mut up = MockServer { calls: 0 };
|
||||
let Relayed::Denied { response, .. } = g.relay(&call("shell", 1), &mut up).unwrap() else {
|
||||
panic!("expected Denied")
|
||||
};
|
||||
let v: Value = serde_json::from_str(&response).unwrap();
|
||||
let ptr = format!("/error/data/_meta/{}", JCP_META_NS.replace('/', "~1"));
|
||||
let meta = v.pointer(&ptr).expect("a denial must carry its receipt");
|
||||
assert_eq!(meta["receipt_b64"].as_str(), Some(b64(g.receipts().last().unwrap()).as_str()));
|
||||
assert_eq!(up.calls, 0, "upstream still never contacted");
|
||||
}
|
||||
|
||||
/// The witness moves to MCP's namespaced `_meta`, and the flat key a client
|
||||
/// may still be sending keeps working — a silent behaviour change at an
|
||||
/// enforcement point is worse than a legacy key.
|
||||
#[test]
|
||||
fn witness_reads_the_meta_slot_and_still_honours_the_legacy_key() {
|
||||
let id = ContentId::of_bytes(b"w");
|
||||
let hex = id.to_hex();
|
||||
|
||||
let modern = json!({"name":"t","arguments":{},
|
||||
"_meta": { JCP_META_NS: { "witness": [hex] } }});
|
||||
assert!(declared_witness_ids(&modern).contains(&id), "namespaced _meta slot");
|
||||
|
||||
let legacy = json!({"name":"t","arguments":{}, "_jcp_witness": [hex]});
|
||||
assert!(declared_witness_ids(&legacy).contains(&id), "legacy flat key still honoured");
|
||||
|
||||
// `_meta` present but carrying someone else's namespace must not be read
|
||||
// as ours, and must not resurrect the legacy key by accident.
|
||||
let foreign = json!({"name":"t","arguments":{}, "_meta": { "other/ns": { "witness": [hex] } }});
|
||||
assert!(declared_witness_ids(&foreign).is_empty(), "another vendor's key is not ours");
|
||||
}
|
||||
|
||||
/// Attaching must never mangle a payload it does not understand.
|
||||
#[test]
|
||||
fn a_response_the_gateway_cannot_parse_is_returned_untouched() {
|
||||
let junk = "not json at all";
|
||||
assert_eq!(attach_receipt(junk, b"r", 1, 2), junk);
|
||||
// Valid JSON, but no `result` object to extend.
|
||||
let err = r#"{"jsonrpc":"2.0","id":1,"error":{"code":-1,"message":"x"}}"#;
|
||||
assert_eq!(attach_receipt(err, b"r", 1, 2), err);
|
||||
}
|
||||
|
||||
/// Base64 is RFC 4648 with padding — pinned against known vectors, because a
|
||||
/// receipt that does not decode is not a receipt.
|
||||
#[test]
|
||||
fn base64_matches_rfc4648() {
|
||||
assert_eq!(b64(b""), "");
|
||||
assert_eq!(b64(b"f"), "Zg==");
|
||||
assert_eq!(b64(b"fo"), "Zm8=");
|
||||
assert_eq!(b64(b"foo"), "Zm9v");
|
||||
assert_eq!(b64(b"foob"), "Zm9vYg==");
|
||||
assert_eq!(b64(b"fooba"), "Zm9vYmE=");
|
||||
assert_eq!(b64(b"foobar"), "Zm9vYmFy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn granted_tool_is_allowed_metered_and_debited() {
|
||||
let mut g = gw();
|
||||
|
|
|
|||
Loading…
Reference in a new issue