Skip to main content

aa_core/
dev_tool.rs

1//! Foundational types for the AI dev tool governance framework.
2//!
3//! These types are referenced by the `DevToolAdapter` trait and by every
4//! per-tool adapter (Claude Code, Codex, GitHub Copilot, Windsurf Cascade).
5//! They are intentionally light and free of runtime dependencies so that
6//! adapters can be implemented in `no_std` contexts where applicable.
7
8#[cfg(feature = "alloc")]
9use alloc::string::String;
10#[cfg(feature = "alloc")]
11use alloc::vec::Vec;
12#[cfg(feature = "std")]
13use std::path::PathBuf;
14
15#[cfg(feature = "serde")]
16use serde::{Deserialize, Serialize};
17
18/// Governance level applied to a managed AI dev tool or agent.
19///
20/// Variants are ordered such that
21/// `L0Discover < L1Observe < L2Enforce < L3Native`. The derived `Ord`
22/// implementation enables policies to express "at-least-this-level" rules,
23/// for example `governance_level >= L2Enforce`.
24///
25/// | Level | Capability |
26/// | --- | --- |
27/// | [`L0Discover`][Self::L0Discover] | eBPF / proxy detects unknown agents and their external behavior. |
28/// | [`L1Observe`][Self::L1Observe] | Network, file, process, and MCP observability without enforcement. |
29/// | [`L2Enforce`][Self::L2Enforce] | Allow / deny, approval, redaction, and budget enforcement. |
30/// | [`L3Native`][Self::L3Native] | Full SDK-integrated governance with identity, lineage, and semantic context. |
31#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
32#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
33pub enum GovernanceLevel {
34    /// L0 — Discover. eBPF / proxy detects unknown agents and their
35    /// external behavior; no policy enforcement is applied.
36    ///
37    /// This is also the [`Default`] for [`GovernanceLevel`]: any agent or
38    /// rule that does not declare a level is treated as L0 (discover-only).
39    #[default]
40    L0Discover,
41    /// L1 — Observe. Network, file, process, and MCP observability
42    /// without enforcement.
43    L1Observe,
44    /// L2 — Enforce. Allow / deny, approval, redaction, and budget
45    /// enforcement applied to the governed tool.
46    L2Enforce,
47    /// L3 — Native. Full SDK-integrated governance with identity,
48    /// lineage, and semantic context awareness.
49    L3Native,
50}
51
52impl core::fmt::Display for GovernanceLevel {
53    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
54        match self {
55            GovernanceLevel::L0Discover => write!(f, "L0Discover"),
56            GovernanceLevel::L1Observe => write!(f, "L1Observe"),
57            GovernanceLevel::L2Enforce => write!(f, "L2Enforce"),
58            GovernanceLevel::L3Native => write!(f, "L3Native"),
59        }
60    }
61}
62
63impl core::str::FromStr for GovernanceLevel {
64    type Err = &'static str;
65
66    fn from_str(s: &str) -> Result<Self, Self::Err> {
67        match s {
68            "L0" | "L0Discover" => Ok(GovernanceLevel::L0Discover),
69            "L1" | "L1Observe" => Ok(GovernanceLevel::L1Observe),
70            "L2" | "L2Enforce" => Ok(GovernanceLevel::L2Enforce),
71            "L3" | "L3Native" => Ok(GovernanceLevel::L3Native),
72            _ => Err("unknown governance level; expected L0, L1, L2, or L3"),
73        }
74    }
75}
76
77/// Concrete kind of AI dev tool being governed.
78///
79/// Concrete variants are matched against built-in `DevToolAdapter`
80/// implementations. The [`Custom`][Self::Custom] variant lets out-of-tree
81/// adapters identify themselves by name without requiring a code change
82/// to this enum.
83#[cfg(feature = "alloc")]
84#[derive(Debug, Clone, PartialEq, Eq, Hash)]
85#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
86pub enum DevToolKind {
87    /// Anthropic Claude Code (CLI).
88    ClaudeCode,
89    /// OpenAI Codex CLI.
90    Codex,
91    /// GitHub Copilot operating in agent mode.
92    GitHubCopilot,
93    /// Codeium Windsurf Cascade IDE agent.
94    WindsurfCascade,
95    /// Adapter-defined custom tool identified by an opaque name string.
96    Custom(String),
97}
98
99/// Lightweight description of an MCP server an adapter is aware of.
100///
101/// Returned by [`DevToolAdapter::list_mcp_servers`] and consumed by
102/// [`DevToolAdapter::apply_mcp_governance`]. This is a minimal placeholder;
103/// when `aa-core` grows a richer MCP type (e.g. transport-aware
104/// description), this struct will be replaced or wrapped without any
105/// trait-method signature change.
106///
107/// [`DevToolAdapter::list_mcp_servers`]: <not yet defined; introduced in this same Subtask>
108/// [`DevToolAdapter::apply_mcp_governance`]: <not yet defined; introduced in this same Subtask>
109#[cfg(feature = "alloc")]
110#[derive(Debug, Clone)]
111#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
112pub struct McpServerInfo {
113    /// Stable identifier the tool uses for this MCP server (matches the
114    /// key under which the server appears in the tool's native
115    /// configuration file).
116    pub name: String,
117    /// Executable invoked to start the MCP server process.
118    pub command: String,
119    /// Arguments passed to `command` when the MCP server is started.
120    pub args: Vec<String>,
121}
122
123/// Error type returned from [`DevToolAdapter`] method failures.
124///
125/// Variants are kept narrow so the gateway and the `aa run` launcher can
126/// match on them and respond differently (e.g. `ToolNotFound` is
127/// surfaced as a friendly CLI error, while `Io` is logged and
128/// retried). The `#[from]` attribute on `Io` lets adapter implementations
129/// use the `?` operator with `std::io::Error` without manual
130/// `.map_err(...)` plumbing.
131///
132/// Gated on `feature = "std"` because [`AdapterError::SettingsApplyFailed`]
133/// and [`AdapterError::Io`] wrap [`std::io::Error`].
134///
135/// `#[non_exhaustive]` is kept so future variants can be added without
136/// breaking downstream callers that match on this enum.
137#[cfg(feature = "std")]
138#[derive(Debug, thiserror::Error)]
139#[non_exhaustive]
140pub enum AdapterError {
141    /// The tool's binary or installation marker could not be located on
142    /// the host.
143    #[error("dev tool not found on this host")]
144    ToolNotFound,
145
146    /// Detection failed for a reason other than the tool simply not being
147    /// installed (e.g. permission denied reading the install directory,
148    /// version probe failed).
149    #[error("dev tool detection failed: {0}")]
150    DetectionFailed(String),
151
152    /// The policy contained constructs the tool's native managed-settings
153    /// format cannot express. Returned by
154    /// [`DevToolAdapter::generate_managed_settings`].
155    #[error("managed-settings generation failed: {0}")]
156    SettingsGenerationFailed(String),
157
158    /// Writing rendered managed settings to the tool's configuration
159    /// surface failed. Returned by [`DevToolAdapter::apply_settings`].
160    #[error("managed-settings apply failed: {0}")]
161    SettingsApplyFailed(std::io::Error),
162
163    /// The tool's binary could not be located, or its argument format
164    /// cannot accommodate the launcher's governance wiring. Returned by
165    /// [`DevToolAdapter::build_launch_command`].
166    #[error("launch command construction failed: {0}")]
167    LaunchFailed(String),
168
169    /// The tool's MCP configuration surface could not be read or written
170    /// (malformed file, permission denied, schema mismatch). Returned
171    /// by [`DevToolAdapter::list_mcp_servers`] and
172    /// [`DevToolAdapter::apply_mcp_governance`].
173    #[error("MCP configuration failed: {0}")]
174    McpConfigFailed(String),
175
176    /// Generic I/O failure not covered by a more specific variant. The
177    /// `#[from]` attribute lets adapter implementations use `?` to
178    /// propagate `std::io::Error` directly.
179    #[error("I/O error: {0}")]
180    Io(#[from] std::io::Error),
181
182    /// Serialization or deserialization failure during managed-settings
183    /// or MCP-config rendering. Adapter implementations stringify their
184    /// underlying serde error (e.g. `serde_json::Error::to_string()`)
185    /// and pass the message in. Keeps `aa-core` free of a runtime
186    /// `serde_json` dependency.
187    #[error("serialization error: {0}")]
188    Serde(String),
189}
190
191/// Static metadata describing a detected AI dev tool installation.
192///
193/// Returned by `DevToolAdapter::detect` and used to drive registry
194/// decisions, managed-settings generation, and per-tool launch wiring.
195#[cfg(feature = "std")]
196#[derive(Debug, Clone)]
197#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
198pub struct DevToolInfo {
199    /// Concrete tool variant.
200    pub kind: DevToolKind,
201    /// Tool version string, if reported by the binary.
202    pub version: Option<String>,
203    /// Absolute path to the installed tool binary.
204    pub install_path: PathBuf,
205    /// Highest governance level this installation can operate at.
206    pub governance_level: GovernanceLevel,
207    /// Whether the tool exposes MCP server configuration we can govern.
208    pub supports_mcp: bool,
209    /// Whether the tool reads governance config from a managed-settings file.
210    pub supports_managed_settings: bool,
211}
212
213/// Per-tool integration contract for the dev tool governance framework.
214///
215/// Every per-tool adapter (Claude Code, Codex, GitHub Copilot, Windsurf
216/// Cascade, third-party SaaS coding agents) implements this trait. The
217/// gateway and the `aa run` launcher consume adapters via `dyn
218/// DevToolAdapter`, so the trait must be object-safe — that property is
219/// locked in by an explicit compile-time check added in AAASM-925.
220///
221/// Implementations live in their own crates / Stories and are out of
222/// scope for this Subtask (see AAASM-201 through AAASM-205, AAASM-918).
223///
224/// ## Async dispatch
225///
226/// The `async fn` methods are macro-desugared by `async_trait::async_trait`
227/// into boxed-future return types so that `dyn DevToolAdapter` is
228/// dyn-safe on stable Rust. The boxing cost is negligible compared to
229/// the I/O these methods perform (filesystem reads, subprocess writes,
230/// MCP discovery).
231#[cfg(feature = "std")]
232#[async_trait::async_trait]
233pub trait DevToolAdapter: Send + Sync {
234    /// Detect whether the tool this adapter targets is installed on the
235    /// current host.
236    ///
237    /// ### Contract
238    /// * Returns `Some(DevToolInfo)` when the tool's binary or
239    ///   well-known installation marker is present and readable.
240    /// * Returns `None` when the tool is not installed, is unreadable,
241    ///   or cannot be confirmed (e.g. the user lacks filesystem
242    ///   permission).
243    /// * Must not perform network I/O — detection runs at every CLI
244    ///   invocation and is on the hot path.
245    fn detect(&self) -> Option<DevToolInfo>;
246
247    /// Translate an Agent Assembly [`PolicyDocument`] into the tool's
248    /// native managed-settings format (e.g. JSON for Claude Code,
249    /// `.codex/config.toml` for Codex).
250    ///
251    /// ### Contract
252    /// * On success, returns the rendered settings document as a UTF-8
253    ///   string ready to be written by [`apply_settings`].
254    /// * Returns [`AdapterError::SettingsGenerationFailed`] when the
255    ///   policy contains constructs the tool's native config cannot
256    ///   express.
257    /// * Pure: must not touch the filesystem.
258    ///
259    /// [`PolicyDocument`]: crate::policy::PolicyDocument
260    /// [`apply_settings`]: Self::apply_settings
261    async fn generate_managed_settings(&self, policy: &crate::policy::PolicyDocument) -> Result<String, AdapterError>;
262
263    /// Write the rendered managed settings into the tool's
264    /// configuration surface, replacing any prior managed block.
265    ///
266    /// ### Contract
267    /// * On success, the tool will pick up the new policy on its next
268    ///   launch (some tools require a restart; the adapter is expected
269    ///   to document that in its own crate-level docs).
270    /// * Returns [`AdapterError::SettingsApplyFailed`] on filesystem error.
271    /// * Idempotent: applying the same `settings` twice is a no-op.
272    async fn apply_settings(&self, settings: &str) -> Result<(), AdapterError>;
273
274    /// Build the [`std::process::Command`] used by the `aa run` launcher
275    /// to start the tool with governance wiring (proxy, env vars,
276    /// agent identity, optional team identity).
277    ///
278    /// ### Contract
279    /// * Caller passes raw `tool_args` plus the agent and (optional)
280    ///   team identity that the gateway issued for this run.
281    /// * `proxy_addr`, when set, is the `host:port` of the local MitM
282    ///   proxy; the adapter must inject the appropriate
283    ///   `HTTPS_PROXY` / `OPENAI_BASE_URL` / similar env var so the
284    ///   tool routes traffic through it.
285    /// * Returns [`AdapterError::LaunchFailed`] when the tool's binary
286    ///   cannot be located or its argument format cannot accommodate
287    ///   the wiring.
288    /// * Sync (no I/O performed) — the returned `Command` is *built*,
289    ///   not spawned. Spawning is the launcher's job.
290    fn build_launch_command(
291        &self,
292        tool_args: &[String],
293        agent_id: &str,
294        team_id: Option<&str>,
295        proxy_addr: Option<&str>,
296    ) -> Result<std::process::Command, AdapterError>;
297
298    /// Enumerate the MCP servers the tool is currently configured to
299    /// connect to.
300    ///
301    /// ### Contract
302    /// * Returns the parsed list from the tool's native MCP config
303    ///   surface (e.g. `~/.claude/mcp_servers.json`).
304    /// * Returns an empty `Vec` (not an error) when the tool supports
305    ///   MCP but has no servers configured.
306    /// * Returns [`AdapterError::McpConfigFailed`] when the config
307    ///   exists but is malformed.
308    /// * Tools whose `DevToolInfo::supports_mcp == false` should
309    ///   instead return an empty `Vec`.
310    async fn list_mcp_servers(&self) -> Result<Vec<McpServerInfo>, AdapterError>;
311
312    /// Apply an MCP allow / deny list to the tool's configuration.
313    ///
314    /// ### Contract
315    /// * `allowed` lists MCP server names that are permitted; any
316    ///   server not in `allowed` and present in `denied` (or in the
317    ///   currently-configured set) must be removed from the tool's
318    ///   active config.
319    /// * `denied` is an explicit blocklist applied even if a server
320    ///   appears in `allowed` — `denied` wins on conflict (matches
321    ///   policy-engine evaluation order).
322    /// * Returns [`AdapterError::McpConfigFailed`] on filesystem write
323    ///   failure.
324    /// * Tools without MCP support should return `Ok(())` without
325    ///   performing any work.
326    async fn apply_mcp_governance(&self, allowed: &[String], denied: &[String]) -> Result<(), AdapterError>;
327
328    /// Highest governance level this adapter can achieve for the tool
329    /// it targets.
330    ///
331    /// ### Contract
332    /// * Returns the static, build-time-known cap for this adapter
333    ///   (e.g. an SDK-integrated adapter returns `L3Native`; a SaaS
334    ///   coding agent's observability-only adapter returns
335    ///   `L1Observe`).
336    /// * Must agree with `detect()`'s `DevToolInfo::governance_level`
337    ///   for any successful detection — gateway uses this to
338    ///   short-circuit policy decisions when an action would require
339    ///   a level the adapter cannot enforce.
340    fn governance_level(&self) -> GovernanceLevel;
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn governance_level_orders_l0_through_l3() {
349        assert!(GovernanceLevel::L0Discover < GovernanceLevel::L1Observe);
350        assert!(GovernanceLevel::L1Observe < GovernanceLevel::L2Enforce);
351        assert!(GovernanceLevel::L2Enforce < GovernanceLevel::L3Native);
352        assert!(GovernanceLevel::L0Discover < GovernanceLevel::L3Native);
353    }
354
355    #[cfg(all(feature = "serde", feature = "alloc"))]
356    #[test]
357    fn dev_tool_kind_round_trips_via_serde_json() {
358        let cases = [
359            DevToolKind::ClaudeCode,
360            DevToolKind::Codex,
361            DevToolKind::GitHubCopilot,
362            DevToolKind::WindsurfCascade,
363            DevToolKind::Custom(String::from("MyEditor")),
364        ];
365        for original in cases {
366            let json = serde_json::to_string(&original).expect("serialize");
367            let restored: DevToolKind = serde_json::from_str(&json).expect("deserialize");
368            assert_eq!(restored, original);
369        }
370    }
371
372    #[cfg(all(feature = "serde", feature = "std"))]
373    #[test]
374    fn dev_tool_info_round_trips_via_serde_json() {
375        let original = DevToolInfo {
376            kind: DevToolKind::ClaudeCode,
377            version: Some(String::from("1.2.3")),
378            install_path: PathBuf::from("/usr/local/bin/claude"),
379            governance_level: GovernanceLevel::L2Enforce,
380            supports_mcp: true,
381            supports_managed_settings: false,
382        };
383        let json1 = serde_json::to_string(&original).expect("serialize");
384        let restored: DevToolInfo = serde_json::from_str(&json1).expect("deserialize");
385        let json2 = serde_json::to_string(&restored).expect("re-serialize");
386        assert_eq!(json1, json2);
387    }
388
389    // ---- Trait conformance (locks DevToolAdapter's shape) -----------------
390    //
391    // These helpers are compile-time checks. The fact that they reference
392    // `&dyn DevToolAdapter` and `Box<dyn DevToolAdapter>` (which require
393    // `Send + Sync` because the trait inherits those bounds) is what
394    // exercises the assertions — if any future change makes the trait
395    // un-object-safe or drops `Send + Sync`, this module fails to compile.
396
397    #[cfg(feature = "std")]
398    fn _assert_object_safe(_: &dyn DevToolAdapter) {}
399
400    #[cfg(feature = "std")]
401    fn _assert_send_sync<T: Send + Sync>() {}
402
403    /// Compile-time conformance check for `DevToolAdapter`.
404    ///
405    /// Locks two invariants every implementor must satisfy:
406    /// 1. The trait is **object-safe** — `&dyn DevToolAdapter` and
407    ///    `Box<dyn DevToolAdapter>` both compile.
408    /// 2. Trait objects are `Send + Sync` — required so adapters can be
409    ///    stored in a global registry and called from the gateway's
410    ///    Tokio task pool.
411    ///
412    /// If either invariant breaks (someone adds a non-dyn-compatible
413    /// method, removes the `Send + Sync` bound on the trait, etc.) this
414    /// test will fail to compile — which is exactly what AAASM-925
415    /// requires it to do.
416    #[cfg(feature = "std")]
417    #[test]
418    fn trait_is_object_safe() {
419        let _: fn(&dyn DevToolAdapter) = _assert_object_safe;
420        _assert_send_sync::<Box<dyn DevToolAdapter>>();
421    }
422}