返回 CodeWhale
safe_label.rs
根目录 / crates / runtime / 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 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 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 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 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 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(any(test, feature = "test-support"))]
114 pub 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 codewhale_config::catalog::reviewed::public_model_identifier(value)
165 }
166
167 fn phrase_is_allowlisted(value: &str) -> bool {
168 if value.is_empty() || value.len() > MAX_PHRASE_LEN {
169 return false;
170 }
171 if value.contains('/') || value.contains('\\') || value.contains('~') {
172 return false;
173 }
174 if !value
175 .chars()
176 .all(|ch| is_identifier_char(ch) || matches!(ch, ' ' | '(' | ')' | ','))
177 {
178 return false;
179 }
180 !looks_opaque(value)
181 }
182
183 fn is_identifier_char(ch: char) -> bool {
184 ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-' | '+' | '@' | ':' | '/')
185 }
186
187 /// Whether a value carries a key-, token-, or hash-shaped run.
188 ///
189 /// Deliberately shape-based rather than a keyword list: `sk-`-style prefixes
190 /// are only one of the ways a deployment id can be a credential.
191 fn looks_opaque(value: &str) -> bool {
192 let lower = value.to_ascii_lowercase();
193 for marker in ["sk-", "api_key", "apikey", "secret", "password", "bearer"] {
194 if lower.contains(marker) {
195 return true;
196 }
197 }
198 let mut run = 0usize;
199 for ch in value.chars() {
200 // A long unbroken alphanumeric run with no separator is what base64
201 // and hex payloads look like; real ids use `-`, `.`, or `/`.
202 if ch.is_ascii_alphanumeric() {
203 run += 1;
204 if run >= OPAQUE_RUN_LEN {
205 return true;
206 }
207 } else {
208 run = 0;
209 }
210 }
211 false
212 }
213
214 /// Longest single word published verbatim inside an error sentence.
215 const MAX_ERROR_WORD_LEN: usize = 40;
216 /// Longest scheme published from a URL-shaped token.
217 const MAX_SCHEME_LEN: usize = 16;
218 /// Longest `host[:port]` published from a URL-shaped token.
219 const MAX_HOST_LEN: usize = 80;
220 /// Stand-in for a word that is not on the error allowlist.
221 const REDACTED_WORD: &str = "<redacted>";
222 /// Stand-in for anything path-shaped.
223 const REDACTED_PATH: &str = "<path-redacted>";
224
225 /// Bound an error string so it can be shown in the transcript.
226 ///
227 /// This is an **allowlist**, not a scrubber. Host error text is arbitrary: it
228 /// can interpolate a route id, a model id, a deployment path, a quoted server
229 /// name, a URL with a secret in its path, or a raw credential. Rather than
230 /// hunting for the bad parts, every whitespace-separated token must earn its
231 /// place:
232 ///
233 /// - the config crate's secret redaction runs first;
234 /// - a token containing a control character is dropped entirely;
235 /// - a URL-shaped token keeps only `scheme://host[:port]`, and only when both
236 /// are themselves allowlisted — the path, query, fragment, and userinfo are
237 /// never published, because a deployment path can *be* the credential;
238 /// - a path-shaped token (POSIX absolute, `~/`, Windows drive, or anything
239 /// containing a backslash) collapses to `REDACTED_PATH`;
240 /// - a token carrying a quote character (`"`, `'`, or a backtick) is replaced
241 /// wholesale: quoted spans are where hostile identifiers hide;
242 /// - anything else must be a short, ordinary word — ASCII alphanumerics plus
243 /// `-`, `_`, `.`, bounded by `MAX_ERROR_WORD_LEN` and rejected by
244 /// `looks_opaque` — with only a small set of sentence punctuation allowed
245 /// at its edges. Everything else becomes `REDACTED_WORD`.
246 ///
247 /// The result therefore contains no filesystem path, no URL path, no quoted
248 /// span, no token-shaped run, and no control character, and is truncated to
249 /// `MAX_ERROR_LEN`.
250 pub fn safe_error_text(raw: &str) -> String {
251 let redacted = codewhale_config::persistence::redact_secrets(raw);
252 let mut out = String::with_capacity(redacted.len().min(MAX_ERROR_LEN));
253 let mut last_was_redacted = false;
254 for token in redacted.split_whitespace() {
255 let safe = safe_error_token(token);
256 if safe.is_empty() {
257 continue;
258 }
259 // Collapse runs of redactions: `<redacted> <redacted> <redacted>` is
260 // noise, and its length would leak the shape of what was removed.
261 let is_redacted = safe == REDACTED_WORD;
262 if is_redacted && last_was_redacted {
263 continue;
264 }
265 last_was_redacted = is_redacted;
266 if !out.is_empty() {
267 out.push(' ');
268 }
269 out.push_str(&safe);
270 }
271 if out.is_empty() {
272 out.push_str("<unavailable>");
273 }
274 if out.len() > MAX_ERROR_LEN {
275 out.truncate(
276 (0..=MAX_ERROR_LEN)
277 .rev()
278 .find(|index| out.is_char_boundary(*index))
279 .unwrap_or(0),
280 );
281 out.push('…');
282 }
283 out
284 }
285
286 fn safe_error_token(token: &str) -> String {
287 if token.chars().any(char::is_control) {
288 return REDACTED_WORD.to_string();
289 }
290 // A URL keeps its scheme and host and loses everything after it — but only
291 // when the scheme and host are themselves ordinary.
292 if let Some(scheme_end) = token.find("://") {
293 return safe_url_token(token, scheme_end);
294 }
295 // Absolute and home-relative paths, plus Windows drive paths, collapse
296 // entirely: a workspace path names the user's machine and project.
297 let looks_like_path = token.starts_with('/')
298 || token.starts_with("~/")
299 || token.contains('\\')
300 || (token.len() > 2 && token.as_bytes()[1] == b':' && token.contains('\\'));
301 if looks_like_path {
302 return REDACTED_PATH.to_string();
303 }
304 // Quoted spans are the classic carrier for a hostile server, route, or
305 // model id. Never republish one, even partially.
306 if token.contains(['"', '\'', '`']) {
307 return REDACTED_WORD.to_string();
308 }
309
310 let (lead, core, trail) = split_sentence_punctuation(token);
311 if core.is_empty() {
312 // Pure punctuation: keep it only if every character is on the small
313 // sentence-punctuation allowlist, which `split` already guaranteed.
314 return format!("{lead}{trail}");
315 }
316 if error_word_is_allowlisted(core) {
317 format!("{lead}{core}{trail}")
318 } else {
319 REDACTED_WORD.to_string()
320 }
321 }
322
323 /// Collapse a URL-shaped token to `scheme://host[:port]/<path-redacted>`.
324 ///
325 /// Userinfo, path, query, and fragment are dropped unconditionally. A scheme
326 /// or host that is not itself ordinary makes the whole token opaque rather
327 /// than publishing a hostile "host".
328 fn safe_url_token(token: &str, scheme_end: usize) -> String {
329 let scheme = &token[..scheme_end];
330 let rest = &token[scheme_end + 3..];
331 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
332 let host = rest[..authority_end].rsplit('@').next().unwrap_or("");
333
334 let scheme_ok = !scheme.is_empty()
335 && scheme.len() <= MAX_SCHEME_LEN
336 && scheme
337 .chars()
338 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'));
339 let host_ok = !host.is_empty()
340 && host.len() <= MAX_HOST_LEN
341 && host
342 .chars()
343 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | ':'))
344 && !looks_opaque(host);
345 if scheme_ok && host_ok {
346 format!("{scheme}://{host}/{REDACTED_PATH}")
347 } else {
348 REDACTED_WORD.to_string()
349 }
350 }
351
352 /// Sentence punctuation that may bracket an allowlisted word. Deliberately
353 /// excludes every quote character.
354 fn is_edge_punctuation(ch: char) -> bool {
355 matches!(ch, '.' | ',' | ';' | ':' | '!' | '?' | '(' | ')')
356 }
357
358 /// Split leading/trailing sentence punctuation off a token.
359 ///
360 /// Returns `("", token, "")` when the token carries punctuation that is not on
361 /// the edge allowlist, so the caller rejects it as a whole.
362 fn split_sentence_punctuation(token: &str) -> (&str, &str, &str) {
363 let start = token
364 .char_indices()
365 .find(|(_, ch)| !is_edge_punctuation(*ch))
366 .map_or(token.len(), |(index, _)| index);
367 let end = token
368 .char_indices()
369 .rev()
370 .find(|(_, ch)| !is_edge_punctuation(*ch))
371 .map_or(start, |(index, ch)| index + ch.len_utf8());
372 (
373 &token[..start],
374 &token[start..end.max(start)],
375 &token[end.max(start)..],
376 )
377 }
378
379 /// Whether a bare word inside an error sentence may be published verbatim.
380 fn error_word_is_allowlisted(word: &str) -> bool {
381 if word.is_empty() || word.len() > MAX_ERROR_WORD_LEN {
382 return false;
383 }
384 if word.contains("..") {
385 return false;
386 }
387 if !word
388 .chars()
389 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
390 {
391 return false;
392 }
393 !looks_opaque(word)
394 }
395
396 #[cfg(test)]
397 mod tests {
398 use super::*;
399
400 #[test]
401 fn ordinary_identifiers_pass_through_verbatim() {
402 for value in [
403 "deepseek-chat",
404 "claude-sonnet-4-5",
405 "gpt-5-codex",
406 "MiniMax-M3",
407 "my-gateway",
408 "kimi-k2-0905-preview",
409 ] {
410 let label = SafeLabel::identifier(value);
411 assert_eq!(label.as_str(), value, "{value} must publish verbatim");
412 assert!(!label.is_redacted(), "{value}");
413 }
414 }
415
416 #[test]
417 fn hostile_route_and_model_identifiers_never_reach_a_surface() {
418 let hostile = [
419 "/Users/someone/models/private-weights.gguf".to_string(),
420 "~/.codewhale/config.toml".to_string(),
421 "https://internal.example.com/v1/deployments/prod".to_string(),
422 "C:\\Users\\someone\\models\\weights.bin".to_string(),
423 ["sk", "-fixture-not-a-real-key-00000000"].concat(),
424 "deployments/9f8e7d6c5b4a39281706abcdef012345".to_string(),
425 "../../etc/passwd".to_string(),
426 "model with spaces and a /path/inside".to_string(),
427 ["api_key=sk", "-live-1234567890"].concat(),
428 "src/lib.rs".to_string(),
429 "config/prod".to_string(),
430 "models/weights.gguf".to_string(),
431 "foo/bar-baz".to_string(),
432 ];
433 for value in hostile {
434 let label = SafeLabel::identifier(&value);
435 assert!(label.is_redacted(), "`{value}` must not publish verbatim");
436 assert!(label.as_str().starts_with("sha256:"), "{}", label.as_str());
437 assert!(!label.as_str().contains('/'), "{}", label.as_str());
438 assert!(!label.as_str().contains(' '), "{}", label.as_str());
439 }
440 }
441
442 #[test]
443 fn generic_identifiers_reject_all_slashes_and_catalog_models_require_exact_ids() {
444 for path in [
445 "src/lib.rs",
446 "docs/PREVIEW_REQUEST.md",
447 "config/prod",
448 "models/llama-3.gguf",
449 "foo/bar-baz",
450 ] {
451 assert!(
452 SafeLabel::identifier(path).is_redacted(),
453 "relative path `{path}` must not be published"
454 );
455 }
456
457 let known = "qwen/qwen3.6-flash";
458 assert!(SafeLabel::identifier(known).is_redacted());
459 assert_eq!(SafeLabel::catalog_model(known).as_str(), known);
460 for hostile in ["openai/secrets/config", "qwen/src/lib.rs"] {
461 assert!(SafeLabel::identifier(hostile).is_redacted());
462 assert!(SafeLabel::catalog_model(hostile).is_redacted());
463 }
464 }
465
466 #[test]
467 fn fingerprints_are_stable_and_distinguishing() {
468 let first = SafeLabel::identifier("/models/a.gguf");
469 let second = SafeLabel::identifier("/models/a.gguf");
470 let other = SafeLabel::identifier("/models/b.gguf");
471 assert_eq!(first, second);
472 assert_ne!(first, other);
473 }
474
475 #[test]
476 fn phrases_allow_spaces_but_not_paths() {
477 assert_eq!(
478 SafeLabel::phrase("Codex OAuth quota").as_str(),
479 "Codex OAuth quota"
480 );
481 assert!(SafeLabel::phrase("/opt/quota/plan").is_redacted());
482 }
483
484 #[test]
485 fn error_text_is_path_and_url_path_safe() {
486 let raw = "MCP server 'x' failed: cannot spawn /Users/someone/work/repo/bin/server \
487 while calling https://gateway.internal.example.com/v1/secret-deployment/messages";
488 let safe = safe_error_text(raw);
489 assert!(!safe.contains("/Users/someone"), "{safe}");
490 assert!(!safe.contains("/v1/secret-deployment"), "{safe}");
491 assert!(safe.contains("<path-redacted>"), "{safe}");
492 assert!(
493 safe.contains("https://gateway.internal.example.com/<path-redacted>"),
494 "{safe}"
495 );
496 }
497
498 /// The error surface is where hostile text most easily reaches a
499 /// transcript: preflight, MCP, and request-preparation failures all
500 /// interpolate route ids, model ids, server names, and endpoints.
501 #[test]
502 fn hostile_error_text_never_publishes_the_hostile_part() {
503 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
504 let hostile: Vec<String> = vec![
505 "route 'prod-key-8f2a' rejected key sk-live-abcdef0123456789abcdef".to_string(),
506 "cannot read C:\\Users\\someone\\.codewhale\\config.toml".to_string(),
507 format!("cannot read {home}/.codewhale/config.toml"),
508 "GET https://gw.example.com/v1/deployments/prod-key-8f2a?api_key=sk-1234567890abcdef failed".to_string(),
509 "server \"my secret server\" refused: password=hunter2".to_string(),
510 "model /Users/someone/models/private.gguf is unavailable".to_string(),
511 "authorization: Bearer eyJhbGciFAKEFIXTUREnotasecret".to_string(),
512 "endpoint http://10.0.0.5:8443/internal/deploy-9f8e7d6c5b4a3928 timed out".to_string(),
513 format!("crash{}oops", '\u{7}'),
514 ];
515 for raw in &hostile {
516 let safe = safe_error_text(raw);
517 for forbidden in [
518 "prod-key-8f2a",
519 "sk-live-",
520 "sk-1234567890",
521 "/Users/someone",
522 "C:\\Users",
523 ".codewhale",
524 "api_key=",
525 "hunter2",
526 "password=",
527 "eyJhbGci",
528 "/v1/deployments",
529 "/internal/deploy",
530 "private.gguf",
531 "my secret server",
532 ] {
533 assert!(
534 !safe.contains(forbidden),
535 "`{forbidden}` leaked from `{raw}`:\n{safe}"
536 );
537 }
538 assert!(!safe.contains(&home), "home leaked from `{raw}`:\n{safe}");
539 assert!(!safe.contains('"'), "{safe}");
540 assert!(!safe.contains('\''), "{safe}");
541 assert!(!safe.contains('`'), "{safe}");
542 assert!(!safe.chars().any(char::is_control), "{safe}");
543 }
544 }
545
546 #[test]
547 fn ordinary_error_words_survive_so_the_message_stays_useful() {
548 let safe = safe_error_text("the shared route planner could not resolve this turn.");
549 assert_eq!(
550 safe, "the shared route planner could not resolve this turn.",
551 "an allowlisted sentence must survive intact"
552 );
553 }
554
555 #[test]
556 fn a_url_with_a_hostile_authority_is_dropped_rather_than_half_published() {
557 // The "host" here is a long opaque run — republishing it would be
558 // republishing the secret the path redaction exists to remove.
559 let safe = safe_error_text("calling https://9f8e7d6c5b4a39281706abcdef012345.example/x");
560 assert!(!safe.contains("9f8e7d6c5b4a3928"), "{safe}");
561 assert!(safe.contains("<redacted>"), "{safe}");
562 }
563
564 #[test]
565 fn error_text_is_bounded_and_single_line() {
566 let raw = format!("failure {}", "x".repeat(4_000));
567 let safe = safe_error_text(&raw);
568 assert!(safe.chars().count() <= MAX_ERROR_LEN + 1, "{}", safe.len());
569 assert!(!safe.contains('\n'));
570 }
571 }
572
572 lines RUST