@hoanghainh/komagu 2.0.0 → 2.0.1

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 (130) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +1 -1
  3. package/assess/lsa/examples/eval-run-4/ground-truth.json +823 -0
  4. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/ai-run-stats.json +58 -0
  5. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/coverage.json +85 -0
  6. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/findings/priv-privilege-001.json +822 -0
  7. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/findings/priv-privilege-002.json +393 -0
  8. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/findings.json +4477 -0
  9. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/mass-assignment.candidates.json +232 -0
  10. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-001.json +425 -0
  11. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-002.json +333 -0
  12. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-003.json +400 -0
  13. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-004.json +403 -0
  14. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-005.json +398 -0
  15. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-006.json +63 -0
  16. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/score.json +120 -0
  17. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/ai-run-stats.json +55 -0
  18. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/coverage.json +50 -0
  19. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/findings/priv-privilege-001.json +613 -0
  20. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/findings/priv-privilege-002.json +287 -0
  21. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/findings.json +4870 -0
  22. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/mass-assignment.candidates.json +232 -0
  23. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-001.json +425 -0
  24. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-002.json +333 -0
  25. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-003.json +400 -0
  26. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-004.json +403 -0
  27. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-005.json +398 -0
  28. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-006.json +63 -0
  29. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/score.json +107 -0
  30. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/ai-run-stats.json +58 -0
  31. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/coverage.json +92 -0
  32. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/findings/priv-privilege-001.json +645 -0
  33. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/findings/priv-privilege-002.json +222 -0
  34. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/findings.json +4859 -0
  35. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/mass-assignment.candidates.json +232 -0
  36. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-001.json +425 -0
  37. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-002.json +333 -0
  38. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-003.json +400 -0
  39. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-004.json +403 -0
  40. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-005.json +398 -0
  41. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-006.json +63 -0
  42. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/score.json +121 -0
  43. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/coverage.json +111 -0
  44. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/findings/priv-privilege-001.json +6 -0
  45. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/findings/priv-privilege-002.json +344 -0
  46. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/findings.json +3544 -0
  47. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/mass-assignment.candidates.json +232 -0
  48. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-001.json +425 -0
  49. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-002.json +333 -0
  50. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-003.json +400 -0
  51. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-004.json +403 -0
  52. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-005.json +398 -0
  53. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-006.json +63 -0
  54. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/score.json +123 -0
  55. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/ai-run-stats.json +50 -0
  56. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/coverage.json +42 -0
  57. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings/priv-privilege-001.json +540 -0
  58. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings/priv-privilege-002.json +238 -0
  59. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings.json +4383 -0
  60. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings.sarif +8729 -0
  61. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/mass-assignment.candidates.json +232 -0
  62. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/report.ja.md +1911 -0
  63. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/report.md +1911 -0
  64. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-001.json +425 -0
  65. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-002.json +333 -0
  66. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-003.json +400 -0
  67. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-004.json +403 -0
  68. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-005.json +398 -0
  69. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-006.json +63 -0
  70. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/score.json +101 -0
  71. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/stack-profile.json +226 -0
  72. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/ai-run-stats.json +54 -0
  73. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/coverage.json +66 -0
  74. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/findings/priv-privilege-001.json +440 -0
  75. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/findings/priv-privilege-002.json +229 -0
  76. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/findings.json +3424 -0
  77. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/mass-assignment.candidates.json +232 -0
  78. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-001.json +425 -0
  79. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-002.json +333 -0
  80. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-003.json +400 -0
  81. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-004.json +403 -0
  82. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-005.json +398 -0
  83. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-006.json +63 -0
  84. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/score.json +120 -0
  85. package/assess/lsa/examples/eval-run-5/gptoss-a/ai-run-stats.json +88 -0
  86. package/assess/lsa/examples/eval-run-5/gptoss-a/coverage.json +36 -0
  87. package/assess/lsa/examples/eval-run-5/gptoss-a/findings/priv-privilege-001.json +530 -0
  88. package/assess/lsa/examples/eval-run-5/gptoss-a/findings/priv-privilege-002.json +248 -0
  89. package/assess/lsa/examples/eval-run-5/gptoss-a/findings.json +4766 -0
  90. package/assess/lsa/examples/eval-run-5/gptoss-a/findings.sarif +9326 -0
  91. package/assess/lsa/examples/eval-run-5/gptoss-a/mass-assignment.candidates.json +232 -0
  92. package/assess/lsa/examples/eval-run-5/gptoss-a/report.ja.md +2011 -0
  93. package/assess/lsa/examples/eval-run-5/gptoss-a/report.md +2011 -0
  94. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-001.json +425 -0
  95. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-002.json +333 -0
  96. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-003.json +400 -0
  97. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-004.json +403 -0
  98. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-005.json +398 -0
  99. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-006.json +63 -0
  100. package/assess/lsa/examples/eval-run-5/gptoss-a/score.json +80 -0
  101. package/assess/lsa/examples/eval-run-5/gptoss-a/stack-profile.json +226 -0
  102. package/assess/lsa/examples/eval-run-5/gptoss-b/ai-run-stats.json +83 -0
  103. package/assess/lsa/examples/eval-run-5/gptoss-b/coverage.json +44 -0
  104. package/assess/lsa/examples/eval-run-5/gptoss-b/findings/priv-privilege-001.json +579 -0
  105. package/assess/lsa/examples/eval-run-5/gptoss-b/findings/priv-privilege-002.json +268 -0
  106. package/assess/lsa/examples/eval-run-5/gptoss-b/findings.json +3913 -0
  107. package/assess/lsa/examples/eval-run-5/gptoss-b/mass-assignment.candidates.json +232 -0
  108. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-001.json +425 -0
  109. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-002.json +333 -0
  110. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-003.json +400 -0
  111. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-004.json +403 -0
  112. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-005.json +398 -0
  113. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-006.json +63 -0
  114. package/assess/lsa/examples/eval-run-5/gptoss-b/score.json +94 -0
  115. package/assess/lsa/examples/eval-run-5/ground-truth.json +823 -0
  116. package/assess/lsa/prompts/35-privilege-auditor.md +3 -1
  117. package/assess/lsa/tests/EVAL-RESULTS.md +156 -0
  118. package/assess/lsa/tests/score_findings.py +58 -0
  119. package/assess/lsa/tests/test_eval_run_4.py +73 -0
  120. package/assess/lsa/tests/test_eval_run_5.py +73 -0
  121. package/assess/lsa/tests/test_privilege_tier2.py +30 -0
  122. package/operate/ai/anchoring.py +130 -0
  123. package/operate/ai/client.py +13 -0
  124. package/operate/ai/context.py +105 -0
  125. package/operate/ai/runner.py +197 -69
  126. package/operate/cli/__init__.py +1 -1
  127. package/operate/cli/commands/scan.py +1 -1
  128. package/operate/tests/test_ai_anchoring.py +184 -0
  129. package/operate/tests/test_headless_ai.py +148 -1
  130. package/package.json +1 -1
@@ -69,7 +69,9 @@ privileged, and a hinted route can be safe.
69
69
  2. **Find the model's fields.** Read the model / entity / table. List the
70
70
  privileged fields it holds (role, admin flag, permissions, owner, user,
71
71
  tenant, price, amount, balance, status, verified, approved, plan).
72
- No privileged field → `safe`, reason "no privileged field".
72
+ No privileged field → `safe`, reason "no privileged field". If you cannot
73
+ find or read the model's definition, you do not know its fields: answer
74
+ `needs_review` (reason "model fields not visible"), never `safe`.
73
75
  3. **Look for what limits the fields**, with `file:line`: explicit field
74
76
  copy, allow-list (`$fillable` without privileged fields, `permit(...)`,
75
77
  `[Bind(Include=...)]`, DTO with only editable fields), validation that
@@ -152,3 +152,159 @@ Based on Qwen 3.5's findings, the following concrete improvements were implement
152
152
  | **False Positives on Safe Code** | 0 | 0 | 0 on safe markers | **0** |
153
153
  | **Precision (matched / active)** | 0.965 (55 / 57) | 0.965 (55 / 57) | 0.966 | **1.000 (57 / 57)** |
154
154
  | **Legacy Stack Shims** | None | None | Recommended | **4 Stacks (`remediate/legacy_shims/`)** |
155
+
156
+ ---
157
+
158
+ ## Run 4 — 2026-10-31, kit 0.3.1 (BFLA & mass assignment Tier 2, local Ollama)
159
+
160
+ Issue #286. First end-to-end measurement of phase 4c (`35-privilege-auditor`) with real models, and a
161
+ re-check of BOLA / injection on the same harness. All runs are **local only**: fixture code never left
162
+ the machine, and no third-party LLM was used.
163
+
164
+ ### Setup
165
+
166
+ | Item | Value |
167
+ |---|---|
168
+ | Eval repo | `tests/build_eval_repo.py` (10 fixture apps, markers stripped); ground truth 117 items: 25 BOLA, 34 SQL/NoSQL injection, 10 BFLA, 15 mass assignment, plus 33 SAFE markers |
169
+ | Pipeline | `komagu scan <repo> --no-cache --headless-ai http://localhost:11434/v1 --model <model> --ai-timeout 3600` (`operate/ai/runner.py`, Tier 1 pre-scan + phases 2–6) |
170
+ | Hosting | Ollama 0.40.13 on an Apple M4 Pro, 24 GB RAM, 100% GPU; context 32,768 tokens |
171
+ | Models | `gpt-oss:20b` (MXFP4, reasoning effort default `medium`), `qwen3.5:9b` |
172
+ | Scoring | `tests/score_findings.py --verdicts` (tolerance 3 lines); archived per run in [`assess/lsa/examples/eval-run-4/`](../examples/eval-run-4/) |
173
+
174
+ **This is a floor, not a ceiling.** A 9–20B model on a laptop is much weaker than the models used in
175
+ Runs 1–2. The numbers below say what a fully local deployment achieves today, not what the prompts can
176
+ do with a stronger model.
177
+
178
+ **Not comparable with Run 3.** Run 3 does not record its model, and the archived Run 1 / Run 2 / Qwen
179
+ numbers were produced through agent runtimes that can open files, not through the headless runner.
180
+ The BOLA / injection columns below are therefore a new baseline for the headless harness, not a
181
+ regression check against Run 3.
182
+
183
+ ### Harness fixes found by this run
184
+
185
+ Measuring with real models exposed defects in the headless runner that inflated or lost results.
186
+ They were fixed before the numbers were trusted, in two iterations (the limit set in #286):
187
+
188
+ | Iteration | Fix | Effect seen |
189
+ |---|---|---|
190
+ | 0 → 1 | Verifier no longer marks a whole batch `confirmed` when its reply cannot be parsed (it used to, with a made-up "consensus" note); such findings stay `unverified` | Results stopped being inflated by broken replies |
191
+ | 0 → 1 | A failed call (timeout) is recorded per batch in `ai-run-stats.json` instead of aborting the run | A 25-candidate injection batch took > 15 min and used to kill the run |
192
+ | 0 → 1 | Phase 4c gets the same JSON output template as 4a / 4b; sanitizer joins list `impact` values and moves a verdict written into `subtype` to `status`; unparseable replies saved to `OUT/ai-raw/` | Mass assignment findings stopped failing schema validation |
193
+ | 1 → 2 | Verifier now receives the code it judges (finding site, file header, evidence lines, server-side includes) and the auth model, and may only cite controls that are visible; otherwise `likely` + `code_not_visible` | In iteration 1 it rejected 10 of 11 real BFLA findings by inventing an admin base class and a role check in a login-only `auth.php` |
194
+ | 1 → 2 | Verifier batches of 5 instead of 10; sanitizer fills a `null` evidence line and a missing `status` | No verifier parse failures in iteration 2 |
195
+
196
+ ### Results after verification
197
+
198
+ "Marker" columns use the normal location match. "Verdict" columns (BFLA / mass assignment only) count a
199
+ truth item as found when the privilege auditor judged the route or candidate that contains it
200
+ `vulnerable` or `needs_review`, whatever path and line the model then wrote into the finding.
201
+
202
+ | Run | BOLA | Injection | BFLA marker | Mass marker | BFLA verdict | Mass verdict | FP on safe code | Precision | Rejected | Unverified | Parse fail | Min · calls · tokens |
203
+ |---|---|---|---|---|---|---|---|---|---|---|---|---|
204
+ | iter 0, gpt-oss A | 19/25 | 20/34 | 8/10 | 2/15 | 10/10 | 12/15 | 0 | 0.689 | 10 | 11 | 3 | 81 · 22 · 311K |
205
+ | iter 1, gpt-oss A | 21/25 | 34/34 | 1/10 | 2/15 | 10/10 | 14/15 | 0 | 0.765 | 19 | 17 | 2 | 75 · 21 · 288K |
206
+ | iter 1, gpt-oss B | 19/25 | 28/34 | 5/10 | 5/15 | 10/10 | 14/15 | 1 | 0.667 | 4 | 8 | 3 | 80 · 22 · 273K |
207
+ | iter 1, qwen3.5 (incomplete) | 23/25 | 33/34 | 0/10 | 0/15 | 0/10 | 8/15 | 1 | 0.778 | 0 | 52 | — | stopped after 2 h 50 min |
208
+ | **iter 2, gpt-oss A** | **24/25** | **34/34** | **8/10** | **6/15** | **10/10** | **10/15** | **0** | 0.729 | 1 | 0 | 0 | 57 · 32 · 309K |
209
+ | **iter 2, gpt-oss B** | **11/25** | **32/34** | **5/10** | **4/15** | **10/10** | **9/15** | **0** | 0.693 | 1 | 0 | 1 | 56 · 29 · 318K |
210
+
211
+ Final harness = iteration 2. Stated plainly for `gpt-oss:20b` on it:
212
+
213
+ - **BFLA:** the auditor flagged the right route for **10 / 10** BFLA markers in both runs, with no SAFE-BFLA
214
+ route flagged. Only **8 / 10 and 5 / 10** survive as correctly located findings, because the model
215
+ writes wrong or non-existent paths into the finding (`dotnet-mvc/Controllers/AdminController.cs`,
216
+ `routes.js`).
217
+ - **Mass assignment:** **10 / 15 and 9 / 15** at verdict level, **6 / 15 and 4 / 15** as located findings.
218
+ Five candidates in .NET (`CustomerController.cs`) and Java (`AccountController.java`,
219
+ `ProfileServlet.java`) were judged `safe` in both runs: real model misses.
220
+ - **False positives on SAFE markers:** **0** in both iteration-2 runs, for all four categories.
221
+ - **BOLA / injection:** 24 / 25 and 34 / 34 in run A. In run B, 11 of its 14 missed BOLA items sit in
222
+ one batch whose reply was empty (`routes-001`: `gpt-oss` returned no content, so 15 routes got no
223
+ verdict); the other 3 are real misses (`perl-cgi/cgi-bin/order.cgi:19` is missed in both runs).
224
+ - **Run-to-run variance is large.** Two runs with the same model, harness and temperature 0.1 differ by
225
+ up to 13 BOLA items and 3 BFLA findings. Single-run numbers from a local model should not be quoted
226
+ as product capability.
227
+ - **Precision ~0.7** is measured against markers only. Of the ~25 unmatched findings per run, most are BOLA
228
+ findings in files that carry BFLA / mass-assignment markers but no BOLA markers
229
+ (`CustomerController.cs`, `AccountController.java`, `AdminController.cs`), plus the misplaced BFLA /
230
+ mass findings above. They have not been triaged by a person and are not counted as false positives.
231
+
232
+ **qwen3.5:9b** could not be measured on this machine: with a 32K context the MLX runner used 22 GB of the
233
+ 24 GB RAM, the system swapped (29.7 / 30 GB), and two calls hit the 1-hour timeout (the privilege batch
234
+ holding all BFLA routes, and a verifier batch). The run was stopped after 2 h 50 min with 52 findings
235
+ unverified. Its partial output is archived as `iter1-qwen-a-incomplete/` and says nothing about the
236
+ model's ability.
237
+
238
+ ### Known gaps (follow-up: #319)
239
+
240
+ 1. **Misplaced findings.** Take `file` / `line` of BFLA and mass assignment findings from the Tier 1 route
241
+ or candidate the auditor judged (`route_id` / `candidate_id`), not from the model's text. Expected to
242
+ close most of the gap between the marker and verdict columns.
243
+ 2. **Empty replies.** Retry a batch once when the reply is empty or unparseable (seen with `gpt-oss`).
244
+ 3. **Mass assignment on .NET / Java binding.** Review `knowledge/mass-assignment-patterns.md` for
245
+ `UpdateModel` / `TryUpdateModel` and Spring `@ModelAttribute` binding into entities.
246
+ 4. **Re-run qwen3.5:9b** with 16K context or on a machine with more memory.
247
+ 5. **Triage unmatched BOLA findings** and add markers to the fixtures where they are real.
248
+
249
+ ---
250
+
251
+ ## Run 5 — 2026-10-31, kit 0.3.1 (Run 4 follow-up: anchored locations, retries, model context)
252
+
253
+ Issue #319. Same eval repo, ground truth, hosting and model as Run 4 (`gpt-oss:20b`, Ollama 0.40.13,
254
+ M4 Pro 24 GB, 32K context, local only). Outputs are archived in
255
+ [`assess/lsa/examples/eval-run-5/`](../examples/eval-run-5/).
256
+
257
+ ### Changes since Run 4 iteration 2
258
+
259
+ | Change | Why (from Run 4) |
260
+ |---|---|
261
+ | Finding `file` / `line` come from the Tier 1 route handler span or candidate the finding references (`operate/ai/anchoring.py`); evidence is re-pointed or dropped; unknown references get `confidence: low` | The auditor judged the right route for 10 / 10 BFLA markers, but wrote paths such as `routes.js`, `app.py`, `dotnet-mvc/...` |
262
+ | An empty or unparseable batch reply is retried once with a JSON-only reminder | One empty reply cost 15 BOLA routes |
263
+ | Each mass assignment candidate carries the bound model's definition (same app and language); an unknown model means `needs_review`, never `safe` (prompt `35-privilege-auditor.md` and the headless 4c prompt) | Five .NET / Java candidates were judged `safe` in both runs because the auditor never saw `Account.java` (`role`, `balance`) |
264
+
265
+ ### Results after verification
266
+
267
+ | Run | BOLA | Injection | BFLA marker | Mass marker | BFLA verdict | Mass verdict | FP on safe code | Precision | Rejected | Unverified | Retries · parse fail | Min · calls · tokens |
268
+ |---|---|---|---|---|---|---|---|---|---|---|---|---|
269
+ | Run 4 iter 2, A | 24/25 | 34/34 | 8/10 | 6/15 | 10/10 | 10/15 | 0 | 0.729 | 1 | 0 | — · 0 | 57 · 32 · 309K |
270
+ | Run 4 iter 2, B | 11/25 | 32/34 | 5/10 | 4/15 | 10/10 | 9/15 | 0 | 0.693 | 1 | 0 | — · 1 | 56 · 29 · 318K |
271
+ | **Run 5, A** | **24/25** | **34/34** | **10/10** | **10/15** | **10/10** | **15/15** | **0** | **0.818** | 2 | 0 | 0 · 0 | 83 · 32 · 328K |
272
+ | **Run 5, B** | **12/25** | **32/34** | **9/10** | **14/15** | **10/10** | **15/15** | **0** | **0.779** | 3 | 0 | 1 · 1 | 86 · 31 · 369K |
273
+
274
+ - **BFLA:** 10 / 10 and 9 / 10 as located findings (from 8 and 5). Every anchored finding pointed at a
275
+ known route or candidate (0 unanchored).
276
+ - **Mass assignment:** the auditor flagged **15 / 15** candidates in both runs (from 10 and 9), including the
277
+ five .NET / Java bindings. As located findings 10 / 15 and 14 / 15: in run A the model gave a
278
+ `vulnerable` verdict for four candidates (`php-plain/profile.php` ×2, `CustomerController.cs` ×2)
279
+ without writing a finding for them.
280
+ - **`php-plain/register.php:4` (MASS marker)** was rejected by the verifier in both runs: `extract($_POST)`
281
+ is followed by an INSERT of only `email` and `name`. The argument is sound for this fixture as written,
282
+ so the marker should be reviewed rather than counted as a verifier error.
283
+ - **BOLA in run B (12 / 25)** again lost batch `routes-001` (15 routes). This time the reply was not empty
284
+ but invalid JSON: the model put code with unescaped `"` into `recommendation.code_before`; the retry
285
+ failed the same way. The same batch failed in Run 4 run B.
286
+ - **No false positives** on SAFE markers in either run; precision rose to 0.82 / 0.78.
287
+ - Runs are ~45% slower than Run 4 iteration 2 (83–86 min): the privilege and verifier prompts are longer
288
+ and there are more findings to verify.
289
+
290
+ ### Unmatched BOLA findings (triage, not yet reflected in fixtures)
291
+
292
+ About 20 BOLA findings per run sit in files with no BOLA marker. By route:
293
+
294
+ | Route(s) | Assessment |
295
+ |---|---|
296
+ | `Customer/EditName`, `account/rename`, `python-flask` `/users/<uid>/name`, `account/profile` | Likely **real BOLA** not marked: the record ID comes from the request and is not bound to the caller |
297
+ | `Customer/Edit`, `Customer/Refresh`, `account/save`, `account/api`, `/users/<uid>` | Mass-assignment fixtures that also take the ID from the request: plausibly BOLA as well |
298
+ | `Admin/DeleteUser`, `Admin/Export`, `admin/users/{id}/delete`, `delete_user.php`, `promote.php`, `manage_users.php` | **BFLA routes also reported as BOLA**: same flaw seen from a second category; duplicate rather than wrong |
299
+
300
+ Fixture markers were left unchanged so Run 4 and Run 5 share the same ground truth.
301
+
302
+ ### Remaining gaps
303
+
304
+ 1. **Invalid JSON from code fields:** drop `code_before` / `code_after` from the 4a / 4b output templates
305
+ (the sanitizer already reduces `recommendation` to a string), or repair unescaped quotes before parsing.
306
+ 2. **Verdict without finding:** write a minimal finding for every `vulnerable` / `needs_review` verdict
307
+ the model did not turn into one.
308
+ 3. **Fixtures:** review the `register.php` MASS marker; add BOLA markers for the routes judged real above.
309
+ 4. **qwen3.5:9b** still unmeasured: 32K context does not fit in 24 GB next to the OS; needs 16K context or
310
+ a larger machine.
@@ -7,13 +7,20 @@ A vulnerable truth item is found (TP) when an active finding (status != rejected
7
7
  same category (sql_injection and nosql_injection are treated as one group), and the truth
8
8
  line is within --tolerance lines of the finding location or of any evidence line in the same file.
9
9
  A safe truth item hit by an active finding counts as a false positive (FP-safe).
10
+
11
+ --verdicts adds a verdict-level score for bfla and mass_assignment: a truth item counts as found when
12
+ the privilege auditor judged the route or candidate that contains it "vulnerable" or "needs_review",
13
+ whatever file and line the model then wrote into the finding. Routes cover their handler line up to
14
+ the next inventoried route in the same file; mass assignment candidates match their exact line.
10
15
  """
11
16
  from __future__ import annotations
12
17
 
13
18
  import argparse
19
+ import glob
14
20
  import json
15
21
  import sys
16
22
  from pathlib import Path
23
+ from typing import Dict, List, Optional, Tuple
17
24
 
18
25
 
19
26
  def group(cat: str) -> str:
@@ -32,11 +39,60 @@ def hits(finding: dict, item: dict, tol: int) -> bool:
32
39
  return False
33
40
 
34
41
 
42
+ FLAGGED = ("vulnerable", "needs_review")
43
+
44
+
45
+ def _same_file(a: str, b: str) -> bool:
46
+ return bool(a) and bool(b) and (a.endswith(b) or b.endswith(a))
47
+
48
+
49
+ def _route_for(item: dict, routes: List[Tuple[str, int, str]]) -> Optional[str]:
50
+ """route_id whose handler span (handler line .. next route in the file) contains the truth line."""
51
+ in_file = sorted((line, rid) for f, line, rid in routes if _same_file(f, item["file"]) and line <= item["line"])
52
+ return in_file[-1][1] if in_file else None
53
+
54
+
55
+ def verdict_scores(out: Path, truth: List[dict]) -> Dict[str, dict]:
56
+ routes: List[Tuple[str, int, str]] = []
57
+ for p in glob.glob(str(out / "routes" / "*.json")):
58
+ for r in json.loads(Path(p).read_text()).get("routes", []):
59
+ h = r.get("handler") or {}
60
+ if h.get("file") and isinstance(h.get("line"), int):
61
+ routes.append((h["file"], h["line"], r.get("route_id")))
62
+ cands_path = out / "mass-assignment.candidates.json"
63
+ cands = json.loads(cands_path.read_text()).get("candidates", []) if cands_path.is_file() else []
64
+ route_v: Dict[str, str] = {}
65
+ cand_v: Dict[str, str] = {}
66
+ for p in glob.glob(str(out / "findings" / "priv-*.json")):
67
+ doc = json.loads(Path(p).read_text())
68
+ route_v.update({v.get("route_id"): v.get("verdict") for v in doc.get("privilege_route_verdicts", []) if isinstance(v, dict)})
69
+ cand_v.update({v.get("candidate_id"): v.get("verdict") for v in doc.get("candidate_verdicts", []) if isinstance(v, dict)})
70
+
71
+ def flagged(item: dict) -> bool:
72
+ if item["category"] == "bfla":
73
+ return route_v.get(_route_for(item, routes)) in FLAGGED
74
+ return any(_same_file(c.get("file", ""), item["file"]) and c.get("line") == item["line"]
75
+ and cand_v.get(c.get("candidate_id")) in FLAGGED for c in cands)
76
+
77
+ rows = {}
78
+ for cat in ("bfla", "mass_assignment"):
79
+ vul = [t for t in truth if t["category"] == cat and t["vulnerable"]]
80
+ safe = [t for t in truth if t["category"] == cat and not t["vulnerable"]]
81
+ if not vul:
82
+ continue
83
+ found = [t for t in vul if flagged(t)]
84
+ rows[cat] = {"truth_vulnerable": len(vul), "found": len(found), "recall": round(len(found) / len(vul), 3),
85
+ "flagged_safe": [f"{t['file']}:{t['line']}" for t in safe if flagged(t)],
86
+ "missed": [f"{t['file']}:{t['line']}" for t in vul if t not in found]}
87
+ return rows
88
+
89
+
35
90
  def main() -> int:
36
91
  ap = argparse.ArgumentParser(description=__doc__)
37
92
  ap.add_argument("ground_truth")
38
93
  ap.add_argument("out")
39
94
  ap.add_argument("--tolerance", type=int, default=3)
95
+ ap.add_argument("--verdicts", action="store_true", help="also score bfla / mass_assignment by auditor verdicts")
40
96
  a = ap.parse_args()
41
97
  truth = json.loads(Path(a.ground_truth).read_text())["items"]
42
98
  # fixture paths are relative to tests/fixtures; eval repo keeps the same layout
@@ -69,6 +125,8 @@ def main() -> int:
69
125
  report = {"per_category": rows, "active_findings": len(g_findings), "matched_findings": len(matched_findings),
70
126
  "precision": precision, "unmatched_findings": unmatched,
71
127
  "needs_review_not_scored": needs_review}
128
+ if a.verdicts:
129
+ report["verdict_level"] = verdict_scores(Path(a.out), truth)
72
130
  print(json.dumps(report, indent=2))
73
131
  return 0
74
132
 
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env python3
2
+ """Keeps the archived Evaluation Run 4 numbers (Issue #286) in step with the scorer and EVAL-RESULTS.md."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ import unittest
10
+ from pathlib import Path
11
+
12
+ TESTS_DIR = Path(__file__).resolve().parent
13
+ LSA_ROOT = TESTS_DIR.parent
14
+ EVAL_RUN_4_DIR = LSA_ROOT / "examples" / "eval-run-4"
15
+ GROUND_TRUTH = EVAL_RUN_4_DIR / "ground-truth.json"
16
+ RUNS = ("iter0-gptoss-a", "iter1-gptoss-a", "iter1-gptoss-b", "iter1-qwen-a-incomplete", "iter2-gptoss-a", "iter2-gptoss-b")
17
+ # (BOLA, injection, BFLA, mass assignment) found by location, then BFLA / mass found by auditor verdict
18
+ EXPECTED = {
19
+ "iter2-gptoss-a": {"marker": (24, 34, 8, 6), "verdict": (10, 10)},
20
+ "iter2-gptoss-b": {"marker": (11, 32, 5, 4), "verdict": (10, 9)},
21
+ }
22
+
23
+
24
+ def score(run: str) -> dict:
25
+ proc = subprocess.run(
26
+ [sys.executable, str(TESTS_DIR / "score_findings.py"), "--verdicts", str(GROUND_TRUTH), str(EVAL_RUN_4_DIR / run)],
27
+ capture_output=True, text=True, check=True,
28
+ )
29
+ return json.loads(proc.stdout)
30
+
31
+
32
+ class TestEvaluationRun4(unittest.TestCase):
33
+ def test_artifacts_exist(self) -> None:
34
+ self.assertTrue(GROUND_TRUTH.is_file())
35
+ for run in RUNS:
36
+ for name in ("findings.json", "coverage.json", "score.json"):
37
+ self.assertTrue((EVAL_RUN_4_DIR / run / name).is_file(), f"{run}/{name}")
38
+
39
+ def test_archived_scores_match_the_scorer(self) -> None:
40
+ for run in RUNS:
41
+ with self.subTest(run=run):
42
+ archived = json.loads((EVAL_RUN_4_DIR / run / "score.json").read_text(encoding="utf-8"))
43
+ self.assertEqual(score(run), archived)
44
+
45
+ def test_final_harness_numbers_match_eval_results(self) -> None:
46
+ for run, want in EXPECTED.items():
47
+ with self.subTest(run=run):
48
+ report = score(run)
49
+ pc, vl = report["per_category"], report["verdict_level"]
50
+ got = tuple(pc[c]["found"] for c in ("bola", "injection", "bfla", "mass_assignment"))
51
+ self.assertEqual(got, want["marker"])
52
+ self.assertEqual((vl["bfla"]["found"], vl["mass_assignment"]["found"]), want["verdict"])
53
+
54
+ def test_final_harness_has_no_false_positive_on_safe_code(self) -> None:
55
+ for run in EXPECTED:
56
+ with self.subTest(run=run):
57
+ report = score(run)
58
+ for cat, row in report["per_category"].items():
59
+ self.assertEqual(row["false_positive_on_safe_code"], [], cat)
60
+ for cat, row in report["verdict_level"].items():
61
+ self.assertEqual(row["flagged_safe"], [], cat)
62
+
63
+ def test_final_harness_records_run_stats(self) -> None:
64
+ for run in EXPECTED:
65
+ with self.subTest(run=run):
66
+ stats = json.loads((EVAL_RUN_4_DIR / run / "ai-run-stats.json").read_text(encoding="utf-8"))
67
+ self.assertEqual(stats["model"], "gpt-oss:20b")
68
+ self.assertGreater(stats["totals"]["prompt_tokens"], 0)
69
+ self.assertEqual(stats["call_errors"], {})
70
+
71
+
72
+ if __name__ == "__main__":
73
+ unittest.main()
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env python3
2
+ """Keeps the archived Evaluation Run 5 numbers (Issue #319) in step with the scorer and EVAL-RESULTS.md."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ import unittest
10
+ from pathlib import Path
11
+
12
+ TESTS_DIR = Path(__file__).resolve().parent
13
+ LSA_ROOT = TESTS_DIR.parent
14
+ EVAL_RUN_5_DIR = LSA_ROOT / "examples" / "eval-run-5"
15
+ GROUND_TRUTH = EVAL_RUN_5_DIR / "ground-truth.json"
16
+ RUNS = ("gptoss-a", "gptoss-b")
17
+ # (BOLA, injection, BFLA, mass assignment) found by location, then BFLA / mass found by auditor verdict
18
+ EXPECTED = {
19
+ "gptoss-a": {"marker": (24, 34, 10, 10), "verdict": (10, 15)},
20
+ "gptoss-b": {"marker": (12, 32, 9, 14), "verdict": (10, 15)},
21
+ }
22
+
23
+ def score(run: str) -> dict:
24
+ proc = subprocess.run(
25
+ [sys.executable, str(TESTS_DIR / "score_findings.py"), "--verdicts", str(GROUND_TRUTH), str(EVAL_RUN_5_DIR / run)],
26
+ capture_output=True, text=True, check=True,
27
+ )
28
+ return json.loads(proc.stdout)
29
+
30
+
31
+ class TestEvaluationRun5(unittest.TestCase):
32
+ def test_artifacts_exist(self) -> None:
33
+ self.assertTrue(GROUND_TRUTH.is_file())
34
+ for run in RUNS:
35
+ for name in ("findings.json", "coverage.json", "score.json"):
36
+ self.assertTrue((EVAL_RUN_5_DIR / run / name).is_file(), f"{run}/{name}")
37
+
38
+ def test_archived_scores_match_the_scorer(self) -> None:
39
+ for run in RUNS:
40
+ with self.subTest(run=run):
41
+ archived = json.loads((EVAL_RUN_5_DIR / run / "score.json").read_text(encoding="utf-8"))
42
+ self.assertEqual(score(run), archived)
43
+
44
+ def test_final_harness_numbers_match_eval_results(self) -> None:
45
+ for run, want in EXPECTED.items():
46
+ with self.subTest(run=run):
47
+ report = score(run)
48
+ pc, vl = report["per_category"], report["verdict_level"]
49
+ got = tuple(pc[c]["found"] for c in ("bola", "injection", "bfla", "mass_assignment"))
50
+ self.assertEqual(got, want["marker"])
51
+ self.assertEqual((vl["bfla"]["found"], vl["mass_assignment"]["found"]), want["verdict"])
52
+
53
+ def test_final_harness_has_no_false_positive_on_safe_code(self) -> None:
54
+ for run in EXPECTED:
55
+ with self.subTest(run=run):
56
+ report = score(run)
57
+ for cat, row in report["per_category"].items():
58
+ self.assertEqual(row["false_positive_on_safe_code"], [], cat)
59
+ for cat, row in report["verdict_level"].items():
60
+ self.assertEqual(row["flagged_safe"], [], cat)
61
+
62
+ def test_final_harness_records_run_stats(self) -> None:
63
+ for run in EXPECTED:
64
+ with self.subTest(run=run):
65
+ stats = json.loads((EVAL_RUN_5_DIR / run / "ai-run-stats.json").read_text(encoding="utf-8"))
66
+ self.assertEqual(stats["model"], "gpt-oss:20b")
67
+ self.assertGreater(stats["totals"]["prompt_tokens"], 0)
68
+ self.assertEqual(stats["call_errors"], {})
69
+ self.assertEqual(stats["anchoring"]["unanchored"], [])
70
+
71
+
72
+ if __name__ == "__main__":
73
+ unittest.main()
@@ -242,6 +242,36 @@ class TestScoring(unittest.TestCase):
242
242
  self.assertEqual(report["per_category"]["mass_assignment"]["recall"], 0.0)
243
243
  self.assertNotIn("bola", report["per_category"])
244
244
 
245
+ def test_verdict_level_ignores_misplaced_findings(self) -> None:
246
+ """--verdicts credits the auditor's route / candidate verdict even when the finding has a wrong path (#286)."""
247
+ with tempfile.TemporaryDirectory() as tmp:
248
+ truth = Path(tmp) / "gt.json"
249
+ write_json(truth, {"items": [
250
+ {"file": "admin.js", "line": 9, "category": "bfla", "vulnerable": True, "label": "BFLA"},
251
+ {"file": "admin.js", "line": 13, "category": "bfla", "vulnerable": False, "label": "SAFE-BFLA"},
252
+ {"file": "p.php", "line": 6, "category": "mass_assignment", "vulnerable": True, "label": "MASS"},
253
+ {"file": "p.php", "line": 9, "category": "mass_assignment", "vulnerable": False, "label": "SAFE-MASS"},
254
+ ]})
255
+ out = Path(tmp) / "out"
256
+ write_json(out / "findings.json", {"findings": [
257
+ {"finding_id": "BFLA-R-1-1", "category": "bfla", "status": "confirmed",
258
+ "location": {"file": "routes.js", "start_line": 1}, "evidence": []}]})
259
+ write_json(out / "routes" / "routes-001.json", {"routes": [
260
+ {"route_id": "R-1", "handler": {"file": "admin.js", "line": 8}},
261
+ {"route_id": "R-2", "handler": {"file": "admin.js", "line": 12}}]})
262
+ write_json(out / "mass-assignment.candidates.json", {"candidates": [
263
+ {"candidate_id": "M-1", "file": "p.php", "line": 6}]})
264
+ write_json(out / "findings" / "priv-privilege-001.json", {
265
+ "privilege_route_verdicts": [{"route_id": "R-1", "verdict": "vulnerable"}],
266
+ "candidate_verdicts": [{"candidate_id": "M-1", "verdict": "needs_review"}], "findings": []})
267
+ res = subprocess.run([sys.executable, str(SCORE), "--verdicts", str(truth), str(out)], capture_output=True, text=True)
268
+ report = json.loads(res.stdout)
269
+ self.assertEqual(report["per_category"]["bfla"]["found"], 0)
270
+ self.assertEqual(report["verdict_level"]["bfla"], {"truth_vulnerable": 1, "found": 1, "recall": 1.0,
271
+ "flagged_safe": [], "missed": []})
272
+ self.assertEqual(report["verdict_level"]["mass_assignment"]["found"], 1)
273
+ self.assertEqual(report["verdict_level"]["mass_assignment"]["flagged_safe"], [])
274
+
245
275
 
246
276
  if __name__ == "__main__":
247
277
  unittest.main()
@@ -0,0 +1,130 @@
1
+ """Anchor model-written finding locations to the Tier 1 routes and candidates they refer to (#319).
2
+
3
+ Small local models judge the right route or candidate but often write a wrong or non-existent path
4
+ into the finding (Evaluation Run 4: `routes.js`, `app.py`, `dotnet-mvc/...`). Tier 1 already knows
5
+ where every route handler and query / binding candidate is, so the location comes from there.
6
+
7
+ Rules:
8
+ - route findings (`route_id`): the file is the route handler's file; the model's line is kept when
9
+ it falls inside the handler span (handler line up to the next route in the same file, or the end
10
+ of the file), otherwise the handler line is used.
11
+ - candidate findings (`candidate_id`): file and line are the candidate's.
12
+ - evidence entries are re-pointed the same way; entries whose file does not exist and cannot be
13
+ re-pointed are dropped.
14
+ - findings with an unknown `route_id` / `candidate_id` keep their location, get `confidence: low`
15
+ and are reported as unanchored.
16
+
17
+ Standard-library only (Python 3.8+ compatible).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Any, Dict, List, Optional, Tuple
26
+
27
+ CANDIDATE_FILES = ("injection.candidates.json", "mass-assignment.candidates.json")
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Anchor:
32
+ file: str
33
+ line: int
34
+ end_line: Optional[int] # last line of the handler span; None for an exact candidate site
35
+
36
+
37
+ @dataclass
38
+ class AnchorReport:
39
+ moved: List[str] = field(default_factory=list)
40
+ unanchored: List[str] = field(default_factory=list)
41
+
42
+
43
+ def _read(path: Path) -> Dict[str, Any]:
44
+ try:
45
+ doc = json.loads(path.read_text(encoding="utf-8"))
46
+ return doc if isinstance(doc, dict) else {}
47
+ except (OSError, ValueError):
48
+ return {}
49
+
50
+
51
+ def _line_count(target_dir: Path, rel_file: str) -> int:
52
+ try:
53
+ return len((target_dir / rel_file).read_text(encoding="utf-8", errors="replace").splitlines())
54
+ except OSError:
55
+ return 0
56
+
57
+
58
+ def load_anchors(out_dir: Path, target_dir: Path) -> Dict[str, Anchor]:
59
+ """route_id / candidate_id -> Anchor, from OUT/routes/*.json and the Tier 1 candidate files."""
60
+ handlers: List[Tuple[str, int, str]] = []
61
+ for path in sorted((out_dir / "routes").glob("*.json")):
62
+ for route in _read(path).get("routes") or []:
63
+ if not isinstance(route, dict):
64
+ continue
65
+ handler = route.get("handler") or {}
66
+ if route.get("route_id") and handler.get("file") and isinstance(handler.get("line"), int):
67
+ handlers.append((handler["file"], handler["line"], route["route_id"]))
68
+
69
+ anchors: Dict[str, Anchor] = {}
70
+ for rel_file, line, route_id in handlers:
71
+ later = sorted(ln for f, ln, _ in handlers if f == rel_file and ln > line)
72
+ end = later[0] - 1 if later else max(_line_count(target_dir, rel_file), line)
73
+ anchors[route_id] = Anchor(rel_file, line, end)
74
+
75
+ for name in CANDIDATE_FILES:
76
+ for cand in _read(out_dir / name).get("candidates") or []:
77
+ if isinstance(cand, dict) and cand.get("candidate_id") and cand.get("file") and isinstance(cand.get("line"), int):
78
+ anchors[cand["candidate_id"]] = Anchor(cand["file"], cand["line"], None)
79
+ return anchors
80
+
81
+
82
+ def _anchored_line(anchor: Anchor, line: Any) -> int:
83
+ if anchor.end_line is None or not isinstance(line, int):
84
+ return anchor.line
85
+ return line if anchor.line <= line <= anchor.end_line else anchor.line
86
+
87
+
88
+ def _repoint_evidence(evidence: Any, anchor: Anchor, target_dir: Path) -> List[Dict[str, Any]]:
89
+ kept = []
90
+ for ev in evidence if isinstance(evidence, list) else []:
91
+ if not isinstance(ev, dict):
92
+ continue
93
+ rel = str(ev.get("file") or "")
94
+ if rel == anchor.file or (rel and (target_dir / rel).is_file()):
95
+ kept.append(ev)
96
+ elif anchor.end_line is not None and isinstance(ev.get("line"), int) and anchor.line <= ev["line"] <= anchor.end_line:
97
+ kept.append({**ev, "file": anchor.file})
98
+ return kept or [{"file": anchor.file, "line": anchor.line, "why": "Location taken from the Tier 1 inventory"}]
99
+
100
+
101
+ def anchor_finding(finding: Dict[str, Any], anchors: Dict[str, Anchor], target_dir: Path) -> Tuple[Dict[str, Any], Optional[bool]]:
102
+ """Return (finding with Tier 1 location, moved?); moved is None when the finding has no known anchor."""
103
+ key = finding.get("route_id") or finding.get("candidate_id")
104
+ anchor = anchors.get(key) if key else None
105
+ if anchor is None:
106
+ return {**finding, "confidence": "low"}, None
107
+ loc = finding.get("location") if isinstance(finding.get("location"), dict) else {}
108
+ # a wrong path with a line inside the handler span is most likely the right line in the wrong file
109
+ new_loc = {**loc, "file": anchor.file, "start_line": _anchored_line(anchor, loc.get("start_line"))}
110
+ if anchor.end_line is None:
111
+ new_loc.pop("end_line", None)
112
+ moved = (loc.get("file"), loc.get("start_line")) != (new_loc["file"], new_loc["start_line"])
113
+ evidence = _repoint_evidence(finding.get("evidence"), anchor, target_dir)
114
+ return {**finding, "location": new_loc, "evidence": evidence}, moved
115
+
116
+
117
+ def anchor_findings(findings: List[Dict[str, Any]], anchors: Dict[str, Anchor], target_dir: Path,
118
+ report: AnchorReport) -> List[Dict[str, Any]]:
119
+ out = []
120
+ for f in findings:
121
+ if not isinstance(f, dict):
122
+ out.append(f)
123
+ continue
124
+ anchored, moved = anchor_finding(f, anchors, target_dir)
125
+ if moved is None:
126
+ report.unanchored.append(str(f.get("finding_id")))
127
+ elif moved:
128
+ report.moved.append(str(f.get("finding_id")))
129
+ out.append(anchored)
130
+ return out
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import json
9
9
  import re
10
+ import time
10
11
  import urllib.error
11
12
  import urllib.request
12
13
  from typing import Any, Dict, List, Optional
@@ -30,6 +31,8 @@ class LLMClient:
30
31
  self.model = model
31
32
  self.api_key = api_key or "ollama"
32
33
  self.timeout = timeout
34
+ # Running totals across chat() calls, read by the runner for ai-run-stats.json
35
+ self.usage: Dict[str, float] = {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "seconds": 0.0}
33
36
 
34
37
  def chat(
35
38
  self,
@@ -54,10 +57,12 @@ class LLMClient:
54
57
  }
55
58
 
56
59
  req = urllib.request.Request(self.endpoint, data=data, headers=headers, method="POST")
60
+ started = time.monotonic()
57
61
  try:
58
62
  with urllib.request.urlopen(req, timeout=self.timeout) as resp:
59
63
  body = resp.read().decode("utf-8")
60
64
  res_json = json.loads(body)
65
+ self._record_usage(res_json.get("usage") or {}, time.monotonic() - started)
61
66
  choices = res_json.get("choices", [])
62
67
  if not choices:
63
68
  raise RuntimeError(f"LLM API returned no choices: {body}")
@@ -69,6 +74,14 @@ class LLMClient:
69
74
  except urllib.error.URLError as e:
70
75
  raise RuntimeError(f"Failed to connect to LLM endpoint at {self.endpoint}: {e.reason}") from e
71
76
 
77
+ def _record_usage(self, usage: Dict[str, Any], seconds: float) -> None:
78
+ self.usage = {
79
+ "calls": self.usage["calls"] + 1,
80
+ "prompt_tokens": self.usage["prompt_tokens"] + int(usage.get("prompt_tokens") or 0),
81
+ "completion_tokens": self.usage["completion_tokens"] + int(usage.get("completion_tokens") or 0),
82
+ "seconds": round(self.usage["seconds"] + seconds, 3),
83
+ }
84
+
72
85
  @staticmethod
73
86
  def extract_json(content: str) -> Any:
74
87
  """Extract and parse JSON from Markdown code blocks or raw text string."""