aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/permissions/writer.rs
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-04-10 13:24:57 -0700
committerGitHub <noreply@github.com>2026-04-10 20:24:57 +0000
commit09279a428659cf41824737d3e0c97bcc19a8885a (patch)
tree64731502c065df2483e8dd680d46c5559f3094f2 /crates/atuin-ai/src/permissions/writer.rs
parentfeat: add strip_trailing_whitespace, on by default (#3390) (diff)
downloadatuin-09279a428659cf41824737d3e0c97bcc19a8885a.zip
feat: Client-tool execution + permission system (#3370)
Adds client-side tool execution to Atuin AI, starting with `atuin_history`. The server can request tool calls, which are executed locally with a permission system, and results are sent back to continue the conversation.
Diffstat (limited to 'crates/atuin-ai/src/permissions/writer.rs')
-rw-r--r--crates/atuin-ai/src/permissions/writer.rs198
1 files changed, 198 insertions, 0 deletions
diff --git a/crates/atuin-ai/src/permissions/writer.rs b/crates/atuin-ai/src/permissions/writer.rs
new file mode 100644
index 00000000..b2bd9482
--- /dev/null
+++ b/crates/atuin-ai/src/permissions/writer.rs
@@ -0,0 +1,198 @@
+use std::path::Path;
+
+use eyre::Result;
+
+use crate::permissions::rule::Rule;
+
+/// Whether a rule should be added to the allow or deny list.
+#[allow(dead_code)]
+pub(crate) enum RuleDisposition {
+ Allow,
+ Deny,
+}
+
+/// Write a permission rule to a `permissions.ai.toml` file.
+///
+/// If the file doesn't exist it is created (along with parent directories).
+/// If it does exist, `toml_edit` is used to append the rule while preserving
+/// existing formatting and comments.
+///
+/// **Not concurrent-safe.** The read-modify-write cycle is not atomic. In the
+/// current UI this is fine — the Select widget serializes permission decisions —
+/// but callers should not invoke this concurrently for the same file.
+pub(crate) async fn write_rule(
+ file_path: &Path,
+ rule: &Rule,
+ disposition: RuleDisposition,
+) -> Result<()> {
+ let content = if tokio::fs::try_exists(file_path).await.unwrap_or(false) {
+ tokio::fs::read_to_string(file_path).await?
+ } else {
+ String::new()
+ };
+
+ let mut doc: toml_edit::DocumentMut = content.parse()?;
+
+ // Ensure [permissions] table exists
+ if !doc.contains_key("permissions") {
+ doc["permissions"] = toml_edit::Item::Table(toml_edit::Table::new());
+ }
+
+ let key = match disposition {
+ RuleDisposition::Allow => "allow",
+ RuleDisposition::Deny => "deny",
+ };
+
+ // Use as_table_like_mut so both standard and inline tables work.
+ let permissions = doc["permissions"]
+ .as_table_like_mut()
+ .ok_or_else(|| eyre::eyre!("[permissions] is not a table"))?;
+
+ // Get or create the array
+ if !permissions.contains_key(key) {
+ permissions.insert(key, toml_edit::Item::Value(toml_edit::Array::new().into()));
+ }
+
+ let array = permissions
+ .get_mut(key)
+ .and_then(|item| item.as_value_mut())
+ .and_then(|v| v.as_array_mut())
+ .ok_or_else(|| eyre::eyre!("permissions.{key} is not an array"))?;
+
+ // Don't add duplicates
+ let rule_str = rule.to_string();
+ let already_present = array.iter().any(|v| v.as_str() == Some(&rule_str));
+ if !already_present {
+ array.push(rule_str);
+ }
+
+ // Write back, creating parent directories as needed
+ if let Some(parent) = file_path.parent() {
+ tokio::fs::create_dir_all(parent).await?;
+ }
+ tokio::fs::write(file_path, doc.to_string()).await?;
+
+ Ok(())
+}
+
+/// Build the path to the project-level permissions file.
+/// `project_root` is typically a git root or the current working directory.
+pub(crate) fn project_permissions_path(project_root: &Path) -> std::path::PathBuf {
+ project_root.join(".atuin").join("permissions.ai.toml")
+}
+
+/// Build the path to the global permissions file (sibling of atuin config).
+pub(crate) fn global_permissions_path() -> std::path::PathBuf {
+ atuin_common::utils::config_dir().join("permissions.ai.toml")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn creates_new_file_with_allow_rule() {
+ let dir = tempfile::tempdir().unwrap();
+ let file = dir.path().join("permissions.ai.toml");
+ let rule = Rule {
+ tool: "AtuinHistory".to_string(),
+ scope: None,
+ };
+
+ write_rule(&file, &rule, RuleDisposition::Allow)
+ .await
+ .unwrap();
+
+ let content = tokio::fs::read_to_string(&file).await.unwrap();
+ assert!(content.contains("[permissions]"));
+ assert!(content.contains(r#""AtuinHistory""#));
+ }
+
+ #[tokio::test]
+ async fn appends_to_existing_file() {
+ let dir = tempfile::tempdir().unwrap();
+ let file = dir.path().join("permissions.ai.toml");
+ let existing = r#"# My permissions
+[permissions]
+allow = ["Read"]
+"#;
+ tokio::fs::write(&file, existing).await.unwrap();
+
+ let rule = Rule {
+ tool: "AtuinHistory".to_string(),
+ scope: None,
+ };
+ write_rule(&file, &rule, RuleDisposition::Allow)
+ .await
+ .unwrap();
+
+ let content = tokio::fs::read_to_string(&file).await.unwrap();
+ // Comment preserved
+ assert!(content.contains("# My permissions"));
+ // Both rules present
+ assert!(content.contains(r#""Read""#));
+ assert!(content.contains(r#""AtuinHistory""#));
+ }
+
+ #[tokio::test]
+ async fn does_not_duplicate_existing_rule() {
+ let dir = tempfile::tempdir().unwrap();
+ let file = dir.path().join("permissions.ai.toml");
+ let existing = r#"[permissions]
+allow = ["AtuinHistory"]
+"#;
+ tokio::fs::write(&file, existing).await.unwrap();
+
+ let rule = Rule {
+ tool: "AtuinHistory".to_string(),
+ scope: None,
+ };
+ write_rule(&file, &rule, RuleDisposition::Allow)
+ .await
+ .unwrap();
+
+ let content = tokio::fs::read_to_string(&file).await.unwrap();
+ // Should appear exactly once
+ assert_eq!(content.matches("AtuinHistory").count(), 1);
+ }
+
+ #[tokio::test]
+ async fn handles_inline_table_permissions() {
+ let dir = tempfile::tempdir().unwrap();
+ let file = dir.path().join("permissions.ai.toml");
+ // Inline table style — as_table_mut() would return None for this
+ let existing = r#"permissions = { allow = ["Read"] }
+"#;
+ tokio::fs::write(&file, existing).await.unwrap();
+
+ let rule = Rule {
+ tool: "AtuinHistory".to_string(),
+ scope: None,
+ };
+ write_rule(&file, &rule, RuleDisposition::Allow)
+ .await
+ .unwrap();
+
+ let content = tokio::fs::read_to_string(&file).await.unwrap();
+ assert!(content.contains(r#""Read""#));
+ assert!(content.contains(r#""AtuinHistory""#));
+ }
+
+ #[tokio::test]
+ async fn writes_deny_rule() {
+ let dir = tempfile::tempdir().unwrap();
+ let file = dir.path().join("permissions.ai.toml");
+ let rule = Rule {
+ tool: "Shell".to_string(),
+ scope: None,
+ };
+
+ write_rule(&file, &rule, RuleDisposition::Deny)
+ .await
+ .unwrap();
+
+ let content = tokio::fs::read_to_string(&file).await.unwrap();
+ assert!(content.contains("deny"));
+ assert!(content.contains(r#""Shell""#));
+ }
+}