Skip to content

Commit 1d2fb8c

Browse files
feat: add access mode selection to TUI session creation (shepherdjerred#173)
* feat: add access mode selection to TUI session creation Add read-only vs read-write access mode selector to the TUI session creation dialog. Users can now choose the access mode when creating sessions, with read-only as the secure default. - Add AccessMode field to CreateDialogState - Add access mode radio selector to create dialog UI - Wire up keyboard navigation (Tab, Space, Arrow keys) - Apply selected mode to session creation requests - Default to ReadOnly for security (principle of least privilege) For Docker sessions, this controls the per-session HTTP proxy filtering to block write operations (POST, PUT, DELETE, PATCH) when read-only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: change AccessMode default to ReadOnly for consistent security Address code review feedback - ensures all code paths (TUI, CLI, API) get the secure ReadOnly default unless explicitly overridden. This fixes a security inconsistency where Default::default() would return ReadWrite while the TUI defaulted to ReadOnly. - Update Default impl to return ReadOnly (principle of least privilege) - Update TUI to use Default::default() for consistency --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 2888060 commit 1d2fb8c

4 files changed

Lines changed: 46 additions & 11 deletions

File tree

packages/multiplexer/src/core/session.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ pub enum AccessMode {
257257

258258
impl Default for AccessMode {
259259
fn default() -> Self {
260-
Self::ReadWrite
260+
Self::ReadOnly // Principle of least privilege - secure default
261261
}
262262
}
263263

packages/multiplexer/src/tui/app.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use tokio::task::JoinHandle;
88
use uuid::Uuid;
99

1010
use crate::api::{ApiClient, Client};
11-
use crate::core::Session;
11+
use crate::core::{AccessMode, Session};
1212
use crate::tui::attached::PtySession;
1313

1414
/// Progress update from background session creation task
@@ -66,6 +66,7 @@ pub enum CreateDialogFocus {
6666
Prompt,
6767
RepoPath,
6868
Backend,
69+
AccessMode,
6970
SkipChecks,
7071
PlanMode,
7172
Buttons,
@@ -116,6 +117,7 @@ pub struct CreateDialogState {
116117
pub backend_zellij: bool, // true = Zellij, false = Docker
117118
pub skip_checks: bool,
118119
pub plan_mode: bool,
120+
pub access_mode: AccessMode,
119121

120122
/// Image file paths to attach to the prompt.
121123
///
@@ -339,6 +341,7 @@ impl CreateDialogState {
339341
backend_zellij: true, // Default to Zellij
340342
skip_checks: false,
341343
plan_mode: true, // Default to plan mode ON
344+
access_mode: Default::default(), // ReadOnly by default (secure)
342345
images: Vec::new(),
343346
prompt_scroll_offset: 0,
344347
focus: CreateDialogFocus::default(),
@@ -362,6 +365,14 @@ impl CreateDialogState {
362365
self.skip_checks = !self.backend_zellij;
363366
}
364367

368+
/// Toggle between ReadOnly and ReadWrite access modes
369+
pub fn toggle_access_mode(&mut self) {
370+
self.access_mode = match self.access_mode {
371+
AccessMode::ReadOnly => AccessMode::ReadWrite,
372+
AccessMode::ReadWrite => AccessMode::ReadOnly,
373+
};
374+
}
375+
365376
/// Scroll the prompt field up
366377
pub fn scroll_prompt_up(&mut self) {
367378
if self.prompt_scroll_offset > 0 {
@@ -724,7 +735,7 @@ impl App {
724735
dangerous_skip_checks: self.create_dialog.skip_checks,
725736
print_mode: false, // TUI always uses interactive mode
726737
plan_mode: self.create_dialog.plan_mode,
727-
access_mode: Default::default(),
738+
access_mode: self.create_dialog.access_mode,
728739
images: self.create_dialog.images.clone(),
729740
};
730741

packages/multiplexer/src/tui/components/create_dialog.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use ratatui::{
66
widgets::{Block, Borders, Clear, Paragraph, Wrap},
77
};
88

9+
use crate::core::AccessMode;
910
use crate::tui::app::{App, CreateDialogFocus};
1011

1112
/// Render the create session dialog
@@ -38,6 +39,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) {
3839
Constraint::Length(prompt_height as u16 + 2), // Prompt (dynamic + borders)
3940
Constraint::Length(3), // Repo path
4041
Constraint::Length(2), // Backend
42+
Constraint::Length(2), // Access mode
4143
Constraint::Length(2), // Skip checks
4244
Constraint::Length(2), // Plan mode
4345
Constraint::Length(1), // Spacer
@@ -86,13 +88,25 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) {
8688
inner[3],
8789
);
8890

91+
// Access mode selection
92+
render_radio_field(
93+
frame,
94+
"Access Mode",
95+
&[
96+
("Read-Only", dialog.access_mode == AccessMode::ReadOnly),
97+
("Read-Write", dialog.access_mode == AccessMode::ReadWrite),
98+
],
99+
dialog.focus == CreateDialogFocus::AccessMode,
100+
inner[4],
101+
);
102+
89103
// Skip checks checkbox
90104
render_checkbox_field(
91105
frame,
92106
"Dangerously skip checks",
93107
dialog.skip_checks,
94108
dialog.focus == CreateDialogFocus::SkipChecks,
95-
inner[4],
109+
inner[5],
96110
);
97111

98112
// Plan mode checkbox
@@ -101,15 +115,15 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) {
101115
"Start in plan mode",
102116
dialog.plan_mode,
103117
dialog.focus == CreateDialogFocus::PlanMode,
104-
inner[5],
118+
inner[6],
105119
);
106120

107121
// Buttons
108122
render_buttons(
109123
frame,
110124
dialog.focus == CreateDialogFocus::Buttons,
111125
dialog.button_create_focused,
112-
inner[7],
126+
inner[8],
113127
);
114128

115129
// Render directory picker overlay if active

packages/multiplexer/src/tui/events.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,8 @@ async fn handle_create_dialog_key(app: &mut App, key: KeyEvent) -> anyhow::Resul
245245
CreateDialogFocus::Name => CreateDialogFocus::Prompt,
246246
CreateDialogFocus::Prompt => CreateDialogFocus::RepoPath,
247247
CreateDialogFocus::RepoPath => CreateDialogFocus::Backend,
248-
CreateDialogFocus::Backend => CreateDialogFocus::SkipChecks,
248+
CreateDialogFocus::Backend => CreateDialogFocus::AccessMode,
249+
CreateDialogFocus::AccessMode => CreateDialogFocus::SkipChecks,
249250
CreateDialogFocus::SkipChecks => CreateDialogFocus::PlanMode,
250251
CreateDialogFocus::PlanMode => CreateDialogFocus::Buttons,
251252
CreateDialogFocus::Buttons => CreateDialogFocus::Name,
@@ -258,7 +259,8 @@ async fn handle_create_dialog_key(app: &mut App, key: KeyEvent) -> anyhow::Resul
258259
CreateDialogFocus::Prompt => CreateDialogFocus::Name,
259260
CreateDialogFocus::RepoPath => CreateDialogFocus::Prompt,
260261
CreateDialogFocus::Backend => CreateDialogFocus::RepoPath,
261-
CreateDialogFocus::SkipChecks => CreateDialogFocus::Backend,
262+
CreateDialogFocus::AccessMode => CreateDialogFocus::Backend,
263+
CreateDialogFocus::SkipChecks => CreateDialogFocus::AccessMode,
262264
CreateDialogFocus::PlanMode => CreateDialogFocus::SkipChecks,
263265
CreateDialogFocus::Buttons => CreateDialogFocus::PlanMode,
264266
};
@@ -299,7 +301,7 @@ async fn handle_create_dialog_key(app: &mut App, key: KeyEvent) -> anyhow::Resul
299301
dangerous_skip_checks: app.create_dialog.skip_checks,
300302
print_mode: false, // TUI always uses interactive mode
301303
plan_mode: app.create_dialog.plan_mode,
302-
access_mode: Default::default(),
304+
access_mode: app.create_dialog.access_mode,
303305
images: app.create_dialog.images.clone(),
304306
};
305307

@@ -380,7 +382,8 @@ async fn handle_create_dialog_key(app: &mut App, key: KeyEvent) -> anyhow::Resul
380382
CreateDialogFocus::Prompt => CreateDialogFocus::Name,
381383
CreateDialogFocus::RepoPath => CreateDialogFocus::Prompt,
382384
CreateDialogFocus::Backend => CreateDialogFocus::RepoPath,
383-
CreateDialogFocus::SkipChecks => CreateDialogFocus::Backend,
385+
CreateDialogFocus::AccessMode => CreateDialogFocus::Backend,
386+
CreateDialogFocus::SkipChecks => CreateDialogFocus::AccessMode,
384387
CreateDialogFocus::PlanMode => CreateDialogFocus::SkipChecks,
385388
CreateDialogFocus::Buttons => CreateDialogFocus::PlanMode,
386389
};
@@ -391,7 +394,8 @@ async fn handle_create_dialog_key(app: &mut App, key: KeyEvent) -> anyhow::Resul
391394
CreateDialogFocus::Name => CreateDialogFocus::Prompt,
392395
CreateDialogFocus::Prompt => CreateDialogFocus::RepoPath,
393396
CreateDialogFocus::RepoPath => CreateDialogFocus::Backend,
394-
CreateDialogFocus::Backend => CreateDialogFocus::SkipChecks,
397+
CreateDialogFocus::Backend => CreateDialogFocus::AccessMode,
398+
CreateDialogFocus::AccessMode => CreateDialogFocus::SkipChecks,
395399
CreateDialogFocus::SkipChecks => CreateDialogFocus::PlanMode,
396400
CreateDialogFocus::PlanMode => CreateDialogFocus::Buttons,
397401
CreateDialogFocus::Buttons => CreateDialogFocus::Name,
@@ -401,6 +405,9 @@ async fn handle_create_dialog_key(app: &mut App, key: KeyEvent) -> anyhow::Resul
401405
CreateDialogFocus::Backend => {
402406
app.create_dialog.toggle_backend();
403407
}
408+
CreateDialogFocus::AccessMode => {
409+
app.create_dialog.toggle_access_mode();
410+
}
404411
CreateDialogFocus::SkipChecks => {
405412
app.create_dialog.skip_checks = !app.create_dialog.skip_checks;
406413
}
@@ -416,6 +423,9 @@ async fn handle_create_dialog_key(app: &mut App, key: KeyEvent) -> anyhow::Resul
416423
CreateDialogFocus::Backend => {
417424
app.create_dialog.toggle_backend();
418425
}
426+
CreateDialogFocus::AccessMode => {
427+
app.create_dialog.toggle_access_mode();
428+
}
419429
CreateDialogFocus::SkipChecks => {
420430
app.create_dialog.skip_checks = !app.create_dialog.skip_checks;
421431
}

0 commit comments

Comments
 (0)