use std::io::Write as _;
use std::path::{Path, PathBuf};
use trusty_common::error_capture::CapturedError;
use trusty_common::error_capture::fingerprint::compute_fingerprint;
pub const PM_GUARD_DENIALS_FILE: &str = "pm-guard-denials.jsonl";
pub const PM_GUARD_DENY_TARGET: &str = "trusty_mpm::pm_guard";
#[derive(Debug, Clone, Copy)]
pub struct DeniedCall<'a> {
pub check: &'a str,
pub tool: &'a str,
pub command: &'a str,
pub cwd: &'a str,
pub session_id: &'a str,
pub reason: &'a str,
}
#[must_use]
pub fn pm_guard_denials_path() -> Option<PathBuf> {
trusty_common::resolve_data_dir("trusty-mpm")
.ok()
.map(|dir| dir.join(PM_GUARD_DENIALS_FILE))
}
#[must_use]
pub fn denial_record(call: &DeniedCall<'_>, timestamp_secs: u64) -> CapturedError {
let message = format!("pm-guard denied {} [{}]", call.tool, call.check);
let command = super::scrub(call.command).text;
let fields = format!(
"check={} tool={} cwd={} session={} command={command:?} reason={:?}",
call.check, call.tool, call.cwd, call.session_id, call.reason
);
CapturedError {
timestamp_secs,
crate_target: PM_GUARD_DENY_TARGET.to_string(),
crate_version: env!("CARGO_PKG_VERSION").to_string(),
fingerprint: compute_fingerprint(PM_GUARD_DENY_TARGET, &message, None, None),
message,
fields,
file: None,
line: None,
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
}
}
pub fn append_denial(path: &Path, record: &CapturedError) -> std::io::Result<()> {
let mut line = serde_json::to_string(record)?;
line.push('\n');
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?
.write_all(line.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
fn call<'a>(check: &'a str, command: &'a str) -> DeniedCall<'a> {
DeniedCall {
check,
tool: "Bash",
command,
cwd: "/tmp/repo",
session_id: "s-1",
reason: "tm pm-guard: refused",
}
}
#[test]
fn denial_record_carries_check_command_and_cwd_without_secrets() {
let token = "ghp_abcdefghijklmnopqrstuvwxyz0123456789"; let command = format!("rm -rf build && echo {token}");
let record = denial_record(&call("destructive-delete", &command), 7);
assert_eq!(record.crate_target, PM_GUARD_DENY_TARGET);
assert_eq!(record.message, "pm-guard denied Bash [destructive-delete]");
assert_eq!(record.timestamp_secs, 7);
for part in ["check=destructive-delete", "cwd=/tmp/repo", "rm -rf build"] {
assert!(record.fields.contains(part), "{part}: {}", record.fields);
}
assert!(!record.fields.contains(token), "{}", record.fields);
}
#[test]
fn denials_of_one_check_share_a_fingerprint() {
let a = denial_record(&call("worktree-add", "git worktree add /tmp/a"), 1);
let b = denial_record(&call("worktree-add", "git worktree add /tmp/b"), 2);
let other = denial_record(&call("head-switch", "git worktree add /tmp/a"), 1);
assert_eq!(a.fingerprint, b.fingerprint);
assert_ne!(a.fingerprint, other.fingerprint);
}
#[test]
fn append_denial_writes_one_line_per_record_and_reports_an_unwritable_store() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join(PM_GUARD_DENIALS_FILE);
let record = denial_record(&call("head-switch", "git switch main"), 3);
append_denial(&path, &record).expect("append");
append_denial(&path, &record).expect("append");
let read = trusty_common::error_capture::ErrorStore::read_records(&path, 10);
assert_eq!(read, vec![record.clone(), record.clone()]);
let blocked = tmp.path().join("blocked");
std::fs::create_dir(&blocked).expect("mkdir");
assert!(append_denial(&blocked, &record).is_err());
}
}