Files
archy/core/archipelago/src/assistant/grants.rs
T

152 lines
5.6 KiB
Rust

//! D-16: default-closed permission-category grants, persisted under
//! `data_dir`. All ten `PermissionCategory` variants are closed on a fresh
//! node — nothing is shared with the model until the operator deliberately
//! opens a category. A missing or unreadable grants file is
//! `default_closed()`, never an error and never a permissive default: the
//! assistant looking unconfigured on a fresh node is an accepted cost
//! (13-CONTEXT.md D-16), not a bug to work around by defaulting open.
use std::collections::BTreeSet;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use super::PermissionCategory;
const GRANTS_FILE: &str = "assistant/grants.json";
/// The set of currently-open permission categories. Construct via
/// [`Grants::default_closed`] or [`Grants::load`] — never via a `Default`
/// impl that could silently be permissive.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Grants {
categories: BTreeSet<PermissionCategory>,
}
impl Grants {
/// D-16: a fresh node grants nothing. Every one of the ten categories is
/// closed until the operator explicitly opens it.
pub fn default_closed() -> Grants {
Grants {
categories: BTreeSet::new(),
}
}
/// Whether `category` is currently open.
pub fn allows(&self, category: PermissionCategory) -> bool {
self.categories.contains(&category)
}
/// The full set of currently-open categories.
pub fn categories(&self) -> &BTreeSet<PermissionCategory> {
&self.categories
}
/// Open or close a single category. Callers must still call
/// [`Grants::save`] to persist the change.
pub fn set(&mut self, category: PermissionCategory, granted: bool) {
if granted {
self.categories.insert(category);
} else {
self.categories.remove(&category);
}
}
/// Load the persisted grants for this node. A missing file, or one that
/// fails to parse, is `default_closed()` — never an error, and never
/// anything other than empty. This is the one place D-16's "nothing is
/// shared with the model until deliberately granted" is enforced at the
/// data layer; `CallerScope::granted_categories` has no other source of
/// authority to fall back to.
pub async fn load(data_dir: &Path) -> Grants {
let path = data_dir.join(GRANTS_FILE);
let Ok(content) = tokio::fs::read_to_string(&path).await else {
return Grants::default_closed();
};
serde_json::from_str(&content).unwrap_or_else(|_| Grants::default_closed())
}
/// Whether a grants file exists on disk at all. `load` cannot say this —
/// it maps "absent" and "present but empty" to the same value, and the
/// unified `ai.permissions.get` reader needs the distinction: an existing
/// file is authoritative, while an absent one triggers the one-time
/// legacy migration.
pub(crate) async fn exists(data_dir: &Path) -> bool {
tokio::fs::metadata(data_dir.join(GRANTS_FILE))
.await
.is_ok()
}
/// Persist the grants for this node, 0600 (following
/// `streaming/session.rs`'s `data_dir`-scoped persisted-state
/// convention, and this codebase's convention of keeping
/// non-world-readable anything that shapes what a model or a remote
/// peer can reach on this node).
pub async fn save(&self, data_dir: &Path) -> Result<()> {
let dir = data_dir.join("assistant");
tokio::fs::create_dir_all(&dir)
.await
.context("Failed to create assistant dir")?;
let path = data_dir.join(GRANTS_FILE);
let content = serde_json::to_string_pretty(self).context("Failed to serialize grants")?;
tokio::fs::write(&path, &content)
.await
.context("Failed to write grants file")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).ok();
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn fresh_node_grants_are_empty() {
let tmp = tempfile::tempdir().expect("tempdir");
let grants = Grants::load(tmp.path()).await;
assert!(
grants.categories().is_empty(),
"a fresh node with no grants file must grant nothing"
);
for category in PermissionCategory::ALL {
assert!(
!grants.allows(category),
"{category:?} must be closed by default"
);
}
}
#[tokio::test]
async fn grant_persists_across_load() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut grants = Grants::load(tmp.path()).await;
grants.set(PermissionCategory::System, true);
grants.save(tmp.path()).await.expect("save");
let reloaded = Grants::load(tmp.path()).await;
assert!(reloaded.allows(PermissionCategory::System));
assert!(!reloaded.allows(PermissionCategory::Wallet));
}
#[tokio::test]
async fn revoke_removes_the_category() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut grants = Grants::load(tmp.path()).await;
grants.set(PermissionCategory::Network, true);
grants.save(tmp.path()).await.expect("save");
let mut grants = Grants::load(tmp.path()).await;
grants.set(PermissionCategory::Network, false);
grants.save(tmp.path()).await.expect("save");
let reloaded = Grants::load(tmp.path()).await;
assert!(!reloaded.allows(PermissionCategory::Network));
}
}