返回 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 `tool.gate.decision` record `audit.log` keeps for the same decision.
60 /// The reason goes through the secret redactor: a reviewer's rationale can
61 /// quote the command it judged. The caller adds `session_id`.
62 #[must_use]
63 pub fn tool_gate_audit_record(
64 agent_id: Option<&str>,
65 tool_id: &str,
66 tool_name: &str,
67 gate: ToolGate,
68 decision: ToolGateVerdict,
69 risk: Option<&str>,
70 reason: &str,
71 ) -> serde_json::Value {
72 serde_json::json!({
73 "agent_id": agent_id,
74 "tool_id": tool_id,
75 "tool_name": tool_name,
76 "gate": gate.as_str(),
77 "decision": decision.as_str(),
78 "risk": risk,
79 "reason": codewhale_secrets::redact::redact_secrets(reason),
80 })
81 }
82
83 /// The receipt for a safety-floor hold that Auto-Review denied without
84 /// pausing (the posture never opens a prompt).
85 #[must_use]
86 pub fn auto_review_held_receipt(locale: Locale, tool_name: &str) -> String {
87 fill(
88 tr(locale, MessageId::AutoReviewReceiptHeld),
89 tool_name,
90 "",
91 "",
92 )
93 }
94
95 fn fill(template: Cow<'static, str>, tool_name: &str, risk: &str, reason: &str) -> String {
96 template
97 .replace("{tool}", &bounded_tool_name(tool_name))
98 .replace("{risk}", risk)
99 .replace("{reason}", reason)
100 }
101
102 fn bounded_tool_name(tool_name: &str) -> String {
103 let cleaned = bounded_gate_reason(tool_name);
104 if cleaned.chars().count() <= MAX_TOOL_NAME_CHARS {
105 return cleaned;
106 }
107 let mut out: String = cleaned.chars().take(MAX_TOOL_NAME_CHARS - 1).collect();
108 out.push('…');
109 out
110 }
111
112 #[cfg(test)]
113 mod tests {
114 use super::*;
115
116 #[test]
117 fn gate_audit_record_names_the_gate_and_redacts_the_reason() {
118 let record = tool_gate_audit_record(
119 None,
120 "call-1",
121 "bash",
122 ToolGate::AutoReviewDeterministic,
123 ToolGateVerdict::Denied,
124 None,
125 "blocked `curl -H 'Authorization: Bearer sk-live-abcdefghijklmnopqrstuv' x`",
126 );
127 assert_eq!(record["gate"], "auto_review_deterministic");
128 assert_eq!(record["decision"], "denied");
129 assert_eq!(record["tool_id"], "call-1");
130 let reason = record["reason"].as_str().expect("reason");
131 assert!(
132 !reason.contains("sk-live-abcdefghijklmnopqrstuv"),
133 "{reason}"
134 );
135
136 // Written where every other audit event goes.
137 let home = tempfile::tempdir().expect("tempdir");
138 crate::audit::log_sensitive_event_in(home.path(), "tool.gate.decision", record);
139 let log = std::fs::read_to_string(home.path().join("audit.log")).expect("audit.log");
140 assert!(log.contains("\"event\":\"tool.gate.decision\""), "{log}");
141 assert!(
142 log.contains("\"gate\":\"auto_review_deterministic\""),
143 "{log}"
144 );
145 }
146
147 #[test]
148 fn guardian_allow_names_tool_risk_and_reason() {
149 let line = tool_gate_receipt(
150 Locale::En,
151 "bash",
152 ToolGate::AutoReviewGuardian,
153 ToolGateVerdict::Allowed,
154 Some("low"),
155 "reads a log file inside the workspace",
156 );
157 assert_eq!(
158 line,
159 "Auto-Review allowed 'bash' (low risk, model guardian): reads a log file inside the workspace"
160 );
161 }
162
163 #[test]
164 fn guardian_deny_and_unavailable_are_distinct_receipts() {
165 let denied = tool_gate_receipt(
166 Locale::En,
167 "bash",
168 ToolGate::AutoReviewGuardian,
169 ToolGateVerdict::Denied,
170 Some("high"),
171 "would push to a remote",
172 );
173 assert!(denied.starts_with("Auto-Review denied 'bash' (high risk, model guardian): "));
174 let unavailable = tool_gate_receipt(
175 Locale::En,
176 "File",
177 ToolGate::AutoReviewGuardian,
178 ToolGateVerdict::Unavailable,
179 None,
180 "guardian request timed out",
181 );
182 assert!(unavailable.contains("could not review 'File' (guardian request timed out)"));
183 assert!(
184 unavailable.ends_with("denied, fail closed"),
185 "{unavailable}"
186 );
187 assert!(!unavailable.contains("unknown risk"), "{unavailable}");
188 }
189
190 #[test]
191 fn deterministic_block_receipt_names_the_policy() {
192 let line = tool_gate_receipt(
193 Locale::En,
194 "bash",
195 ToolGate::AutoReviewDeterministic,
196 ToolGateVerdict::Denied,
197 None,
198 "publish-like command",
199 );
200 assert_eq!(
201 line,
202 "Auto-Review blocked 'bash' (deterministic policy): publish-like command"
203 );
204 }
205
206 #[test]
207 fn receipts_bound_and_defang_untrusted_reason_and_tool_text() {
208 let long = "x".repeat(600);
209 let line = tool_gate_receipt(
210 Locale::En,
211 "ba\u{1b}[31msh\n\u{202E}",
212 ToolGate::AutoReviewGuardian,
213 ToolGateVerdict::Denied,
214 Some("medium"),
215 &format!("evil\u{1b}]0;title\u{7} {long}"),
216 );
217 assert!(!line.contains('\u{1b}'));
218 assert!(!line.contains('\n'));
219 assert!(!line.contains('\u{202E}'));
220 assert!(!line.contains('\u{7}'));
221 assert!(line.chars().count() < 340, "{}", line.chars().count());
222 }
223
224 #[test]
225 fn held_receipt_is_localized_and_names_the_tool() {
226 let en = auto_review_held_receipt(Locale::En, "write");
227 assert!(en.starts_with("Auto-Review held 'write' without pausing"));
228 let ja = auto_review_held_receipt(Locale::Ja, "write");
229 assert!(ja.contains("'write'"));
230 assert_ne!(ja, en);
231 }
232
233 #[test]
234 fn every_shipped_pack_keeps_receipt_placeholders() {
235 for locale in Locale::shipped_complete() {
236 let line = tool_gate_receipt(
237 *locale,
238 "bash",
239 ToolGate::AutoReviewGuardian,
240 ToolGateVerdict::Allowed,
241 Some("low"),
242 "REASON-SENTINEL",
243 );
244 assert!(line.contains("'bash'"), "{locale:?}: {line}");
245 assert!(line.contains("REASON-SENTINEL"), "{locale:?}: {line}");
246 assert!(!line.contains('{'), "{locale:?}: {line}");
247 }
248 }
249 }
250
250 lines RUST