Skip to main content

diavlos_core/
policy.rs

1//! Policy hook. One rule in v0.1: which verbs need a human approve.
2//! A small file per room. The rule engine grows later; the hook point is
3//! what can't be added later without a rewrite.
4
5use std::path::Path;
6
7use serde::{Deserialize, Serialize};
8
9use crate::error::Result;
10use crate::files::{HARD_MAX_FILES, HARD_MAX_FILE_BYTES};
11use crate::message::{DataClass, Message};
12
13const MB: u64 = 1024 * 1024;
14
15/// The rule file for one room.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct Policy {
18    /// Verbs (of an `action`) that must not run without a human approve.
19    #[serde(default = "default_approve_verbs")]
20    pub approve_verbs: Vec<String>,
21    /// How deep one task may hand work on: a task sent in reply to a task
22    /// sent in reply to a task, and so on. A task that would go deeper, or
23    /// hand work back to an agent already in that chain, is refused. 0 turns
24    /// the check off.
25    #[serde(default = "default_max_task_hops")]
26    pub max_task_hops: u32,
27    /// Files on messages: "any", "safe" or "off". Unset means "any", or
28    /// "off" in a room whose class is confidential or pii.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub files: Option<FileMode>,
31    /// Biggest single file, in MB. Never over 1024.
32    #[serde(default = "default_max_file_mb")]
33    pub max_file_mb: u64,
34    #[serde(default = "default_max_files_per_message")]
35    pub max_files_per_message: usize,
36    /// What one member may upload to the home in 24 hours, in MB.
37    #[serde(default = "default_daily_file_mb")]
38    pub daily_file_mb_per_member: u64,
39    /// Everything the home keeps for this room, in MB.
40    #[serde(default = "default_room_file_store_mb")]
41    pub room_file_store_mb: u64,
42    /// Days a file is kept after everyone it was for has fetched it.
43    #[serde(default = "default_file_keep_days")]
44    pub file_keep_days: u64,
45    /// Days a file is kept in any case.
46    #[serde(default = "default_file_max_days")]
47    pub file_max_days: u64,
48}
49
50/// Which files a room takes.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum FileMode {
54    /// Everything, with warnings for the reader.
55    Any,
56    /// Plain text, common pictures, PDF and ZIP, checked by their bytes.
57    Safe,
58    /// No files.
59    Off,
60}
61
62impl FileMode {
63    pub fn as_str(&self) -> &'static str {
64        match self {
65            FileMode::Any => "any",
66            FileMode::Safe => "safe",
67            FileMode::Off => "off",
68        }
69    }
70}
71
72fn default_max_file_mb() -> u64 {
73    25
74}
75fn default_max_files_per_message() -> usize {
76    5
77}
78fn default_daily_file_mb() -> u64 {
79    200
80}
81fn default_room_file_store_mb() -> u64 {
82    2048
83}
84fn default_file_keep_days() -> u64 {
85    7
86}
87fn default_file_max_days() -> u64 {
88    30
89}
90
91fn default_max_task_hops() -> u32 {
92    4
93}
94
95fn default_approve_verbs() -> Vec<String> {
96    ["delete", "deploy", "pay", "mail"]
97        .map(String::from)
98        .to_vec()
99}
100
101impl Default for Policy {
102    fn default() -> Self {
103        Policy {
104            approve_verbs: default_approve_verbs(),
105            max_task_hops: default_max_task_hops(),
106            files: None,
107            max_file_mb: default_max_file_mb(),
108            max_files_per_message: default_max_files_per_message(),
109            daily_file_mb_per_member: default_daily_file_mb(),
110            room_file_store_mb: default_room_file_store_mb(),
111            file_keep_days: default_file_keep_days(),
112            file_max_days: default_file_max_days(),
113        }
114    }
115}
116
117impl Policy {
118    pub fn load(path: &Path) -> Result<Self> {
119        if !path.exists() {
120            return Ok(Policy::default());
121        }
122        let text = std::fs::read_to_string(path)?;
123        toml::from_str(&text).map_err(|e| crate::error::Error::Invalid(format!("policy: {e}")))
124    }
125
126    pub fn save(&self, path: &Path) -> Result<()> {
127        if let Some(parent) = path.parent() {
128            std::fs::create_dir_all(parent)?;
129        }
130        let text = toml::to_string_pretty(self)
131            .map_err(|e| crate::error::Error::Invalid(format!("policy: {e}")))?;
132        std::fs::write(path, text)?;
133        Ok(())
134    }
135
136    /// Which files the room takes, given its data class.
137    pub fn file_mode(&self, class: DataClass) -> FileMode {
138        self.files.unwrap_or(match class {
139            DataClass::Confidential | DataClass::Pii => FileMode::Off,
140            DataClass::Public | DataClass::Internal => FileMode::Any,
141        })
142    }
143
144    /// Biggest single file, in bytes, never over the hard cap.
145    pub fn max_file_bytes(&self) -> u64 {
146        (self.max_file_mb.saturating_mul(MB)).min(HARD_MAX_FILE_BYTES)
147    }
148
149    pub fn max_files(&self) -> usize {
150        self.max_files_per_message.min(HARD_MAX_FILES)
151    }
152
153    pub fn daily_file_bytes(&self) -> u64 {
154        self.daily_file_mb_per_member.saturating_mul(MB)
155    }
156
157    pub fn room_file_bytes(&self) -> u64 {
158        self.room_file_store_mb.saturating_mul(MB)
159    }
160
161    /// Does this verb need a human-signed approve before it runs?
162    pub fn requires_approve(&self, verb: &str) -> bool {
163        self.approve_verbs.iter().any(|v| v == verb)
164    }
165}
166
167/// The hook. The helper calls it for every message before it is stored.
168/// v0.1 ships one implementation; the interface is what matters.
169pub trait PolicyHook: Send + Sync {
170    /// Return an error to refuse the message.
171    fn check(&self, policy: &Policy, msg: &Message) -> Result<()>;
172    /// True if the message's action needs an approve before it runs.
173    fn needs_approve(&self, policy: &Policy, msg: &Message) -> bool {
174        msg.action
175            .as_ref()
176            .map(|a| policy.requires_approve(&a.verb))
177            .unwrap_or(false)
178    }
179}
180
181/// The v0.1 hook: never refuses, only answers `needs_approve`.
182#[derive(Debug, Default, Clone, Copy)]
183pub struct DefaultHook;
184
185impl PolicyHook for DefaultHook {
186    fn check(&self, _policy: &Policy, _msg: &Message) -> Result<()> {
187        Ok(())
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn defaults_and_file_roundtrip() {
197        let p = Policy::default();
198        assert!(p.requires_approve("deploy"));
199        assert!(!p.requires_approve("lint"));
200        let dir = std::env::temp_dir().join(format!("diavlos-policy-{}", ulid::Ulid::generate()));
201        let path = dir.join("policy.toml");
202        assert_eq!(Policy::load(&path).unwrap(), Policy::default());
203        let custom = Policy {
204            approve_verbs: vec!["rm".into()],
205            max_task_hops: 2,
206            files: Some(FileMode::Safe),
207            max_file_mb: 5000,
208            ..Policy::default()
209        };
210        custom.save(&path).unwrap();
211        assert_eq!(Policy::load(&path).unwrap(), custom);
212        std::fs::remove_dir_all(dir).unwrap();
213        // The hard cap wins over the file.
214        assert_eq!(custom.max_file_bytes(), HARD_MAX_FILE_BYTES);
215    }
216
217    #[test]
218    fn files_default_off_only_in_sensitive_rooms() {
219        let p = Policy::default();
220        assert_eq!(p.file_mode(DataClass::Internal), FileMode::Any);
221        assert_eq!(p.file_mode(DataClass::Public), FileMode::Any);
222        assert_eq!(p.file_mode(DataClass::Pii), FileMode::Off);
223        assert_eq!(p.file_mode(DataClass::Confidential), FileMode::Off);
224        let on: Policy = toml::from_str("files = \"safe\"\n").unwrap();
225        assert_eq!(on.file_mode(DataClass::Pii), FileMode::Safe);
226        assert_eq!(on.max_file_bytes(), 25 * MB);
227    }
228}