Skip to repository content410 lines · 15.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:03:31.521Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
receipt.rs
1//! Authority receipt detail for one activity row.
2//!
3//! An allowed decision is never proof that the target broker acted. The
4//! inspector surfaces the authority block only: tool, allowed flag, receipt,
5//! decision, bounded result refs, and refusal reason class when refused.
6
7use serde::{Deserialize, Serialize};
8
9use crate::public_ref::{is_public_safe_ref, sanitize_public_ref, PublicRef};
10
11/// Cap on bounded result references kept on one inspector row.
12pub const MAX_BOUNDED_RESULT_REFS: usize = 8;
13
14/// Allowed vs refused authority decision.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum AuthorityStatus {
18 Allowed,
19 Refused,
20}
21
22impl AuthorityStatus {
23 pub fn from_allowed_flag(allowed: bool) -> Self {
24 if allowed {
25 Self::Allowed
26 } else {
27 Self::Refused
28 }
29 }
30
31 pub fn is_allowed(self) -> bool {
32 matches!(self, Self::Allowed)
33 }
34
35 pub fn as_label(self) -> &'static str {
36 match self {
37 Self::Allowed => "allowed",
38 Self::Refused => "refused",
39 }
40 }
41}
42
43/// Reason class for a refused authority decision.
44///
45/// Matches the `@openagentsinc/authority` denial reasons plus a reserved
46/// category when the refusal is a reserved-action deny.
47#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case", tag = "class")]
49pub enum RefusalReasonClass {
50 ProfileInactive,
51 ReservedAction,
52 GrantNotFound,
53 ConditionMissing,
54 ConditionFailed,
55 ProfileInvalid,
56 AuthorityRefused,
57 /// Public-safe unknown class string after sanitization.
58 Other { reason_class: PublicRef },
59}
60
61impl RefusalReasonClass {
62 /// Parse a public-safe reason class token from service/runtime output.
63 pub fn parse(raw: &str) -> Option<Self> {
64 let trimmed = raw.trim();
65 let normalized = trimmed.to_ascii_lowercase().replace('-', "_");
66 match normalized.as_str() {
67 "profile_inactive" => Some(Self::ProfileInactive),
68 "reserved_action" | "reserved" | "needs_owner_reserved_action" => {
69 Some(Self::ReservedAction)
70 }
71 "grant_not_found" => Some(Self::GrantNotFound),
72 "condition_missing" => Some(Self::ConditionMissing),
73 "condition_failed" => Some(Self::ConditionFailed),
74 "profile_invalid" => Some(Self::ProfileInvalid),
75 "authority_refused" | "denied" | "refused" => Some(Self::AuthorityRefused),
76 other => sanitize_public_ref(other).map(|reason_class| Self::Other { reason_class }),
77 }
78 }
79
80 pub fn as_label(&self) -> &str {
81 match self {
82 Self::ProfileInactive => "profile_inactive",
83 Self::ReservedAction => "reserved_action",
84 Self::GrantNotFound => "grant_not_found",
85 Self::ConditionMissing => "condition_missing",
86 Self::ConditionFailed => "condition_failed",
87 Self::ProfileInvalid => "profile_invalid",
88 Self::AuthorityRefused => "authority_refused",
89 Self::Other { reason_class } => reason_class.as_str(),
90 }
91 }
92
93 pub fn is_reserved(&self) -> bool {
94 matches!(self, Self::ReservedAction)
95 }
96}
97
98/// Refusal detail shown when `allowed` is false.
99#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
100pub struct RefusalDetail {
101 pub reason_class: RefusalReasonClass,
102 /// Present when the refusal maps to a reserved category id.
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub reserved_category: Option<PublicRef>,
105}
106
107impl RefusalDetail {
108 pub fn new(reason_class: RefusalReasonClass, reserved_category: Option<PublicRef>) -> Self {
109 // Only surface reserved_category when the reason class is reserved.
110 let reserved_category = if reason_class.is_reserved() {
111 reserved_category
112 } else {
113 None
114 };
115 Self {
116 reason_class,
117 reserved_category,
118 }
119 }
120}
121
122/// Public-safe details for one activity/receipt row.
123#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
124pub struct AuthorityReceiptDetail {
125 pub tool_ref: PublicRef,
126 pub allowed: bool,
127 pub authority_receipt_ref: PublicRef,
128 pub decision_ref: PublicRef,
129 /// Primary bounded result reference (first safe result ref, if any).
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub bounded_result_ref: Option<PublicRef>,
132 /// Additional bounded result references (already capped and redacted).
133 #[serde(default, skip_serializing_if = "Vec::is_empty")]
134 pub bounded_result_refs: Vec<PublicRef>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub refusal: Option<RefusalDetail>,
137}
138
139impl AuthorityReceiptDetail {
140 pub fn status(&self) -> AuthorityStatus {
141 AuthorityStatus::from_allowed_flag(self.allowed)
142 }
143
144 /// Build a detail view from raw service fields, redacting unsafe values.
145 ///
146 /// Returns `None` when the required public refs cannot be sanitized.
147 pub fn from_raw(input: RawAuthorityBlock<'_>) -> Option<Self> {
148 let tool_ref = sanitize_public_ref(input.tool_ref)?;
149 let authority_receipt_ref = sanitize_public_ref(input.authority_receipt_ref)?;
150 let decision_ref = sanitize_public_ref(input.decision_ref)?;
151
152 let mut bounded_result_refs: Vec<PublicRef> = input
153 .result_refs
154 .iter()
155 .filter_map(|r| sanitize_public_ref(r))
156 .take(MAX_BOUNDED_RESULT_REFS)
157 .collect();
158 // Prefer an explicit primary when provided and safe.
159 if let Some(primary) = input.bounded_result_ref.and_then(sanitize_public_ref) {
160 bounded_result_refs.retain(|r| r != &primary);
161 bounded_result_refs.insert(0, primary);
162 if bounded_result_refs.len() > MAX_BOUNDED_RESULT_REFS {
163 bounded_result_refs.truncate(MAX_BOUNDED_RESULT_REFS);
164 }
165 }
166 let bounded_result_ref = bounded_result_refs.first().cloned();
167
168 let refusal = if input.allowed {
169 None
170 } else {
171 let reason_class = input
172 .refusal_reason
173 .and_then(RefusalReasonClass::parse)
174 .or_else(|| {
175 // Derive reserved from blocker / category hints.
176 if input
177 .reserved_category
178 .is_some_and(|c| is_public_safe_ref(c) || c.starts_with("reserved."))
179 {
180 Some(RefusalReasonClass::ReservedAction)
181 } else {
182 Some(RefusalReasonClass::AuthorityRefused)
183 }
184 })
185 .unwrap_or(RefusalReasonClass::AuthorityRefused);
186 let reserved_category = input
187 .reserved_category
188 .and_then(sanitize_public_ref)
189 .or_else(|| {
190 input
191 .result_refs
192 .iter()
193 .find_map(|r| {
194 let s = r.trim();
195 if s.starts_with("reserved.") {
196 sanitize_public_ref(s)
197 } else {
198 None
199 }
200 })
201 });
202 Some(RefusalDetail::new(reason_class, reserved_category))
203 };
204
205 Some(Self {
206 tool_ref,
207 allowed: input.allowed,
208 authority_receipt_ref,
209 decision_ref,
210 bounded_result_ref,
211 bounded_result_refs,
212 refusal,
213 })
214 }
215}
216
217/// Raw authority block fields as they arrive from the service projection.
218///
219/// Callers pass references only; this type never owns secrets and drops any
220/// field that fails public-safety checks during projection.
221#[derive(Clone, Debug)]
222pub struct RawAuthorityBlock<'a> {
223 pub tool_ref: &'a str,
224 pub allowed: bool,
225 pub authority_receipt_ref: &'a str,
226 pub decision_ref: &'a str,
227 pub result_refs: &'a [&'a str],
228 pub bounded_result_ref: Option<&'a str>,
229 pub refusal_reason: Option<&'a str>,
230 pub reserved_category: Option<&'a str>,
231}
232
233/// JSON-facing authority block (service event payload subset).
234#[derive(Clone, Debug, Default, Serialize, Deserialize)]
235#[serde(rename_all = "camelCase")]
236pub struct AuthorityBlockJson {
237 #[serde(default, alias = "tool_ref")]
238 pub tool_ref: Option<String>,
239 #[serde(default)]
240 pub allowed: Option<bool>,
241 #[serde(
242 default,
243 alias = "authority_receipt_ref",
244 alias = "authorityRef",
245 alias = "authority_ref"
246 )]
247 pub authority_receipt_ref: Option<String>,
248 #[serde(default, alias = "decision_ref")]
249 pub decision_ref: Option<String>,
250 #[serde(default, alias = "result_refs", alias = "resultRefs")]
251 pub result_refs: Vec<String>,
252 #[serde(
253 default,
254 alias = "bounded_result_ref",
255 alias = "boundedResultRef"
256 )]
257 pub bounded_result_ref: Option<String>,
258 #[serde(
259 default,
260 alias = "refusal_reason",
261 alias = "refusalReason",
262 alias = "reason"
263 )]
264 pub refusal_reason: Option<String>,
265 #[serde(
266 default,
267 alias = "reserved_category",
268 alias = "reservedCategory"
269 )]
270 pub reserved_category: Option<String>,
271 // Unknown keys (content, token, rawOutput, paths, etc.) are ignored by
272 // serde's default and never become inspector fields.
273}
274
275impl AuthorityBlockJson {
276 /// Project into a public-safe receipt detail, dropping unsafe fields.
277 pub fn project(&self) -> Option<AuthorityReceiptDetail> {
278 let tool_ref = self.tool_ref.as_deref()?;
279 let authority_receipt_ref = self.authority_receipt_ref.as_deref()?;
280 let decision_ref = self.decision_ref.as_deref()?;
281 let result_ref_stores: Vec<&str> = self.result_refs.iter().map(String::as_str).collect();
282 AuthorityReceiptDetail::from_raw(RawAuthorityBlock {
283 tool_ref,
284 allowed: self.allowed.unwrap_or(true),
285 authority_receipt_ref,
286 decision_ref,
287 result_refs: &result_ref_stores,
288 bounded_result_ref: self.bounded_result_ref.as_deref(),
289 refusal_reason: self.refusal_reason.as_deref(),
290 reserved_category: self.reserved_category.as_deref(),
291 })
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn allowed_receipt_has_no_refusal() {
301 let detail = AuthorityReceiptDetail::from_raw(RawAuthorityBlock {
302 tool_ref: "tool.sarah.list_full_auto_runs",
303 allowed: true,
304 authority_receipt_ref: "receipt.authority.sarah.tool.abc.list",
305 decision_ref: "decision.sarah.tool.call-1",
306 result_refs: &["result.full_auto.run.r1"],
307 bounded_result_ref: None,
308 refusal_reason: Some("should_be_ignored"),
309 reserved_category: Some("reserved.secret_export"),
310 })
311 .expect("valid allowed receipt");
312 assert!(detail.allowed);
313 assert!(detail.refusal.is_none());
314 assert_eq!(
315 detail.bounded_result_ref.as_ref().map(PublicRef::as_str),
316 Some("result.full_auto.run.r1")
317 );
318 }
319
320 #[test]
321 fn refused_receipt_exposes_reason_class_and_reserved_category() {
322 let detail = AuthorityReceiptDetail::from_raw(RawAuthorityBlock {
323 tool_ref: "tool.sarah.move_financial_value",
324 allowed: false,
325 authority_receipt_ref: "receipt.authority.sarah.tool.abc.refuse",
326 decision_ref: "decision.sarah.tool.call-2",
327 result_refs: &["blocker.sarah.authority_refused"],
328 bounded_result_ref: None,
329 refusal_reason: Some("reserved_action"),
330 reserved_category: Some("reserved.financial_custody"),
331 })
332 .expect("valid refused receipt");
333 assert!(!detail.allowed);
334 let refusal = detail.refusal.expect("refusal present");
335 assert_eq!(refusal.reason_class.as_label(), "reserved_action");
336 assert_eq!(
337 refusal.reserved_category.as_ref().map(PublicRef::as_str),
338 Some("reserved.financial_custody")
339 );
340 }
341
342 #[test]
343 fn redacts_unsafe_result_refs_and_required_fields() {
344 let detail = AuthorityReceiptDetail::from_raw(RawAuthorityBlock {
345 tool_ref: "tool.sarah.read_release_state",
346 allowed: true,
347 authority_receipt_ref: "receipt.authority.sarah.tool.safe",
348 decision_ref: "decision.sarah.tool.call-3",
349 result_refs: &[
350 "result.release.state.ok",
351 "/Users/owner/.codex/auth.json",
352 "sk-ant-api03-SECRETVALUE0000000000",
353 "result.with spaces is bad",
354 ],
355 bounded_result_ref: Some("Bearer eyJhbGciOiJIUzI1NiJ9"),
356 refusal_reason: None,
357 reserved_category: None,
358 })
359 .expect("required refs valid");
360 assert_eq!(detail.bounded_result_refs.len(), 1);
361 assert_eq!(
362 detail.bounded_result_ref.as_ref().map(PublicRef::as_str),
363 Some("result.release.state.ok")
364 );
365 }
366
367 #[test]
368 fn rejects_when_required_ref_is_unsafe() {
369 assert!(
370 AuthorityReceiptDetail::from_raw(RawAuthorityBlock {
371 tool_ref: "/tmp/evil",
372 allowed: true,
373 authority_receipt_ref: "receipt.authority.sarah.tool.safe",
374 decision_ref: "decision.sarah.tool.call-4",
375 result_refs: &[],
376 bounded_result_ref: None,
377 refusal_reason: None,
378 reserved_category: None,
379 })
380 .is_none()
381 );
382 }
383
384 #[test]
385 fn json_projection_drops_unsafe_payload_fields() {
386 let json = serde_json::json!({
387 "toolRef": "tool.sarah.control_full_auto",
388 "allowed": false,
389 "authorityReceiptRef": "receipt.authority.sarah.tool.ctrl",
390 "decisionRef": "decision.sarah.tool.call-5",
391 "resultRefs": ["blocker.sarah.authority_refused"],
392 "refusalReason": "reserved_action",
393 "reservedCategory": "reserved.stable_release_without_direction",
394 "content": "full tool dump with secrets sk-abc",
395 "token": "OPENAGENTS_AGENT_TOKEN=should-not-appear",
396 "rawOutput": {"path": "/Users/owner/private"}
397 });
398 let block: AuthorityBlockJson = serde_json::from_value(json).expect("parse");
399 let detail = block.project().expect("project");
400 let encoded = serde_json::to_string(&detail).expect("encode");
401 assert!(!encoded.contains("sk-abc"));
402 assert!(!encoded.contains("OPENAGENTS_AGENT_TOKEN"));
403 assert!(!encoded.contains("/Users/owner"));
404 assert!(!encoded.contains("full tool dump"));
405 assert!(encoded.contains("reserved_action"));
406 assert!(encoded.contains("reserved.stable_release_without_direction"));
407 assert!(!detail.allowed);
408 }
409}
410