Skip to main content

ferrify_infra/
lib.rs

1//! Runtime primitives for verification, sandbox selection, and tool brokering.
2//!
3//! `agent-infra` defines the boundary between Ferrify's control-plane types and
4//! the outside world. This includes sandbox selection, tool-broker contracts,
5//! and the verification backend that shells out to Cargo.
6//!
7//! The crate stays intentionally small in the starter implementation. The goal
8//! is to make operational boundaries explicit before adding richer runtimes or
9//! external integrations.
10//!
11//! # Examples
12//!
13//! ```
14//! use agent_domain::ModeSlug;
15//! use agent_infra::{SandboxManager, SandboxProfile};
16//!
17//! let mode = ModeSlug::new("verifier").expect("verifier is a valid mode slug");
18//! assert_eq!(
19//!     SandboxManager::profile_for_mode(&mode),
20//!     SandboxProfile::ReadOnlyWorkspace
21//! );
22//! ```
23
24use 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/// The sandbox profile attached to a stage.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum SandboxProfile {
38    /// Read-only access to the workspace.
39    ReadOnlyWorkspace,
40    /// Workspace writes without network access.
41    WorkspaceWriteNoNetwork,
42    /// Workspace writes with allowlisted network access.
43    WorkspaceWriteAllowlistedNetwork,
44    /// Broad ephemeral autonomy reserved for isolated environments.
45    EphemeralFullAuto,
46}
47
48/// Chooses runtime sandbox profiles for modes.
49#[derive(Debug, Default)]
50pub struct SandboxManager;
51
52impl SandboxManager {
53    /// Returns the recommended sandbox profile for a mode.
54    ///
55    /// Ferrify keeps verifier stages read-only and uses a write-without-network
56    /// profile for implementer stages. Unknown modes currently default to the
57    /// conservative read-only profile.
58    #[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/// A raw tool request that must pass through the broker.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ToolRequest {
71    /// The tool identifier.
72    pub tool: String,
73    /// The serialized input payload.
74    pub input: String,
75    /// The mode making the request.
76    pub requested_by_mode: ModeSlug,
77    /// The capability associated with the tool call.
78    pub capability: Capability,
79}
80
81/// A normalized tool output blob.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct ToolOutputBlob {
84    /// The mime type for the payload.
85    pub mime_type: String,
86    /// The textual content captured from the tool.
87    pub content: String,
88}
89
90/// A fact observed from tool output.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct ObservedFact {
93    /// The fact subject.
94    pub subject: String,
95    /// The fact detail.
96    pub detail: String,
97    /// The operational role attached to the fact source.
98    pub input_role: InputRole,
99    /// The trust classification attached to the fact.
100    pub trust_level: TrustLevel,
101}
102
103/// An audit record for a tool invocation.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct AuditRecord {
106    /// The mode that initiated the call.
107    pub mode: ModeSlug,
108    /// The capability that gated the call.
109    pub capability: Capability,
110}
111
112/// The normalized result returned by a brokered tool call.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ToolReceipt {
115    /// The synthetic tool call identifier.
116    pub tool_call_id: String,
117    /// The operational role assigned to the raw output.
118    pub input_role: InputRole,
119    /// The trust level of the raw output.
120    pub trust_level: TrustLevel,
121    /// The raw output blob.
122    pub raw_output: ToolOutputBlob,
123    /// Normalized facts extracted from the output.
124    pub normalized_facts: Vec<ObservedFact>,
125    /// Audit data attached to the call.
126    pub audit: AuditRecord,
127}
128
129/// A broker that mediates tool execution under policy.
130pub trait ToolBroker {
131    /// Executes a tool request under the current effective policy.
132    ///
133    /// # Errors
134    ///
135    /// Implementations should return [`ToolError`] when the policy forbids the
136    /// capability, when the request cannot be normalized, or when the tool
137    /// backend itself is unavailable.
138    fn call(
139        &self,
140        request: ToolRequest,
141        policy: &EffectivePolicy,
142    ) -> Result<ToolReceipt, ToolError>;
143}
144
145/// A deny-by-default broker used by Ferrify.
146#[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
163/// Runs verification commands and returns receipts.
164pub trait VerificationBackend {
165    /// Executes the verification plan at the repository root.
166    ///
167    /// # Errors
168    ///
169    /// Returns [`InfraError`] when the backend cannot launch the required
170    /// command or cannot collect its result.
171    fn run(
172        &self,
173        root: &Path,
174        plan: &VerificationPlan,
175    ) -> Result<Vec<ValidationReceipt>, InfraError>;
176}
177
178/// A process-based verification backend that shells out to Cargo.
179#[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/// Errors produced by the infrastructure layer.
203#[derive(Debug, Error)]
204pub enum InfraError {
205    /// Process execution failed.
206    #[error("failed to execute verification command: {0}")]
207    Io(#[from] std::io::Error),
208}
209
210/// Errors produced by tool brokering.
211#[derive(Debug, Error)]
212pub enum ToolError {
213    /// The requested capability is not allowed.
214    #[error("tool capability `{0:?}` is not allowed")]
215    Unauthorized(Capability),
216    /// The tool is not implemented by the starter broker.
217    #[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}