187 lines
6.9 KiB
Rust
187 lines
6.9 KiB
Rust
//! What the assistant is allowed to read about this node.
|
|
//!
|
|
//! # Why this lives on the node and not in the browser
|
|
//!
|
|
//! These grants were stored in `localStorage`, which is scoped to an
|
|
//! **origin**. A node answers on several: its LAN address, its Tailscale
|
|
//! address, `<host>.local`, and its hostname. Granting "Media" over the LAN
|
|
//! and returning over Tailscale showed every switch off again — not reset,
|
|
//! simply never set *there*. Operator-reported as "the AI Data Access settings
|
|
//! are not persistent through sessions, often turns them all off", and it made
|
|
//! a working content path look broken: every scope silently returns nothing
|
|
//! without a grant, so an ungranted permission is indistinguishable from an
|
|
//! empty library.
|
|
//!
|
|
//! The grant answers "what may the assistant read **about this node**". That is
|
|
//! a property of the node, not of one browser at one address, so the node is
|
|
//! where it belongs. Stored here it survives a cache clear, a new device, a
|
|
//! different browser, and any change of address.
|
|
//!
|
|
//! # Why the category list is not validated against a hardcoded set
|
|
//!
|
|
//! The authoritative list of categories lives in the UI
|
|
//! (`AI_PERMISSION_CATEGORIES`) and in the context broker's `fetchAndSanitize`.
|
|
//! Duplicating it here would create a third copy that silently drops a new
|
|
//! category on upgrade — the grant would round-trip through an older node and
|
|
//! come back missing. Unknown strings are stored verbatim and simply never
|
|
//! match a fetch, which fails closed. **Storing a category grants nothing on
|
|
//! its own**: the broker checks each category before it fetches, and the node
|
|
//! re-checks before it answers (T-13-33). This file records intent; it is not
|
|
//! the enforcement point.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::Path;
|
|
|
|
const FILE_PATH: &str = "settings/ai_permissions.json";
|
|
|
|
/// Defence against a hand-edited or hostile file turning into unbounded
|
|
/// memory. Far above any real category count.
|
|
const MAX_CATEGORIES: usize = 64;
|
|
const MAX_CATEGORY_LEN: usize = 64;
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct AiPermissions {
|
|
/// Categories the operator has granted. Empty means the assistant reads
|
|
/// nothing about this node, which is the default: a fresh node grants
|
|
/// nothing until asked.
|
|
#[serde(default)]
|
|
pub granted: Vec<String>,
|
|
}
|
|
|
|
impl AiPermissions {
|
|
/// Drop anything malformed and de-duplicate. Applied on both read and
|
|
/// write so a hand-edited file cannot produce a state the UI can never
|
|
/// display or undo.
|
|
pub fn sanitized(mut self) -> Self {
|
|
self.granted.retain(|c| {
|
|
!c.is_empty()
|
|
&& c.len() <= MAX_CATEGORY_LEN
|
|
// Category ids are lowercase kebab (`ai-local`). Anything else
|
|
// is not something this node will ever match against.
|
|
&& c.chars()
|
|
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
|
|
});
|
|
self.granted.sort();
|
|
self.granted.dedup();
|
|
self.granted.truncate(MAX_CATEGORIES);
|
|
self
|
|
}
|
|
|
|
pub fn is_granted(&self, category: &str) -> bool {
|
|
self.granted.iter().any(|c| c == category)
|
|
}
|
|
}
|
|
|
|
pub async fn load(data_dir: &Path) -> AiPermissions {
|
|
let path = data_dir.join(FILE_PATH);
|
|
match tokio::fs::read(&path).await {
|
|
Ok(bytes) => serde_json::from_slice::<AiPermissions>(&bytes)
|
|
.map(AiPermissions::sanitized)
|
|
.unwrap_or_else(|e| {
|
|
// Fail closed. An unreadable grant file must not be treated as
|
|
// "everything allowed" — the assistant simply reads nothing
|
|
// until the operator sets it again.
|
|
tracing::warn!(error = %e, "AI permissions unreadable; granting nothing");
|
|
AiPermissions::default()
|
|
}),
|
|
// Absent file is the ordinary first-run case, not an error.
|
|
Err(_) => AiPermissions::default(),
|
|
}
|
|
}
|
|
|
|
pub async fn save(data_dir: &Path, perms: AiPermissions) -> anyhow::Result<AiPermissions> {
|
|
let perms = perms.sanitized();
|
|
let path = data_dir.join(FILE_PATH);
|
|
if let Some(parent) = path.parent() {
|
|
tokio::fs::create_dir_all(parent).await?;
|
|
}
|
|
// Temp + rename so a crash mid-write cannot leave a truncated file that
|
|
// reads as "no grants" on the next boot.
|
|
let tmp = path.with_extension("json.tmp");
|
|
tokio::fs::write(&tmp, serde_json::to_vec_pretty(&perms)?).await?;
|
|
tokio::fs::rename(&tmp, &path).await?;
|
|
Ok(perms)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_fresh_node_grants_nothing() {
|
|
assert!(AiPermissions::default().granted.is_empty());
|
|
assert!(!AiPermissions::default().is_granted("media"));
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_categories_survive_a_round_trip() {
|
|
// A newer UI may grant a category this binary has never heard of.
|
|
// Dropping it would silently revoke the grant on downgrade/upgrade.
|
|
let p = AiPermissions {
|
|
granted: vec!["media".into(), "some-future-category".into()],
|
|
}
|
|
.sanitized();
|
|
assert!(p.is_granted("some-future-category"));
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_entries_are_dropped_not_stored() {
|
|
let p = AiPermissions {
|
|
granted: vec![
|
|
"media".into(),
|
|
"".into(),
|
|
"UPPER".into(),
|
|
"has space".into(),
|
|
"../../etc/passwd".into(),
|
|
"x".repeat(500),
|
|
],
|
|
}
|
|
.sanitized();
|
|
assert_eq!(p.granted, vec!["media".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn duplicates_collapse() {
|
|
let p = AiPermissions {
|
|
granted: vec!["media".into(), "media".into(), "files".into()],
|
|
}
|
|
.sanitized();
|
|
assert_eq!(p.granted, vec!["files".to_string(), "media".to_string()]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn absent_file_reads_as_no_grants_rather_than_an_error() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
assert!(load(dir.path()).await.granted.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_corrupt_file_fails_closed() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join(FILE_PATH);
|
|
tokio::fs::create_dir_all(path.parent().unwrap())
|
|
.await
|
|
.unwrap();
|
|
tokio::fs::write(&path, b"{ not json").await.unwrap();
|
|
// The dangerous failure would be defaulting to "all granted".
|
|
assert!(load(dir.path()).await.granted.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn saved_grants_survive_a_reload() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
save(
|
|
dir.path(),
|
|
AiPermissions {
|
|
granted: vec!["media".into(), "files".into()],
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let back = load(dir.path()).await;
|
|
assert!(back.is_granted("media"));
|
|
assert!(back.is_granted("files"));
|
|
assert!(!back.is_granted("wallet"));
|
|
}
|
|
}
|