返回 CodeWhale
gate_receipts.rs
根目录 / crates / tui / src / tui / gate_receipts.rs
1 //! Transcript receipts for permission decisions nobody was prompted for.
2 //!
3 //! Auto-Review makes decisions a person would otherwise never see: the model
4 //! guardian allows or denies a held call, the deterministic policy blocks one,
5 //! or a hold that needs a person is denied instead of opening a modal. The
6 //! audit log keeps the full record; these one-line notes make the decision
7 //! and its stated reason visible in the transcript, the way a permission
8 //! prompt would have been.
9 //!
10 //! Proven-safe deterministic allows are deliberately silent (a routine read
11 //! is not news) — the same convention other harnesses use for rule-based
12 //! auto-approvals.
13
14 use std::borrow::Cow;
15
16 use crate::core::events::{ToolGate, ToolGateVerdict, bounded_gate_reason};
17 use codewhale_localization::{Locale, MessageId, tr};
18
19 /// Longest tool name echoed into a receipt. Tool names are model-authored
20 /// text on some wire dialects, so they are bounded like every other field.
21 const MAX_TOOL_NAME_CHARS: usize = 64;
22
23 /// One transcript line for a [`crate::core::events::Event::ToolGateDecision`].
24 #[must_use]
25 pub fn tool_gate_receipt(
26 locale: Locale,
27 tool_name: &str,
28 gate: ToolGate,
29 decision: ToolGateVerdict,
30 risk: Option<&str>,
31 reason: &str,
32 ) -> String {
33 let id = match (gate, decision) {
34 (ToolGate::AutoReviewGuardian, ToolGateVerdict::Allowed) => {
35 MessageId::AutoReviewReceiptGuardianAllowed
36 }
37 (ToolGate::AutoReviewGuardian, ToolGateVerdict::Denied) => {
38 MessageId::AutoReviewReceiptGuardianDenied
39 }
40 (ToolGate::AutoReviewGuardian, ToolGateVerdict::Unavailable) => {
41 MessageId::AutoReviewReceiptGuardianUnavailable
42 }
43 // The deterministic engine only surfaces blocks; an allow it proved
44 // safe stays silent and an unavailable deterministic verdict does not
45 // exist (the engine always answers).
46 (
47 ToolGate::AutoReviewDeterministic,
48 ToolGateVerdict::Denied | ToolGateVerdict::Allowed | ToolGateVerdict::Unavailable,
49 ) => MessageId::AutoReviewReceiptDeterministicBlocked,
50 };
51 fill(
52 tr(locale, id),
53 tool_name,
54 risk.unwrap_or("unknown"),
55 &bounded_gate_reason(reason),
56 )
57 }
58
59 /// The receipt for a safety-floor hold that Auto-Review denied without
60 /// pausing (the posture never opens a prompt).
61 #[must_use]
62 pub fn auto_review_held_receipt(locale: Locale, tool_name: &str) -> String {
63 fill(
64 tr(locale, MessageId::AutoReviewReceiptHeld),
65 tool_name,
66 "",
67 "",
68 )
69 }
70
71 fn fill(template: Cow<'static, str>, tool_name: &str, risk: &str, reason: &str) -> String {
72 template
73 .replace("{tool}", &bounded_tool_name(tool_name))
74 .replace("{risk}", risk)
75 .replace("{reason}", reason)
76 }
77
78 fn bounded_tool_name(tool_name: &str) -> String {
79 let cleaned = bounded_gate_reason(tool_name);
80 if cleaned.chars().count() <= MAX_TOOL_NAME_CHARS {
81 return cleaned;
82 }
83 let mut out: String = cleaned.chars().take(MAX_TOOL_NAME_CHARS - 1).collect();
84 out.push('…');
85 out
86 }
87
88 #[cfg(test)]
89 mod tests {
90 use super::*;
91
92 #[test]
93 fn guardian_allow_names_tool_risk_and_reason() {
94 let line = tool_gate_receipt(
95 Locale::En,
96 "bash",
97 ToolGate::AutoReviewGuardian,
98 ToolGateVerdict::Allowed,
99 Some("low"),
100 "reads a log file inside the workspace",
101 );
102 assert_eq!(
103 line,
104 "Auto-Review allowed 'bash' (low risk, model guardian): reads a log file inside the workspace"
105 );
106 }
107
108 #[test]
109 fn guardian_deny_and_unavailable_are_distinct_receipts() {
110 let denied = tool_gate_receipt(
111 Locale::En,
112 "bash",
113 ToolGate::AutoReviewGuardian,
114 ToolGateVerdict::Denied,
115 Some("high"),
116 "would push to a remote",
117 );
118 assert!(denied.starts_with("Auto-Review denied 'bash' (high risk, model guardian): "));
119 let unavailable = tool_gate_receipt(
120 Locale::En,
121 "File",
122 ToolGate::AutoReviewGuardian,
123 ToolGateVerdict::Unavailable,
124 None,
125 "guardian request timed out",
126 );
127 assert!(unavailable.contains("could not review 'File' (guardian request timed out)"));
128 assert!(
129 unavailable.ends_with("denied, fail closed"),
130 "{unavailable}"
131 );
132 assert!(!unavailable.contains("unknown risk"), "{unavailable}");
133 }
134
135 #[test]
136 fn deterministic_block_receipt_names_the_policy() {
137 let line = tool_gate_receipt(
138 Locale::En,
139 "bash",
140 ToolGate::AutoReviewDeterministic,
141 ToolGateVerdict::Denied,
142 None,
143 "publish-like command",
144 );
145 assert_eq!(
146 line,
147 "Auto-Review blocked 'bash' (deterministic policy): publish-like command"
148 );
149 }
150
151 #[test]
152 fn receipts_bound_and_defang_untrusted_reason_and_tool_text() {
153 let long = "x".repeat(600);
154 let line = tool_gate_receipt(
155 Locale::En,
156 "ba\u{1b}[31msh\n\u{202E}",
157 ToolGate::AutoReviewGuardian,
158 ToolGateVerdict::Denied,
159 Some("medium"),
160 &format!("evil\u{1b}]0;title\u{7} {long}"),
161 );
162 assert!(!line.contains('\u{1b}'));
163 assert!(!line.contains('\n'));
164 assert!(!line.contains('\u{202E}'));
165 assert!(!line.contains('\u{7}'));
166 assert!(line.chars().count() < 340, "{}", line.chars().count());
167 }
168
169 #[test]
170 fn held_receipt_is_localized_and_names_the_tool() {
171 let en = auto_review_held_receipt(Locale::En, "write");
172 assert!(en.starts_with("Auto-Review held 'write' without pausing"));
173 let ja = auto_review_held_receipt(Locale::Ja, "write");
174 assert!(ja.contains("'write'"));
175 assert_ne!(ja, en);
176 }
177
178 #[test]
179 fn every_shipped_pack_keeps_receipt_placeholders() {
180 for locale in Locale::shipped_complete() {
181 let line = tool_gate_receipt(
182 *locale,
183 "bash",
184 ToolGate::AutoReviewGuardian,
185 ToolGateVerdict::Allowed,
186 Some("low"),
187 "REASON-SENTINEL",
188 );
189 assert!(line.contains("'bash'"), "{locale:?}: {line}");
190 assert!(line.contains("REASON-SENTINEL"), "{locale:?}: {line}");
191 assert!(!line.contains('{'), "{locale:?}: {line}");
192 }
193 }
194 }
195
195 lines RUST