use std::{
collections::HashMap,
pin::Pin,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use tokio::sync::{RwLock, oneshot};
use tracing::{info, warn};
use crate::store::redb_store::RedbStore;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GrantScope {
Once,
Session,
Always,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionRequest {
pub request_id: String,
pub agent_id: String,
pub app: String,
pub reason: String,
pub estimated_steps: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionDecision {
AllowOnce,
AllowSession,
AllowAlways,
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedGrant {
pub agent_id: String,
pub app: String,
pub decision: PermissionDecision,
pub granted_at: i64,
}
pub type CheckFut<'a> =
Pin<Box<dyn Future<Output = Result<Option<PermissionDecision>>> + Send + 'a>>;
pub type RecordFut<'a> = Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
pub trait PermissionStore: Send + Sync {
fn check<'a>(&'a self, agent_id: &'a str, app: &'a str) -> CheckFut<'a>;
fn record<'a>(
&'a self,
agent_id: &'a str,
app: &'a str,
decision: PermissionDecision,
) -> RecordFut<'a>;
fn revoke<'a>(&'a self, agent_id: &'a str, app: &'a str) -> RecordFut<'a>;
fn bypass_all(&self) -> bool;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PersistedGrant {
decision: PermissionDecision,
granted_at: i64,
}
#[derive(Debug, Clone, Copy)]
struct CachedDecision {
decision: PermissionDecision,
#[allow(dead_code)] scope: GrantScope,
#[allow(dead_code)] granted_at: i64,
}
fn compose_key(agent_id: &str, app: &str) -> String {
format!("{agent_id}\0{app}")
}
fn split_key(key: &str) -> Option<(&str, &str)> {
key.split_once('\0')
}
pub struct RedbPermissionStore {
sessions: RwLock<HashMap<(String, String), CachedDecision>>,
redb: Arc<RedbStore>,
bypass_all: AtomicBool,
pending: RwLock<HashMap<String, oneshot::Sender<PermissionDecision>>>,
}
impl RedbPermissionStore {
pub fn new(redb: Arc<RedbStore>, bypass_all: bool) -> Self {
if bypass_all {
warn!("computer-use permission gate: bypass_all = true (every action auto-approved)");
}
Self {
sessions: RwLock::new(HashMap::new()),
redb,
bypass_all: AtomicBool::new(bypass_all),
pending: RwLock::new(HashMap::new()),
}
}
pub fn is_bypass_all(&self) -> bool {
self.bypass_all.load(Ordering::Relaxed)
}
pub fn set_bypass_all(&self, on: bool) {
let prev = self.bypass_all.swap(on, Ordering::SeqCst);
if prev != on {
warn!(
bypass_all = on,
"computer-use permission gate: bypass toggled"
);
}
}
pub async fn list_grants(&self) -> Result<Vec<SavedGrant>> {
let raw = self.redb.permission_list_all()?;
let mut out = Vec::with_capacity(raw.len());
for (key, value) in raw {
let Some((agent_id, app)) = split_key(&key) else {
warn!(key = %key, "skipping malformed permission key");
continue;
};
match serde_json::from_str::<PersistedGrant>(&value) {
Ok(g) => out.push(SavedGrant {
agent_id: agent_id.to_owned(),
app: app.to_owned(),
decision: g.decision,
granted_at: g.granted_at,
}),
Err(e) => warn!(error = %e, key = %key, "skipping corrupt permission grant"),
}
}
Ok(out)
}
pub async fn register_pending_request(
&self,
request_id: &str,
) -> oneshot::Receiver<PermissionDecision> {
let (tx, rx) = oneshot::channel();
let mut pending = self.pending.write().await;
pending.insert(request_id.to_owned(), tx);
rx
}
pub async fn resolve_pending_request(
&self,
request_id: &str,
decision: PermissionDecision,
) -> bool {
let mut pending = self.pending.write().await;
match pending.remove(request_id) {
Some(tx) => tx.send(decision).is_ok(),
None => false,
}
}
async fn load_persistent(
&self,
agent_id: &str,
app: &str,
) -> Result<Option<PermissionDecision>> {
let key = compose_key(agent_id, app);
let raw = self.redb.permission_get(&key)?;
let Some(json) = raw else {
return Ok(None);
};
let grant: PersistedGrant = match serde_json::from_str(&json) {
Ok(g) => g,
Err(e) => {
warn!(error = %e, key = %key, "corrupt permission grant in redb, ignoring");
return Ok(None);
}
};
let mut sessions = self.sessions.write().await;
sessions.insert(
(agent_id.to_owned(), app.to_owned()),
CachedDecision {
decision: grant.decision,
scope: GrantScope::Always,
granted_at: grant.granted_at,
},
);
Ok(Some(grant.decision))
}
}
impl PermissionStore for RedbPermissionStore {
fn check<'a>(&'a self, agent_id: &'a str, app: &'a str) -> CheckFut<'a> {
Box::pin(async move {
if self.bypass_all.load(Ordering::Relaxed) {
return Ok(Some(PermissionDecision::AllowAlways));
}
{
let mut sessions = self.sessions.write().await;
let key = (agent_id.to_owned(), app.to_owned());
if let Some(cached) = sessions.get(&key) {
let decision = cached.decision;
if cached.scope == GrantScope::Once {
sessions.remove(&key);
}
return Ok(Some(decision));
}
}
self.load_persistent(agent_id, app).await
})
}
fn record<'a>(
&'a self,
agent_id: &'a str,
app: &'a str,
decision: PermissionDecision,
) -> RecordFut<'a> {
Box::pin(async move {
let now = chrono::Utc::now().timestamp();
match decision {
PermissionDecision::AllowOnce => {
let mut sessions = self.sessions.write().await;
sessions.insert(
(agent_id.to_owned(), app.to_owned()),
CachedDecision {
decision,
scope: GrantScope::Once,
granted_at: now,
},
);
info!(
agent_id,
app, "permission: allow_once (cached, consume-on-read)"
);
}
PermissionDecision::AllowSession => {
let mut sessions = self.sessions.write().await;
sessions.insert(
(agent_id.to_owned(), app.to_owned()),
CachedDecision {
decision,
scope: GrantScope::Session,
granted_at: now,
},
);
info!(agent_id, app, "permission: allow_session (cached)");
}
PermissionDecision::AllowAlways => {
let key = compose_key(agent_id, app);
let value = serde_json::to_string(&PersistedGrant {
decision,
granted_at: now,
})?;
self.redb.permission_put(&key, &value)?;
let mut sessions = self.sessions.write().await;
sessions.insert(
(agent_id.to_owned(), app.to_owned()),
CachedDecision {
decision,
scope: GrantScope::Always,
granted_at: now,
},
);
info!(agent_id, app, "permission: allow_always (persisted)");
}
PermissionDecision::Deny => {
let mut sessions = self.sessions.write().await;
sessions.insert(
(agent_id.to_owned(), app.to_owned()),
CachedDecision {
decision,
scope: GrantScope::Session,
granted_at: now,
},
);
info!(agent_id, app, "permission: deny (cached for session)");
}
}
Ok(())
})
}
fn revoke<'a>(&'a self, agent_id: &'a str, app: &'a str) -> RecordFut<'a> {
Box::pin(async move {
let key = compose_key(agent_id, app);
self.redb.permission_delete(&key)?;
let mut sessions = self.sessions.write().await;
sessions.remove(&(agent_id.to_owned(), app.to_owned()));
info!(agent_id, app, "permission: revoked");
Ok(())
})
}
fn bypass_all(&self) -> bool {
self.bypass_all.load(Ordering::Relaxed)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::MemoryTier;
fn open_store(bypass: bool) -> (RedbPermissionStore, Arc<RedbStore>, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let redb = Arc::new(
RedbStore::open(&dir.path().join("perm.redb"), MemoryTier::Low).expect("open redb"),
);
let store = RedbPermissionStore::new(redb.clone(), bypass);
(store, redb, dir)
}
#[tokio::test]
async fn fresh_store_returns_none() {
let (store, _redb, _dir) = open_store(false);
let got = store.check("agent:a", "WeChat").await.expect("check");
assert_eq!(got, None);
}
#[tokio::test]
async fn allow_session_is_cached_in_memory() {
let (store, _redb, _dir) = open_store(false);
store
.record("agent:a", "WeChat", PermissionDecision::AllowSession)
.await
.expect("record");
let got = store.check("agent:a", "WeChat").await.expect("check");
assert_eq!(got, Some(PermissionDecision::AllowSession));
}
#[tokio::test]
async fn allow_once_is_consume_on_read() {
let (store, _redb, _dir) = open_store(false);
store
.record("agent:a", "WeChat", PermissionDecision::AllowOnce)
.await
.expect("record");
let first = store.check("agent:a", "WeChat").await.expect("check");
assert_eq!(first, Some(PermissionDecision::AllowOnce));
let second = store.check("agent:a", "WeChat").await.expect("check");
assert_eq!(second, None);
}
#[tokio::test]
async fn allow_always_persists_across_store_instances() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("perm.redb");
{
let redb = Arc::new(RedbStore::open(&path, MemoryTier::Low).expect("open 1"));
let store = RedbPermissionStore::new(redb, false);
store
.record("agent:a", "WeChat", PermissionDecision::AllowAlways)
.await
.expect("record");
}
let redb = Arc::new(RedbStore::open(&path, MemoryTier::Low).expect("open 2"));
let store = RedbPermissionStore::new(redb, false);
let got = store.check("agent:a", "WeChat").await.expect("check");
assert_eq!(got, Some(PermissionDecision::AllowAlways));
}
#[tokio::test]
async fn revoke_clears_session_and_persistent() {
let (store, _redb, _dir) = open_store(false);
store
.record("agent:a", "WeChat", PermissionDecision::AllowAlways)
.await
.expect("record");
store.revoke("agent:a", "WeChat").await.expect("revoke");
let got = store.check("agent:a", "WeChat").await.expect("check");
assert_eq!(got, None);
}
#[tokio::test]
async fn bypass_all_short_circuits() {
let (store, _redb, _dir) = open_store(true);
let got = store.check("agent:a", "WeChat").await.expect("check");
assert_eq!(got, Some(PermissionDecision::AllowAlways));
assert!(store.bypass_all());
}
#[tokio::test]
async fn deny_is_cached_for_session_but_not_persisted() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("perm.redb");
{
let redb = Arc::new(RedbStore::open(&path, MemoryTier::Low).expect("open 1"));
let store = RedbPermissionStore::new(redb, false);
store
.record("agent:a", "WeChat", PermissionDecision::Deny)
.await
.expect("record");
assert_eq!(
store.check("agent:a", "WeChat").await.expect("check 1"),
Some(PermissionDecision::Deny)
);
}
let redb = Arc::new(RedbStore::open(&path, MemoryTier::Low).expect("open 2"));
let store = RedbPermissionStore::new(redb, false);
assert_eq!(
store.check("agent:a", "WeChat").await.expect("check 2"),
None
);
}
#[tokio::test]
async fn pending_request_round_trip() {
let (store, _redb, _dir) = open_store(false);
let req_id = "req-123";
let rx = store.register_pending_request(req_id).await;
let resolved = store
.resolve_pending_request(req_id, PermissionDecision::AllowOnce)
.await;
assert!(resolved);
let got = rx.await.expect("recv");
assert_eq!(got, PermissionDecision::AllowOnce);
let again = store
.resolve_pending_request(req_id, PermissionDecision::Deny)
.await;
assert!(!again);
}
}