argus-code-review 0.3.1__tar.gz → 0.3.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.
Files changed (160) hide show
  1. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/CHANGELOG.md +8 -0
  2. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/PKG-INFO +3 -1
  3. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/README.md +2 -0
  4. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/cli.py +19 -2
  5. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/config.py +21 -1
  6. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/dotenv_utils.py +20 -0
  7. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/graph.py +10 -0
  8. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/scope.py +7 -0
  9. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_cli_args.py +23 -0
  10. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_cli_preflight.py +1 -0
  11. argus_code_review-0.3.2/tests/test_dotenv_utils.py +93 -0
  12. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_scope_gate.py +16 -0
  13. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/.gitignore +0 -0
  14. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/LICENSE +0 -0
  15. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/__init__.py +0 -0
  16. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/bench.py +0 -0
  17. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/bench_default.toml +0 -0
  18. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/coverage.py +0 -0
  19. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/gemini_cache.py +0 -0
  20. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/gemini_runner.py +0 -0
  21. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/github_client.py +0 -0
  22. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/helpers.py +0 -0
  23. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/llm/models.py +0 -0
  24. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/llm/output_models.py +0 -0
  25. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/llm/pricing.py +0 -0
  26. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/llm/usage.py +0 -0
  27. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/models.py +0 -0
  28. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/openai_client.py +0 -0
  29. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/openai_runner.py +0 -0
  30. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/pipeline_models.py +0 -0
  31. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/precheck/__init__.py +0 -0
  32. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/precheck/actions_scanner.py +0 -0
  33. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/precheck/engine.py +0 -0
  34. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/precheck/eslint_bundle/.gitignore +0 -0
  35. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/precheck/eslint_bundle/README.md +0 -0
  36. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/precheck/eslint_bundle/eslint.config.js +0 -0
  37. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/precheck/eslint_bundle/package-lock.json +0 -0
  38. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/precheck/eslint_bundle/package.json +0 -0
  39. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/precheck/js_scanner.py +0 -0
  40. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/precheck/migration_scanner.py +0 -0
  41. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/precheck/rules/README.md +0 -0
  42. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/precheck/sarif.py +0 -0
  43. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/precheck/scanner_utils.py +0 -0
  44. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/precheck/secrets_scanner.py +0 -0
  45. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/precheck/shadow.py +0 -0
  46. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/precheck/terraform_scanner.py +0 -0
  47. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/precheck/workflow_lint_scanner.py +0 -0
  48. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/__init__.py +0 -0
  49. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-blocking-validator.md +0 -0
  50. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-coverage-check.md +0 -0
  51. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-cross-cutting.md +0 -0
  52. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-feedback-verifier.md +0 -0
  53. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-lite.md +0 -0
  54. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-planner.md +0 -0
  55. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-preflight-router.md +0 -0
  56. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-prior-art.md +0 -0
  57. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-scope.md +0 -0
  58. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-specialist-deployment.md +0 -0
  59. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-specialist-frontend.md +0 -0
  60. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-specialist-infra.md +0 -0
  61. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-specialist-llm-patterns.md +0 -0
  62. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-specialist-observability.md +0 -0
  63. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-specialist-orchestration.md +0 -0
  64. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-specialist-security.md +0 -0
  65. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-specialist-slackbot.md +0 -0
  66. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-specialist-sql.md +0 -0
  67. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-subagent.md +0 -0
  68. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-tests-and-docs.md +0 -0
  69. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts/pr-review-writer.md +0 -0
  70. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/prompts_runtime.py +0 -0
  71. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/repo_provision.py +0 -0
  72. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/review_tools.py +0 -0
  73. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/runners.py +0 -0
  74. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/storage/__init__.py +0 -0
  75. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/storage/http.py +0 -0
  76. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/storage/models.py +0 -0
  77. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/storage/precheck.py +0 -0
  78. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/storage/resolver.py +0 -0
  79. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/storage/session.py +0 -0
  80. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/storage/sql.py +0 -0
  81. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/argus/storage/sqlite.py +0 -0
  82. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/pyproject.toml +0 -0
  83. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/schema/008_add_code_reviews.sql +0 -0
  84. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/schema/009_add_reviewer_version.sql +0 -0
  85. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/schema/010_add_review_patterns.sql +0 -0
  86. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/schema/011_add_review_progress_columns.sql +0 -0
  87. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/schema/015_create_agent_runs.sql +0 -0
  88. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/schema/016_add_agent_runs_failure_reason.sql +0 -0
  89. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/schema/017_add_precheck_rules.sql +0 -0
  90. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/schema/018_widen_agent_runs_failure_reason.sql +0 -0
  91. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/__init__.py +0 -0
  92. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/conftest.py +0 -0
  93. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/golden/review_response.schema.json +0 -0
  94. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/storage/__init__.py +0 -0
  95. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/storage/test_backend_contract.py +0 -0
  96. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/storage/test_http.py +0 -0
  97. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/storage/test_models.py +0 -0
  98. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/storage/test_resolver.py +0 -0
  99. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/storage/test_session.py +0 -0
  100. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/storage/test_sql.py +0 -0
  101. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/storage/test_sqlite_backend.py +0 -0
  102. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_actions_scanner.py +0 -0
  103. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_argus_review_local.py +0 -0
  104. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_bench.py +0 -0
  105. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_bench_config_guard.py +0 -0
  106. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_catchup_gate.py +0 -0
  107. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_cli_output_contract.py +0 -0
  108. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_cli_post_review.py +0 -0
  109. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_cli_prompts.py +0 -0
  110. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_config.py +0 -0
  111. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_conftest.py +0 -0
  112. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_gemini_cache.py +0 -0
  113. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_gemini_runner.py +0 -0
  114. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_github_client_checks_signal.py +0 -0
  115. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_github_client_write.py +0 -0
  116. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_graph_fetch_diff.py +0 -0
  117. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_graph_get_llm_temperature.py +0 -0
  118. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_graph_http_guards.py +0 -0
  119. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_graph_precheck.py +0 -0
  120. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_graph_preflight_image_bump.py +0 -0
  121. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_graph_progress.py +0 -0
  122. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_graph_storage_resolution.py +0 -0
  123. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_graph_timeout_surfacing.py +0 -0
  124. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_js_scanner.py +0 -0
  125. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_llm_models.py +0 -0
  126. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_llm_models_override.py +0 -0
  127. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_llm_pricing.py +0 -0
  128. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_migration_scanner.py +0 -0
  129. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_models.py +0 -0
  130. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_multi_round.py +0 -0
  131. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_openai_client.py +0 -0
  132. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_openai_runner.py +0 -0
  133. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_output_models.py +0 -0
  134. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_packaging.py +0 -0
  135. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_plan_review.py +0 -0
  136. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_precheck_engine.py +0 -0
  137. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_precheck_engine_integration.py +0 -0
  138. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_precheck_sarif.py +0 -0
  139. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_precheck_shadow.py +0 -0
  140. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_precheck_shadow_integration.py +0 -0
  141. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_prompts.py +0 -0
  142. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_repo_provision.py +0 -0
  143. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_review_patterns_integration.py +0 -0
  144. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_review_tools.py +0 -0
  145. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_runners_context7.py +0 -0
  146. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_runners_context_usage.py +0 -0
  147. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_runners_helpers.py +0 -0
  148. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_runners_new.py +0 -0
  149. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_runners_turn_budget.py +0 -0
  150. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_scanner_utils.py +0 -0
  151. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_scope.py +0 -0
  152. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_sdk_bundled_cli_version.py +0 -0
  153. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_secrets_scanner.py +0 -0
  154. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_specialist_validation.py +0 -0
  155. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_stage_costs.py +0 -0
  156. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_storage_precheck.py +0 -0
  157. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_structured_output_methods.py +0 -0
  158. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_terraform_scanner.py +0 -0
  159. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_terraform_scanner_integration.py +0 -0
  160. {argus_code_review-0.3.1 → argus_code_review-0.3.2}/tests/test_workflow_lint_scanner.py +0 -0
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.2] - 2026-10-21
11
+
12
+ ### Added
13
+
14
+ - CLI `--expect-head-sha <sha>`: the review fails before any LLM spend unless the resolved head commit equals this 40-character lowercase SHA, so a service that reviews a specific commit never silently reviews a newer one if the PR head moves (TECH-7789). Also available as `run_review(..., expect_head_sha=...)`.
15
+ - `ARGUS_NO_DOTENV` (truthy): stop all `.env` loading. It disables both `load_dotenv_early` and the `Settings` `env_file`, so a `.env` in the working directory cannot configure the process. For service workers that review untrusted checkouts, alongside the prompt-override and bench-override switches. Enforced on `Settings` itself, so every `Settings()` construction honors it.
16
+
10
17
  ## [0.3.1] - 2026-10-19
11
18
 
12
19
  ### Fixed
@@ -435,6 +442,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
435
442
  - `argus --version`, `argus prompts list`, and `argus prompts export`.
436
443
 
437
444
  [Unreleased]: https://github.com/redesignhealth/argus-review/compare/v0.3.1...HEAD
445
+ [0.3.2]: https://github.com/redesignhealth/argus-review/compare/v0.3.1...v0.3.2
438
446
  [0.3.1]: https://github.com/redesignhealth/argus-review/compare/v0.3.0...v0.3.1
439
447
  [0.3.0]: https://github.com/redesignhealth/argus-review/compare/v0.2.11...v0.3.0
440
448
  [0.2.11]: https://github.com/redesignhealth/argus-review/compare/v0.2.10...v0.2.11
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: argus-code-review
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Self-orchestrated PR review agent using LangGraph + Claude Agent SDK
5
5
  Project-URL: Repository, https://github.com/redesignhealth/argus-review
6
6
  Project-URL: Issues, https://github.com/redesignhealth/argus-review/issues
@@ -280,6 +280,8 @@ Argus reads configuration from environment variables, or from a `.env` file
280
280
  in the working directory (add it to `.gitignore` — never commit it).
281
281
  [`.env.example`](.env.example) lists every variable Argus reads, required
282
282
  and optional, with comments — copy it to `.env` and fill in what you need.
283
+ Set `ARGUS_NO_DOTENV=1` in the real environment to ignore `.env` files
284
+ entirely (for service workers that run in a directory they do not control).
283
285
 
284
286
  Three keys are required for a first run:
285
287
 
@@ -229,6 +229,8 @@ Argus reads configuration from environment variables, or from a `.env` file
229
229
  in the working directory (add it to `.gitignore` — never commit it).
230
230
  [`.env.example`](.env.example) lists every variable Argus reads, required
231
231
  and optional, with comments — copy it to `.env` and fill in what you need.
232
+ Set `ARGUS_NO_DOTENV=1` in the real environment to ignore `.env` files
233
+ entirely (for service workers that run in a directory they do not control).
232
234
 
233
235
  Three keys are required for a first run:
234
236
 
@@ -70,6 +70,8 @@ import time
70
70
  from pathlib import Path
71
71
  from typing import TYPE_CHECKING, Final
72
72
 
73
+ from argus.dotenv_utils import TRUTHY_STRINGS
74
+
73
75
  if TYPE_CHECKING:
74
76
  from argus.config import Settings
75
77
  from argus.models import ReviewRequest, ReviewResponse
@@ -79,6 +81,7 @@ logging.basicConfig(
79
81
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
80
82
  datefmt="%H:%M:%S",
81
83
  )
84
+
82
85
  logger = logging.getLogger("argus_review_local")
83
86
 
84
87
  # Hard wall-clock backstop: if the entire review has not completed within this
@@ -229,6 +232,7 @@ async def run(
229
232
  request: "ReviewRequest",
230
233
  review_override_sha: str | None = None,
231
234
  review_override_reason: str | None = None,
235
+ expect_head_sha: str | None = None,
232
236
  ) -> "ReviewResponse":
233
237
  """Run the review pipeline (storage backend resolved automatically:
234
238
  Postgres if ``ARGUS_DB_URL`` is set, HTTP shim if the storage URLs are
@@ -240,6 +244,7 @@ async def run(
240
244
  flow_run_id=None,
241
245
  review_override_sha=review_override_sha,
242
246
  review_override_reason=review_override_reason,
247
+ expect_head_sha=expect_head_sha,
243
248
  )
244
249
 
245
250
 
@@ -363,6 +368,16 @@ def _add_review_args(parser: argparse.ArgumentParser) -> None:
363
368
  default=None,
364
369
  help="Reason for --review-override-sha (1-500 characters); recorded in the scope receipt.",
365
370
  )
371
+ parser.add_argument(
372
+ "--expect-head-sha",
373
+ dest="expect_head_sha",
374
+ default=None,
375
+ help=(
376
+ "Fail before any review spend unless the resolved head commit equals this "
377
+ "40-character lowercase SHA. For services that review a specific commit and "
378
+ "must not silently review a newer one if the PR head moved."
379
+ ),
380
+ )
366
381
  parser.add_argument(
367
382
  "--no-prompt-overrides",
368
383
  action="store_true",
@@ -483,10 +498,11 @@ def _validate_review_args(parser: argparse.ArgumentParser, args: argparse.Namesp
483
498
  """Cross-flag validation that argparse's own declarative options can't express."""
484
499
  if args.post and args.sha and not args.pr:
485
500
  parser.error("--post requires --pr (a PR to comment on); --sha alone has nowhere to post")
486
- from argus.scope import validate_override
501
+ from argus.scope import validate_expect_head_sha, validate_override
487
502
 
488
503
  try:
489
504
  validate_override(args.review_override_sha, args.review_override_reason)
505
+ validate_expect_head_sha(args.expect_head_sha)
490
506
  except ValueError as exc:
491
507
  parser.error(str(exc))
492
508
 
@@ -676,6 +692,7 @@ def _run_review(parser: argparse.ArgumentParser, args: argparse.Namespace) -> No
676
692
  request,
677
693
  review_override_sha=args.review_override_sha,
678
694
  review_override_reason=args.review_override_reason,
695
+ expect_head_sha=args.expect_head_sha,
679
696
  )
680
697
  )
681
698
  except HistoryBackendConnectivityError as exc:
@@ -720,7 +737,7 @@ def _packaged_prompt_names() -> list[str]:
720
737
  # argus.config.Settings). Kept in sync here so `argus prompts list`/`export`
721
738
  # (deliberately Settings-free, see below) treats the env var identically to
722
739
  # `argus review`, which resolves it through Settings.
723
- _PYDANTIC_TRUTHY_STRINGS = frozenset({"1", "true", "t", "yes", "y", "on"})
740
+ _PYDANTIC_TRUTHY_STRINGS = TRUTHY_STRINGS
724
741
 
725
742
 
726
743
  def _is_truthy_env_value(value: str) -> bool:
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  from functools import lru_cache
11
11
 
12
12
  from pydantic import Field
13
- from pydantic_settings import BaseSettings, SettingsConfigDict
13
+ from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
14
14
 
15
15
  # Single source of truth for the reviewer-subprocess wall-clock budget.
16
16
  # ``argus.runners._SUBPROCESS_TIMEOUT_S`` (the fallback used when no Settings
@@ -209,6 +209,26 @@ class Settings(BaseSettings):
209
209
  env_file_encoding="utf-8",
210
210
  )
211
211
 
212
+ @classmethod
213
+ def settings_customise_sources(
214
+ cls,
215
+ settings_cls: type[BaseSettings],
216
+ init_settings: PydanticBaseSettingsSource,
217
+ env_settings: PydanticBaseSettingsSource,
218
+ dotenv_settings: PydanticBaseSettingsSource,
219
+ file_secret_settings: PydanticBaseSettingsSource,
220
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
221
+ """Drop the ``.env`` source when ``ARGUS_NO_DOTENV`` is truthy.
222
+
223
+ Enforced here, on the class, so every ``Settings()`` construction honors the
224
+ kill switch, not just the cached accessor.
225
+ """
226
+ from argus.dotenv_utils import dotenv_disabled
227
+
228
+ if dotenv_disabled():
229
+ return (init_settings, env_settings, file_secret_settings)
230
+ return (init_settings, env_settings, dotenv_settings, file_secret_settings)
231
+
212
232
  @property
213
233
  def db_url(self) -> str | None:
214
234
  """Resolved Postgres URL: ``ARGUS_DB_URL`` wins over ``SUPABASE_DB_URL``."""
@@ -13,6 +13,22 @@ import os
13
13
  from pathlib import Path
14
14
 
15
15
 
16
+ # The strings pydantic treats as True for a bool setting; shared with the CLI so the
17
+ # two truthy checks cannot drift.
18
+ TRUTHY_STRINGS = frozenset({"1", "true", "t", "yes", "y", "on"})
19
+
20
+
21
+ def dotenv_disabled() -> bool:
22
+ """True when ``ARGUS_NO_DOTENV`` is truthy.
23
+
24
+ Official/service runs review untrusted checkouts from a working directory
25
+ that may contain a ``.env``; this switch stops every ``.env`` load (both
26
+ :func:`load_dotenv_early` and the pydantic ``Settings`` env_file) so the
27
+ process environment is the only configuration source.
28
+ """
29
+ return os.environ.get("ARGUS_NO_DOTENV", "").strip().lower() in TRUTHY_STRINGS
30
+
31
+
16
32
  def _preferred_env_filename() -> str:
17
33
  """Pick the default env filename based on ENVIRONMENT."""
18
34
  env = (os.environ.get("ENVIRONMENT") or "").lower()
@@ -61,11 +77,15 @@ def load_dotenv_early(
61
77
  """Load dotenv file early (before importing settings/clients).
62
78
 
63
79
  This is a no-op when:
80
+ - ``ARGUS_NO_DOTENV`` is truthy, or
64
81
  - running in AWS Lambda (secrets are expected to come from SSM/env), or
65
82
  - running in pytest and `skip_in_test=True`, or
66
83
  - python-dotenv isn't installed, or
67
84
  - no env file can be found.
68
85
  """
86
+ if dotenv_disabled():
87
+ return None
88
+
69
89
  # Never load local dotenv inside Lambda.
70
90
  if os.environ.get("AWS_LAMBDA_FUNCTION_NAME"):
71
91
  return None
@@ -85,6 +85,7 @@ from argus.models import (
85
85
  from argus.scope import (
86
86
  SCOPE_PROMPT,
87
87
  SHA40,
88
+ validate_expect_head_sha,
88
89
  ScopeAssessment,
89
90
  ScopeUnverified,
90
91
  build_scope_input,
@@ -3605,6 +3606,7 @@ async def run_review(
3605
3606
  *,
3606
3607
  review_override_sha: str | None = None,
3607
3608
  review_override_reason: str | None = None,
3609
+ expect_head_sha: str | None = None,
3608
3610
  ) -> ReviewResponse:
3609
3611
  """Execute the v3 review pipeline via LangGraph StateGraph.
3610
3612
 
@@ -3620,6 +3622,7 @@ async def run_review(
3620
3622
  import uuid
3621
3623
 
3622
3624
  override = validate_override(review_override_sha, review_override_reason)
3625
+ validate_expect_head_sha(expect_head_sha)
3623
3626
 
3624
3627
  # Fail fast on a bad ARGUS_DB_URL / ARGUS_HISTORY_DB_PATH, before any of
3625
3628
  # the pipeline's LLM cost runs. Both history-backend implementations
@@ -3690,6 +3693,13 @@ async def run_review(
3690
3693
  else:
3691
3694
  head_sha_for_worktree = None
3692
3695
 
3696
+ if expect_head_sha is not None and expect_head_sha != head_sha_for_worktree:
3697
+ raise ValueError(
3698
+ f"expect_head_sha {expect_head_sha[:12]} does not match the resolved head "
3699
+ f"{(head_sha_for_worktree or 'none')[:12]}; the PR head moved, refusing to review "
3700
+ "a different commit"
3701
+ )
3702
+
3693
3703
  if override and override[0] != head_sha_for_worktree:
3694
3704
  raise ValueError(
3695
3705
  f"--review-override-sha {override[0][:12]} does not match the resolved head "
@@ -50,6 +50,13 @@ class ScopeAssessment(BaseModel):
50
50
  rationale: str
51
51
 
52
52
 
53
+ def validate_expect_head_sha(sha: str | None) -> str | None:
54
+ """Return ``sha`` unchanged if it is ``None`` or a 40-char lowercase hex SHA, else raise."""
55
+ if sha is not None and not SHA40.fullmatch(sha):
56
+ raise ValueError("expect_head_sha must be a 40-character lowercase hex SHA")
57
+ return sha
58
+
59
+
53
60
  def validate_override(sha: str | None, reason: str | None) -> tuple[str, str] | None:
54
61
  """Return ``(sha, reason)`` for a valid human override pair, ``None`` if neither is set."""
55
62
  if sha is None and reason is None:
@@ -343,3 +343,26 @@ class TestPromptsSubcommand:
343
343
  def test_prompts_export_force_defaults_off(self) -> None:
344
344
  args = _parse(["prompts", "export", "/tmp/out"])
345
345
  assert args.force is False
346
+
347
+
348
+ class TestExpectHeadShaFlag:
349
+ def test_defaults_to_none(self) -> None:
350
+ assert _parse(["review", "owner/repo", "--pr", "1"]).expect_head_sha is None
351
+
352
+ def test_flag_parses(self) -> None:
353
+ sha = "a" * 40
354
+ args = _parse(["review", "owner/repo", "--pr", "1", "--expect-head-sha", sha])
355
+ assert args.expect_head_sha == sha
356
+
357
+ def test_malformed_sha_is_a_parser_error(self) -> None:
358
+ parser = _build_parser()
359
+ for bad in ("a" * 39, "A" * 40, "g" * 40, ""):
360
+ args = _parse(["review", "owner/repo", "--pr", "1", "--expect-head-sha", bad])
361
+ with pytest.raises(SystemExit) as exc:
362
+ _validate_review_args(parser, args)
363
+ assert exc.value.code == 2
364
+
365
+ def test_valid_sha_passes_validation(self) -> None:
366
+ parser = _build_parser()
367
+ args = _parse(["review", "owner/repo", "--pr", "1", "--expect-head-sha", "a" * 40])
368
+ _validate_review_args(parser, args)
@@ -229,6 +229,7 @@ class TestRunReviewConnectivityErrorExit:
229
229
  output=None,
230
230
  review_override_sha=None,
231
231
  review_override_reason=None,
232
+ expect_head_sha=None,
232
233
  )
233
234
  parser = MagicMock()
234
235
 
@@ -0,0 +1,93 @@
1
+ """Tests for ``ARGUS_NO_DOTENV``: the kill switch for every ``.env`` load."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from argus.config import Settings, clear_cache, get_settings
11
+ from argus.dotenv_utils import dotenv_disabled, load_dotenv_early
12
+
13
+ _SENTINEL = "ARGUS_TEST_DOTENV_SENTINEL"
14
+
15
+
16
+ @pytest.mark.parametrize("value", ["1", "true", "TRUE", " yes ", "On", "t", "y"])
17
+ def test_dotenv_disabled_truthy_values(value: str, monkeypatch: pytest.MonkeyPatch) -> None:
18
+ monkeypatch.setenv("ARGUS_NO_DOTENV", value)
19
+ assert dotenv_disabled() is True
20
+
21
+
22
+ @pytest.mark.parametrize("value", ["", "0", "false", "no", "off", "enabled", "2"])
23
+ def test_dotenv_disabled_falsy_values(value: str, monkeypatch: pytest.MonkeyPatch) -> None:
24
+ monkeypatch.setenv("ARGUS_NO_DOTENV", value)
25
+ assert dotenv_disabled() is False
26
+
27
+
28
+ def test_dotenv_disabled_unset(monkeypatch: pytest.MonkeyPatch) -> None:
29
+ monkeypatch.delenv("ARGUS_NO_DOTENV", raising=False)
30
+ assert dotenv_disabled() is False
31
+
32
+
33
+ def test_load_dotenv_early_is_a_noop_when_disabled(
34
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
35
+ ) -> None:
36
+ (tmp_path / ".env").write_text(f"{_SENTINEL}=loaded\n")
37
+ monkeypatch.delenv(_SENTINEL, raising=False)
38
+ monkeypatch.setenv("ARGUS_NO_DOTENV", "1")
39
+ assert load_dotenv_early(start=tmp_path, env_filename=".env") is None
40
+ import os
41
+
42
+ assert _SENTINEL not in os.environ
43
+
44
+
45
+ def test_load_dotenv_early_still_loads_when_not_disabled(
46
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
47
+ ) -> None:
48
+ import os
49
+
50
+ (tmp_path / ".env").write_text(f"{_SENTINEL}=loaded\n")
51
+ monkeypatch.delenv(_SENTINEL, raising=False)
52
+ monkeypatch.delenv("ARGUS_NO_DOTENV", raising=False)
53
+ try:
54
+ assert load_dotenv_early(start=tmp_path, env_filename=".env") is not None
55
+ assert os.environ[_SENTINEL] == "loaded"
56
+ finally:
57
+ os.environ.pop(_SENTINEL, None)
58
+
59
+
60
+ class TestSettingsHonorKillSwitch:
61
+ @pytest.fixture(autouse=True)
62
+ def _isolate(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
63
+ (tmp_path / ".env").write_text("ARGUS_GEMINI_CACHE_TTL=7\n")
64
+ monkeypatch.chdir(tmp_path)
65
+ monkeypatch.delenv("ARGUS_GEMINI_CACHE_TTL", raising=False)
66
+ clear_cache()
67
+ yield
68
+ clear_cache()
69
+
70
+ def test_cached_settings_read_dotenv_by_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
71
+ monkeypatch.delenv("ARGUS_NO_DOTENV", raising=False)
72
+ assert get_settings().ARGUS_GEMINI_CACHE_TTL == 7
73
+
74
+ def test_cached_settings_ignore_dotenv_when_disabled(
75
+ self, monkeypatch: pytest.MonkeyPatch
76
+ ) -> None:
77
+ monkeypatch.setenv("ARGUS_NO_DOTENV", "true")
78
+ default = Settings.model_fields["ARGUS_GEMINI_CACHE_TTL"].default
79
+ assert get_settings().ARGUS_GEMINI_CACHE_TTL == default
80
+
81
+ def test_direct_settings_construction_also_ignores_dotenv(
82
+ self, monkeypatch: pytest.MonkeyPatch
83
+ ) -> None:
84
+ monkeypatch.setenv("ARGUS_NO_DOTENV", "1")
85
+ default = Settings.model_fields["ARGUS_GEMINI_CACHE_TTL"].default
86
+ assert Settings().ARGUS_GEMINI_CACHE_TTL == default
87
+
88
+ def test_real_environment_still_wins_when_disabled(
89
+ self, monkeypatch: pytest.MonkeyPatch
90
+ ) -> None:
91
+ monkeypatch.setenv("ARGUS_NO_DOTENV", "1")
92
+ monkeypatch.setenv("ARGUS_GEMINI_CACHE_TTL", "42")
93
+ assert Settings().ARGUS_GEMINI_CACHE_TTL == 42
@@ -476,6 +476,22 @@ class TestRunReviewHaltAndReceipt:
476
476
  with pytest.raises(ValueError, match="stale"):
477
477
  await _run({}, review_override_sha="d" * 40, review_override_reason="r")
478
478
 
479
+ async def test_expect_head_sha_mismatch_fails_before_any_graph_run(self) -> None:
480
+ with pytest.raises(ValueError, match="head moved"):
481
+ await _run({}, expect_head_sha="d" * 40)
482
+
483
+ async def test_expect_head_sha_must_be_a_full_lowercase_sha(self) -> None:
484
+ for bad in (HEAD[:12], HEAD.upper(), "z" * 40):
485
+ with pytest.raises(ValueError, match="40-character"):
486
+ await _run({}, expect_head_sha=bad)
487
+
488
+ async def test_expect_head_sha_match_runs_the_review(self) -> None:
489
+ resp, _, _ = await _run(
490
+ {"response": _approve(), "head_sha": HEAD, "findings": [], "scope": _scope_dict()},
491
+ expect_head_sha=HEAD,
492
+ )
493
+ assert resp.scope is not None and resp.scope.head_sha == HEAD
494
+
479
495
  async def test_override_pair_is_required(self) -> None:
480
496
  with pytest.raises(ValueError, match="together"):
481
497
  await _run({}, review_override_sha=HEAD)