use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use secrets_vault::{decrypt_blob, decrypt_raw_blob, encrypt_blob, encrypt_raw_blob};
#[derive(Default, Serialize, Deserialize)]
pub struct Registry {
#[serde(default)]
pub projects: BTreeMap<String, ProjectMeta>,
#[serde(default)]
pub grants: BTreeMap<String, BTreeMap<String, Grant>>,
}
#[derive(Default, Serialize, Deserialize)]
pub struct ProjectMeta {
pub gcp_project: String,
#[serde(default)]
pub keys: Vec<String>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Grant {
pub scope: Scope,
}
#[derive(Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Scope {
Always,
Session { expires: u64 },
}
pub fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
impl Registry {
pub fn path(dir: &Path) -> PathBuf {
dir.join("registry.enc")
}
pub fn load_raw(dir: &Path, key: &[u8; 32]) -> Result<Self, String> {
match std::fs::read(Self::path(dir)) {
Ok(data) => {
if data.starts_with(b"QVLT") {
return Err(
"registry.enc is in the legacy v1 format — run `secrets migrate`".into()
);
}
let plain = decrypt_raw_blob(&data, key)
.map_err(|e| format!("registry decrypt: {e}"))?;
serde_json::from_slice(&plain).map_err(|e| format!("registry parse: {e}"))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Registry::default()),
Err(e) => Err(format!("reading registry: {e}")),
}
}
pub fn save_v1(&self, dir: &Path, passphrase: &str) -> Result<(), String> {
let json = serde_json::to_vec_pretty(self).map_err(|e| format!("serialize: {e}"))?;
let enc = encrypt_blob(&json, passphrase).map_err(|e| format!("encrypt: {e}"))?;
std::fs::create_dir_all(dir).ok();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).ok();
}
let path = Self::path(dir);
std::fs::write(&path, &enc).map_err(|e| format!("writing registry: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).ok();
}
Ok(())
}
pub fn load_v1(dir: &Path, passphrase: &str) -> Result<Self, String> {
match std::fs::read(Self::path(dir)) {
Ok(data) => {
let plain =
decrypt_blob(&data, passphrase).map_err(|e| format!("registry decrypt: {e}"))?;
serde_json::from_slice(&plain).map_err(|e| format!("registry parse: {e}"))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Registry::default()),
Err(e) => Err(format!("reading registry: {e}")),
}
}
pub fn save_raw(&self, dir: &Path, key: &[u8; 32]) -> Result<(), String> {
let json = serde_json::to_vec_pretty(self).map_err(|e| format!("serialize: {e}"))?;
let enc = encrypt_raw_blob(&json, key).map_err(|e| format!("encrypt: {e}"))?;
std::fs::create_dir_all(dir).ok();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).ok();
}
let path = Self::path(dir);
std::fs::write(&path, &enc).map_err(|e| format!("writing registry: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).ok();
}
Ok(())
}
pub fn grant_for(&self, agent: &str, project: &str, now: u64) -> Option<&Grant> {
let g = self.grants.get(agent)?.get(project)?;
match g.scope {
Scope::Always => Some(g),
Scope::Session { expires } if expires > now => Some(g),
_ => None,
}
}
pub fn set_grant(&mut self, agent: &str, project: &str, scope: Scope) {
self.grants
.entry(agent.to_string())
.or_default()
.insert(project.to_string(), Grant { scope });
}
pub fn revoke(&mut self, agent: &str, project: &str) -> bool {
if let Some(m) = self.grants.get_mut(agent) {
let removed = m.remove(project).is_some();
if m.is_empty() {
self.grants.remove(agent);
}
removed
} else {
false
}
}
}
pub const KNOWN_AGENTS: &[&str] = &[
"claude", "grok", "codex", "cursor", "aider", "copilot", "gemini", "cody", "continue",
];
#[cfg(target_os = "macos")]
pub fn resolve_agent() -> Option<String> {
resolve_agent_from(std::process::id() as i32)
}
#[cfg(target_os = "macos")]
pub fn resolve_agent_from(start_pid: i32) -> Option<String> {
let mut pid = start_pid;
for _ in 0..24 {
let (ppid, comm) = proc_info(pid)?;
let lc = comm.to_lowercase();
if let Some(a) = KNOWN_AGENTS.iter().find(|a| lc.contains(**a)) {
return Some((*a).to_string());
}
if let Some(path) = proc_path(pid) {
let lp = path.to_lowercase();
if let Some(a) = KNOWN_AGENTS
.iter()
.find(|a| lp.split('/').any(|seg| seg.contains(**a)))
{
return Some((*a).to_string());
}
}
if ppid <= 1 {
break;
}
pid = ppid;
}
None
}
#[cfg(target_os = "macos")]
fn proc_path(pid: i32) -> Option<String> {
let mut buf = vec![0u8; 4 * libc::PATH_MAX as usize];
let n = unsafe {
libc::proc_pidpath(pid, buf.as_mut_ptr() as *mut libc::c_void, buf.len() as u32)
};
if n <= 0 {
return None;
}
Some(String::from_utf8_lossy(&buf[..n as usize]).into_owned())
}
#[cfg(target_os = "macos")]
fn proc_info(pid: i32) -> Option<(i32, String)> {
let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
let size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
let n = unsafe {
libc::proc_pidinfo(
pid,
libc::PROC_PIDTBSDINFO,
0,
&mut info as *mut _ as *mut libc::c_void,
size,
)
};
if n <= 0 {
return None;
}
let cstr = |buf: &[libc::c_char]| -> String {
buf.iter().take_while(|&&c| c != 0).map(|&c| c as u8 as char).collect()
};
let name = {
let full = cstr(&info.pbi_name); if full.is_empty() {
cstr(&info.pbi_comm) } else {
full
}
};
Some((info.pbi_ppid as i32, name))
}
#[cfg(windows)]
pub fn resolve_agent_from(start_pid: i32) -> Option<String> {
let table = match process_table() {
Some(t) => t,
None => {
eprintln!(
"(warning: could not enumerate processes — agent identity is unknown, \
so agent grant enforcement cannot be applied to this call)"
);
return None;
}
};
let mut pid = start_pid as u32;
for _ in 0..24 {
let (ppid, name) = table.get(&pid)?;
let lc = name.to_lowercase();
if let Some(a) = KNOWN_AGENTS.iter().find(|a| lc.contains(**a)) {
return Some((*a).to_string());
}
if *ppid == 0 || *ppid == pid {
break;
}
pid = *ppid;
}
None
}
#[cfg(windows)]
fn process_table() -> Option<std::collections::HashMap<u32, (u32, String)>> {
use windows::Win32::Foundation::CloseHandle;
use windows::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
TH32CS_SNAPPROCESS,
};
let mut map = std::collections::HashMap::new();
unsafe {
let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()?;
let mut entry = PROCESSENTRY32W {
dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
..Default::default()
};
if Process32FirstW(snap, &mut entry).is_err() {
let _ = CloseHandle(snap);
return None;
}
loop {
let end = entry
.szExeFile
.iter()
.position(|&c| c == 0)
.unwrap_or(entry.szExeFile.len());
let name = String::from_utf16_lossy(&entry.szExeFile[..end]);
map.insert(
entry.th32ProcessID,
(entry.th32ParentProcessID, name),
);
if Process32NextW(snap, &mut entry).is_err() {
break;
}
}
let _ = CloseHandle(snap);
}
Some(map)
}
#[cfg(windows)]
pub fn resolve_agent() -> Option<String> {
resolve_agent_from(std::process::id() as i32)
}
#[cfg(not(any(target_os = "macos", windows)))]
pub fn resolve_agent() -> Option<String> {
None
}
#[cfg(not(any(target_os = "macos", windows)))]
pub fn resolve_agent_from(_start_pid: i32) -> Option<String> {
None
}
#[cfg(all(test, windows))]
mod win_ancestry_tests {
use super::*;
#[test]
fn snapshot_sees_this_process_and_its_parent() {
let table = process_table().expect("Toolhelp snapshot");
let me = std::process::id();
let (ppid, name) = table.get(&me).expect("our own pid is in the snapshot");
assert!(
name.to_lowercase().contains("secrets"),
"expected our own image name, got {name}"
);
assert!(*ppid != 0, "we should have a parent process");
assert!(table.contains_key(ppid), "our parent should be in the snapshot too");
}
#[test]
fn walk_terminates_and_never_invents_an_agent() {
let got = resolve_agent_from(std::process::id() as i32);
if let Some(a) = &got {
assert!(
KNOWN_AGENTS.contains(&a.as_str()),
"resolved '{a}', which is not a known agent id"
);
}
}
#[test]
fn unknown_pid_resolves_to_none() {
assert_eq!(resolve_agent_from(0x7FFF_FFFF), None);
}
}