fix: v0.24.1 — Windows Unicode fix, AWQ/GPTQ output path traversal

- Replace non-ASCII symbols (checkmarks, arrows, bullets, em-dashes)
  with ASCII equivalents in Rich console output to prevent
  UnicodeEncodeError on Windows without PYTHONIOENCODING=utf-8
- Add _validate_output_path() for AWQ/GPTQ export — output path
  traversal is now checked before import check (previously unreachable
  when autoawq/auto-gptq not installed)
- 4 new tests for output path validation (2065 total, 0 failures)
- Update SECURITY.md with v0.22.0–v0.24.1 hardening history
This commit is contained in:
Alpamys 2026-04-03 23:41:44 +05:00
parent d83dad0a3b
commit 02a2af4b83
14 changed files with 132 additions and 50 deletions

View File

@ -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 |

View File

@ -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

View File

@ -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"

View File

@ -1,3 +1,3 @@
"""Soup CLI — Fine-tune LLMs in one command."""
__version__ = "0.24.0"
__version__ = "0.24.1"

View File

@ -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()

View File

@ -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)

View File

@ -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}[/]")

View File

@ -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

View File

@ -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}")

View File

@ -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}",
))

View File

@ -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 "-"

View File

@ -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}[/]")

View File

@ -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

View File

@ -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 ────────────────────────────────────────────