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}