Skip to content

Commit 280bb68

Browse files
Add command validation system with strict mode and CLI options
- Implement validator module with OS/shell compatibility checks - Add --no-strict-validation flag for translate and run commands - Support environment variable overrides for validation settings - Include comprehensive unit tests for command validation - Update documentation with new validation options and examples
1 parent 8c7e272 commit 280bb68

File tree

8 files changed

+767
-31
lines changed

8 files changed

+767
-31
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ CommandRex can be invoked using either `commandrex` or `python -m commandrex` fo
8484
For example:
8585
- `commandrex run` - Start interactive mode with multi-command selection
8686
- `commandrex translate "query"` - Translate a natural language query
87+
- `commandrex translate "query" --no-strict-validation` - Translate without strict env validation (per-invocation)
8788
- `commandrex explain "command"` - Explain a shell command
8889

8990
### Interactive Mode
@@ -100,6 +101,7 @@ This launches CommandRex in interactive mode with a welcome screen displaying "C
100101

101102
**Options:**
102103
- `--debug` or `-d`: Enable debug mode with detailed system information
104+
- `--no-strict-validation`: Disable strict environment validation for this run/translation
103105
- `--api-key YOUR_KEY`: Use a specific OpenAI API key for this session
104106
- `--model MODEL_NAME`: Specify an OpenAI model (default: gpt-4.1-mini-2025-04-14)
105107
- `--translate "query"` or `-t "query"`: Directly translate a query without entering interactive mode
@@ -122,6 +124,7 @@ commandrex translate "list all files in the current directory including hidden o
122124
- `--multi-select`: Present multiple command options to choose from interactively
123125
- `--api-key YOUR_KEY`: Use a specific OpenAI API key for this translation
124126
- `--model MODEL_NAME`: Specify an OpenAI model (default: gpt-4.1-mini-2025-04-14)
127+
- `--no-strict-validation`: Disable strict validation for this translation (shows guidance instead of blocking)
125128

126129
**Examples:**
127130
```bash

commandrex/config/settings.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ class Settings:
5050
"allow_file_operations": True,
5151
"dangerous_commands_require_confirmation": True,
5252
},
53+
"validation": {
54+
# Strictly reject commands incompatible with OS/shell
55+
"strict_mode": True,
56+
# Phase 2 placeholder: attempt auto transform of incompatible commands
57+
"auto_transform": False,
58+
# Offer alternative compatible commands when strict_mode blocks
59+
"suggest_alternatives": True,
60+
},
5361
"advanced": {
5462
"debug_mode": False,
5563
"log_level": "INFO",
@@ -70,6 +78,9 @@ def __init__(self):
7078
if self.config_file.exists():
7179
self.load()
7280

81+
# Apply environment overrides after file load
82+
self._apply_env_overrides()
83+
7384
def _get_config_dir(self) -> Path:
7485
"""
7586
Get the configuration directory for the application.
@@ -188,6 +199,39 @@ def set(self, section: str, key: str, value: Any) -> bool:
188199
print(f"Error setting {section}.{key}: {e}")
189200
return False
190201

202+
def _apply_env_overrides(self) -> None:
203+
"""
204+
Apply environment variable overrides for selected settings.
205+
206+
Supported overrides:
207+
- COMMANDREX_VALIDATION_STRICT_MODE -> validation.strict_mode (bool)
208+
- COMMANDREX_VALIDATION_AUTO_TRANSFORM -> validation.auto_transform (bool)
209+
- COMMANDREX_VALIDATION_SUGGEST_ALTERNATIVES
210+
-> validation.suggest_alternatives (bool)
211+
"""
212+
213+
def _env_bool(name: str, default: Optional[bool]) -> Optional[bool]:
214+
val = os.environ.get(name)
215+
if val is None:
216+
return default
217+
val_lower = val.strip().lower()
218+
if val_lower in {"1", "true", "yes", "on"}:
219+
return True
220+
if val_lower in {"0", "false", "no", "off"}:
221+
return False
222+
return default
223+
224+
strict = _env_bool("COMMANDREX_VALIDATION_STRICT_MODE", None)
225+
auto_tf = _env_bool("COMMANDREX_VALIDATION_AUTO_TRANSFORM", None)
226+
suggest = _env_bool("COMMANDREX_VALIDATION_SUGGEST_ALTERNATIVES", None)
227+
228+
if strict is not None:
229+
self.set("validation", "strict_mode", strict)
230+
if auto_tf is not None:
231+
self.set("validation", "auto_transform", auto_tf)
232+
if suggest is not None:
233+
self.set("validation", "suggest_alternatives", suggest)
234+
191235
def get_all(self) -> Dict[str, Dict[str, Any]]:
192236
"""
193237
Get all settings.

commandrex/executor/shell_manager.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import threading
1313
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
1414

15+
from commandrex.config.settings import settings
1516
from commandrex.executor import platform_utils
1617
from commandrex.executor.command_parser import CommandParser
1718

@@ -294,13 +295,24 @@ async def read_stream(stream, chunks, callback):
294295

295296
except asyncio.TimeoutError:
296297
# Terminate the process if it times out
297-
process.terminate()
298+
import inspect
299+
300+
term = getattr(process, "terminate", None)
301+
if callable(term) and inspect.iscoroutinefunction(term):
302+
await term()
303+
elif callable(term):
304+
term()
305+
298306
try:
299307
# Give it a chance to terminate gracefully
300308
await asyncio.wait_for(process.wait(), 2.0)
301309
except asyncio.TimeoutError:
302310
# Force kill if it doesn't terminate
303-
process.kill()
311+
kill = getattr(process, "kill", None)
312+
if callable(kill) and inspect.iscoroutinefunction(kill):
313+
await kill()
314+
elif callable(kill):
315+
kill()
304316
await process.wait()
305317

306318
terminated = True
@@ -407,7 +419,7 @@ async def execute_command_safely(
407419
validation_info = {}
408420

409421
if validate:
410-
# Validate the command
422+
# Validate the command (syntactic/danger checks from parser)
411423
validation_info = self.command_parser.validate_command(command)
412424

413425
if not validation_info["is_valid"]:
@@ -418,6 +430,39 @@ async def execute_command_safely(
418430
reasons = ", ".join(validation_info["reasons"])
419431
raise ValueError(f"Dangerous command: {reasons}")
420432

433+
# Environment-aware strict validation: ensure command matches OS/shell
434+
try:
435+
from commandrex.validator.command_validator import (
436+
CommandValidator, # noqa: E402
437+
)
438+
439+
env_validator = CommandValidator()
440+
env = env_validator.detect_environment()
441+
shell_name = env.get("shell", "")
442+
os_name = env.get("os", "")
443+
env_result = env_validator.validate_for_environment(
444+
command, shell_override=shell_name, os_override=os_name
445+
)
446+
if not env_result.is_valid:
447+
reasons = "; ".join(env_result.reasons)
448+
msg = (
449+
"Incompatible command for environment "
450+
f"(OS={os_name}, shell={shell_name}): {reasons}"
451+
)
452+
# Honor settings toggle: strict_mode controls hard failure
453+
strict_mode = settings.get("validation", "strict_mode", True)
454+
suggest_alts = settings.get(
455+
"validation", "suggest_alternatives", True
456+
)
457+
if strict_mode:
458+
raise ValueError(msg)
459+
# Non-strict: attach reasons to validation_info for UI consumption
460+
if suggest_alts:
461+
validation_info["env_issues"] = env_result.reasons
462+
except ImportError:
463+
# If validator is unavailable, skip strict env validation
464+
pass
465+
421466
# Execute the command
422467
result = await self.execute_command(
423468
command, stdout_callback, stderr_callback, timeout, cwd, env

commandrex/main.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,11 @@ def translate(
372372
"--multi-select",
373373
help="Present multiple command options to choose from before executing.",
374374
),
375+
no_strict_validation: bool = typer.Option(
376+
False,
377+
"--no-strict-validation",
378+
help="Disable strict environment validation for this translation.",
379+
),
375380
) -> None:
376381
"""
377382
Translate natural language to a shell command.
@@ -401,6 +406,12 @@ def translate(
401406
# Create OpenAI client
402407
client = openai_client.OpenAIClient(api_key=api_key, model=model)
403408

409+
# Apply per-invocation toggle for strict validation
410+
if no_strict_validation:
411+
from commandrex.config.settings import settings as _settings
412+
413+
_settings.set("validation", "strict_mode", False)
414+
404415
# Create prompt builder
405416
pb = prompt_builder.PromptBuilder()
406417

@@ -812,6 +823,11 @@ def run(
812823
"-t",
813824
help="Directly translate a natural language query without interactive mode.",
814825
),
826+
no_strict_validation: bool = typer.Option(
827+
False,
828+
"--no-strict-validation",
829+
help="Disable strict environment validation for this session.",
830+
),
815831
) -> None:
816832
"""
817833
Start the CommandRex terminal interface.
@@ -925,6 +941,10 @@ def signal_handler(sig, frame):
925941
if not check_api_key():
926942
raise typer.Exit(1)
927943

944+
# Apply per-invocation toggle for strict validation (session-wide)
945+
if no_strict_validation:
946+
settings.settings.set("validation", "strict_mode", False)
947+
928948
# Display welcome screen for interactive mode (only when no direct query/translate)
929949
if not query and not translate_arg:
930950
display_welcome_screen(console)

commandrex/translator/openai_client.py

Lines changed: 65 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
# Import from our own modules
1818
from commandrex.config import api_manager
19+
from commandrex.config.settings import settings
1920

2021
# Set up logging
2122
logger = logging.getLogger(__name__)
@@ -299,36 +300,72 @@ async def translate_to_command(
299300
is_dangerous = response_data.get("is_dangerous", False)
300301
alternatives = response_data.get("alternatives", [])
301302

302-
# Post-generation validation (phase 1 lite):
303-
# Basic checks for forbidden tokens and path separators.
304-
issues: List[str] = []
305-
shell_rules = strict_rules.get(shell_key) if rules else None
306-
if shell_rules:
307-
# Forbidden tokens
308-
for fbd in shell_rules["forbidden"]:
309-
# simple token existence check
310-
if f"{fbd} " in command or command.lower().startswith(fbd):
311-
issues.append(f"Forbidden command for {shell_key}: {fbd}")
312-
313-
# Path separator check (only if command seems to contain a path)
314-
wrong_sep = shell_rules["wrong_sep"]
315-
right_sep = shell_rules["path_sep"]
316-
if wrong_sep in command and right_sep not in command:
317-
issues.append(
318-
f"Wrong path separator '{wrong_sep}' for shell {shell_key}"
319-
)
320-
321-
if issues:
322-
logger.warning(
323-
"LLM command did not pass environment validation: %s",
324-
issues,
303+
# Post-generation validation (Phase 1 strict):
304+
# Enforce environment-aware validation and reject incompatible commands.
305+
# Respect settings toggles for strictness and suggestions.
306+
strict_mode = settings.get("validation", "strict_mode", True)
307+
suggest_alts = settings.get("validation", "suggest_alternatives", True)
308+
try:
309+
from commandrex.validator.command_validator import ( # noqa: E402
310+
CommandValidator,
325311
)
326-
# We do not auto-correct here in phase 1; only surface info.
327-
explanation = (
328-
explanation
329-
+ "\nEnvironment validation issues detected: "
330-
+ "; ".join(issues)
312+
313+
validator = CommandValidator()
314+
# Use detected shell/os where possible
315+
os_name_val = os_name
316+
shell_name_val = shell_key
317+
validation = validator.validate_for_environment(
318+
command, shell_override=shell_name_val, os_override=os_name_val
331319
)
320+
if not validation.is_valid:
321+
issues_text = "; ".join(validation.reasons)
322+
logger.error(
323+
"Rejected command due to environment validation: %s",
324+
issues_text,
325+
)
326+
if strict_mode:
327+
raise ValueError(
328+
"Generated command is incompatible with the current "
329+
f"environment: {issues_text}"
330+
)
331+
# Non-strict mode: keep the command but annotate explanation
332+
if suggest_alts:
333+
explanation = (
334+
f"{explanation}\nEnvironment validation issues: "
335+
+ issues_text
336+
)
337+
except ImportError:
338+
# If validator module is unavailable, fall back to previous
339+
# lite checks while keeping lines within E501 limits.
340+
issues: List[str] = []
341+
shell_rules = strict_rules.get(shell_key) if rules else None
342+
if shell_rules:
343+
for fbd in shell_rules["forbidden"]:
344+
starts_forbidden = command.lower().startswith(fbd)
345+
has_token = f"{fbd} " in command
346+
if has_token or starts_forbidden:
347+
issues.append(
348+
f"Forbidden command for {shell_key}: {fbd}"
349+
)
350+
wrong_sep = shell_rules["wrong_sep"]
351+
right_sep = shell_rules["path_sep"]
352+
wrong_only = wrong_sep in command and right_sep not in command
353+
if wrong_only:
354+
issues.append(
355+
"Wrong path separator "
356+
f"'{wrong_sep}' for shell {shell_key}"
357+
)
358+
if issues:
359+
logger.warning(
360+
"LLM command did not pass environment validation "
361+
"(fallback): %s",
362+
issues,
363+
)
364+
if issues and suggest_alts:
365+
explanation = (
366+
f"{explanation}\nEnvironment validation issues detected: "
367+
+ "; ".join(issues)
368+
)
332369

333370
return CommandTranslationResult(
334371
command=command,

0 commit comments

Comments
 (0)