返回 CodeWhale
redaction.rs
根目录 / crates / config / src / redaction.rs
1 //! Model-bound redaction opt-out (`[redaction] model_bound`).
2 //!
3 //! Codewhale masks credential-looking values in tool output before it is sent
4 //! to an upstream model (the "model boundary"). That masking is a security
5 //! backstop: a file read by a tool can contain a configured API key, a bare
6 //! provider token, or a credential-shaped opaque string, and the model must
7 //! never see those bytes.
8 //!
9 //! This module adds a deliberate, documented way to turn that masking off for
10 //! users who must edit files that contain real credentials. Because it lowers
11 //! a security boundary, it is not a plain boolean:
12 //!
13 //! * Setting `[redaction] model_bound = "disabled"` in `config.toml` only
14 //! records a *request*.
15 //! * The request takes effect only after a restart of the interactive TUI and
16 //! an explicit confirmation on the startup gate screen, which persists a
17 //! receipt next to the config file actually loaded by that launch.
18 //! * Non-interactive entry points (`codewhale exec`, hooks, automation) never
19 //! confirm anything; as long as no confirmation receipt exists they resolve
20 //! to the safe default (`Enabled`), whatever the config file says.
21 //! * Dismissing the gate (choosing "keep masking on") leaves the config field
22 //! and the receipt untouched, so the next launch asks again until the user
23 //! confirms or edits the field back to `"enabled"`.
24 //!
25 //! A confirmation receipt is bound to the loaded config file, including an
26 //! explicit `--config` or `CODEWHALE_CONFIG_PATH`. Different config filenames
27 //! have independent receipts. A receipt is honored only while the canonical
28 //! path, contents and modification time still match and the config requests
29 //! `"disabled"`.
30 //! Editing the field back to `"enabled"` - or changing `config.toml` in any
31 //! way - and later re-requesting `"disabled"` always asks for a fresh
32 //! confirmation, even when no process ran in between.
33
34 use serde::{Deserialize, Serialize};
35 use sha2::{Digest, Sha256};
36 use std::fs;
37 use std::io;
38 use std::path::{Path, PathBuf};
39
40 /// Name of the confirmation-receipt file, stored next to `config.toml` in the
41 /// Codewhale home directory.
42 pub const MODEL_BOUND_STATE_FILE_NAME: &str = "redaction-state.json";
43
44 /// Whether credential-shaped values are masked at the model boundary.
45 ///
46 /// Parsing is deliberately forgiving on the way in — the config value is a
47 /// security switch and users reach for boolean spellings — so `true`/`false`,
48 /// `"on"`/`"off"`, and `"enabled"`/`"disabled"` (any casing) all resolve to
49 /// the same two states. Serialization always writes the canonical
50 /// `"enabled"` / `"disabled"` words.
51 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
52 #[serde(rename_all = "kebab-case")]
53 pub enum ModelBoundMasking {
54 /// Mask credential-shaped tool output before it reaches the model (default).
55 #[default]
56 Enabled,
57 /// Let the model see the raw bytes of tool output, credentials included.
58 /// Only effective after an explicit startup confirmation (see the module
59 /// docs); until then it resolves to [`ModelBoundMasking::Enabled`].
60 Disabled,
61 }
62
63 impl ModelBoundMasking {
64 pub fn is_disabled(self) -> bool {
65 self == Self::Disabled
66 }
67 }
68
69 impl<'de> serde::Deserialize<'de> for ModelBoundMasking {
70 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
71 where
72 D: serde::Deserializer<'de>,
73 {
74 #[derive(serde::Deserialize)]
75 #[serde(untagged)]
76 enum Raw {
77 Flag(bool),
78 Word(String),
79 }
80 match Raw::deserialize(deserializer)? {
81 Raw::Flag(true) => Ok(ModelBoundMasking::Enabled),
82 Raw::Flag(false) => Ok(ModelBoundMasking::Disabled),
83 Raw::Word(word) => match word.to_ascii_lowercase().as_str() {
84 "enabled" | "on" | "true" => Ok(ModelBoundMasking::Enabled),
85 "disabled" | "off" | "false" => Ok(ModelBoundMasking::Disabled),
86 other => Err(serde::de::Error::unknown_variant(
87 other,
88 &["enabled", "disabled", "on", "off", "true", "false"],
89 )),
90 },
91 }
92 }
93 }
94
95 /// The `[redaction]` table of `config.toml`.
96 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
97 #[serde(default)]
98 pub struct RedactionToml {
99 /// Model-bound masking policy: `"enabled"` (default) or `"disabled"`.
100 /// Boolean spellings are also accepted: `false` / `"off"` mean the same
101 /// as `"disabled"`, and `true` / `"on"` mean `"enabled"`.
102 ///
103 /// A `"disabled"` request is honored only after a TUI restart and a one-time
104 /// confirmation on the startup gate; see the module documentation.
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub model_bound: Option<ModelBoundMasking>,
107 }
108
109 impl RedactionToml {
110 /// The requested masking mode, defaulting to [`ModelBoundMasking::Enabled`].
111 pub fn model_bound_masking(&self) -> ModelBoundMasking {
112 self.model_bound.unwrap_or_default()
113 }
114 }
115
116 /// Receipt belonging to this exact config file. Keep the established name
117 /// for config.toml; other filenames get independent receipts even when they
118 /// live in the same directory and contain identical configuration.
119 pub fn model_bound_state_path(config_path: &Path) -> PathBuf {
120 // --config and environment resolution may spell the same file through
121 // different symlinks (for example /var and /private/var on macOS).
122 let resolved = config_path.canonicalize().ok();
123 let config_path = resolved.as_deref().unwrap_or(config_path);
124 if config_path.file_name() == Some(std::ffi::OsStr::new(crate::CONFIG_FILE_NAME)) {
125 config_path.with_file_name(MODEL_BOUND_STATE_FILE_NAME)
126 } else {
127 let identity: String = Sha256::digest(config_path.as_os_str().as_encoded_bytes())
128 .iter()
129 .map(|byte| format!("{byte:02x}"))
130 .collect();
131 config_path.with_file_name(format!("redaction-state-{identity}.json"))
132 }
133 }
134
135 /// Clear only this config's confirmation. A stale receipt is rejected even
136 /// when filesystem errors prevent this best-effort sweep.
137 pub fn clear_model_bound_disabled_confirmation(config_path: &Path) -> io::Result<()> {
138 let path = model_bound_state_path(config_path);
139 for attempt in 0..6 {
140 match write_state(&path, config_path, false) {
141 Ok(()) => {
142 let _ = fs::remove_file(&path);
143 return Ok(());
144 }
145 Err(_) if attempt < 5 => {
146 std::thread::sleep(std::time::Duration::from_millis(100));
147 }
148 Err(err) => return Err(err),
149 }
150 }
151 unreachable!()
152 }
153
154 /// Persist consent for the config that was actually loaded by the caller.
155 /// Never infer that source from the receipt directory or the process home.
156 pub fn record_model_bound_disabled_confirmation(config_path: &Path) -> io::Result<PathBuf> {
157 let path = model_bound_state_path(config_path);
158 write_state(&path, config_path, true)?;
159 Ok(path)
160 }
161
162 pub fn confirmation_required(desired: ModelBoundMasking, config_path: Option<&Path>) -> bool {
163 desired.is_disabled() && !confirmed_for_current_request(desired, config_path)
164 }
165
166 /// Unloaded, unreadable, changed and unconfirmed requests remain masked.
167 pub fn effective_masking(
168 desired: ModelBoundMasking,
169 config_path: Option<&Path>,
170 ) -> ModelBoundMasking {
171 if confirmed_for_current_request(desired, config_path) {
172 ModelBoundMasking::Disabled
173 } else {
174 ModelBoundMasking::Enabled
175 }
176 }
177
178 fn confirmed_for_current_request(desired: ModelBoundMasking, config_path: Option<&Path>) -> bool {
179 let Some(config_path) = config_path else {
180 return false;
181 };
182 let receipt = read_state(&model_bound_state_path(config_path));
183 if !receipt.model_bound_disabled_confirmed {
184 return false;
185 }
186 let current = config_binding(config_path).ok();
187 if !desired.is_disabled() || current.is_none() || current != receipt.config_binding {
188 let _ = clear_model_bound_disabled_confirmation(config_path);
189 return false;
190 }
191 true
192 }
193
194 // === State-file plumbing (path-parameterized so tests stay hermetic) ===
195
196 #[derive(Debug, Default, Serialize, Deserialize)]
197 #[serde(default)]
198 struct StateFile {
199 model_bound_disabled_confirmed: bool,
200 config_binding: Option<ConfigBinding>,
201 }
202
203 #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
204 struct ConfigBinding {
205 config_path: PathBuf,
206 sha256: [u8; 32],
207 modified: std::time::SystemTime,
208 }
209
210 fn config_binding(config_path: &Path) -> io::Result<ConfigBinding> {
211 let config_path = config_path.canonicalize()?;
212 let file = fs::File::open(&config_path)?;
213 let modified = file.metadata()?.modified()?;
214 let mut body = String::new();
215 std::io::Read::read_to_string(&mut &file, &mut body)?;
216 let config: crate::ConfigToml = toml::from_str(&body)
217 .map_err(|_| io::Error::other("cannot confirm an unreadable redaction config"))?;
218 if !config.redaction_model_bound_masking().is_disabled() {
219 return Err(io::Error::other(
220 "config does not request disabling model-bound masking",
221 ));
222 }
223 Ok(ConfigBinding {
224 config_path,
225 sha256: Sha256::digest(body.as_bytes()).into(),
226 modified,
227 })
228 }
229
230 fn read_state(path: &std::path::Path) -> StateFile {
231 fs::read_to_string(path)
232 .ok()
233 .and_then(|body| serde_json::from_str(&body).ok())
234 .unwrap_or_default()
235 }
236
237 fn write_state(path: &Path, config_path: &Path, confirmed: bool) -> io::Result<()> {
238 if let Some(parent) = path.parent()
239 && !parent.as_os_str().is_empty()
240 {
241 fs::create_dir_all(parent)?;
242 }
243 let body = serde_json::to_string_pretty(&StateFile {
244 model_bound_disabled_confirmed: confirmed,
245 config_binding: if confirmed {
246 Some(config_binding(config_path)?)
247 } else {
248 None
249 },
250 })
251 .map_err(io::Error::other)?;
252 fs::write(path, body)
253 }
254
255 #[cfg(test)]
256 mod tests {
257 use super::*;
258 use std::path::Path;
259
260 fn state_path(tmp: &Path) -> PathBuf {
261 tmp.join(MODEL_BOUND_STATE_FILE_NAME)
262 }
263
264 #[test]
265 fn absent_state_is_not_confirmed() {
266 let tmp = tempfile::tempdir().expect("tempdir");
267 assert!(!read_state(&state_path(tmp.path())).model_bound_disabled_confirmed);
268 }
269
270 #[test]
271 fn confirmation_round_trips_through_the_state_file() {
272 let tmp = tempfile::tempdir().expect("tempdir");
273 let path = state_path(tmp.path());
274 fs::write(
275 tmp.path().join(crate::CONFIG_FILE_NAME),
276 "[redaction]\nmodel_bound = \"disabled\"\n",
277 )
278 .expect("write config");
279 write_state(&path, &tmp.path().join(crate::CONFIG_FILE_NAME), true).expect("write state");
280 assert!(read_state(&path).model_bound_disabled_confirmed);
281 }
282
283 /// The disable-and-confirm lifecycle belongs to one explicit config.
284 #[test]
285 fn loaded_path_lifecycle_requires_confirmation_before_disabling() {
286 let tmp = tempfile::tempdir().expect("tempdir");
287 let config_path = tmp.path().join(crate::CONFIG_FILE_NAME);
288 assert!(!confirmed_for_current_request(
289 ModelBoundMasking::Disabled,
290 Some(&config_path)
291 ));
292
293 let desired = ModelBoundMasking::Disabled;
294 assert!(confirmation_required(desired, Some(&config_path)));
295 assert_eq!(
296 effective_masking(desired, Some(&config_path)),
297 ModelBoundMasking::Enabled
298 );
299
300 assert!(
301 record_model_bound_disabled_confirmation(&config_path).is_err(),
302 "missing config cannot authorize an opt-out"
303 );
304 let config_path = tmp.path().join(crate::CONFIG_FILE_NAME);
305 let disabled_config = "[redaction]\nmodel_bound = \"disabled\"\n";
306 fs::write(&config_path, disabled_config).expect("write disabled config");
307 let written = record_model_bound_disabled_confirmation(&config_path).expect("record");
308 assert_eq!(
309 written,
310 tmp.path()
311 .canonicalize()
312 .unwrap()
313 .join(MODEL_BOUND_STATE_FILE_NAME)
314 );
315 assert!(confirmed_for_current_request(
316 ModelBoundMasking::Disabled,
317 Some(&config_path)
318 ));
319 assert!(!confirmation_required(desired, Some(&config_path)));
320 assert_eq!(
321 effective_masking(desired, Some(&config_path)),
322 ModelBoundMasking::Disabled
323 );
324
325 // Windows real-time AV scanning can hold a short exclusive lock on a
326 // file we just wrote; the confirm -> re-enable sweep below rewrites
327 // that same file back-to-back, which is exactly the lock window. The
328 // product flow never does this (record and sweep happen on different
329 // launches), so back off briefly here to keep the test deterministic
330 // on Defender-equipped machines.
331 std::thread::sleep(std::time::Duration::from_millis(300));
332
333 // An enabled request never disables, even with a receipt on disk -
334 // and going back to enabled invalidates the receipt, so the next
335 // disabled request must be confirmed again.
336 let enabled = ModelBoundMasking::Enabled;
337 assert!(!confirmation_required(enabled, Some(&config_path)));
338 assert_eq!(
339 effective_masking(enabled, Some(&config_path)),
340 ModelBoundMasking::Enabled
341 );
342 clear_model_bound_disabled_confirmation(&config_path)
343 .expect("explicit clear must succeed after re-enabling");
344 assert!(
345 !confirmed_for_current_request(ModelBoundMasking::Disabled, Some(&config_path)),
346 "returning to enabled must clear the confirmation receipt"
347 );
348
349 // Re-disabling after an enabled period asks again from scratch.
350 assert!(confirmation_required(desired, Some(&config_path)));
351 assert_eq!(
352 effective_masking(desired, Some(&config_path)),
353 ModelBoundMasking::Enabled
354 );
355
356 // The receipt is bound to the config it was made against: rewriting
357 // config.toml after a fresh confirmation (an enabled -> disabled
358 // round trip with zero processes in between) must invalidate it too.
359 record_model_bound_disabled_confirmation(&config_path).expect("record again");
360 assert!(!confirmation_required(desired, Some(&config_path)));
361 // Ensure config.toml is strictly newer than the receipt before the
362 // rewrite check runs.
363 std::thread::sleep(std::time::Duration::from_millis(30));
364 std::fs::write(
365 tmp.path().join(crate::CONFIG_FILE_NAME),
366 "[redaction]\nmodel_bound = \"disabled\"\n",
367 )
368 .expect("touch config after receipt");
369 assert!(
370 confirmation_required(desired, Some(&config_path)),
371 "a config rewritten after the receipt must force a fresh confirmation"
372 );
373 assert_eq!(
374 effective_masking(desired, Some(&config_path)),
375 ModelBoundMasking::Enabled
376 );
377
378 record_model_bound_disabled_confirmation(&config_path).expect("confirm readable config");
379 let original_mtime = fs::metadata(&config_path).unwrap().modified().unwrap();
380 fs::write(&config_path, format!("{disabled_config}# changed\n")).unwrap();
381 fs::File::options()
382 .write(true)
383 .open(&config_path)
384 .unwrap()
385 .set_times(fs::FileTimes::new().set_modified(original_mtime))
386 .unwrap();
387 assert_eq!(
388 effective_masking(desired, Some(&config_path)),
389 ModelBoundMasking::Enabled,
390 "changed bytes with preserved timestamps must invalidate confirmation"
391 );
392
393 record_model_bound_disabled_confirmation(&config_path).expect("confirm updated config");
394 fs::remove_file(&config_path).unwrap();
395 assert_eq!(
396 effective_masking(desired, Some(&config_path)),
397 ModelBoundMasking::Enabled,
398 "missing config metadata must fail closed"
399 );
400 fs::write(&config_path, disabled_config).unwrap();
401 fs::write(&written, r#"{"model_bound_disabled_confirmed":true}"#).unwrap();
402 assert_eq!(
403 effective_masking(desired, Some(&config_path)),
404 ModelBoundMasking::Enabled,
405 "legacy receipts without a config binding cannot authorize the opt-out"
406 );
407 fs::write(&config_path, "[redaction]\nmodel_bound = \"enabled\"\n").unwrap();
408 assert!(
409 record_model_bound_disabled_confirmation(&config_path).is_err(),
410 "a loaded disabled request cannot confirm an enabled file on disk"
411 );
412 }
413
414 #[test]
415 fn custom_configs_cannot_borrow_or_erase_each_others_confirmation() {
416 let temp = tempfile::tempdir().unwrap();
417 let default = temp.path().join("config.toml");
418 let custom = temp.path().join("work.toml");
419 let other = temp.path().join("personal.toml");
420 for path in [&default, &custom, &other] {
421 fs::write(path, "[redaction]\nmodel_bound = \"disabled\"\n").unwrap();
422 }
423 let desired = ModelBoundMasking::Disabled;
424 record_model_bound_disabled_confirmation(&default).unwrap();
425 assert!(confirmation_required(desired, Some(&custom)));
426 record_model_bound_disabled_confirmation(&custom).unwrap();
427 assert!(!confirmation_required(desired, Some(&custom)));
428 assert!(confirmation_required(desired, Some(&other)));
429 assert_ne!(
430 model_bound_state_path(&custom),
431 model_bound_state_path(&other)
432 );
433 assert_ne!(
434 model_bound_state_path(&custom),
435 model_bound_state_path(&default)
436 );
437
438 // Re-enabling one config must not revoke either of the other files.
439 record_model_bound_disabled_confirmation(&other).unwrap();
440 assert_eq!(
441 effective_masking(ModelBoundMasking::Enabled, Some(&custom)),
442 ModelBoundMasking::Enabled
443 );
444 assert!(confirmation_required(desired, Some(&custom)));
445 assert!(!confirmation_required(desired, Some(&default)));
446 assert!(!confirmation_required(desired, Some(&other)));
447 assert!(confirmation_required(desired, None));
448 assert_eq!(effective_masking(desired, None), ModelBoundMasking::Enabled);
449 }
450
451 #[test]
452 fn copied_receipt_cannot_confirm_identical_file_with_identical_timestamp() {
453 let temp = tempfile::tempdir().unwrap();
454 let source = temp.path().join("first.toml");
455 let destination = temp.path().join("second.toml");
456 fs::write(&source, "[redaction]\nmodel_bound = \"disabled\"\n").unwrap();
457 fs::copy(&source, &destination).unwrap();
458 let modified = fs::metadata(&source).unwrap().modified().unwrap();
459 fs::File::options()
460 .write(true)
461 .open(&destination)
462 .unwrap()
463 .set_times(fs::FileTimes::new().set_modified(modified))
464 .unwrap();
465 let receipt = record_model_bound_disabled_confirmation(&source).unwrap();
466 fs::copy(receipt, model_bound_state_path(&destination)).unwrap();
467 assert!(confirmation_required(
468 ModelBoundMasking::Disabled,
469 Some(&destination)
470 ));
471 assert!(!confirmation_required(
472 ModelBoundMasking::Disabled,
473 Some(&source)
474 ));
475 }
476
477 #[test]
478 fn custom_confirmation_reads_loaded_file_instead_of_sibling_config_toml() {
479 let temp = tempfile::tempdir().unwrap();
480 let custom = temp.path().join("selected.toml");
481 let default = temp.path().join("config.toml");
482 fs::write(&default, "[redaction]\nmodel_bound = \"disabled\"\n").unwrap();
483 assert!(record_model_bound_disabled_confirmation(&custom).is_err());
484 fs::write(&custom, "[redaction]\nmodel_bound = \"enabled\"\n").unwrap();
485 assert!(record_model_bound_disabled_confirmation(&custom).is_err());
486 fs::write(&custom, "[redaction]\nmodel_bound = \"disabled\"\n").unwrap();
487 fs::write(&default, "[redaction]\nmodel_bound = \"enabled\"\n").unwrap();
488 record_model_bound_disabled_confirmation(&custom).unwrap();
489 assert!(!confirmation_required(
490 ModelBoundMasking::Disabled,
491 Some(&custom)
492 ));
493 assert!(!model_bound_state_path(&default).exists());
494 }
495
496 #[test]
497 fn corrupt_state_reads_as_unconfirmed() {
498 let tmp = tempfile::tempdir().expect("tempdir");
499 let path = state_path(tmp.path());
500 std::fs::write(&path, "not json at all").expect("write corrupt state");
501 assert!(!read_state(&path).model_bound_disabled_confirmed);
502 }
503
504 #[test]
505 fn toml_table_parses_and_round_trips() {
506 let parsed: crate::ConfigToml =
507 toml::from_str("[redaction]\nmodel_bound = \"disabled\"\n").expect("parse");
508 assert_eq!(
509 parsed
510 .redaction
511 .as_ref()
512 .expect("redaction table")
513 .model_bound_masking(),
514 ModelBoundMasking::Disabled
515 );
516
517 let absent: crate::ConfigToml = toml::from_str("").expect("parse empty");
518 assert_eq!(
519 absent.redaction_model_bound_masking(),
520 ModelBoundMasking::Enabled
521 );
522
523 let serialized = toml::to_string(&parsed).expect("serialize");
524 assert!(
525 serialized.contains("model_bound = \"disabled\""),
526 "{serialized}"
527 );
528 }
529
530 /// The switch reads like a boolean to most people (`model_bound = false`
531 /// is the natural way to ask "don't mask"). Accept boolean and on/off
532 /// spellings so a plain `false` cannot hard-fail config parsing.
533 #[test]
534 fn boolean_and_on_off_spellings_parse_to_the_same_states() {
535 for (body, expected) in [
536 ("model_bound = false", ModelBoundMasking::Disabled),
537 ("model_bound = true", ModelBoundMasking::Enabled),
538 ("model_bound = \"false\"", ModelBoundMasking::Disabled),
539 ("model_bound = \"off\"", ModelBoundMasking::Disabled),
540 ("model_bound = \"OFF\"", ModelBoundMasking::Disabled),
541 ("model_bound = \"on\"", ModelBoundMasking::Enabled),
542 ("model_bound = \"disabled\"", ModelBoundMasking::Disabled),
543 ("model_bound = \"ENABLED\"", ModelBoundMasking::Enabled),
544 ] {
545 let parsed: crate::ConfigToml =
546 toml::from_str(&format!("[redaction]\n{body}\n")).expect("parse");
547 assert_eq!(parsed.redaction_model_bound_masking(), expected, "{body}");
548 }
549
550 // Garbage stays a hard error with a useful message, not a silent default.
551 let err = toml::from_str::<crate::ConfigToml>("[redaction]\nmodel_bound = \"maybe\"\n")
552 .expect_err("unknown variant must fail");
553 assert!(err.to_string().contains("enabled"), "{err}");
554 }
555 }
556
556 lines RUST