use anyhow::{anyhow, Result};
use easy_error::{bail, Error as BundError};
use rust_multistackvm::multistackvm::VM;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[allow(dead_code)]
pub mod category {
pub const STORE_READ: &str = "store_read";
pub const STORE_WRITE: &str = "store_write";
pub const FS_READ: &str = "fs_read";
pub const FS_WRITE: &str = "fs_write";
pub const NET: &str = "net";
pub const SHELL: &str = "shell";
pub const CODE_EVAL: &str = "code_eval";
pub const KEYMAP: &str = "keymap";
pub const EDITOR_READ: &str = "editor_read";
pub const EDITOR_WRITE: &str = "editor_write";
pub const AI_WRITE: &str = "ai_write";
pub const AI_READ: &str = "ai_read";
pub const THEME_WRITE: &str = "theme_write";
}
pub const DEFAULT_DENIED_CATEGORIES: &[&str] = &[
category::STORE_WRITE,
category::EDITOR_WRITE,
category::AI_WRITE,
category::THEME_WRITE,
category::FS_WRITE,
category::NET,
category::SHELL,
category::CODE_EVAL,
category::KEYMAP,
];
pub const WORD_CATEGORIES: &[(&str, &str)] = &[
("ink.node.list", category::STORE_READ),
("ink.node.get", category::STORE_READ),
("ink.node.children", category::STORE_READ),
("ink.paragraph.text", category::STORE_READ),
("ink.search.text", category::STORE_READ),
("ink.snapshot.list", category::STORE_READ),
("ink.path.to_uuid", category::STORE_READ),
("ink.paragraph.target", category::STORE_READ),
("ink.tree.add", category::STORE_WRITE),
("ink.tree.delete", category::STORE_WRITE),
("ink.tree.rename", category::STORE_WRITE),
("ink.tree.move_up", category::STORE_WRITE),
("ink.tree.move_down", category::STORE_WRITE),
("ink.tree.morph", category::STORE_WRITE),
("ink.paragraph.set_status", category::STORE_WRITE),
("ink.paragraph.set_target", category::STORE_WRITE),
("ink.paragraph.save", category::STORE_WRITE),
("ink.db.sync", category::STORE_WRITE),
("ink.db.checkpoint", category::STORE_WRITE),
("ink.db.reindex", category::STORE_WRITE),
("ink.key.bind", category::KEYMAP),
("ink.key.bind_lambda", category::KEYMAP),
("ink.key.unbind", category::KEYMAP),
("ink.key.list", category::KEYMAP),
("ink.editor.cursor", category::EDITOR_READ),
("ink.editor.text", category::EDITOR_READ),
("ink.editor.find", category::EDITOR_READ),
("ink.editor.goto", category::EDITOR_WRITE),
("ink.editor.insert", category::EDITOR_WRITE),
("ink.editor.scroll", category::EDITOR_WRITE),
("ink.editor.delete_line", category::EDITOR_WRITE),
("ink.editor.delete_to_bol", category::EDITOR_WRITE),
("ink.editor.delete_to_eol", category::EDITOR_WRITE),
("ink.ai.history", category::AI_READ),
("ink.ai.clear_history", category::AI_WRITE),
("ink.ai.send", category::AI_WRITE),
("ink.ai.set_system_prompt", category::AI_WRITE),
("ink.editor.replace", category::EDITOR_WRITE),
("ink.editor.replace_all", category::EDITOR_WRITE),
("ink.search.load", category::EDITOR_READ),
("ink.ai.poll", category::AI_READ),
("ink.ai.send_blocking", category::AI_WRITE),
("ink.theme.set", category::THEME_WRITE),
("ink.typst.assemble", category::STORE_WRITE),
("ink.typst.build", category::STORE_WRITE),
("ink.typst.take", category::STORE_WRITE),
("ink.pane.show", category::EDITOR_READ),
("ink.pane.close", category::EDITOR_READ),
("ink.pane.clear", category::EDITOR_READ),
("ink.pane.line", category::EDITOR_READ),
("ink.input", category::EDITOR_READ),
("ink.fs.read", category::FS_READ),
("ink.fs.write", category::FS_WRITE),
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Policy {
#[serde(default)]
pub disabled_categories: Vec<String>,
#[serde(default)]
pub disabled_words: Vec<String>,
#[serde(default)]
pub enabled_words: Vec<String>,
#[serde(default)]
pub enabled_categories: Vec<String>,
#[serde(default)]
pub no_default_deny: bool,
#[serde(default)]
pub bootstrap: String,
}
impl Default for Policy {
fn default() -> Self {
Self {
disabled_categories: Vec::new(),
disabled_words: Vec::new(),
enabled_words: Vec::new(),
enabled_categories: Vec::new(),
no_default_deny: false,
bootstrap: String::new(),
}
}
}
impl Policy {
pub fn is_open(&self) -> bool {
self.disabled_categories.is_empty()
&& self.disabled_words.is_empty()
&& self.no_default_deny
}
fn effective_denied_categories(&self) -> HashSet<&str> {
let mut s: HashSet<&str> = HashSet::new();
if !self.no_default_deny {
for c in DEFAULT_DENIED_CATEGORIES {
s.insert(*c);
}
}
for c in &self.disabled_categories {
s.insert(c.as_str());
}
for c in &self.enabled_categories {
s.remove(c.as_str());
}
s
}
}
pub fn apply_policy(vm: &mut VM, policy: &Policy) -> Result<()> {
let denied_categories = policy.effective_denied_categories();
let enabled: HashSet<&str> = policy.enabled_words.iter().map(String::as_str).collect();
let denied_words: HashSet<&str> =
policy.disabled_words.iter().map(String::as_str).collect();
for (word, cat) in WORD_CATEGORIES {
if enabled.contains(*word) {
continue; }
let cat_denied = denied_categories.contains(*cat);
let word_denied = denied_words.contains(*word);
if cat_denied || word_denied {
tracing::warn!(
target: "inkhaven::scripting::policy",
"denying {} (category {})",
word,
cat
);
vm.register_inline(word.to_string(), denied_stub)
.map_err(|e| anyhow!("policy: re-register {word} as denied: {e}"))?;
}
}
Ok(())
}
fn denied_stub(_vm: &mut VM) -> std::result::Result<&mut VM, BundError> {
bail!(
"script denied by inkhaven policy — earlier log lines name the offending word"
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_policy_is_conservative() {
let p = Policy::default();
let denied = p.effective_denied_categories();
assert!(denied.contains(category::FS_WRITE));
assert!(denied.contains(category::NET));
assert!(denied.contains(category::SHELL));
assert!(denied.contains(category::CODE_EVAL));
assert!(!denied.contains(category::STORE_READ));
assert!(!denied.contains(category::FS_READ));
}
#[test]
fn no_default_deny_clears_baseline() {
let p = Policy {
no_default_deny: true,
..Policy::default()
};
assert!(p.effective_denied_categories().is_empty());
}
#[test]
fn enabled_words_override_category_deny() {
let p = Policy {
disabled_categories: vec![category::STORE_READ.into()],
enabled_words: vec!["ink.node.list".into()],
..Policy::default()
};
let denied_cats = p.effective_denied_categories();
let enabled: HashSet<&str> = p.enabled_words.iter().map(String::as_str).collect();
for (word, cat) in WORD_CATEGORIES {
if *cat == category::STORE_READ {
let cat_denied = denied_cats.contains(*cat);
let effectively_denied = cat_denied && !enabled.contains(*word);
if *word == "ink.node.list" {
assert!(!effectively_denied, "ink.node.list should be allowed");
} else {
assert!(effectively_denied, "{word} should be denied");
}
}
}
}
}