diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 19ef0f4..4fb8b60 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,6 +1,6 @@ # Soup CLI — Project CLAUDE.md -Soup is a CLI-first LLM fine-tuning tool (v0.24.0). Python 3.9+, MIT license. +Soup is a CLI-first LLM fine-tuning tool (v0.24.1). Python 3.9+, MIT license. ## Build & Development @@ -294,6 +294,8 @@ soup version # Show version (--full for details) - **Dataset registry**: JSON validation on load — catches corruption + type mismatch (v0.24.0) - **Loss watchdog**: threshold bounded le=100.0, patience bounded le=1000 (v0.24.0) - **Freeze training**: freeze_layers bounded le=1000 (v0.24.0) +- **AWQ/GPTQ export**: output path traversal validation before import check (v0.24.1) +- **Windows Unicode**: Rich console symbols replaced with ASCII equivalents (v0.24.1) ## Code Conventions @@ -367,7 +369,7 @@ soup version # Show version (--full for details) 15. **Tag**: `git tag v0.X.Y && git push origin v0.X.Y` 16. **Release**: `gh release create v0.X.Y` with changelog (What's New, Install/Upgrade) -## Tests (74 test files, 2061 tests) +## Tests (74 test files, 2065 tests) | File | Covers | |------|--------| @@ -436,7 +438,7 @@ soup version # Show version (--full for details) | test_multi_adapter.py | Multi-adapter serving: validation, parsing, FastAPI endpoints, CLI | | test_data_sample.py | Data sampling: random/diverse/hard strategies, CLI, edge cases | | test_adapters.py | Adapter management: list/info/compare, discovery, metadata | -| test_awq_gptq_export.py | AWQ/GPTQ export: format support, CLI, quantize mocks, calibration, security | +| test_awq_gptq_export.py | AWQ/GPTQ export: format support, CLI, quantize mocks, calibration, output path traversal, security | | test_packing.py | Sample packing: config, YAML, trainer integration, sweep | | test_data_split.py | Data split: ratio/absolute/stratified splits, seed, edge cases | | test_curriculum.py | Curriculum learning: config, length sort, buckets, sweep | diff --git a/SECURITY.md b/SECURITY.md index 3f1ffaa..6e3ca07 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,9 +9,9 @@ We provide security updates for the following versions: - **Versions older than 3 minor versions:** No support Example: -- v0.24.0-0.24.x → Full support (latest) -- v0.23.0-0.23.x → Bug-fix support only -- v0.22.x and below → No support +- v0.24.0-0.24.x -- Full support (latest) +- v0.23.0-0.23.x -- Bug-fix support only +- v0.22.x and below -- No support ## Reporting a Vulnerability @@ -124,6 +124,10 @@ No known critical vulnerabilities in current releases. - **v0.19.0**: Custom eval JSONL schema validation + 10k task cap, regex scoring ReDoS guard, judge API SSRF protection + API key isolation, human eval local-only terminal UI + 10k prompt cap, leaderboard read-only SQLite queries - **v0.20.0**: Ollama provider localhost-only validation (remote blocked), Anthropic provider API key from env only (never CLI arg), vLLM provider SSRF protection (scheme whitelist + localhost-only HTTP), output path traversal protection (`..` blocked), configurable rate limiting (`--requests-per-minute`) - **v0.21.0**: Migrate input/output path traversal protection (resolve + relative_to(cwd)), Unsloth .ipynb AST-only parsing (no exec/eval), recipes output path traversal protection, NEFTune config bounded (ge=0.0, le=50.0) +- **v0.22.0**: Multi-adapter serving path traversal protection (resolve + relative_to(cwd)), adapter name validation (alphanumeric + hyphens only), unknown adapter returns 404 (not 500) +- **v0.23.0**: AWQ/GPTQ calibration data path traversal protection (resolve + relative_to(cwd)), AWQ/GPTQ output path stays under cwd, curriculum_buckets bounded (ge=1, le=20), AWQ/GPTQ trust_remote_code warning panel +- **v0.24.0**: HF download trust_remote_code=False + warning panel, HF download output path sanitized (Path.name), download --samples capped at 1M, dataset registry name validation (no path separators/null bytes), registry path traversal protection, loss_watchdog threshold le=100 + patience le=1000, freeze_layers le=1000 +- **v0.24.1**: AWQ/GPTQ output path traversal validation moved before import check (previously unreachable when autoawq/auto-gptq not installed), Windows Unicode fix for Rich console output (replaced non-ASCII symbols with ASCII equivalents) ## Security Scanning diff --git a/pyproject.toml b/pyproject.toml index b40c265..15da582 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "soup-cli" -version = "0.24.0" +version = "0.24.1" description = "Fine-tune LLMs in one command. No SSH, no config hell." readme = "README.md" license = "MIT" diff --git a/soup_cli/__init__.py b/soup_cli/__init__.py index 00f8ff5..9b3a532 100644 --- a/soup_cli/__init__.py +++ b/soup_cli/__init__.py @@ -1,3 +1,3 @@ """Soup CLI — Fine-tune LLMs in one command.""" -__version__ = "0.24.0" +__version__ = "0.24.1" diff --git a/soup_cli/commands/adapters.py b/soup_cli/commands/adapters.py index 8ee0375..ddd6562 100644 --- a/soup_cli/commands/adapters.py +++ b/soup_cli/commands/adapters.py @@ -143,7 +143,7 @@ def info( f"Size on disk: [bold]{size}[/]" ) - console.print(Panel(info_text, title=f"Adapter Info — {adapter_path.name}")) + console.print(Panel(info_text, title=f"Adapter Info -- {adapter_path.name}")) @app.command() diff --git a/soup_cli/commands/data.py b/soup_cli/commands/data.py index 445eddc..6cfc28c 100644 --- a/soup_cli/commands/data.py +++ b/soup_cli/commands/data.py @@ -844,18 +844,18 @@ def split_data( output_msg = ( f"[green]Split {total} rows:[/]\n" - f" Train: {len(train_data)} → [bold]{train_path}[/]" + f" Train: {len(train_data)} -> [bold]{train_path}[/]" ) if val_data: val_path = parent / f"{stem}_val.jsonl" _write_jsonl(val_path, val_data) - output_msg += f"\n Val: {len(val_data)} → [bold]{val_path}[/]" + output_msg += f"\n Val: {len(val_data)} -> [bold]{val_path}[/]" if test_data: test_path = parent / f"{stem}_test.jsonl" _write_jsonl(test_path, test_data) - output_msg += f"\n Test: {len(test_data)} → [bold]{test_path}[/]" + output_msg += f"\n Test: {len(test_data)} -> [bold]{test_path}[/]" console.print(output_msg) diff --git a/soup_cli/commands/export.py b/soup_cli/commands/export.py index 1b26bf3..e9d4fff 100644 --- a/soup_cli/commands/export.py +++ b/soup_cli/commands/export.py @@ -602,6 +602,20 @@ def _export_tensorrt(model_path: Path, output: Optional[str], base: Optional[str ) +def _validate_output_path(output: Optional[str]) -> Optional[Path]: + """Validate output path stays under cwd (path traversal protection).""" + if output is None: + return None + out_path = Path(output).resolve() + cwd = Path.cwd().resolve() + try: + out_path.relative_to(cwd) + except ValueError: + console.print("[red]Output path must be under the current working directory.[/]") + raise typer.Exit(1) + return out_path + + def _validate_calibration_path(calibration_data: Optional[str]) -> Optional[Path]: """Validate calibration data path stays under cwd.""" if calibration_data is None: @@ -661,6 +675,9 @@ def _export_awq( ) raise typer.Exit(1) + # Validate output path (security: path traversal protection) + validated_output = _validate_output_path(output) + # Validate calibration path (security: path traversal protection) cal_path = _validate_calibration_path(calibration_data) @@ -695,7 +712,8 @@ def _export_awq( _merge_adapter(str(model_path), base_model, str(merge_dir)) source_path = merge_dir - output_path = Path(output) if output else model_path.parent / f"{model_path.name}_awq" + default_out = model_path.parent / f"{model_path.name}_awq" + output_path = validated_output if validated_output else default_out console.print( Panel( @@ -776,6 +794,9 @@ def _export_gptq( ) raise typer.Exit(1) + # Validate output path (security: path traversal protection) + validated_output = _validate_output_path(output) + # Validate calibration path (security: path traversal protection) cal_path = _validate_calibration_path(calibration_data) @@ -810,7 +831,8 @@ def _export_gptq( _merge_adapter(str(model_path), base_model, str(merge_dir)) source_path = merge_dir - output_path = Path(output) if output else model_path.parent / f"{model_path.name}_gptq" + default_out = model_path.parent / f"{model_path.name}_gptq" + output_path = validated_output if validated_output else default_out console.print( Panel( @@ -908,12 +930,15 @@ def _auto_deploy_ollama( version = detect_ollama() if not version: console.print( - "[red]Ollama not found — skipping deploy.[/]\n" + "[red]Ollama not found -- skipping deploy.[/]\n" "Install from: [bold]https://ollama.com[/]" ) raise typer.Exit(1) - console.print(f"\n[green]✓[/] Ollama v{version} detected — deploying as [bold]{ollama_name}[/]") + console.print( + f"\n[green]OK[/] Ollama v{version} detected" + f" -- deploying as [bold]{ollama_name}[/]" + ) console.print( "[yellow]Warning:[/] This will overwrite any existing Ollama model " f"named '{ollama_name}'." @@ -929,7 +954,7 @@ def _auto_deploy_ollama( console.print(f"[red]Deploy failed:[/] {message}") raise typer.Exit(1) - console.print(f"[green]✓[/] Deployed to Ollama: [bold]{ollama_name}[/]") + console.print(f"[green]OK[/] Deployed to Ollama: [bold]{ollama_name}[/]") console.print(f"Run: [bold]ollama run {ollama_name}[/]") diff --git a/soup_cli/commands/migrate.py b/soup_cli/commands/migrate.py index bf67265..87eab1c 100644 --- a/soup_cli/commands/migrate.py +++ b/soup_cli/commands/migrate.py @@ -108,7 +108,7 @@ def migrate( )) if dry_run: - console.print("[dim]Dry run — no file written.[/]") + console.print("[dim]Dry run -- no file written.[/]") return # Check for existing file diff --git a/soup_cli/commands/profile.py b/soup_cli/commands/profile.py index dc21b89..507bd25 100644 --- a/soup_cli/commands/profile.py +++ b/soup_cli/commands/profile.py @@ -151,7 +151,7 @@ def _render_profile(result: dict, cfg, gpu_memory_gb: float) -> None: f"~{result['activation_memory_gb']:.1f} GB", ) mem_table.add_row("Overhead", f"~{result['overhead_gb']:.1f} GB") - mem_table.add_row("─" * 20, "─" * 10) + mem_table.add_row("-" * 20, "-" * 10) mem_table.add_row("[bold]Total[/]", f"[bold]~{result['total_memory_gb']:.1f} GB[/]") # Speed info @@ -165,26 +165,26 @@ def _render_profile(result: dict, cfg, gpu_memory_gb: float) -> None: fits = result["total_memory_gb"] <= gpu_memory_gb if fits: recs.append( - f"[green]✓[/] Fits in {gpu_memory_gb:.0f} GB VRAM" + f"[green]OK[/] Fits in {gpu_memory_gb:.0f} GB VRAM" ) else: recs.append( - f"[red]✗[/] Does NOT fit in {gpu_memory_gb:.0f} GB VRAM " + f"[red]X[/] Does NOT fit in {gpu_memory_gb:.0f} GB VRAM " f"(need ~{result['total_memory_gb']:.0f} GB)" ) recs.append( - f"[green]✓[/] Recommended batch_size: {result['recommended_batch_size']}" + f"[green]OK[/] Recommended batch_size: {result['recommended_batch_size']}" ) if result["total_memory_gb"] > 24 and not result["gradient_checkpointing"]: recs.append( - "[yellow]⚠[/] Consider gradient_checkpointing: true for memory savings" + "[yellow]![/] Consider gradient_checkpointing: true for memory savings" ) if result["total_memory_gb"] > 40: recs.append( - "[yellow]⚠[/] Consider DeepSpeed ZeRO-3 or FSDP for distributed training" + "[yellow]![/] Consider DeepSpeed ZeRO-3 or FSDP for distributed training" ) # Compatible GPUs (show top 5) @@ -203,4 +203,4 @@ def _render_profile(result: dict, cfg, gpu_memory_gb: float) -> None: if gpu_list: console.print("[bold]Compatible GPUs:[/]") for gpu_name in gpu_list: - console.print(f" • {gpu_name}") + console.print(f" - {gpu_name}") diff --git a/soup_cli/commands/recipes.py b/soup_cli/commands/recipes.py index 3d39c0c..07eb93c 100644 --- a/soup_cli/commands/recipes.py +++ b/soup_cli/commands/recipes.py @@ -47,7 +47,7 @@ def show( console.print(Panel( Syntax(recipe.yaml_str, "yaml", theme="monokai"), - title=f"[bold green]{name}[/] — {recipe.description}", + title=f"[bold green]{name}[/] -- {recipe.description}", )) diff --git a/soup_cli/commands/runs.py b/soup_cli/commands/runs.py index b1ae757..3042166 100644 --- a/soup_cli/commands/runs.py +++ b/soup_cli/commands/runs.py @@ -281,7 +281,7 @@ def _fmt_loss(run: dict) -> str: def _fmt_float(val: Optional[float]) -> str: - """Format a float or return '—'.""" + """Format a float or return '-'.""" if val is not None: return f"{val:.4f}" return "-" diff --git a/soup_cli/commands/sweep.py b/soup_cli/commands/sweep.py index 5fce0a9..bd2e1cf 100644 --- a/soup_cli/commands/sweep.py +++ b/soup_cli/commands/sweep.py @@ -145,7 +145,7 @@ def sweep( if final_loss > best_loss * early_stop: console.print( f"[yellow]Loss {final_loss:.4f} exceeds threshold " - f"({best_loss:.4f} × {early_stop} = {best_loss * early_stop:.4f})[/]" + f"({best_loss:.4f} x {early_stop} = {best_loss * early_stop:.4f})[/]" ) except Exception as exc: console.print(f"[red]Run {run_name} failed: {exc}[/]") diff --git a/soup_cli/utils/profiler.py b/soup_cli/utils/profiler.py index f16c16f..87a7605 100644 --- a/soup_cli/utils/profiler.py +++ b/soup_cli/utils/profiler.py @@ -304,7 +304,7 @@ def recommend_gpu(total_memory_gb: float) -> list[str]: if not compatible: compatible.append( - f"No single GPU fits {total_memory_gb:.1f} GB — use multi-GPU (DeepSpeed/FSDP)" + f"No single GPU fits {total_memory_gb:.1f} GB -- use multi-GPU (DeepSpeed/FSDP)" ) return compatible diff --git a/tests/test_awq_gptq_export.py b/tests/test_awq_gptq_export.py index 80f4e9c..90758ff 100644 --- a/tests/test_awq_gptq_export.py +++ b/tests/test_awq_gptq_export.py @@ -142,18 +142,23 @@ class TestAwqExportFunction: import soup_cli.commands.export as export_mod + out_path = tmp_path / "out" with mock_patch.object(builtins, "__import__", side_effect=_mock_import(awq_mock=awq_mod)): with mock_patch( "transformers.AutoTokenizer.from_pretrained", return_value=mock_tokenizer, ): - export_mod._export_awq( - model_dir, str(tmp_path / "out"), None, - bits=4, group_size=128, calibration_data=None, - ) - mock_awq_class.from_pretrained.assert_called_once() - mock_model.quantize.assert_called_once() - mock_model.save_quantized.assert_called_once() + with mock_patch( + "soup_cli.commands.export._validate_output_path", + return_value=out_path, + ): + export_mod._export_awq( + model_dir, str(out_path), None, + bits=4, group_size=128, calibration_data=None, + ) + mock_awq_class.from_pretrained.assert_called_once() + mock_model.quantize.assert_called_once() + mock_model.save_quantized.assert_called_once() def test_export_awq_default_output_path(self, tmp_path): """Default AWQ output path should be model_name + _awq suffix.""" @@ -204,6 +209,7 @@ class TestAwqExportFunction: import soup_cli.commands.export as export_mod + out_path = tmp_path / "out" with mock_patch.object(builtins, "__import__", side_effect=_mock_import(awq_mock=awq_mod)): with mock_patch( "transformers.AutoTokenizer.from_pretrained", @@ -213,13 +219,18 @@ class TestAwqExportFunction: "soup_cli.commands.export._validate_calibration_path", return_value=cal_file, ): - export_mod._export_awq( - model_dir, str(tmp_path / "out"), None, - bits=4, group_size=128, calibration_data=str(cal_file), - ) - quant_call = mock_model.quantize.call_args - assert quant_call is not None - assert "calib_data" in quant_call.kwargs + with mock_patch( + "soup_cli.commands.export._validate_output_path", + return_value=out_path, + ): + export_mod._export_awq( + model_dir, str(out_path), None, + bits=4, group_size=128, + calibration_data=str(cal_file), + ) + quant_call = mock_model.quantize.call_args + assert quant_call is not None + assert "calib_data" in quant_call.kwargs def test_export_awq_invalid_bits(self, tmp_path): """Invalid bits value should raise ClickExit.""" @@ -286,6 +297,7 @@ class TestGptqExportFunction: import soup_cli.commands.export as export_mod + out_path = tmp_path / "out" with mock_patch.object( builtins, "__import__", side_effect=_mock_import(gptq_mock=gptq_mod) ): @@ -293,13 +305,17 @@ class TestGptqExportFunction: "transformers.AutoTokenizer.from_pretrained", return_value=mock_tokenizer, ): - export_mod._export_gptq( - model_dir, str(tmp_path / "out"), None, - bits=4, group_size=128, calibration_data=None, - ) - mock_gptq_class.from_pretrained.assert_called_once() - mock_model.quantize.assert_called_once() - mock_model.save_quantized.assert_called_once() + with mock_patch( + "soup_cli.commands.export._validate_output_path", + return_value=out_path, + ): + export_mod._export_gptq( + model_dir, str(out_path), None, + bits=4, group_size=128, calibration_data=None, + ) + mock_gptq_class.from_pretrained.assert_called_once() + mock_model.quantize.assert_called_once() + mock_model.save_quantized.assert_called_once() def test_export_gptq_default_output_path(self, tmp_path): """Default GPTQ output path should be model_name + _gptq suffix.""" @@ -552,6 +568,41 @@ class TestCalibrationPathValidation: _validate_calibration_path("C:/Windows/System32/drivers/etc/hosts") +# ─── Output Path Validation Tests ──────────────────────────────────────── + + +class TestOutputPathValidation: + """Test _validate_output_path function.""" + + def test_none_returns_none(self): + from soup_cli.commands.export import _validate_output_path + + assert _validate_output_path(None) is None + + def test_valid_path_returns_path(self): + """Valid path under cwd should return resolved Path.""" + from soup_cli.commands.export import _validate_output_path + + out = Path.cwd() / "test_output_xyzzy" + result = _validate_output_path(str(out)) + assert result is not None + assert result.name == "test_output_xyzzy" + + def test_path_outside_cwd_raises(self): + """Path outside cwd should raise ClickExit.""" + from soup_cli.commands.export import _validate_output_path + + with pytest.raises(ClickExit): + _validate_output_path("C:/Windows/System32/evil_output") + + def test_path_traversal_rejected(self): + """Relative path traversal should be rejected.""" + from soup_cli.commands.export import _validate_output_path + + with pytest.raises(ClickExit): + _validate_output_path("../../../tmp/evil") + + # ─── Optional Dependency Tests ────────────────────────────────────────────