Skip to repository content495 lines · 18.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:03:00.328Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
capability.rs
1//! What the Exo agent behind this lane is allowed to be. `OMEGA-DELTA-0042`,
2//! omega#87.
3//!
4//! Exo's differentiator is that its agent can rewrite itself. The flagship Exo
5//! agent runs unrestricted networked `shell` in a sandbox with Exo's own source
6//! tree mounted read-write, plus a `guardian_action` tool that builds and
7//! restarts Exo. There is no approval prompt anywhere in Exo — the security
8//! model is sandbox isolation, and Exo's threat model assumes you *want* the
9//! agent to modify itself.
10//!
11//! Omega supplies the authority gate Exo lacks. The ordinary lane refuses
12//! self-modification. A separate, typed, one-use grant can authorize one exact
13//! turn after a visible human confirmation. The lane never silently enables
14//! Exo's self-modification tools.
15//!
16//! # Read the agent, do not assume it
17//!
18//! "Never enables" is easy and nearly worthless on its own: the lane emits no
19//! configuring command (see [`crate::command`]), so of course it enables
20//! nothing. The capability that matters is the one the agent *already has*,
21//! configured by whoever set Exo up — and an Omega lane pointed at the flagship
22//! self-improving agent would be surfacing exactly the capability this packet
23//! excludes, without Omega having enabled anything.
24//!
25//! So the lane reads `exo agent show` before it sends a turn and refuses when
26//! the agent carries self-modification capability. That is a live observation of
27//! the agent that is about to run, not an assertion about Omega's own argv.
28//!
29//! Three capabilities are refused, and each is one of the three things
30//! `docs/RSI.md` and the flagship agent's own configuration use to rewrite Exo:
31//!
32//! * **agent-authored tools** — `tool_creation: enabled`, the agent writing
33//! TypeScript into `.exo/agent-tools/` at runtime;
34//! * **a tool module** — `typescript_tool_modules: N` where `N > 0`, which is
35//! how `guardian_action` is installed (`examples/exo/guardian-tools.ts`);
36//! * **a read-write mount** — a `sandbox_mounts` entry in `rw` mode, which is
37//! how Exo's source tree gets into the sandbox to be edited.
38//!
39//! Networking is deliberately **not** in that list. An agent with a shell and a
40//! network is a high-capability agent, and it is also what any useful coding
41//! agent is; refusing it would make the lane refuse everything and would say
42//! nothing about self-modification. It is reported, so a reader sees it, and it
43//! does not refuse.
44
45/// What `exo agent show` said about the agent this lane points at.
46///
47/// Every field is read out of Exo's own output. Nothing here is a default this
48/// file invented: a record Omega could not parse becomes
49/// [`ExoAgentReadError`], not a permissive record.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct ExoAgent {
52 /// The agent slug, as Exo reports it.
53 pub slug: String,
54 /// The executor Exo will run the turn with: `basic`, `rlm`, `typescript`,
55 /// `codex`, `claude-code`, `cursor`, or a module path.
56 pub harness: String,
57 /// The model binding the agent resolves to.
58 pub model: String,
59 /// Whether the agent may author its own tools at runtime.
60 pub agent_authored_tools: bool,
61 /// How many TypeScript tool modules are loaded into it.
62 pub tool_modules: u32,
63 /// Exact TypeScript tool-module paths reported by Exo.
64 pub tool_module_paths: Vec<String>,
65 /// Whether any sandbox mount is read-write.
66 pub read_write_mount: bool,
67 /// Exact agent-level sandbox mounts.
68 pub mounts: Vec<ExoMount>,
69 /// Whether the agent's sandbox has a network. Reported, never refused.
70 pub networking: bool,
71}
72
73/// A mount observed in an Exo agent or conversation record.
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub struct ExoMount {
76 pub host_path: String,
77 pub mount_path: String,
78 pub read_write: bool,
79}
80
81/// Capability-bearing fields from `exo conversation show`.
82#[derive(Clone, Debug, PartialEq, Eq)]
83pub struct ExoConversation {
84 pub slug: String,
85 pub mounts: Vec<ExoMount>,
86}
87
88/// Why the lane will not run a turn on this agent.
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub enum SelfModification {
91 /// `tool_creation: enabled`.
92 AgentAuthoredTools,
93 /// One or more `--tool-module` modules, the `guardian_action` shape.
94 ToolModule,
95 /// A read-write sandbox mount, the source-tree shape.
96 ReadWriteMount,
97}
98
99impl SelfModification {
100 /// The stable token this refusal is recorded under.
101 #[must_use]
102 pub const fn token(self) -> &'static str {
103 match self {
104 Self::AgentAuthoredTools => "agent_authored_tools",
105 Self::ToolModule => "tool_module",
106 Self::ReadWriteMount => "read_write_mount",
107 }
108 }
109
110 /// Every capability the lane refuses, in declaration order.
111 #[must_use]
112 pub const fn all() -> &'static [Self] {
113 &[
114 Self::AgentAuthoredTools,
115 Self::ToolModule,
116 Self::ReadWriteMount,
117 ]
118 }
119}
120
121impl std::fmt::Display for SelfModification {
122 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 formatter.write_str(match self {
124 Self::AgentAuthoredTools => {
125 "this Exo agent may write its own tools at runtime; the Omega lane does not \
126 surface Exo's self-modification"
127 }
128 Self::ToolModule => {
129 "this Exo agent has a tool module loaded, which is how guardian_action is \
130 installed; the Omega lane does not surface Exo's self-modification"
131 }
132 Self::ReadWriteMount => {
133 "this Exo agent has a read-write mount, which is how Exo's source tree is \
134 edited from inside the sandbox; the Omega lane does not surface Exo's \
135 self-modification"
136 }
137 })
138 }
139}
140
141impl std::error::Error for SelfModification {}
142
143/// Why `exo agent show` could not be read.
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
145pub enum ExoAgentReadError {
146 /// A field the decision depends on was not in the output.
147 MissingField(&'static str),
148 /// A field was present and this build could not read its value.
149 UnreadableField(&'static str),
150}
151
152impl std::fmt::Display for ExoAgentReadError {
153 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 match self {
155 Self::MissingField(field) => {
156 write!(formatter, "exo agent show did not report {field}")
157 }
158 Self::UnreadableField(field) => {
159 write!(formatter, "exo agent show reported an unreadable {field}")
160 }
161 }
162 }
163}
164
165impl std::error::Error for ExoAgentReadError {}
166
167impl ExoAgent {
168 /// Read `exo agent show` output.
169 ///
170 /// Fails closed on every shape it does not recognise. Exo's house rule is
171 /// that it does not keep backwards compatibility, so the realistic failure
172 /// is not a malicious record — it is upstream renaming `tool_creation` in a
173 /// commit nobody read, and a parser that defaulted the missing field to
174 /// `false` would answer "no self-modification" about an agent it could no
175 /// longer see. That is the one wrong answer this whole file exists to
176 /// prevent, so an absent field is an error and never a default.
177 ///
178 /// # Errors
179 ///
180 /// [`ExoAgentReadError`] when a load-bearing field is missing or unreadable.
181 pub fn parse(output: &str) -> Result<Self, ExoAgentReadError> {
182 let field = |name: &'static str| -> Result<&str, ExoAgentReadError> {
183 output
184 .lines()
185 .find_map(|line| line.strip_prefix(name)?.strip_prefix(':'))
186 .map(str::trim)
187 .ok_or(ExoAgentReadError::MissingField(name))
188 };
189
190 let tool_creation = match field("tool_creation")? {
191 "enabled" => true,
192 "disabled" => false,
193 _ => return Err(ExoAgentReadError::UnreadableField("tool_creation")),
194 };
195 let tool_modules: u32 = field("typescript_tool_modules")?
196 .parse()
197 .map_err(|_| ExoAgentReadError::UnreadableField("typescript_tool_modules"))?;
198 let networking = match field("enable_networking")? {
199 "true" => true,
200 "false" => false,
201 _ => return Err(ExoAgentReadError::UnreadableField("enable_networking")),
202 };
203
204 let mounts = mounts(output, "sandbox_mounts")?;
205 Ok(Self {
206 slug: field("slug")?.to_owned(),
207 harness: field("harness")?.to_owned(),
208 model: field("model")?.to_owned(),
209 agent_authored_tools: tool_creation,
210 tool_modules,
211 tool_module_paths: tool_module_paths(output, tool_modules)?,
212 read_write_mount: mounts.iter().any(|mount| mount.read_write),
213 mounts,
214 networking,
215 })
216 }
217
218 /// Whether the lane may run a turn on this agent.
219 ///
220 /// # Errors
221 ///
222 /// The first self-modification capability found, in
223 /// [`SelfModification::all`] order.
224 pub fn admits_lane_turn(&self) -> Result<(), SelfModification> {
225 if self.agent_authored_tools {
226 return Err(SelfModification::AgentAuthoredTools);
227 }
228 if self.tool_modules > 0 {
229 return Err(SelfModification::ToolModule);
230 }
231 if self.read_write_mount {
232 return Err(SelfModification::ReadWriteMount);
233 }
234 Ok(())
235 }
236}
237
238impl ExoConversation {
239 /// Parse the conversation record and retain exact mount paths.
240 pub fn parse(output: &str) -> Result<Self, ExoAgentReadError> {
241 let slug = output
242 .lines()
243 .find_map(|line| line.strip_prefix("slug:"))
244 .map(str::trim)
245 .filter(|value| !value.is_empty())
246 .ok_or(ExoAgentReadError::MissingField("slug"))?
247 .to_owned();
248 Ok(Self {
249 slug,
250 mounts: mounts(output, "mounts")?,
251 })
252 }
253
254 /// Whether the conversation itself can modify a mounted host path.
255 pub fn admits_lane_turn(&self) -> Result<(), SelfModification> {
256 if self.mounts.iter().any(|mount| mount.read_write) {
257 Err(SelfModification::ReadWriteMount)
258 } else {
259 Ok(())
260 }
261 }
262}
263
264fn tool_module_paths(output: &str, expected: u32) -> Result<Vec<String>, ExoAgentReadError> {
265 let mut lines = output.lines();
266 lines
267 .find(|line| line.starts_with("typescript_tool_modules:"))
268 .ok_or(ExoAgentReadError::MissingField("typescript_tool_modules"))?;
269 let paths = lines
270 .take_while(|line| line.starts_with(" - "))
271 .map(|line| line.trim_start_matches(" - ").to_owned())
272 .collect::<Vec<_>>();
273 if paths.len() != expected as usize {
274 return Err(ExoAgentReadError::UnreadableField(
275 "typescript_tool_modules",
276 ));
277 }
278 Ok(paths)
279}
280
281/// Read the `sandbox_mounts:` block.
282///
283/// Exo prints ` none` for an empty set and ` <host> -> <path> (ro)` or
284/// `(rw[, internal])` per mount. A block this build cannot read is an error for
285/// the same reason a missing field is.
286fn mounts(output: &str, field: &'static str) -> Result<Vec<ExoMount>, ExoAgentReadError> {
287 let mut lines = output.lines();
288 lines
289 .find(|line| line.trim_end() == format!("{field}:"))
290 .ok_or(ExoAgentReadError::MissingField(field))?;
291
292 let mut mounts = Vec::new();
293 for line in lines {
294 let Some(entry) = line.strip_prefix(" ") else {
295 break;
296 };
297 let entry = entry.trim();
298 if entry == "none" {
299 return Ok(Vec::new());
300 }
301 let Some((paths, mode)) = entry.rsplit_once(" (") else {
302 return Err(ExoAgentReadError::UnreadableField(field));
303 };
304 let mode = mode.trim_end_matches(')');
305 let read_write = match mode.split(',').next().map(str::trim) {
306 Some("rw") => true,
307 Some("ro") => false,
308 _ => return Err(ExoAgentReadError::UnreadableField(field)),
309 };
310 let Some((host_path, mount_path)) = paths.split_once(" -> ") else {
311 return Err(ExoAgentReadError::UnreadableField(field));
312 };
313 mounts.push(ExoMount {
314 host_path: host_path.to_owned(),
315 mount_path: mount_path.to_owned(),
316 read_write,
317 });
318 }
319 Ok(mounts)
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 /// Real `exo agent show` output, captured from the pinned Exo at
327 /// `baa07f67` on 2026-07-25. Not a hand-written fixture: this is what the
328 /// binary printed for the agent the lane was driven against.
329 const DRIVEN_AGENT: &str = "\
330id: 019f9cc4-0bf8-7fa2-8a48-eb84460661c1
331slug: omega-lane
332name: OmegaLane
333harness: basic
334typescript_module: none
335typescript_tool_modules: 0
336tool_creation: disabled
337sandbox_image: default
338sandbox_provider: apple-container
339sandbox_scope: conversation
340enable_networking: false
341sandbox_mounts:
342 none
343model: gpt5mini
344max_output_tokens: none
345max_tool_round_trips: none
346braintrust: none
347";
348
349 #[test]
350 fn the_agent_the_lane_was_driven_against_is_admitted() {
351 let agent = ExoAgent::parse(DRIVEN_AGENT).expect("real output parses");
352 assert_eq!(agent.slug, "omega-lane");
353 assert_eq!(agent.harness, "basic");
354 assert_eq!(agent.model, "gpt5mini");
355 assert!(!agent.agent_authored_tools);
356 assert_eq!(agent.tool_modules, 0);
357 assert!(!agent.read_write_mount);
358 assert!(!agent.networking);
359 assert_eq!(agent.admits_lane_turn(), Ok(()));
360 }
361
362 /// The flagship Exo agent, which is the thing this refusal exists for: the
363 /// source tree mounted read-write, `guardian-tools.ts` loaded, and runtime
364 /// tool authoring on.
365 #[test]
366 fn the_self_improving_exo_agent_is_refused() {
367 let self_improving = DRIVEN_AGENT
368 .replace("tool_creation: disabled", "tool_creation: enabled")
369 .replace(
370 "typescript_tool_modules: 0",
371 "typescript_tool_modules: 1\n - /workspace/exo/examples/exo/guardian-tools.ts",
372 )
373 .replace(
374 "sandbox_mounts:\n none",
375 "sandbox_mounts:\n /Users/x/exo -> /workspace/exo (rw)",
376 );
377 let agent = ExoAgent::parse(&self_improving).expect("parses");
378 assert_eq!(
379 agent.admits_lane_turn(),
380 Err(SelfModification::AgentAuthoredTools)
381 );
382 }
383
384 /// Each capability refuses on its own, so removing any one of the three
385 /// checks fails a test rather than being masked by the other two.
386 #[test]
387 fn each_self_modification_capability_refuses_on_its_own() {
388 let cases = [
389 (
390 "tool_creation: disabled",
391 "tool_creation: enabled",
392 SelfModification::AgentAuthoredTools,
393 ),
394 (
395 "typescript_tool_modules: 0",
396 "typescript_tool_modules: 2\n - /tools/one.ts\n - /tools/two.ts",
397 SelfModification::ToolModule,
398 ),
399 (
400 "sandbox_mounts:\n none",
401 "sandbox_mounts:\n /Users/x/exo -> /workspace/exo (rw, internal)",
402 SelfModification::ReadWriteMount,
403 ),
404 ];
405 assert_eq!(cases.len(), SelfModification::all().len());
406 for (from, to, expected) in cases {
407 let agent = ExoAgent::parse(&DRIVEN_AGENT.replace(from, to)).expect("parses");
408 assert_eq!(agent.admits_lane_turn(), Err(expected), "{to}");
409 }
410 }
411
412 /// A read-only mount is not self-modification. Refusing it would make the
413 /// gate refuse an ordinary working directory and say nothing about Exo
414 /// rewriting itself.
415 #[test]
416 fn a_read_only_mount_is_not_self_modification() {
417 let agent = ExoAgent::parse(&DRIVEN_AGENT.replace(
418 "sandbox_mounts:\n none",
419 "sandbox_mounts:\n /Users/x/project -> /workspace/project (ro)",
420 ))
421 .expect("parses");
422 assert!(!agent.read_write_mount);
423 assert_eq!(agent.admits_lane_turn(), Ok(()));
424 }
425
426 /// Networking is reported and does not refuse. See the module docs.
427 #[test]
428 fn a_networked_agent_is_reported_and_not_refused() {
429 let agent = ExoAgent::parse(
430 &DRIVEN_AGENT.replace("enable_networking: false", "enable_networking: true"),
431 )
432 .expect("parses");
433 assert!(agent.networking);
434 assert_eq!(agent.admits_lane_turn(), Ok(()));
435 }
436
437 #[test]
438 fn a_conversation_only_read_write_mount_is_refused() {
439 let shown = "\
440slug: omega-lane
441mounts:
442 /Users/x/exo -> /workspace/exo (rw)
443";
444 let conversation = ExoConversation::parse(shown).expect("conversation parses");
445 assert_eq!(
446 conversation.admits_lane_turn(),
447 Err(SelfModification::ReadWriteMount)
448 );
449 assert_eq!(conversation.mounts[0].host_path, "/Users/x/exo");
450 }
451
452 /// The upstream-rename failure, which is the realistic one. A field the
453 /// decision depends on going missing must not read as "no capability".
454 #[test]
455 fn a_renamed_field_refuses_to_parse_rather_than_defaulting_to_permissive() {
456 for field in [
457 "tool_creation",
458 "typescript_tool_modules",
459 "sandbox_mounts",
460 "harness",
461 "model",
462 "slug",
463 "enable_networking",
464 ] {
465 let renamed = DRIVEN_AGENT
466 .lines()
467 .filter(|line| !line.starts_with(&format!("{field}:")))
468 .collect::<Vec<_>>()
469 .join("\n");
470 let read = ExoAgent::parse(&renamed);
471 assert!(
472 read.is_err(),
473 "dropping {field} still parsed, and the record would claim no capability: {read:?}"
474 );
475 }
476 }
477
478 #[test]
479 fn an_unreadable_value_is_refused_rather_than_read_as_false() {
480 for (from, to) in [
481 ("tool_creation: disabled", "tool_creation: maybe"),
482 (
483 "typescript_tool_modules: 0",
484 "typescript_tool_modules: some",
485 ),
486 ("enable_networking: false", "enable_networking: yes"),
487 ] {
488 assert!(
489 ExoAgent::parse(&DRIVEN_AGENT.replace(from, to)).is_err(),
490 "{to}"
491 );
492 }
493 }
494}
495