mirror of https://github.com/razor-ai/soup.git
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:
parent
d83dad0a3b
commit
02a2af4b83
|
|
@ -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 |
|
||||
|
|
|
|||
10
SECURITY.md
10
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
"""Soup CLI — Fine-tune LLMs in one command."""
|
||||
|
||||
__version__ = "0.24.0"
|
||||
__version__ = "0.24.1"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}[/]")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "-"
|
||||
|
|
|
|||
|
|
@ -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}[/]")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue