codejury 0.9.1__tar.gz → 0.10.1__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 (188) hide show
  1. {codejury-0.9.1 → codejury-0.10.1}/PKG-INFO +7 -2
  2. {codejury-0.9.1 → codejury-0.10.1}/README.md +6 -1
  3. {codejury-0.9.1 → codejury-0.10.1}/codejury/agents/debate.py +6 -3
  4. {codejury-0.9.1 → codejury-0.10.1}/codejury/agents/mock.py +1 -2
  5. codejury-0.10.1/codejury/agents/parsing.py +45 -0
  6. {codejury-0.9.1 → codejury-0.10.1}/codejury/agents/verifier.py +7 -3
  7. {codejury-0.9.1 → codejury-0.10.1}/codejury/analysis/provenance.py +83 -38
  8. {codejury-0.9.1 → codejury-0.10.1}/codejury/analysis/taint.py +46 -55
  9. {codejury-0.9.1 → codejury-0.10.1}/codejury/assembly.py +8 -2
  10. {codejury-0.9.1 → codejury-0.10.1}/codejury/baseline.py +5 -9
  11. {codejury-0.9.1 → codejury-0.10.1}/codejury/cli.py +22 -14
  12. {codejury-0.9.1 → codejury-0.10.1}/codejury/domain/capability.py +15 -2
  13. {codejury-0.9.1 → codejury-0.10.1}/codejury/domain/observation.py +25 -4
  14. {codejury-0.9.1 → codejury-0.10.1}/codejury/evaluation.py +8 -2
  15. {codejury-0.9.1 → codejury-0.10.1}/codejury/infrastructure/json_parse.py +8 -3
  16. {codejury-0.9.1 → codejury-0.10.1}/codejury/integrations/github.py +24 -9
  17. codejury-0.10.1/codejury/orchestrators/adaptive.py +55 -0
  18. {codejury-0.9.1 → codejury-0.10.1}/codejury/orchestrators/base.py +9 -1
  19. {codejury-0.9.1 → codejury-0.10.1}/codejury/orchestrators/challenge.py +11 -1
  20. {codejury-0.9.1 → codejury-0.10.1}/codejury/providers/retry.py +8 -1
  21. {codejury-0.9.1 → codejury-0.10.1}/codejury/reporting.py +15 -10
  22. {codejury-0.9.1 → codejury-0.10.1}/codejury/sources/callers.py +3 -1
  23. {codejury-0.9.1 → codejury-0.10.1}/codejury/sources/diff.py +2 -1
  24. {codejury-0.9.1 → codejury-0.10.1}/codejury/sources/function.py +12 -3
  25. {codejury-0.9.1 → codejury-0.10.1}/codejury/sources/repo.py +4 -0
  26. {codejury-0.9.1 → codejury-0.10.1}/codejury/suppression.py +7 -9
  27. {codejury-0.9.1 → codejury-0.10.1}/codejury/tasks/base.py +2 -0
  28. {codejury-0.9.1 → codejury-0.10.1}/codejury.egg-info/PKG-INFO +7 -2
  29. {codejury-0.9.1 → codejury-0.10.1}/codejury.egg-info/SOURCES.txt +4 -0
  30. {codejury-0.9.1 → codejury-0.10.1}/pyproject.toml +1 -1
  31. codejury-0.10.1/tests/test_adaptive.py +71 -0
  32. {codejury-0.9.1 → codejury-0.10.1}/tests/test_evaluation.py +1 -1
  33. codejury-0.10.1/tests/test_parsing.py +32 -0
  34. codejury-0.10.1/tests/test_review_fixes.py +100 -0
  35. codejury-0.9.1/codejury/agents/parsing.py +0 -42
  36. {codejury-0.9.1 → codejury-0.10.1}/LICENSE +0 -0
  37. {codejury-0.9.1 → codejury-0.10.1}/codejury/__init__.py +0 -0
  38. {codejury-0.9.1 → codejury-0.10.1}/codejury/agents/__init__.py +0 -0
  39. {codejury-0.9.1 → codejury-0.10.1}/codejury/agents/base.py +0 -0
  40. {codejury-0.9.1 → codejury-0.10.1}/codejury/agents/refuter.py +0 -0
  41. {codejury-0.9.1 → codejury-0.10.1}/codejury/analysis/__init__.py +0 -0
  42. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/capabilities/authentication.yaml +0 -0
  43. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/capabilities/authorization.yaml +0 -0
  44. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/capabilities/business_logic.yaml +0 -0
  45. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/capabilities/crypto.yaml +0 -0
  46. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/capabilities/data_protection.yaml +0 -0
  47. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/capabilities/dependency_config.yaml +0 -0
  48. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/capabilities/error_logging.yaml +0 -0
  49. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/capabilities/excessive_agency.yaml +0 -0
  50. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/capabilities/input_validation.yaml +0 -0
  51. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/capabilities/insecure_output_handling.yaml +0 -0
  52. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/capabilities/output_encoding.yaml +0 -0
  53. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/capabilities/prompt_injection.yaml +0 -0
  54. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/capabilities/secrets.yaml +0 -0
  55. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/capabilities/session.yaml +0 -0
  56. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/ag_allowlist_safe.yaml +0 -0
  57. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/ag_arbitrary_tool_vuln.yaml +0 -0
  58. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/ag_destructive_no_confirm_vuln.yaml +0 -0
  59. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/ag_fixed_enum_safe.yaml +0 -0
  60. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/ag_human_approval_safe.yaml +0 -0
  61. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/ag_model_confirmed_vuln.yaml +0 -0
  62. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/authn_bcrypt_password.yaml +0 -0
  63. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/authn_jwt_noverify_vuln.yaml +0 -0
  64. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/authn_jwt_verified_safe.yaml +0 -0
  65. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/authn_sha256_checksum_safe.yaml +0 -0
  66. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/authn_sha256_password.yaml +0 -0
  67. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/authn_weak_hash_indirect_vuln.yaml +0 -0
  68. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/authz_idor_vuln.yaml +0 -0
  69. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/authz_owner_safe.yaml +0 -0
  70. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/business_logic_price_tamper_vuln.yaml +0 -0
  71. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/business_logic_server_checked_safe.yaml +0 -0
  72. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/cmdi_fixed_argv_safe.yaml +0 -0
  73. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/cmdi_ossystem_vuln.yaml +0 -0
  74. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/cmdi_subprocess_safe.yaml +0 -0
  75. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/crypto_aesgcm_safe.yaml +0 -0
  76. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/crypto_ecb_vuln.yaml +0 -0
  77. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/data_protection_plaintext_pii_vuln.yaml +0 -0
  78. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/data_protection_tokenized_safe.yaml +0 -0
  79. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/dependency_config_tls_verify_off_vuln.yaml +0 -0
  80. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/dependency_config_tls_verify_on_safe.yaml +0 -0
  81. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/deserialize_json_safe.yaml +0 -0
  82. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/deserialize_pickle_vuln.yaml +0 -0
  83. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/error_logging_redacted_safe.yaml +0 -0
  84. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/error_logging_secret_leak_vuln.yaml +0 -0
  85. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/ioh_escaped_output_safe.yaml +0 -0
  86. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/ioh_exec_output_vuln.yaml +0 -0
  87. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/ioh_innerhtml_output_vuln.yaml +0 -0
  88. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/ioh_json_response_safe.yaml +0 -0
  89. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/ioh_output_to_sql_vuln.yaml +0 -0
  90. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/ioh_schema_validated_safe.yaml +0 -0
  91. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/literal_eval_safe.yaml +0 -0
  92. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/path_basename_safe.yaml +0 -0
  93. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/path_contained_safe.yaml +0 -0
  94. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/path_traversal_vuln.yaml +0 -0
  95. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/pi_delimited_data_safe.yaml +0 -0
  96. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/pi_format_role_vuln.yaml +0 -0
  97. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/pi_indirect_rag_vuln.yaml +0 -0
  98. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/pi_system_concat_vuln.yaml +0 -0
  99. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/pi_user_content_concat_safe.yaml +0 -0
  100. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/pi_user_role_safe.yaml +0 -0
  101. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/secrets_env_safe.yaml +0 -0
  102. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/secrets_hardcoded_vuln.yaml +0 -0
  103. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/session_fixation_vuln.yaml +0 -0
  104. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/session_secure_cookie_safe.yaml +0 -0
  105. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/sql_constant_concat_safe.yaml +0 -0
  106. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/sqli_format_vuln.yaml +0 -0
  107. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/sqli_fstring_query.yaml +0 -0
  108. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/sqli_indirect_var_vuln.yaml +0 -0
  109. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/sqli_parameterized_query.yaml +0 -0
  110. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/ssrf_allowlist_safe.yaml +0 -0
  111. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/ssrf_constant_url_safe.yaml +0 -0
  112. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/ssrf_substring_allowlist_bypass_vuln.yaml +0 -0
  113. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/ssrf_user_url_vuln.yaml +0 -0
  114. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/xfile_idor_no_check_vuln.yaml +0 -0
  115. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/xfile_idor_owner_checked_safe.yaml +0 -0
  116. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/xfile_path_sanitized_safe.yaml +0 -0
  117. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/xfile_path_tainted_vuln.yaml +0 -0
  118. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/xss_innerhtml_constant_safe.yaml +0 -0
  119. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/xss_innerhtml_vuln.yaml +0 -0
  120. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/golden/xss_textcontent_safe.yaml +0 -0
  121. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/suppressions.yaml +0 -0
  122. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/taint.yaml +0 -0
  123. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/tasks/audit_diff_debate.yaml +0 -0
  124. {codejury-0.9.1 → codejury-0.10.1}/codejury/data/tasks/quick_scan_single.yaml +0 -0
  125. {codejury-0.9.1 → codejury-0.10.1}/codejury/domain/__init__.py +0 -0
  126. {codejury-0.9.1 → codejury-0.10.1}/codejury/domain/artifact.py +0 -0
  127. {codejury-0.9.1 → codejury-0.10.1}/codejury/domain/context.py +0 -0
  128. {codejury-0.9.1 → codejury-0.10.1}/codejury/domain/result.py +0 -0
  129. {codejury-0.9.1 → codejury-0.10.1}/codejury/infrastructure/__init__.py +0 -0
  130. {codejury-0.9.1 → codejury-0.10.1}/codejury/infrastructure/cache.py +0 -0
  131. {codejury-0.9.1 → codejury-0.10.1}/codejury/integrations/__init__.py +0 -0
  132. {codejury-0.9.1 → codejury-0.10.1}/codejury/orchestrators/__init__.py +0 -0
  133. {codejury-0.9.1 → codejury-0.10.1}/codejury/orchestrators/debate.py +0 -0
  134. {codejury-0.9.1 → codejury-0.10.1}/codejury/orchestrators/pipeline.py +0 -0
  135. {codejury-0.9.1 → codejury-0.10.1}/codejury/orchestrators/reflexion.py +0 -0
  136. {codejury-0.9.1 → codejury-0.10.1}/codejury/orchestrators/single.py +0 -0
  137. {codejury-0.9.1 → codejury-0.10.1}/codejury/orchestrators/taint_gate.py +0 -0
  138. {codejury-0.9.1 → codejury-0.10.1}/codejury/providers/__init__.py +0 -0
  139. {codejury-0.9.1 → codejury-0.10.1}/codejury/providers/anthropic.py +0 -0
  140. {codejury-0.9.1 → codejury-0.10.1}/codejury/providers/base.py +0 -0
  141. {codejury-0.9.1 → codejury-0.10.1}/codejury/providers/litellm.py +0 -0
  142. {codejury-0.9.1 → codejury-0.10.1}/codejury/providers/mock.py +0 -0
  143. {codejury-0.9.1 → codejury-0.10.1}/codejury/providers/openai.py +0 -0
  144. {codejury-0.9.1 → codejury-0.10.1}/codejury/providers/openai_format.py +0 -0
  145. {codejury-0.9.1 → codejury-0.10.1}/codejury/resources.py +0 -0
  146. {codejury-0.9.1 → codejury-0.10.1}/codejury/sources/__init__.py +0 -0
  147. {codejury-0.9.1 → codejury-0.10.1}/codejury/sources/base.py +0 -0
  148. {codejury-0.9.1 → codejury-0.10.1}/codejury/sources/chunker.py +0 -0
  149. {codejury-0.9.1 → codejury-0.10.1}/codejury/sources/mock.py +0 -0
  150. {codejury-0.9.1 → codejury-0.10.1}/codejury/tasks/__init__.py +0 -0
  151. {codejury-0.9.1 → codejury-0.10.1}/codejury/tasks/registry.py +0 -0
  152. {codejury-0.9.1 → codejury-0.10.1}/codejury.egg-info/dependency_links.txt +0 -0
  153. {codejury-0.9.1 → codejury-0.10.1}/codejury.egg-info/entry_points.txt +0 -0
  154. {codejury-0.9.1 → codejury-0.10.1}/codejury.egg-info/requires.txt +0 -0
  155. {codejury-0.9.1 → codejury-0.10.1}/codejury.egg-info/top_level.txt +0 -0
  156. {codejury-0.9.1 → codejury-0.10.1}/setup.cfg +0 -0
  157. {codejury-0.9.1 → codejury-0.10.1}/tests/test_anthropic_provider.py +0 -0
  158. {codejury-0.9.1 → codejury-0.10.1}/tests/test_assembly.py +0 -0
  159. {codejury-0.9.1 → codejury-0.10.1}/tests/test_audit_pipeline.py +0 -0
  160. {codejury-0.9.1 → codejury-0.10.1}/tests/test_baseline.py +0 -0
  161. {codejury-0.9.1 → codejury-0.10.1}/tests/test_cache.py +0 -0
  162. {codejury-0.9.1 → codejury-0.10.1}/tests/test_callers.py +0 -0
  163. {codejury-0.9.1 → codejury-0.10.1}/tests/test_capability.py +0 -0
  164. {codejury-0.9.1 → codejury-0.10.1}/tests/test_challenge.py +0 -0
  165. {codejury-0.9.1 → codejury-0.10.1}/tests/test_cli_audit.py +0 -0
  166. {codejury-0.9.1 → codejury-0.10.1}/tests/test_context.py +0 -0
  167. {codejury-0.9.1 → codejury-0.10.1}/tests/test_debate_agents.py +0 -0
  168. {codejury-0.9.1 → codejury-0.10.1}/tests/test_debate_orchestrator.py +0 -0
  169. {codejury-0.9.1 → codejury-0.10.1}/tests/test_diff_source.py +0 -0
  170. {codejury-0.9.1 → codejury-0.10.1}/tests/test_function_source.py +0 -0
  171. {codejury-0.9.1 → codejury-0.10.1}/tests/test_integrations.py +0 -0
  172. {codejury-0.9.1 → codejury-0.10.1}/tests/test_json_parse.py +0 -0
  173. {codejury-0.9.1 → codejury-0.10.1}/tests/test_litellm_provider.py +0 -0
  174. {codejury-0.9.1 → codejury-0.10.1}/tests/test_openai_provider.py +0 -0
  175. {codejury-0.9.1 → codejury-0.10.1}/tests/test_orchestrator.py +0 -0
  176. {codejury-0.9.1 → codejury-0.10.1}/tests/test_pipeline_orchestrator.py +0 -0
  177. {codejury-0.9.1 → codejury-0.10.1}/tests/test_provenance.py +0 -0
  178. {codejury-0.9.1 → codejury-0.10.1}/tests/test_reflexion_orchestrator.py +0 -0
  179. {codejury-0.9.1 → codejury-0.10.1}/tests/test_repo_source.py +0 -0
  180. {codejury-0.9.1 → codejury-0.10.1}/tests/test_reporting.py +0 -0
  181. {codejury-0.9.1 → codejury-0.10.1}/tests/test_retry_provider.py +0 -0
  182. {codejury-0.9.1 → codejury-0.10.1}/tests/test_sarif.py +0 -0
  183. {codejury-0.9.1 → codejury-0.10.1}/tests/test_suppression.py +0 -0
  184. {codejury-0.9.1 → codejury-0.10.1}/tests/test_taint.py +0 -0
  185. {codejury-0.9.1 → codejury-0.10.1}/tests/test_taint_crossfile.py +0 -0
  186. {codejury-0.9.1 → codejury-0.10.1}/tests/test_taint_gate.py +0 -0
  187. {codejury-0.9.1 → codejury-0.10.1}/tests/test_tasks.py +0 -0
  188. {codejury-0.9.1 → codejury-0.10.1}/tests/test_verifier.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codejury
3
- Version: 0.9.1
3
+ Version: 0.10.1
4
4
  Summary: General-purpose Application Security AI audit framework -- five-layer architecture, capabilities as first-class data
5
5
  Author: AISecLabs
6
6
  License-Expression: MIT
@@ -83,7 +83,7 @@ git diff | codejury audit --provider anthropic
83
83
  | `codejury run <task>` | Run a named task preset (see [Tasks](#tasks)). |
84
84
  | `codejury eval` | Score the golden cases; report precision / recall / F1, overall and per capability. |
85
85
 
86
- Shared flags: `--orchestrator {single,pipeline,debate,reflexion,challenge,taint}`,
86
+ Shared flags: `--orchestrator {single,pipeline,debate,reflexion,challenge,taint,adaptive}`,
87
87
  `--provider {anthropic,openai,litellm}`, `--model`,
88
88
  `--format {text,markdown,json,sarif}`.
89
89
 
@@ -103,6 +103,11 @@ value reaching the sink is constant, sanitized, or trusted (using cross-file
103
103
  caller/callee context). It downgrades only on positive proof, so it removes false
104
104
  positives without dropping real findings.
105
105
 
106
+ `--orchestrator adaptive` keeps cost down: it runs the single verifier first and
107
+ escalates to a full debate only for artifacts worth it -- any VULNERABLE verdict
108
+ (verify a flag before reporting) or a low-confidence PARTIAL/UNKNOWN one. Clean,
109
+ confident files pay a single call; only the rest pay for debate.
110
+
106
111
  `--format sarif` emits a SARIF 2.1.0 log (validates against the official schema)
107
112
  for CI and security dashboards: each problem with a code location becomes a
108
113
  result carrying its capability (as the rule id), CWE, and a precise location.
@@ -53,7 +53,7 @@ git diff | codejury audit --provider anthropic
53
53
  | `codejury run <task>` | Run a named task preset (see [Tasks](#tasks)). |
54
54
  | `codejury eval` | Score the golden cases; report precision / recall / F1, overall and per capability. |
55
55
 
56
- Shared flags: `--orchestrator {single,pipeline,debate,reflexion,challenge,taint}`,
56
+ Shared flags: `--orchestrator {single,pipeline,debate,reflexion,challenge,taint,adaptive}`,
57
57
  `--provider {anthropic,openai,litellm}`, `--model`,
58
58
  `--format {text,markdown,json,sarif}`.
59
59
 
@@ -73,6 +73,11 @@ value reaching the sink is constant, sanitized, or trusted (using cross-file
73
73
  caller/callee context). It downgrades only on positive proof, so it removes false
74
74
  positives without dropping real findings.
75
75
 
76
+ `--orchestrator adaptive` keeps cost down: it runs the single verifier first and
77
+ escalates to a full debate only for artifacts worth it -- any VULNERABLE verdict
78
+ (verify a flag before reporting) or a low-confidence PARTIAL/UNKNOWN one. Clean,
79
+ confident files pay a single call; only the rest pay for debate.
80
+
76
81
  `--format sarif` emits a SARIF 2.1.0 log (validates against the official schema)
77
82
  for CI and security dashboards: each problem with a code location becomes a
78
83
  result carrying its capability (as the rule id), CWE, and a precise location.
@@ -19,15 +19,18 @@ from codejury.agents.parsing import one_of, str_list, to_evidence, to_float
19
19
  from codejury.domain.artifact import CodeArtifact
20
20
  from codejury.domain.capability import Capability
21
21
  from codejury.domain.context import AnalysisContext
22
- from codejury.domain.observation import Concession, Finding, Observation
22
+ from codejury.domain.observation import Concession, Finding, Observation, Severity
23
23
  from codejury.infrastructure.json_parse import extract_json_object
24
24
  from codejury.providers.base import Message, Provider
25
25
 
26
- _SEVERITY = {"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"}
26
+ from typing import get_args
27
+
28
+ _SEVERITY = set(get_args(Severity)) # single source of truth: the Severity Literal
27
29
 
28
30
  _FINDING_SHAPE = (
29
31
  '{"capability": "id.sub", "title": "...", "severity": "HIGH", "cwe": "CWE-89", '
30
- '"description": "...", "evidence": [{"file": "...", "line": 0, "code": "..."}], "confidence": 0.0}'
32
+ '"description": "...", "recommendation": "...", "matched_anti": ["id"], '
33
+ '"evidence": [{"file": "...", "line": 0, "code": "..."}], "confidence": 0.0}'
31
34
  )
32
35
 
33
36
  _DEEP_LENS = (
@@ -2,8 +2,7 @@
2
2
 
3
3
  It really calls the provider (so the dry-run exercises the agent -> provider
4
4
  path), then emits one Verdict per in-scope capability, parking the model's reply
5
- in ``reasoning``. It does no real parsing or judgement; the Phase 3 VerifierAgent
6
- will.
5
+ in ``reasoning``. It does no real parsing or judgement; ``VerifierAgent`` does.
7
6
  """
8
7
 
9
8
  from __future__ import annotations
@@ -0,0 +1,45 @@
1
+ """Shared coercion from loosely-typed model JSON into domain values.
2
+
3
+ Agents parse model output that may omit, mistype, or invent fields. These
4
+ helpers coerce defensively -- they never raise on bad input, they fall back.
5
+ """
6
+
7
+ import math
8
+
9
+ from codejury.domain.observation import Evidence
10
+
11
+
12
+ def str_list(value: object) -> list[str]:
13
+ return [str(x) for x in value] if isinstance(value, list) else []
14
+
15
+
16
+ def to_float(value: object, default: float) -> float:
17
+ # reject bool (it is an int subclass) and non-finite values (json.loads accepts
18
+ # NaN/Infinity); a NaN confidence would silently corrupt every downstream sort.
19
+ if isinstance(value, bool):
20
+ return default
21
+ try:
22
+ result = float(value) # type: ignore[arg-type]
23
+ except (TypeError, ValueError):
24
+ return default
25
+ return result if math.isfinite(result) else default
26
+
27
+
28
+ def one_of(value: object, allowed: set[str], default: str) -> str:
29
+ # `value in allowed` would raise TypeError on an unhashable model value
30
+ # (e.g. status -> [] or {}); guard so bad input falls back, never raises.
31
+ return value if isinstance(value, str) and value in allowed else default
32
+
33
+
34
+ def to_evidence(items: object) -> list[Evidence]:
35
+ if not isinstance(items, list):
36
+ return []
37
+ out: list[Evidence] = []
38
+ for e in items:
39
+ if not isinstance(e, dict):
40
+ continue
41
+ line = e.get("line")
42
+ # a location needs a real 1-based line; reject 0, negatives, and bool (an int subclass)
43
+ valid_line = line if isinstance(line, int) and not isinstance(line, bool) and line >= 1 else None
44
+ out.append(Evidence(file=str(e.get("file", "")), line=valid_line, code=str(e.get("code", ""))))
45
+ return out
@@ -15,11 +15,13 @@ from codejury.agents.base import Agent
15
15
  from codejury.agents.parsing import one_of, str_list, to_evidence, to_float
16
16
  from codejury.domain.capability import Capability
17
17
  from codejury.domain.context import AnalysisContext
18
- from codejury.domain.observation import Observation, Verdict
18
+ from codejury.domain.observation import Observation, Verdict, VerdictStatus
19
19
  from codejury.infrastructure.json_parse import extract_json_object
20
20
  from codejury.providers.base import Message, Provider
21
21
 
22
- _VALID_STATUS = {"SECURE", "VULNERABLE", "PARTIAL", "NOT_PRESENT", "UNKNOWN"}
22
+ from typing import get_args
23
+
24
+ _VALID_STATUS = set(get_args(VerdictStatus)) # single source of truth: the VerdictStatus Literal
23
25
 
24
26
  _SYSTEM = (
25
27
  "You are a security verifier. You check code against a checklist of correct and "
@@ -108,8 +110,10 @@ def _anti_pattern_cwes(cap: Capability) -> dict[str, tuple[int, str]]:
108
110
 
109
111
 
110
112
  def _resolve_cwe(matched_anti: list[str], cwe_by_id: dict[str, tuple[int, str]]) -> str:
113
+ # the most severe matched anti-pattern's CWE; ties broken by CWE id so the result
114
+ # is fully determined by the inputs, not by dict/list ordering.
111
115
  matched = [cwe_by_id[a] for a in matched_anti if a in cwe_by_id]
112
- return max(matched, key=lambda rank_cwe: rank_cwe[0])[1] if matched else ""
116
+ return max(matched, key=lambda rank_cwe: (rank_cwe[0], rank_cwe[1]))[1] if matched else ""
113
117
 
114
118
 
115
119
  def _parse_verdicts(text: str, cap: Capability) -> list[Verdict]:
@@ -72,7 +72,40 @@ def find_calls(scope: ast.AST, callee: str) -> list[ast.Call]:
72
72
 
73
73
  def trace_value(func: ast.FunctionDef | ast.AsyncFunctionDef, expr: ast.AST) -> Origin:
74
74
  """Trace where ``expr`` (an expression inside ``func``) gets its value from."""
75
- return _classify(expr, _params(func), _assignments(func), frozenset())
75
+ params = _params(func)
76
+
77
+ def leaf(node: ast.AST, recurse) -> Origin:
78
+ if isinstance(node, ast.Call):
79
+ # the return value's provenance depends on the callee's semantics, which
80
+ # P1-03 decides with the vocabulary; here we just name the callee.
81
+ name = _call_name(node)
82
+ return Origin(calls=frozenset({name})) if name else _UNKNOWN
83
+ if isinstance(node, (ast.Attribute, ast.Subscript)):
84
+ dotted = _dotted(node)
85
+ origin = Origin(attrs=frozenset({dotted})) if dotted else _UNKNOWN
86
+ if _root_name(node) in params: # e.g. request.args[...] where `request` is a parameter
87
+ origin = origin.merge(Origin(params=frozenset({_root_name(node)})))
88
+ return origin
89
+ return _UNKNOWN
90
+
91
+ return reduce_value(
92
+ expr,
93
+ params=params,
94
+ assigns=_assignments(func),
95
+ combine=_merge_origins,
96
+ leaf=leaf,
97
+ on_param=lambda n: Origin(params=frozenset({n})),
98
+ on_global=lambda n: Origin(globals_=frozenset({n})),
99
+ on_constant=lambda: _LITERAL,
100
+ on_cycle=Origin, # assignment cycle -- no new information
101
+ )
102
+
103
+
104
+ def _merge_origins(origins: list[Origin]) -> Origin:
105
+ out = Origin()
106
+ for o in origins:
107
+ out = out.merge(o)
108
+ return out
76
109
 
77
110
 
78
111
  def parameters(func: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
@@ -100,51 +133,63 @@ def access_root(node: ast.AST) -> str | None:
100
133
  return _root_name(node)
101
134
 
102
135
 
103
- def _classify(expr: ast.AST, params: set[str], assigns: dict[str, list[ast.AST]], seen: frozenset[str]) -> Origin:
136
+ def reduce_value(
137
+ expr: ast.AST,
138
+ *,
139
+ params: set[str],
140
+ assigns: dict[str, list[ast.AST]],
141
+ combine,
142
+ leaf,
143
+ on_param,
144
+ on_global,
145
+ on_constant,
146
+ on_cycle,
147
+ seen: frozenset[str] = frozenset(),
148
+ ):
149
+ """Walk a value expression, folding it to a single result of the caller's type.
150
+
151
+ This is the structural recursion shared by provenance (P1-01) and taint
152
+ classification (P1-03): the composite forms that pass a value through (binops,
153
+ f-strings, conditionals, collections) and the local-name dispatch (parameter /
154
+ assignment chain / assignment cycle / free global) are handled here once. The
155
+ caller supplies the policy:
156
+
157
+ - ``combine(list)`` -> fold component results (also defines the empty result);
158
+ - ``on_constant() / on_param(name) / on_global(name) / on_cycle()`` -> leaf
159
+ results for a literal, a parameter, a free name, and an assignment cycle;
160
+ - ``leaf(node, recurse)`` -> everything else (calls, attribute/subscript
161
+ access, unmodelled nodes); ``recurse`` re-enters the walk for sub-expressions
162
+ (e.g. a taint propagator recursing into its arguments).
163
+ """
164
+
165
+ def recurse(e: ast.AST, _seen: frozenset[str] | None = None):
166
+ return reduce_value(
167
+ e, params=params, assigns=assigns, combine=combine, leaf=leaf,
168
+ on_param=on_param, on_global=on_global, on_constant=on_constant,
169
+ on_cycle=on_cycle, seen=seen if _seen is None else _seen,
170
+ )
171
+
104
172
  if isinstance(expr, ast.Constant):
105
- return _LITERAL
173
+ return on_constant()
106
174
  if isinstance(expr, ast.JoinedStr): # f-string: literal parts + interpolated exprs
107
- origin = _LITERAL
108
- for value in expr.values:
109
- if isinstance(value, ast.FormattedValue):
110
- origin = origin.merge(_classify(value.value, params, assigns, seen))
111
- return origin
175
+ return combine([on_constant()] + [recurse(v.value) for v in expr.values if isinstance(v, ast.FormattedValue)])
112
176
  if isinstance(expr, ast.BinOp):
113
- return _classify(expr.left, params, assigns, seen).merge(_classify(expr.right, params, assigns, seen))
114
- if isinstance(expr, (ast.BoolOp,)):
115
- return _merge_all(expr.values, params, assigns, seen)
116
- if isinstance(expr, ast.IfExp): # value is one branch or the other; the test does not flow in
117
- return _classify(expr.body, params, assigns, seen).merge(_classify(expr.orelse, params, assigns, seen))
177
+ return combine([recurse(expr.left), recurse(expr.right)])
178
+ if isinstance(expr, ast.BoolOp):
179
+ return combine([recurse(v) for v in expr.values])
180
+ if isinstance(expr, ast.IfExp): # one branch or the other; the test does not flow in
181
+ return combine([recurse(expr.body), recurse(expr.orelse)])
118
182
  if isinstance(expr, (ast.List, ast.Tuple, ast.Set)):
119
- return _merge_all(expr.elts, params, assigns, seen)
120
- if isinstance(expr, ast.Call):
121
- # the return value's taint depends on the callee's semantics, which P1-03
122
- # decides with the sanitizer/propagator catalog; here we just name the callee.
123
- name = _call_name(expr)
124
- return Origin(calls=frozenset({name})) if name else _UNKNOWN
125
- if isinstance(expr, (ast.Attribute, ast.Subscript)):
126
- dotted = _dotted(expr)
127
- origin = Origin(attrs=frozenset({dotted})) if dotted else _UNKNOWN
128
- root = _root_name(expr)
129
- if root in params: # e.g. request.args[...] where `request` is a parameter
130
- origin = origin.merge(Origin(params=frozenset({root})))
131
- return origin
183
+ return combine([recurse(e) for e in expr.elts])
132
184
  if isinstance(expr, ast.Name):
133
185
  if expr.id in seen: # assignment cycle -- stop
134
- return Origin()
186
+ return on_cycle()
135
187
  if expr.id in params:
136
- return Origin(params=frozenset({expr.id}))
188
+ return on_param(expr.id)
137
189
  if expr.id in assigns:
138
- return _merge_all(assigns[expr.id], params, assigns, seen | {expr.id})
139
- return Origin(globals_=frozenset({expr.id})) # module global, import, or builtin
140
- return _UNKNOWN
141
-
142
-
143
- def _merge_all(exprs: list[ast.AST], params, assigns, seen) -> Origin:
144
- origin = Origin()
145
- for e in exprs:
146
- origin = origin.merge(_classify(e, params, assigns, seen))
147
- return origin
190
+ return combine([recurse(r, seen | {expr.id}) for r in assigns[expr.id]])
191
+ return on_global(expr.id) # module global, import, or builtin
192
+ return leaf(expr, recurse)
148
193
 
149
194
 
150
195
  def _params(func: ast.AST) -> set[str]:
@@ -35,6 +35,7 @@ from codejury.analysis.provenance import (
35
35
  callee,
36
36
  find_calls,
37
37
  parameters,
38
+ reduce_value,
38
39
  )
39
40
  from codejury.resources import TAINT_FILE
40
41
 
@@ -108,65 +109,55 @@ def taint_of(
108
109
 
109
110
  ``resolve_param`` is an optional ``(name) -> Taint`` callback; when given, a
110
111
  value that reaches a parameter is resolved through it (the cross-file caller
111
- hop, P1-03b) instead of returning PARAM.
112
+ hop, P1-03b) instead of returning PARAM. Shares the structural recursion with
113
+ provenance via ``reduce_value``; this is the vocabulary-aware policy.
112
114
  """
113
- return _walk(func, expr, vocab, assignments(func), parameters(func), frozenset(), resolve_param)
114
-
115
-
116
- def _walk(func, expr, vocab, assigns, params, seen, resolve) -> Taint:
117
- def w(e):
118
- return _walk(func, e, vocab, assigns, params, seen, resolve)
119
-
120
- if isinstance(expr, ast.Constant):
121
- return Taint.CONSTANT
122
- if isinstance(expr, ast.JoinedStr):
123
- return _combine([Taint.CONSTANT] + [w(v.value) for v in expr.values if isinstance(v, ast.FormattedValue)])
124
- if isinstance(expr, ast.BinOp):
125
- return _combine([w(expr.left), w(expr.right)])
126
- if isinstance(expr, ast.BoolOp):
127
- return _combine([w(v) for v in expr.values])
128
- if isinstance(expr, ast.IfExp):
129
- return _combine([w(expr.body), w(expr.orelse)])
130
- if isinstance(expr, (ast.List, ast.Tuple, ast.Set)):
131
- return _combine([w(e) for e in expr.elts] or [Taint.CONSTANT])
132
- if isinstance(expr, ast.Call):
133
- if _callee_in(expr, vocab.sanitizers):
134
- return Taint.SANITIZED # a sanitizer cleans its result regardless of input
135
- if _callee_in(expr, vocab.sources):
136
- return Taint.EXTERNAL # e.g. input()
137
- # a method ON a source/trusted object, e.g. request.args.get("id") or os.environ.get("X")
138
- func_path = access_path(expr.func)
139
- if func_path and _access_in(func_path, vocab.sources):
140
- return Taint.EXTERNAL
141
- if func_path and _access_in(func_path, vocab.trusted):
142
- return Taint.TRUSTED
143
- if _callee_in(expr, vocab.propagators) or _callee_in(expr, vocab.safe_sinks):
144
- return _combine([w(a) for a in expr.args] or [Taint.CONSTANT])
145
- return Taint.UNKNOWN # unknown call -- a cross-file hop may resolve it later
146
- if isinstance(expr, (ast.Attribute, ast.Subscript)):
147
- path = access_path(expr)
148
- if path and _access_in(path, vocab.sources):
149
- return Taint.EXTERNAL
150
- if path and _access_in(path, vocab.trusted):
151
- return Taint.TRUSTED
152
- root = access_root(expr)
153
- if root in params: # attribute of a parameter -- resolve at call site
154
- return resolve(root) if resolve else Taint.PARAM
155
- return Taint.UNKNOWN # unknown object attribute (e.g. self.x): not provably safe
156
- if isinstance(expr, ast.Name):
157
- if expr.id in seen:
158
- return Taint.CONSTANT # assignment cycle: no new information
159
- if expr.id in params:
160
- return resolve(expr.id) if resolve else Taint.PARAM
161
- if expr.id in assigns:
162
- return _combine([_walk(func, r, vocab, assigns, params, seen | {expr.id}, resolve)
163
- for r in assigns[expr.id]])
164
- return Taint.TRUSTED # free module global / builtin -- conventionally a constant
165
- return Taint.UNKNOWN
115
+ params = parameters(func)
116
+
117
+ def on_param(name: str) -> Taint:
118
+ return resolve_param(name) if resolve_param else Taint.PARAM
119
+
120
+ def leaf(node: ast.AST, recurse) -> Taint:
121
+ if isinstance(node, ast.Call):
122
+ if _callee_in(node, vocab.sanitizers):
123
+ return Taint.SANITIZED # a sanitizer cleans its result regardless of input
124
+ if _callee_in(node, vocab.sources):
125
+ return Taint.EXTERNAL # e.g. input()
126
+ # a method ON a source/trusted object, e.g. request.args.get("id")
127
+ func_path = access_path(node.func)
128
+ if func_path and _access_in(func_path, vocab.sources):
129
+ return Taint.EXTERNAL
130
+ if func_path and _access_in(func_path, vocab.trusted):
131
+ return Taint.TRUSTED
132
+ if _callee_in(node, vocab.propagators) or _callee_in(node, vocab.safe_sinks):
133
+ return _combine([recurse(a) for a in node.args])
134
+ return Taint.UNKNOWN # unknown call -- a cross-file hop may resolve it later
135
+ if isinstance(node, (ast.Attribute, ast.Subscript)):
136
+ path = access_path(node)
137
+ if path and _access_in(path, vocab.sources):
138
+ return Taint.EXTERNAL
139
+ if path and _access_in(path, vocab.trusted):
140
+ return Taint.TRUSTED
141
+ if access_root(node) in params: # attribute of a parameter -- resolve at the call site
142
+ return on_param(access_root(node))
143
+ return Taint.UNKNOWN # unknown object attribute (e.g. self.x): not provably safe
144
+ return Taint.UNKNOWN
145
+
146
+ return reduce_value(
147
+ expr,
148
+ params=params,
149
+ assigns=assignments(func),
150
+ combine=_combine,
151
+ leaf=leaf,
152
+ on_param=on_param,
153
+ on_global=lambda n: Taint.TRUSTED, # free module global / builtin -- conventionally a constant
154
+ on_constant=lambda: Taint.CONSTANT,
155
+ on_cycle=lambda: Taint.CONSTANT, # assignment cycle: no new information
156
+ )
166
157
 
167
158
 
168
159
  def _combine(taints: list[Taint]) -> Taint:
169
- return max(taints, key=lambda t: _RANK[t])
160
+ return max(taints, key=lambda t: _RANK[t]) if taints else Taint.CONSTANT
170
161
 
171
162
 
172
163
  def _callee_in(call: ast.Call, names: tuple[str, ...]) -> bool:
@@ -18,6 +18,7 @@ from codejury.domain.context import AnalysisContext
18
18
  from codejury.domain.result import AnalysisResult
19
19
  from codejury.infrastructure.cache import VerdictCache, verdict_key
20
20
  from codejury.orchestrators.base import Orchestrator
21
+ from codejury.orchestrators.adaptive import AdaptiveOrchestrator
21
22
  from codejury.orchestrators.challenge import ChallengeOrchestrator
22
23
  from codejury.orchestrators.debate import DebateOrchestrator
23
24
  from codejury.orchestrators.pipeline import PipelineOrchestrator
@@ -31,7 +32,7 @@ from codejury.providers.openai import OpenAIProvider
31
32
  from codejury.providers.retry import RetryProvider
32
33
  from codejury.sources.base import Source
33
34
 
34
- STRATEGIES = ("single", "pipeline", "debate", "reflexion", "challenge", "taint")
35
+ STRATEGIES = ("single", "pipeline", "debate", "reflexion", "challenge", "taint", "adaptive")
35
36
  PROVIDERS = ("anthropic", "openai", "litellm")
36
37
  DEFAULT_MODEL = os.environ.get("CODEJURY_MODEL", "claude-sonnet-4-6")
37
38
  DEFAULT_API_BASE = os.environ.get("CODEJURY_API_BASE")
@@ -59,6 +60,11 @@ def build_orchestration(
59
60
  roles = (FinderAgent, ChallengerAgent, JudgeAgent)
60
61
  agents = {cls.role: cls(provider=provider, model=model, max_tokens=max_tokens) for cls in roles}
61
62
  return agents, DebateOrchestrator()
63
+ if strategy == "adaptive":
64
+ roles = (FinderAgent, ChallengerAgent, JudgeAgent)
65
+ agents = {cls.role: cls(provider=provider, model=model, max_tokens=max_tokens) for cls in roles}
66
+ agents["verifier"] = VerifierAgent(provider=provider, model=model, max_tokens=max_tokens)
67
+ return agents, AdaptiveOrchestrator()
62
68
  if strategy == "reflexion":
63
69
  agents = {
64
70
  "actor": FinderAgent(provider=provider, model=model, max_tokens=max_tokens),
@@ -82,7 +88,7 @@ def build_orchestration(
82
88
  def provider_tag(provider: Provider) -> str:
83
89
  """A stable short name for a provider (unwrapping RetryProvider) for cache keys,
84
90
  so two providers that accept the same model string do not share cached verdicts."""
85
- return type(getattr(provider, "_inner", provider)).__name__
91
+ return type(getattr(provider, "inner", provider)).__name__
86
92
 
87
93
 
88
94
  def orchestration_descriptor(provider: Provider, strategy: str, model: str, max_tokens: int) -> str:
@@ -15,13 +15,11 @@ NOT_PRESENT verdicts and concessions are always kept.
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
- from codejury.domain.observation import Concession, Finding, Observation, Verdict
18
+ from codejury.domain.observation import Concession, Finding, Observation, Verdict, is_problem
19
19
  from codejury.domain.result import AnalysisResult
20
20
 
21
21
  Results = list[tuple[str, AnalysisResult]]
22
22
 
23
- _PROBLEM_STATUSES = ("VULNERABLE", "PARTIAL")
24
-
25
23
 
26
24
  def finding_key(o: Observation) -> tuple:
27
25
  """A location-tolerant fingerprint for matching a finding across versions."""
@@ -39,13 +37,13 @@ def filter_new(results: Results, baseline: Results) -> tuple[Results, int]:
39
37
 
40
38
  Returns (filtered_results, dropped_count). Non-problem observations are kept.
41
39
  """
42
- seen = {finding_key(o) for _, r in baseline for o in r.observations if _is_problem(o)}
40
+ seen = {finding_key(o) for _, r in baseline for o in r.observations if is_problem(o)}
43
41
  filtered: Results = []
44
42
  dropped = 0
45
43
  for path, result in results:
46
44
  kept: list[Observation] = []
47
45
  for o in result.observations:
48
- if _is_problem(o) and finding_key(o) in seen:
46
+ if is_problem(o) and finding_key(o) in seen:
49
47
  dropped += 1
50
48
  else:
51
49
  kept.append(o)
@@ -53,10 +51,8 @@ def filter_new(results: Results, baseline: Results) -> tuple[Results, int]:
53
51
  return filtered, dropped
54
52
 
55
53
 
56
- def _is_problem(o: Observation) -> bool:
57
- return isinstance(o, Finding) or (isinstance(o, Verdict) and o.status in _PROBLEM_STATUSES)
58
-
59
-
60
54
  def _evidence_sig(o: Observation) -> str:
55
+ # fingerprints only the first evidence snippet -- enough to distinguish findings
56
+ # in practice; two findings differing only in a later snippet would collide.
61
57
  evidence = getattr(o, "evidence", [])
62
58
  return " ".join(evidence[0].code.split()) if evidence and evidence[0].code else ""
@@ -280,6 +280,7 @@ def main(argv: list[str] | None = None) -> int:
280
280
  scan_p.add_argument("--no-suppress", action="store_true", help="disable the known-noise suppression filter")
281
281
  scan_p.add_argument("--no-cache", action="store_true", help="bypass the verdict cache (always re-query the model)")
282
282
  scan_p.add_argument("--baseline", default=None, help="a prior JSON report; report only findings new since it")
283
+ scan_p.add_argument("--retries", type=int, default=0, help="provider retry attempts on failure")
283
284
  scan_p.add_argument("--fail-on", choices=_FAIL_ON, default=None, dest="fail_on", help="exit 1 if a finding at/above this severity is found")
284
285
 
285
286
  run_p = sub.add_parser("run", help="run a named task preset against a unified diff")
@@ -301,9 +302,19 @@ def main(argv: list[str] | None = None) -> int:
301
302
  eval_p.add_argument("--model", default=DEFAULT_MODEL)
302
303
  eval_p.add_argument("--api-base", default=DEFAULT_API_BASE, help="provider base URL (env: CODEJURY_API_BASE)")
303
304
  eval_p.add_argument("--api-key", default=DEFAULT_API_KEY, help="provider API key (env: CODEJURY_API_KEY)")
305
+ eval_p.add_argument("--retries", type=int, default=0, help="provider retry attempts on failure")
304
306
 
305
307
  args = parser.parse_args(argv)
308
+ try:
309
+ return _dispatch(args, parser)
310
+ except Exception as exc:
311
+ # expected failures (missing diff file, provider auth, malformed input) become
312
+ # one stderr line, not a traceback. eval's own message is preserved by command name.
313
+ print(f"{args.command or 'codejury'} failed: {exc}", file=sys.stderr)
314
+ return 1
306
315
 
316
+
317
+ def _dispatch(args, parser) -> int:
307
318
  if args.command == "audit":
308
319
  results = audit(
309
320
  _read_diff(args.diff),
@@ -326,12 +337,15 @@ def main(argv: list[str] | None = None) -> int:
326
337
  capabilities = load_capabilities(args.capabilities)
327
338
  if args.only:
328
339
  wanted = {x.strip() for x in args.only.split(",")}
340
+ unknown = wanted - {c.id for c in capabilities}
341
+ if unknown:
342
+ print(f"warning: --only names unknown capability id(s): {', '.join(sorted(unknown))}", file=sys.stderr)
329
343
  capabilities = [c for c in capabilities if c.id in wanted]
330
344
  extensions = tuple(e if e.startswith(".") else "." + e for e in args.ext.split(","))
331
345
  results = scan(
332
346
  args.directory,
333
347
  capabilities,
334
- provider=make_provider(args.provider, api_key=args.api_key, api_base=args.api_base),
348
+ provider=make_provider(args.provider, api_key=args.api_key, api_base=args.api_base, retries=args.retries),
335
349
  model=args.model,
336
350
  max_tokens=args.max_tokens,
337
351
  strategy=args.orchestrator,
@@ -359,19 +373,13 @@ def main(argv: list[str] | None = None) -> int:
359
373
  return _gate_exit(results, args.fail_on)
360
374
 
361
375
  if args.command == "eval":
362
- try:
363
- report = evaluate(
364
- load_cases(args.dataset, split=args.split),
365
- load_capabilities(args.capabilities),
366
- provider=make_provider(args.provider, api_key=args.api_key, api_base=args.api_base),
367
- model=args.model,
368
- strategy=args.orchestrator,
369
- )
370
- except Exception as exc:
371
- # e.g. a missing API key surfaces as a provider auth error -- report it
372
- # as one line, not a traceback (audit gets this via the orchestrator).
373
- print(f"eval failed: {exc}")
374
- return 1
376
+ report = evaluate(
377
+ load_cases(args.dataset, split=args.split),
378
+ load_capabilities(args.capabilities),
379
+ provider=make_provider(args.provider, api_key=args.api_key, api_base=args.api_base, retries=args.retries),
380
+ model=args.model,
381
+ strategy=args.orchestrator,
382
+ )
375
383
  print(json.dumps(report.to_dict(), indent=2) if args.fmt == "json" else _render_eval(report))
376
384
  return 0
377
385
 
@@ -20,8 +20,12 @@ from typing import Any
20
20
 
21
21
  import yaml
22
22
 
23
+ from typing import get_args
24
+
23
25
  from codejury.domain.observation import Severity
24
26
 
27
+ _SEVERITIES = frozenset(get_args(Severity))
28
+
25
29
 
26
30
  @dataclass(frozen=True, kw_only=True)
27
31
  class CorrectPattern:
@@ -57,12 +61,18 @@ class AntiPattern:
57
61
 
58
62
  @classmethod
59
63
  def from_dict(cls, data: dict[str, Any]) -> AntiPattern:
64
+ severity = data.get("severity", "MEDIUM")
65
+ if severity not in _SEVERITIES:
66
+ raise ValueError(
67
+ f"anti_pattern {data.get('id')!r}: invalid severity {severity!r}; "
68
+ f"expected one of {', '.join(_SEVERITIES)}"
69
+ )
60
70
  return cls(
61
71
  id=data["id"],
62
72
  description=data.get("description", ""),
63
73
  signals=list(data.get("signals", [])),
64
74
  cwe=data.get("cwe", ""),
65
- severity=data.get("severity", "MEDIUM"),
75
+ severity=severity,
66
76
  why_bad=data.get("why_bad", ""),
67
77
  example_bad=data.get("example_bad", ""),
68
78
  example_good=data.get("example_good", ""),
@@ -130,7 +140,10 @@ def load_capability(path: str | Path) -> Capability:
130
140
  data = yaml.safe_load(f)
131
141
  if not isinstance(data, dict):
132
142
  raise ValueError(f"{path}: expected a YAML mapping at the top level, got {type(data).__name__}")
133
- return Capability.from_dict(data)
143
+ try:
144
+ return Capability.from_dict(data)
145
+ except (KeyError, ValueError) as exc:
146
+ raise ValueError(f"{path}: {exc}") from exc
134
147
 
135
148
 
136
149
  def load_capabilities(directory: str | Path) -> list[Capability]: