codejury 0.10.0__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.10.0 → codejury-0.10.1}/PKG-INFO +1 -1
  2. {codejury-0.10.0 → codejury-0.10.1}/codejury/agents/debate.py +6 -3
  3. {codejury-0.10.0 → codejury-0.10.1}/codejury/agents/mock.py +1 -2
  4. codejury-0.10.1/codejury/agents/parsing.py +45 -0
  5. {codejury-0.10.0 → codejury-0.10.1}/codejury/agents/verifier.py +7 -3
  6. {codejury-0.10.0 → codejury-0.10.1}/codejury/assembly.py +1 -1
  7. {codejury-0.10.0 → codejury-0.10.1}/codejury/baseline.py +5 -9
  8. {codejury-0.10.0 → codejury-0.10.1}/codejury/cli.py +22 -14
  9. {codejury-0.10.0 → codejury-0.10.1}/codejury/domain/capability.py +15 -2
  10. {codejury-0.10.0 → codejury-0.10.1}/codejury/domain/observation.py +25 -4
  11. {codejury-0.10.0 → codejury-0.10.1}/codejury/evaluation.py +8 -2
  12. {codejury-0.10.0 → codejury-0.10.1}/codejury/infrastructure/json_parse.py +8 -3
  13. {codejury-0.10.0 → codejury-0.10.1}/codejury/integrations/github.py +24 -9
  14. {codejury-0.10.0 → codejury-0.10.1}/codejury/orchestrators/base.py +9 -1
  15. {codejury-0.10.0 → codejury-0.10.1}/codejury/orchestrators/challenge.py +11 -1
  16. {codejury-0.10.0 → codejury-0.10.1}/codejury/providers/retry.py +8 -1
  17. {codejury-0.10.0 → codejury-0.10.1}/codejury/reporting.py +15 -10
  18. {codejury-0.10.0 → codejury-0.10.1}/codejury/sources/callers.py +3 -1
  19. {codejury-0.10.0 → codejury-0.10.1}/codejury/sources/diff.py +2 -1
  20. {codejury-0.10.0 → codejury-0.10.1}/codejury/sources/function.py +12 -3
  21. {codejury-0.10.0 → codejury-0.10.1}/codejury/sources/repo.py +4 -0
  22. {codejury-0.10.0 → codejury-0.10.1}/codejury/suppression.py +7 -9
  23. {codejury-0.10.0 → codejury-0.10.1}/codejury/tasks/base.py +2 -0
  24. {codejury-0.10.0 → codejury-0.10.1}/codejury.egg-info/PKG-INFO +1 -1
  25. {codejury-0.10.0 → codejury-0.10.1}/codejury.egg-info/SOURCES.txt +2 -0
  26. {codejury-0.10.0 → codejury-0.10.1}/pyproject.toml +1 -1
  27. {codejury-0.10.0 → codejury-0.10.1}/tests/test_evaluation.py +1 -1
  28. codejury-0.10.1/tests/test_parsing.py +32 -0
  29. codejury-0.10.1/tests/test_review_fixes.py +100 -0
  30. codejury-0.10.0/codejury/agents/parsing.py +0 -42
  31. {codejury-0.10.0 → codejury-0.10.1}/LICENSE +0 -0
  32. {codejury-0.10.0 → codejury-0.10.1}/README.md +0 -0
  33. {codejury-0.10.0 → codejury-0.10.1}/codejury/__init__.py +0 -0
  34. {codejury-0.10.0 → codejury-0.10.1}/codejury/agents/__init__.py +0 -0
  35. {codejury-0.10.0 → codejury-0.10.1}/codejury/agents/base.py +0 -0
  36. {codejury-0.10.0 → codejury-0.10.1}/codejury/agents/refuter.py +0 -0
  37. {codejury-0.10.0 → codejury-0.10.1}/codejury/analysis/__init__.py +0 -0
  38. {codejury-0.10.0 → codejury-0.10.1}/codejury/analysis/provenance.py +0 -0
  39. {codejury-0.10.0 → codejury-0.10.1}/codejury/analysis/taint.py +0 -0
  40. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/capabilities/authentication.yaml +0 -0
  41. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/capabilities/authorization.yaml +0 -0
  42. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/capabilities/business_logic.yaml +0 -0
  43. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/capabilities/crypto.yaml +0 -0
  44. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/capabilities/data_protection.yaml +0 -0
  45. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/capabilities/dependency_config.yaml +0 -0
  46. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/capabilities/error_logging.yaml +0 -0
  47. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/capabilities/excessive_agency.yaml +0 -0
  48. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/capabilities/input_validation.yaml +0 -0
  49. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/capabilities/insecure_output_handling.yaml +0 -0
  50. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/capabilities/output_encoding.yaml +0 -0
  51. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/capabilities/prompt_injection.yaml +0 -0
  52. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/capabilities/secrets.yaml +0 -0
  53. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/capabilities/session.yaml +0 -0
  54. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/ag_allowlist_safe.yaml +0 -0
  55. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/ag_arbitrary_tool_vuln.yaml +0 -0
  56. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/ag_destructive_no_confirm_vuln.yaml +0 -0
  57. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/ag_fixed_enum_safe.yaml +0 -0
  58. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/ag_human_approval_safe.yaml +0 -0
  59. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/ag_model_confirmed_vuln.yaml +0 -0
  60. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/authn_bcrypt_password.yaml +0 -0
  61. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/authn_jwt_noverify_vuln.yaml +0 -0
  62. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/authn_jwt_verified_safe.yaml +0 -0
  63. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/authn_sha256_checksum_safe.yaml +0 -0
  64. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/authn_sha256_password.yaml +0 -0
  65. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/authn_weak_hash_indirect_vuln.yaml +0 -0
  66. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/authz_idor_vuln.yaml +0 -0
  67. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/authz_owner_safe.yaml +0 -0
  68. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/business_logic_price_tamper_vuln.yaml +0 -0
  69. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/business_logic_server_checked_safe.yaml +0 -0
  70. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/cmdi_fixed_argv_safe.yaml +0 -0
  71. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/cmdi_ossystem_vuln.yaml +0 -0
  72. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/cmdi_subprocess_safe.yaml +0 -0
  73. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/crypto_aesgcm_safe.yaml +0 -0
  74. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/crypto_ecb_vuln.yaml +0 -0
  75. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/data_protection_plaintext_pii_vuln.yaml +0 -0
  76. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/data_protection_tokenized_safe.yaml +0 -0
  77. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/dependency_config_tls_verify_off_vuln.yaml +0 -0
  78. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/dependency_config_tls_verify_on_safe.yaml +0 -0
  79. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/deserialize_json_safe.yaml +0 -0
  80. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/deserialize_pickle_vuln.yaml +0 -0
  81. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/error_logging_redacted_safe.yaml +0 -0
  82. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/error_logging_secret_leak_vuln.yaml +0 -0
  83. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/ioh_escaped_output_safe.yaml +0 -0
  84. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/ioh_exec_output_vuln.yaml +0 -0
  85. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/ioh_innerhtml_output_vuln.yaml +0 -0
  86. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/ioh_json_response_safe.yaml +0 -0
  87. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/ioh_output_to_sql_vuln.yaml +0 -0
  88. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/ioh_schema_validated_safe.yaml +0 -0
  89. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/literal_eval_safe.yaml +0 -0
  90. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/path_basename_safe.yaml +0 -0
  91. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/path_contained_safe.yaml +0 -0
  92. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/path_traversal_vuln.yaml +0 -0
  93. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/pi_delimited_data_safe.yaml +0 -0
  94. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/pi_format_role_vuln.yaml +0 -0
  95. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/pi_indirect_rag_vuln.yaml +0 -0
  96. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/pi_system_concat_vuln.yaml +0 -0
  97. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/pi_user_content_concat_safe.yaml +0 -0
  98. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/pi_user_role_safe.yaml +0 -0
  99. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/secrets_env_safe.yaml +0 -0
  100. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/secrets_hardcoded_vuln.yaml +0 -0
  101. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/session_fixation_vuln.yaml +0 -0
  102. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/session_secure_cookie_safe.yaml +0 -0
  103. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/sql_constant_concat_safe.yaml +0 -0
  104. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/sqli_format_vuln.yaml +0 -0
  105. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/sqli_fstring_query.yaml +0 -0
  106. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/sqli_indirect_var_vuln.yaml +0 -0
  107. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/sqli_parameterized_query.yaml +0 -0
  108. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/ssrf_allowlist_safe.yaml +0 -0
  109. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/ssrf_constant_url_safe.yaml +0 -0
  110. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/ssrf_substring_allowlist_bypass_vuln.yaml +0 -0
  111. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/ssrf_user_url_vuln.yaml +0 -0
  112. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/xfile_idor_no_check_vuln.yaml +0 -0
  113. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/xfile_idor_owner_checked_safe.yaml +0 -0
  114. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/xfile_path_sanitized_safe.yaml +0 -0
  115. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/xfile_path_tainted_vuln.yaml +0 -0
  116. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/xss_innerhtml_constant_safe.yaml +0 -0
  117. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/xss_innerhtml_vuln.yaml +0 -0
  118. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/golden/xss_textcontent_safe.yaml +0 -0
  119. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/suppressions.yaml +0 -0
  120. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/taint.yaml +0 -0
  121. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/tasks/audit_diff_debate.yaml +0 -0
  122. {codejury-0.10.0 → codejury-0.10.1}/codejury/data/tasks/quick_scan_single.yaml +0 -0
  123. {codejury-0.10.0 → codejury-0.10.1}/codejury/domain/__init__.py +0 -0
  124. {codejury-0.10.0 → codejury-0.10.1}/codejury/domain/artifact.py +0 -0
  125. {codejury-0.10.0 → codejury-0.10.1}/codejury/domain/context.py +0 -0
  126. {codejury-0.10.0 → codejury-0.10.1}/codejury/domain/result.py +0 -0
  127. {codejury-0.10.0 → codejury-0.10.1}/codejury/infrastructure/__init__.py +0 -0
  128. {codejury-0.10.0 → codejury-0.10.1}/codejury/infrastructure/cache.py +0 -0
  129. {codejury-0.10.0 → codejury-0.10.1}/codejury/integrations/__init__.py +0 -0
  130. {codejury-0.10.0 → codejury-0.10.1}/codejury/orchestrators/__init__.py +0 -0
  131. {codejury-0.10.0 → codejury-0.10.1}/codejury/orchestrators/adaptive.py +0 -0
  132. {codejury-0.10.0 → codejury-0.10.1}/codejury/orchestrators/debate.py +0 -0
  133. {codejury-0.10.0 → codejury-0.10.1}/codejury/orchestrators/pipeline.py +0 -0
  134. {codejury-0.10.0 → codejury-0.10.1}/codejury/orchestrators/reflexion.py +0 -0
  135. {codejury-0.10.0 → codejury-0.10.1}/codejury/orchestrators/single.py +0 -0
  136. {codejury-0.10.0 → codejury-0.10.1}/codejury/orchestrators/taint_gate.py +0 -0
  137. {codejury-0.10.0 → codejury-0.10.1}/codejury/providers/__init__.py +0 -0
  138. {codejury-0.10.0 → codejury-0.10.1}/codejury/providers/anthropic.py +0 -0
  139. {codejury-0.10.0 → codejury-0.10.1}/codejury/providers/base.py +0 -0
  140. {codejury-0.10.0 → codejury-0.10.1}/codejury/providers/litellm.py +0 -0
  141. {codejury-0.10.0 → codejury-0.10.1}/codejury/providers/mock.py +0 -0
  142. {codejury-0.10.0 → codejury-0.10.1}/codejury/providers/openai.py +0 -0
  143. {codejury-0.10.0 → codejury-0.10.1}/codejury/providers/openai_format.py +0 -0
  144. {codejury-0.10.0 → codejury-0.10.1}/codejury/resources.py +0 -0
  145. {codejury-0.10.0 → codejury-0.10.1}/codejury/sources/__init__.py +0 -0
  146. {codejury-0.10.0 → codejury-0.10.1}/codejury/sources/base.py +0 -0
  147. {codejury-0.10.0 → codejury-0.10.1}/codejury/sources/chunker.py +0 -0
  148. {codejury-0.10.0 → codejury-0.10.1}/codejury/sources/mock.py +0 -0
  149. {codejury-0.10.0 → codejury-0.10.1}/codejury/tasks/__init__.py +0 -0
  150. {codejury-0.10.0 → codejury-0.10.1}/codejury/tasks/registry.py +0 -0
  151. {codejury-0.10.0 → codejury-0.10.1}/codejury.egg-info/dependency_links.txt +0 -0
  152. {codejury-0.10.0 → codejury-0.10.1}/codejury.egg-info/entry_points.txt +0 -0
  153. {codejury-0.10.0 → codejury-0.10.1}/codejury.egg-info/requires.txt +0 -0
  154. {codejury-0.10.0 → codejury-0.10.1}/codejury.egg-info/top_level.txt +0 -0
  155. {codejury-0.10.0 → codejury-0.10.1}/setup.cfg +0 -0
  156. {codejury-0.10.0 → codejury-0.10.1}/tests/test_adaptive.py +0 -0
  157. {codejury-0.10.0 → codejury-0.10.1}/tests/test_anthropic_provider.py +0 -0
  158. {codejury-0.10.0 → codejury-0.10.1}/tests/test_assembly.py +0 -0
  159. {codejury-0.10.0 → codejury-0.10.1}/tests/test_audit_pipeline.py +0 -0
  160. {codejury-0.10.0 → codejury-0.10.1}/tests/test_baseline.py +0 -0
  161. {codejury-0.10.0 → codejury-0.10.1}/tests/test_cache.py +0 -0
  162. {codejury-0.10.0 → codejury-0.10.1}/tests/test_callers.py +0 -0
  163. {codejury-0.10.0 → codejury-0.10.1}/tests/test_capability.py +0 -0
  164. {codejury-0.10.0 → codejury-0.10.1}/tests/test_challenge.py +0 -0
  165. {codejury-0.10.0 → codejury-0.10.1}/tests/test_cli_audit.py +0 -0
  166. {codejury-0.10.0 → codejury-0.10.1}/tests/test_context.py +0 -0
  167. {codejury-0.10.0 → codejury-0.10.1}/tests/test_debate_agents.py +0 -0
  168. {codejury-0.10.0 → codejury-0.10.1}/tests/test_debate_orchestrator.py +0 -0
  169. {codejury-0.10.0 → codejury-0.10.1}/tests/test_diff_source.py +0 -0
  170. {codejury-0.10.0 → codejury-0.10.1}/tests/test_function_source.py +0 -0
  171. {codejury-0.10.0 → codejury-0.10.1}/tests/test_integrations.py +0 -0
  172. {codejury-0.10.0 → codejury-0.10.1}/tests/test_json_parse.py +0 -0
  173. {codejury-0.10.0 → codejury-0.10.1}/tests/test_litellm_provider.py +0 -0
  174. {codejury-0.10.0 → codejury-0.10.1}/tests/test_openai_provider.py +0 -0
  175. {codejury-0.10.0 → codejury-0.10.1}/tests/test_orchestrator.py +0 -0
  176. {codejury-0.10.0 → codejury-0.10.1}/tests/test_pipeline_orchestrator.py +0 -0
  177. {codejury-0.10.0 → codejury-0.10.1}/tests/test_provenance.py +0 -0
  178. {codejury-0.10.0 → codejury-0.10.1}/tests/test_reflexion_orchestrator.py +0 -0
  179. {codejury-0.10.0 → codejury-0.10.1}/tests/test_repo_source.py +0 -0
  180. {codejury-0.10.0 → codejury-0.10.1}/tests/test_reporting.py +0 -0
  181. {codejury-0.10.0 → codejury-0.10.1}/tests/test_retry_provider.py +0 -0
  182. {codejury-0.10.0 → codejury-0.10.1}/tests/test_sarif.py +0 -0
  183. {codejury-0.10.0 → codejury-0.10.1}/tests/test_suppression.py +0 -0
  184. {codejury-0.10.0 → codejury-0.10.1}/tests/test_taint.py +0 -0
  185. {codejury-0.10.0 → codejury-0.10.1}/tests/test_taint_crossfile.py +0 -0
  186. {codejury-0.10.0 → codejury-0.10.1}/tests/test_taint_gate.py +0 -0
  187. {codejury-0.10.0 → codejury-0.10.1}/tests/test_tasks.py +0 -0
  188. {codejury-0.10.0 → 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.10.0
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
@@ -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]:
@@ -88,7 +88,7 @@ def build_orchestration(
88
88
  def provider_tag(provider: Provider) -> str:
89
89
  """A stable short name for a provider (unwrapping RetryProvider) for cache keys,
90
90
  so two providers that accept the same model string do not share cached verdicts."""
91
- return type(getattr(provider, "_inner", provider)).__name__
91
+ return type(getattr(provider, "inner", provider)).__name__
92
92
 
93
93
 
94
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]:
@@ -14,6 +14,7 @@ across subclass inheritance.
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ import dataclasses
17
18
  from dataclasses import asdict, dataclass, field
18
19
  from typing import Any, ClassVar, Literal
19
20
 
@@ -112,13 +113,33 @@ _OBSERVATION_CLASSES: dict[ObservationKind, type[Observation]] = {
112
113
  }
113
114
 
114
115
 
116
+ _PROBLEM_STATUSES = ("VULNERABLE", "PARTIAL")
117
+
118
+
119
+ def is_problem(o: Observation) -> bool:
120
+ """A reportable problem: a Finding, or a VULNERABLE/PARTIAL Verdict. The single
121
+ definition shared by reporting, suppression, and the diff baseline."""
122
+ return isinstance(o, Finding) or (isinstance(o, Verdict) and o.status in _PROBLEM_STATUSES)
123
+
124
+
125
+ def _only_known(cls: type, data: dict[str, Any]) -> dict[str, Any]:
126
+ fields = {f.name for f in dataclasses.fields(cls)}
127
+ return {k: v for k, v in data.items() if k in fields}
128
+
129
+
115
130
  def observation_from_dict(data: dict[str, Any]) -> Observation:
116
131
  """Reconstruct an observation from its ``to_dict`` form (inverse of it).
117
132
 
118
- Used by the verdict cache to round-trip results through JSON.
133
+ Used to round-trip results through JSON (the verdict cache and the diff
134
+ baseline). On-disk reports are external input, so this fails with a clear
135
+ error on a missing/unknown ``kind`` and tolerates unknown extra keys (forward
136
+ compatibility) rather than crashing with KeyError/TypeError.
119
137
  """
120
138
  data = dict(data)
121
- cls = _OBSERVATION_CLASSES[data.pop("kind")]
139
+ kind = data.pop("kind", None)
140
+ cls = _OBSERVATION_CLASSES.get(kind)
141
+ if cls is None:
142
+ raise ValueError(f"unknown observation kind: {kind!r}")
122
143
  if "evidence" in data:
123
- data["evidence"] = [Evidence(**e) for e in data["evidence"]]
124
- return cls(**data)
144
+ data["evidence"] = [Evidence(**_only_known(Evidence, e)) for e in data["evidence"]]
145
+ return cls(**_only_known(cls, data))
@@ -30,6 +30,7 @@ import yaml
30
30
  from codejury.domain.artifact import CodeArtifact
31
31
  from codejury.domain.capability import Capability
32
32
  from codejury.domain.context import AnalysisContext
33
+ from codejury.domain.observation import Observation
33
34
  from codejury.providers.base import Provider
34
35
 
35
36
 
@@ -126,7 +127,12 @@ def load_cases(directory: str | Path, *, split: str | None = None) -> list[Golde
126
127
  for path in sorted(Path(directory).glob("*.yaml")):
127
128
  with open(path, encoding="utf-8") as f:
128
129
  data = yaml.safe_load(f)
129
- case = GoldenCase.from_dict(path.stem, data)
130
+ if not isinstance(data, dict):
131
+ raise ValueError(f"{path}: expected a YAML mapping, got {type(data).__name__}")
132
+ try:
133
+ case = GoldenCase.from_dict(path.stem, data)
134
+ except KeyError as exc:
135
+ raise ValueError(f"{path}: golden case missing required key {exc}") from exc
130
136
  if split is None or case.split == split:
131
137
  cases.append(case)
132
138
  return cases
@@ -166,7 +172,7 @@ def evaluate(
166
172
  return report
167
173
 
168
174
 
169
- def _predicted_vulnerable(observations: list) -> bool:
175
+ def _predicted_vulnerable(observations: list[Observation]) -> bool:
170
176
  """Did the run flag a problem? A Finding (debate/reflexion) or a VULNERABLE
171
177
  Verdict (single/pipeline/taint) counts; SECURE verdicts and dismissed
172
178
  concessions do not. (A bare Finding has no ``status``, so checking only
@@ -10,13 +10,18 @@ from __future__ import annotations
10
10
  import json
11
11
  import re
12
12
 
13
- # Greedy so a fenced block with nested braces is captured whole.
14
- _FENCE = re.compile(r"```(?:json)?\s*(\{.*\})\s*```", re.DOTALL)
13
+ # Non-greedy so a second code fence later in the text doesn't get swallowed into the
14
+ # captured span; nested braces inside one block are still matched by the {...} anchors.
15
+ _FENCE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)
16
+
17
+ # Defensive ceiling: model output is bounded by max_tokens, but never scan an
18
+ # unbounded string (the balanced-brace pass is superlinear on pathological input).
19
+ _MAX_SCAN = 1_000_000
15
20
 
16
21
 
17
22
  def extract_json_object(text: str) -> dict | None:
18
23
  """Return the first JSON object found in `text`, or None if there is none."""
19
- text = text.strip()
24
+ text = text.strip()[:_MAX_SCAN]
20
25
 
21
26
  try:
22
27
  obj = json.loads(text)
@@ -10,6 +10,7 @@ review body. The review requests changes when any problem is found.
10
10
  from __future__ import annotations
11
11
 
12
12
  import json
13
+ import urllib.error
13
14
  import urllib.request
14
15
  from typing import Any, Callable
15
16
 
@@ -18,18 +19,21 @@ from codejury.domain.result import AnalysisResult
18
19
 
19
20
  Results = list[tuple[str, AnalysisResult]]
20
21
 
22
+ # rank for ordering inline comments most-severe first before truncating to max_comments
23
+ _COMMENT_RANK = {"CRITICAL": 0, "HIGH": 1, "VULNERABLE": 1, "MEDIUM": 2, "PARTIAL": 2, "LOW": 3, "INFO": 4}
24
+
21
25
 
22
26
  def build_review(results: Results, *, max_comments: int = 50) -> dict:
23
- comments: list[dict] = []
24
- problems = 0
27
+ ranked: list[tuple[int, dict]] = []
25
28
  for _path, result in results:
26
29
  for o in result.observations:
27
30
  comment = _inline_comment(o)
28
- if comment is None:
29
- continue
30
- problems += 1
31
- if len(comments) < max_comments:
32
- comments.append(comment)
31
+ if comment is not None:
32
+ ranked.append((_comment_rank(o), comment))
33
+ problems = len(ranked)
34
+ # surface the most severe inline when there are more problems than the cap
35
+ ranked.sort(key=lambda rc: rc[0])
36
+ comments = [c for _, c in ranked[:max_comments]]
33
37
 
34
38
  body = (
35
39
  f"codejury found {problems} issue(s)." if problems else "codejury found no issues."
@@ -43,6 +47,11 @@ def build_review(results: Results, *, max_comments: int = 50) -> dict:
43
47
  }
44
48
 
45
49
 
50
+ def _comment_rank(o: Observation) -> int:
51
+ key = o.severity if o.kind == "finding" else getattr(o, "status", "")
52
+ return _COMMENT_RANK.get(key, 5)
53
+
54
+
46
55
  def _inline_comment(o: Observation) -> dict | None:
47
56
  if o.kind == "finding":
48
57
  evidence = o.evidence[0] if o.evidence else None
@@ -75,8 +84,14 @@ def post_review(
75
84
  if transport is not None:
76
85
  return transport(url, data, headers)
77
86
  request = urllib.request.Request(url, data=data, headers=headers, method="POST")
78
- with urllib.request.urlopen(request) as response:
79
- return response.status
87
+ try:
88
+ with urllib.request.urlopen(request) as response:
89
+ return response.status
90
+ except urllib.error.HTTPError as exc:
91
+ # surface GitHub's error detail (auth 401, repo/PR 404, out-of-diff line 422);
92
+ # the token is in the request, not this response body, so it is safe to include.
93
+ detail = exc.read().decode("utf-8", "replace")[:500]
94
+ raise RuntimeError(f"GitHub review POST failed: {exc.code} {detail}") from exc
80
95
 
81
96
 
82
97
  def parse_pr_ref(ref: str) -> tuple[str, str, int]:
@@ -16,4 +16,12 @@ from codejury.domain.result import AnalysisResult
16
16
 
17
17
  class Orchestrator(ABC):
18
18
  @abstractmethod
19
- def run(self, agents: dict[str, Agent], context: AnalysisContext) -> AnalysisResult: ...
19
+ def run(self, agents: dict[str, Agent], context: AnalysisContext) -> AnalysisResult:
20
+ """Run the orchestration and return a result.
21
+
22
+ Contract: never raise for an agent/provider failure -- record it in
23
+ ``AnalysisResult.error`` and return the observations produced so far.
24
+ ``single`` deliberately stops at the first failure; the multi-agent
25
+ strategies record the error and return their partial ruling.
26
+ """
27
+ ...
@@ -17,6 +17,7 @@ taint checks over-report.
17
17
  from __future__ import annotations
18
18
 
19
19
  import dataclasses
20
+ from collections import Counter
20
21
 
21
22
  from codejury.agents.base import Agent
22
23
  from codejury.domain.context import AnalysisContext
@@ -50,10 +51,19 @@ class ChallengeOrchestrator(Orchestrator):
50
51
 
51
52
  refutations = agents["refuter"].run(dataclasses.replace(context, history=flagged))
52
53
  reasons = {c.target: c.reason for c in refutations if isinstance(c, Concession)}
54
+ # the refuter targets a capability, not an individual verdict; if a capability
55
+ # has more than one flagged verdict the refutation is ambiguous, so leave them
56
+ # all standing rather than risk dismissing one the refuter did not concede.
57
+ flagged_per_cap = Counter(v.capability for v in flagged)
53
58
 
54
59
  observations: list[Observation] = []
55
60
  for v in verdicts:
56
- if isinstance(v, Verdict) and v.status == "VULNERABLE" and v.capability in reasons:
61
+ if (
62
+ isinstance(v, Verdict)
63
+ and v.status == "VULNERABLE"
64
+ and v.capability in reasons
65
+ and flagged_per_cap[v.capability] == 1
66
+ ):
57
67
  observations.append(
58
68
  Concession(
59
69
  capability=v.capability,
@@ -21,11 +21,18 @@ class RetryProvider(Provider):
21
21
  max_attempts: int = 3,
22
22
  base_delay: float = 1.0,
23
23
  sleep: Callable[[float], None] = time.sleep,
24
+ retryable: tuple[type[BaseException], ...] = (Exception,),
24
25
  ) -> None:
25
26
  self._inner = inner
26
27
  self._max_attempts = max_attempts
27
28
  self._base_delay = base_delay
28
29
  self._sleep = sleep
30
+ self._retryable = retryable
31
+
32
+ @property
33
+ def inner(self) -> Provider:
34
+ """The wrapped provider (so callers need not reach into a private field)."""
35
+ return self._inner
29
36
 
30
37
  def complete(
31
38
  self,
@@ -41,7 +48,7 @@ class RetryProvider(Provider):
41
48
  return self._inner.complete(
42
49
  system=system, messages=messages, model=model, max_tokens=max_tokens, cache=cache
43
50
  )
44
- except Exception:
51
+ except self._retryable:
45
52
  if attempt == self._max_attempts:
46
53
  raise
47
54
  self._sleep(self._base_delay * attempt)
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
  import json
12
12
 
13
13
  from codejury import __version__ as _tool_version
14
- from codejury.domain.observation import Observation, observation_from_dict
14
+ from codejury.domain.observation import Observation, is_problem, observation_from_dict
15
15
  from codejury.domain.result import AnalysisResult
16
16
 
17
17
  Results = list[tuple[str, AnalysisResult]]
@@ -39,8 +39,17 @@ def to_json(results: Results) -> str:
39
39
 
40
40
 
41
41
  def from_json(text: str) -> Results:
42
- """Parse a ``to_json`` report back into results (used to load a diff baseline)."""
43
- payload = json.loads(text)
42
+ """Parse a ``to_json`` report back into results (used to load a diff baseline).
43
+
44
+ Raises ValueError on a malformed report (it is external, possibly hand-edited,
45
+ input) rather than an opaque KeyError/TypeError.
46
+ """
47
+ try:
48
+ payload = json.loads(text)
49
+ except json.JSONDecodeError as exc:
50
+ raise ValueError(f"baseline report is not valid JSON: {exc}") from exc
51
+ if not isinstance(payload, dict):
52
+ raise ValueError("baseline report must be a JSON object with a 'files' list")
44
53
  return [
45
54
  (
46
55
  f.get("path", ""),
@@ -61,7 +70,7 @@ def to_markdown(results: Results) -> str:
61
70
  if result.error:
62
71
  lines.append(f"> error: {result.error}")
63
72
 
64
- problems = sorted((o for o in result.observations if _is_problem(o)), key=_rank)
73
+ problems = sorted((o for o in result.observations if is_problem(o)), key=_rank)
65
74
  cleared = [o for o in result.observations if o.kind == "verdict" and o.status in _CLEARED]
66
75
  dismissed = [o for o in result.observations if o.kind == "concession"]
67
76
 
@@ -93,16 +102,12 @@ def _summary(results: Results) -> list[str]:
93
102
  dismissed += 1
94
103
  return [
95
104
  f"- files audited: {len(results)}",
96
- f"- issues: {vulnerable} vulnerable verdict(s), {findings} finding(s)",
105
+ f"- issues: {vulnerable} problem verdict(s) (VULNERABLE/PARTIAL), {findings} finding(s)",
97
106
  f"- checked and clear: {cleared}",
98
107
  f"- dismissed: {dismissed}",
99
108
  ]
100
109
 
101
110
 
102
- def _is_problem(o: Observation) -> bool:
103
- return o.kind == "finding" or (o.kind == "verdict" and o.status in _PROBLEM_STATUSES)
104
-
105
-
106
111
  def _rank(o: Observation) -> int:
107
112
  if o.kind == "finding":
108
113
  return _SEVERITY_ORDER.get(o.severity, 5)
@@ -146,7 +151,7 @@ def to_sarif(results: Results) -> str:
146
151
 
147
152
  for _, result in results:
148
153
  for o in result.observations:
149
- if not _is_problem(o):
154
+ if not is_problem(o):
150
155
  continue
151
156
  locations = _sarif_locations(o)
152
157
  if not locations:
@@ -4,7 +4,9 @@ For a file under review, find where the functions and classes it defines are
4
4
  called elsewhere in the repository. Showing those call sites lets the verifier
5
5
  trace where an argument comes from -- which is exactly what single-file review
6
6
  lacks for taint-style issues (a path/command that is operator-supplied vs
7
- attacker-controlled). This is a textual usage finder, not a full call graph.
7
+ attacker-controlled). This is a textual usage finder, not a full call graph: the
8
+ call regex also matches a name inside a string, a comment, or a `def`/`class`
9
+ line elsewhere, so treat the result as a hint, not a precise caller set.
8
10
  """
9
11
 
10
12
  from __future__ import annotations
@@ -3,7 +3,8 @@
3
3
  Splits on ``diff --git`` headers (falling back to a single section for a plain
4
4
  diff with no git header). The path is taken from the +++ line, then the ---
5
5
  line, then the header -- skipping /dev/null so adds and deletes still resolve to
6
- the real file.
6
+ the real file. Combined/merge diffs (``diff --cc``) are not split and would fold
7
+ into the preceding section; the intended input is an ordinary two-way PR diff.
7
8
  """
8
9
 
9
10
  from __future__ import annotations
@@ -1,8 +1,10 @@
1
1
  """FunctionSource -- split Python source into one CodeArtifact per function.
2
2
 
3
- Parses the AST and emits an artifact for every function and method (including
4
- async and nested ones), in source order. Good for deeply auditing one handler at
5
- a time. The content must be valid Python; a parse failure raises SyntaxError.
3
+ Parses the AST and emits an artifact for every top-level function and method
4
+ (async included), in source order. Functions nested inside another function are
5
+ skipped: their source already lives inside the enclosing function's artifact, so
6
+ emitting them again would send the model overlapping, duplicated content. The
7
+ content must be valid Python; a parse failure raises SyntaxError.
6
8
  """
7
9
 
8
10
  from __future__ import annotations
@@ -23,6 +25,7 @@ class FunctionSource(Source):
23
25
  functions = [
24
26
  node for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
25
27
  ]
28
+ functions = [n for n in functions if not _nested_in_function(n, functions)]
26
29
  functions.sort(key=lambda n: n.lineno)
27
30
 
28
31
  artifacts = []
@@ -33,3 +36,9 @@ class FunctionSource(Source):
33
36
  CodeArtifact(kind="function", path=f"{self._path}::{node.name}", content=segment)
34
37
  )
35
38
  return artifacts
39
+
40
+
41
+ def _nested_in_function(node: ast.AST, functions: list[ast.AST]) -> bool:
42
+ """True if ``node`` is defined inside another function (a method in a class is
43
+ not -- its enclosing scope is the class, not a function)."""
44
+ return any(other is not node and any(d is node for d in ast.walk(other)) for other in functions)