fix(audit): DSH integration audits into its own home; tests never touch the real audit.log

The DSH integration takes an explicit Codewhale home (DshPaths::under)
for all of its state, but its five integration.dsh.* audit events went
through log_sensitive_event, which resolves $CODEWHALE_HOME or the real
~/.codewhale. The DSH unit tests build DshPaths under a tempdir without
setting CODEWHALE_HOME, so every run appended to the developer's real
audit.log (the founder's has 20,010 integration.dsh lines).

- DshPaths records its codewhale_home; the DSH audit events use the new
  audit::log_sensitive_event_in(home, ...) so they land beside the
  integration's state.
- Under cfg(test), the default audit path with no explicit
  CODEWHALE_HOME is a per-process temp log, never the real home.
- Guard test test_process_never_resolves_the_real_home_audit_log, plus
  checks that DSH connect writes its event to the test home's audit.log.

Tests:
- cargo test -p codewhale-tui --lib -- audit integrations::dsh -> 89 passed; 0 failed
- Same filter run with CODEWHALE_HOME unset and the real HOME: real
  ~/.codewhale/audit.log size 11731929 -> 11731929, integration.dsh
  lines 20010 -> 20010 (unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vjkk8uP5v3qYaBPonDEzBc
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-24 14:14:46 -07:00
1 parent 99c445c78f
commit 244368675b
3 files changed
+71 -11

No files matched your search

+47 -4
View File
@@ -1,7 +1,7 @@
//! Lightweight audit logging for sensitive operations.
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use chrono::Utc;
use serde_json::{Value, json};
@@ -14,7 +14,18 @@ use crate::utils::{flush_and_sync, open_append};
/// This helper is best-effort by design: callers should not fail critical flows
/// if audit persistence fails.
pub fn log_sensitive_event(event: &str, details: Value) {
if let Err(err) = append_event(event, details) {
let result = default_audit_path().and_then(|path| append_event(&path, event, details));
if let Err(err) = result {
crate::logging::warn(format!("audit log write failed: {err}"));
}
}
/// Append an audit event to `<codewhale_home>/audit.log` for a caller that
/// already owns an explicit Codewhale home (for example the DSH integration's
/// `DshPaths`), so its audit record lands beside the rest of its state rather
/// than in whatever home the process environment names (#6534).
pub fn log_sensitive_event_in(codewhale_home: &Path, event: &str, details: Value) {
if let Err(err) = append_event(&codewhale_home.join("audit.log"), event, details) {
crate::logging::warn(format!("audit log write failed: {err}"));
}
}
@@ -48,8 +59,8 @@ fn rotate_if_oversized(path: &std::path::Path) {
let _ = fs::rename(path, std::path::Path::new(&rolled));
}
fn append_event(event: &str, details: Value) -> anyhow::Result<()> {
let path = default_audit_path()?;
fn append_event(path: &Path, event: &str, details: Value) -> anyhow::Result<()> {
let path = path.to_path_buf();
let parent = path.parent().map(|p| p.to_path_buf());
if let Some(ref parent) = parent {
fs::create_dir_all(parent)?;
@@ -71,6 +82,15 @@ fn append_event(event: &str, details: Value) -> anyhow::Result<()> {
}
fn default_audit_path() -> anyhow::Result<PathBuf> {
// A test process without an explicit CODEWHALE_HOME must never append to
// the developer's real ~/.codewhale/audit.log (#6534); it gets a
// per-process scratch log instead.
#[cfg(test)]
if !codewhale_config::codewhale_home_is_explicit() {
return Ok(std::env::temp_dir()
.join(format!("codewhale-test-audit-{}", std::process::id()))
.join("audit.log"));
}
Ok(codewhale_config::codewhale_home()?.join("audit.log"))
}
@@ -86,6 +106,29 @@ pub fn audit_log_path() -> Option<PathBuf> {
mod tests {
use super::{AUDIT_LOG_ROTATE_BYTES, rotate_if_oversized};
/// #6534 guard: with no explicit CODEWHALE_HOME, a test process resolves
/// the audit log outside the real user home, so no test can append to it.
#[test]
fn test_process_never_resolves_the_real_home_audit_log() {
let _lock = crate::test_support::lock_test_env();
let _home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
let path = super::audit_log_path().expect("audit path");
let real = codewhale_paths::user_home()
.expect("user home")
.join(".codewhale")
.join("audit.log");
assert_ne!(path, real);
assert!(path.starts_with(std::env::temp_dir()), "{}", path.display());
}
#[test]
fn an_explicit_home_receives_its_own_events() {
let dir = tempfile::tempdir().expect("tempdir");
super::log_sensitive_event_in(dir.path(), "test.event", serde_json::json!({"k": 1}));
let log = std::fs::read_to_string(dir.path().join("audit.log")).expect("audit.log");
assert!(log.contains("\"event\":\"test.event\""), "{log}");
}
#[test]
fn a_small_log_is_left_alone() {
let dir = tempfile::TempDir::new().expect("tempdir");
+14 -5
View File
@@ -55,6 +55,9 @@ pub(crate) const CLI_COMMAND: &str = "codewhale integrations dsh";
/// Codewhale-owned files for this integration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DshPaths {
/// The Codewhale home these paths live under; the integration's audit
/// events are written to its `audit.log` (#6534).
pub(crate) codewhale_home: PathBuf,
pub(crate) root: PathBuf,
pub(crate) overlay: PathBuf,
pub(crate) receipt: PathBuf,
@@ -74,6 +77,7 @@ impl DshPaths {
skin_preview: root.join(SKIN_PREVIEW_FILE),
bundle_dir: root.join(bundle::BUNDLE_DIR),
root,
codewhale_home: codewhale_home.to_path_buf(),
}
}
@@ -519,7 +523,8 @@ pub(crate) fn apply_plan(
});
doc.current = Some(record.clone());
doc.save(&paths.receipt)?;
crate::audit::log_sensitive_event(
crate::audit::log_sensitive_event_in(
&paths.codewhale_home,
&format!("integration.dsh.{}", event.as_str()),
serde_json::json!({
"overlay_path": paths.overlay.display().to_string(),
@@ -562,7 +567,8 @@ pub(crate) fn set_disabled(paths: &DshPaths, disabled: bool) -> Result<DshConnec
});
doc.current = Some(record.clone());
doc.save(&paths.receipt)?;
crate::audit::log_sensitive_event(
crate::audit::log_sensitive_event_in(
&paths.codewhale_home,
&format!("integration.dsh.{}", event.as_str()),
serde_json::json!({ "overlay_path": paths.overlay.display().to_string() }),
);
@@ -610,7 +616,8 @@ pub(crate) fn remove(paths: &DshPaths) -> Result<Vec<PathBuf>> {
)),
});
doc.save(&paths.receipt)?;
crate::audit::log_sensitive_event(
crate::audit::log_sensitive_event_in(
&paths.codewhale_home,
"integration.dsh.remove",
serde_json::json!({ "removed": removed.iter().map(|p| p.display().to_string()).collect::<Vec<_>>() }),
);
@@ -862,7 +869,8 @@ pub(crate) fn install_bundle(
});
doc.current = Some(record);
doc.save(&paths.receipt)?;
crate::audit::log_sensitive_event(
crate::audit::log_sensitive_event_in(
&paths.codewhale_home,
"integration.dsh.install_bundle",
serde_json::json!({
"profile_dir": bundle_record.profile_dir.display().to_string(),
@@ -914,7 +922,8 @@ pub(crate) fn remove_bundle(
});
doc.current = Some(record);
doc.save(&paths.receipt)?;
crate::audit::log_sensitive_event(
crate::audit::log_sensitive_event_in(
&paths.codewhale_home,
"integration.dsh.remove_bundle",
serde_json::json!({ "removed": removed.iter().map(|p| p.display().to_string()).collect::<Vec<_>>() }),
);
+10 -2
View File
@@ -604,10 +604,13 @@ fn connect_update_disable_enable_remove_lifecycle_writes_only_owned_files() {
);
let report = compute_status(&paths, detection, Ok(moved), false, avail()).unwrap();
assert!(matches!(report.state, DshIntegrationState::Detected { .. }));
// Every write stayed under the integration root.
// Every write stayed under the integration root, apart from Codewhale's
// own audit log in the same home (#6534).
let audit_log = paths.codewhale_home.join("audit.log");
assert!(audit_log.is_file(), "audit events land in this home");
for entry in walk(&paths.root.parent().unwrap().parent().unwrap().to_path_buf()) {
assert!(
entry.starts_with(&paths.root),
entry.starts_with(&paths.root) || entry == audit_log,
"unexpected file {}",
entry.display()
);
@@ -1097,6 +1100,11 @@ fn install_update_remove_bundle_lifecycle_uses_documented_plugin_commands() {
assert!(install_bundle(&paths, &detection, &runner, &avail(), DshAppBundle::Web).is_err());
let plan = super::plan(&paths, &detection, &id, "web", false, false, true).unwrap();
apply_plan(&paths, &detection, &plan, DshReceiptEvent::Connect).unwrap();
// #6534: the audit event lands in this test's Codewhale home, not the
// process's (real) home.
let audit = std::fs::read_to_string(paths.codewhale_home.join("audit.log"))
.expect("audit log beside the integration state");
assert!(audit.contains("integration.dsh.connect"), "{audit}");
// pnpm missing → truthful refusal, nothing written.
let err = install_bundle(