loki-mode 7.26.0 → 7.27.0

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.
@@ -0,0 +1,1075 @@
1
+ #!/usr/bin/env bash
2
+ # autonomy/verify.sh - Autonomi Verify (loki verify) deterministic verification core.
3
+ #
4
+ # Verification-as-a-Service MVP, 30-day cut per
5
+ # internal/VERIFICATION-BOT-SPEC-2026-06.md Section 6.
6
+ #
7
+ # This is a STANDALONE module. It deliberately does NOT source run.sh or
8
+ # completion-council.sh: those functions are welded to the autonomous
9
+ # iteration loop (TARGET_DIR / ITERATION_COUNT globals, scattered
10
+ # .loki/quality/* outputs, and the wrong diff base -- HEAD~1 / --cached /
11
+ # run-start SHA). This module FAITHFULLY PORTS the deterministic detection
12
+ # patterns (same runner-detection order, same globs) but re-bases the diff
13
+ # onto PR semantics and consolidates output into one evidence document.
14
+ #
15
+ # Entanglement refactors implemented here (spec Section 2.3):
16
+ # 1. Diff base: verify_diff_base() resolves merge-base(base, HEAD)..HEAD,
17
+ # NOT HEAD~1 (run.sh:6305) or run-start SHA (completion-council.sh:1203).
18
+ # 2. Inverted verdict policy: inconclusive maps to CONCERNS, never VERIFIED.
19
+ # The build loop's council_evidence_gate is pass-through-on-inconclusive
20
+ # by design (completion-council.sh:1216-1221); a verifier wants the
21
+ # opposite.
22
+ # 3. Consolidated output: one evidence.json + one report.md, not the
23
+ # scattered .loki/quality/*.json / .loki/council/*.json artifacts.
24
+ #
25
+ # Reuse map (faithful ports, source cited):
26
+ # - test runner detection: enforce_test_coverage (run.sh:6624)
27
+ # - static analysis detection: enforce_static_analysis (run.sh:6299)
28
+ # - diff+test evidence mechanic: council_evidence_gate
29
+ # (completion-council.sh:1189) -- mechanic reused, policy inverted.
30
+ #
31
+ # NO LLM review in this MVP (deterministic-only first slice). The spec
32
+ # sequences the single-reviewer LLM stage and the blind council later
33
+ # (spec Section 6). This is stated honestly in the help text and the
34
+ # evidence document (llm_review.status = "skipped").
35
+ #
36
+ # Exit codes (per BUILD TASK; see NOTE below):
37
+ # 0 VERIFIED
38
+ # 1 CONCERNS
39
+ # 2 BLOCKED
40
+ # 3 verifier error (could not complete; never silently passes)
41
+ #
42
+ # NOTE on exit-code divergence from the spec: spec Section 1.1 lists
43
+ # 0=VERIFIED, 1=BLOCKED, 2=CONCERNS, 3=error (BLOCKED and CONCERNS swapped
44
+ # vs this implementation). This module follows the explicit BUILD TASK
45
+ # ordering (0/1/2 = VERIFIED/CONCERNS/BLOCKED). A human must reconcile the
46
+ # two before the GitHub App (Phase 2) consumes exit codes. The divergence is
47
+ # surfaced in `loki verify --help`.
48
+
49
+ set -uo pipefail
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Constants
53
+ # ---------------------------------------------------------------------------
54
+ VERIFY_EXIT_VERIFIED=0
55
+ VERIFY_EXIT_CONCERNS=1
56
+ VERIFY_EXIT_BLOCKED=2
57
+ VERIFY_EXIT_ERROR=3
58
+ VERIFY_SCHEMA_VERSION="1.0"
59
+
60
+ # Resolve tool version from the VERSION file shipped alongside the repo.
61
+ _verify_tool_version() {
62
+ local here
63
+ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
64
+ if [ -f "$here/../VERSION" ]; then
65
+ tr -d '[:space:]' <"$here/../VERSION"
66
+ else
67
+ echo "unknown"
68
+ fi
69
+ }
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Logging (stderr; stdout is reserved for the human verdict line)
73
+ # ---------------------------------------------------------------------------
74
+ _verify_log() { printf '[verify] %s\n' "$*" >&2; }
75
+ _verify_err() { printf '[verify][error] %s\n' "$*" >&2; }
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Findings accumulator
79
+ #
80
+ # Each finding is one TAB-separated record appended to a temp file:
81
+ # severity \t category \t source \t file \t line \t message
82
+ # Severity is one of: Critical High Medium Low Info
83
+ # ---------------------------------------------------------------------------
84
+ _VERIFY_FINDINGS_FILE=""
85
+ _VERIFY_GATES_FILE=""
86
+
87
+ _verify_add_finding() {
88
+ # $1 severity $2 category $3 source $4 file $5 line $6 message
89
+ printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
90
+ "$1" "$2" "$3" "$4" "${5:-null}" "$6" >>"$_VERIFY_FINDINGS_FILE"
91
+ }
92
+
93
+ _verify_add_gate() {
94
+ # $1 gate $2 status(pass|fail|inconclusive|skipped) $3 runner/scanner $4 summary $5 reproducible(true|false)
95
+ printf '%s\t%s\t%s\t%s\t%s\n' \
96
+ "$1" "$2" "${3:-}" "${4:-}" "${5:-true}" >>"$_VERIFY_GATES_FILE"
97
+ }
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Entanglement 1: PR-aware diff base.
101
+ #
102
+ # Resolves merge-base(base, HEAD)..HEAD. This is the WHOLE change set vs the
103
+ # target branch, i.e. PR semantics. Replaces the build loop's HEAD~1 /
104
+ # --cached / run-start-SHA bases.
105
+ #
106
+ # Sets globals: VERIFY_BASE_REF VERIFY_HEAD_SHA VERIFY_MERGE_BASE
107
+ # VERIFY_DIFF_FILES VERIFY_DIFF_INS VERIFY_DIFF_DEL
108
+ # VERIFY_DIFF_NAMES (newline list) VERIFY_DIFF_RESOLVED(true|false)
109
+ # ---------------------------------------------------------------------------
110
+ verify_diff_base() {
111
+ local base_ref="$1"
112
+
113
+ VERIFY_DIFF_RESOLVED="false"
114
+ VERIFY_BASE_REF="$base_ref"
115
+ VERIFY_HEAD_SHA=""
116
+ VERIFY_MERGE_BASE=""
117
+ VERIFY_DIFF_FILES=0
118
+ VERIFY_DIFF_INS=0
119
+ VERIFY_DIFF_DEL=0
120
+ VERIFY_DIFF_NAMES=""
121
+
122
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
123
+ _verify_log "not a git repository; diff base unresolved (inconclusive)"
124
+ return 0
125
+ fi
126
+
127
+ VERIFY_HEAD_SHA="$(git rev-parse HEAD 2>/dev/null || echo "")"
128
+ if [ -z "$VERIFY_HEAD_SHA" ]; then
129
+ _verify_log "no HEAD commit; diff base unresolved (inconclusive)"
130
+ return 0
131
+ fi
132
+
133
+ # Resolve the base ref. Prefer origin/<base>, fall back to local <base>.
134
+ local resolved_base=""
135
+ if git rev-parse --verify --quiet "origin/$base_ref" >/dev/null 2>&1; then
136
+ resolved_base="origin/$base_ref"
137
+ elif git rev-parse --verify --quiet "$base_ref" >/dev/null 2>&1; then
138
+ resolved_base="$base_ref"
139
+ else
140
+ _verify_log "base ref '$base_ref' not found (tried origin/$base_ref and $base_ref); diff base unresolved (inconclusive)"
141
+ return 0
142
+ fi
143
+ VERIFY_BASE_REF="$resolved_base"
144
+
145
+ local mb
146
+ mb="$(git merge-base "$resolved_base" HEAD 2>/dev/null || echo "")"
147
+ if [ -z "$mb" ]; then
148
+ _verify_log "no merge-base between $resolved_base and HEAD; diff base unresolved (inconclusive)"
149
+ return 0
150
+ fi
151
+ VERIFY_MERGE_BASE="$mb"
152
+
153
+ # Diff stats for merge-base..HEAD.
154
+ VERIFY_DIFF_NAMES="$(git diff --name-only "$mb" HEAD 2>/dev/null || echo "")"
155
+ if [ -n "$VERIFY_DIFF_NAMES" ]; then
156
+ VERIFY_DIFF_FILES="$(printf '%s\n' "$VERIFY_DIFF_NAMES" | grep -c . || echo 0)"
157
+ else
158
+ VERIFY_DIFF_FILES=0
159
+ fi
160
+
161
+ local numstat
162
+ numstat="$(git diff --numstat "$mb" HEAD 2>/dev/null || echo "")"
163
+ if [ -n "$numstat" ]; then
164
+ VERIFY_DIFF_INS="$(printf '%s\n' "$numstat" | awk '$1 ~ /^[0-9]+$/ {s+=$1} END {print s+0}')"
165
+ VERIFY_DIFF_DEL="$(printf '%s\n' "$numstat" | awk '$2 ~ /^[0-9]+$/ {s+=$2} END {print s+0}')"
166
+ fi
167
+
168
+ VERIFY_DIFF_RESOLVED="true"
169
+ _verify_log "diff base: $resolved_base..HEAD (merge-base $mb) -> $VERIFY_DIFF_FILES files, +$VERIFY_DIFF_INS/-$VERIFY_DIFF_DEL"
170
+ return 0
171
+ }
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Gate: build (faithful extension of run.sh language detection).
175
+ #
176
+ # A build is run only when a build command is DETECTABLE. If none is
177
+ # detectable, the gate is `skipped` (not-applicable), which does NOT degrade
178
+ # the verdict. A detected build that fails emits a Critical finding.
179
+ # ---------------------------------------------------------------------------
180
+ verify_gate_build() {
181
+ local tree="$1"
182
+ local timeout_s="${LOKI_GATE_TIMEOUT:-300}"
183
+
184
+ # package.json with a "build" script.
185
+ if [ -f "$tree/package.json" ] && grep -q '"build"[[:space:]]*:' "$tree/package.json" 2>/dev/null; then
186
+ local out rc=0
187
+ out="$(cd "$tree" && timeout "$timeout_s" npm run build 2>&1)" || rc=$?
188
+ if [ "$rc" -eq 0 ]; then
189
+ _verify_add_gate "build" "pass" "npm" "npm run build succeeded" "true"
190
+ else
191
+ _verify_add_gate "build" "fail" "npm" "npm run build failed (rc=$rc)" "true"
192
+ _verify_add_finding "Critical" "build" "deterministic:npm-build" "package.json" "null" \
193
+ "Build failed: npm run build exited $rc. $(printf '%s' "$out" | tail -2 | tr '\n' ' ')"
194
+ fi
195
+ return 0
196
+ fi
197
+
198
+ # Go.
199
+ if [ -f "$tree/go.mod" ] && command -v go >/dev/null 2>&1; then
200
+ local out rc=0
201
+ out="$(cd "$tree" && timeout "$timeout_s" go build ./... 2>&1)" || rc=$?
202
+ if [ "$rc" -eq 0 ]; then
203
+ _verify_add_gate "build" "pass" "go" "go build ./... succeeded" "true"
204
+ else
205
+ _verify_add_gate "build" "fail" "go" "go build ./... failed (rc=$rc)" "true"
206
+ _verify_add_finding "Critical" "build" "deterministic:go-build" "go.mod" "null" \
207
+ "Build failed: go build exited $rc. $(printf '%s' "$out" | tail -2 | tr '\n' ' ')"
208
+ fi
209
+ return 0
210
+ fi
211
+
212
+ # Rust.
213
+ if [ -f "$tree/Cargo.toml" ] && command -v cargo >/dev/null 2>&1; then
214
+ local out rc=0
215
+ out="$(cd "$tree" && timeout "$timeout_s" cargo build 2>&1)" || rc=$?
216
+ if [ "$rc" -eq 0 ]; then
217
+ _verify_add_gate "build" "pass" "cargo" "cargo build succeeded" "true"
218
+ else
219
+ _verify_add_gate "build" "fail" "cargo" "cargo build failed (rc=$rc)" "true"
220
+ _verify_add_finding "Critical" "build" "deterministic:cargo-build" "Cargo.toml" "null" \
221
+ "Build failed: cargo build exited $rc. $(printf '%s' "$out" | tail -2 | tr '\n' ' ')"
222
+ fi
223
+ return 0
224
+ fi
225
+
226
+ _verify_add_gate "build" "skipped" "" "no detectable build command" "true"
227
+ return 0
228
+ }
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Gate: tests (faithful port of enforce_test_coverage detection, run.sh:6624).
232
+ #
233
+ # Detection order mirrors the source: vitest -> jest -> mocha (package.json),
234
+ # then python (pytest), then go test, then cargo test.
235
+ #
236
+ # skipped = no test framework detected at all (not-applicable).
237
+ # inconclusive = a project is present but no runnable framework
238
+ # (e.g. package.json with no test runner, or a python project
239
+ # with no pytest on PATH). Forces at-least-CONCERNS.
240
+ # fail = tests ran and failed -> High finding.
241
+ # pass = tests ran green.
242
+ # ---------------------------------------------------------------------------
243
+ verify_gate_tests() {
244
+ local tree="$1"
245
+ local timeout_s="${LOKI_GATE_TIMEOUT:-300}"
246
+ local runner="none"
247
+ local rc=0
248
+ local out=""
249
+
250
+ if [ -f "$tree/package.json" ]; then
251
+ if grep -q '"vitest"' "$tree/package.json" 2>/dev/null; then
252
+ runner="vitest"
253
+ out="$(cd "$tree" && timeout "$timeout_s" npx vitest run 2>&1)" || rc=$?
254
+ elif grep -q '"jest"' "$tree/package.json" 2>/dev/null; then
255
+ runner="jest"
256
+ out="$(cd "$tree" && timeout "$timeout_s" npx jest --passWithNoTests --forceExit 2>&1)" || rc=$?
257
+ elif grep -q '"mocha"' "$tree/package.json" 2>/dev/null; then
258
+ runner="mocha"
259
+ out="$(cd "$tree" && timeout "$timeout_s" npx mocha 2>&1)" || rc=$?
260
+ fi
261
+ fi
262
+
263
+ if [ "$runner" = "none" ]; then
264
+ # Python project? (mirrors run.sh:6729-6742 detection)
265
+ local has_python=false
266
+ if [ -f "$tree/setup.py" ] || [ -f "$tree/pyproject.toml" ] \
267
+ || [ -f "$tree/setup.cfg" ] || [ -f "$tree/pytest.ini" ] \
268
+ || [ -f "$tree/conftest.py" ]; then
269
+ has_python=true
270
+ elif [ -d "$tree/tests" ]; then
271
+ if find "$tree/tests" -maxdepth 3 -type f \
272
+ \( -name 'test_*.py' -o -name '*_test.py' -o -name 'conftest.py' \) \
273
+ -print -quit 2>/dev/null | grep -q .; then
274
+ has_python=true
275
+ fi
276
+ fi
277
+ # Council finding (v7.27.0): bare root-level test files with no
278
+ # pyproject/setup/tests-dir were invisible to detection, letting verify
279
+ # emit VERIFIED while pytest could have discovered and run them. Detect
280
+ # them so the gate runs (or goes inconclusive -> CONCERNS when no
281
+ # runner is installed), never silently skipped.
282
+ if [ "$has_python" = "false" ]; then
283
+ if find "$tree" -maxdepth 1 -type f \
284
+ \( -name 'test_*.py' -o -name '*_test.py' \) \
285
+ -print -quit 2>/dev/null | grep -q .; then
286
+ has_python=true
287
+ fi
288
+ fi
289
+ if [ "$has_python" = "true" ]; then
290
+ if command -v pytest >/dev/null 2>&1; then
291
+ runner="pytest"
292
+ out="$(cd "$tree" && timeout "$timeout_s" pytest --tb=short 2>&1)" || rc=$?
293
+ else
294
+ # Applicable but cannot run -> inconclusive (Entanglement 2).
295
+ _verify_add_gate "tests" "inconclusive" "pytest" "python project detected but pytest not on PATH" "true"
296
+ return 0
297
+ fi
298
+ fi
299
+ fi
300
+
301
+ if [ "$runner" = "none" ] && [ -f "$tree/go.mod" ]; then
302
+ if command -v go >/dev/null 2>&1; then
303
+ runner="go-test"
304
+ out="$(cd "$tree" && timeout "$timeout_s" go test ./... 2>&1)" || rc=$?
305
+ else
306
+ _verify_add_gate "tests" "inconclusive" "go-test" "go project detected but go not on PATH" "true"
307
+ return 0
308
+ fi
309
+ fi
310
+
311
+ if [ "$runner" = "none" ] && [ -f "$tree/Cargo.toml" ]; then
312
+ if command -v cargo >/dev/null 2>&1; then
313
+ runner="cargo-test"
314
+ out="$(cd "$tree" && timeout "$timeout_s" cargo test 2>&1)" || rc=$?
315
+ else
316
+ _verify_add_gate "tests" "inconclusive" "cargo-test" "rust project detected but cargo not on PATH" "true"
317
+ return 0
318
+ fi
319
+ fi
320
+
321
+ if [ "$runner" = "none" ]; then
322
+ _verify_add_gate "tests" "skipped" "" "no test framework detected" "true"
323
+ return 0
324
+ fi
325
+
326
+ if [ "$rc" -eq 124 ]; then
327
+ _verify_add_gate "tests" "inconclusive" "$runner" "test run timed out after ${timeout_s}s" "true"
328
+ return 0
329
+ fi
330
+
331
+ if [ "$rc" -eq 0 ]; then
332
+ _verify_add_gate "tests" "pass" "$runner" "tests passed" "true"
333
+ else
334
+ _verify_add_gate "tests" "fail" "$runner" "tests failed (rc=$rc)" "true"
335
+ _verify_add_finding "High" "tests" "deterministic:$runner" "" "null" \
336
+ "Tests failed under $runner (exit $rc). $(printf '%s' "$out" | tail -2 | tr '\n' ' ')"
337
+ fi
338
+ return 0
339
+ }
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # Gate: static analysis / lint (faithful port of enforce_static_analysis,
343
+ # run.sh:6299). Runs only on files in the PR diff (merge-base..HEAD).
344
+ #
345
+ # skipped = no changed files OR no lint-capable files in the diff.
346
+ # fail = a configured/available linter reported errors -> Medium finding.
347
+ # pass = ran clean.
348
+ # ---------------------------------------------------------------------------
349
+ verify_gate_static() {
350
+ local tree="$1"
351
+ local changed="$VERIFY_DIFF_NAMES"
352
+
353
+ if [ -z "$changed" ]; then
354
+ _verify_add_gate "static_analysis" "skipped" "" "no changed files in diff" "true"
355
+ return 0
356
+ fi
357
+
358
+ local findings=0
359
+ local checked=0
360
+ local details=""
361
+
362
+ # JavaScript/TypeScript syntax (node --check for .js, tsc/bun for .ts).
363
+ local js_files
364
+ js_files="$(printf '%s\n' "$changed" | grep -E '\.(js|jsx)$' || true)"
365
+ local ts_files
366
+ ts_files="$(printf '%s\n' "$changed" | grep -E '\.(ts|tsx)$' || true)"
367
+
368
+ if [ -n "$js_files" ] && command -v node >/dev/null 2>&1; then
369
+ local f
370
+ while IFS= read -r f; do
371
+ [ -z "$f" ] && continue
372
+ [ -f "$tree/$f" ] || continue
373
+ checked=$((checked + 1))
374
+ node --check "$tree/$f" >/dev/null 2>&1 || {
375
+ findings=$((findings + 1))
376
+ details="${details}JS syntax error: $f. "
377
+ _verify_add_finding "Medium" "lint" "deterministic:node-check" "$f" "null" \
378
+ "JavaScript syntax error in $f (node --check failed)."
379
+ }
380
+ done <<<"$js_files"
381
+ fi
382
+
383
+ if [ -n "$ts_files" ]; then
384
+ local tschecker=""
385
+ if command -v tsc >/dev/null 2>&1; then tschecker="tsc"
386
+ elif command -v bun >/dev/null 2>&1; then tschecker="bun"; fi
387
+ if [ -n "$tschecker" ]; then
388
+ local f
389
+ while IFS= read -r f; do
390
+ [ -z "$f" ] && continue
391
+ [ -f "$tree/$f" ] || continue
392
+ checked=$((checked + 1))
393
+ local ok=0
394
+ if [ "$tschecker" = "tsc" ]; then
395
+ (cd "$tree" && tsc --noEmit --allowJs --jsx preserve --target esnext "$f") >/dev/null 2>&1 || ok=1
396
+ else
397
+ (cd "$tree" && bun --check "$f") >/dev/null 2>&1 || ok=1
398
+ fi
399
+ if [ "$ok" -ne 0 ]; then
400
+ findings=$((findings + 1))
401
+ details="${details}TS syntax error: $f. "
402
+ _verify_add_finding "Medium" "lint" "deterministic:$tschecker" "$f" "null" \
403
+ "TypeScript syntax error in $f ($tschecker --check failed)."
404
+ fi
405
+ done <<<"$ts_files"
406
+ fi
407
+ fi
408
+
409
+ # Python (py_compile).
410
+ local py_files
411
+ py_files="$(printf '%s\n' "$changed" | grep -E '\.py$' || true)"
412
+ if [ -n "$py_files" ] && command -v python3 >/dev/null 2>&1; then
413
+ local f
414
+ while IFS= read -r f; do
415
+ [ -z "$f" ] && continue
416
+ [ -f "$tree/$f" ] || continue
417
+ checked=$((checked + 1))
418
+ python3 -m py_compile "$tree/$f" >/dev/null 2>&1 || {
419
+ findings=$((findings + 1))
420
+ details="${details}Python syntax error: $f. "
421
+ _verify_add_finding "Medium" "lint" "deterministic:py_compile" "$f" "null" \
422
+ "Python syntax error in $f (py_compile failed)."
423
+ }
424
+ done <<<"$py_files"
425
+ fi
426
+
427
+ # Shell (bash -n).
428
+ local sh_files
429
+ sh_files="$(printf '%s\n' "$changed" | grep -E '\.(sh|bash)$' || true)"
430
+ if [ -n "$sh_files" ]; then
431
+ local f
432
+ while IFS= read -r f; do
433
+ [ -z "$f" ] && continue
434
+ [ -f "$tree/$f" ] || continue
435
+ checked=$((checked + 1))
436
+ bash -n "$tree/$f" >/dev/null 2>&1 || {
437
+ findings=$((findings + 1))
438
+ details="${details}Shell syntax error: $f. "
439
+ _verify_add_finding "Medium" "lint" "deterministic:bash-n" "$f" "null" \
440
+ "Shell syntax error in $f (bash -n failed)."
441
+ }
442
+ done <<<"$sh_files"
443
+ fi
444
+
445
+ if [ "$checked" -eq 0 ]; then
446
+ _verify_add_gate "static_analysis" "skipped" "" "no lint-capable changed files" "true"
447
+ elif [ "$findings" -eq 0 ]; then
448
+ _verify_add_gate "static_analysis" "pass" "syntax" "$checked file(s) checked, no syntax errors" "true"
449
+ else
450
+ _verify_add_gate "static_analysis" "fail" "syntax" "$findings issue(s) in $checked checked file(s): $details" "true"
451
+ fi
452
+ return 0
453
+ }
454
+
455
+ # ---------------------------------------------------------------------------
456
+ # Gate: secret scan (NET-NEW). gitleaks if available, otherwise a regex
457
+ # fallback over the PR-diff files. A planted credential is a Critical finding.
458
+ #
459
+ # skipped = no changed files to scan.
460
+ # ---------------------------------------------------------------------------
461
+ verify_gate_secret_scan() {
462
+ local tree="$1"
463
+ local changed="$VERIFY_DIFF_NAMES"
464
+
465
+ if command -v gitleaks >/dev/null 2>&1; then
466
+ if [ -z "$changed" ]; then
467
+ _verify_add_gate "secret_scan" "skipped" "gitleaks" "no changed files in diff" "true"
468
+ return 0
469
+ fi
470
+ # Scan ONLY the PR-diff files, not the whole tree/history. A verifier
471
+ # attests that THE CHANGE is safe; blocking on pre-existing secrets in
472
+ # untouched files is the #1 false-positive-fatigue failure (spec
473
+ # Section 7.2). Use gitleaks filesystem mode (`detect --no-git --source`)
474
+ # per changed file, which is stable across gitleaks v8 and avoids
475
+ # depending on a specific commit-range flag form.
476
+ local hits=0 detail="" f
477
+ while IFS= read -r f; do
478
+ [ -z "$f" ] && continue
479
+ [ -f "$tree/$f" ] || continue
480
+ if ! gitleaks detect --no-banner --redact --no-git \
481
+ --source "$tree/$f" >/dev/null 2>&1; then
482
+ hits=$((hits + 1))
483
+ detail="${detail}${f} "
484
+ _verify_add_finding "Critical" "security" "deterministic:gitleaks" "$f" "null" \
485
+ "gitleaks detected a secret in $f."
486
+ fi
487
+ done <<<"$changed"
488
+ if [ "$hits" -eq 0 ]; then
489
+ _verify_add_gate "secret_scan" "pass" "gitleaks" "no secrets in changed files" "true"
490
+ else
491
+ _verify_add_gate "secret_scan" "fail" "gitleaks" "$hits changed file(s) with secrets: $detail" "true"
492
+ fi
493
+ return 0
494
+ fi
495
+
496
+ # Regex fallback over PR-diff files ONLY (gitleaks not installed). A verifier
497
+ # attests that THE CHANGE is safe; it must not block on pre-existing secrets
498
+ # in untouched files (false-positive fatigue, spec Section 7.2). There is
499
+ # deliberately NO whole-repo fallback: an empty diff short-circuits upstream
500
+ # before this gate runs.
501
+ local changed="$VERIFY_DIFF_NAMES"
502
+ if [ -z "$changed" ]; then
503
+ _verify_add_gate "secret_scan" "skipped" "regex-fallback" "no changed files in diff" "true"
504
+ return 0
505
+ fi
506
+
507
+ # High-confidence credential patterns. Conservative on purpose to limit
508
+ # false positives (spec Section 7.2).
509
+ local patterns=(
510
+ 'AKIA[0-9A-Z]{16}' # AWS access key id
511
+ '-----BEGIN [A-Z ]*PRIVATE KEY-----' # PEM private key
512
+ 'gh[pousr]_[A-Za-z0-9]{36,}' # GitHub token
513
+ 'xox[baprs]-[A-Za-z0-9-]{10,}' # Slack token
514
+ 'sk-[A-Za-z0-9]{20,}' # OpenAI-style key
515
+ 'AIza[0-9A-Za-z_-]{35}' # Google API key
516
+ )
517
+
518
+ local hits=0
519
+ local detail=""
520
+ local f
521
+ while IFS= read -r f; do
522
+ [ -z "$f" ] && continue
523
+ [ -f "$tree/$f" ] || continue
524
+ # Skip obvious binaries.
525
+ if LC_ALL=C grep -Iq . "$tree/$f" 2>/dev/null; then : ; else continue; fi
526
+ local p
527
+ for p in "${patterns[@]}"; do
528
+ if grep -Eq "$p" "$tree/$f" 2>/dev/null; then
529
+ hits=$((hits + 1))
530
+ detail="${detail}${f} "
531
+ _verify_add_finding "Critical" "security" "deterministic:regex-secret-scan" "$f" "null" \
532
+ "Potential hardcoded secret detected in $f (matched a high-confidence credential pattern)."
533
+ break
534
+ fi
535
+ done
536
+ done <<<"$changed"
537
+
538
+ if [ "$hits" -eq 0 ]; then
539
+ _verify_add_gate "secret_scan" "pass" "regex-fallback" "no secrets matched" "true"
540
+ else
541
+ _verify_add_gate "secret_scan" "fail" "regex-fallback" "$hits file(s) with potential secrets: $detail" "true"
542
+ fi
543
+ return 0
544
+ }
545
+
546
+ # ---------------------------------------------------------------------------
547
+ # Gate: dependency audit (NET-NEW). npm audit / pip-audit when lockfiles
548
+ # exist. High/Critical CVE -> High finding; moderate -> Medium.
549
+ #
550
+ # skipped = no lockfile (not-applicable).
551
+ # inconclusive = tool needed but not installed, or audit could not complete
552
+ # (e.g. offline) -> forces at-least-CONCERNS.
553
+ # ---------------------------------------------------------------------------
554
+ verify_gate_dependency_audit() {
555
+ local tree="$1"
556
+ local ran=0
557
+
558
+ # npm
559
+ if [ -f "$tree/package-lock.json" ] || [ -f "$tree/npm-shrinkwrap.json" ]; then
560
+ ran=1
561
+ if command -v npm >/dev/null 2>&1; then
562
+ local out rc=0
563
+ out="$(cd "$tree" && npm audit --json 2>/dev/null)" || rc=$?
564
+ # npm audit exits nonzero when vulns are found; rc alone is not an
565
+ # error. Distinguish "ran and found vulns" from "could not run".
566
+ if printf '%s' "$out" | python3 -c "import sys,json; json.load(sys.stdin)" >/dev/null 2>&1; then
567
+ local sev
568
+ sev="$(printf '%s' "$out" | python3 -c '
569
+ import sys, json
570
+ d = json.load(sys.stdin)
571
+ v = d.get("metadata", {}).get("vulnerabilities", {})
572
+ crit = v.get("critical", 0); high = v.get("high", 0)
573
+ mod = v.get("moderate", 0); low = v.get("low", 0)
574
+ print("%d %d %d %d" % (crit, high, mod, low))
575
+ ' 2>/dev/null || echo "")"
576
+ if [ -n "$sev" ]; then
577
+ read -r _c _h _m _l <<<"$sev"
578
+ if [ "$_c" -gt 0 ] || [ "$_h" -gt 0 ]; then
579
+ _verify_add_gate "dependency_audit" "fail" "npm-audit" "$_c critical, $_h high CVEs" "true"
580
+ _verify_add_finding "High" "dependencies" "deterministic:npm-audit" "package-lock.json" "null" \
581
+ "npm audit found $_c critical and $_h high severity vulnerabilities."
582
+ elif [ "$_m" -gt 0 ]; then
583
+ _verify_add_gate "dependency_audit" "fail" "npm-audit" "$_m moderate CVEs" "true"
584
+ _verify_add_finding "Medium" "dependencies" "deterministic:npm-audit" "package-lock.json" "null" \
585
+ "npm audit found $_m moderate severity vulnerabilities."
586
+ else
587
+ _verify_add_gate "dependency_audit" "pass" "npm-audit" "no high/critical CVEs ($_l low)" "true"
588
+ fi
589
+ else
590
+ _verify_add_gate "dependency_audit" "inconclusive" "npm-audit" "could not parse npm audit output" "true"
591
+ fi
592
+ else
593
+ _verify_add_gate "dependency_audit" "inconclusive" "npm-audit" "npm audit could not complete (offline or registry error)" "true"
594
+ fi
595
+ else
596
+ _verify_add_gate "dependency_audit" "inconclusive" "npm-audit" "package-lock.json present but npm not on PATH" "true"
597
+ fi
598
+ fi
599
+
600
+ # python (pip-audit)
601
+ if [ -f "$tree/requirements.txt" ] || [ -f "$tree/Pipfile.lock" ] || [ -f "$tree/poetry.lock" ]; then
602
+ ran=1
603
+ if command -v pip-audit >/dev/null 2>&1; then
604
+ local out rc=0
605
+ out="$(cd "$tree" && pip-audit --format json 2>/dev/null)" || rc=$?
606
+ if printf '%s' "$out" | python3 -c "import sys,json; json.load(sys.stdin)" >/dev/null 2>&1; then
607
+ local count
608
+ count="$(printf '%s' "$out" | python3 -c '
609
+ import sys, json
610
+ d = json.load(sys.stdin)
611
+ deps = d.get("dependencies", d if isinstance(d, list) else [])
612
+ n = sum(len(x.get("vulns", [])) for x in deps) if isinstance(deps, list) else 0
613
+ print(n)
614
+ ' 2>/dev/null || echo "")"
615
+ if [ -n "$count" ] && [ "$count" -gt 0 ]; then
616
+ _verify_add_gate "dependency_audit" "fail" "pip-audit" "$count known vulnerabilities" "true"
617
+ _verify_add_finding "High" "dependencies" "deterministic:pip-audit" "requirements.txt" "null" \
618
+ "pip-audit found $count known vulnerabilities in Python dependencies."
619
+ elif [ -n "$count" ]; then
620
+ _verify_add_gate "dependency_audit" "pass" "pip-audit" "no known vulnerabilities" "true"
621
+ else
622
+ _verify_add_gate "dependency_audit" "inconclusive" "pip-audit" "could not parse pip-audit output" "true"
623
+ fi
624
+ else
625
+ _verify_add_gate "dependency_audit" "inconclusive" "pip-audit" "pip-audit could not complete (offline or index error)" "true"
626
+ fi
627
+ else
628
+ _verify_add_gate "dependency_audit" "inconclusive" "pip-audit" "python lockfile present but pip-audit not on PATH" "true"
629
+ fi
630
+ fi
631
+
632
+ if [ "$ran" -eq 0 ]; then
633
+ _verify_add_gate "dependency_audit" "skipped" "" "no dependency lockfile found" "true"
634
+ fi
635
+ return 0
636
+ }
637
+
638
+ # ---------------------------------------------------------------------------
639
+ # Verdict computation (Entanglement 2: inconclusive -> CONCERNS, never VERIFIED).
640
+ #
641
+ # Pure function of:
642
+ # - highest severity finding present (Critical/High block by default),
643
+ # - evidence conclusiveness (any inconclusive gate, or unresolved diff,
644
+ # or empty diff -> at least CONCERNS),
645
+ # - block threshold (default Critical,High; configurable via --block-on).
646
+ #
647
+ # Sets globals: VERIFY_VERDICT VERIFY_EXIT
648
+ # ---------------------------------------------------------------------------
649
+ verify_compute_verdict() {
650
+ local block_on="$1" # comma list, lowercased, e.g. "critical,high"
651
+
652
+ local has_critical=false has_high=false has_medium=false has_low=false
653
+ if [ -s "$_VERIFY_FINDINGS_FILE" ]; then
654
+ local sev
655
+ while IFS=$'\t' read -r sev _; do
656
+ case "$sev" in
657
+ Critical) has_critical=true ;;
658
+ High) has_high=true ;;
659
+ Medium) has_medium=true ;;
660
+ Low) has_low=true ;;
661
+ esac
662
+ done <"$_VERIFY_FINDINGS_FILE"
663
+ fi
664
+
665
+ # Determine blocking from threshold.
666
+ local blocked=false
667
+ case ",$block_on," in
668
+ *,critical,*) [ "$has_critical" = "true" ] && blocked=true ;;
669
+ esac
670
+ case ",$block_on," in
671
+ *,high,*) [ "$has_high" = "true" ] && blocked=true ;;
672
+ esac
673
+ case ",$block_on," in
674
+ *,medium,*) [ "$has_medium" = "true" ] && blocked=true ;;
675
+ esac
676
+ case ",$block_on," in
677
+ *,low,*) [ "$has_low" = "true" ] && blocked=true ;;
678
+ esac
679
+
680
+ # Conclusiveness: any inconclusive gate, unresolved diff, or empty diff.
681
+ # Read the gate status (field 2, tab-separated) directly rather than relying
682
+ # on grep -P, which is not portable to BSD grep (e.g. under a constrained
683
+ # PATH on macOS). Each gate record is: gate \t status \t ... .
684
+ local inconclusive=false
685
+ local _g_gate _g_status
686
+ while IFS=$'\t' read -r _g_gate _g_status _; do
687
+ if [ "$_g_status" = "inconclusive" ]; then
688
+ inconclusive=true
689
+ break
690
+ fi
691
+ done <"$_VERIFY_GATES_FILE"
692
+ if [ "${VERIFY_DIFF_RESOLVED:-false}" != "true" ]; then
693
+ inconclusive=true
694
+ elif [ "${VERIFY_DIFF_FILES:-0}" -eq 0 ]; then
695
+ # Empty diff: nothing to verify -> not VERIFIED.
696
+ inconclusive=true
697
+ fi
698
+
699
+ # Any below-threshold finding (Medium/Low present but not blocking) -> CONCERNS.
700
+ local has_nonblock_finding=false
701
+ if [ "$has_medium" = "true" ] || [ "$has_low" = "true" ]; then
702
+ has_nonblock_finding=true
703
+ fi
704
+ # A blocking-severity finding that is NOT in the block list still counts as
705
+ # a non-block finding (e.g. --block-on critical with a High present).
706
+ if [ "$blocked" = "false" ]; then
707
+ if [ "$has_critical" = "true" ] || [ "$has_high" = "true" ]; then
708
+ has_nonblock_finding=true
709
+ fi
710
+ fi
711
+
712
+ if [ "$blocked" = "true" ]; then
713
+ VERIFY_VERDICT="BLOCKED"
714
+ VERIFY_EXIT=$VERIFY_EXIT_BLOCKED
715
+ elif [ "$has_nonblock_finding" = "true" ] || [ "$inconclusive" = "true" ]; then
716
+ VERIFY_VERDICT="CONCERNS"
717
+ VERIFY_EXIT=$VERIFY_EXIT_CONCERNS
718
+ else
719
+ VERIFY_VERDICT="VERIFIED"
720
+ VERIFY_EXIT=$VERIFY_EXIT_VERIFIED
721
+ fi
722
+ }
723
+
724
+ # ---------------------------------------------------------------------------
725
+ # Evidence emitters: consolidated evidence.json + report.md (Entanglement 3).
726
+ # ---------------------------------------------------------------------------
727
+ verify_emit_evidence() {
728
+ local out_dir="$1"
729
+ local started_at="$2"
730
+ local completed_at="$3"
731
+ local block_on="$4"
732
+
733
+ local tool_version
734
+ tool_version="$(_verify_tool_version)"
735
+ local repo_name
736
+ repo_name="$(git config --get remote.origin.url 2>/dev/null | sed -E 's#.*[:/]([^/]+/[^/]+)(\.git)?$#\1#' || echo "local")"
737
+ [ -z "$repo_name" ] && repo_name="local"
738
+
739
+ _VERIFY_OUT_DIR="$out_dir" \
740
+ _VERIFY_FINDINGS="$_VERIFY_FINDINGS_FILE" \
741
+ _VERIFY_GATES="$_VERIFY_GATES_FILE" \
742
+ _V_VERDICT="$VERIFY_VERDICT" \
743
+ _V_EXIT="$VERIFY_EXIT" \
744
+ _V_SCHEMA="$VERIFY_SCHEMA_VERSION" \
745
+ _V_TOOLVER="$tool_version" \
746
+ _V_REPO="$repo_name" \
747
+ _V_HEAD="${VERIFY_HEAD_SHA:-}" \
748
+ _V_BASE="${VERIFY_BASE_REF:-}" \
749
+ _V_MB="${VERIFY_MERGE_BASE:-}" \
750
+ _V_FILES="${VERIFY_DIFF_FILES:-0}" \
751
+ _V_INS="${VERIFY_DIFF_INS:-0}" \
752
+ _V_DEL="${VERIFY_DIFF_DEL:-0}" \
753
+ _V_START="$started_at" \
754
+ _V_DONE="$completed_at" \
755
+ _V_BLOCKON="$block_on" \
756
+ python3 - <<'PYEOF'
757
+ import json, os, hashlib
758
+
759
+ out_dir = os.environ["_VERIFY_OUT_DIR"]
760
+ findings_file = os.environ["_VERIFY_FINDINGS"]
761
+ gates_file = os.environ["_VERIFY_GATES"]
762
+
763
+ def read_tsv(path, n):
764
+ rows = []
765
+ if os.path.exists(path):
766
+ with open(path) as f:
767
+ for line in f:
768
+ line = line.rstrip("\n")
769
+ if not line:
770
+ continue
771
+ parts = line.split("\t")
772
+ parts += [""] * (n - len(parts))
773
+ rows.append(parts[:n])
774
+ return rows
775
+
776
+ block_on = [s.strip() for s in os.environ["_V_BLOCKON"].split(",") if s.strip()]
777
+
778
+ gates = []
779
+ for gate, status, runner, summary, reproducible in read_tsv(gates_file, 5):
780
+ g = {"gate": gate, "status": status, "reproducible": (reproducible == "true")}
781
+ if runner:
782
+ g["runner" if gate in ("tests",) else "scanner" if gate in ("secret_scan", "dependency_audit") else "runner"] = runner
783
+ if summary:
784
+ g["summary"] = summary
785
+ gates.append(g)
786
+
787
+ findings = []
788
+ for severity, category, source, fpath, line, message in read_tsv(findings_file, 6):
789
+ text = message[:80]
790
+ fid = "%s::%s" % (source, text)
791
+ blocking = severity.lower() in block_on
792
+ findings.append({
793
+ "id": fid,
794
+ "severity": severity,
795
+ "category": category,
796
+ "source": source,
797
+ "file": fpath if fpath else None,
798
+ "line": (int(line) if line.isdigit() else None),
799
+ "message": message,
800
+ "blocking": blocking,
801
+ })
802
+
803
+ doc = {
804
+ "schema_version": os.environ["_V_SCHEMA"],
805
+ "verdict": os.environ["_V_VERDICT"],
806
+ "exit_code": int(os.environ["_V_EXIT"]),
807
+ "subject": {
808
+ "repo": os.environ["_V_REPO"],
809
+ "pr_number": None,
810
+ "head_sha": os.environ["_V_HEAD"],
811
+ "base_branch": os.environ["_V_BASE"],
812
+ "merge_base_sha": os.environ["_V_MB"],
813
+ "diff_stats": {
814
+ "files_changed": int(os.environ["_V_FILES"]),
815
+ "insertions": int(os.environ["_V_INS"]),
816
+ "deletions": int(os.environ["_V_DEL"]),
817
+ },
818
+ },
819
+ "produced_by": {
820
+ "tool": "loki verify",
821
+ "tool_version": os.environ["_V_TOOLVER"],
822
+ "run_started_at": os.environ["_V_START"],
823
+ "run_completed_at": os.environ["_V_DONE"],
824
+ "runner": "self-hosted",
825
+ "key_source": "none",
826
+ },
827
+ "deterministic_gates": gates,
828
+ "llm_review": {
829
+ "status": "skipped",
830
+ "reason": "deterministic-only MVP (30-day cut); single-reviewer LLM stage and blind council are deferred to Phase 2",
831
+ "reproducible": False,
832
+ },
833
+ "findings": findings,
834
+ "suppressed": [],
835
+ }
836
+
837
+ os.makedirs(out_dir, exist_ok=True)
838
+ ev_path = os.path.join(out_dir, "evidence.json")
839
+ with open(ev_path, "w") as f:
840
+ json.dump(doc, f, indent=2)
841
+ f.write("\n")
842
+
843
+ # ----- Markdown report -----
844
+ def sev_rank(s):
845
+ return {"Critical": 0, "High": 1, "Medium": 2, "Low": 3, "Info": 4}.get(s, 5)
846
+
847
+ lines = []
848
+ lines.append("# Autonomi Verify report")
849
+ lines.append("")
850
+ lines.append("Verdict: **%s** (exit %d)" % (doc["verdict"], doc["exit_code"]))
851
+ lines.append("")
852
+ lines.append("Tool: loki verify %s | deterministic-only MVP (no LLM review)" % doc["produced_by"]["tool_version"])
853
+ lines.append("")
854
+ s = doc["subject"]
855
+ lines.append("## Subject")
856
+ lines.append("")
857
+ lines.append("- Repo: %s" % s["repo"])
858
+ lines.append("- Base: %s" % (s["base_branch"] or "(unresolved)"))
859
+ lines.append("- Head: %s" % (s["head_sha"] or "(none)"))
860
+ lines.append("- Merge base: %s" % (s["merge_base_sha"] or "(none)"))
861
+ ds = s["diff_stats"]
862
+ lines.append("- Diff: %d files, +%d / -%d" % (ds["files_changed"], ds["insertions"], ds["deletions"]))
863
+ lines.append("")
864
+ lines.append("## Deterministic gates")
865
+ lines.append("")
866
+ lines.append("| Gate | Status | Detail |")
867
+ lines.append("|------|--------|--------|")
868
+ for g in gates:
869
+ detail = g.get("summary", "")
870
+ lines.append("| %s | %s | %s |" % (g["gate"], g["status"], detail.replace("|", "\\|")))
871
+ lines.append("")
872
+ lines.append("## Findings")
873
+ lines.append("")
874
+ if findings:
875
+ lines.append("| Severity | Category | Source | File:Line | Blocking | Message |")
876
+ lines.append("|----------|----------|--------|-----------|----------|---------|")
877
+ for fd in sorted(findings, key=lambda x: sev_rank(x["severity"])):
878
+ loc = (fd["file"] or "-")
879
+ if fd["line"] is not None:
880
+ loc += ":%d" % fd["line"]
881
+ lines.append("| %s | %s | %s | %s | %s | %s |" % (
882
+ fd["severity"], fd["category"], fd["source"], loc,
883
+ "yes" if fd["blocking"] else "no",
884
+ fd["message"].replace("|", "\\|")))
885
+ else:
886
+ lines.append("No findings.")
887
+ lines.append("")
888
+ lines.append("## LLM review")
889
+ lines.append("")
890
+ lines.append("Skipped: %s" % doc["llm_review"]["reason"])
891
+ lines.append("")
892
+ lines.append("Evidence JSON: %s" % ev_path)
893
+ lines.append("")
894
+
895
+ with open(os.path.join(out_dir, "report.md"), "w") as f:
896
+ f.write("\n".join(lines))
897
+
898
+ print(ev_path)
899
+ PYEOF
900
+ }
901
+
902
+ # ---------------------------------------------------------------------------
903
+ # Help
904
+ # ---------------------------------------------------------------------------
905
+ verify_help() {
906
+ cat <<'EOF'
907
+ loki verify - Autonomi Verify: deterministic PR verification (MVP)
908
+
909
+ USAGE:
910
+ loki verify [<base-ref>] [options]
911
+
912
+ DESCRIPTION:
913
+ Verifies the CURRENT working tree (HEAD) against a base ref by computing
914
+ the PR-style delta: merge-base(base, HEAD)..HEAD. It runs deterministic
915
+ quality gates on that change set and emits a single auditable verdict plus
916
+ a machine-readable evidence document.
917
+
918
+ This MVP is DETERMINISTIC-ONLY. There is NO LLM code review in this slice.
919
+ The single-reviewer LLM stage and the blind multi-reviewer council are
920
+ sequenced for a later phase (see the verification spec). The evidence
921
+ document records llm_review.status = "skipped" honestly.
922
+
923
+ ARGUMENTS:
924
+ <base-ref> Base branch/ref to diff against. Default: origin/main, then
925
+ main. Tries origin/<ref> before local <ref>.
926
+
927
+ OPTIONS:
928
+ --base <ref> Same as the positional base-ref (explicit form).
929
+ --out <dir> Output directory. Default: .loki/verify
930
+ --block-on <list> Comma list of severities that BLOCK.
931
+ Default: critical,high (one notch looser than the
932
+ Loki build loop, which also blocks on medium).
933
+ --no-llm Accepted for forward-compat; LLM is already off in MVP.
934
+ -h, --help Show this help.
935
+
936
+ GATES (deterministic, reproducible by construction):
937
+ build run if a build command is detectable (npm/go/cargo)
938
+ tests vitest/jest/mocha/pytest/go test/cargo test
939
+ static_analysis syntax check of changed files (node/tsc/py_compile/bash -n)
940
+ secret_scan gitleaks if installed, else high-confidence regex fallback
941
+ dependency_audit npm audit / pip-audit when a lockfile exists
942
+
943
+ VERDICT MODEL:
944
+ VERIFIED zero findings, diff non-empty, all gates conclusive
945
+ CONCERNS below-threshold findings, OR inconclusive evidence
946
+ (inconclusive is NEVER upgraded to VERIFIED)
947
+ BLOCKED one or more findings at/above the block threshold
948
+
949
+ Gate-not-applicable (e.g. no lockfile) = skipped, does NOT affect verdict.
950
+ Gate-applicable-but-could-not-run = inconclusive, forces at-least-CONCERNS.
951
+
952
+ The diff is the COMMITTED delta merge-base(base, HEAD)..HEAD (PR semantics).
953
+ Uncommitted working-tree changes are not verified; commit them first. An
954
+ empty diff yields CONCERNS (nothing to verify), never VERIFIED.
955
+
956
+ EXIT CODES (this implementation):
957
+ 0 VERIFIED
958
+ 1 CONCERNS
959
+ 2 BLOCKED
960
+ 3 verifier error (could not complete; never silently passes)
961
+
962
+ NOTE: the verification spec (Section 1.1) lists 1=BLOCKED, 2=CONCERNS.
963
+ This implementation follows the build-task ordering (1=CONCERNS,
964
+ 2=BLOCKED). A human must reconcile the two before the GitHub App consumes
965
+ these codes.
966
+
967
+ OUTPUT:
968
+ <out>/evidence.json consolidated machine-readable evidence (schema 1.0)
969
+ <out>/report.md human-readable verdict + findings table
970
+
971
+ EOF
972
+ }
973
+
974
+ # ---------------------------------------------------------------------------
975
+ # Entry point
976
+ # ---------------------------------------------------------------------------
977
+ verify_main() {
978
+ local base_ref=""
979
+ local out_dir=".loki/verify"
980
+ local block_on="critical,high"
981
+
982
+ while [ $# -gt 0 ]; do
983
+ case "$1" in
984
+ -h|--help) verify_help; return 0 ;;
985
+ --base)
986
+ base_ref="${2:-}"; shift 2 ;;
987
+ --out)
988
+ out_dir="${2:-}"; shift 2 ;;
989
+ --block-on)
990
+ block_on="$(printf '%s' "${2:-}" | tr '[:upper:]' '[:lower:]')"; shift 2 ;;
991
+ --no-llm)
992
+ shift ;;
993
+ --) shift; break ;;
994
+ -*)
995
+ _verify_err "unknown option: $1"; verify_help; return $VERIFY_EXIT_ERROR ;;
996
+ *)
997
+ if [ -z "$base_ref" ]; then base_ref="$1"; else
998
+ _verify_err "unexpected argument: $1"; return $VERIFY_EXIT_ERROR
999
+ fi
1000
+ shift ;;
1001
+ esac
1002
+ done
1003
+
1004
+ # Default base.
1005
+ if [ -z "$base_ref" ]; then
1006
+ if git rev-parse --verify --quiet "origin/main" >/dev/null 2>&1; then
1007
+ base_ref="main"
1008
+ else
1009
+ base_ref="main"
1010
+ fi
1011
+ fi
1012
+
1013
+ local tree="."
1014
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
1015
+ _verify_err "not inside a git repository; cannot resolve a PR diff"
1016
+ # Still emit an evidence doc with inconclusive verdict (never silent pass).
1017
+ fi
1018
+
1019
+ _VERIFY_FINDINGS_FILE="$(mktemp -t loki-verify-findings.XXXXXX)" || { _verify_err "mktemp failed"; return $VERIFY_EXIT_ERROR; }
1020
+ _VERIFY_GATES_FILE="$(mktemp -t loki-verify-gates.XXXXXX)" || { _verify_err "mktemp failed"; return $VERIFY_EXIT_ERROR; }
1021
+ # shellcheck disable=SC2064
1022
+ trap "rm -f '$_VERIFY_FINDINGS_FILE' '$_VERIFY_GATES_FILE'" RETURN
1023
+
1024
+ local started_at completed_at
1025
+ started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
1026
+
1027
+ _verify_log "loki verify (deterministic-only MVP) starting; base=$base_ref out=$out_dir"
1028
+
1029
+ verify_diff_base "$base_ref"
1030
+
1031
+ # Short-circuit on an empty or unresolvable change set. A verifier verifies
1032
+ # a CHANGE; with nothing to verify there is no basis for a VERIFIED verdict.
1033
+ # Recording the gates as skipped (rather than running them against the whole
1034
+ # tree) avoids blocking on pre-existing repo state, and the unresolved/empty
1035
+ # diff is already reclassified as inconclusive -> CONCERNS by the verdict
1036
+ # function (Entanglement 2). A real PR always has a non-empty committed diff,
1037
+ # so this only triggers on the degenerate local case (e.g. uncommitted-only
1038
+ # changes: merge-base..HEAD is committed-only by design, spec Entanglement 1).
1039
+ if [ "${VERIFY_DIFF_RESOLVED:-false}" != "true" ] || [ "${VERIFY_DIFF_FILES:-0}" -eq 0 ]; then
1040
+ local _skip_reason="no resolvable change set vs base"
1041
+ [ "${VERIFY_DIFF_RESOLVED:-false}" = "true" ] && _skip_reason="empty diff vs base (nothing to verify)"
1042
+ _verify_log "$_skip_reason; skipping gates (verdict -> CONCERNS)"
1043
+ local _g
1044
+ for _g in build tests static_analysis secret_scan dependency_audit; do
1045
+ _verify_add_gate "$_g" "skipped" "" "$_skip_reason" "true"
1046
+ done
1047
+ else
1048
+ verify_gate_build "$tree"
1049
+ verify_gate_tests "$tree"
1050
+ verify_gate_static "$tree"
1051
+ verify_gate_secret_scan "$tree"
1052
+ verify_gate_dependency_audit "$tree"
1053
+ fi
1054
+
1055
+ verify_compute_verdict "$block_on"
1056
+
1057
+ completed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
1058
+
1059
+ verify_emit_evidence "$out_dir" "$started_at" "$completed_at" "$block_on" >/dev/null || {
1060
+ _verify_err "failed to emit evidence document"
1061
+ return $VERIFY_EXIT_ERROR
1062
+ }
1063
+
1064
+ printf 'VERDICT: %s\n' "$VERIFY_VERDICT"
1065
+ printf 'Evidence: %s/evidence.json\n' "$out_dir"
1066
+ printf 'Report: %s/report.md\n' "$out_dir"
1067
+
1068
+ return "$VERIFY_EXIT"
1069
+ }
1070
+
1071
+ # Allow direct execution: bash autonomy/verify.sh [args]
1072
+ if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
1073
+ verify_main "$@"
1074
+ exit $?
1075
+ fi