Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:47:31.409Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

sandbox_settings.rs

529 lines · 19.7 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.