返回 CodeWhale
safe_label.rs
根目录 / crates / tui / src / safe_label.rs
1 //! The allowlisted safe-label boundary for `/preview-request` (#1004).
2 //!
3 //! Every free-form string that reaches a manifest surface — human table or
4 //! JSON — crosses this module first. Nothing here is a "scrubber" that tries
5 //! to find secrets in arbitrary text: a value either matches a narrow
6 //! allowlist and is published verbatim, or it is replaced by a stable
7 //! `sha256:<12 hex>` fingerprint. Two previews of the same route still
8 //! compare equal, and nothing that was not on the allowlist is ever printed.
9 //!
10 //! Why this exists at all: the obvious "identifier" fields are not safe by
11 //! construction. A custom `[providers.<name>]` key is user-authored text, and
12 //! a model id can be a filesystem path (`/models/llama-3.gguf`), a URL, a URL
13 //! path, or a deployment id that is itself a credential. Bounding the *shape*
14 //! of what may be printed is the only way to keep those out of a manifest a
15 //! user will paste into an issue tracker.
16 //!
17 //! Error strings get the same treatment through [`safe_error_text`], which is
18 //! path- and URL-path-safe: an MCP or request-preparation failure often
19 //! carries an absolute workspace path or an endpoint URL, and neither may
20 //! reach the transcript.
21
22 use serde::{Serialize, Serializer};
23
24 /// Longest identifier published verbatim. Real provider/model/route ids are
25 /// far shorter; anything longer is treated as opaque payload.
26 const MAX_IDENTIFIER_LEN: usize = 64;
27 /// Longest short phrase (labels with spaces, e.g. a billing presentation).
28 const MAX_PHRASE_LEN: usize = 80;
29 /// Longest error sentence published. Errors are truncated, never wrapped.
30 const MAX_ERROR_LEN: usize = 200;
31 /// A run of this many characters from a single "opaque" alphabet reads as a
32 /// key, token, or hash rather than as a name.
33 const OPAQUE_RUN_LEN: usize = 20;
34 /// Hex prefix length used when a value is replaced by its fingerprint.
35 const FINGERPRINT_HEX_LEN: usize = 12;
36
37 /// A string that is safe to publish on a manifest surface.
38 ///
39 /// Construct with [`SafeLabel::identifier`], [`SafeLabel::catalog_model`], or
40 /// [`SafeLabel::phrase`]; all fall back to a fingerprint when the input is not
41 /// on the allowlist. There is deliberately no constructor that takes
42 /// arbitrary text verbatim.
43 #[derive(Debug, Clone, PartialEq, Eq)]
44 pub(crate) struct SafeLabel {
45 text: String,
46 redacted: bool,
47 }
48
49 impl SafeLabel {
50 /// A generic identifier-shaped value: provider id, route id, reasoning
51 /// tier. Allows `A-Z a-z 0-9 . _ : - + @` and rejects every slash. Model
52 /// ids with a slash must use [`Self::catalog_model`] instead.
53 pub(crate) fn identifier(raw: &str) -> Self {
54 let trimmed = raw.trim();
55 if identifier_is_allowlisted(trimmed) {
56 Self {
57 text: trimmed.to_string(),
58 redacted: false,
59 }
60 } else {
61 Self::fingerprint(raw)
62 }
63 }
64
65 /// A model label. Slash-bearing values are published only when the exact
66 /// id exists in the active local model catalog; a vendor-looking prefix is
67 /// never authority by itself. Non-slash ids retain the generic identifier
68 /// boundary for custom compatible deployments.
69 pub(crate) fn catalog_model(raw: &str) -> Self {
70 let trimmed = raw.trim();
71 if !trimmed.contains('/') {
72 return Self::identifier(raw);
73 }
74 if catalog_model_identifier_is_allowlisted(trimmed) {
75 Self {
76 text: trimmed.to_string(),
77 redacted: false,
78 }
79 } else {
80 Self::fingerprint(raw)
81 }
82 }
83
84 /// A short human phrase: the same allowlist plus spaces, parentheses, and
85 /// commas, for host-supplied presentation labels.
86 pub(crate) fn phrase(raw: &str) -> Self {
87 let trimmed = raw.trim();
88 if phrase_is_allowlisted(trimmed) {
89 Self {
90 text: trimmed.to_string(),
91 redacted: false,
92 }
93 } else {
94 Self::fingerprint(raw)
95 }
96 }
97
98 /// Replace a value with a stable fingerprint of its exact bytes.
99 fn fingerprint(raw: &str) -> Self {
100 let digest = crate::hashing::sha256_hex(raw.as_bytes());
101 Self {
102 text: format!("sha256:{}", &digest[..FINGERPRINT_HEX_LEN]),
103 redacted: true,
104 }
105 }
106
107 pub(crate) fn as_str(&self) -> &str {
108 &self.text
109 }
110
111 /// True when the original value failed the allowlist and only its
112 /// fingerprint is being published.
113 #[cfg(test)]
114 pub(crate) fn is_redacted(&self) -> bool {
115 self.redacted
116 }
117 }
118
119 impl std::fmt::Display for SafeLabel {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 f.write_str(&self.text)
122 }
123 }
124
125 impl Serialize for SafeLabel {
126 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
127 serializer.serialize_str(&self.text)
128 }
129 }
130
131 fn identifier_is_allowlisted(value: &str) -> bool {
132 if value.is_empty() || value.len() > MAX_IDENTIFIER_LEN {
133 return false;
134 }
135 if value.starts_with('/') || value.starts_with('~') || value.starts_with('.') {
136 return false;
137 }
138 if value.contains("//") || value.contains("..") || value.contains(':') && value.contains('/') {
139 return false;
140 }
141 if !value.chars().all(is_identifier_char) {
142 return false;
143 }
144 if value.contains('/') {
145 return false;
146 }
147 !looks_opaque(value)
148 }
149
150 fn catalog_model_identifier_is_allowlisted(value: &str) -> bool {
151 if value.is_empty()
152 || value.len() > MAX_IDENTIFIER_LEN
153 || value.starts_with('/')
154 || value.starts_with('~')
155 || value.starts_with('.')
156 || value.contains("//")
157 || value.contains("..")
158 || value.contains(':')
159 || !value.chars().all(is_identifier_char)
160 || looks_opaque(value)
161 {
162 return false;
163 }
164 crate::model_catalog::resolved_entry(value)
165 .is_some_and(|entry| entry.id == value || entry.provider_model_id.as_deref() == Some(value))
166 }
167
168 fn phrase_is_allowlisted(value: &str) -> bool {
169 if value.is_empty() || value.len() > MAX_PHRASE_LEN {
170 return false;
171 }
172 if value.contains('/') || value.contains('\\') || value.contains('~') {
173 return false;
174 }
175 if !value
176 .chars()
177 .all(|ch| is_identifier_char(ch) || matches!(ch, ' ' | '(' | ')' | ','))
178 {
179 return false;
180 }
181 !looks_opaque(value)
182 }
183
184 fn is_identifier_char(ch: char) -> bool {
185 ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-' | '+' | '@' | ':' | '/')
186 }
187
188 /// Whether a value carries a key-, token-, or hash-shaped run.
189 ///
190 /// Deliberately shape-based rather than a keyword list: `sk-`-style prefixes
191 /// are only one of the ways a deployment id can be a credential.
192 fn looks_opaque(value: &str) -> bool {
193 let lower = value.to_ascii_lowercase();
194 for marker in ["sk-", "api_key", "apikey", "secret", "password", "bearer"] {
195 if lower.contains(marker) {
196 return true;
197 }
198 }
199 let mut run = 0usize;
200 for ch in value.chars() {
201 // A long unbroken alphanumeric run with no separator is what base64
202 // and hex payloads look like; real ids use `-`, `.`, or `/`.
203 if ch.is_ascii_alphanumeric() {
204 run += 1;
205 if run >= OPAQUE_RUN_LEN {
206 return true;
207 }
208 } else {
209 run = 0;
210 }
211 }
212 false
213 }
214
215 /// Longest single word published verbatim inside an error sentence.
216 const MAX_ERROR_WORD_LEN: usize = 40;
217 /// Longest scheme published from a URL-shaped token.
218 const MAX_SCHEME_LEN: usize = 16;
219 /// Longest `host[:port]` published from a URL-shaped token.
220 const MAX_HOST_LEN: usize = 80;
221 /// Stand-in for a word that is not on the error allowlist.
222 const REDACTED_WORD: &str = "<redacted>";
223 /// Stand-in for anything path-shaped.
224 const REDACTED_PATH: &str = "<path-redacted>";
225
226 /// Bound an error string so it can be shown in the transcript.
227 ///
228 /// This is an **allowlist**, not a scrubber. Host error text is arbitrary: it
229 /// can interpolate a route id, a model id, a deployment path, a quoted server
230 /// name, a URL with a secret in its path, or a raw credential. Rather than
231 /// hunting for the bad parts, every whitespace-separated token must earn its
232 /// place:
233 ///
234 /// - the config crate's secret redaction runs first;
235 /// - a token containing a control character is dropped entirely;
236 /// - a URL-shaped token keeps only `scheme://host[:port]`, and only when both
237 /// are themselves allowlisted — the path, query, fragment, and userinfo are
238 /// never published, because a deployment path can *be* the credential;
239 /// - a path-shaped token (POSIX absolute, `~/`, Windows drive, or anything
240 /// containing a backslash) collapses to [`REDACTED_PATH`];
241 /// - a token carrying a quote character (`"`, `'`, or a backtick) is replaced
242 /// wholesale: quoted spans are where hostile identifiers hide;
243 /// - anything else must be a short, ordinary word — ASCII alphanumerics plus
244 /// `-`, `_`, `.`, bounded by [`MAX_ERROR_WORD_LEN`] and rejected by
245 /// [`looks_opaque`] — with only a small set of sentence punctuation allowed
246 /// at its edges. Everything else becomes [`REDACTED_WORD`].
247 ///
248 /// The result therefore contains no filesystem path, no URL path, no quoted
249 /// span, no token-shaped run, and no control character, and is truncated to
250 /// [`MAX_ERROR_LEN`].
251 pub(crate) fn safe_error_text(raw: &str) -> String {
252 let redacted = codewhale_config::persistence::redact_secrets(raw);
253 let mut out = String::with_capacity(redacted.len().min(MAX_ERROR_LEN));
254 let mut last_was_redacted = false;
255 for token in redacted.split_whitespace() {
256 let safe = safe_error_token(token);
257 if safe.is_empty() {
258 continue;
259 }
260 // Collapse runs of redactions: `<redacted> <redacted> <redacted>` is
261 // noise, and its length would leak the shape of what was removed.
262 let is_redacted = safe == REDACTED_WORD;
263 if is_redacted && last_was_redacted {
264 continue;
265 }
266 last_was_redacted = is_redacted;
267 if !out.is_empty() {
268 out.push(' ');
269 }
270 out.push_str(&safe);
271 }
272 if out.is_empty() {
273 out.push_str("<unavailable>");
274 }
275 if out.len() > MAX_ERROR_LEN {
276 out.truncate(
277 (0..=MAX_ERROR_LEN)
278 .rev()
279 .find(|index| out.is_char_boundary(*index))
280 .unwrap_or(0),
281 );
282 out.push('…');
283 }
284 out
285 }
286
287 fn safe_error_token(token: &str) -> String {
288 if token.chars().any(char::is_control) {
289 return REDACTED_WORD.to_string();
290 }
291 // A URL keeps its scheme and host and loses everything after it — but only
292 // when the scheme and host are themselves ordinary.
293 if let Some(scheme_end) = token.find("://") {
294 return safe_url_token(token, scheme_end);
295 }
296 // Absolute and home-relative paths, plus Windows drive paths, collapse
297 // entirely: a workspace path names the user's machine and project.
298 let looks_like_path = token.starts_with('/')
299 || token.starts_with("~/")
300 || token.contains('\\')
301 || (token.len() > 2 && token.as_bytes()[1] == b':' && token.contains('\\'));
302 if looks_like_path {
303 return REDACTED_PATH.to_string();
304 }
305 // Quoted spans are the classic carrier for a hostile server, route, or
306 // model id. Never republish one, even partially.
307 if token.contains(['"', '\'', '`']) {
308 return REDACTED_WORD.to_string();
309 }
310
311 let (lead, core, trail) = split_sentence_punctuation(token);
312 if core.is_empty() {
313 // Pure punctuation: keep it only if every character is on the small
314 // sentence-punctuation allowlist, which `split` already guaranteed.
315 return format!("{lead}{trail}");
316 }
317 if error_word_is_allowlisted(core) {
318 format!("{lead}{core}{trail}")
319 } else {
320 REDACTED_WORD.to_string()
321 }
322 }
323
324 /// Collapse a URL-shaped token to `scheme://host[:port]/<path-redacted>`.
325 ///
326 /// Userinfo, path, query, and fragment are dropped unconditionally. A scheme
327 /// or host that is not itself ordinary makes the whole token opaque rather
328 /// than publishing a hostile "host".
329 fn safe_url_token(token: &str, scheme_end: usize) -> String {
330 let scheme = &token[..scheme_end];
331 let rest = &token[scheme_end + 3..];
332 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
333 let host = rest[..authority_end].rsplit('@').next().unwrap_or("");
334
335 let scheme_ok = !scheme.is_empty()
336 && scheme.len() <= MAX_SCHEME_LEN
337 && scheme
338 .chars()
339 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'));
340 let host_ok = !host.is_empty()
341 && host.len() <= MAX_HOST_LEN
342 && host
343 .chars()
344 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | ':'))
345 && !looks_opaque(host);
346 if scheme_ok && host_ok {
347 format!("{scheme}://{host}/{REDACTED_PATH}")
348 } else {
349 REDACTED_WORD.to_string()
350 }
351 }
352
353 /// Sentence punctuation that may bracket an allowlisted word. Deliberately
354 /// excludes every quote character.
355 fn is_edge_punctuation(ch: char) -> bool {
356 matches!(ch, '.' | ',' | ';' | ':' | '!' | '?' | '(' | ')')
357 }
358
359 /// Split leading/trailing sentence punctuation off a token.
360 ///
361 /// Returns `("", token, "")` when the token carries punctuation that is not on
362 /// the edge allowlist, so the caller rejects it as a whole.
363 fn split_sentence_punctuation(token: &str) -> (&str, &str, &str) {
364 let start = token
365 .char_indices()
366 .find(|(_, ch)| !is_edge_punctuation(*ch))
367 .map_or(token.len(), |(index, _)| index);
368 let end = token
369 .char_indices()
370 .rev()
371 .find(|(_, ch)| !is_edge_punctuation(*ch))
372 .map_or(start, |(index, ch)| index + ch.len_utf8());
373 (
374 &token[..start],
375 &token[start..end.max(start)],
376 &token[end.max(start)..],
377 )
378 }
379
380 /// Whether a bare word inside an error sentence may be published verbatim.
381 fn error_word_is_allowlisted(word: &str) -> bool {
382 if word.is_empty() || word.len() > MAX_ERROR_WORD_LEN {
383 return false;
384 }
385 if word.contains("..") {
386 return false;
387 }
388 if !word
389 .chars()
390 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
391 {
392 return false;
393 }
394 !looks_opaque(word)
395 }
396
397 #[cfg(test)]
398 mod tests {
399 use super::*;
400
401 #[test]
402 fn ordinary_identifiers_pass_through_verbatim() {
403 for value in [
404 "deepseek-chat",
405 "claude-sonnet-4-5",
406 "gpt-5-codex",
407 "MiniMax-M3",
408 "my-gateway",
409 "kimi-k2-0905-preview",
410 ] {
411 let label = SafeLabel::identifier(value);
412 assert_eq!(label.as_str(), value, "{value} must publish verbatim");
413 assert!(!label.is_redacted(), "{value}");
414 }
415 }
416
417 #[test]
418 fn hostile_route_and_model_identifiers_never_reach_a_surface() {
419 let hostile = [
420 "/Users/someone/models/private-weights.gguf".to_string(),
421 "~/.codewhale/config.toml".to_string(),
422 "https://internal.example.com/v1/deployments/prod".to_string(),
423 "C:\\Users\\someone\\models\\weights.bin".to_string(),
424 ["sk", "-fixture-not-a-real-key-00000000"].concat(),
425 "deployments/9f8e7d6c5b4a39281706abcdef012345".to_string(),
426 "../../etc/passwd".to_string(),
427 "model with spaces and a /path/inside".to_string(),
428 ["api_key=sk", "-live-1234567890"].concat(),
429 "src/lib.rs".to_string(),
430 "config/prod".to_string(),
431 "models/weights.gguf".to_string(),
432 "foo/bar-baz".to_string(),
433 ];
434 for value in hostile {
435 let label = SafeLabel::identifier(&value);
436 assert!(label.is_redacted(), "`{value}` must not publish verbatim");
437 assert!(label.as_str().starts_with("sha256:"), "{}", label.as_str());
438 assert!(!label.as_str().contains('/'), "{}", label.as_str());
439 assert!(!label.as_str().contains(' '), "{}", label.as_str());
440 }
441 }
442
443 #[test]
444 fn generic_identifiers_reject_all_slashes_and_catalog_models_require_exact_ids() {
445 let _catalog_guard = crate::model_catalog::test_catalog_lock();
446 for path in [
447 "src/lib.rs",
448 "docs/PREVIEW_REQUEST.md",
449 "config/prod",
450 "models/llama-3.gguf",
451 "foo/bar-baz",
452 ] {
453 assert!(
454 SafeLabel::identifier(path).is_redacted(),
455 "relative path `{path}` must not be published"
456 );
457 }
458
459 let known = "qwen/qwen3.6-flash";
460 assert!(SafeLabel::identifier(known).is_redacted());
461 assert_eq!(SafeLabel::catalog_model(known).as_str(), known);
462 for hostile in ["openai/secrets/config", "qwen/src/lib.rs"] {
463 assert!(SafeLabel::identifier(hostile).is_redacted());
464 assert!(SafeLabel::catalog_model(hostile).is_redacted());
465 }
466 }
467
468 #[test]
469 fn fingerprints_are_stable_and_distinguishing() {
470 let first = SafeLabel::identifier("/models/a.gguf");
471 let second = SafeLabel::identifier("/models/a.gguf");
472 let other = SafeLabel::identifier("/models/b.gguf");
473 assert_eq!(first, second);
474 assert_ne!(first, other);
475 }
476
477 #[test]
478 fn phrases_allow_spaces_but_not_paths() {
479 assert_eq!(
480 SafeLabel::phrase("Codex OAuth quota").as_str(),
481 "Codex OAuth quota"
482 );
483 assert!(SafeLabel::phrase("/opt/quota/plan").is_redacted());
484 }
485
486 #[test]
487 fn error_text_is_path_and_url_path_safe() {
488 let raw = "MCP server 'x' failed: cannot spawn /Users/someone/work/repo/bin/server \
489 while calling https://gateway.internal.example.com/v1/secret-deployment/messages";
490 let safe = safe_error_text(raw);
491 assert!(!safe.contains("/Users/someone"), "{safe}");
492 assert!(!safe.contains("/v1/secret-deployment"), "{safe}");
493 assert!(safe.contains("<path-redacted>"), "{safe}");
494 assert!(
495 safe.contains("https://gateway.internal.example.com/<path-redacted>"),
496 "{safe}"
497 );
498 }
499
500 /// The error surface is where hostile text most easily reaches a
501 /// transcript: preflight, MCP, and request-preparation failures all
502 /// interpolate route ids, model ids, server names, and endpoints.
503 #[test]
504 fn hostile_error_text_never_publishes_the_hostile_part() {
505 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
506 let hostile: Vec<String> = vec![
507 "route 'prod-key-8f2a' rejected key sk-live-abcdef0123456789abcdef".to_string(),
508 "cannot read C:\\Users\\someone\\.codewhale\\config.toml".to_string(),
509 format!("cannot read {home}/.codewhale/config.toml"),
510 "GET https://gw.example.com/v1/deployments/prod-key-8f2a?api_key=sk-1234567890abcdef failed".to_string(),
511 "server \"my secret server\" refused: password=hunter2".to_string(),
512 "model /Users/someone/models/private.gguf is unavailable".to_string(),
513 "authorization: Bearer eyJhbGciFAKEFIXTUREnotasecret".to_string(),
514 "endpoint http://10.0.0.5:8443/internal/deploy-9f8e7d6c5b4a3928 timed out".to_string(),
515 format!("crash{}oops", '\u{7}'),
516 ];
517 for raw in &hostile {
518 let safe = safe_error_text(raw);
519 for forbidden in [
520 "prod-key-8f2a",
521 "sk-live-",
522 "sk-1234567890",
523 "/Users/someone",
524 "C:\\Users",
525 ".codewhale",
526 "api_key=",
527 "hunter2",
528 "password=",
529 "eyJhbGci",
530 "/v1/deployments",
531 "/internal/deploy",
532 "private.gguf",
533 "my secret server",
534 ] {
535 assert!(
536 !safe.contains(forbidden),
537 "`{forbidden}` leaked from `{raw}`:\n{safe}"
538 );
539 }
540 assert!(!safe.contains(&home), "home leaked from `{raw}`:\n{safe}");
541 assert!(!safe.contains('"'), "{safe}");
542 assert!(!safe.contains('\''), "{safe}");
543 assert!(!safe.contains('`'), "{safe}");
544 assert!(!safe.chars().any(char::is_control), "{safe}");
545 }
546 }
547
548 #[test]
549 fn ordinary_error_words_survive_so_the_message_stays_useful() {
550 let safe = safe_error_text("the shared route planner could not resolve this turn.");
551 assert_eq!(
552 safe, "the shared route planner could not resolve this turn.",
553 "an allowlisted sentence must survive intact"
554 );
555 }
556
557 #[test]
558 fn a_url_with_a_hostile_authority_is_dropped_rather_than_half_published() {
559 // The "host" here is a long opaque run — republishing it would be
560 // republishing the secret the path redaction exists to remove.
561 let safe = safe_error_text("calling https://9f8e7d6c5b4a39281706abcdef012345.example/x");
562 assert!(!safe.contains("9f8e7d6c5b4a3928"), "{safe}");
563 assert!(safe.contains("<redacted>"), "{safe}");
564 }
565
566 #[test]
567 fn error_text_is_bounded_and_single_line() {
568 let raw = format!("failure {}", "x".repeat(4_000));
569 let safe = safe_error_text(&raw);
570 assert!(safe.chars().count() <= MAX_ERROR_LEN + 1, "{}", safe.len());
571 assert!(!safe.contains('\n'));
572 }
573 }
574
574 lines RUST