Skip to main content

bamboo_engine/ticket_worker_plan/
files.rs

1//! File execution uses the existing native permission gate and HostBridge.
2//! The Worker never invokes an ambient filesystem tool for a Ticket Run.
3use super::*;
4use bamboo_agent_core::tools::{
5    ToolCall, ToolError, ToolExecutionContext, ToolExecutor, ToolOutcome, ToolResult, ToolSchema,
6};
7use bamboo_subagent::executor::HostBridge;
8use bamboo_tickets::{FileOperation, FileReply};
9use serde::Deserialize;
10use serde_json::{json, Value};
11use std::{
12    collections::{BTreeMap, BTreeSet},
13    sync::Mutex,
14};
15
16pub const TICKET_FILE_ACTION: &str = "_ticket_workspace_file_v1";
17
18/// Read-only preflight for an explicitly prepared Git worktree. This never
19/// creates branches or workspaces, and runs outside the Ticket transaction lock.
20pub async fn verify_workspace(workspace: bamboo_tickets::ExecutionWorkspace) -> Result<()> {
21    tokio::task::spawn_blocking(move || {
22        use std::{path::Path, process::Command};
23        fn git(path: &str, args: &[&str]) -> Result<String> {
24            let output = Command::new("git")
25                .env("GIT_OPTIONAL_LOCKS", "0")
26                .args(["-C", path])
27                .args(args)
28                .output()?;
29            if !output.status.success() {
30                return Err(Error::ScopeDenied(
31                    "Git workspace identity cannot be verified".into(),
32                ));
33            }
34            String::from_utf8(output.stdout)
35                .map(|s| s.trim().to_owned())
36                .map_err(|_| Error::ScopeDenied("Git workspace identity is not UTF-8".into()))
37        }
38        let root = Path::new(&workspace.worktree).canonicalize()?;
39        let marker = std::fs::symlink_metadata(root.join(".git"))?;
40        if !marker.is_file()
41            || marker.file_type().is_symlink()
42            || Path::new(&git(
43                &workspace.worktree,
44                &["rev-parse", "--show-toplevel"],
45            )?)
46            .canonicalize()?
47                != root
48            || git(&workspace.worktree, &["rev-parse", "HEAD"])? != workspace.base_commit
49            || git(&workspace.worktree, &["symbolic-ref", "--short", "HEAD"])? != workspace.branch
50            || Path::new(&git(
51                &workspace.worktree,
52                &["rev-parse", "--path-format=absolute", "--git-common-dir"],
53            )?)
54            .canonicalize()?
55                != Path::new(&git(
56                    &workspace.repo,
57                    &["rev-parse", "--path-format=absolute", "--git-common-dir"],
58                )?)
59                .canonicalize()?
60        {
61            return Err(Error::ScopeDenied(
62                "Assignment must use its exact isolated repo/base/branch/worktree".into(),
63            ));
64        }
65        Ok(())
66    })
67    .await
68    .map_err(|_| Error::AuthorityUnavailable("workspace preflight stopped".into()))?
69}
70
71#[derive(Clone, Deserialize)]
72#[serde(deny_unknown_fields)]
73struct FileRequest {
74    tool: String,
75    arguments: Value,
76    expected_sha256: Option<String>,
77}
78#[derive(Deserialize)]
79#[serde(deny_unknown_fields)]
80struct ReadArgs {
81    file_path: String,
82}
83#[derive(Deserialize)]
84#[serde(deny_unknown_fields)]
85struct WriteArgs {
86    file_path: String,
87    content: String,
88}
89
90fn operation(request: &FileRequest) -> Result<FileOperation> {
91    match request.tool.as_str() {
92        "Read" if request.expected_sha256.is_none() => {
93            let args: ReadArgs = serde_json::from_value(request.arguments.clone())?;
94            Ok(FileOperation::Read {
95                file_path: args.file_path,
96            })
97        }
98        "Write" => {
99            let args: WriteArgs = serde_json::from_value(request.arguments.clone())?;
100            Ok(FileOperation::Write {
101                file_path: args.file_path,
102                content: args.content,
103                expected_sha256: request.expected_sha256.clone(),
104            })
105        }
106        _ => Err(Error::ScopeDenied(
107            "unsupported Ticket file operation".into(),
108        )),
109    }
110}
111
112pub fn apply_host_file_request(
113    service: &TicketService,
114    caller: &Session,
115    run_id: &str,
116    args: &Value,
117    call_id: &str,
118    ceiling: &[String],
119) -> Result<FileReply> {
120    let body = args
121        .as_object()
122        .filter(|o| o.len() == 1)
123        .and_then(|o| o.get(TICKET_FILE_ACTION))
124        .ok_or_else(|| Error::ScopeDenied("invalid file callback".into()))?;
125    let request: FileRequest = serde_json::from_value(body.clone())?;
126    if !ceiling.contains(&request.tool) {
127        return Err(Error::ScopeDenied(
128            "file tool outside immutable native ceiling".into(),
129        ));
130    }
131    let assignment = caller
132        .metadata
133        .get(TICKET_LOCAL_PLAN_KEY)
134        .ok_or_else(|| Error::ScopeDenied("Child has no Ticket capability".into()))?;
135    let plan = TicketWorkerPlan::from_receipt_identity(Arc::new(service.clone()), assignment)?;
136    if caller.kind != SessionKind::Child
137        || caller.id != plan.session_id
138        || run_id != plan.run_id
139        || caller.parent_session_id.as_deref() != Some(plan.supervisor_session_id.as_str())
140    {
141        return Err(Error::ScopeDenied("file callback Run changed".into()));
142    }
143    service.workspace_file(&plan.authority, call_id, &operation(&request)?)
144}
145
146pub struct RemoteFileExecutor {
147    inner: Arc<dyn ToolExecutor>,
148    host: HostBridge,
149    session_id: String,
150    tools: BTreeSet<String>,
151    reads: Mutex<BTreeMap<String, String>>,
152    calls: Mutex<BTreeMap<String, FileRequest>>,
153}
154impl RemoteFileExecutor {
155    pub fn new(
156        inner: Arc<dyn ToolExecutor>,
157        host: HostBridge,
158        session_id: String,
159        tools: &[String],
160    ) -> Result<Self> {
161        bamboo_tickets::validate_native_tool_ceiling(tools.iter().map(String::as_str))?;
162        Ok(Self {
163            inner,
164            host,
165            session_id,
166            tools: tools.iter().cloned().collect(),
167            reads: Mutex::new(BTreeMap::new()),
168            calls: Mutex::new(BTreeMap::new()),
169        })
170    }
171    fn permitted(&self, call: &ToolCall) -> std::result::Result<(), ToolError> {
172        if self.tools.contains(&call.function.name) {
173            Ok(())
174        } else {
175            Err(ToolError::NotFound(call.function.name.clone()))
176        }
177    }
178}
179#[async_trait::async_trait]
180impl ToolExecutor for RemoteFileExecutor {
181    async fn execute(&self, _: &ToolCall) -> std::result::Result<ToolResult, ToolError> {
182        Err(ToolError::Execution(
183            "Ticket file execution needs a bound Run context".into(),
184        ))
185    }
186    async fn execute_with_context_outcome(
187        &self,
188        call: &ToolCall,
189        ctx: ToolExecutionContext<'_>,
190    ) -> std::result::Result<ToolOutcome, ToolError> {
191        self.permitted(call)?;
192        if ctx.session_id != Some(self.session_id.as_str()) || ctx.tool_call_id != call.id {
193            return Err(ToolError::Execution("Ticket file context changed".into()));
194        }
195        if call.function.name == "Task" {
196            return self.inner.execute_with_context_outcome(call, ctx).await;
197        }
198        let args: Value = serde_json::from_str(&call.function.arguments)
199            .map_err(|e| ToolError::InvalidArguments(e.to_string()))?;
200        if let Some(outcome) = self
201            .inner
202            .check_permissions_for_resolved(call, &call.function.name, &args, &ctx)
203            .await?
204        {
205            return Ok(outcome);
206        }
207        let path = args
208            .get("file_path")
209            .and_then(Value::as_str)
210            .ok_or_else(|| ToolError::InvalidArguments("file_path missing".into()))?;
211        let hash = if call.function.name == "Write" {
212            self.reads.lock().expect("read tracker").get(path).cloned()
213        } else {
214            None
215        };
216        let mut request = FileRequest {
217            tool: call.function.name.clone(),
218            arguments: args.clone(),
219            expected_sha256: hash.clone(),
220        };
221        operation(&request).map_err(|e| ToolError::InvalidArguments(e.to_string()))?;
222        {
223            let mut calls = self.calls.lock().expect("file call identities");
224            if let Some(original) = calls.get(&call.id) {
225                if original.tool != request.tool || original.arguments != request.arguments {
226                    return Err(ToolError::Execution("idempotency_conflict".into()));
227                }
228                request = original.clone();
229            } else {
230                if calls.len() >= 32 {
231                    return Err(ToolError::Execution("file call budget exceeded".into()));
232                }
233                calls.insert(call.id.clone(), request.clone());
234            }
235        }
236        let result=self.host.subagent_call(json!({(TICKET_FILE_ACTION):{"tool":request.tool,"arguments":request.arguments,"expected_sha256":request.expected_sha256}}),&call.id).await.map_err(ToolError::Execution)?;
237        let reply: FileReply =
238            serde_json::from_value(result).map_err(|e| ToolError::Execution(e.to_string()))?;
239        self.reads
240            .lock()
241            .expect("read tracker")
242            .insert(path.into(), reply.sha256.clone());
243        Ok(ToolOutcome::Completed(ToolResult {
244            success: true,
245            result: serde_json::to_string(&reply)
246                .map_err(|e| ToolError::Execution(e.to_string()))?,
247            display_preference: None,
248            images: Vec::new(),
249        }))
250    }
251    async fn execute_with_context(
252        &self,
253        call: &ToolCall,
254        ctx: ToolExecutionContext<'_>,
255    ) -> std::result::Result<ToolResult, ToolError> {
256        self.execute_with_context_outcome(call, ctx)
257            .await
258            .map(ToolOutcome::into_tool_result)
259    }
260    async fn check_permissions_for(
261        &self,
262        call: &ToolCall,
263        ctx: &ToolExecutionContext<'_>,
264    ) -> std::result::Result<Option<ToolOutcome>, ToolError> {
265        self.permitted(call)?;
266        self.inner.check_permissions_for(call, ctx).await
267    }
268    fn list_tools(&self) -> Vec<ToolSchema> {
269        let mut schemas = self.inner.list_tools();
270        schemas.retain(|s| self.tools.contains(&s.function.name));
271        for schema in &mut schemas {
272            match schema.function.name.as_str() {
273                "Read" => {
274                    schema.function.description="Read one complete UTF-8 file inside this Assignment's write roots. Both raw content and the encoded result must fit 16 KiB. No symlinks, hardlinks, .git, .bamboo control directories, traversal or directory listing.".into();
275                    schema.function.parameters = json!({"type":"object","additionalProperties":false,"required":["file_path"],"properties":{"file_path":{"type":"string"}}});
276                }
277                "Write" => {
278                    schema.function.description="Create one absent UTF-8 file, 1–16384 bytes, in an existing directory inside this Assignment's write roots, excluding .git and .bamboo control directories. Existing-file replacement is unsupported even after Read; use a new path. Installation cannot overwrite an externally created file. Unknown effects cannot be automatically retried.".into();
279                    schema.function.parameters = json!({"type":"object","additionalProperties":false,"required":["file_path","content"],"properties":{"file_path":{"type":"string"},"content":{"type":"string","minLength":1,"maxLength":16384}}});
280                }
281                _ => {}
282            }
283        }
284        schemas
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use bamboo_agent_core::tools::FunctionCall;
292    struct Gate(std::sync::atomic::AtomicBool);
293    #[async_trait::async_trait]
294    impl ToolExecutor for Gate {
295        async fn execute(&self, _: &ToolCall) -> std::result::Result<ToolResult, ToolError> {
296            panic!("ambient file execution must never occur")
297        }
298        async fn check_permissions_for(
299            &self,
300            _: &ToolCall,
301            _: &ToolExecutionContext<'_>,
302        ) -> std::result::Result<Option<ToolOutcome>, ToolError> {
303            if self.0.load(std::sync::atomic::Ordering::SeqCst) {
304                Err(ToolError::Execution("policy denied".into()))
305            } else {
306                Ok(None)
307            }
308        }
309        fn list_tools(&self) -> Vec<ToolSchema> {
310            Vec::new()
311        }
312    }
313    #[tokio::test]
314    async fn permission_gate_and_frozen_raw_call_precede_host_file_execution() {
315        let (bridge, mut requests) = HostBridge::channel();
316        let gate = Arc::new(Gate(std::sync::atomic::AtomicBool::new(true)));
317        let executor = RemoteFileExecutor::new(
318            gate.clone(),
319            bridge,
320            "child".into(),
321            &["Task".into(), "Read".into(), "Write".into()],
322        )
323        .unwrap();
324        let call = ToolCall {
325            id: "write".into(),
326            tool_type: "function".into(),
327            function: FunctionCall {
328                name: "Write".into(),
329                arguments: json!({"file_path":"/fixture/code","content":"new"}).to_string(),
330            },
331        };
332        let mut ctx = ToolExecutionContext::none(&call.id);
333        ctx.session_id = Some("child");
334        assert!(executor
335            .execute_with_context_outcome(&call, ctx)
336            .await
337            .is_err());
338        assert!(requests.try_recv().is_err());
339        let mut shell = call.clone();
340        shell.function.name = "Bash".into();
341        assert!(executor
342            .execute_with_context_outcome(&shell, ctx)
343            .await
344            .is_err());
345        assert!(requests.try_recv().is_err());
346        gate.0.store(false, std::sync::atomic::Ordering::SeqCst);
347        let pump = tokio::spawn(async move {
348            let mut original = None;
349            for _ in 0..2 {
350                let request = requests.recv().await.unwrap();
351                let body = request.body["args"][TICKET_FILE_ACTION].clone();
352                if let Some(prior) = &original {
353                    assert_eq!(
354                        &body, prior,
355                        "same raw Write must retain its original hash after success"
356                    );
357                } else {
358                    assert!(body["expected_sha256"].is_null());
359                    original = Some(body);
360                }
361                request
362                    .reply
363                    .send(
364                        json!({"result":{"content":null,"sha256":"a".repeat(64),"artifact":null}}),
365                    )
366                    .unwrap();
367            }
368        });
369        assert!(
370            executor
371                .execute_with_context_outcome(&call, ctx)
372                .await
373                .unwrap()
374                .into_tool_result()
375                .success
376        );
377        assert!(
378            executor
379                .execute_with_context_outcome(&call, ctx)
380                .await
381                .unwrap()
382                .into_tool_result()
383                .success
384        );
385        let mut changed = call.clone();
386        changed.function.arguments =
387            json!({"file_path":"/fixture/code","content":"different"}).to_string();
388        assert!(executor
389            .execute_with_context_outcome(&changed, ctx)
390            .await
391            .is_err());
392        pump.await.unwrap();
393    }
394}