1use std::{
25 path::Path,
26 process::{Command, Output},
27};
28
29use agent_domain::{
30 ArtifactRef, Capability, EffectivePolicy, InputRole, ModeSlug, TrustLevel, ValidationReceipt,
31 VerificationKind, VerificationPlan, VerificationStatus,
32};
33use thiserror::Error;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum SandboxProfile {
38 ReadOnlyWorkspace,
40 WorkspaceWriteNoNetwork,
42 WorkspaceWriteAllowlistedNetwork,
44 EphemeralFullAuto,
46}
47
48#[derive(Debug, Default)]
50pub struct SandboxManager;
51
52impl SandboxManager {
53 #[must_use]
59 pub fn profile_for_mode(mode_slug: &ModeSlug) -> SandboxProfile {
60 match mode_slug.as_str() {
61 "implementer" => SandboxProfile::WorkspaceWriteNoNetwork,
62 "verifier" => SandboxProfile::ReadOnlyWorkspace,
63 _ => SandboxProfile::ReadOnlyWorkspace,
64 }
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ToolRequest {
71 pub tool: String,
73 pub input: String,
75 pub requested_by_mode: ModeSlug,
77 pub capability: Capability,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct ToolOutputBlob {
84 pub mime_type: String,
86 pub content: String,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct ObservedFact {
93 pub subject: String,
95 pub detail: String,
97 pub input_role: InputRole,
99 pub trust_level: TrustLevel,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct AuditRecord {
106 pub mode: ModeSlug,
108 pub capability: Capability,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ToolReceipt {
115 pub tool_call_id: String,
117 pub input_role: InputRole,
119 pub trust_level: TrustLevel,
121 pub raw_output: ToolOutputBlob,
123 pub normalized_facts: Vec<ObservedFact>,
125 pub audit: AuditRecord,
127}
128
129pub trait ToolBroker {
131 fn call(
139 &self,
140 request: ToolRequest,
141 policy: &EffectivePolicy,
142 ) -> Result<ToolReceipt, ToolError>;
143}
144
145#[derive(Debug, Default)]
147pub struct DenyByDefaultToolBroker;
148
149impl ToolBroker for DenyByDefaultToolBroker {
150 fn call(
151 &self,
152 request: ToolRequest,
153 policy: &EffectivePolicy,
154 ) -> Result<ToolReceipt, ToolError> {
155 if !policy.allowed_capabilities.contains(&request.capability) {
156 return Err(ToolError::Unauthorized(request.capability));
157 }
158
159 Err(ToolError::Unavailable(request.tool))
160 }
161}
162
163pub trait VerificationBackend {
165 fn run(
172 &self,
173 root: &Path,
174 plan: &VerificationPlan,
175 ) -> Result<Vec<ValidationReceipt>, InfraError>;
176}
177
178#[derive(Debug, Default)]
180pub struct ProcessVerificationBackend;
181
182impl VerificationBackend for ProcessVerificationBackend {
183 fn run(
184 &self,
185 root: &Path,
186 plan: &VerificationPlan,
187 ) -> Result<Vec<ValidationReceipt>, InfraError> {
188 let mut receipts = Vec::new();
189 for step in &plan.required {
190 let (program, args) = command_for(*step);
191 let output = Command::new(program)
192 .args(args)
193 .current_dir(root)
194 .output()?;
195 receipts.push(receipt_for(*step, program, args, &output));
196 }
197
198 Ok(receipts)
199 }
200}
201
202#[derive(Debug, Error)]
204pub enum InfraError {
205 #[error("failed to execute verification command: {0}")]
207 Io(#[from] std::io::Error),
208}
209
210#[derive(Debug, Error)]
212pub enum ToolError {
213 #[error("tool capability `{0:?}` is not allowed")]
215 Unauthorized(Capability),
216 #[error("tool `{0}` is unavailable in Ferrify")]
218 Unavailable(String),
219}
220
221fn command_for(step: VerificationKind) -> (&'static str, &'static [&'static str]) {
222 match step {
223 VerificationKind::CargoFmtCheck => ("cargo", &["fmt", "--check"]),
224 VerificationKind::CargoCheck => ("cargo", &["check"]),
225 VerificationKind::CargoClippy => (
226 "cargo",
227 &[
228 "clippy",
229 "--workspace",
230 "--all-targets",
231 "--all-features",
232 "--",
233 "-D",
234 "warnings",
235 ],
236 ),
237 VerificationKind::TargetedTests => ("cargo", &["test", "--workspace"]),
238 }
239}
240
241fn receipt_for(
242 step: VerificationKind,
243 program: &str,
244 args: &[&str],
245 output: &Output,
246) -> ValidationReceipt {
247 let command = if args.is_empty() {
248 program.to_owned()
249 } else {
250 format!("{program} {}", args.join(" "))
251 };
252
253 let status_code = output.status.code().unwrap_or(-1);
254 let stdout_tail = tail_snippet(&output.stdout);
255 let stderr_tail = tail_snippet(&output.stderr);
256 ValidationReceipt {
257 step,
258 command,
259 status: if output.status.success() {
260 VerificationStatus::Succeeded
261 } else {
262 VerificationStatus::Failed
263 },
264 artifacts: vec![
265 ArtifactRef {
266 label: "exit-code".to_owned(),
267 location: status_code.to_string(),
268 },
269 ArtifactRef {
270 label: "stdout-tail".to_owned(),
271 location: stdout_tail,
272 },
273 ArtifactRef {
274 label: "stderr-tail".to_owned(),
275 location: stderr_tail,
276 },
277 ],
278 }
279}
280
281fn tail_snippet(bytes: &[u8]) -> String {
282 let text = String::from_utf8_lossy(bytes);
283 let tail = text.trim();
284 if tail.is_empty() {
285 return "<empty>".to_owned();
286 }
287
288 const MAX_CHARS: usize = 240;
289 let char_count = tail.chars().count();
290 if char_count <= MAX_CHARS {
291 return tail.to_owned();
292 }
293
294 let suffix = tail
295 .chars()
296 .skip(char_count.saturating_sub(MAX_CHARS))
297 .collect::<String>();
298 format!("...{suffix}")
299}
300
301#[cfg(test)]
302mod tests {
303 use agent_domain::ModeSlug;
304
305 use super::{SandboxManager, SandboxProfile};
306
307 #[test]
308 fn verifier_mode_uses_read_only_profile() {
309 let verifier_mode = ModeSlug::new("verifier")
310 .unwrap_or_else(|error| panic!("verifier should be a valid mode slug: {error}"));
311 assert_eq!(
312 SandboxManager::profile_for_mode(&verifier_mode),
313 SandboxProfile::ReadOnlyWorkspace
314 );
315 }
316}