bamboo_engine/ticket_worker_plan/
files.rs1use 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
18pub 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}