loki-mode 9.54.2 → 9.55.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.
package/README.md CHANGED
@@ -748,7 +748,7 @@ env vars. See [Enterprise Identity Roadmap](docs/ENTERPRISE-IDENTITY-ROADMAP.md)
748
748
  | 8 quality gates | Yes | No | No | No |
749
749
  | Blind code review | Yes | No | No | No |
750
750
  | Enterprise auth (OIDC token + scoped RBAC) | Yes | No | Yes | No |
751
- | Air-gapped deployment | Yes | No | No | No |
751
+ | Air-gapped deployment | Verification and the build pipeline, yes; builds need a model you host ([details](docs/air-gapped.md)) | No | No | No |
752
752
  | Docker + CI/CD generation | Yes | No | Yes | No |
753
753
  | Source-available (BUSL-1.1) | Yes | No | No | No |
754
754
  | Free tier | Source-available | Yes | Yes | Yes |
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v9.54.2
6
+ # Loki Mode v9.55.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -470,4 +470,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
470
470
 
471
471
  ---
472
472
 
473
- **v9.54.2 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
473
+ **v9.55.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 9.54.2
1
+ 9.55.0
@@ -219,13 +219,22 @@ council_augment_from_managed_memory() {
219
219
  return 0
220
220
  fi
221
221
  local target_dir="${TARGET_DIR:-.}"
222
- local project_dir="${PROJECT_DIR:-$(pwd)}"
222
+ # D7: never fall back to the cwd. The council runs with cwd inside the
223
+ # agent's own repo, which can ship its own memory/managed_memory package
224
+ # that shadows the real one -- `${PROJECT_DIR:-$(pwd)}` would `cd` into
225
+ # that repo and `python3 -m memory.managed_memory.retrieve` would import
226
+ # the agent's module (BACKLOG 63). PROJECT_DIR must be explicit; unset ->
227
+ # skip augmentation (silent no-op, same as the flags-off path above).
228
+ if [ -z "${PROJECT_DIR:-}" ]; then
229
+ return 0
230
+ fi
231
+ local project_dir="$PROJECT_DIR"
223
232
  local out_file="$target_dir/.loki/managed/council-augment.txt"
224
233
  mkdir -p "$target_dir/.loki/managed" 2>/dev/null || true
225
234
  (
226
235
  cd "$project_dir" 2>/dev/null && \
227
236
  LOKI_TARGET_DIR="$target_dir" \
228
- timeout 5 python3 -m memory.managed_memory.retrieve \
237
+ timeout 5 python3 -E -m memory.managed_memory.retrieve \
229
238
  --query "completion-council verdict context" --top-k 3 \
230
239
  > "$out_file" 2>/dev/null || true
231
240
  ) || true
@@ -1961,19 +1970,44 @@ if failed_n is None:
1961
1970
  # (node --test on a *.test.js with no test() calls; jest --passWithNoTests with
1962
1971
  # no suites) records pass:"inconclusive" + status:"no_tests_run" -- a mini
1963
1972
  # fake-green when read as affirmative. The pass value is then the STRING
1964
- # "inconclusive" (not the bool True/False), so the old else-branch (PASS) would
1965
- # have counted it as green. Route it to INCONCLUSIVE (pass-through, never a
1966
- # block): a real runner ran but proved nothing, exactly like runner=="none".
1967
- # Only the boolean True passes as affirmative; only the boolean False blocks.
1968
- if runner == 'none':
1973
+ # "inconclusive" (not the bool True/False), so an else-branch of plain PASS
1974
+ # would have counted it as green.
1975
+ #
1976
+ # BACKLOG 33 rework (S-07, take 2): status=='no_tests_run' MUST be checked
1977
+ # BEFORE the runner=='none' sentinel below. A zero-test record can carry
1978
+ # runner=='none' (explicit or omitted/defaulted), and the old order let
1979
+ # runner=='none' short-circuit first, printing PASS -- a real runner having
1980
+ # run and found nothing is a WORSE signal than no test tooling applying here
1981
+ # at all, and must never read as equal-or-better. ZEROTESTS is a distinct
1982
+ # verdict token (not reused INCONCLUSIVE) so the shell dispatch below can
1983
+ # route it to reason=no_tests_executed for EVERY runner value, without
1984
+ # colliding with the parse-failure sentinel INCONCLUSIVE:none:true above or
1985
+ # the generic NO_PASS case.
1986
+ #
1987
+ # runner=='none' below is now UNCONDITIONAL again (this is the fix for the
1988
+ # prior rework's regression): the ONLY real writer of runner:"none" is
1989
+ # run.sh's enforce_test_coverage's no-test-tooling branch, which always
1990
+ # writes pass:"inconclusive" (the STRING) with status:"not_run" -- never a
1991
+ # boolean pass:true. Narrowing this branch to require passed is True (as the
1992
+ # prior, unmerged rework did) therefore caught the ENTIRE real no-test-tooling
1993
+ # population and routed it to NO_PASS/no_pass_recorded instead of
1994
+ # PASS/no_test_runner, silently defeating LOKI_EVIDENCE_NO_TESTS_AFFIRMATIVE=1
1995
+ # for the real shape and dropping the heuristic council's vote on genuine
1996
+ # no-test-tooling projects. runner=='none' is a distinct dimension (no
1997
+ # suite applies here) from status=='no_tests_run' (a suite ran and executed
1998
+ # nothing); checking status first is what keeps the two separate, not a
1999
+ # pass-value narrowing on top of it.
2000
+ if status == 'no_tests_run':
2001
+ print('ZEROTESTS:%s:true' % runner)
2002
+ elif runner == 'none':
1969
2003
  print('PASS:none:true')
1970
2004
  elif passed is False or (failed_n or 0) > 0:
1971
2005
  print('FAIL:%s:false' % runner)
1972
- elif status == 'no_tests_run' or passed is not True:
1973
- # Both are INCONCLUSIVE; NO_PASS names the second: no boolean pass was
1974
- # recorded at all (missing, null, or a non-boolean value), which is not a
1975
- # zero-test run.
1976
- print('%s:%s:true' % ('INCONCLUSIVE' if status == 'no_tests_run' else 'NO_PASS', runner))
2006
+ elif passed is not True:
2007
+ # No boolean pass was recorded at all (missing, null, or a non-boolean
2008
+ # value): not a zero-test run (status check above already excluded that),
2009
+ # just an unrecorded outcome.
2010
+ print('NO_PASS:%s:true' % runner)
1977
2011
  else:
1978
2012
  print('PASS:%s:true' % runner)
1979
2013
  PYEOF
@@ -1987,20 +2021,25 @@ PYEOF
1987
2021
  fi
1988
2022
  # INCONCLUSIVE => test_fails stays "false" => pass-through.
1989
2023
  # No test suite ran: a present results file that records runner=="none"
1990
- # is not affirmative evidence. Route to council (inconclusive), not a
1991
- # silent diff-alone pass. Default-on; LOKI_EVIDENCE_NO_TESTS_AFFIRMATIVE=1
1992
- # restores the old affirmative-PASS behavior.
1993
- if [ "$test_runner" = "none" ] && [ "${LOKI_EVIDENCE_NO_TESTS_AFFIRMATIVE:-0}" != "1" ]; then
2024
+ # is not affirmative evidence UNLESS it is also the ZEROTESTS shape
2025
+ # (excluded below; that reason is more specific: a suite ran and found
2026
+ # nothing, distinct from no test tooling existing at all). Route to
2027
+ # council (inconclusive), not a silent diff-alone pass. Default-on;
2028
+ # LOKI_EVIDENCE_NO_TESTS_AFFIRMATIVE=1 restores the old affirmative-PASS
2029
+ # behavior for this branch only -- never for ZEROTESTS below.
2030
+ if [ "$test_runner" = "none" ] && [ "$_verdict" != "ZEROTESTS" ] && [ "${LOKI_EVIDENCE_NO_TESTS_AFFIRMATIVE:-0}" != "1" ]; then
1994
2031
  test_inconclusive="true"
1995
2032
  test_inconclusive_reason="no_test_runner"
1996
2033
  fi
1997
- # #82: a real runner that executed ZERO tests (pass:"inconclusive" +
1998
- # status:"no_tests_run") is INCONCLUSIVE, not affirmative. Mirror the
1999
- # runner=="none" pass-through so it routes to the council instead of
2000
- # reading as green. Honest (not a block): test_fails stays "false".
2034
+ # #82: ZERO tests executed (pass:"inconclusive" + status:"no_tests_run")
2035
+ # is INCONCLUSIVE, not affirmative, for EVERY runner value including
2036
+ # none/omitted (BACKLOG 33 rework, take 2: the ZEROTESTS token from the
2037
+ # parser above already isolates this case from the runner=="none"
2038
+ # no-test-tooling sentinel, so this branch no longer needs to exclude
2039
+ # runner=="none" -- doing so was the take-1 bug this rework fixes).
2001
2040
  # Not gated by LOKI_EVIDENCE_NO_TESTS_AFFIRMATIVE -- a zero-test run is
2002
2041
  # never affirmative evidence regardless of that opt-out.
2003
- if [ "$_verdict" = "INCONCLUSIVE" ] && [ "$test_runner" != "none" ]; then
2042
+ if [ "$_verdict" = "ZEROTESTS" ]; then
2004
2043
  test_inconclusive="true"
2005
2044
  test_inconclusive_reason="no_tests_executed"
2006
2045
  fi
@@ -2791,6 +2830,17 @@ print(json.dumps(items[:5]))
2791
2830
  if [ "$persist_fails" = "true" ]; then persist_ok="false"; else persist_ok="true"; fi
2792
2831
  if [ "$auth_fails" = "true" ]; then auth_ok="false"; else auth_ok="true"; fi
2793
2832
  if [ "$authz_fails" = "true" ]; then authz_ok="false"; else authz_ok="true"; fi
2833
+ # BACKLOG 61: mirror the _write_evidence_details (BACKLOG 55) fix here.
2834
+ # $test_pass defaults to "true" and stays "true" for every inconclusive
2835
+ # outcome (no runner, no results file, zero tests, no pass recorded), so
2836
+ # writing it raw lets tests.pass read true when this block was triggered
2837
+ # by a DIFFERENT axis (e.g. empty_diff) while tests never actually ran.
2838
+ local tests_pass_json='"inconclusive"'
2839
+ if [ "$test_fails" = "true" ]; then
2840
+ tests_pass_json="false"
2841
+ elif [ "$test_inconclusive" != "true" ] && [ "$test_runner" != "none" ] && [ "$test_pass" = "true" ]; then
2842
+ tests_pass_json="true"
2843
+ fi
2794
2844
  # Record WHY boot was inconclusive (no_app_runner / not_serveable / etc.) so a
2795
2845
  # consumer of the block report can tell a genuine boot pass from a pass-through.
2796
2846
  boot_reason_json=$(_R="${boot_inconclusive_reason:-}" python3 -E -s -c "import sys; sys.path[:] = [p for p in sys.path if p not in ('', '.')]; import json,os; print(json.dumps(os.environ['_R']))" 2>/dev/null || echo '""')
@@ -2808,7 +2858,7 @@ print(json.dumps(items[:5]))
2808
2858
  "reason": "$reason",
2809
2859
  "checks": {
2810
2860
  "diff": {"ok": $diff_ok, "base_sha": "$base_for_json", "files_changed": $diff_files, "sources": "committed|unstaged|staged|untracked union"},
2811
- "tests": {"ok": $tests_ok, "runner": "$test_runner", "pass": $test_pass},
2861
+ "tests": {"ok": $tests_ok, "runner": "$test_runner", "pass": $tests_pass_json},
2812
2862
  "boot": {"ok": $boot_ok, "inconclusive": $boot_inconclusive, "reason": $boot_reason_json},
2813
2863
  "secret": {"ok": $secret_ok},
2814
2864
  "nomock": {"ok": $nomock_ok, "inconclusive": $nomock_inconclusive, "reason": $nomock_reason_json},
@@ -3595,14 +3645,26 @@ council_evaluate_member() {
3595
3645
  # fragile log grep. The completion route guarantees this file is written
3596
3646
  # before the council votes via ensure_completion_test_evidence()
3597
3647
  # (autonomy/run.sh): a project with a real runner records its true PASS/FAIL,
3598
- # and a project with no runner records {"runner":"none","pass":true}. A
3599
- # greenfield run with an empty .loki/ has NO such file -> no positive base ->
3600
- # the member stays CONTINUE.
3648
+ # and a project with no test tooling records the shape run.sh's
3649
+ # enforce_test_coverage's no-runner branch actually writes:
3650
+ # {"runner":"none","pass":"inconclusive","status":"not_run",...} -- the pass
3651
+ # value is the STRING "inconclusive", never the boolean true. A greenfield
3652
+ # run with an empty .loki/ has NO such file -> no positive base -> the
3653
+ # member stays CONTINUE.
3601
3654
  #
3602
- # Parse verdict mirrors council_evidence_gate: runner=="none" => PASS,
3603
- # pass is False => FAIL, only a boolean True (and not status no_tests_run)
3604
- # => PASS; a missing, null or non-boolean pass key recorded no outcome =>
3605
- # INCONCLUSIVE (not red, not positive). Unparseable/missing file => absent.
3655
+ # Parse verdict mirrors council_evidence_gate (BACKLOG 33 rework, take 2):
3656
+ # status=='no_tests_run' is checked FIRST (a real runner ran and executed
3657
+ # ZERO tests, #82) and is always inconclusive, whatever the runner field --
3658
+ # this must be decided before runner=='none' below, or a zero-test record
3659
+ # with runner omitted/'none' would short-circuit into the no-tooling
3660
+ # sentinel and print an affirmative pass, exactly the ordering bug already
3661
+ # fixed in council_evidence_gate. runner=='none' is UNCONDITIONALLY pass
3662
+ # (the no-test-tooling sentinel: no suite applies here at all, a distinct
3663
+ # and BETTER signal than a suite that ran and found nothing) -- it is not
3664
+ # narrowed to a boolean pass:true, because the real writer never produces
3665
+ # that shape. passed is False => FAIL; a missing, null or non-boolean pass
3666
+ # key recorded no outcome => INCONCLUSIVE (not red, not positive).
3667
+ # Unparseable/missing file => absent.
3606
3668
  local tr_file="$loki_dir/quality/test-results.json"
3607
3669
  local test_evidence="absent" # absent | pass | fail | inconclusive
3608
3670
  local test_runner_seen="none"
@@ -3618,11 +3680,14 @@ except (json.JSONDecodeError, IOError, KeyError, ValueError):
3618
3680
  sys.exit(0)
3619
3681
  runner = d.get('runner', 'none')
3620
3682
  passed = d.get('pass')
3621
- if runner == 'none':
3683
+ status = d.get('status', '')
3684
+ if status == 'no_tests_run':
3685
+ print('inconclusive:%s' % runner)
3686
+ elif runner == 'none':
3622
3687
  print('pass:none')
3623
3688
  elif passed is False:
3624
3689
  print('fail:%s' % runner)
3625
- elif passed is not True or d.get('status') == 'no_tests_run':
3690
+ elif passed is not True:
3626
3691
  print('inconclusive:%s' % runner)
3627
3692
  else:
3628
3693
  print('pass:%s' % runner)
@@ -4623,8 +4688,19 @@ council_should_stop() {
4623
4688
  # v6.83.0 Phase 1: shadow-write the final council verdict to the
4624
4689
  # managed memory store. Backgrounded + silent; flags gate the work
4625
4690
  # inside the Python module so no-op when off.
4691
+ #
4692
+ # D7: never fall back to the cwd. Identical threat model to
4693
+ # council_augment_from_managed_memory (BACKLOG 63) but more severe:
4694
+ # this call EXECUTES memory.managed_memory.shadow_write as a module,
4695
+ # not just imports one for reading, so `cd "${PROJECT_DIR:-$(pwd)}"`
4696
+ # with PROJECT_DIR unset runs the agent's own repo's shadow_write.py
4697
+ # (if it ships one) with the council's privileges -- arbitrary code
4698
+ # execution, not just data poisoning. PROJECT_DIR must be explicit;
4699
+ # unset -> skip the shadow-write entirely (silent no-op, same as the
4700
+ # flags-off path above; STOP still returns 0 unchanged).
4626
4701
  if [ "${LOKI_MANAGED_AGENTS:-false}" = "true" ] && \
4627
- [ "${LOKI_MANAGED_MEMORY:-false}" = "true" ]; then
4702
+ [ "${LOKI_MANAGED_MEMORY:-false}" = "true" ] && \
4703
+ [ -n "${PROJECT_DIR:-}" ]; then
4628
4704
  local _verdict_file="$loki_dir/council/verdicts/iteration-$ITERATION_COUNT.json"
4629
4705
  if [ ! -f "$_verdict_file" ]; then
4630
4706
  # Fall back to the round vote file as the verdict payload.
@@ -4632,9 +4708,9 @@ council_should_stop() {
4632
4708
  fi
4633
4709
  if [ -f "$_verdict_file" ]; then
4634
4710
  (
4635
- cd "${PROJECT_DIR:-$(pwd)}" 2>/dev/null && \
4711
+ cd "$PROJECT_DIR" 2>/dev/null && \
4636
4712
  LOKI_TARGET_DIR="$loki_dir/.." \
4637
- timeout 15 python3 -m memory.managed_memory.shadow_write \
4713
+ timeout 15 python3 -E -m memory.managed_memory.shadow_write \
4638
4714
  --verdict "$_verdict_file" >/dev/null 2>&1 || true
4639
4715
  ) &
4640
4716
  disown 2>/dev/null || true
package/autonomy/crash.sh CHANGED
@@ -5,11 +5,11 @@
5
5
  # controls BOTH PostHog usage telemetry and local crash capture. It is
6
6
  # sourceable for helpers only; it executes nothing on source.
7
7
  #
8
- # Collection is OPT-IN and OFF by default. Nothing is collected, written, or
9
- # sent unless the user explicitly opts in. A default install cannot phone home,
10
- # which makes air-gapped, GDPR, and FedRAMP deployments safe out of the box.
8
+ # Collection is ON by default for an individual interactive install and
9
+ # auto-off in CI, non-interactive, LOKI_ENTERPRISE=true and LOKI_AIRGAP=true
10
+ # contexts (see loki_collection_enabled below and docs/PRIVACY.md).
11
11
  #
12
- # Opt-in (collection is enabled ONLY when one of these is present):
12
+ # Explicit opt-in (forces collection on, even where it would auto-off):
13
13
  # - LOKI_TELEMETRY=on (case-insensitive, exact word "on")
14
14
  # - ~/.loki/config line: TELEMETRY_ENABLED=true (written by: loki telemetry on)
15
15
  #
@@ -20,8 +20,8 @@
20
20
  # - ~/.loki/config line: TELEMETRY_DISABLED=true
21
21
  #
22
22
  # All capture is best-effort: it never blocks the parent and always returns 0.
23
- # Phase 0 has zero network egress; local capture is also gated by opt-in so a
24
- # default install writes nothing at all.
23
+ # Phase 0 crash capture has zero network egress; local capture uses the same
24
+ # gate as telemetry.
25
25
 
26
26
  # Double-source guard.
27
27
  if [ -n "${_LOKI_CRASH_SH_SOURCED:-}" ]; then
@@ -100,6 +100,13 @@ _loki_done_recog_invoke() {
100
100
  # fall through to the claude/deterministic paths (fail-closed)
101
101
  fi
102
102
 
103
+ # Only the provider the operator chose may see a prompt (V10 P5). The gate
104
+ # above admits any provider with an argv seam, but this call is claude-only.
105
+ # Policy (and the LOKI_ALLOW_CLAUDE_SIDECALLS=1 opt-in) lives in loader.sh.
106
+ # ponytail: upgrade path is routing this call through provider_invoke_argv.
107
+ type loki_claude_sidecall_allowed >/dev/null 2>&1 \
108
+ || . "$(dirname "${BASH_SOURCE[0]}")/../../providers/loader.sh" 2>/dev/null
109
+ loki_claude_sidecall_allowed 2>/dev/null || return 1
103
110
  command -v claude >/dev/null 2>&1 || return 1
104
111
 
105
112
  # STRUCTURED VERDICT (v8.x): when the CLI supports --json-schema, force valid
@@ -76,6 +76,13 @@ _loki_prd_enrich_invoke() {
76
76
  fi
77
77
  # fall through to the claude path (fail-closed)
78
78
  fi
79
+ # Only the provider the operator chose may see a prompt (V10 P5). The gate
80
+ # above admits any provider with an argv seam, but this call is claude-only.
81
+ # Policy (and the LOKI_ALLOW_CLAUDE_SIDECALLS=1 opt-in) lives in loader.sh.
82
+ # ponytail: upgrade path is routing this call through provider_invoke_argv.
83
+ type loki_claude_sidecall_allowed >/dev/null 2>&1 \
84
+ || . "$(dirname "${BASH_SOURCE[0]}")/../../providers/loader.sh" 2>/dev/null
85
+ loki_claude_sidecall_allowed 2>/dev/null || return 1
79
86
  command -v claude >/dev/null 2>&1 || return 1
80
87
  local rc=0
81
88
  local out=""
@@ -59,7 +59,10 @@ Schema compatibility:
59
59
  CLI:
60
60
  python3 autonomy/lib/proof-verify.py <proof.json> [repo_dir]
61
61
  Prints the JSON result. Exit 0 if ok, 1 on tamper / drift / bad signature,
62
- 2 on a usage / load error (missing file, malformed JSON).
62
+ 2 on a usage / load error (missing file, malformed JSON) OR when every check
63
+ that ran passed but drift could not be re-derived (not a git tree, no
64
+ base_sha, an unresolvable base ref) -- an inability to measure drift is not
65
+ evidence of tampering, so it must not share exit 1 with a genuine failure.
63
66
  """
64
67
 
65
68
  import hashlib
@@ -791,9 +794,10 @@ def verify(proof_path, repo_dir="."):
791
794
  if not _is_git_repo(repo_dir):
792
795
  result["diff_drift"] = None
793
796
  if not result["reason"]:
794
- result["reason"] = "repo_dir is not a git work tree; drift unverifiable"
797
+ result["reason"] = ("could not check drift: repo_dir is not a git "
798
+ "work tree; drift unverifiable")
795
799
  result["reasons"].append(
796
- "drift unverifiable: %r is not a git work tree, so the recorded "
800
+ "could not check drift: %r is not a git work tree, so the recorded "
797
801
  "diff cannot be re-derived (re-run from the repository the receipt "
798
802
  "was generated in)" % repo_dir)
799
803
  elif not base_sha:
@@ -801,19 +805,22 @@ def verify(proof_path, repo_dir="."):
801
805
  # so the diff cannot be re-derived. Report honestly, do NOT pass.
802
806
  result["diff_drift"] = None
803
807
  if not result["reason"]:
804
- result["reason"] = "base ref unresolvable (no recorded base_sha; drift unverifiable)"
808
+ result["reason"] = ("could not check drift: base ref unresolvable "
809
+ "(no recorded base_sha; drift unverifiable)")
805
810
  result["reasons"].append(
806
- "drift unverifiable: the receipt records no base_sha, so there is "
807
- "no starting point to re-derive the diff from (schema v1.0 receipt)")
811
+ "could not check drift: the receipt records no base_sha, so there "
812
+ "is no starting point to re-derive the diff from (schema v1.0 "
813
+ "receipt)")
808
814
  elif not _rev_resolvable(repo_dir, base_sha):
809
815
  result["diff_drift"] = None
810
816
  if not result["reason"]:
811
- result["reason"] = ("base ref unresolvable (%s not found in repo; "
812
- "drift unverifiable)" % base_sha)
817
+ result["reason"] = ("could not check drift: base ref unresolvable "
818
+ "(%s not found in repo; drift unverifiable)"
819
+ % base_sha)
813
820
  result["reasons"].append(
814
- "drift unverifiable: recorded base ref %s is not present in this "
815
- "repository (fetch the branch, or verify against the repo the "
816
- "receipt was generated in)" % base_sha)
821
+ "could not check drift: recorded base ref %s is not present in "
822
+ "this repository (fetch the branch, or verify against the repo "
823
+ "the receipt was generated in)" % base_sha)
817
824
  else:
818
825
  # Drift answers "does this receipt still describe the CURRENT branch
819
826
  # state". A receipt is for verifying the work as it stands now, so we
@@ -829,10 +836,11 @@ def verify(proof_path, repo_dir="."):
829
836
  if current_stat is None:
830
837
  result["diff_drift"] = None
831
838
  if not result["reason"]:
832
- result["reason"] = "git diff could not be computed; drift unverifiable"
839
+ result["reason"] = ("could not check drift: git diff could not "
840
+ "be computed; drift unverifiable")
833
841
  result["reasons"].append(
834
- "drift unverifiable: git diff %s..HEAD could not be computed"
835
- % base_sha)
842
+ "could not check drift: git diff %s..HEAD could not be "
843
+ "computed" % base_sha)
836
844
  else:
837
845
  drift = False
838
846
  if recorded_stat is not None:
@@ -846,12 +854,13 @@ def verify(proof_path, repo_dir="."):
846
854
  # compare against -- cannot confirm the facts match.
847
855
  result["diff_drift"] = None
848
856
  if not result["reason"]:
849
- result["reason"] = ("no recorded diff stat to compare; "
850
- "drift unverifiable")
857
+ result["reason"] = ("could not check drift: no recorded "
858
+ "diff stat to compare; drift "
859
+ "unverifiable")
851
860
  result["reasons"].append(
852
- "drift unverifiable: the repository diff was re-derived, "
853
- "but the receipt recorded no diff stat to compare it "
854
- "against")
861
+ "could not check drift: the repository diff was "
862
+ "re-derived, but the receipt recorded no diff stat to "
863
+ "compare it against")
855
864
 
856
865
  # diff_sha256: a stronger content check than the counts. Only when
857
866
  # the receipt recorded one (v1.1).
@@ -945,13 +954,70 @@ def verify(proof_path, repo_dir="."):
945
954
  # CLI shim (mirrors dashboard/audit.py _unified_cli style)
946
955
  # ---------------------------------------------------------------------------
947
956
 
957
+ def drift_unverifiable(result):
958
+ """True iff every check that ran passed and drift alone could not be
959
+ re-derived (not a git tree, no base_sha, an unresolvable base ref, or the
960
+ diff/tree could not be recomputed) -- the exit-2 "could not check" case
961
+ from docs/exit-codes.md, distinct from a genuine tamper/drift failure.
962
+
963
+ Fail-safe direction: this is an ALLOWLIST, not a check on `diff_drift is
964
+ None` alone. `diff_drift` (and `tree_drift`) can be None for reasons OTHER
965
+ than "unmeasurable" -- e.g. an early return on a missing/mismatched
966
+ integrity hash never reaches the drift block at all, leaving diff_drift at
967
+ its initial None while the receipt is genuinely tampered. Requiring every
968
+ OTHER check to have passed before honoring a None here keeps a real
969
+ failure from being laundered into "could not check". Any check added
970
+ later that is not explicitly listed here defaults to exit 1, never 2.
971
+
972
+ Known ceiling (not a new regression): this allowlist only covers a
973
+ genuinely unmeasurable check. A forger who re-hashes the receipt after
974
+ stripping base_sha, tree_sha256, and the diff stat produces a receipt
975
+ that passes hash_ok and every other listed check, then reaches this
976
+ function with the same unresolvable-drift signature as an honest
977
+ outside-the-repo verification -- so it also exits 2 ("could not check"),
978
+ not 1 ("failed"). `ok` stays False either way, so nothing here reports
979
+ VERIFIED; the ceiling is only that a deliberately stripped receipt and an
980
+ honestly unverifiable one are not distinguished from each other.
981
+ """
982
+ if result.get("ok"):
983
+ return False
984
+ if not result.get("hash_ok"):
985
+ return False
986
+ if result.get("gpg_ok") not in (True, "n/a"):
987
+ return False
988
+ if result.get("headline_consistent") is False:
989
+ return False
990
+ if result.get("cost_coherent") is False:
991
+ return False
992
+ if result.get("schema_supported") is not True:
993
+ return False
994
+ if result.get("diff_drift") is True:
995
+ return False
996
+ if result.get("tree_drift") is True:
997
+ return False
998
+ # tree_drift is None also means "no tree was recorded to check" (not
999
+ # applicable), which is not itself a reason to report exit 2 -- only count
1000
+ # it as unmeasured when a tree digest was actually recorded.
1001
+ tree_unmeasured = (
1002
+ bool(result.get("tree_recheck", {}).get("recorded"))
1003
+ and result.get("tree_drift") is None
1004
+ )
1005
+ return result.get("diff_drift") is None or tree_unmeasured
1006
+
1007
+
948
1008
  def render_reasons(result):
949
1009
  """Render a verdict as human-readable lines.
950
1010
 
951
1011
  The JSON report is the machine surface; this is the one a person reads.
952
1012
  A passing receipt renders the verdict alone -- never a fabricated reason.
953
1013
  """
954
- lines = ["VERIFIED" if result.get("ok") else "FAILED"]
1014
+ if result.get("ok"):
1015
+ header = "VERIFIED"
1016
+ elif drift_unverifiable(result):
1017
+ header = "COULD NOT CHECK"
1018
+ else:
1019
+ header = "FAILED"
1020
+ lines = [header]
955
1021
  for reason in (result.get("reasons") or []):
956
1022
  lines.append(" - %s" % reason)
957
1023
  return "\n".join(lines)
@@ -984,7 +1050,13 @@ def _cli(argv=None):
984
1050
  print(json.dumps({"ok": False, "error": "verify failed: %s" % exc}))
985
1051
  return 2
986
1052
  print(render_reasons(result) if human else json.dumps(result, indent=2))
987
- return 0 if result.get("ok") else 1
1053
+ if result.get("ok"):
1054
+ return 0
1055
+ if drift_unverifiable(result):
1056
+ # An inability to measure drift is not evidence of tampering (BACKLOG
1057
+ # 31 / docs/exit-codes.md: 2 = could not check, never 1 = failed).
1058
+ return 2
1059
+ return 1
988
1060
 
989
1061
 
990
1062
  if __name__ == "__main__":
@@ -309,6 +309,13 @@ loki_council_dispatch_agents() {
309
309
  # 3. Invoke claude (only when the SDK path did not already produce a response).
310
310
  # Guard against absent binary or non-zero exit.
311
311
  if [ "$_va_sdk_done" != "1" ]; then
312
+ # Only the provider the operator chose may see a prompt (V10 P5): with any
313
+ # other provider this falls back to the heuristic council. Policy (and the
314
+ # LOKI_ALLOW_CLAUDE_SIDECALLS=1 opt-in) lives in providers/loader.sh.
315
+ # ponytail: upgrade path is a provider-neutral structured dispatch.
316
+ type loki_claude_sidecall_allowed >/dev/null 2>&1 \
317
+ || . "${__LOKI_VA_REPO_ROOT}/providers/loader.sh" 2>/dev/null
318
+ loki_claude_sidecall_allowed 2>/dev/null || return 1
312
319
  command -v claude >/dev/null 2>&1 || return 1
313
320
 
314
321
  # caveman HARD-SUPPRESS (parsed output, v7.41.0): the response is parsed for