agent_sdk_toolkit/workspace/
write.rs1use std::{fs, sync::Arc};
6
7use agent_sdk_core::{
8 AgentError, ExecutorRef, PolicyRef, ToolExecutionOutput, ToolExecutionRequest, ToolExecutor,
9 ToolPackId, ToolPackKind, ToolPackSnapshot,
10 policy::{CapabilityPermission, EffectClass, RiskClass},
11};
12use serde::{Deserialize, Serialize};
13
14use super::{
15 bounds::BoundedWorkspace,
16 policy::WorkspacePolicy,
17 util::{content_ref_for, first_arg_ref, hash_bytes, policy_denial, tool_failure},
18};
19use crate::{
20 packs::{ToolkitPackBundle, tool_snapshot},
21 testing::{InMemoryJsonArgumentStore, InMemoryToolkitContentStore},
22};
23
24#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
25pub struct WorkspaceWriteRequest {
28 pub path: String,
30 pub contents: String,
33 pub mode: WorkspaceWriteMode,
37}
38
39#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
40#[serde(rename_all = "snake_case")]
41pub enum WorkspaceWriteMode {
44 CreateNew,
46 Overwrite,
48}
49
50#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
51pub struct WorkspaceWriteOutput {
54 pub path: String,
56 pub created: bool,
59 pub overwritten: bool,
62 pub before_hash: Option<String>,
65 pub after_hash: String,
68 pub non_reversible_reason: Option<String>,
71}
72
73impl BoundedWorkspace {
74 pub(super) fn write(
78 &self,
79 request: &WorkspaceWriteRequest,
80 ) -> Result<WorkspaceWriteOutput, AgentError> {
81 if request.contents.len() as u64 > self.policy.max_file_bytes {
82 return Err(policy_denial("workspace write exceeds max_file_bytes"));
83 }
84 let path = self.resolve_for_write(&request.path)?;
85 let exists = path.exists();
86 match (&request.mode, exists) {
87 (WorkspaceWriteMode::CreateNew, true) => {
88 return Err(policy_denial(
89 "workspace write create_new refused existing path",
90 ));
91 }
92 (WorkspaceWriteMode::CreateNew, false) if !self.policy.allow_create => {
93 return Err(policy_denial("workspace write create scope is not granted"));
94 }
95 (WorkspaceWriteMode::Overwrite, false) if !self.policy.allow_create => {
96 return Err(policy_denial("workspace write missing create scope"));
97 }
98 (WorkspaceWriteMode::Overwrite, true) if !self.policy.allow_overwrite => {
99 return Err(policy_denial(
100 "workspace write overwrite scope is not granted",
101 ));
102 }
103 _ => {}
104 }
105 let before_hash = if exists {
106 if fs::metadata(&path).map_err(tool_failure)?.len() > self.policy.max_file_bytes {
107 return Err(policy_denial(
108 "workspace write existing file exceeds max_file_bytes",
109 ));
110 }
111 Some(hash_bytes(&fs::read(&path).map_err(tool_failure)?))
112 } else {
113 None
114 };
115 fs::write(&path, request.contents.as_bytes()).map_err(tool_failure)?;
116 let after_hash = hash_bytes(request.contents.as_bytes());
117 Ok(WorkspaceWriteOutput {
118 path: request.path.clone(),
119 created: !exists,
120 overwritten: exists,
121 before_hash,
122 after_hash,
123 non_reversible_reason: if exists {
124 None
125 } else {
126 Some("created file has no prior content to restore automatically".to_string())
127 },
128 })
129 }
130}
131
132#[derive(Clone)]
133pub struct WorkspaceWriteExecutor {
136 executor_ref: ExecutorRef,
137 workspace: Arc<BoundedWorkspace>,
138 arguments: InMemoryJsonArgumentStore,
139 content: InMemoryToolkitContentStore,
140}
141
142impl WorkspaceWriteExecutor {
143 pub fn new(
147 workspace: Arc<BoundedWorkspace>,
148 arguments: InMemoryJsonArgumentStore,
149 content: InMemoryToolkitContentStore,
150 ) -> Self {
151 Self {
152 executor_ref: ExecutorRef::new("executor.toolkit.workspace_write.v1"),
153 workspace,
154 arguments,
155 content,
156 }
157 }
158
159 pub fn pack_bundle(
163 source: agent_sdk_core::SourceRef,
164 policy_ref: PolicyRef,
165 workspace: &WorkspacePolicy,
166 ) -> Result<ToolkitPackBundle, AgentError> {
167 let snapshot = ToolPackSnapshot::new(
168 ToolPackId::new("toolpack.workspace_write.v1"),
169 ToolPackKind::WorkspaceWrite,
170 "v1",
171 source.clone(),
172 )
173 .with_workspace_bounds(workspace.bounds_snapshot(policy_ref.clone()))
174 .with_tool(tool_snapshot(
175 "cap.toolkit.workspace_write",
176 "workspace_write",
177 "executor.toolkit.workspace_write.v1",
178 "schema.toolkit.workspace_write.v1",
179 vec![policy_ref],
180 vec![CapabilityPermission::FilesystemWrite],
181 EffectClass::Write,
182 RiskClass::High,
183 &source,
184 ));
185 ToolkitPackBundle::from_snapshot(snapshot)
186 }
187}
188
189impl ToolExecutor for WorkspaceWriteExecutor {
190 fn executor_ref(&self) -> &ExecutorRef {
191 &self.executor_ref
192 }
193
194 fn execute(&self, request: &ToolExecutionRequest) -> Result<ToolExecutionOutput, AgentError> {
195 let args_ref = first_arg_ref(request)?;
196 let write_request: WorkspaceWriteRequest = self.arguments.get(args_ref)?;
197 let output = self.workspace.write(&write_request)?;
198 let content_ref = content_ref_for(request, "workspace_write");
199 self.content.put(content_ref.clone(), &output)?;
200 let mut envelope =
201 ToolExecutionOutput::completed("workspace write recorded before and after metadata");
202 envelope.content_refs.push(content_ref);
203 envelope.reconciliation_ref = Some(format!("reconcile.{}", output.after_hash));
204 Ok(envelope)
205 }
206}