diff --git a/docs/reference/tui.md b/docs/reference/tui.md index 00480452..55cb73ae 100644 --- a/docs/reference/tui.md +++ b/docs/reference/tui.md @@ -97,6 +97,14 @@ captured and displayed in the conversation area. environment variable. Set it to `1` or `true` to permit shell commands. When unset, shell commands are blocked. +Before execution, the command is scanned by the **shell danger detection** +subsystem. If a dangerous pattern is matched (e.g. `rm -rf /`, fork bombs, +pipe-to-shell), a warning overlay is shown with the danger level and a +description of the risk. The user can cancel or proceed. + +See [Shell Danger Detection](tui_shell_safety.md) for the full reference, +including the built-in pattern registry and how to add custom patterns. + ## Help Panel (F1) Press `F1` at any time to toggle the context-sensitive help panel. The panel @@ -352,6 +360,105 @@ Key modules: | `tui/permissions/models.py` | `ToolPermissionRequest`, `PermissionDecision`, `DiffDisplayMode` | | `tui/permissions/screen.py` | `PermissionsScreen` — split-pane diff view for tool permission requests | | `tui/permissions/service.py` | `PermissionRequestService` — request queue and session-scoped decisions | +| `tui/widgets/permission_question.py` | `PermissionQuestionWidget` — inline single-file permission request widget | +| `tui/widgets/actor_selection_overlay.py` | `ActorSelectionOverlay` — first-run actor selection overlay | +| `tui/shell_safety/pattern_detector.py` | `DangerousPatternDetector` — shell command danger detection | +| `tui/shell_safety/pattern_registry.py` | `DEFAULT_PATTERNS` — built-in dangerous shell pattern registry | +| `tui/first_run.py` | `is_first_run()`, `create_default_persona_for_actor()` — first-run helpers | + +## First-Run Experience + +On first launch — when no personas are configured — the TUI displays the +`ActorSelectionOverlay` in the centre of the screen. The overlay guides the +user to select an actor from a curated list: + +- `anthropic/claude-4-sonnet` *(recommended)* +- `anthropic/claude-4-opus` +- `openai/gpt-4o` +- `openai/o3` +- `google/gemini-2` + +**Key bindings inside the overlay:** + +| Key | Action | +|-----|--------| +| `j` / `↓` | Move selection down | +| `k` / `↑` | Move selection up | +| `/` | Enter search mode to filter actors | +| `Enter` | Confirm the highlighted actor | + +After confirmation, a `"default"` persona is created and the overlay is +dismissed. Subsequent launches restore the last active persona from +`~/.config/cleveragents/tui-state.yaml`. + +**Module**: `cleveragents.tui.widgets.actor_selection_overlay` + +The `ActorSelectionOverlay` widget exposes: + +| Property / Method | Description | +|-------------------|-------------| +| `actors` | Full (unfiltered) actor list | +| `filtered_actors` | Currently filtered actor list | +| `selected_index` | Zero-based index of the highlighted actor | +| `search_query` | Active search filter string | +| `confirmed` | `True` if the user has confirmed a selection | +| `selected_actor` | Confirmed actor name, or `None` if not yet confirmed | +| `show(actors?)` | Display the overlay, optionally overriding the actor list | +| `hide()` | Hide the overlay | +| `move_up()` / `move_down()` | Navigate the list | +| `set_search(query)` | Apply a substring filter (case-insensitive) | +| `confirm()` | Confirm the highlighted actor; returns the actor name or `None` | + +The first-run detection helper is in `cleveragents.tui.first_run`: + +```python +from cleveragents.tui.first_run import is_first_run, create_default_persona_for_actor + +if is_first_run(registry): + # Show ActorSelectionOverlay, then: + persona = create_default_persona_for_actor(registry, "openai/gpt-4o") +``` + +--- + +## Inline Permission Questions + +For **single-file** permission requests, the TUI renders a +`PermissionQuestionWidget` inline in the conversation stream rather than +pushing the full `PermissionsScreen`. + +``` +Permission Required + +The actor wants to write to: + src/api/main.py + + ❯ a Allow once + A Allow always (this session) + r Reject once + R Reject always (this session) + +Press v to open full PermissionsScreen with diff view +``` + +**Key bindings:** + +| Key | Action | +|-----|--------| +| `a` | Allow once | +| `A` | Allow always (this session) | +| `r` | Reject once | +| `R` | Reject always (this session) | +| `↑` / `↓` | Navigate options | +| `Enter` | Confirm highlighted option | +| `v` | Open full `PermissionsScreen` with diff view | + +A `PermissionDecisionEvent` is emitted when the user makes a decision. + +See [Permission Question Widget](tui_permission_question.md) for the full +API reference. + +--- ## Permissions Screen @@ -387,6 +494,8 @@ See [`tui_thought_block.md`](tui_thought_block.md) for the full API reference. - [ADR-045: TUI Persona System](../adr/ADR-045-tui-persona-system.md) - [ADR-046: TUI Reference and Command System](../adr/ADR-046-tui-reference-and-command-system.md) - [TUI Permissions Screen](tui_permissions.md) +- [TUI Permission Question Widget](tui_permission_question.md) +- [TUI Shell Danger Detection](tui_shell_safety.md) - [TUI Thought Blocks](tui_thought_block.md) - [Session CLI Reference](session_cli.md) - [Output Rendering Framework](output_rendering.md) diff --git a/docs/reference/tui_permission_question.md b/docs/reference/tui_permission_question.md new file mode 100644 index 00000000..c5268dd3 --- /dev/null +++ b/docs/reference/tui_permission_question.md @@ -0,0 +1,246 @@ +# TUI — Permission Question Widget + +The Permission Question Widget renders inline permission requests directly in +the conversation stream for **single-file operations**. For multi-file +operations, the full [`PermissionsScreen`](tui_permissions.md) is used instead. + +Introduced in the [Unreleased] milestone (issue #997). +See also [`tui.md`](tui.md) for the overall TUI architecture. + +**Module**: `cleveragents.tui.widgets.permission_question` + +--- + +## Overview + +When an actor requests a single-file operation (write, delete, read, shell +execution, or network access), the TUI renders a `PermissionQuestionWidget` +inline in the conversation stream. The user can respond with single-key +shortcuts or navigate with arrow keys. + +``` +Permission Required + +The actor wants to write to: + src/api/main.py + + ❯ a Allow once + A Allow always (this session) + r Reject once + R Reject always (this session) + +Press v to open full PermissionsScreen with diff view +``` + +--- + +## Domain Models + +### `PermissionRequestType` + +**Module**: `cleveragents.domain.models.core.inline_permission_question` + +`StrEnum` classifying the type of operation being requested. + +| Value | Description | +|-------|-------------| +| `file_write` | Actor wants to write to a file | +| `file_delete` | Actor wants to delete a file | +| `file_read` | Actor wants to read a file | +| `shell_exec` | Actor wants to execute a shell command | +| `network` | Actor wants to access the network | + +### `PermissionDecision` + +**Module**: `cleveragents.domain.models.core.inline_permission_question` + +`StrEnum` for the user's decision on a permission request. + +| Value | Description | +|-------|-------------| +| `allow_once` | Allow this specific operation once | +| `allow_always` | Allow all operations from this actor for the session | +| `reject_once` | Reject this specific operation once | +| `reject_always` | Reject all operations from this actor for the session | + +### `InlinePermissionQuestion` + +**Module**: `cleveragents.domain.models.core.inline_permission_question` + +Pydantic model representing a single-file permission request. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `file_path` | `str` | — | Path of the file the actor wants to operate on (non-empty) | +| `request_type` | `PermissionRequestType` | — | Type of operation being requested | +| `diff_content` | `str` | `""` | Unified diff content showing proposed changes (may be empty) | +| `actor_name` | `str` | `"actor"` | Name of the actor requesting permission | + +**Properties:** + +| Property | Type | Description | +|----------|------|-------------| +| `has_diff` | `bool` | `True` when `diff_content` is non-empty after stripping whitespace | + +**Methods:** + +| Method | Returns | Description | +|--------|---------|-------------| +| `description_line()` | `str` | Human-readable one-line description (e.g. `"The actor wants to write to:"`) | + +--- + +## `render_permission_question` + +**Module**: `cleveragents.tui.widgets.permission_question` + +Pure rendering helper that produces the widget's text content. + +```python +def render_permission_question( + question: InlinePermissionQuestion, + selected_index: int = 0, + *, + show_diff: bool = False, +) -> str: +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `question` | `InlinePermissionQuestion` | — | The permission question to render | +| `selected_index` | `int` | `0` | Index of the currently highlighted option (0–3) | +| `show_diff` | `bool` | `False` | When `True`, appends the diff content below the options | + +Returns a multi-line string suitable for display in a Textual `Static` widget. + +--- + +## `PermissionDecisionEvent` + +**Module**: `cleveragents.tui.widgets.permission_question` + +Emitted when the user makes a permission decision. The host application +listens for this event to act on the user's choice. + +| Attribute | Type | Description | +|-----------|------|-------------| +| `question` | `InlinePermissionQuestion` | The original permission question | +| `decision` | `PermissionDecision` | The decision made by the user | + +--- + +## `PermissionQuestionWidget` + +**Module**: `cleveragents.tui.widgets.permission_question` + +Textual `Static` subclass that renders an inline permission question in the +conversation stream. + +### Constructor + +```python +PermissionQuestionWidget(question: InlinePermissionQuestion, *args, **kwargs) +``` + +### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `question` | `InlinePermissionQuestion` | The associated permission question | +| `selected_index` | `int` | Index of the currently highlighted option | +| `decision` | `PermissionDecision \| None` | The decision if made, else `None` | +| `open_full_screen` | `bool` | `True` if the user pressed `v` to open `PermissionsScreen` | + +### Navigation + +| Method | Returns | Description | +|--------|---------|-------------| +| `move_up()` | `None` | Move the selection cursor up by one option (wraps) | +| `move_down()` | `None` | Move the selection cursor down by one option (wraps) | + +### Key Handling + +| Method | Returns | Description | +|--------|---------|-------------| +| `handle_key(key)` | `PermissionDecisionEvent \| None` | Process a key press; returns a decision event when resolved | + +**Supported keys:** + +| Key | Action | +|-----|--------| +| `↑` / `up` | Move selection up | +| `↓` / `down` | Move selection down | +| `Enter` | Confirm the currently highlighted option | +| `a` | Allow once | +| `A` | Allow always (this session) | +| `r` | Reject once | +| `R` | Reject always (this session) | +| `v` | Set `open_full_screen = True` (host opens `PermissionsScreen`) | + +--- + +## Usage Example + +```python +from cleveragents.domain.models.core.inline_permission_question import ( + InlinePermissionQuestion, + PermissionDecision, + PermissionRequestType, +) +from cleveragents.tui.widgets.permission_question import ( + PermissionDecisionEvent, + PermissionQuestionWidget, + render_permission_question, +) + +# Build the domain model +question = InlinePermissionQuestion( + file_path="src/api/main.py", + request_type=PermissionRequestType.FILE_WRITE, + diff_content="--- a/src/api/main.py\n+++ b/src/api/main.py\n@@ -1 +1,2 @@\n+import logging\n", + actor_name="openai/gpt-4o", +) + +# Pure rendering (no Textual dependency) +text = render_permission_question(question, selected_index=0, show_diff=True) +print(text) + +# Widget usage (requires Textual) +widget = PermissionQuestionWidget(question) + +# Simulate key presses +event = widget.handle_key("down") # moves cursor, returns None +event = widget.handle_key("enter") # confirms selection, returns PermissionDecisionEvent +assert event is not None +assert event.decision == PermissionDecision.ALLOW_ALWAYS # second option + +# Single-key shortcut +event = widget.handle_key("r") +assert event.decision == PermissionDecision.REJECT_ONCE + +# Open full screen +widget.handle_key("v") +assert widget.open_full_screen is True +``` + +--- + +## Relationship to PermissionsScreen + +The `PermissionQuestionWidget` is designed for **single-file** operations +where a compact inline display is sufficient. When: + +- The operation involves **multiple files**, or +- The user presses **`v`** to request a detailed diff view, + +the host application should push the full +[`PermissionsScreen`](tui_permissions.md) overlay instead. + +--- + +## Related Documentation + +- [TUI Reference](tui.md) — overall TUI architecture +- [TUI Permissions Screen](tui_permissions.md) — full-screen permission overlay for multi-file operations +- [TUI Shell Safety](tui_shell_safety.md) — shell command danger detection +- [Permissions](permissions.md) — permission system and role bindings diff --git a/docs/reference/tui_shell_safety.md b/docs/reference/tui_shell_safety.md new file mode 100644 index 00000000..d02164b4 --- /dev/null +++ b/docs/reference/tui_shell_safety.md @@ -0,0 +1,241 @@ +# TUI — Shell Danger Detection + +The shell safety subsystem detects dangerous command patterns before execution +in the TUI shell mode (`!` prefix). When a dangerous pattern is matched, the +TUI surfaces a warning overlay so the user can confirm or abort before the +command runs. + +Introduced in the [Unreleased] milestone (issue #1003). +See also [`tui.md`](tui.md) for the overall TUI architecture. + +**Module**: `cleveragents.tui.shell_safety` + +--- + +## Overview + +When the user types a shell command in the TUI (prefixed with `!`), the +`DangerousPatternDetector` scans the command against a configurable registry +of patterns before execution. If any patterns match, a warning is displayed +with the danger level and a description of the risk. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ⚠ Dangerous command detected │ +│ │ +│ [Critical] rm -rf / recursively deletes the entire filesystem │ +│ root. This will destroy the operating system and all data. │ +│ │ +│ Command: rm -rf / │ +│ │ +│ [c] Cancel [p] Proceed anyway │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Domain Models + +### `ShellDangerLevel` + +**Module**: `cleveragents.tui.shell_safety.danger_level` + +`IntEnum` classifying the severity of a dangerous shell command. Levels are +ordered so that numeric comparisons work naturally +(`level >= ShellDangerLevel.HIGH`). + +| Value | Int | Description | +|-------|-----|-------------| +| `LOW` | 1 | Minor risk — generally recoverable side-effects (e.g. `chmod 777` on a single file) | +| `MEDIUM` | 2 | Moderate risk — can cause data loss or security exposure in common scenarios | +| `HIGH` | 3 | High risk — significant, hard-to-reverse damage (e.g. `dd if=`, `mkfs`) | +| `CRITICAL` | 4 | Critical risk — can destroy the entire system or create a fork bomb | + +### `DangerousPattern` + +**Module**: `cleveragents.tui.shell_safety.dangerous_pattern` + +Immutable frozen dataclass describing a single dangerous shell pattern. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `name` | `str` | — | Short human-readable identifier (e.g. `"rm_rf_root"`) | +| `pattern` | `str` | — | Regular expression matching the dangerous command text | +| `level` | `ShellDangerLevel` | — | Danger severity classification | +| `description` | `str` | — | Human-readable explanation of the risk | +| `case_sensitive` | `bool` | `False` | When `True`, the regex is compiled without `re.IGNORECASE` | + +**Methods:** + +| Method | Returns | Description | +|--------|---------|-------------| +| `matches(command)` | `bool` | `True` if `command` matches this pattern | + +### `DangerousCommandWarning` + +**Module**: `cleveragents.tui.shell_safety.warning` + +Immutable frozen dataclass produced by `DangerousPatternDetector` when a +dangerous command is detected. + +| Field | Type | Description | +|-------|------|-------------| +| `command` | `str` | The original command string that triggered the warning | +| `matched_pattern` | `DangerousPattern` | The pattern that matched | +| `danger_level` | `ShellDangerLevel` | Convenience alias for `matched_pattern.level` | +| `message` | `str` | Human-readable warning message for display | + +**Class methods:** + +| Method | Returns | Description | +|--------|---------|-------------| +| `from_pattern(command, pattern)` | `DangerousCommandWarning` | Construct a warning from a command and the matching pattern | + +--- + +## `DangerousPatternDetector` + +**Module**: `cleveragents.tui.shell_safety.pattern_detector` + +Domain service that checks shell commands against a configurable registry of +dangerous patterns. Ships with built-in `DEFAULT_PATTERNS` but supports full +customisation. + +### Constructor + +```python +DangerousPatternDetector(patterns: list[DangerousPattern] | None = None) +``` + +When `patterns` is `None`, the built-in `DEFAULT_PATTERNS` are used. + +### Registry Management + +| Method | Returns | Description | +|--------|---------|-------------| +| `add_pattern(pattern)` | `None` | Append a pattern to the registry (checked in insertion order) | +| `remove_pattern(name)` | `bool` | Remove the pattern with the given name; returns `True` if removed | +| `replace_patterns(patterns)` | `None` | Replace the entire registry with a new list | +| `patterns` *(property)* | `tuple[DangerousPattern, ...]` | Read-only view of the current registry | + +### Detection + +| Method | Returns | Description | +|--------|---------|-------------| +| `check(command)` | `list[DangerousCommandWarning]` | All warnings for `command` (may be empty); ordered by insertion order | +| `check_first(command)` | `DangerousCommandWarning \| None` | First (highest-priority) warning, or `None` | +| `is_dangerous(command)` | `bool` | `True` if `command` matches any registered pattern | +| `max_danger_level(command)` | `ShellDangerLevel \| None` | Highest danger level matched, or `None` | + +--- + +## Default Pattern Registry + +**Module**: `cleveragents.tui.shell_safety.pattern_registry` + +The `DEFAULT_PATTERNS` tuple ships with the following built-in patterns: + +### Critical + +| Name | Pattern | Description | +|------|---------|-------------| +| `rm_rf_root` | `rm -rf /` | Recursively deletes the entire filesystem root | +| `rm_rf_wildcard` | `rm -rf /*` or `rm -rf *` | Deletes large portions of the filesystem | +| `fork_bomb` | `:(){ :|:& };:` | Exhausts the process table, requiring a hard reboot | + +### High + +| Name | Pattern | Description | +|------|---------|-------------| +| `dd_if_device` | `dd if=` | Can overwrite disk devices or fill storage | +| `mkfs` | `mkfs` | Formats a filesystem, permanently erasing all data | +| `shred_device` | `shred /dev/` or `shred --remove` | Overwrites data in an unrecoverable way | + +### Medium + +| Name | Pattern | Description | +|------|---------|-------------| +| `chmod_777` | `chmod 777` | Grants world-readable/writable/executable permissions | +| `sudo_rm` | `sudo rm` | Runs `rm` with elevated privileges | +| `wget_pipe_sh` | `wget … \| sh` | Executes arbitrary remote code without inspection | +| `curl_pipe_sh` | `curl … \| sh` | Executes arbitrary remote code without inspection | +| `wget_pipe_bash` | `wget … \| bash` | Executes arbitrary remote code without inspection | +| `curl_pipe_bash` | `curl … \| bash` | Executes arbitrary remote code without inspection | + +### Low + +| Name | Pattern | Description | +|------|---------|-------------| +| `git_push_force` | `git push --force` | Overwrites remote history, causing data loss for collaborators | +| `chmod_recursive_permissive` | `chmod -R 6xx/7xx` | Exposes sensitive files to unintended access | + +--- + +## Usage Examples + +### Basic detection + +```python +from cleveragents.tui.shell_safety.pattern_detector import DangerousPatternDetector + +detector = DangerousPatternDetector() + +# Check a command +warnings = detector.check("rm -rf /") +if warnings: + print(warnings[0].message) + # [Critical] Dangerous command detected: rm -rf / recursively deletes ... + +# Quick boolean check +if detector.is_dangerous("sudo rm -rf /var/log"): + print("Dangerous!") + +# Highest danger level +level = detector.max_danger_level("wget http://example.com/script.sh | bash") +print(level) # ShellDangerLevel.MEDIUM +``` + +### Custom patterns + +```python +from cleveragents.tui.shell_safety.danger_level import ShellDangerLevel +from cleveragents.tui.shell_safety.dangerous_pattern import DangerousPattern +from cleveragents.tui.shell_safety.pattern_detector import DangerousPatternDetector + +detector = DangerousPatternDetector() + +# Add a custom pattern +detector.add_pattern( + DangerousPattern( + name="drop_database", + pattern=r"\bDROP\s+DATABASE\b", + level=ShellDangerLevel.CRITICAL, + description="DROP DATABASE permanently deletes the entire database.", + case_sensitive=False, + ) +) + +# Remove a built-in pattern +detector.remove_pattern("git_push_force") +``` + +--- + +## Integration with Shell Mode + +The `DangerousPatternDetector` is invoked by the TUI shell mode handler +(`cleveragents.tui.input.shell_exec`) before any subprocess is started. +If `check_first()` returns a warning, the TUI presents a confirmation +overlay before proceeding. + +The shell mode is entered by typing `!` at the prompt. See +[Shell Mode](tui.md#shell-mode) in the TUI reference for the full +shell mode documentation. + +--- + +## Related Documentation + +- [TUI Reference](tui.md) — overall TUI architecture and shell mode +- [TUI Permissions Screen](tui_permissions.md) — full-screen permission overlay +- [TUI Permission Question Widget](tui_permission_question.md) — inline permission widget