loki-mode 7.49.0 → 7.51.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.
@@ -29,6 +29,28 @@
29
29
  # prompt-injected, and surfaced in proof-of-done. LOKI_ASSUMPTIONS_REQUIRE_CONFIRM=1
30
30
  # disables auto-ack for a human-in-the-loop path (only confirmed=true clears).
31
31
  #
32
+ # Design note (P2-4 contradictions -- the exception to the lifecycle above):
33
+ # A GAP can become a recorded assumption: "the spec is silent, so I took the
34
+ # implementer default." A CONTRADICTION cannot -- there is no default that
35
+ # satisfies "X and not-X". So a contradiction (class=contradictory, forced to
36
+ # severity=high) is NEVER auto-acknowledged, even in default autonomous mode
37
+ # (spec_ledger_acknowledge_all skips it). It stays acknowledged=false and keeps
38
+ # the completion gate BLOCKED until a human sets confirmed=true. We chose this
39
+ # "block, do not assume away" behavior over the softer "auto-ack but surface
40
+ # loudly" option because a silently-acked contradiction lets "done" be declared
41
+ # over a spec we know is internally inconsistent, which is exactly the
42
+ # accuracy failure this feature exists to prevent. Honest cost: in a fully
43
+ # autonomous run an unresolved contradiction grinds the loop to max-iterations
44
+ # (no human to confirm), wasting budget. It is STILL surfaced in proof-of-done
45
+ # on every terminal path. Escape hatches: a human sets confirmed=true in
46
+ # .loki/assumptions/, or the operator sets LOKI_ASSUMPTION_GATE=0. A follow-up
47
+ # in run.sh (out of this module's scope) should add an early hard-stop on an
48
+ # unresolved contradiction so the run fails fast instead of grinding.
49
+ # Contradictions come from two sources: spec-INTERNAL (grill finds them, the
50
+ # classifier tags them) and spec-EXTERNAL (spec_interrogation_external_check
51
+ # compares the spec to the repo's declared dependencies; narrow + high-
52
+ # confidence by design).
53
+ #
32
54
  # Provider-aware + clean degrade: grill needs a provider CLI; when absent we log
33
55
  # an honest message, skip the grill subcall (NO fabricated questions), but STILL
34
56
  # fold prd-analyzer's deterministic missing-dimension assumptions into the ledger
@@ -38,6 +60,7 @@
38
60
  # LOKI_SPEC_GRILL=0 skip interrogation entirely
39
61
  # LOKI_ASSUMPTION_GATE=0 completion gate is pass-through (gate file)
40
62
  # LOKI_ASSUMPTIONS_REQUIRE_CONFIRM=1 require human confirmed=true (no auto-ack)
63
+ # LOKI_SPEC_EXTERNAL_CHECK=0 skip the spec-vs-repo external contradiction check
41
64
 
42
65
  set -uo pipefail
43
66
 
@@ -81,6 +104,17 @@ spec_interrogation_severity_for() {
81
104
  printf 'high'; return 0 ;;
82
105
  esac
83
106
 
107
+ # P2-4: a "Contradictions" SECTION escalates to high even when the line has
108
+ # no contradiction keyword. Keyword-only detection would silently miss a
109
+ # contradiction phrased plainly ("section 2 mandates immutable records;
110
+ # section 5 specifies an edit endpoint."), which is exactly the failure P2-4
111
+ # exists to prevent -- and the grill-prompt follow-up that adds a
112
+ # "Contradictions" section would emit such keyword-free findings.
113
+ case "$lc_section" in
114
+ *contradiction*|*contradictor*)
115
+ printf 'high'; return 0 ;;
116
+ esac
117
+
84
118
  # Section-driven severity.
85
119
  case "$lc_section" in
86
120
  *security*|*scale*|*reliability*)
@@ -113,6 +147,13 @@ spec_interrogation_class_for() {
113
147
  printf 'contradictory'; return 0 ;;
114
148
  esac
115
149
 
150
+ # P2-4: a "Contradictions" SECTION tags its findings contradictory even when
151
+ # the line carries no contradiction keyword (see severity_for for rationale).
152
+ case "$lc_section" in
153
+ *contradiction*|*contradictor*)
154
+ printf 'contradictory'; return 0 ;;
155
+ esac
156
+
116
157
  case "$lc_section" in
117
158
  *security*|*scale*|*reliability*) printf 'missing'; return 0 ;;
118
159
  *unstated\ assumption*) printf 'underspecified'; return 0 ;;
@@ -249,9 +290,19 @@ spec_interrogation_classify_report() {
249
290
  # Trim leading/trailing whitespace.
250
291
  stripped="$(printf '%s' "$q" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
251
292
  [ -z "$stripped" ] && continue
252
- # Skip explicit "None identified." placeholders (no fabricated findings).
253
- case "$stripped" in
254
- "None identified"*|"None."*|"None"|"N/A"*) continue ;;
293
+ # Skip negative-result / clean-spec lines so a grill that honestly
294
+ # reports "nothing found" never becomes a persisted finding (and, under
295
+ # "### Contradictions", never deadlocks a clean spec to max-iterations).
296
+ # Match a lowercased copy (bash 3.2 has no ${var,,}); write the original.
297
+ # Patterns are START-anchored to whole-line negative phrasings so a real
298
+ # finding that merely contains "no" (e.g. "no input validation on the
299
+ # login endpoint") is NOT skipped.
300
+ local stripped_lc
301
+ stripped_lc="$(printf '%s' "$stripped" | tr '[:upper:]' '[:lower:]')"
302
+ case "$stripped_lc" in
303
+ "none"|"none."*|"none found"*|"none identified"*|\
304
+ "no contradiction"*|"no issues"*|"no conflicts"*|"no problems"*|\
305
+ "no concerns"*|"no gaps"*|"not applicable"*|"n/a"*) continue ;;
255
306
  esac
256
307
 
257
308
  local sev class affects assumption
@@ -260,7 +311,20 @@ spec_interrogation_classify_report() {
260
311
  affects="$(_spec_affects_for "$section")"
261
312
  # No-fabrication: the finding is a QUESTION; the honest assumption is a
262
313
  # stated default, NOT an invented resolution the build will not follow.
263
- assumption="Spec gives no answer; proceeding with the implementer default for ${affects}."
314
+ # P2-4: a CONTRADICTION is special. A gap can become a recorded
315
+ # assumption (a stated default), but a contradiction CANNOT be assumed
316
+ # away -- there is no consistent default for "X and not-X". So a
317
+ # contradictory finding carries a contradiction-specific message instead
318
+ # of the implementer-default text. This keeps spec_ledger_prompt_block
319
+ # honest (it would otherwise instruct the build agent to "proceed with a
320
+ # default" for something that has no consistent default) and makes the
321
+ # ledger rollup state the truth: unresolvable by assumption, needs a
322
+ # human.
323
+ if [ "$class" = "contradictory" ]; then
324
+ assumption="UNRESOLVED CONTRADICTION: the spec is internally inconsistent here and cannot be assumed away; a human must resolve it before this can be built correctly."
325
+ else
326
+ assumption="Spec gives no answer; proceeding with the implementer default for ${affects}."
327
+ fi
264
328
 
265
329
  spec_ledger_write \
266
330
  "$stripped" \
@@ -274,6 +338,144 @@ spec_interrogation_classify_report() {
274
338
  return 0
275
339
  }
276
340
 
341
+ # ---------------------------------------------------------------------------
342
+ # P2-4 EXTERNAL contradiction check: spec vs existing code/config.
343
+ #
344
+ # A spec-internal contradiction is found by the grill (LLM) and recognized by
345
+ # the classifier above. An EXTERNAL contradiction is the spec disagreeing with
346
+ # the repo it is being built into (spec says Postgres, repo wired to Mongo).
347
+ #
348
+ # Design constraint -- PRECISION OVER RECALL (deliberate):
349
+ # A contradiction is tagged high + contradictory and (per the auto-ack skip
350
+ # above) BLOCKS completion until a human resolves it. In an autonomous run no
351
+ # human is present, so a false positive grinds a GOOD spec to max-iterations.
352
+ # That is a severe failure mode. A grep heuristic is far lower-confidence than
353
+ # the LLM-identified internal contradictions feeding the same blocking path, so
354
+ # this check fires ONLY on UNAMBIGUOUS POSITIVE-CONFLICT evidence and is happy
355
+ # to miss real conflicts (low recall) rather than ever block a clean spec.
356
+ #
357
+ # Scope of THIS slice: database-engine conflict only -- the single signal where
358
+ # "what the spec names" and "what the repo is wired to" are both concretely
359
+ # detectable from declared dependencies. The trigger requires ALL FOUR:
360
+ # 1. the spec explicitly names database engine X, AND
361
+ # 2. a manifest (package.json / requirements.txt / go.mod / pyproject.toml /
362
+ # Gemfile) declares a concrete driver for a DIFFERENT engine Y, AND
363
+ # 3. that same manifest declares NO driver for engine X, AND
364
+ # 4. the spec does NOT also name engine Y (if it names BOTH, the spec is
365
+ # discussing the choice -- "we chose Mongo over Postgres" -- not in
366
+ # conflict; skip rather than false-fire on a good spec).
367
+ # Bare prose substring matches in source files are intentionally NOT used (specs
368
+ # mention databases in passing; comments and migrations carry both drivers).
369
+ #
370
+ # Honest deferral: other external conflicts (REST-vs-gRPC, language/runtime,
371
+ # cloud provider) are NOT implemented here. They need a higher-confidence
372
+ # extractor than a single grep and are documented as the harder follow-up.
373
+ #
374
+ # Usage: spec_interrogation_external_check <spec_path>
375
+ # Best-effort; writes at most one contradiction ledger entry; never fails a run.
376
+ # ---------------------------------------------------------------------------
377
+ spec_interrogation_external_check() {
378
+ local spec_path="${1:-}"
379
+ [ -n "$spec_path" ] && [ -f "$spec_path" ] || return 0
380
+ [ "${LOKI_SPEC_EXTERNAL_CHECK:-1}" = "0" ] && return 0
381
+
382
+ local repo_root="${TARGET_DIR:-.}"
383
+
384
+ # Collect declared-dependency manifest text (declarations only, not prose).
385
+ local manifests="" m
386
+ for m in \
387
+ "$repo_root/package.json" \
388
+ "$repo_root/requirements.txt" \
389
+ "$repo_root/pyproject.toml" \
390
+ "$repo_root/go.mod" \
391
+ "$repo_root/Gemfile"; do
392
+ [ -f "$m" ] && manifests="$manifests $m"
393
+ done
394
+ # No declared dependencies => no concrete repo signal => nothing to conflict.
395
+ [ -n "$manifests" ] || return 0
396
+
397
+ # Lowercase the spec body and the manifest text once.
398
+ local spec_lc deps_lc
399
+ spec_lc="$(tr '[:upper:]' '[:lower:]' < "$spec_path" 2>/dev/null)"
400
+ # shellcheck disable=SC2086 # word-split of the manifest path list is intended
401
+ deps_lc="$(cat $manifests 2>/dev/null | tr '[:upper:]' '[:lower:]')"
402
+ [ -n "$spec_lc" ] && [ -n "$deps_lc" ] || return 0
403
+
404
+ # Engine -> concrete driver-dependency token (a dependency name, not prose).
405
+ # Keys are the engine names we look for in the SPEC; values are the package
406
+ # tokens that prove the repo is wired to that engine.
407
+ _spec_db_driver_token() {
408
+ case "$1" in
409
+ postgres) printf '%s' 'pg|psycopg|postgresql|asyncpg|node-postgres|sequelize-postgres|gorm.io/driver/postgres' ;;
410
+ mongodb) printf '%s' 'mongoose|pymongo|mongodb|motor|go.mongodb.org/mongo-driver' ;;
411
+ mysql) printf '%s' 'mysql|mysql2|pymysql|mysqlclient|gorm.io/driver/mysql' ;;
412
+ *) printf '' ;;
413
+ esac
414
+ }
415
+ # Does the spec name engine $1? Match unambiguous engine names only.
416
+ _spec_names_engine() {
417
+ case "$1" in
418
+ postgres) printf '%s' 'postgres\|postgresql' ;;
419
+ mongodb) printf '%s' 'mongodb\|mongo db\|mongo database' ;;
420
+ mysql) printf '%s' 'mysql' ;;
421
+ *) printf '' ;;
422
+ esac
423
+ }
424
+
425
+ local engines="postgres mongodb mysql"
426
+ local spec_engine other_engine
427
+ for spec_engine in $engines; do
428
+ local spec_pat
429
+ spec_pat="$(_spec_names_engine "$spec_engine")"
430
+ [ -n "$spec_pat" ] || continue
431
+ printf '%s' "$spec_lc" | grep -q -e "$spec_pat" || continue
432
+
433
+ # Spec names this engine. Is the repo wired to a DIFFERENT one, with no
434
+ # driver for the spec's engine?
435
+ local spec_engine_token
436
+ spec_engine_token="$(_spec_db_driver_token "$spec_engine")"
437
+ # If the repo DOES declare a driver for the spec's engine, there is no
438
+ # conflict (they agree) -- skip.
439
+ if printf '%s' "$deps_lc" | grep -E -q -- "$spec_engine_token"; then
440
+ continue
441
+ fi
442
+
443
+ for other_engine in $engines; do
444
+ [ "$other_engine" = "$spec_engine" ] && continue
445
+ # PRECISION guard: if the spec ALSO names other_engine, this is not an
446
+ # unambiguous conflict -- the spec is discussing both engines (e.g.
447
+ # "we chose MongoDB over PostgreSQL"). Skip rather than false-fire on
448
+ # a good spec, since a false contradiction would block it to max-iter.
449
+ local other_spec_pat
450
+ other_spec_pat="$(_spec_names_engine "$other_engine")"
451
+ if [ -n "$other_spec_pat" ] && printf '%s' "$spec_lc" | grep -q -e "$other_spec_pat"; then
452
+ continue
453
+ fi
454
+ local other_token
455
+ other_token="$(_spec_db_driver_token "$other_engine")"
456
+ [ -n "$other_token" ] || continue
457
+ if printf '%s' "$deps_lc" | grep -E -q -- "$other_token"; then
458
+ # UNAMBIGUOUS: spec names X, repo declares a Y driver, repo has
459
+ # no X driver. Record one high/contradictory external finding.
460
+ local gap assumption
461
+ gap="External contradiction: the spec specifies the ${spec_engine} database, but this repository declares a ${other_engine} driver dependency and no ${spec_engine} driver."
462
+ assumption="UNRESOLVED CONTRADICTION: the spec and the existing code disagree on the database engine and this cannot be assumed away; a human must reconcile the spec with the repo before building."
463
+ spec_ledger_write \
464
+ "$gap" \
465
+ "$assumption" \
466
+ "external: spec vs repo dependencies" \
467
+ "high" \
468
+ "contradictory" \
469
+ "data-store" \
470
+ "external-check"
471
+ # One database-engine conflict is enough to block; stop scanning.
472
+ return 0
473
+ fi
474
+ done
475
+ done
476
+ return 0
477
+ }
478
+
277
479
  # ---------------------------------------------------------------------------
278
480
  # Fold prd-analyzer's deterministic missing-dimension assumptions into the
279
481
  # ledger as medium (non-blocking). Reads .loki/prd-observations.md "Assumptions
@@ -371,6 +573,16 @@ print("%d %d" % (total, high))
371
573
  # Auto-acknowledgment lifecycle helper: set acknowledged=true on every ledger
372
574
  # entry. run.sh calls this once an iteration AFTER assumptions are injected into
373
575
  # the build prompt (unless LOKI_ASSUMPTIONS_REQUIRE_CONFIRM=1). Best-effort.
576
+ #
577
+ # P2-4 EXCEPTION: class=contradictory entries are NEVER auto-acknowledged. A gap
578
+ # can be assumed away (auto-ack records that the implementer-default was taken),
579
+ # but a contradiction is unresolvable by assumption -- there is no default that
580
+ # satisfies "X and not-X". Auto-acknowledging it would silently clear the
581
+ # completion gate and let "done" be declared over an internally inconsistent
582
+ # spec. So a contradiction stays acknowledged=false until a human confirms a
583
+ # resolution (sets confirmed=true). This is deliberately the same teeth the
584
+ # LOKI_ASSUMPTIONS_REQUIRE_CONFIRM=1 path applies to ALL entries, scoped here to
585
+ # just contradictions in default autonomous mode. See the design note below.
374
586
  # ---------------------------------------------------------------------------
375
587
  spec_ledger_acknowledge_all() {
376
588
  [ "${LOKI_ASSUMPTIONS_REQUIRE_CONFIRM:-0}" = "1" ] && return 0
@@ -388,6 +600,9 @@ for p in glob.glob(os.path.join(d, "a-*.json")):
388
600
  continue
389
601
  if r.get("acknowledged"):
390
602
  continue
603
+ # P2-4: a contradiction cannot be assumed away, so it is never auto-acked.
604
+ if r.get("class") == "contradictory":
605
+ continue
391
606
  r["acknowledged"] = True
392
607
  fd, tmp = tempfile.mkstemp(dir=os.path.dirname(p), suffix=".tmp")
393
608
  try:
@@ -531,6 +746,11 @@ spec_interrogation_run() {
531
746
  # (works with no provider) so degrade still surfaces something.
532
747
  spec_ledger_fold_prd_observations || true
533
748
 
749
+ # P2-4: best-effort EXTERNAL contradiction check (spec vs repo deps). Runs
750
+ # with no provider (it is pure file inspection) and is intentionally narrow
751
+ # (high-confidence DB-engine conflict only) so it never blocks a clean spec.
752
+ spec_interrogation_external_check "$spec_path" || true
753
+
534
754
  spec_ledger_rebuild_md || true
535
755
 
536
756
  local counts total high
package/autonomy/spec.sh CHANGED
@@ -470,12 +470,17 @@ PYEOF
470
470
  # drift check quietly and, on drift, emits ONE SPEC_DRIFT record to stdout in
471
471
  # the verify finding TSV shape:
472
472
  # severity \t category \t source \t file \t line \t message
473
- # Severity is Medium (-> CONCERNS, per the task). Graceful no-op (prints
474
- # nothing, returns 0) when there is no lock or the spec cannot be resolved.
473
+ # Severity is High (-> BLOCKED under verify's default --block-on critical,high).
474
+ # A spec lock is an explicit human declaration that "this spec is the contract"
475
+ # (loki spec lock / sync), so once it exists, real drift is a blocking gate, not
476
+ # a soft concern. Graceful no-op (prints nothing, returns 0) when there is no
477
+ # lock or the spec cannot be resolved, so an unlocked / first-run workflow is
478
+ # never blocked.
475
479
  #
476
480
  # This function is intentionally side-effect-light for the verify caller: it
477
- # still writes the drift-report.json (a useful artifact) but never blocks and
478
- # never prints to the verify human channel.
481
+ # still writes the drift-report.json (a useful artifact) and never prints to the
482
+ # verify human channel; the BLOCK is delivered purely via the High finding the
483
+ # verify verdict logic consumes.
479
484
  # ---------------------------------------------------------------------------
480
485
  spec_verify_hook() {
481
486
  local out_dir="${1:-$SPEC_DIR_DEFAULT}"
@@ -485,16 +490,17 @@ spec_verify_hook() {
485
490
  local spec_path
486
491
  # Prefer the spec path recorded in the lock; fall back to resolution.
487
492
  spec_path="$(python3 -c 'import sys,json; print(json.load(open(sys.argv[1])).get("spec_path",""))' "$lock_file" 2>/dev/null || echo "")"
488
- # MEDIUM-4: the lock recorded a spec path but that file is now MISSING (the
489
- # locked spec was deleted). That is real drift -- the contract the lock binds
490
- # no longer exists -- so emit a Medium spec_drift finding instead of silently
491
- # returning 0. NEVER fall back to spec_resolve_source here: comparing against
492
- # a different candidate file would mask the deletion and attest a spec that is
493
- # not the locked one. The empty-spec_path case below is a SEPARATE, legitimate
494
- # fallback (legacy locks that never recorded a path).
493
+ # The lock recorded a spec path but that file is now MISSING (the locked spec
494
+ # was deleted). That is real drift -- the contract the lock binds no longer
495
+ # exists -- so emit a High spec_drift finding (blocking, consistent with the
496
+ # content-drift finding below) instead of silently returning 0. NEVER fall
497
+ # back to spec_resolve_source here: comparing against a different candidate
498
+ # file would mask the deletion and attest a spec that is not the locked one.
499
+ # The empty-spec_path case below is a SEPARATE, legitimate fallback (legacy
500
+ # locks that never recorded a path).
495
501
  if [ -n "$spec_path" ] && [ ! -f "$spec_path" ]; then
496
502
  printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
497
- "Medium" "spec_drift" "deterministic:loki-spec" "$spec_path" "null" \
503
+ "High" "spec_drift" "deterministic:loki-spec" "$spec_path" "null" \
498
504
  "locked spec file missing: $spec_path (the spec is the contract; restore it or run 'loki spec sync' after review to re-lock against the current spec)"
499
505
  return 0
500
506
  fi
@@ -523,9 +529,10 @@ PYEOF
523
529
  )"
524
530
  [ -n "$summary" ] || return 0
525
531
 
526
- # Emit one Medium SPEC_DRIFT finding in the verify TSV shape.
532
+ # Emit one High SPEC_DRIFT finding in the verify TSV shape (blocking under
533
+ # verify's default --block-on critical,high; only reachable when a lock exists).
527
534
  printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
528
- "Medium" "spec_drift" "deterministic:loki-spec" "$spec_path" "null" "$summary"
535
+ "High" "spec_drift" "deterministic:loki-spec" "$spec_path" "null" "$summary"
529
536
  return 0
530
537
  }
531
538
 
@@ -572,8 +579,10 @@ EXIT CODES:
572
579
 
573
580
  VERIFY INTEGRATION:
574
581
  When .loki/spec/spec.lock exists, `loki verify` runs the drift check and
575
- adds a Medium-severity SPEC_DRIFT finding on drift, which maps to a CONCERNS
576
- verdict. No lock = graceful no-op.
582
+ adds a High-severity SPEC_DRIFT finding on drift, which BLOCKS verify under
583
+ its default --block-on critical,high. Locking is an explicit declaration
584
+ that the spec is the contract, so post-lock drift is a hard gate. No lock =
585
+ graceful no-op (gate skipped, never blocks an unlocked / first-run workflow).
577
586
 
578
587
  EOF
579
588
  }
@@ -453,11 +453,104 @@ verify_gate_static() {
453
453
  }
454
454
 
455
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.
456
+ # Gate: secret scan (NET-NEW).
457
+ #
458
+ # WHAT IS SCANNED:
459
+ # Only the files in the PR diff (merge-base(base,HEAD)..HEAD), i.e. the net
460
+ # change being verified. Pre-existing secrets in untouched files are NOT
461
+ # scanned by design: a verifier attests that THE CHANGE is safe, and blocking
462
+ # on legacy secrets is the #1 false-positive-fatigue failure (spec 7.2).
463
+ # Binary files are skipped.
464
+ #
465
+ # WHEN IT RUNS:
466
+ # As a gate inside `loki verify`, after generation/edits and BEFORE the change
467
+ # is treated as VERIFIED. A real secret here forces a non-VERIFIED verdict.
468
+ #
469
+ # GITLEAKS vs FALLBACK:
470
+ # - If `gitleaks` is on PATH, it scans each changed file (filesystem mode,
471
+ # `detect --no-git --source`). Any hit -> Critical finding for that file.
472
+ # - If gitleaks is NOT installed, a documented two-tier regex fallback runs
473
+ # (see verify_secret_scan_file). The fallback is a deterministic safety net,
474
+ # not a replacement for a full scanner; gitleaks is recommended for depth.
475
+ #
476
+ # BLOCKING GUARANTEE:
477
+ # Every detected secret is emitted as a Critical finding. The default
478
+ # --block-on is "critical,high" (verify_main), so verify_compute_verdict sets
479
+ # verdict=BLOCKED and exit=2 (VERIFY_EXIT_BLOCKED). The scan BLOCKS, it does
480
+ # not merely warn. (Operators who pass a narrower --block-on accept the risk.)
481
+ #
482
+ # FOLLOW-UP (deferred, honest): a true pre-WRITE hook that scans generated bytes
483
+ # BEFORE they touch disk would be stronger still, but it must hook every write
484
+ # path in run.sh (out of scope for this verify.sh slice). The gate here is the
485
+ # strong post-generation / pre-completion scan; the pre-write hook is tracked
486
+ # as a follow-up and is NOT claimed to exist.
458
487
  #
459
488
  # skipped = no changed files to scan.
460
489
  # ---------------------------------------------------------------------------
490
+
491
+ # Two-tier regex secret matcher for a single file. Echoes nothing; returns 0 if
492
+ # a high-confidence secret is found, 1 otherwise. Used ONLY by the fallback path
493
+ # (gitleaks absent). Kept as a standalone function so the test suite can drive it
494
+ # and so the two tiers are documented in one place.
495
+ #
496
+ # TIER 1 -- specific credential FORMATS. A match is conclusive on its own: the
497
+ # string already looks exactly like a real credential, so we do NOT apply the
498
+ # placeholder filter (a leaked key is a leak even if a comment says EXAMPLE).
499
+ #
500
+ # TIER 2 -- generic long quoted-value assignments (api_key="...", bearer
501
+ # tokens). This is a length + charset heuristic (>=16 contiguous secret-charset
502
+ # chars), NOT a true Shannon-entropy measure. These are false-positive magnets,
503
+ # so a match is only counted if the matched LINE survives the placeholder /
504
+ # env-reference deny filter. That keeps obvious non-secrets (your-api-key-here,
505
+ # REDACTED, ${API_KEY}, process.env.X) clean.
506
+ verify_secret_scan_file() {
507
+ local file="$1"
508
+
509
+ # TIER 1: specific formats. No deny filter -- a format match is a finding.
510
+ local tier1=(
511
+ 'AKIA[0-9A-Z]{16}' # AWS access key id
512
+ 'ASIA[0-9A-Z]{16}' # AWS temporary (STS) key id
513
+ '-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----' # PEM private key block
514
+ 'gh[pousr]_[A-Za-z0-9]{36,}' # GitHub token (ghp_/gho_/...)
515
+ 'github_pat_[A-Za-z0-9_]{60,}' # GitHub fine-grained PAT
516
+ 'xox[baprs]-[A-Za-z0-9-]{10,}' # Slack token (xoxb-/xoxp-/...)
517
+ 'sk-[A-Za-z0-9]{20,}' # OpenAI-style secret key
518
+ 'AIza[0-9A-Za-z_-]{35}' # Google API key
519
+ 'glpat-[A-Za-z0-9_-]{20,}' # GitLab personal access token
520
+ )
521
+ local p
522
+ for p in "${tier1[@]}"; do
523
+ # -e terminates option parsing so patterns beginning with '-' (the PEM
524
+ # "-----BEGIN ... PRIVATE KEY-----" block) are not mistaken for a flag.
525
+ if LC_ALL=C grep -Eq -e "$p" "$file" 2>/dev/null; then
526
+ return 0
527
+ fi
528
+ done
529
+
530
+ # Deny filter for TIER 2: a matched line is IGNORED if it is plainly a
531
+ # placeholder or an environment-variable reference rather than a literal.
532
+ # - env refs: ${VAR}, $VAR, process.env.X, os.environ/os.getenv, %VAR%
533
+ # - placeholders: your-/your_..., redacted, changeme, placeholder, example,
534
+ # dummy/sample/fake, xxxx+, <...>, runs of 4+ asterisks
535
+ # ("test" is deliberately NOT denied -- too common, would mask real keys)
536
+ local deny='(\$\{|\$[A-Za-z_]|process\.env|os\.(environ|getenv)|%[A-Za-z_]+%|your[-_]|redacted|changeme|change[-_]me|placeholder|example|dummy|sample|fake|<[^>]*>|x{4,}|\*{4,})'
537
+
538
+ # TIER 2: generic assignments. Grab matching lines, drop denied ones, count.
539
+ # api_key / apikey / secret / token / password / passwd / access_key, an
540
+ # assignment operator (= or :), then a quoted >=16-char high-entropy value.
541
+ local tier2='(api[_-]?key|secret|token|password|passwd|access[_-]?key|client[_-]?secret|auth)[A-Za-z0-9_]*[[:space:]]*[:=][[:space:]]*["'"'"']?[A-Za-z0-9_/+.=-]{16,}'
542
+ # Bearer tokens: "Bearer <>=20 high-entropy chars>".
543
+ local bearer='[Bb]earer[[:space:]]+[A-Za-z0-9_.\-]{20,}'
544
+
545
+ local surviving
546
+ surviving="$(LC_ALL=C grep -EiI "$tier2|$bearer" "$file" 2>/dev/null \
547
+ | LC_ALL=C grep -Eiv "$deny" 2>/dev/null)"
548
+ if [ -n "$surviving" ]; then
549
+ return 0
550
+ fi
551
+ return 1
552
+ }
553
+
461
554
  verify_gate_secret_scan() {
462
555
  local tree="$1"
463
556
  local changed="$VERIFY_DIFF_NAMES"
@@ -504,17 +597,10 @@ verify_gate_secret_scan() {
504
597
  return 0
505
598
  fi
506
599
 
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
-
600
+ # High-confidence two-tier matcher (verify_secret_scan_file): tier 1 specific
601
+ # credential formats flag unconditionally; tier 2 generic assignments flag
602
+ # only when the line survives the placeholder/env-ref deny filter. Conservative
603
+ # on purpose to limit false positives (spec Section 7.2).
518
604
  local hits=0
519
605
  local detail=""
520
606
  local f
@@ -523,16 +609,12 @@ verify_gate_secret_scan() {
523
609
  [ -f "$tree/$f" ] || continue
524
610
  # Skip obvious binaries.
525
611
  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
612
+ if verify_secret_scan_file "$tree/$f"; then
613
+ hits=$((hits + 1))
614
+ detail="${detail}${f} "
615
+ _verify_add_finding "Critical" "security" "deterministic:regex-secret-scan" "$f" "null" \
616
+ "Potential hardcoded secret detected in $f (matched a high-confidence credential pattern)."
617
+ fi
536
618
  done <<<"$changed"
537
619
 
538
620
  if [ "$hits" -eq 0 ]; then
@@ -975,9 +1057,9 @@ EOF
975
1057
  # Living-spec drift gate (integration with autonomy/spec.sh).
976
1058
  #
977
1059
  # When .loki/spec/spec.lock exists, source the spec module and run its
978
- # verify hook, which emits at most one SPEC_DRIFT finding (Medium -> CONCERNS)
979
- # in the verify finding TSV shape. Records a gate row either way so the
980
- # evidence document shows the spec was checked. Fully graceful: no lock, no
1060
+ # verify hook, which emits at most one SPEC_DRIFT finding (High -> BLOCKED when
1061
+ # the spec is locked) in the verify finding TSV shape. Records a gate row either
1062
+ # way so the evidence document shows the spec was checked. Fully graceful: no lock, no
981
1063
  # module, or a hook error all degrade to a skipped gate and never abort verify.
982
1064
  # ---------------------------------------------------------------------------
983
1065
  verify_spec_drift_gate() {
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.49.0"
10
+ __version__ = "7.51.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try: