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 AUDIO: &str = "audio";
}
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.tag.list", category::STORE_READ),
("ink.tag.list_for", category::STORE_READ),
("ink.tag.search", category::STORE_READ),
("ink.event.list", category::STORE_READ),
("ink.event.list_orphans", category::STORE_READ),
("ink.thread.list", category::STORE_READ),
("ink.review.list", 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.tag.add", category::STORE_WRITE),
("ink.tag.remove", category::STORE_WRITE),
("ink.event.add", category::STORE_WRITE),
("ink.event.set_end", category::STORE_WRITE),
("ink.event.set_precision", category::STORE_WRITE),
("ink.event.set_track", category::STORE_WRITE),
("ink.event.link_paragraph", category::STORE_WRITE),
("ink.review.add_comment", category::STORE_WRITE),
("ink.review.resolve", 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.set_cursor", category::EDITOR_WRITE),
("ink.story.render", category::FS_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.tts.speak", category::AUDIO),
("ink.fs.read", category::FS_READ),
("ink.fs.write", category::FS_WRITE),
("ink.pdf.load", category::FS_READ),
("ink.pdf.save", category::FS_WRITE),
("ink.export.docx", category::FS_WRITE),
("ink.export.manuscript", category::FS_WRITE),
("ink.export.markdown", category::FS_WRITE),
("ink.export.tex", category::FS_WRITE),
("ink.export.epub", category::FS_WRITE),
("ink.lang.list", category::STORE_READ),
("ink.lang.generate_word", category::STORE_READ),
("ink.lang.syllabify", category::STORE_READ),
("ink.lang.ipa", category::STORE_READ),
("ink.lang.stress", category::STORE_READ),
("ink.lang.tone", category::STORE_READ),
("ink.lang.transliterate", category::STORE_READ),
("ink.lang.gloss", category::STORE_READ),
("ink.lang.paradigm", category::STORE_READ),
("ink.lang.derive", category::STORE_READ),
("ink.lang.agree", category::STORE_READ),
("ink.lang.sentence", category::STORE_READ),
("ink.lang.relative", category::STORE_READ),
("ink.lang.complement", category::STORE_READ),
("ink.lang.coordinate", category::STORE_READ),
("ink.lang.stats", category::STORE_READ),
("ink.lang.audit", category::STORE_READ),
("ink.lang.query", category::STORE_READ),
("ink.lang.gaps", category::STORE_READ),
("ink.lang.sound_change", category::STORE_READ),
("ink.lang.cognates", category::STORE_READ),
("ink.lang.family_tree", category::STORE_READ),
("ink.lang.names", category::STORE_READ),
("ink.lang.prose", category::STORE_READ),
("ink.lang.poem", category::STORE_READ),
("ink.lang.varieties", category::STORE_READ),
("ink.lang.lect", category::STORE_READ),
("ink.lang.borrow", category::STORE_READ),
("ink.lang.areal", category::STORE_READ),
("ink.lang.idiolect", category::STORE_READ),
("ink.lang.ecology", category::STORE_READ),
("ink.lang.init", category::STORE_WRITE),
("ink.lang.define", category::STORE_WRITE),
("ink.lang.add_word", category::STORE_WRITE),
("ink.lang.remove_word", category::STORE_WRITE),
("ink.lang.derive_add", category::STORE_WRITE),
("ink.lang.grammar_set", category::STORE_WRITE),
("ink.lang.idiom_add", category::STORE_WRITE),
("ink.lang.metaphor_add", category::STORE_WRITE),
("ink.lang.compose", category::AI_WRITE),
("ink.lang.reconstruct", category::AI_WRITE),
("ink.lang.realism_check", category::AI_WRITE),
("ink.lang.generate_lexicon", category::AI_WRITE),
("ink.lang.glyph_lint", category::FS_READ),
("ink.lang.dictionary", category::FS_WRITE),
("ink.lang.grammar_book", category::FS_WRITE),
("ink.lang.font_build", category::FS_WRITE),
("ink.lang.glyph_draft", 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 fs_unsandboxed: bool,
#[serde(default = "default_trust_decision")]
pub trust_decision: String,
#[serde(default)]
pub bootstrap: String,
}
fn default_trust_decision() -> String {
"ask".to_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,
fs_unsandboxed: false,
trust_decision: default_trust_decision(),
bootstrap: String::new(),
}
}
}
impl Policy {
pub fn denies(&self, category: &str) -> bool {
self.effective_denied_categories().contains(category)
}
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");
}
}
}
}
#[test]
fn review_list_classified_as_store_read() {
let cat = WORD_CATEGORIES
.iter()
.find(|(w, _)| *w == "ink.review.list")
.map(|(_, c)| *c);
assert_eq!(cat, Some(category::STORE_READ));
}
#[test]
fn review_writes_classified_as_store_write() {
for word in ["ink.review.add_comment", "ink.review.resolve"] {
let cat = WORD_CATEGORIES
.iter()
.find(|(w, _)| *w == word)
.map(|(_, c)| *c);
assert_eq!(
cat,
Some(category::STORE_WRITE),
"{word} should inherit the store_write gate"
);
}
}
#[test]
fn thread_list_classified_as_store_read() {
let cat = WORD_CATEGORIES
.iter()
.find(|(w, _)| *w == "ink.thread.list")
.map(|(_, c)| *c);
assert_eq!(cat, Some(category::STORE_READ));
}
#[test]
fn pdf_disk_words_classified() {
let cat = |w: &str| WORD_CATEGORIES.iter().find(|(n, _)| *n == w).map(|(_, c)| *c);
assert_eq!(cat("ink.pdf.load"), Some(category::FS_READ));
assert_eq!(
cat("ink.pdf.save"),
Some(category::FS_WRITE),
"ink.pdf.save must inherit the fs_write deny-by-default gate"
);
}
#[test]
fn export_disk_words_classified() {
let cat = |w: &str| WORD_CATEGORIES.iter().find(|(n, _)| *n == w).map(|(_, c)| *c);
for w in [
"ink.export.docx",
"ink.export.manuscript",
"ink.export.markdown",
"ink.export.tex",
"ink.export.epub",
] {
assert_eq!(cat(w), Some(category::FS_WRITE), "{w} must be fs_write");
}
}
#[test]
fn lang_words_classified() {
let cat = |w: &str| WORD_CATEGORIES.iter().find(|(n, _)| *n == w).map(|(_, c)| *c);
for w in [
"ink.lang.list",
"ink.lang.generate_word",
"ink.lang.syllabify",
"ink.lang.ipa",
"ink.lang.gloss",
"ink.lang.sentence",
] {
assert_eq!(cat(w), Some(category::STORE_READ), "{w} must be store_read");
}
let mutators = [
"ink.lang.init",
"ink.lang.define",
"ink.lang.add_word",
"ink.lang.remove_word",
"ink.lang.derive_add",
"ink.lang.grammar_set",
"ink.lang.idiom_add",
"ink.lang.metaphor_add",
];
for w in mutators {
assert_eq!(
cat(w),
Some(category::STORE_WRITE),
"{w} must inherit the store_write deny-by-default gate"
);
}
for w in [
"ink.lang.compose",
"ink.lang.reconstruct",
"ink.lang.realism_check",
"ink.lang.generate_lexicon",
] {
assert_eq!(cat(w), Some(category::AI_WRITE), "{w} must be ai_write");
}
let p = Policy::default();
let denied = p.effective_denied_categories();
for w in mutators {
assert!(denied.contains(cat(w).unwrap()), "{w} denied by default");
}
assert!(denied.contains(category::AI_WRITE), "ai_write denied by default");
assert_eq!(cat("ink.lang.glyph_lint"), Some(category::FS_READ));
for w in [
"ink.lang.dictionary",
"ink.lang.grammar_book",
"ink.lang.font_build",
"ink.lang.glyph_draft",
] {
assert_eq!(cat(w), Some(category::FS_WRITE), "{w} must be fs_write");
assert!(denied.contains(cat(w).unwrap()), "{w} denied by default");
}
}
#[test]
fn review_writes_default_denied() {
let p = Policy::default();
let denied = p.effective_denied_categories();
assert!(denied.contains(category::STORE_WRITE));
for word in ["ink.review.add_comment", "ink.review.resolve"] {
let cat = WORD_CATEGORIES
.iter()
.find(|(w, _)| *w == word)
.map(|(_, c)| *c)
.unwrap();
assert!(
denied.contains(cat),
"{word} should be denied by default"
);
}
}
}