lemming-cli 0.7.1__tar.gz → 0.7.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/.github/workflows/ci.yml +4 -1
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/.github/workflows/screenshots.yml +1 -1
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/Dockerfile +1 -1
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/PKG-INFO +2 -2
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/docker-compose.yml +1 -1
- lemming_cli-0.7.2/docs/screenshots/dashboard-desktop.png +0 -0
- lemming_cli-0.7.2/docs/screenshots/dashboard-mobile.png +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/pyproject.toml +2 -2
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/auth.py +18 -5
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/auth_test.py +10 -2
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/config.py +21 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/config_test.py +15 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/tasks.py +18 -4
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/tasks_test.py +18 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/operations.py +10 -3
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/operations_test.py +17 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/readability.py +23 -2
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/readability_test.py +47 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/paths.py +31 -8
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/paths_test.py +34 -6
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/scope.py +4 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/scope_test.py +8 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/uv.lock +6 -6
- lemming_cli-0.7.1/docs/screenshots/dashboard-desktop.png +0 -0
- lemming_cli-0.7.1/docs/screenshots/dashboard-mobile.png +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/.dockerignore +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/.github/workflows/publish.yml +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/.gitignore +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/.prettierignore +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/.prettierrc +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/LICENSE +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/README.md +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/SKILL.md +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/biome.json +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/docs/EVALS.md +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/docs/HOOKS.md +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/docs/WORKSPACE.md +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/docs/screenshots/task-log-desktop.png +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/docs/screenshots/task-log-mobile.png +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/package-lock.json +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/package.json +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/playwright.config.js +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/__init__.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/__init__.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/conftest.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/context.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/context_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/directories.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/directories_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/files.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/files_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/hooks.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/hooks_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/logging.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/logging_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/loop.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/loop_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/main.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/api/main_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/artifact_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/brief_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/__init__.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/artifact.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/brief.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/config.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/config_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/exec_cli.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/exec_cli_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/goal.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/goal_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/hooks.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/hooks_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/machine_output_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/main.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/main_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/progress.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/progress_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/readability_cli.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/readability_cli_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/skill.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/skill_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/stop_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/tasks.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/cli/tasks_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/conftest.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/__init__.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/__main__.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/cli.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/cli_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/container.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/container_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/fixtures.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/fixtures_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/harness.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/harness_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/metrics.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/metrics_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/readability/base/README.md +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/readability/base/calc/__init__.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/readability/base/calc/legacy.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/readability/base/calc/ops.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/readability/base/calc/ops_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/readability/clean/calc/ops.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/readability/clean/calc/ops_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/readability/dead-code/calc/ops.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/readability/dead-code/calc/ops_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/readability/duplicated-behavior/calc/ops.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/readability/duplicated-behavior/calc/ops_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/readability/false-reuse/calc/limits.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/readability/false-reuse/calc/limits_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/readability/lint-debt/calc/ops.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/readability/lint-debt/calc/ops_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/roadmap/add-only/README.md +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/roadmap/add-only/calc/__init__.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/roadmap/add-only/calc/ops.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/roadmap/add-only/calc/ops_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/roadmap/buggy-subtract/README.md +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/roadmap/buggy-subtract/calc/__init__.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/roadmap/buggy-subtract/calc/ops.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/roadmap/buggy-subtract/calc/ops_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/roadmap/full-ops/README.md +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/roadmap/full-ops/calc/__init__.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/roadmap/full-ops/calc/ops.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/roadmap/full-ops/calc/ops_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/roadmap/unwired-cli/README.md +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/roadmap/unwired-cli/calc/__init__.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/roadmap/unwired-cli/calc/cli.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/roadmap/unwired-cli/calc/cli_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/roadmap/unwired-cli/calc/ops.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/roadmap/unwired-cli/calc/ops_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/task/hidden/stats/summary_hidden_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/task/summary/README.md +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/task/summary/stats/__init__.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/task/summary/stats/summary.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/projects/task/summary/stats/summary_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/report.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/report_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/roadmap.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/roadmap_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/scenarios.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/scenarios_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/suites.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/task.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/task_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/trial.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/evals/trial_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/hooks.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/hooks_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/integration_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/main.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/main_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/models.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/models_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/orchestrator.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/orchestrator_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/persistence.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/persistence_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/prompts/hooks/50-readability.md +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/prompts/hooks/60-testing.md +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/prompts/hooks/70-ux.md +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/prompts/hooks/90-roadmap.md +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/prompts/taskrunner.md +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/prompts.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/prompts_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/providers.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/providers_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/runner.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/runner_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/shutdown.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/shutdown_integration_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/shutdown_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/tasks/__init__.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/tasks/lifecycle.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/tasks/lifecycle_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/tasks/limits.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/tasks/operations.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/tasks/operations_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/tasks/progress.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/tasks/progress_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/tasks/queries.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/tasks/queries_test.py +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/web/dashboard.spec.js +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/web/dashboard.test.js +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/web/favicon.js +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/web/favicon.spec.js +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/web/files.html +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/web/files.spec.js +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/web/index.html +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/web/index.js +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/web/logs.html +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/web/logs.test.js +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/web/mancha.js +0 -0
- {lemming_cli-0.7.1 → lemming_cli-0.7.2}/src/lemming/web/screenshots.spec.js +0 -0
|
@@ -6,6 +6,9 @@ on:
|
|
|
6
6
|
pull_request:
|
|
7
7
|
branches: [main]
|
|
8
8
|
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
9
12
|
jobs:
|
|
10
13
|
lint-and-test:
|
|
11
14
|
runs-on: ubuntu-latest
|
|
@@ -28,7 +31,7 @@ jobs:
|
|
|
28
31
|
|
|
29
32
|
# Before the Python tests: they invoke readability, which runs this.
|
|
30
33
|
- name: Install Node.js dependencies
|
|
31
|
-
run: npm
|
|
34
|
+
run: npm ci
|
|
32
35
|
|
|
33
36
|
# Runs ruff and ruff format as before, and adds the type and prose
|
|
34
37
|
# checks the separate steps never covered.
|
|
@@ -28,7 +28,7 @@ RUN uv sync --no-dev --group evals --frozen
|
|
|
28
28
|
# the web assets. The browsers are not, and they are more than half the
|
|
29
29
|
# image, so anything that will never drive one builds without them.
|
|
30
30
|
ARG INSTALL_BROWSERS=true
|
|
31
|
-
RUN npm
|
|
31
|
+
RUN npm ci \
|
|
32
32
|
&& if [ "$INSTALL_BROWSERS" = "true" ]; then \
|
|
33
33
|
npx playwright install --with-deps; \
|
|
34
34
|
fi
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lemming-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: An autonomous, iterative task runner for AI coding agents.
|
|
5
5
|
Project-URL: Homepage, https://github.com/owahltinez/lemming
|
|
6
6
|
Project-URL: Repository, https://github.com/owahltinez/lemming
|
|
@@ -14,7 +14,7 @@ Requires-Dist: click>=8.0.0
|
|
|
14
14
|
Requires-Dist: fastapi>=0.124.4
|
|
15
15
|
Requires-Dist: pydantic>=2.12.5
|
|
16
16
|
Requires-Dist: pyyaml>=6.0.3
|
|
17
|
-
Requires-Dist: readability-cli>=0.10.
|
|
17
|
+
Requires-Dist: readability-cli>=0.10.6
|
|
18
18
|
Requires-Dist: uvicorn>=0.33.0
|
|
19
19
|
Description-Content-Type: text/markdown
|
|
20
20
|
|
|
Binary file
|
|
Binary file
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lemming-cli"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.2"
|
|
8
8
|
description = "An autonomous, iterative task runner for AI coding agents."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -19,7 +19,7 @@ dependencies = [
|
|
|
19
19
|
"fastapi>=0.124.4",
|
|
20
20
|
"pydantic>=2.12.5",
|
|
21
21
|
"pyyaml>=6.0.3",
|
|
22
|
-
"readability-cli>=0.10.
|
|
22
|
+
"readability-cli>=0.10.6",
|
|
23
23
|
"uvicorn>=0.33.0",
|
|
24
24
|
]
|
|
25
25
|
|
|
@@ -23,6 +23,13 @@ def _token_matches(candidate: str | None, share_token: str) -> bool:
|
|
|
23
23
|
return secrets.compare_digest(candidate.encode(), share_token.encode())
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
def _with_security_headers(response: fastapi.Response) -> fastapi.Response:
|
|
27
|
+
"""Attaches baseline security headers to an outgoing response."""
|
|
28
|
+
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
|
29
|
+
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
|
30
|
+
return response
|
|
31
|
+
|
|
32
|
+
|
|
26
33
|
async def share_token_middleware(request: fastapi.Request, call_next):
|
|
27
34
|
"""Require a share token for every request when one is configured.
|
|
28
35
|
|
|
@@ -40,18 +47,24 @@ async def share_token_middleware(request: fastapi.Request, call_next):
|
|
|
40
47
|
"""
|
|
41
48
|
share_token = getattr(request.app.state, "share_token", None)
|
|
42
49
|
if not share_token:
|
|
43
|
-
return await call_next(request)
|
|
50
|
+
return _with_security_headers(await call_next(request))
|
|
44
51
|
|
|
45
52
|
token = request.query_params.get("token")
|
|
46
53
|
if _token_matches(token, share_token):
|
|
47
54
|
response = await call_next(request)
|
|
48
55
|
response.set_cookie(
|
|
49
|
-
key="lemming_share_token",
|
|
56
|
+
key="lemming_share_token",
|
|
57
|
+
value=token,
|
|
58
|
+
httponly=True,
|
|
59
|
+
samesite="strict",
|
|
60
|
+
secure=request.url.scheme == "https",
|
|
50
61
|
)
|
|
51
|
-
return response
|
|
62
|
+
return _with_security_headers(response)
|
|
52
63
|
|
|
53
64
|
cookie_token = request.cookies.get("lemming_share_token")
|
|
54
65
|
if _token_matches(cookie_token, share_token):
|
|
55
|
-
return await call_next(request)
|
|
66
|
+
return _with_security_headers(await call_next(request))
|
|
56
67
|
|
|
57
|
-
return
|
|
68
|
+
return _with_security_headers(
|
|
69
|
+
fastapi.Response("Unauthorized", status_code=401)
|
|
70
|
+
)
|
|
@@ -20,9 +20,17 @@ def test_share_token_middleware():
|
|
|
20
20
|
# Valid token via query
|
|
21
21
|
response = client.get("/api/data?token=secret123")
|
|
22
22
|
assert response.status_code == 200
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
set_cookie = response.headers.get("set-cookie", "")
|
|
24
|
+
assert "lemming_share_token=secret123" in set_cookie
|
|
25
|
+
assert "samesite=strict" in set_cookie.lower()
|
|
26
|
+
assert response.headers.get("x-content-type-options") == "nosniff"
|
|
27
|
+
assert response.headers.get("referrer-policy") == "no-referrer"
|
|
28
|
+
|
|
29
|
+
https_client = fastapi.testclient.TestClient(
|
|
30
|
+
main.app, base_url="https://testserver"
|
|
25
31
|
)
|
|
32
|
+
https_resp = https_client.get("/api/data?token=secret123")
|
|
33
|
+
assert "secure" in https_resp.headers.get("set-cookie", "").lower()
|
|
26
34
|
|
|
27
35
|
# Valid token via cookie
|
|
28
36
|
client.cookies.set("lemming_share_token", "secret123")
|
|
@@ -51,6 +51,18 @@ class RunRequest(pydantic.BaseModel):
|
|
|
51
51
|
env: dict[str, str] | None = None
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
_BLOCKED_ENV_VARS = frozenset(
|
|
55
|
+
{
|
|
56
|
+
"LD_PRELOAD",
|
|
57
|
+
"LD_LIBRARY_PATH",
|
|
58
|
+
"DYLD_INSERT_LIBRARIES",
|
|
59
|
+
"PYTHONSTARTUP",
|
|
60
|
+
"PYTHONPATH",
|
|
61
|
+
"NODE_OPTIONS",
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
54
66
|
@router.post("/api/run")
|
|
55
67
|
def run_loop(
|
|
56
68
|
request: fastapi.Request,
|
|
@@ -58,6 +70,15 @@ def run_loop(
|
|
|
58
70
|
project: str | None = None,
|
|
59
71
|
):
|
|
60
72
|
"""Start the orchestrator loop as a detached background process."""
|
|
73
|
+
if run_request.env:
|
|
74
|
+
blocked = sorted(
|
|
75
|
+
key for key in run_request.env if key.upper() in _BLOCKED_ENV_VARS
|
|
76
|
+
)
|
|
77
|
+
if blocked:
|
|
78
|
+
raise fastapi.HTTPException(
|
|
79
|
+
400, f"Forbidden environment variable(s): {', '.join(blocked)}"
|
|
80
|
+
)
|
|
81
|
+
|
|
61
82
|
tasks_file = context.resolve_tasks_file(request.app.state, project)
|
|
62
83
|
project_dir = context.resolve_project_dir(request.app.state, project)
|
|
63
84
|
|
|
@@ -82,3 +82,18 @@ def test_run_loop_with_project_cwd(test_workspace, client):
|
|
|
82
82
|
# Verify Popen was called with the correct cwd
|
|
83
83
|
_, kwargs = mock_popen.call_args
|
|
84
84
|
assert str(kwargs["cwd"]) == str(subproject_dir)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_run_loop_blocks_dangerous_env_vars(client, test_tasks):
|
|
88
|
+
for var in (
|
|
89
|
+
"LD_PRELOAD",
|
|
90
|
+
"LD_LIBRARY_PATH",
|
|
91
|
+
"DYLD_INSERT_LIBRARIES",
|
|
92
|
+
"PYTHONSTARTUP",
|
|
93
|
+
"PYTHONPATH",
|
|
94
|
+
"NODE_OPTIONS",
|
|
95
|
+
):
|
|
96
|
+
with patch("subprocess.Popen") as mock_popen:
|
|
97
|
+
response = client.post("/api/run", json={"env": {var: "evil"}})
|
|
98
|
+
assert response.status_code == 400, var
|
|
99
|
+
mock_popen.assert_not_called()
|
|
@@ -34,6 +34,17 @@ def add_task(
|
|
|
34
34
|
request: fastapi.Request, task: AddTaskRequest, project: str | None = None
|
|
35
35
|
):
|
|
36
36
|
"""Add a new task and start the orchestrator loop if needed."""
|
|
37
|
+
if task.parent_tasks_file:
|
|
38
|
+
root = request.app.state.root.resolve()
|
|
39
|
+
lemming_home = paths.get_lemming_home().resolve()
|
|
40
|
+
candidate = (root / task.parent_tasks_file).resolve()
|
|
41
|
+
if not (
|
|
42
|
+
candidate.is_relative_to(root)
|
|
43
|
+
or candidate.is_relative_to(lemming_home)
|
|
44
|
+
):
|
|
45
|
+
raise fastapi.HTTPException(
|
|
46
|
+
400, "parent_tasks_file must be inside the workspace"
|
|
47
|
+
)
|
|
37
48
|
tasks_file = context.resolve_tasks_file(request.app.state, project)
|
|
38
49
|
try:
|
|
39
50
|
new_task = operations.add_task(
|
|
@@ -200,9 +211,12 @@ def get_task_log(
|
|
|
200
211
|
request: fastapi.Request, task_id: str, project: str | None = None
|
|
201
212
|
):
|
|
202
213
|
"""Get the runner log output for a task, or empty if none exists."""
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
214
|
+
tasks_file = context.resolve_tasks_file(request.app.state, project)
|
|
215
|
+
try:
|
|
216
|
+
paths._validate_task_id(task_id)
|
|
217
|
+
log_file = queries.resolve_log_file(tasks_file, task_id)
|
|
218
|
+
except models.TaskNotFoundError:
|
|
207
219
|
return {"log": ""}
|
|
220
|
+
except (ValueError, models.AmbiguousTaskIdError) as e:
|
|
221
|
+
raise fastapi.HTTPException(400, str(e))
|
|
208
222
|
return {"log": log_file.read_text(encoding="utf-8")}
|
|
@@ -511,3 +511,21 @@ def test_add_task_refuses_corrupted_tasks_file(client, test_tasks):
|
|
|
511
511
|
assert response.status_code == 500
|
|
512
512
|
assert str(test_tasks) in response.json()["detail"]
|
|
513
513
|
assert test_tasks.read_text(encoding="utf-8") == corrupt
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def test_add_task_rejects_outside_parent_tasks_file(client, test_tasks):
|
|
517
|
+
for bad_path in ("/etc/passwd", "../../outside.yml"):
|
|
518
|
+
response = client.post(
|
|
519
|
+
"/api/tasks",
|
|
520
|
+
json={
|
|
521
|
+
"description": "Child task",
|
|
522
|
+
"parent": "task1",
|
|
523
|
+
"parent_tasks_file": bad_path,
|
|
524
|
+
},
|
|
525
|
+
)
|
|
526
|
+
assert response.status_code == 400, bad_path
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def test_get_task_log_rejects_traversal(client, test_tasks):
|
|
530
|
+
response = client.get("/api/tasks/..%2Fsecret/log")
|
|
531
|
+
assert response.status_code in (400, 404)
|
|
@@ -278,6 +278,12 @@ def serve(
|
|
|
278
278
|
api_main.app.state.verbose = ctx.obj["VERBOSE"]
|
|
279
279
|
api_main.app.state.root = pathlib.Path.cwd().resolve()
|
|
280
280
|
|
|
281
|
+
is_remote_host = host not in ("127.0.0.1", "::1", "localhost")
|
|
282
|
+
token = os.environ.get("LEMMING_SHARE_TOKEN") or (
|
|
283
|
+
secrets.token_urlsafe(32) if (tunnel or is_remote_host) else None
|
|
284
|
+
)
|
|
285
|
+
api_main.app.state.share_token = token
|
|
286
|
+
|
|
281
287
|
tunnel_proc = None
|
|
282
288
|
if tunnel:
|
|
283
289
|
click.echo(f"[ Lemming ] Starting local server on port {port}...")
|
|
@@ -296,9 +302,6 @@ def serve(
|
|
|
296
302
|
click.echo(f"[ Lemming ] Error starting tunnel: {e}", err=True)
|
|
297
303
|
sys.exit(1)
|
|
298
304
|
|
|
299
|
-
token = secrets.token_urlsafe(32)
|
|
300
|
-
api_main.app.state.share_token = token
|
|
301
|
-
|
|
302
305
|
click.echo("[ Lemming ] ")
|
|
303
306
|
click.echo("[ Lemming ] ⚠️ SECURITY WARNING ")
|
|
304
307
|
click.echo(
|
|
@@ -317,6 +320,10 @@ def serve(
|
|
|
317
320
|
# The token is required locally too, so echo a usable local link.
|
|
318
321
|
click.echo(f"[ Lemming ] 🖥️ Local: http://{host}:{port}?token={token}")
|
|
319
322
|
click.echo("")
|
|
323
|
+
elif token:
|
|
324
|
+
click.echo(
|
|
325
|
+
f"Launching Lemming UI at http://{host}:{port}?token={token}"
|
|
326
|
+
)
|
|
320
327
|
else:
|
|
321
328
|
click.echo(f"Launching Lemming UI at http://{host}:{port}")
|
|
322
329
|
|
|
@@ -9,6 +9,7 @@ from unittest import mock
|
|
|
9
9
|
import click.testing
|
|
10
10
|
|
|
11
11
|
from lemming import models, persistence
|
|
12
|
+
from lemming.api import main as api_main
|
|
12
13
|
from lemming.cli import main as cli
|
|
13
14
|
from lemming.cli import operations
|
|
14
15
|
|
|
@@ -138,6 +139,22 @@ class TestCLIOperations(unittest.TestCase):
|
|
|
138
139
|
self.assertEqual(result.exit_code, 0)
|
|
139
140
|
self.assertIn("Launches the local web dashboard", result.output)
|
|
140
141
|
|
|
142
|
+
def test_serve_non_loopback_enables_share_token(self):
|
|
143
|
+
original_token = getattr(api_main.app.state, "share_token", None)
|
|
144
|
+
try:
|
|
145
|
+
with mock.patch("uvicorn.run"):
|
|
146
|
+
result = self.cli_runner.invoke(
|
|
147
|
+
cli.cli,
|
|
148
|
+
self.base_args + ["serve", "--host", "0.0.0.0"],
|
|
149
|
+
)
|
|
150
|
+
self.assertEqual(result.exit_code, 0)
|
|
151
|
+
self.assertTrue(api_main.app.state.share_token)
|
|
152
|
+
self.assertIn(
|
|
153
|
+
f"?token={api_main.app.state.share_token}", result.output
|
|
154
|
+
)
|
|
155
|
+
finally:
|
|
156
|
+
api_main.app.state.share_token = original_token
|
|
157
|
+
|
|
141
158
|
|
|
142
159
|
class TestQuietPollLogConfig(unittest.TestCase):
|
|
143
160
|
def test_filter_path_resolves(self):
|
|
@@ -18,11 +18,12 @@ lint-debt-repaid against clean-fast-exit.
|
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
20
|
import pathlib
|
|
21
|
+
import shutil
|
|
21
22
|
import subprocess
|
|
22
23
|
import sys
|
|
23
24
|
|
|
24
25
|
from .. import models
|
|
25
|
-
from . import fixtures, metrics, scenarios
|
|
26
|
+
from . import container, fixtures, metrics, scenarios
|
|
26
27
|
|
|
27
28
|
_GOAL = (
|
|
28
29
|
"Build a small calculator CLI in calc/ with add, subtract, and "
|
|
@@ -94,8 +95,28 @@ def _save_finished_task(workspace: pathlib.Path, progress: list[str]) -> None:
|
|
|
94
95
|
|
|
95
96
|
def _check_tests_pass(workspace: pathlib.Path) -> scenarios.Check:
|
|
96
97
|
"""Checks that the fixture's unit tests still pass after the hook."""
|
|
98
|
+
if shutil.which("docker"):
|
|
99
|
+
cmd = [
|
|
100
|
+
"docker",
|
|
101
|
+
"run",
|
|
102
|
+
"--rm",
|
|
103
|
+
"--network=none",
|
|
104
|
+
"-v",
|
|
105
|
+
f"{workspace}:{container.WORKSPACE_MOUNT}:ro",
|
|
106
|
+
"-w",
|
|
107
|
+
container.WORKSPACE_MOUNT,
|
|
108
|
+
container.DEFAULT_IMAGE,
|
|
109
|
+
"python3",
|
|
110
|
+
"-m",
|
|
111
|
+
"unittest",
|
|
112
|
+
"discover",
|
|
113
|
+
"-p",
|
|
114
|
+
"*_test.py",
|
|
115
|
+
]
|
|
116
|
+
else:
|
|
117
|
+
cmd = [sys.executable, "-m", "unittest", "discover", "-p", "*_test.py"]
|
|
97
118
|
result = subprocess.run(
|
|
98
|
-
|
|
119
|
+
cmd,
|
|
99
120
|
cwd=workspace,
|
|
100
121
|
capture_output=True,
|
|
101
122
|
text=True,
|
|
@@ -2,6 +2,7 @@ import pathlib
|
|
|
2
2
|
import shutil
|
|
3
3
|
import tempfile
|
|
4
4
|
import unittest
|
|
5
|
+
import unittest.mock
|
|
5
6
|
|
|
6
7
|
from lemming import models
|
|
7
8
|
from lemming.evals import fixtures, readability, scenarios, suites
|
|
@@ -39,6 +40,15 @@ class ScenarioTestCase(unittest.TestCase):
|
|
|
39
40
|
def setUp(self):
|
|
40
41
|
self.workspace = pathlib.Path(tempfile.mkdtemp())
|
|
41
42
|
self.addCleanup(shutil.rmtree, self.workspace, ignore_errors=True)
|
|
43
|
+
orig_which = shutil.which
|
|
44
|
+
patcher = unittest.mock.patch(
|
|
45
|
+
"lemming.evals.readability.shutil.which",
|
|
46
|
+
side_effect=lambda cmd: (
|
|
47
|
+
None if cmd == "docker" else orig_which(cmd)
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
patcher.start()
|
|
51
|
+
self.addCleanup(patcher.stop)
|
|
42
52
|
|
|
43
53
|
def build(self, name: str):
|
|
44
54
|
self.scenario = _scenario(name)
|
|
@@ -370,6 +380,43 @@ class TestFixtureIntegrity(ScenarioTestCase):
|
|
|
370
380
|
check = readability._check_tests_pass(workspace)
|
|
371
381
|
self.assertTrue(check.passed, check.detail)
|
|
372
382
|
|
|
383
|
+
def test_check_tests_pass_uses_docker_when_available(self):
|
|
384
|
+
completed = unittest.mock.Mock(returncode=0, stderr="")
|
|
385
|
+
with (
|
|
386
|
+
unittest.mock.patch(
|
|
387
|
+
"lemming.evals.readability.shutil.which",
|
|
388
|
+
return_value="/usr/bin/docker",
|
|
389
|
+
),
|
|
390
|
+
unittest.mock.patch(
|
|
391
|
+
"lemming.evals.readability.subprocess.run",
|
|
392
|
+
return_value=completed,
|
|
393
|
+
) as mock_run,
|
|
394
|
+
):
|
|
395
|
+
check = readability._check_tests_pass(self.workspace)
|
|
396
|
+
|
|
397
|
+
self.assertTrue(check.passed)
|
|
398
|
+
cmd = mock_run.call_args[0][0]
|
|
399
|
+
self.assertEqual(
|
|
400
|
+
cmd,
|
|
401
|
+
[
|
|
402
|
+
"docker",
|
|
403
|
+
"run",
|
|
404
|
+
"--rm",
|
|
405
|
+
"--network=none",
|
|
406
|
+
"-v",
|
|
407
|
+
f"{self.workspace}:/workspace:ro",
|
|
408
|
+
"-w",
|
|
409
|
+
"/workspace",
|
|
410
|
+
"lemming-evals",
|
|
411
|
+
"python3",
|
|
412
|
+
"-m",
|
|
413
|
+
"unittest",
|
|
414
|
+
"discover",
|
|
415
|
+
"-p",
|
|
416
|
+
"*_test.py",
|
|
417
|
+
],
|
|
418
|
+
)
|
|
419
|
+
|
|
373
420
|
|
|
374
421
|
if __name__ == "__main__":
|
|
375
422
|
unittest.main()
|
|
@@ -257,6 +257,18 @@ def get_working_dir(tasks_file: pathlib.Path) -> pathlib.Path:
|
|
|
257
257
|
return tasks_file_abs.parent
|
|
258
258
|
|
|
259
259
|
|
|
260
|
+
def _validate_task_id(task_id: str) -> None:
|
|
261
|
+
"""Rejects empty or path-traversing task identifiers."""
|
|
262
|
+
if (
|
|
263
|
+
not task_id
|
|
264
|
+
or "/" in task_id
|
|
265
|
+
or "\\" in task_id
|
|
266
|
+
or ".." in task_id
|
|
267
|
+
or pathlib.Path(task_id).name != task_id
|
|
268
|
+
):
|
|
269
|
+
raise ValueError(f"Invalid task ID: {task_id!r}")
|
|
270
|
+
|
|
271
|
+
|
|
260
272
|
def get_log_file(tasks_file: pathlib.Path, task_id: str) -> pathlib.Path:
|
|
261
273
|
"""Returns the log file path for a specific task.
|
|
262
274
|
|
|
@@ -267,6 +279,7 @@ def get_log_file(tasks_file: pathlib.Path, task_id: str) -> pathlib.Path:
|
|
|
267
279
|
Returns:
|
|
268
280
|
A pathlib.Path to the log file for the given task.
|
|
269
281
|
"""
|
|
282
|
+
_validate_task_id(task_id)
|
|
270
283
|
project_dir = get_project_dir(tasks_file)
|
|
271
284
|
project_dir.mkdir(parents=True, exist_ok=True)
|
|
272
285
|
return project_dir / f"{task_id}-runner.log"
|
|
@@ -286,6 +299,7 @@ def get_brief_file(tasks_file: pathlib.Path, task_id: str) -> pathlib.Path:
|
|
|
286
299
|
Returns:
|
|
287
300
|
A pathlib.Path to the brief file for the given task.
|
|
288
301
|
"""
|
|
302
|
+
_validate_task_id(task_id)
|
|
289
303
|
project_dir = get_project_dir(tasks_file)
|
|
290
304
|
project_dir.mkdir(parents=True, exist_ok=True)
|
|
291
305
|
return project_dir / f"{task_id}-brief.md"
|
|
@@ -306,6 +320,7 @@ def get_artifacts_dir(tasks_file: pathlib.Path, task_id: str) -> pathlib.Path:
|
|
|
306
320
|
Returns:
|
|
307
321
|
A pathlib.Path to the artifacts directory, which may not exist yet.
|
|
308
322
|
"""
|
|
323
|
+
_validate_task_id(task_id)
|
|
309
324
|
return get_project_dir(tasks_file) / f"{task_id}-artifacts"
|
|
310
325
|
|
|
311
326
|
|
|
@@ -342,19 +357,20 @@ def _truncate_name(name: str) -> str:
|
|
|
342
357
|
|
|
343
358
|
|
|
344
359
|
@functools.cache
|
|
345
|
-
def in_git_repo() -> bool:
|
|
346
|
-
"""Check if the current directory is inside a git
|
|
360
|
+
def in_git_repo(cwd: pathlib.Path | None = None) -> bool:
|
|
361
|
+
"""Check if a directory (or the current directory) is inside a git repo.
|
|
347
362
|
|
|
348
|
-
|
|
349
|
-
|
|
363
|
+
Args:
|
|
364
|
+
cwd: Directory to check, defaulting to the current working directory.
|
|
350
365
|
|
|
351
366
|
Returns:
|
|
352
367
|
True if inside a git repository, False otherwise.
|
|
353
368
|
"""
|
|
369
|
+
target = cwd if cwd is not None else pathlib.Path.cwd()
|
|
354
370
|
try:
|
|
355
371
|
return (
|
|
356
372
|
subprocess.run(
|
|
357
|
-
["git", "rev-parse", "--git-dir"],
|
|
373
|
+
["git", "-C", str(target), "rev-parse", "--git-dir"],
|
|
358
374
|
capture_output=True,
|
|
359
375
|
check=False,
|
|
360
376
|
).returncode
|
|
@@ -365,7 +381,7 @@ def in_git_repo() -> bool:
|
|
|
365
381
|
|
|
366
382
|
|
|
367
383
|
def is_ignored(path: pathlib.Path) -> bool:
|
|
368
|
-
"""Check if a given path is ignored by git.
|
|
384
|
+
"""Check if a given path is sensitive or ignored by git.
|
|
369
385
|
|
|
370
386
|
Args:
|
|
371
387
|
path: The path to check for git-ignore status.
|
|
@@ -373,12 +389,19 @@ def is_ignored(path: pathlib.Path) -> bool:
|
|
|
373
389
|
Returns:
|
|
374
390
|
True if the path is ignored by git, False otherwise.
|
|
375
391
|
"""
|
|
376
|
-
if
|
|
392
|
+
if (
|
|
393
|
+
".git" in path.parts
|
|
394
|
+
or path.name == ".env"
|
|
395
|
+
or path.name.startswith(".env.")
|
|
396
|
+
):
|
|
397
|
+
return True
|
|
398
|
+
cwd = path if path.is_dir() else path.parent
|
|
399
|
+
if not in_git_repo(cwd):
|
|
377
400
|
return False
|
|
378
401
|
try:
|
|
379
402
|
return (
|
|
380
403
|
subprocess.run(
|
|
381
|
-
["git", "check-ignore", "-q", str(path)],
|
|
404
|
+
["git", "-C", str(cwd), "check-ignore", "-q", "--", str(path)],
|
|
382
405
|
capture_output=True,
|
|
383
406
|
check=False,
|
|
384
407
|
).returncode
|
|
@@ -6,20 +6,20 @@ import subprocess
|
|
|
6
6
|
import time
|
|
7
7
|
from unittest import mock
|
|
8
8
|
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
9
11
|
from lemming import paths
|
|
10
12
|
|
|
11
13
|
|
|
12
|
-
def test_get_lemming_home_default():
|
|
14
|
+
def test_get_lemming_home_default(monkeypatch):
|
|
13
15
|
expected = pathlib.Path.home() / ".local" / "lemming"
|
|
14
|
-
|
|
15
|
-
del os.environ["LEMMING_HOME"]
|
|
16
|
+
monkeypatch.delenv("LEMMING_HOME", raising=False)
|
|
16
17
|
assert paths.get_lemming_home() == expected
|
|
17
18
|
|
|
18
19
|
|
|
19
|
-
def test_get_lemming_home_override(tmp_path):
|
|
20
|
-
|
|
20
|
+
def test_get_lemming_home_override(tmp_path, monkeypatch):
|
|
21
|
+
monkeypatch.setenv("LEMMING_HOME", str(tmp_path))
|
|
21
22
|
assert paths.get_lemming_home() == tmp_path.resolve()
|
|
22
|
-
del os.environ["LEMMING_HOME"]
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def test_get_working_dir(tmp_path, monkeypatch):
|
|
@@ -84,6 +84,34 @@ def test_get_log_file(tmp_path):
|
|
|
84
84
|
assert log_file.parent.exists()
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
def test_get_log_and_brief_file_reject_path_traversal(tmp_path):
|
|
88
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
89
|
+
for bad_id in ("../escape", "a/b", "..", ""):
|
|
90
|
+
with pytest.raises(ValueError):
|
|
91
|
+
paths.get_log_file(tasks_file, bad_id)
|
|
92
|
+
with pytest.raises(ValueError):
|
|
93
|
+
paths.get_brief_file(tasks_file, bad_id)
|
|
94
|
+
with pytest.raises(ValueError):
|
|
95
|
+
paths.get_artifacts_dir(tasks_file, bad_id)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_is_ignored_blocks_git_and_dotenv_and_uses_target_cwd(tmp_path):
|
|
99
|
+
assert paths.is_ignored(tmp_path / ".git") is True
|
|
100
|
+
assert paths.is_ignored(tmp_path / ".git" / "config") is True
|
|
101
|
+
assert paths.is_ignored(tmp_path / ".env") is True
|
|
102
|
+
assert paths.is_ignored(tmp_path / ".env.local") is True
|
|
103
|
+
|
|
104
|
+
repo_dir = tmp_path / "subrepo"
|
|
105
|
+
repo_dir.mkdir()
|
|
106
|
+
subprocess.run(["git", "-C", str(repo_dir), "init", "-q"], check=True)
|
|
107
|
+
(repo_dir / ".gitignore").write_text("secret.txt\n", encoding="utf-8")
|
|
108
|
+
(repo_dir / "secret.txt").touch()
|
|
109
|
+
(repo_dir / "public.txt").touch()
|
|
110
|
+
|
|
111
|
+
assert paths.is_ignored(repo_dir / "secret.txt") is True
|
|
112
|
+
assert paths.is_ignored(repo_dir / "public.txt") is False
|
|
113
|
+
|
|
114
|
+
|
|
87
115
|
def test_in_git_repo_and_is_ignored(tmp_path):
|
|
88
116
|
orig_cwd = os.getcwd()
|
|
89
117
|
os.chdir(tmp_path)
|
|
@@ -85,6 +85,10 @@ def _range_paths(spec: str, working_dir: pathlib.Path) -> list[str]:
|
|
|
85
85
|
Raises:
|
|
86
86
|
ScopeError: If git cannot read the range.
|
|
87
87
|
"""
|
|
88
|
+
if spec.startswith("-"):
|
|
89
|
+
raise ScopeError(
|
|
90
|
+
f"Could not resolve '{spec}' as a path or a revision range."
|
|
91
|
+
)
|
|
88
92
|
result = _git(working_dir, "diff", "--name-only", spec)
|
|
89
93
|
if result.returncode != 0:
|
|
90
94
|
detail = (result.stderr or result.stdout).strip().splitlines()
|
|
@@ -99,6 +99,14 @@ def test_an_unresolvable_range_is_reported(repo):
|
|
|
99
99
|
scope.resolve_scope(("no-such-branch...HEAD",), repo)
|
|
100
100
|
|
|
101
101
|
|
|
102
|
+
def test_a_leading_dash_range_is_rejected(repo, tmp_path):
|
|
103
|
+
"""A range starting with '-' must not be passed as a flag to git diff."""
|
|
104
|
+
out_file = tmp_path / "pwn..HEAD"
|
|
105
|
+
with pytest.raises(scope.ScopeError):
|
|
106
|
+
scope.resolve_scope((f"--output={out_file}",), repo)
|
|
107
|
+
assert not out_file.exists()
|
|
108
|
+
|
|
109
|
+
|
|
102
110
|
def test_a_path_that_does_not_exist_is_still_a_path(repo):
|
|
103
111
|
"""Requiring existence would reject globs the agent can expand."""
|
|
104
112
|
assert scope.resolve_scope(("src/**/*.py",), repo) == ["src/**/*.py"]
|