Skip to repository content529 lines · 19.7 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:47:31.409Z 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
sandbox_settings.rs
1use std::path::PathBuf;
2
3use agent_settings::AgentSettings;
4use gpui::{ReadGlobal as _, ScrollHandle, prelude::*};
5use http_proxy::HostPattern;
6use settings::{Settings as _, SettingsStore};
7use ui::{Banner, Divider, Severity, SwitchField, ToggleState, Tooltip, prelude::*};
8use util::ResultExt as _;
9
10use crate::SettingsWindow;
11use crate::components::{SettingsInputField, SettingsSectionHeader};
12
13const DOMAINS_DESCRIPTION: &str = "Each entry is an exact domain (github.com) or a leading-*. subdomain wildcard (*.npmjs.org). IP addresses and local domains are not allowed.";
14
15const WRITE_PATHS_DESCRIPTION: &str = "Each entry must be an absolute path and grants write access to the whole subtree, except protected Git metadata.";
16
17pub(crate) fn render_sandbox_settings_page(
18 settings_window: &SettingsWindow,
19 scroll_handle: &ScrollHandle,
20 _window: &mut Window,
21 cx: &mut Context<SettingsWindow>,
22) -> AnyElement {
23 // Sandbox permissions are a user-level setting; they aren't configurable
24 // per-project, so always operate against the global value here.
25 let permissions = AgentSettings::get_global(cx).sandbox_permissions.clone();
26 let validation_error = settings_window.sandbox_host_validation_error.clone();
27
28 // Read the list values from the raw user settings content rather than the
29 // compiled `AgentSettings`. The compiled `write_paths` are lexically
30 // normalized (see `compile_sandbox_permissions`), so editing or removing a
31 // row by the normalized value would fail to match the literal entry stored
32 // in settings.json and silently leave the permission in place.
33 let (network_hosts, write_paths) = raw_sandbox_lists(cx);
34
35 let host_rows: Vec<AnyElement> = network_hosts
36 .into_iter()
37 .enumerate()
38 .map(|(index, host)| render_host_row(index, host, cx))
39 .collect();
40 let add_host_input = render_add_host_input(cx);
41
42 let path_rows: Vec<AnyElement> = write_paths
43 .into_iter()
44 .enumerate()
45 .map(|(index, path)| render_path_row(index, path, cx))
46 .collect();
47 let add_path_input = render_add_path_input(cx);
48
49 let empty_border = cx.theme().colors().border_variant;
50 let sandbox_enabled = !permissions.allow_unsandboxed;
51
52 v_flex()
53 .id("sandbox-settings-page")
54 .size_full()
55 .pt_2p5()
56 .px_8()
57 .pb_16()
58 .gap_6()
59 .overflow_y_scroll()
60 .track_scroll(scroll_handle)
61 .child(
62 SwitchField::new(
63 "sandbox-enabled",
64 Some("Enable Sandbox"),
65 Some(
66 "Wrap agent-run terminal commands in an OS-level sandbox. When off, commands run with Omega's own permissions."
67 .into(),
68 ),
69 sandbox_enabled,
70 move |state, _window, cx| {
71 set_sandbox_enabled(*state == ToggleState::Selected, cx);
72 },
73 )
74 .tab_index(0),
75 )
76 .child({
77 let docs_url =
78 client::zed_urls::sandboxing_docs(Some("persistent-sandbox-permissions"), cx);
79 let tooltip = format!("Opens {docs_url}");
80 // Wrap in a row so the button shrinks to its content width instead
81 // of stretching across the settings page.
82 h_flex().child(
83 Button::new("sandbox-docs-link", "Learn more about sandboxing")
84 .label_size(LabelSize::Small)
85 .color(Color::Muted)
86 .end_icon(
87 Icon::new(IconName::ArrowUpRight)
88 .color(Color::Muted)
89 .size(IconSize::XSmall),
90 )
91 .tooltip(Tooltip::text(tooltip))
92 .on_click(move |_, _, cx| cx.open_url(&docs_url)),
93 )
94 })
95 .when(sandbox_enabled, |this| this
96 .when_some(validation_error, |this, error| {
97 this.child(
98 Banner::new()
99 .severity(Severity::Warning)
100 .child(Label::new(error).size(LabelSize::Small))
101 .action_slot(
102 Button::new("dismiss-sandbox-host-error", "Dismiss")
103 .style(ButtonStyle::Tinted(ui::TintColor::Warning))
104 .on_click(cx.listener(|this, _, _, cx| {
105 this.sandbox_host_validation_error = None;
106 cx.notify();
107 })),
108 ),
109 )
110 })
111 .child(
112 v_flex()
113 .gap_4()
114 .child(SettingsSectionHeader::new("Network").no_padding(true))
115 .child(
116 SwitchField::new(
117 "sandbox-allow-all-hosts",
118 Some("Allow All Domains"),
119 Some(
120 "Let sandboxed commands reach any domain over the network without prompting."
121 .into(),
122 ),
123 permissions.allow_all_hosts,
124 move |state, _window, cx| {
125 set_allow_all_hosts(*state == ToggleState::Selected, cx);
126 },
127 )
128 .tab_index(0),
129 )
130 .child(render_list_section(
131 "Allowed Domains",
132 DOMAINS_DESCRIPTION,
133 host_rows,
134 add_host_input,
135 empty_border,
136 )),
137 )
138
139 .child(Divider::horizontal())
140 .child(
141 v_flex()
142 .gap_4()
143 .child(SettingsSectionHeader::new("File System").no_padding(true))
144 .child(
145 SwitchField::new(
146 "sandbox-allow-fs-write-all",
147 Some("Allow All File System Writes"),
148 Some(
149 "Let sandboxed commands write anywhere except protected Git metadata without prompting."
150 .into(),
151 ),
152 permissions.allow_fs_write_all,
153 move |state, _window, cx| {
154 set_allow_fs_write_all(*state == ToggleState::Selected, cx);
155 },
156 )
157 .tab_index(0),
158 )
159 .child(render_list_section(
160 "Writable Paths",
161 WRITE_PATHS_DESCRIPTION,
162 path_rows,
163 add_path_input,
164 empty_border,
165 )),
166 )
167 .child(Divider::horizontal())
168 .child(
169 v_flex()
170 .gap_4()
171 .child(SettingsSectionHeader::new("Escalation Prompts").no_padding(true))
172 .child(
173 SwitchField::new(
174 "sandbox-warn-confusable-unicode",
175 Some("Warn About Confusable Unicode"),
176 Some(
177 "Warn when an approval prompt requests a domain or write path that contains potentially confusable Unicode characters, such as homoglyphs (i.e. two symbols that look similar, such as a Cyrillic `а`)"
178 .into(),
179 ),
180 permissions.warn_confusable_unicode,
181 move |state, _window, cx| {
182 set_warn_confusable_unicode(*state == ToggleState::Selected, cx);
183 },
184 )
185 .tab_index(0),
186 ),
187 )
188 )
189 .into_any_element()
190}
191
192fn render_list_section(
193 title: &'static str,
194 description: &'static str,
195 rows: Vec<AnyElement>,
196 add_input: AnyElement,
197 empty_border: gpui::Hsla,
198) -> impl IntoElement {
199 let is_empty = rows.is_empty();
200
201 v_flex()
202 .gap_0p5()
203 .child(Label::new(title))
204 .child(
205 Label::new(description)
206 .size(LabelSize::Small)
207 .color(Color::Muted),
208 )
209 .child(
210 v_flex()
211 .mt_2()
212 .w_full()
213 .gap_1p5()
214 .when(is_empty, |this| {
215 this.child(render_empty_state(empty_border))
216 })
217 .when(!is_empty, |this| {
218 this.child(v_flex().gap_1p5().children(rows))
219 })
220 .child(add_input),
221 )
222}
223
224fn render_empty_state(border_color: gpui::Hsla) -> AnyElement {
225 h_flex()
226 .p_2()
227 .rounded_md()
228 .border_1()
229 .border_dashed()
230 .border_color(border_color)
231 .child(
232 Label::new("Nothing configured")
233 .size(LabelSize::Small)
234 .color(Color::Disabled),
235 )
236 .into_any_element()
237}
238
239fn render_host_row(index: usize, host: String, cx: &mut Context<SettingsWindow>) -> AnyElement {
240 let host_for_delete = host.clone();
241 let host_for_update = host.clone();
242 let settings_window = cx.entity().downgrade();
243
244 SettingsInputField::new(format!("sandbox-host-{}", index))
245 .with_initial_text(host)
246 .tab_index(0)
247 .with_buffer_font()
248 .color(Color::Default)
249 .action_slot(
250 IconButton::new(format!("sandbox-host-delete-{}", index), IconName::Trash)
251 .icon_size(IconSize::Small)
252 .icon_color(Color::Muted)
253 .tooltip(Tooltip::text("Remove Domain"))
254 .on_click(cx.listener(move |_, _, _, cx| {
255 remove_network_host(host_for_delete.clone(), cx);
256 })),
257 )
258 .on_confirm(move |new_host, _window, cx| {
259 let Some(new_host) = new_host else {
260 return;
261 };
262 let new_host = new_host.trim().to_string();
263 if new_host.is_empty() || new_host == host_for_update {
264 return;
265 }
266 let result = canonicalize_host(&new_host);
267 settings_window
268 .update(cx, |this, cx| {
269 match result {
270 Ok(canonical) => {
271 this.sandbox_host_validation_error = None;
272 update_network_host(host_for_update.clone(), canonical, cx);
273 }
274 Err(error) => {
275 this.sandbox_host_validation_error = Some(error);
276 }
277 }
278 cx.notify();
279 })
280 .log_err();
281 })
282 .into_any_element()
283}
284
285fn render_add_host_input(cx: &mut Context<SettingsWindow>) -> AnyElement {
286 let settings_window = cx.entity().downgrade();
287
288 SettingsInputField::new("sandbox-host-new")
289 .with_placeholder("Add domain (e.g. github.com or *.npmjs.org)…")
290 .tab_index(0)
291 .with_buffer_font()
292 .display_clear_button()
293 .display_confirm_button()
294 .clear_on_confirm()
295 .on_confirm(move |host, _window, cx| {
296 let Some(host) = host else {
297 return;
298 };
299 let host = host.trim().to_string();
300 if host.is_empty() {
301 return;
302 }
303 let result = canonicalize_host(&host);
304 settings_window
305 .update(cx, |this, cx| {
306 match result {
307 Ok(canonical) => {
308 this.sandbox_host_validation_error = None;
309 add_network_host(canonical, cx);
310 }
311 Err(error) => {
312 this.sandbox_host_validation_error = Some(error);
313 }
314 }
315 cx.notify();
316 })
317 .log_err();
318 })
319 .into_any_element()
320}
321
322fn render_path_row(index: usize, path: PathBuf, cx: &mut Context<SettingsWindow>) -> AnyElement {
323 let path_for_delete = path.clone();
324 let path_for_update = path.clone();
325 let settings_window = cx.entity().downgrade();
326
327 SettingsInputField::new(format!("sandbox-path-{}", index))
328 .with_initial_text(path.to_string_lossy().into_owned())
329 .tab_index(0)
330 .with_buffer_font()
331 .color(Color::Default)
332 .action_slot(
333 IconButton::new(format!("sandbox-path-delete-{}", index), IconName::Trash)
334 .icon_size(IconSize::Small)
335 .icon_color(Color::Muted)
336 .tooltip(Tooltip::text("Remove Path"))
337 .on_click(cx.listener(move |_, _, _, cx| {
338 remove_write_path(path_for_delete.clone(), cx);
339 })),
340 )
341 .on_confirm(move |new_path, _window, cx| {
342 let Some(new_path) = new_path else {
343 return;
344 };
345 let new_path = new_path.trim();
346 if new_path.is_empty() {
347 return;
348 }
349 let new_path = PathBuf::from(new_path);
350 if new_path == path_for_update {
351 return;
352 }
353 update_write_path(path_for_update.clone(), new_path, cx);
354 settings_window.update(cx, |_, cx| cx.notify()).log_err();
355 })
356 .into_any_element()
357}
358
359fn render_add_path_input(cx: &mut Context<SettingsWindow>) -> AnyElement {
360 let settings_window = cx.entity().downgrade();
361
362 SettingsInputField::new("sandbox-path-new")
363 .with_placeholder("Add an absolute path (e.g. /path/to/directory)…")
364 .tab_index(0)
365 .with_buffer_font()
366 .display_clear_button()
367 .display_confirm_button()
368 .clear_on_confirm()
369 .on_confirm(move |path, _window, cx| {
370 let Some(path) = path else {
371 return;
372 };
373 let path = path.trim();
374 if path.is_empty() {
375 return;
376 }
377 add_write_path(PathBuf::from(path), cx);
378 settings_window.update(cx, |_, cx| cx.notify()).log_err();
379 })
380 .into_any_element()
381}
382
383/// The literal host and write-path lists as stored in user settings.json. These
384/// are the exact strings/paths that edits and removals must match against.
385fn raw_sandbox_lists(cx: &App) -> (Vec<String>, Vec<PathBuf>) {
386 let store = SettingsStore::global(cx);
387 let permissions = store
388 .raw_user_settings()
389 .and_then(|user| user.content.agent.as_ref())
390 .and_then(|agent| agent.sandbox_permissions.as_ref());
391
392 let network_hosts = permissions
393 .and_then(|permissions| permissions.network_hosts.as_ref())
394 .map(|hosts| hosts.0.clone())
395 .unwrap_or_default();
396 let write_paths = permissions
397 .and_then(|permissions| permissions.write_paths.as_ref())
398 .map(|paths| paths.0.clone())
399 .unwrap_or_default();
400
401 (network_hosts, write_paths)
402}
403
404/// Validate and canonicalize a user-provided domain, returning either the
405/// canonical form to persist or a domain-friendly error to surface.
406fn canonicalize_host(host: &str) -> Result<String, String> {
407 use http_proxy::HostPatternError;
408
409 HostPattern::parse(host)
410 .map(|pattern| pattern.to_string())
411 .map_err(|error| match error {
412 HostPatternError::Empty => "Domain cannot be empty.".to_string(),
413 HostPatternError::IpLiteral(_) => {
414 "IP addresses and local domains aren't allowed; enter a domain like github.com."
415 .to_string()
416 }
417 HostPatternError::InvalidWildcard(_) => {
418 "Wildcards are only allowed as a leading label, e.g. *.github.com.".to_string()
419 }
420 HostPatternError::Invalid { .. } => {
421 "Not a valid domain. Use a domain like github.com or *.npmjs.org.".to_string()
422 }
423 })
424}
425
426fn update_sandbox_permissions(
427 cx: &mut App,
428 update: impl 'static + Send + FnOnce(&mut settings::SandboxPermissionsContent),
429) {
430 SettingsStore::global(cx).update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _| {
431 update(
432 settings
433 .agent
434 .get_or_insert_default()
435 .sandbox_permissions
436 .get_or_insert_default(),
437 );
438 });
439}
440
441fn set_sandbox_enabled(value: bool, cx: &mut App) {
442 // The UI presents an "enabled" switch, but the stored setting is the
443 // inverse (`allow_unsandboxed`).
444 update_sandbox_permissions(cx, move |permissions| {
445 permissions.allow_unsandboxed = Some(!value);
446 });
447}
448
449fn set_allow_all_hosts(value: bool, cx: &mut App) {
450 update_sandbox_permissions(cx, move |permissions| {
451 permissions.allow_all_hosts = Some(value);
452 });
453}
454
455fn set_allow_fs_write_all(value: bool, cx: &mut App) {
456 update_sandbox_permissions(cx, move |permissions| {
457 permissions.allow_fs_write_all = Some(value);
458 });
459}
460
461fn set_warn_confusable_unicode(value: bool, cx: &mut App) {
462 update_sandbox_permissions(cx, move |permissions| {
463 permissions.warn_confusable_unicode = Some(value);
464 });
465}
466
467fn add_network_host(host: String, cx: &mut App) {
468 update_sandbox_permissions(cx, move |permissions| {
469 let hosts = &mut permissions.network_hosts.get_or_insert_default().0;
470 if !hosts.contains(&host) {
471 hosts.push(host);
472 }
473 });
474}
475
476fn update_network_host(old_host: String, new_host: String, cx: &mut App) {
477 update_sandbox_permissions(cx, move |permissions| {
478 let hosts = &mut permissions.network_hosts.get_or_insert_default().0;
479 if hosts.contains(&new_host) {
480 return;
481 }
482 if let Some(entry) = hosts.iter_mut().find(|host| **host == old_host) {
483 *entry = new_host;
484 }
485 });
486}
487
488fn remove_network_host(host: String, cx: &mut App) {
489 update_sandbox_permissions(cx, move |permissions| {
490 if let Some(hosts) = permissions.network_hosts.as_mut() {
491 hosts.0.retain(|entry| *entry != host);
492 }
493 });
494}
495
496fn add_write_path(path: PathBuf, cx: &mut App) {
497 // Normalize away `.`/`..` so the stored entry matches the form the runtime
498 // uses for coverage checks (see `compile_sandbox_permissions`) and the form
499 // persisted by the in-thread "Allow always" grant.
500 let Ok(path) = util::paths::normalize_lexically(&path) else {
501 return;
502 };
503 update_sandbox_permissions(cx, move |permissions| {
504 let paths = &mut permissions.write_paths.get_or_insert_default().0;
505 // Store minimal subtrees so a parent path subsumes its descendants.
506 util::paths::insert_subtree(paths, path);
507 });
508}
509
510fn update_write_path(old_path: PathBuf, new_path: PathBuf, cx: &mut App) {
511 let Ok(new_path) = util::paths::normalize_lexically(&new_path) else {
512 return;
513 };
514 update_sandbox_permissions(cx, move |permissions| {
515 if let Some(paths) = permissions.write_paths.as_mut() {
516 paths.0.retain(|entry| *entry != old_path);
517 util::paths::insert_subtree(&mut paths.0, new_path);
518 }
519 });
520}
521
522fn remove_write_path(path: PathBuf, cx: &mut App) {
523 update_sandbox_permissions(cx, move |permissions| {
524 if let Some(paths) = permissions.write_paths.as_mut() {
525 paths.0.retain(|entry| *entry != path);
526 }
527 });
528}
529