Skip to main content

atman_runtime/tools/
anchor.rs

1use std::path::PathBuf;
2
3use crate::error::RuntimeError;
4use crate::tool::{ApprovalLevel, BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
5use crate::tools::anchor_fs::{self, AnchorError, StateStore};
6use crate::value::Value;
7
8pub struct AnchorRead;
9pub struct AnchorEdit;
10pub struct AnchorWrite;
11pub struct AnchorUndo;
12
13fn text(args: &ToolArgs, name: &str, required: bool) -> Result<Option<String>, RuntimeError> {
14    match args.named(name) {
15        Some(Value::Str(value)) => Ok(Some(value.clone())),
16        Some(Value::Unit) if !required => Ok(None),
17        None if !required => Ok(None),
18        Some(Value::Unit) | None => Err(RuntimeError::MissingArg(name.into())),
19        Some(value) => Err(RuntimeError::TypeMismatch {
20            expected: "string".into(),
21            actual: value.kind_name().into(),
22        }),
23    }
24}
25
26fn path(args: &ToolArgs) -> Result<PathBuf, RuntimeError> {
27    match args.named("path").or_else(|| args.positional.first()) {
28        Some(Value::Path(path)) => Ok(path.clone()),
29        Some(Value::Str(path)) => Ok(PathBuf::from(path)),
30        Some(value) => Err(RuntimeError::TypeMismatch {
31            expected: "path or string".into(),
32            actual: value.kind_name().into(),
33        }),
34        None => Err(RuntimeError::MissingArg("path".into())),
35    }
36}
37
38fn store(ctx: &ToolCtx) -> StateStore {
39    StateStore::new(
40        ctx.data_root
41            .clone()
42            .unwrap_or_else(|| PathBuf::from(".atman"))
43            .join("anchor"),
44    )
45}
46
47fn error(error: AnchorError) -> RuntimeError {
48    RuntimeError::ToolFailed(error.to_string())
49}
50
51fn change_value(change: &anchor_fs::ChangeRecord) -> Value {
52    Value::Struct(vec![
53        ("change_id".into(), Value::Str(change.change_id.clone())),
54        ("path".into(), Value::Str(change.path.clone())),
55        ("before_hash".into(), Value::Str(change.before_hash.clone())),
56        ("after_hash".into(), Value::Str(change.after_hash.clone())),
57    ])
58}
59
60fn schema(properties: serde_json::Value, required: &[&str]) -> serde_json::Value {
61    serde_json::json!({"type":"object", "properties":properties, "required":required})
62}
63
64impl Tool for AnchorRead {
65    fn name(&self) -> &str {
66        "anchor.read"
67    }
68    fn tier(&self) -> Tier {
69        Tier::Zero
70    }
71    fn description(&self) -> Option<&str> {
72        Some("Read a file with stable hashline anchors.")
73    }
74    fn input_schema(&self) -> serde_json::Value {
75        schema(
76            serde_json::json!({"path":{"type":["string","object"]}}),
77            &["path"],
78        )
79    }
80    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
81        Box::pin(async move {
82            let p = ctx.resolve_path(&path(&args)?)?;
83            anchor_fs::read_anchor_text(&p, &store(ctx))
84                .map(Value::Str)
85                .map_err(error)
86        })
87    }
88}
89
90impl Tool for AnchorEdit {
91    fn name(&self) -> &str {
92        "anchor.edit"
93    }
94    fn tier(&self) -> Tier {
95        Tier::Two
96    }
97    fn approval_level(&self, _: &ToolArgs, _: &ToolCtx) -> ApprovalLevel {
98        ApprovalLevel::Approve
99    }
100    fn description(&self) -> Option<&str> {
101        Some("Apply a strict replace, insert, or remove mutation using hashline anchors.")
102    }
103    fn input_schema(&self) -> serde_json::Value {
104        schema(
105            serde_json::json!({"path":{"type":"string"},"operation":{"enum":["replace","insert","remove"]},"target":{"type":"string"},"from":{"type":"string"},"to":{"type":"string"},"at":{"type":"string"},"position":{"enum":["before","after"]},"content":{"type":"string"}}),
106            &["path", "operation"],
107        )
108    }
109    fn invocation_provenance(
110        &self,
111        args: &ToolArgs,
112        ctx: &ToolCtx,
113    ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
114        Ok(crate::permission::ResourceProvenance::for_ctx(ctx)
115            .with_path(ctx, &path(args)?)?
116            .with_risk(crate::trust::RiskKind::FilesystemWrite))
117    }
118
119    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
120        Box::pin(async move {
121            let p = ctx.resolve_path_with_origin(&path(&args)?)?.path;
122            crate::fs_access::authorize_write(ctx, &p, self.name(), true).await?;
123            let operation = text(&args, "operation", true)?.unwrap();
124            let change = anchor_fs::edit_by_anchor(
125                &p,
126                &operation,
127                text(&args, "target", false)?.as_deref(),
128                text(&args, "from", false)?.as_deref(),
129                text(&args, "to", false)?.as_deref(),
130                text(&args, "at", false)?.as_deref(),
131                text(&args, "position", false)?.as_deref(),
132                text(&args, "content", false)?.as_deref(),
133                &store(ctx),
134            )
135            .map_err(error)?;
136            Ok(change_value(&change))
137        })
138    }
139}
140
141impl Tool for AnchorWrite {
142    fn name(&self) -> &str {
143        "anchor.write"
144    }
145    fn tier(&self) -> Tier {
146        Tier::Two
147    }
148    fn approval_level(&self, _: &ToolArgs, _: &ToolCtx) -> ApprovalLevel {
149        ApprovalLevel::Approve
150    }
151    fn description(&self) -> Option<&str> {
152        Some("Overwrite a file only when its expected hash matches.")
153    }
154    fn input_schema(&self) -> serde_json::Value {
155        schema(
156            serde_json::json!({"path":{"type":"string"},"expected_file_hash":{"type":"string"},"content":{"type":"string"}}),
157            &["path", "expected_file_hash", "content"],
158        )
159    }
160    fn invocation_provenance(
161        &self,
162        args: &ToolArgs,
163        ctx: &ToolCtx,
164    ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
165        Ok(crate::permission::ResourceProvenance::for_ctx(ctx)
166            .with_path(ctx, &path(args)?)?
167            .with_risk(crate::trust::RiskKind::FilesystemWrite))
168    }
169
170    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
171        Box::pin(async move {
172            let p = ctx.resolve_path_with_origin(&path(&args)?)?.path;
173            crate::fs_access::authorize_write(ctx, &p, self.name(), true).await?;
174            let h = text(&args, "expected_file_hash", true)?.unwrap();
175            let c = text(&args, "content", true)?.unwrap();
176            anchor_fs::overwrite_with_hash(&p, &h, &c, &store(ctx))
177                .map(|v| change_value(&v))
178                .map_err(error)
179        })
180    }
181}
182
183impl Tool for AnchorUndo {
184    fn name(&self) -> &str {
185        "anchor.undo"
186    }
187    fn tier(&self) -> Tier {
188        Tier::Two
189    }
190    fn approval_level(&self, _: &ToolArgs, _: &ToolCtx) -> ApprovalLevel {
191        ApprovalLevel::Approve
192    }
193    fn description(&self) -> Option<&str> {
194        Some("Undo the latest anchor change, refusing if the file changed since.")
195    }
196    fn input_schema(&self) -> serde_json::Value {
197        schema(
198            serde_json::json!({"path":{"type":"string"},"change_id":{"type":"string"}}),
199            &["path"],
200        )
201    }
202    fn invocation_provenance(
203        &self,
204        args: &ToolArgs,
205        ctx: &ToolCtx,
206    ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
207        Ok(crate::permission::ResourceProvenance::for_ctx(ctx)
208            .with_path(ctx, &path(args)?)?
209            .with_risk(crate::trust::RiskKind::FilesystemWrite))
210    }
211
212    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
213        Box::pin(async move {
214            let p = ctx.resolve_path_with_origin(&path(&args)?)?.path;
215            crate::fs_access::authorize_write(ctx, &p, self.name(), true).await?;
216            let s = store(ctx);
217            let id = text(&args, "change_id", false)?;
218            let change = match id {
219                Some(id) => s.change(&id),
220                None => s.latest_change(&p),
221            }
222            .map_err(error)?
223            .ok_or_else(|| {
224                RuntimeError::ToolFailed(format!("no anchor change for {}", p.display()))
225            })?;
226            let current = std::fs::read(&p).map_err(|e| RuntimeError::ToolFailed(e.to_string()))?;
227            s.undo_strict(&p, &change, &current).map_err(error)?;
228            Ok(change_value(&change))
229        })
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[tokio::test]
238    async fn anchor_write_rejects_external_path_without_changing_file() {
239        let workspace = tempfile::tempdir().unwrap();
240        let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
241            .join("target")
242            .join(format!("r4-anchor-{}.txt", uuid::Uuid::now_v7()));
243        std::fs::create_dir_all(fixture.parent().unwrap()).unwrap();
244        std::fs::write(&fixture, "original").unwrap();
245        let ctx = ToolCtx::new()
246            .with_fs_access(crate::fs_access::FsAccessPolicy::workspace_write(
247                workspace.path().into(),
248            ))
249            .with_workspace(crate::git_workspace::WorkspaceBinding {
250                workspace_id: "test".into(),
251                repository_root: workspace.path().into(),
252                path: workspace.path().into(),
253                branch: None,
254            });
255        let error = AnchorWrite
256            .call(
257                ToolArgs {
258                    positional: vec![],
259                    named: vec![
260                        ("path".into(), Value::Path(fixture.clone())),
261                        ("expected_file_hash".into(), Value::Str("unused".into())),
262                        ("content".into(), Value::Str("changed".into())),
263                    ],
264                },
265                &ctx,
266            )
267            .await
268            .unwrap_err();
269        assert!(error.to_string().contains("outside workspace"));
270        assert_eq!(std::fs::read_to_string(&fixture).unwrap(), "original");
271        std::fs::remove_file(fixture).unwrap();
272    }
273}