use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use zeroize::{Zeroize, Zeroizing};
use secrets_vault::{
is_valid_key, is_valid_project, random_bytes, random_salt, v2_create, MasterSecret,
VaultError, VaultReader,
};
use crate::keychain;
pub const DEFAULT_TTL: &str = "60m";
pub const TTL_WARN_SECS: u64 = 24 * 3600;
pub fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[derive(Clone, Copy, PartialEq)]
pub enum Keystore {
Keychain,
File,
}
pub fn keystore_from_env() -> Result<Keystore, String> {
match std::env::var("SECRETS_LEASE_KEYSTORE") {
Ok(v) if v == "file" => Ok(Keystore::File),
Ok(v) => Err(format!("unknown SECRETS_LEASE_KEYSTORE '{v}' (only 'file' is recognized)")),
Err(_) => {
if cfg!(target_os = "macos") {
Ok(Keystore::Keychain)
} else {
Err("leases anchor their key in the macOS Keychain; on this platform set \
SECRETS_LEASE_KEYSTORE=file (test/portability mode — LEASE_DESIGN.md §7)"
.into())
}
}
}
}
pub fn leases_dir(dir: &Path) -> PathBuf {
dir.join("leases")
}
pub fn lease_file(dir: &Path, project: &str) -> PathBuf {
leases_dir(dir).join(format!("{project}.lease"))
}
fn key_file(dir: &Path, project: &str) -> PathBuf {
leases_dir(dir).join(format!("{project}.key"))
}
fn keychain_account(project: &str) -> String {
format!("lease:{project}")
}
fn log_path(dir: &Path) -> PathBuf {
dir.join("lease.log")
}
fn audit(dir: &Path, line: &str) {
let path = log_path(dir);
#[cfg(unix)]
let file = {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new().create(true).append(true).mode(0o600).open(&path)
};
#[cfg(not(unix))]
let file = std::fs::OpenOptions::new().create(true).append(true).open(&path);
if let Ok(mut f) = file {
let _ = writeln!(f, "{} {line}", now());
}
}
#[derive(Serialize, Deserialize)]
struct Meta {
v: u32,
project: String,
key: String,
created: u64,
expires: u64,
#[serde(default)]
agent: Option<String>,
}
impl Meta {
fn take_key(&mut self) -> Result<Zeroizing<[u8; 32]>, String> {
let mut out = Zeroizing::new([0u8; 32]);
let hexs = self.key.as_bytes();
if hexs.len() != 64 {
self.key.zeroize();
return Err("lease key material is malformed".into());
}
let nib = |b: u8| -> Result<u8, String> {
match b {
b'0'..=b'9' => Ok(b - b'0'),
b'a'..=b'f' => Ok(b - b'a' + 10),
b'A'..=b'F' => Ok(b - b'A' + 10),
_ => Err("lease key material is malformed".into()),
}
};
for i in 0..32 {
match (nib(hexs[2 * i]), nib(hexs[2 * i + 1])) {
(Ok(h), Ok(l)) => out[i] = (h << 4) | l,
_ => {
self.key.zeroize();
return Err("lease key material is malformed".into());
}
}
}
self.key.zeroize();
Ok(out)
}
}
fn keystore_store(dir: &Path, ks: Keystore, project: &str, meta_json: &str) -> Result<(), String> {
match ks {
Keystore::Keychain => keychain::store_plain(&keychain_account(project), meta_json),
Keystore::File => {
ensure_leases_dir(dir)?;
let path = key_file(dir, project);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)
.map_err(|e| format!("writing lease key file: {e}"))?;
f.write_all(meta_json.as_bytes())
.map_err(|e| format!("writing lease key file: {e}"))?;
}
#[cfg(not(unix))]
std::fs::write(&path, meta_json).map_err(|e| format!("writing lease key file: {e}"))?;
Ok(())
}
}
}
fn keystore_load(dir: &Path, ks: Keystore, project: &str) -> Result<Option<Meta>, String> {
let raw: Option<Zeroizing<String>> = match ks {
Keystore::Keychain => keychain::read_plain(&keychain_account(project))?.map(Zeroizing::new),
Keystore::File => match std::fs::read_to_string(key_file(dir, project)) {
Ok(s) => Some(Zeroizing::new(s)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(format!("reading lease key file: {e}")),
},
};
match raw {
None => Ok(None),
Some(json) => {
let meta: Meta =
serde_json::from_str(&json).map_err(|_| "lease metadata is corrupt".to_string())?;
if meta.v != 1 {
return Err(format!("unsupported lease metadata version {}", meta.v));
}
Ok(Some(meta))
}
}
}
fn keystore_delete(dir: &Path, ks: Keystore, project: &str) -> bool {
match ks {
Keystore::Keychain => {
let existed = keychain::read_plain(&keychain_account(project))
.map(|o| o.is_some())
.unwrap_or(false);
let _ = keychain::delete_plain(&keychain_account(project));
existed
}
Keystore::File => {
let path = key_file(dir, project);
let existed = path.exists();
if existed {
secure_delete_file(&path);
}
existed
}
}
}
fn ensure_leases_dir(dir: &Path) -> Result<(), String> {
let ld = leases_dir(dir);
std::fs::create_dir_all(&ld).map_err(|e| format!("creating {}: {e}", ld.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&ld, std::fs::Permissions::from_mode(0o700));
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
}
Ok(())
}
fn secure_delete_file(path: &Path) {
if let Ok(len) = std::fs::metadata(path).map(|m| m.len()) {
if let Ok(mut f) = std::fs::OpenOptions::new().write(true).open(path) {
let zeros = vec![0u8; len.min(1 << 20) as usize];
let mut left = len;
while left > 0 {
let n = (left as usize).min(zeros.len());
if f.write_all(&zeros[..n]).is_err() {
break;
}
left -= n as u64;
}
let _ = f.sync_all();
}
}
let _ = std::fs::remove_file(path);
}
fn purge(dir: &Path, ks: Keystore, project: &str) -> bool {
let had_key = keystore_delete(dir, ks, project);
let path = lease_file(dir, project);
let had_file = path.exists();
if had_file {
secure_delete_file(&path);
}
had_key || had_file
}
pub fn parse_ttl(s: &str) -> Result<u64, String> {
let s = s.trim();
let err = || format!("invalid TTL '{s}' (use e.g. 30s, 90m, 2h, 1d, or minutes)");
let (digits, mult) = match s.chars().last() {
Some('s') => (&s[..s.len() - 1], 1u64),
Some('m') => (&s[..s.len() - 1], 60),
Some('h') => (&s[..s.len() - 1], 3600),
Some('d') => (&s[..s.len() - 1], 86400),
Some(c) if c.is_ascii_digit() => (s, 60),
_ => return Err(err()),
};
let n: u64 = digits.parse().map_err(|_| err())?;
if n == 0 {
return Err("TTL must be positive".into());
}
n.checked_mul(mult).ok_or_else(err)
}
pub fn human_secs(mut secs: u64) -> String {
if secs >= 86400 && secs % 3600 == 0 && secs / 86400 > 0 && secs % 86400 == 0 {
return format!("{}d", secs / 86400);
}
if secs >= 3600 {
let h = secs / 3600;
secs %= 3600;
if secs == 0 {
return format!("{h}h");
}
return format!("{h}h{}m", secs / 60);
}
if secs >= 60 {
return format!("{}m", secs.div_ceil(60));
}
format!("{secs}s")
}
pub fn create(
dir: &Path,
ks: Keystore,
project: &str,
entries: &[(String, Zeroizing<Vec<u8>>)],
ttl_secs: u64,
agent: Option<&str>,
now: u64,
) -> Result<u64, String> {
if !is_valid_project(project) {
return Err(format!("invalid project '{project}'"));
}
if entries.is_empty() {
return Err("no values to lease".into());
}
for (name, _) in entries {
if !is_valid_key(name) {
return Err(format!("invalid key name '{name}'"));
}
}
let replaced = purge(dir, ks, project);
let key_bytes = {
let v = Zeroizing::new(random_bytes(32));
let mut k = Zeroizing::new([0u8; 32]);
k.copy_from_slice(&v);
k
};
let master = MasterSecret::from_raw_key(&key_bytes, &random_salt());
let image = v2_create(&master, entries).map_err(|e| format!("encrypting lease: {e}"))?;
ensure_leases_dir(dir)?;
let path = lease_file(dir, project);
let tmp = leases_dir(dir).join(format!(".{project}.lease.tmp"));
{
#[cfg(unix)]
let mut f = {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp)
.map_err(|e| format!("writing lease: {e}"))?
};
#[cfg(not(unix))]
let mut f = std::fs::File::create(&tmp).map_err(|e| format!("writing lease: {e}"))?;
f.write_all(&image).map_err(|e| format!("writing lease: {e}"))?;
let _ = f.sync_all();
}
std::fs::rename(&tmp, &path).map_err(|e| format!("writing lease: {e}"))?;
if let Ok(d) = std::fs::File::open(leases_dir(dir)) {
let _ = d.sync_all();
}
let expires = now.saturating_add(ttl_secs);
let meta = Meta {
v: 1,
project: project.to_string(),
key: to_hex(key_bytes.as_ref()),
created: now,
expires,
agent: agent.map(String::from),
};
let mut json = Zeroizing::new(
serde_json::to_string(&meta).map_err(|e| format!("serializing lease metadata: {e}"))?,
);
let stored = keystore_store(dir, ks, project, &json);
json.zeroize();
if let Err(e) = stored {
secure_delete_file(&path);
return Err(format!("anchoring lease key: {e}"));
}
audit(
dir,
&format!(
"CREATE project={project} keys={} ttl={}s agent={}{}",
entries.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>().join(","),
ttl_secs,
agent.unwrap_or("-"),
if replaced { " (replaced prior lease)" } else { "" },
),
);
Ok(expires)
}
pub enum ReadOutcome {
Served(Zeroizing<Vec<u8>>),
NoLease,
Expired,
Denied,
NotInLease,
Error(String),
}
pub fn read(
dir: &Path,
ks: Keystore,
project: &str,
key: &str,
caller_agent: Option<&str>,
now: u64,
) -> ReadOutcome {
if !is_valid_project(project) || !is_valid_key(key) {
return ReadOutcome::Error("invalid project/key".into());
}
let mut meta = match keystore_load(dir, ks, project) {
Ok(Some(m)) => m,
Ok(None) => return ReadOutcome::NoLease,
Err(e) => return ReadOutcome::Error(e),
};
if now >= meta.expires {
purge(dir, ks, project);
audit(dir, &format!("EXPIRE project={project} (on read of {key})"));
return ReadOutcome::Expired;
}
if let Some(bound) = &meta.agent {
if caller_agent != Some(bound.as_str()) {
audit(
dir,
&format!(
"DENY project={project} key={key} caller={} bound={bound}",
caller_agent.unwrap_or("-")
),
);
return ReadOutcome::Denied;
}
}
let data = match std::fs::read(lease_file(dir, project)) {
Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
purge(dir, ks, project);
audit(dir, &format!("PURGE-ORPHAN project={project} (key without file)"));
return ReadOutcome::NoLease;
}
Err(e) => return ReadOutcome::Error(format!("reading lease: {e}")),
};
let key_bytes = match meta.take_key() {
Ok(k) => k,
Err(e) => return ReadOutcome::Error(e),
};
let salt = match secrets_vault::v2_salt(&data) {
Ok(s) => s,
Err(e) => {
audit(dir, &format!("TAMPER project={project} ({e})"));
return ReadOutcome::Error(format!("lease file failed verification: {e}"));
}
};
let master = MasterSecret::from_raw_key(&key_bytes, &salt);
let reader = match VaultReader::open(data, &master) {
Ok(r) => r,
Err(e) => {
audit(dir, &format!("TAMPER project={project} ({e})"));
return ReadOutcome::Error(format!("lease file failed verification: {e}"));
}
};
match reader.decrypt_one(&master, key) {
Ok(v) => {
audit(
dir,
&format!(
"SERVE project={project} key={key} caller={}",
caller_agent.unwrap_or("-")
),
);
ReadOutcome::Served(v)
}
Err(VaultError::NotFound) => {
audit(dir, &format!("NOT-IN-LEASE project={project} key={key}"));
ReadOutcome::NotInLease
}
Err(e) => {
audit(dir, &format!("TAMPER project={project} ({e})"));
ReadOutcome::Error(format!("lease record failed verification: {e}"))
}
}
}
pub enum LeaseState {
Active { expires: u64, agent: Option<String>, keys: Vec<String> },
ExpiredPurged,
OrphanPurged,
Corrupt(String),
}
pub fn status(dir: &Path, ks: Keystore, now: u64) -> Vec<(String, LeaseState)> {
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir(leases_dir(dir)) else {
return out;
};
let mut projects: Vec<String> = rd
.flatten()
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
name.strip_suffix(".lease").map(String::from)
})
.collect();
projects.sort();
for project in projects {
let state = match keystore_load(dir, ks, &project) {
Ok(None) => {
secure_delete_file(&lease_file(dir, &project));
audit(dir, &format!("PURGE-ORPHAN project={project} (file without key)"));
LeaseState::OrphanPurged
}
Err(e) => LeaseState::Corrupt(e),
Ok(Some(mut meta)) => {
if now >= meta.expires {
purge(dir, ks, &project);
audit(dir, &format!("EXPIRE project={project} (on status)"));
LeaseState::ExpiredPurged
} else {
match meta.take_key() {
Err(e) => LeaseState::Corrupt(e),
Ok(key_bytes) => match std::fs::read(lease_file(dir, &project))
.map_err(|e| e.to_string())
.and_then(|data| {
let salt =
secrets_vault::v2_salt(&data).map_err(|e| e.to_string())?;
let m = MasterSecret::from_raw_key(&key_bytes, &salt);
VaultReader::open(data, &m).map_err(|e| e.to_string())
}) {
Ok(reader) => LeaseState::Active {
expires: meta.expires,
agent: meta.agent.clone(),
keys: reader.names().map(String::from).collect(),
},
Err(e) => LeaseState::Corrupt(format!("failed verification: {e}")),
},
}
}
}
};
out.push((project, state));
}
out
}
pub fn expires_at(dir: &Path, ks: Keystore, project: &str) -> Option<u64> {
keystore_load(dir, ks, project).ok().flatten().map(|m| m.expires)
}
pub fn revoke(dir: &Path, ks: Keystore, project: &str) -> bool {
let existed = purge(dir, ks, project);
if existed {
audit(dir, &format!("REVOKE project={project}"));
}
existed
}
pub fn revoke_all(dir: &Path, ks: Keystore) -> usize {
let Ok(rd) = std::fs::read_dir(leases_dir(dir)) else {
return 0;
};
let projects: Vec<String> = rd
.flatten()
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
name.strip_suffix(".lease")
.or_else(|| name.strip_suffix(".key"))
.map(String::from)
})
.collect();
let mut n = 0;
let mut seen = std::collections::BTreeSet::new();
for p in projects {
if seen.insert(p.clone()) && revoke(dir, ks, &p) {
n += 1;
}
}
n
}
fn to_hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(s, "{b:02x}");
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!(
"secrets-lease-test-{}-{tag}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn zv(s: &str) -> Zeroizing<Vec<u8>> {
Zeroizing::new(s.as_bytes().to_vec())
}
fn entries() -> Vec<(String, Zeroizing<Vec<u8>>)> {
vec![("DATABASE_URL".into(), zv("postgres://x")), ("API_KEY".into(), zv("sk-1"))]
}
const T0: u64 = 1_750_000_000;
#[test]
fn create_read_round_trip() {
let dir = tmp_dir("roundtrip");
let exp = create(&dir, Keystore::File, "proj", &entries(), 3600, None, T0).unwrap();
assert_eq!(exp, T0 + 3600);
match read(&dir, Keystore::File, "proj", "DATABASE_URL", None, T0 + 10) {
ReadOutcome::Served(v) => assert_eq!(&*v, b"postgres://x"),
_ => panic!("expected Served"),
}
assert!(matches!(
read(&dir, Keystore::File, "proj", "OTHER", None, T0 + 10),
ReadOutcome::NotInLease
));
assert!(matches!(
read(&dir, Keystore::File, "nope", "DATABASE_URL", None, T0 + 10),
ReadOutcome::NoLease
));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ttl_enforced_on_read_and_self_destructs() {
let dir = tmp_dir("ttl");
create(&dir, Keystore::File, "proj", &entries(), 60, None, T0).unwrap();
assert!(matches!(
read(&dir, Keystore::File, "proj", "API_KEY", None, T0 + 59),
ReadOutcome::Served(_)
));
assert!(matches!(
read(&dir, Keystore::File, "proj", "API_KEY", None, T0 + 60),
ReadOutcome::Expired
));
assert!(!lease_file(&dir, "proj").exists(), "lease file survived expiry");
assert!(!key_file(&dir, "proj").exists(), "lease key survived expiry");
assert!(matches!(
read(&dir, Keystore::File, "proj", "API_KEY", None, T0 + 61),
ReadOutcome::NoLease
));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn agent_binding_is_enforced() {
let dir = tmp_dir("agent");
create(&dir, Keystore::File, "proj", &entries(), 3600, Some("claude"), T0).unwrap();
assert!(matches!(
read(&dir, Keystore::File, "proj", "API_KEY", Some("claude"), T0 + 1),
ReadOutcome::Served(_)
));
assert!(matches!(
read(&dir, Keystore::File, "proj", "API_KEY", Some("grok"), T0 + 1),
ReadOutcome::Denied
));
assert!(matches!(
read(&dir, Keystore::File, "proj", "API_KEY", None, T0 + 1),
ReadOutcome::Denied
));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn revoke_destroys_both_artifacts() {
let dir = tmp_dir("revoke");
create(&dir, Keystore::File, "proj", &entries(), 3600, None, T0).unwrap();
assert!(revoke(&dir, Keystore::File, "proj"));
assert!(!lease_file(&dir, "proj").exists());
assert!(!key_file(&dir, "proj").exists());
assert!(matches!(
read(&dir, Keystore::File, "proj", "API_KEY", None, T0 + 1),
ReadOutcome::NoLease
));
assert!(!revoke(&dir, Keystore::File, "proj"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn tampered_lease_never_serves() {
let dir = tmp_dir("tamper");
create(&dir, Keystore::File, "proj", &entries(), 3600, None, T0).unwrap();
let path = lease_file(&dir, "proj");
let mut data = std::fs::read(&path).unwrap();
let n = data.len();
data[n / 2] ^= 0xFF;
std::fs::write(&path, &data).unwrap();
assert!(matches!(
read(&dir, Keystore::File, "proj", "API_KEY", None, T0 + 1),
ReadOutcome::Error(_)
));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cross_project_transplant_fails() {
let dir = tmp_dir("transplant");
create(&dir, Keystore::File, "aaa", &entries(), 3600, None, T0).unwrap();
create(&dir, Keystore::File, "bbb", &entries(), 3600, None, T0).unwrap();
std::fs::copy(lease_file(&dir, "aaa"), lease_file(&dir, "bbb")).unwrap();
assert!(matches!(
read(&dir, Keystore::File, "bbb", "API_KEY", None, T0 + 1),
ReadOutcome::Error(_)
));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn recreate_replaces_and_old_copy_is_dead() {
let dir = tmp_dir("recreate");
create(&dir, Keystore::File, "proj", &entries(), 3600, None, T0).unwrap();
let hoarded = std::fs::read(lease_file(&dir, "proj")).unwrap();
create(&dir, Keystore::File, "proj", &entries(), 3600, None, T0 + 5).unwrap();
std::fs::write(lease_file(&dir, "proj"), &hoarded).unwrap();
assert!(matches!(
read(&dir, Keystore::File, "proj", "API_KEY", None, T0 + 6),
ReadOutcome::Error(_)
));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn status_reports_and_purges() {
let dir = tmp_dir("status");
create(&dir, Keystore::File, "live", &entries(), 3600, Some("claude"), T0).unwrap();
create(&dir, Keystore::File, "old", &entries(), 60, None, T0).unwrap();
std::fs::copy(lease_file(&dir, "live"), lease_file(&dir, "orphan")).unwrap();
let rows = status(&dir, Keystore::File, T0 + 120);
let by_name: std::collections::BTreeMap<_, _> =
rows.iter().map(|(p, s)| (p.as_str(), s)).collect();
match by_name["live"] {
LeaseState::Active { expires, agent, keys } => {
assert_eq!(*expires, T0 + 3600);
assert_eq!(agent.as_deref(), Some("claude"));
assert_eq!(keys, &["API_KEY".to_string(), "DATABASE_URL".to_string()]);
}
_ => panic!("live lease not Active"),
}
assert!(matches!(by_name["old"], LeaseState::ExpiredPurged));
assert!(matches!(by_name["orphan"], LeaseState::OrphanPurged));
assert!(!lease_file(&dir, "old").exists());
assert!(!lease_file(&dir, "orphan").exists());
assert!(lease_file(&dir, "live").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn revoke_all_sweeps() {
let dir = tmp_dir("revokeall");
create(&dir, Keystore::File, "one", &entries(), 3600, None, T0).unwrap();
create(&dir, Keystore::File, "two", &entries(), 3600, None, T0).unwrap();
assert_eq!(revoke_all(&dir, Keystore::File), 2);
assert_eq!(status(&dir, Keystore::File, T0).len(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ttl_grammar() {
assert_eq!(parse_ttl("30s").unwrap(), 30);
assert_eq!(parse_ttl("90m").unwrap(), 5400);
assert_eq!(parse_ttl("2h").unwrap(), 7200);
assert_eq!(parse_ttl("1d").unwrap(), 86400);
assert_eq!(parse_ttl("45").unwrap(), 2700); assert!(parse_ttl("0m").is_err());
assert!(parse_ttl("").is_err());
assert!(parse_ttl("h").is_err());
assert!(parse_ttl("-5m").is_err());
assert!(parse_ttl("1w").is_err());
}
#[test]
fn audit_log_never_contains_values() {
let dir = tmp_dir("audit");
create(&dir, Keystore::File, "proj", &entries(), 3600, Some("claude"), T0).unwrap();
let _ = read(&dir, Keystore::File, "proj", "API_KEY", Some("claude"), T0 + 1);
let _ = read(&dir, Keystore::File, "proj", "API_KEY", Some("grok"), T0 + 2);
revoke(&dir, Keystore::File, "proj");
let log = std::fs::read_to_string(log_path(&dir)).unwrap();
assert!(log.contains("CREATE project=proj"));
assert!(log.contains("SERVE project=proj key=API_KEY caller=claude"));
assert!(log.contains("DENY project=proj key=API_KEY caller=grok bound=claude"));
assert!(log.contains("REVOKE project=proj"));
assert!(!log.contains("sk-1"), "audit log leaked a value");
assert!(!log.contains("postgres"), "audit log leaked a value");
let _ = std::fs::remove_dir_all(&dir);
}
}