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/autonomy/loki CHANGED
@@ -778,6 +778,30 @@ loki_remote_fetch_receipt() {
778
778
  # UNSIGNED there would be a false statement about the receipt (it IS signed),
779
779
  # and saying VERIFIED would be a false statement about this machine (it checked
780
780
  # nothing). It gets its own line and never claims verification.
781
+ # Resolve a tool name to an absolute path, rejecting any PATH entry that is
782
+ # not itself absolute. A bare "" component in PATH (what "export PATH=x:$PATH"
783
+ # on an unset variable leaves, or an accidental "PATH=":$PATH"") is treated by
784
+ # the shell as the current directory, and so is "."; but ANY other relative
785
+ # entry (a bare "bin", "./tools", "../x") resolves against the verifier's cwd
786
+ # just as unsafely, so all of them are rejected the same way, not just those
787
+ # two literal strings. An unscoped `command -v python3`/`gpg`/`git` on a verify
788
+ # path can otherwise resolve to a committed shim sitting in the checkout being
789
+ # verified. Prints nothing (never a relative name) when no real binary is on
790
+ # a real (absolute) PATH entry, so callers fail closed the same way
791
+ # `command -v` failing does.
792
+ loki_verify_tool_path() {
793
+ local name="$1" dir
794
+ local IFS=:
795
+ for dir in $PATH; do
796
+ case "$dir" in
797
+ /*) ;;
798
+ *) continue ;;
799
+ esac
800
+ [ -x "$dir/$name" ] && [ ! -d "$dir/$name" ] && { printf '%s\n' "$dir/$name"; return 0; }
801
+ done
802
+ return 1
803
+ }
804
+
781
805
  # Ask gpg WHY a signature failed. Echoes "nopubkey" when the signing key is
782
806
  # absent (uncheckable), "" otherwise (a genuinely bad signature, or we could
783
807
  # not tell). Fails toward "" so an unclear answer keeps the loud TAMPERED
@@ -829,6 +853,12 @@ loki_remote_attestation_status() {
829
853
  [ -n "$url" ] || { printf ''; return 0; }
830
854
  command -v python3 >/dev/null 2>&1 || { printf ''; return 0; }
831
855
  local tok
856
+ # jq's `//` treats JSON false the same as null/missing, which is exactly
857
+ # the semantics wanted here: `attestation: false` reads as "no attestation
858
+ # present" (UNSIGNED/absent), not as a malformed token. Kept as a bash-level
859
+ # short circuit for the unreachable-JWKS-avoidance case; the Python copy
860
+ # below carries the same `is False` guard so the two never diverge if this
861
+ # jq pre-filter is ever removed.
832
862
  tok="$(jq -r '.verification.attestation // empty' "$pj" 2>/dev/null)"
833
863
  [ -n "$tok" ] || { printf ''; return 0; }
834
864
  python3 -E - "$pj" "$url" "${_LOKI_SCRIPT_DIR}" <<'PY' 2>/dev/null || printf ''
@@ -846,7 +876,11 @@ except ImportError:
846
876
  sys.exit(1) # -> "" : cannot check, never a verdict
847
877
  proof = json.load(open(sys.argv[1]))
848
878
  tok = (proof.get("verification") or {}).get("attestation")
849
- if tok is None or tok == "":
879
+ # `attestation: false` is "no attestation present", the same fact as a missing
880
+ # or empty-string field -- never a malformed token. `is False` only, never
881
+ # `== False`, because `0 == False` in Python and a numeric attestation field
882
+ # is a different (still malformed) shape that must still read as TAMPERED.
883
+ if tok is None or tok == "" or tok is False:
850
884
  sys.exit(1)
851
885
  try:
852
886
  with urllib.request.urlopen(
@@ -854,14 +888,17 @@ try:
854
888
  jwks = json.loads(r.read())
855
889
  except Exception:
856
890
  sys.exit(1) # unreachable JWKS is UNCHECKED, not a failure
857
- # A key set that is not {"keys": [{...}, ...]} is a key-set problem: UNCHECKED,
858
- # never an accusation against the receipt. Checked BEFORE anything below can
859
- # print "bad", so a bad key set can never read as TAMPERED.
891
+ # A key set that is not {"keys": [{...}, ...]} -- INCLUDING an empty list -- is
892
+ # a key-set problem: UNCHECKED, never an accusation against the receipt. An
893
+ # empty keyset is an inability to check (no key was published to check
894
+ # against), not evidence of tampering. Checked BEFORE anything below can print
895
+ # "bad", so a bad or empty key set can never read as TAMPERED.
860
896
  _keys = jwks.get("keys") if isinstance(jwks, dict) else None
861
- if not isinstance(_keys, list) or not all(isinstance(k, dict) for k in _keys):
897
+ if not isinstance(_keys, list) or not _keys or not all(isinstance(k, dict) for k in _keys):
862
898
  sys.exit(1)
863
899
  # The receipt's builder controls the token. An attestation that is not a JWT
864
- # string is a malformed receipt: TAMPERED, decided without any crypto.
900
+ # string (and not the "no attestation" `false` handled above) is a malformed
901
+ # receipt: TAMPERED, decided without any crypto.
865
902
  if not isinstance(tok, str):
866
903
  print("bad"); sys.exit(0)
867
904
  # Without `cryptography`, verify_attestation returns False and this would print
@@ -931,7 +968,12 @@ try:
931
968
  except Exception:
932
969
  sys.exit(1)
933
970
  tok = (proof.get("verification") or {}).get("attestation")
934
- if tok is None or tok == "":
971
+ # `attestation: false` is "no attestation present", the same fact as a missing
972
+ # or empty-string field -- never a malformed token. `is False` only, never
973
+ # `== False`, because `0 == False` in Python and a numeric attestation field
974
+ # is a different (still malformed) shape that must still read as FAILED. Same
975
+ # guard in loki_remote_attestation_status above -- change both.
976
+ if tok is None or tok == "" or tok is False:
935
977
  print("none"); sys.exit(0) # a FACT about the receipt, not an error
936
978
  src = sys.argv[2]
937
979
  try:
@@ -946,15 +988,18 @@ try:
946
988
  jwks = json.loads(r.read())
947
989
  except Exception:
948
990
  sys.exit(1) # "" : unreachable/unreadable, no verdict
949
- # A key set that is not {"keys": [{...}, ...]} is a key-set problem: NOT
950
- # CHECKED, never an accusation. Checked BEFORE anything below can print "bad",
951
- # so a bad key set can never read as FAILED. Same order in
952
- # loki_remote_attestation_status above -- change both.
991
+ # A key set that is not {"keys": [{...}, ...]} -- INCLUDING an empty list -- is
992
+ # a key-set problem: NOT CHECKED, never an accusation. An empty keyset is an
993
+ # inability to check (no key was published to check against), not evidence of
994
+ # tampering. Checked BEFORE anything below can print "bad", so a bad or empty
995
+ # key set can never read as FAILED. Same order in loki_remote_attestation_status
996
+ # above -- change both.
953
997
  _keys = jwks.get("keys") if isinstance(jwks, dict) else None
954
- if not isinstance(_keys, list) or not all(isinstance(k, dict) for k in _keys):
998
+ if not isinstance(_keys, list) or not _keys or not all(isinstance(k, dict) for k in _keys):
955
999
  sys.exit(1) # "" : cannot check
956
1000
  # The receipt's builder controls the token. An attestation that is not a JWT
957
- # string is a malformed receipt: FAILED, decided without any crypto.
1001
+ # string (and not the "no attestation" `false` handled above) is a malformed
1002
+ # receipt: FAILED, decided without any crypto.
958
1003
  if not isinstance(tok, str):
959
1004
  print("bad"); sys.exit(0)
960
1005
  # receipt_jwt imports without `cryptography`, and verify_attestation then
@@ -7773,6 +7818,59 @@ PURPLE_LAB_DEFAULT_HOST="127.0.0.1"
7773
7818
  PURPLE_LAB_STATE_DIR="${HOME}/.loki/purple-lab"
7774
7819
  PURPLE_LAB_PID_FILE="${PURPLE_LAB_STATE_DIR}/purple-lab.pid"
7775
7820
 
7821
+ # Is this pid positively identified as THIS installation's Purple Lab server
7822
+ # (cmd_web_start launches exactly `$DASHBOARD_PYTHON $SKILL_DIR/web-app/server.py`
7823
+ # at :8078), rather than a recycled pid, a decoy that merely mentions the path
7824
+ # in an argument, or another installation's server.py at a different SKILL_DIR?
7825
+ #
7826
+ # A substring match on the path is NOT identity: `vim server.py`, `tail -f
7827
+ # server.py`, or `grep server.py x` in the exact web-app dir would all pass a
7828
+ # naive `case "$cmd" in *server.py*)`. This instead tokenizes the full command
7829
+ # line and requires: (1) argv[0] is an (optionally path-qualified) python
7830
+ # interpreter -- covers "python3", "/usr/bin/python3.12", and the macOS
7831
+ # Python.app framework binary; (2) argv[1] is EXACTLY this installation's
7832
+ # absolute web-app/server.py path, not a substring of it; (3) the process is
7833
+ # owned by this user (SKILL_DIR is frequently a shared global install path, so
7834
+ # uid alone rules out a same-path process started by someone else).
7835
+ # Fails closed: an absent/unreadable `ps` result, or anything this cannot
7836
+ # positively parse, returns "not a match" -- never signal on ambiguity.
7837
+ _loki_pid_looks_like_purplelab() {
7838
+ local _p="$1" _cmd _want _my_uid _p_uid
7839
+ case "$_p" in ''|*[!0-9]*) return 1 ;; esac
7840
+ [ "$_p" -gt 1 ] 2>/dev/null || return 1
7841
+ _want="${SKILL_DIR}/web-app/server.py"
7842
+ [ -f "$_want" ] || return 1
7843
+ _cmd="$(ps -o command= -p "$_p" 2>/dev/null)"
7844
+ [ -n "$_cmd" ] || return 1
7845
+ _my_uid="$(id -u 2>/dev/null)" || return 1
7846
+ _p_uid="$(ps -o uid= -p "$_p" 2>/dev/null | tr -d ' ')"
7847
+ [ -n "$_p_uid" ] && [ "$_p_uid" = "$_my_uid" ] || return 1
7848
+ # Tokenize without glob expansion or command substitution (bash 3.2 safe):
7849
+ # `set -f` in this function's own subshell-free scope would leak, so do it
7850
+ # in a nested subshell and echo the verdict back.
7851
+ (
7852
+ set -f
7853
+ # shellcheck disable=SC2086
7854
+ set -- $_cmd
7855
+ argv0="${1:-}"
7856
+ argv1="${2:-}"
7857
+ # Match the BASENAME only. The prior "*/[Pp]ython*|[Pp]ython*" glob
7858
+ # matched the WHOLE string against "any chars, then /, then
7859
+ # python-substring, then any chars" -- not "the final path segment
7860
+ # starts with python" -- so /opt/python-tools/bin/tail passed. Not
7861
+ # currently exploitable on its own (argv1 exact-match + uid still
7862
+ # gate it), but tightened per council review: a real interpreter's
7863
+ # basename must itself start with [Pp]ython, e.g. "python3",
7864
+ # "python3.12", or a full path ending in one of those.
7865
+ case "${argv0##*/}" in
7866
+ [Pp]ython*) ;;
7867
+ *) exit 1 ;;
7868
+ esac
7869
+ [ "$argv1" = "$_want" ] || exit 1
7870
+ exit 0
7871
+ )
7872
+ }
7873
+
7776
7874
  cmd_web() {
7777
7875
  local subcommand="${1:-start}"
7778
7876
 
@@ -8044,25 +8142,35 @@ cmd_web_start() {
8044
8142
  fi
8045
8143
  fi
8046
8144
 
8047
- # Check if port is already in use -- try to clean up orphaned processes
8145
+ # Check if port is already in use -- try to clean up orphaned processes.
8146
+ # Only kill it if it is positively identified as THIS installation's own
8147
+ # Purple Lab server (never a bare "it's listening on our port" guess): an
8148
+ # unrelated process bound to the same port, or another installation's
8149
+ # server, must never be signaled.
8048
8150
  if lsof -i:"$port" -sTCP:LISTEN &> /dev/null; then
8049
8151
  local blocking_pid
8050
8152
  blocking_pid=$(lsof -ti:"$port" -sTCP:LISTEN 2>/dev/null | head -1)
8051
8153
  if [ -n "$blocking_pid" ]; then
8052
- echo -e "${YELLOW}Port $port in use by PID $blocking_pid -- stopping it...${NC}"
8053
- kill "$blocking_pid" 2>/dev/null
8054
- sleep 1
8055
- if kill -0 "$blocking_pid" 2>/dev/null; then
8056
- kill -9 "$blocking_pid" 2>/dev/null
8154
+ if _loki_pid_looks_like_purplelab "$blocking_pid"; then
8155
+ echo -e "${YELLOW}Port $port in use by PID $blocking_pid -- stopping it...${NC}"
8156
+ kill "$blocking_pid" 2>/dev/null
8057
8157
  sleep 1
8058
- fi
8059
- # Verify port is now free
8060
- if lsof -i:"$port" -sTCP:LISTEN &> /dev/null; then
8061
- echo -e "${RED}Error: Could not free port $port${NC}"
8062
- echo "Use --port to specify a different port, or manually kill the process."
8158
+ if kill -0 "$blocking_pid" 2>/dev/null; then
8159
+ kill -9 "$blocking_pid" 2>/dev/null
8160
+ sleep 1
8161
+ fi
8162
+ # Verify port is now free
8163
+ if lsof -i:"$port" -sTCP:LISTEN &> /dev/null; then
8164
+ echo -e "${RED}Error: Could not free port $port${NC}"
8165
+ echo "Use --port to specify a different port, or manually kill the process."
8166
+ exit 1
8167
+ fi
8168
+ echo -e "${GREEN}Port $port freed.${NC}"
8169
+ else
8170
+ echo -e "${RED}Error: Port $port is in use by PID $blocking_pid, which is not this installation's Purple Lab server.${NC}"
8171
+ echo "Use --port to specify a different port, or manually stop that process."
8063
8172
  exit 1
8064
8173
  fi
8065
- echo -e "${GREEN}Port $port freed.${NC}"
8066
8174
  fi
8067
8175
  fi
8068
8176
 
@@ -8149,7 +8257,11 @@ cmd_web_stop() {
8149
8257
  curl -s --max-time 3 -X POST "http://127.0.0.1:${port}/api/session/stop" >/dev/null 2>&1 || true
8150
8258
  sleep 1
8151
8259
 
8152
- # Try PID file first (check global location, then local)
8260
+ # Try PID file first (check global location, then local). Identity is
8261
+ # verified with _loki_pid_looks_like_purplelab (anchored interpreter +
8262
+ # exact server_py path + uid match), not a loose comm-name substring: a
8263
+ # decoy or another installation's process merely named "python" would
8264
+ # otherwise pass the old check.
8153
8265
  local pid_file="$PURPLE_LAB_PID_FILE"
8154
8266
  if [ ! -f "$pid_file" ]; then
8155
8267
  pid_file="${LOKI_DIR}/purple-lab/purple-lab.pid"
@@ -8158,10 +8270,7 @@ cmd_web_stop() {
8158
8270
  local pid
8159
8271
  pid=$(cat "$pid_file" 2>/dev/null)
8160
8272
  if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
8161
- # Verify the PID belongs to a Purple Lab/Python process (PID recycling guard)
8162
- local pid_cmd
8163
- pid_cmd=$(ps -p "$pid" -o comm= 2>/dev/null || true)
8164
- if [[ "$pid_cmd" == *python* ]] || [[ "$pid_cmd" == *uvicorn* ]] || [[ "$pid_cmd" == *Purple* ]]; then
8273
+ if _loki_pid_looks_like_purplelab "$pid"; then
8165
8274
  kill "$pid" 2>/dev/null
8166
8275
  sleep 1
8167
8276
  if kill -0 "$pid" 2>/dev/null; then
@@ -8170,33 +8279,46 @@ cmd_web_stop() {
8170
8279
  echo -e "${GREEN}Purple Lab stopped (PID: $pid)${NC}"
8171
8280
  stopped=true
8172
8281
  else
8173
- echo -e "${YELLOW}PID $pid is not a Purple Lab process (found: $pid_cmd), skipping${NC}"
8282
+ echo -e "${YELLOW}PID $pid is not this installation's Purple Lab process, skipping${NC}"
8174
8283
  fi
8175
8284
  fi
8176
8285
  rm -f "$PURPLE_LAB_PID_FILE"
8177
8286
  fi
8178
8287
 
8179
- # Also kill any process on the port (catches orphans without PID file)
8288
+ # Also kill any process on the port (catches orphans without PID file).
8289
+ # Same identity check as above: a port/pattern-derived PID must be
8290
+ # positively identified as OURS before it is signaled (D14/D15/D16 class).
8180
8291
  local port_pid
8181
8292
  port_pid=$(lsof -ti:"$port" -sTCP:LISTEN 2>/dev/null | head -1 || true)
8182
8293
  if [ -n "$port_pid" ]; then
8183
- kill "$port_pid" 2>/dev/null
8184
- sleep 1
8185
- if kill -0 "$port_pid" 2>/dev/null; then
8186
- kill -9 "$port_pid" 2>/dev/null
8187
- fi
8188
- if [ "$stopped" = false ]; then
8189
- echo -e "${GREEN}Purple Lab stopped (orphan PID: $port_pid on port $port)${NC}"
8294
+ if _loki_pid_looks_like_purplelab "$port_pid"; then
8295
+ kill "$port_pid" 2>/dev/null
8296
+ sleep 1
8297
+ if kill -0 "$port_pid" 2>/dev/null; then
8298
+ kill -9 "$port_pid" 2>/dev/null
8299
+ fi
8300
+ if [ "$stopped" = false ]; then
8301
+ echo -e "${GREEN}Purple Lab stopped (orphan PID: $port_pid on port $port)${NC}"
8302
+ fi
8303
+ stopped=true
8304
+ else
8305
+ echo -e "${YELLOW}Port $port is held by a process that is not this installation's Purple Lab server, leaving it alone${NC}"
8190
8306
  fi
8191
- stopped=true
8192
8307
  fi
8193
8308
 
8194
- # Also kill any orphan web-app/server.py processes (catches processes from other CWDs)
8309
+ # Also kill any orphan web-app/server.py processes (catches processes from
8310
+ # other CWDs). pgrep -f only ENUMERATES candidates by argv substring; each
8311
+ # candidate must then pass _loki_pid_looks_like_purplelab before being
8312
+ # killed. (The previous cwd-equality check here was dead in practice --
8313
+ # server.py never calls chdir, so its cwd is whatever launched it, not the
8314
+ # web-app dir -- and even when it matched, cwd alone is not identity: an
8315
+ # unrelated python process merely launched FROM that directory would also
8316
+ # pass. argv identity via the shared helper covers both gaps.)
8195
8317
  local orphan_pids
8196
8318
  orphan_pids=$(pgrep -f "web-app/server.py" 2>/dev/null || true)
8197
8319
  if [ -n "$orphan_pids" ]; then
8198
8320
  for opid in $orphan_pids; do
8199
- if kill -0 "$opid" 2>/dev/null; then
8321
+ if kill -0 "$opid" 2>/dev/null && _loki_pid_looks_like_purplelab "$opid"; then
8200
8322
  kill "$opid" 2>/dev/null
8201
8323
  sleep 1
8202
8324
  if kill -0 "$opid" 2>/dev/null; then
@@ -8240,33 +8362,28 @@ cmd_web_stop() {
8240
8362
  rm -f "$pids_file" 2>/dev/null || true
8241
8363
  fi
8242
8364
 
8243
- # Companion cleanup: stop the dashboard server that the web UI runs
8244
- # alongside. Scoped to the dashboard, NOT to every loki process: the
8245
- # documented intent of "loki web stop" is to stop the Purple Lab web UI
8246
- # session, not to reap unrelated builds.
8247
-
8248
- # Kill any dashboard server (by process name and by port)
8249
- local dash_port="${LOKI_DASHBOARD_PORT:-57374}"
8250
- local dash_pids
8251
- dash_pids=$(pgrep -f "dashboard.server\|dashboard/server" 2>/dev/null || true)
8252
- if [ -n "$dash_pids" ]; then
8253
- for dpid in $dash_pids; do
8254
- kill "$dpid" 2>/dev/null || true
8255
- done
8256
- sleep 1
8257
- for dpid in $dash_pids; do
8258
- kill -0 "$dpid" 2>/dev/null && kill -9 "$dpid" 2>/dev/null || true
8259
- done
8260
- echo "Loki Dashboard stopped"
8261
- fi
8262
- # Also kill by port in case pgrep missed it
8263
- local dash_port_pid
8264
- dash_port_pid=$(lsof -ti:"$dash_port" -sTCP:LISTEN 2>/dev/null | head -1 || true)
8265
- if [ -n "$dash_port_pid" ]; then
8266
- kill "$dash_port_pid" 2>/dev/null || true
8267
- sleep 1
8268
- kill -0 "$dash_port_pid" 2>/dev/null && kill -9 "$dash_port_pid" 2>/dev/null || true
8269
- fi
8365
+ # Companion cleanup: the dashboard server that the web UI runs alongside
8366
+ # is already stopped by cmd_dashboard_stop, called before this function by
8367
+ # cmd_web()'s "stop" case -- that path is PID-file-authoritative and is the
8368
+ # single source of truth for "our" dashboard. KNOWN GAP: cmd_dashboard_stop
8369
+ # (this file) verifies only liveness (`kill -0`), not process identity --
8370
+ # unlike autonomy/run.sh's loki_mark_project_stopped_and_maybe_kill_shared_dashboard,
8371
+ # which additionally calls _loki_pid_looks_like_dashboard to guard against a
8372
+ # recycled PID now naming something else. That hardening is not ported
8373
+ # here; out of scope for this fix, noted for a follow-up.
8374
+ #
8375
+ # A former dashboard-argv pgrep/port sweep here was DELETED rather than
8376
+ # hardened: any identity check reachable from argv alone (anchoring on
8377
+ # "-m dashboard.server", the module form cmd_dashboard_start launches) can
8378
+ # only prove "this is A loki dashboard process", never "this is the
8379
+ # dashboard THIS session started" -- a dashboard launched from a different
8380
+ # worktree, a different SKILL_DIR, or a concurrent CI shard passes that
8381
+ # check identically. Re-killing "a" dashboard here duplicates
8382
+ # cmd_dashboard_stop's job with a strictly weaker identity guarantee and
8383
+ # was, before this fix, actually vacuous (the case-statement re-tested the
8384
+ # exact same substrings pgrep had already matched on, and the SIGKILL pass
8385
+ # walked the raw unfiltered pgrep list with no check at all) -- see
8386
+ # docs/v10/DECISIONS.md D14/D15/D16.
8270
8387
 
8271
8388
  # FIX-563 (v7.30.0): do NOT blanket-kill loki-run-* orchestrators here.
8272
8389
  # The prior unscoped `pgrep -f "loki-run-..."` reaped EVERY orchestrator on
@@ -13158,16 +13275,27 @@ _loki_airgap_audit() {
13158
13275
 
13159
13276
  # Provider inference endpoint: the one egress that is genuinely required,
13160
13277
  # unless the active provider serves weights locally.
13161
- local provider="${LOKI_PROVIDER:-claude}"
13278
+ # Judge the provider `loki start` would use, with cmd_start's precedence:
13279
+ # the project's saved choice (.loki/state/provider, `loki provider set`)
13280
+ # wins over LOKI_PROVIDER. Reading only the env var reported a saved claude
13281
+ # project as air-gap ready. A --provider flag at start time is not visible
13282
+ # here; with nothing set, run.sh may auto-detect another installed CLI.
13283
+ local provider="" provider_src="$LOKI_DIR/state/provider"
13284
+ [ -f "$LOKI_DIR/state/provider" ] && provider=$(cat "$LOKI_DIR/state/provider" 2>/dev/null)
13285
+ if [ -z "$provider" ]; then
13286
+ provider_src="LOKI_PROVIDER"
13287
+ provider="${LOKI_PROVIDER:-}"
13288
+ [ -n "$provider" ] || { provider="claude"; provider_src="default"; }
13289
+ fi
13162
13290
  local model_host="" model_required="yes" model_note=""
13163
13291
  case "$provider" in
13164
13292
  claude)
13165
13293
  model_host="${ANTHROPIC_BASE_URL:-https://api.anthropic.com}"
13166
- model_note="Claude Code inference. Set ANTHROPIC_BASE_URL to an in-network gateway, or switch to a local-weights provider."
13294
+ model_note="Claude Code inference. An in-network gateway (ANTHROPIC_BASE_URL) keeps it inside your network but is still counted as required egress here; only a local-weights provider clears it."
13167
13295
  ;;
13168
13296
  codex)
13169
13297
  model_host="${OPENAI_BASE_URL:-https://api.openai.com}"
13170
- model_note="Codex inference. Point at an in-network gateway or switch provider."
13298
+ model_note="Codex inference. An in-network gateway (OPENAI_BASE_URL) is still counted as required egress here; only a local-weights provider clears it."
13171
13299
  ;;
13172
13300
  opencode|cline|aider)
13173
13301
  model_host="${OPENAI_BASE_URL:-${OPENROUTER_API_KEY:+https://openrouter.ai}}"
@@ -13183,10 +13311,35 @@ _loki_airgap_audit() {
13183
13311
  cline) model_var="LOKI_CLINE_MODEL" model_id="${LOKI_CLINE_MODEL:-}" ;;
13184
13312
  aider) model_var="LOKI_AIDER_MODEL" model_id="${LOKI_AIDER_MODEL:-}" ;;
13185
13313
  esac
13186
- if printf '%s' "$model_id" | grep -qiE 'ollama|localhost|127\.0\.0\.1|lmstudio'; then
13187
- model_host="local weights"
13188
- model_required="no"
13189
- model_note="Local model runtime (Ollama / LM Studio / llama.cpp). No inference egress."
13314
+ # An Ollama cloud model (tag ending ":cloud" or "-cloud") runs on
13315
+ # ollama.com through the local daemon (docs.ollama.com/cloud), so
13316
+ # naming ollama is not evidence of local inference regardless of
13317
+ # OLLAMA_HOST.
13318
+ if printf '%s' "$model_id" | grep -qiE '[:-]cloud$'; then
13319
+ model_host="https://ollama.com (Ollama cloud model $model_id)"
13320
+ model_note="Ollama cloud models run on ollama.com. Set $model_var to a local ollama/ model id (no :cloud or -cloud tag)."
13321
+ elif printf '%s' "$model_id" | grep -qiE 'ollama|localhost|127\.0\.0\.1|lmstudio'; then
13322
+ # A bare "ollama"/"lmstudio" in the model id is only local
13323
+ # evidence when the tool actually talks to this host. Ollama
13324
+ # honors OLLAMA_HOST to redirect its client at a remote
13325
+ # server, so a substring match on the model id alone is
13326
+ # forgeable: OLLAMA_HOST=https://remote.example.com with
13327
+ # model id "ollama/..." would egress to that remote host
13328
+ # while still reading as local. Parse OLLAMA_HOST's actual
13329
+ # host and only trust the substring when no override is set
13330
+ # or the override itself points at loopback.
13331
+ local ollama_host_local=1
13332
+ if [ -n "${OLLAMA_HOST:-}" ] && ! loki_remote_is_local_host "$OLLAMA_HOST"; then
13333
+ ollama_host_local=0
13334
+ fi
13335
+ if [ "$ollama_host_local" = "1" ]; then
13336
+ model_host="local weights"
13337
+ model_required="no"
13338
+ model_note="Local model runtime named by $model_var=$model_id. Judged from the model id (and OLLAMA_HOST when set): the endpoint set in the opencode, cline or aider config is not read, so confirm it is on this host or inside your network."
13339
+ else
13340
+ model_host="$OLLAMA_HOST"
13341
+ model_note="OLLAMA_HOST points at a remote host; inference leaves it. Unset OLLAMA_HOST or point it at loopback for zero-egress inference."
13342
+ fi
13190
13343
  else
13191
13344
  [ -n "$model_host" ] || model_host="remote model provider (no local model configured)"
13192
13345
  model_note="Remote model gateway. Set $model_var to an ollama/ model id for zero-egress inference."
@@ -13198,15 +13351,35 @@ _loki_airgap_audit() {
13198
13351
  ;;
13199
13352
  esac
13200
13353
 
13354
+ # LOKI_ALLOW_CLAUDE_SIDECALLS=1 lets engine side-calls prompt the claude
13355
+ # CLI under another provider: inference to Anthropic whatever the model.
13356
+ # Same policy helper the side-call sites use (providers/loader.sh).
13357
+ local sidecall_required="no" sidecall_host="${ANTHROPIC_BASE_URL:-https://api.anthropic.com}"
13358
+ type loki_claude_sidecall_allowed >/dev/null 2>&1 \
13359
+ || . "$_LOKI_SCRIPT_DIR/../providers/loader.sh" 2>/dev/null
13360
+ if [ "$provider" != "claude" ] && command -v claude >/dev/null 2>&1 \
13361
+ && loki_claude_sidecall_allowed "$provider" 2>/dev/null; then
13362
+ sidecall_required="yes"
13363
+ fi
13364
+ local blocked="$model_required"
13365
+ [ "$sidecall_required" = "yes" ] && blocked="yes"
13366
+
13201
13367
  # Optional egress, each individually disableable.
13368
+ # Telemetry: the same gate main() used to send this command's own event.
13369
+ # It is ON by default for an individual interactive install, so a
13370
+ # hardcoded "off" misreported the one egress this audit itself triggers.
13202
13371
  local tel_state="off"
13203
- [ "${LOKI_TELEMETRY:-}" = "on" ] && tel_state="on"
13372
+ if declare -f _loki_telemetry_enabled >/dev/null 2>&1 && _loki_telemetry_enabled; then
13373
+ tel_state="on"
13374
+ fi
13375
+ local tel_host="${LOKI_POSTHOG_HOST:-https://us.i.posthog.com}"
13204
13376
  local upd_state="on"
13205
13377
  [ "${LOKI_NO_UPDATE_CHECK:-0}" = "1" ] && upd_state="off"
13206
13378
 
13207
13379
  if [ "$as_json" = "true" ]; then
13208
- _AG_PROV="$provider" _AG_HOST="$model_host" _AG_REQ="$model_required" \
13209
- _AG_NOTE="$model_note" _AG_TEL="$tel_state" _AG_UPD="$upd_state" \
13380
+ _AG_PROV="$provider" _AG_PSRC="$provider_src" _AG_HOST="$model_host" _AG_REQ="$model_required" \
13381
+ _AG_NOTE="$model_note" _AG_TEL="$tel_state" _AG_TELHOST="$tel_host" _AG_UPD="$upd_state" \
13382
+ _AG_SC="$sidecall_required" _AG_SCHOST="$sidecall_host" \
13210
13383
  python3 -c '
13211
13384
  import json, os
13212
13385
  req = os.environ["_AG_REQ"] == "yes"
@@ -13214,14 +13387,14 @@ egress = [{
13214
13387
  "name": "model_inference",
13215
13388
  "host": os.environ["_AG_HOST"],
13216
13389
  "required": req,
13217
- "disable": "use a local-weights provider (ollama/, lmstudio/) or an in-network gateway",
13390
+ "disable": "use a local-weights provider (opencode, cline or aider with a local ollama/ or lmstudio/ model id)",
13218
13391
  "note": os.environ["_AG_NOTE"],
13219
13392
  }, {
13220
13393
  "name": "telemetry",
13221
- "host": "telemetry endpoint",
13394
+ "host": os.environ["_AG_TELHOST"],
13222
13395
  "required": False,
13223
13396
  "enabled": os.environ["_AG_TEL"] == "on",
13224
- "disable": "loki telemetry off (already the default)",
13397
+ "disable": "loki telemetry off, DO_NOT_TRACK=1, or LOKI_AIRGAP=true (on by default for interactive installs; auto-off in CI)",
13225
13398
  }, {
13226
13399
  "name": "update_check",
13227
13400
  "host": "registry.npmjs.org",
@@ -13229,21 +13402,39 @@ egress = [{
13229
13402
  "enabled": os.environ["_AG_UPD"] == "on",
13230
13403
  "disable": "export LOKI_NO_UPDATE_CHECK=1",
13231
13404
  }]
13405
+ if os.environ["_AG_SC"] == "yes":
13406
+ egress.append({
13407
+ "name": "claude_sidecalls",
13408
+ "host": os.environ["_AG_SCHOST"],
13409
+ "required": True,
13410
+ "disable": "unset LOKI_ALLOW_CLAUDE_SIDECALLS",
13411
+ "note": "LOKI_ALLOW_CLAUDE_SIDECALLS=1 lets engine steps prompt the claude CLI under this provider",
13412
+ })
13232
13413
  blockers = [e for e in egress if e.get("required")]
13233
13414
  print(json.dumps({
13234
13415
  "provider": os.environ["_AG_PROV"],
13416
+ "provider_source": os.environ["_AG_PSRC"],
13235
13417
  "airgap_ready": not blockers,
13236
13418
  "required_egress": [e["name"] for e in blockers],
13237
13419
  "egress": egress,
13420
+ "not_audited": [{
13421
+ "name": "package_installs",
13422
+ "note": "app runner (npm, pip, docker) for the built app, dashboard venv (PyPI), quality-gate npx for tools not installed locally; pre-install or mirror them, a failed npx fetch fails that gate",
13423
+ "disable": "LOKI_APP_RUNNER=false, LOKI_DASHBOARD=false",
13424
+ }, {
13425
+ "name": "delegate_pr",
13426
+ "note": "git push and gh pr create to your remote after a successful run, when gh is installed",
13427
+ "disable": "LOKI_DELEGATE_PR=0",
13428
+ }],
13238
13429
  }, indent=2))
13239
13430
  '
13240
- [ "$model_required" = "yes" ] && return 1
13431
+ [ "$blocked" = "yes" ] && return 1
13241
13432
  return 0
13242
13433
  fi
13243
13434
 
13244
13435
  echo -e "${BOLD}Network egress audit${NC}"
13245
13436
  echo ""
13246
- echo " Active provider: $provider"
13437
+ echo " Active provider: $provider (from $provider_src)"
13247
13438
  echo ""
13248
13439
  echo -e "${BOLD}Egress points${NC}"
13249
13440
  if [ "$model_required" = "yes" ]; then
@@ -13252,13 +13443,33 @@ print(json.dumps({
13252
13443
  echo -e " ${GREEN}none${NC} model inference -> $model_host"
13253
13444
  fi
13254
13445
  echo " $model_note"
13255
- echo -e " optional telemetry [$tel_state] disable: loki telemetry off (default off)"
13446
+ if [ "$sidecall_required" = "yes" ]; then
13447
+ echo -e " ${YELLOW}REQUIRED${NC} claude side-calls -> $sidecall_host"
13448
+ echo " LOKI_ALLOW_CLAUDE_SIDECALLS=1 lets engine steps prompt the claude CLI under $provider."
13449
+ fi
13450
+ echo -e " optional telemetry [$tel_state] -> $tel_host"
13451
+ echo " disable: loki telemetry off, DO_NOT_TRACK=1, or LOKI_AIRGAP=true (on by default for interactive installs)"
13256
13452
  echo -e " optional update check [$upd_state] disable: export LOKI_NO_UPDATE_CHECK=1"
13257
13453
  echo ""
13454
+ echo -e "${BOLD}Not audited here${NC} (depends on the project; see docs/air-gapped.md)"
13455
+ echo " package installs app runner (npm, pip, docker), dashboard venv (PyPI), quality-gate"
13456
+ echo " npx for tools not installed locally. Pre-install or mirror them;"
13457
+ echo " a failed npx fetch fails that gate."
13458
+ echo " disable: LOKI_APP_RUNNER=false, LOKI_DASHBOARD=false"
13459
+ echo " delegate PR git push and gh pr create to your remote after a successful run,"
13460
+ echo " when gh is installed. disable: LOKI_DELEGATE_PR=0"
13461
+ echo ""
13462
+ if [ "$sidecall_required" = "yes" ] && [ "$model_required" != "yes" ]; then
13463
+ echo -e "${YELLOW}Not air-gap ready in this configuration.${NC}"
13464
+ echo " One required egress remains: claude side-calls."
13465
+ echo " To close it: unset LOKI_ALLOW_CLAUDE_SIDECALLS"
13466
+ return 1
13467
+ fi
13258
13468
  if [ "$model_required" = "yes" ]; then
13259
13469
  echo -e "${YELLOW}Not air-gap ready in this configuration.${NC}"
13260
13470
  echo " One required egress remains: model inference."
13261
- echo " To close it, serve the model in-network:"
13471
+ [ "$sidecall_required" = "yes" ] && echo " (Also unset LOKI_ALLOW_CLAUDE_SIDECALLS: claude side-calls are required egress too.)"
13472
+ echo " To close it, serve the model locally:"
13262
13473
  echo " loki provider set opencode"
13263
13474
  echo " export LOKI_OPENCODE_MODEL=ollama/qwen2.5-coder"
13264
13475
  echo ""
@@ -13291,9 +13502,10 @@ cmd_doctor() {
13291
13502
  echo ""
13292
13503
  echo "Options:"
13293
13504
  echo " --json Output machine-readable JSON"
13294
- echo " --airgap Audit network egress: list every host this engine"
13295
- echo " can contact, whether it is REQUIRED or optional,"
13296
- echo " and how to disable it. Exits non-zero if any"
13505
+ echo " --airgap Audit network egress: model inference, telemetry and"
13506
+ echo " the update check, whether each is REQUIRED or optional,"
13507
+ echo " and how to disable it; names what it does not audit"
13508
+ echo " (package installs, delegate PR). Exits non-zero if any"
13297
13509
  echo " required egress remains."
13298
13510
  echo ""
13299
13511
  echo "Checks: node, python3, jq, git, curl, bash version,"
@@ -14337,9 +14549,9 @@ for _sk_name, _sk_rel, _sk_id in _SKILL_ENTRIES:
14337
14549
  # islink is true for a DANGLING link (lstat-based), which is exactly
14338
14550
  # the broken case the text path reports. fail only for the effective
14339
14551
  # provider; otherwise warn, matching the text path badge for badge.
14340
- # `required` stays 'required' in both arms: an absent link has always
14552
+ # 'required' stays 'required' in both arms: an absent link has always
14341
14553
  # been warn+required, so the warn arm introduces no new shape, and a
14342
- # consumer keys on `status`.
14554
+ # consumer keys on 'status'.
14343
14555
  _sk_blocking = bool(_sk_selected) and _sk_id == _sk_selected
14344
14556
  skills.append({'name': _sk_name, 'path': _sk_json_path,
14345
14557
  'status': 'fail' if _sk_blocking else 'warn',
@@ -33249,6 +33461,21 @@ cmd_canary() {
33249
33461
  echo "loki outcomes canary: installed tool is unavailable: $tool_name" >&2
33250
33462
  return 2
33251
33463
  fi
33464
+ if [ "$subcommand" = "verify" ]; then
33465
+ # D7: this is a verify path. -E stops PYTHONPATH (an empty component
33466
+ # would add the cwd as an absolute path) and a committed
33467
+ # sitecustomize.py from acting before the tool runs. python3 is
33468
+ # resolved to an absolute path first so an empty PATH component (or a
33469
+ # relative one) cannot let a committed python3 shim in the checkout
33470
+ # being verified answer in its place.
33471
+ local py3
33472
+ py3="$(loki_verify_tool_path python3)" || {
33473
+ echo "loki outcomes canary verify: python3 is unavailable" >&2
33474
+ return 2
33475
+ }
33476
+ "$py3" -E "$tool" "$@"
33477
+ return $?
33478
+ fi
33252
33479
  python3 "$tool" "$@"
33253
33480
  }
33254
33481
 
@@ -33785,11 +34012,19 @@ except Exception:
33785
34012
  # --- Test suggestions ---
33786
34013
  local test_suggestions=""
33787
34014
  if [ "$ci_test_suggest" = true ] && [ -n "$changed_files" ]; then
33788
- export LOKI_CI_CHANGED_FILES="$changed_files"
33789
- test_suggestions=$(python3 << 'TEST_SUGGEST_PY'
34015
+ # Changed-file list goes through a temp file, read as sys.argv[1], not
34016
+ # through an exported env var: an exported string counts toward the
34017
+ # same execve() size ceiling as argv (plus, on Linux, its own 128 KiB
34018
+ # per-string cap), so a PR touching enough files can crash this
34019
+ # heredoc invocation with "Argument list too long" (exit 126) exactly
34020
+ # like an oversized argv element would.
34021
+ local _ci_changed_tmp
34022
+ _ci_changed_tmp=$(mktemp "${TMPDIR:-/tmp}/loki-ci-changed-XXXXXX")
34023
+ printf '%s' "$changed_files" >"$_ci_changed_tmp"
34024
+ test_suggestions=$(python3 - "$_ci_changed_tmp" << 'TEST_SUGGEST_PY'
33790
34025
  import sys, os
33791
34026
 
33792
- changed = os.environ.get("LOKI_CI_CHANGED_FILES", "").strip().split("\n")
34027
+ changed = open(sys.argv[1]).read().strip().split("\n")
33793
34028
  suggestions = []
33794
34029
 
33795
34030
  for f in changed:
@@ -33848,6 +34083,7 @@ import json
33848
34083
  print(json.dumps(suggestions))
33849
34084
  TEST_SUGGEST_PY
33850
34085
  )
34086
+ rm -f "$_ci_changed_tmp"
33851
34087
  fi
33852
34088
 
33853
34089
  # --- Tally findings ---
@@ -33886,22 +34122,34 @@ TEST_SUGGEST_PY
33886
34122
  file_count=$(echo "$changed_files" | grep -c '.' 2>/dev/null || echo 0)
33887
34123
 
33888
34124
  if [ "$ci_format" = "json" ]; then
33889
- # JSON output via python for proper escaping
33890
- export LOKI_CI_JSON_FINDINGS=""
34125
+ # JSON output via python for proper escaping.
34126
+ #
34127
+ # Findings and test suggestions go through temp files, read as
34128
+ # sys.argv[1]/[2], not through exported env vars: an exported string
34129
+ # counts toward the same execve() size ceiling as argv (plus, on
34130
+ # Linux, its own 128 KiB per-string cap), so a large enough diff --
34131
+ # meaning enough findings, not necessarily a byte-huge diff -- crashed
34132
+ # this exact heredoc with "Argument list too long" (exit 126) before
34133
+ # this fix (BACKLOG 25). META and COUNTS stay as env vars: both are a
34134
+ # handful of fixed-width fields, bounded regardless of diff size.
34135
+ local _ci_findings_tmp _ci_tests_tmp
34136
+ _ci_findings_tmp=$(mktemp "${TMPDIR:-/tmp}/loki-ci-findings-XXXXXX")
34137
+ _ci_tests_tmp=$(mktemp "${TMPDIR:-/tmp}/loki-ci-tests-XXXXXX")
34138
+ : >"$_ci_findings_tmp"
33891
34139
  for f in "${findings[@]+"${findings[@]}"}"; do
33892
- LOKI_CI_JSON_FINDINGS+="${f}"$'\n'
34140
+ printf '%s\n' "$f" >>"$_ci_findings_tmp"
33893
34141
  done
34142
+ printf '%s' "${test_suggestions:-[]}" >"$_ci_tests_tmp"
33894
34143
  export LOKI_CI_JSON_META="ci_env=${ci_env}|pr=${ci_pr_number}|ts=${report_timestamp}|files=${file_count}|exit=${exit_code}"
33895
34144
  export LOKI_CI_JSON_COUNTS="critical=${count_critical}|high=${count_high}|medium=${count_medium}|low=${count_low}|info=${count_info}|total=${count_total}"
33896
- export LOKI_CI_JSON_TESTS="${test_suggestions:-[]}"
33897
34145
 
33898
- python3 << 'CI_JSON_OUT'
33899
- import json, os
34146
+ python3 - "$_ci_findings_tmp" "$_ci_tests_tmp" << 'CI_JSON_OUT'
34147
+ import json, os, sys
33900
34148
 
33901
- findings_raw = os.environ.get("LOKI_CI_JSON_FINDINGS", "").strip().split("\n")
34149
+ findings_raw = open(sys.argv[1]).read().strip().split("\n")
33902
34150
  meta_raw = os.environ.get("LOKI_CI_JSON_META", "")
33903
34151
  counts_raw = os.environ.get("LOKI_CI_JSON_COUNTS", "")
33904
- tests_raw = os.environ.get("LOKI_CI_JSON_TESTS", "[]")
34152
+ tests_raw = open(sys.argv[2]).read().strip() or "[]"
33905
34153
 
33906
34154
  meta = dict(item.split("=", 1) for item in meta_raw.split("|") if "=" in item)
33907
34155
  counts = dict(item.split("=", 1) for item in counts_raw.split("|") if "=" in item)
@@ -33948,7 +34196,8 @@ if tests:
33948
34196
 
33949
34197
  print(json.dumps(report, indent=2))
33950
34198
  CI_JSON_OUT
33951
- unset LOKI_CI_JSON_FINDINGS LOKI_CI_JSON_META LOKI_CI_JSON_COUNTS LOKI_CI_JSON_TESTS
34199
+ rm -f "$_ci_findings_tmp" "$_ci_tests_tmp"
34200
+ unset LOKI_CI_JSON_META LOKI_CI_JSON_COUNTS
33952
34201
 
33953
34202
  elif [ "$ci_format" = "github" ]; then
33954
34203
  # GitHub-flavored markdown for PR comments
@@ -33997,10 +34246,11 @@ CI_JSON_OUT
33997
34246
  if [ -n "${test_suggestions:-}" ] && [ "$test_suggestions" != "[]" ]; then
33998
34247
  echo "### Test Suggestions"
33999
34248
  echo ""
34000
- export LOKI_CI_TEST_SUGG="${test_suggestions}"
34001
- python3 -c "
34002
- import json, os
34003
- tests = json.loads(os.environ.get('LOKI_CI_TEST_SUGG', '[]'))
34249
+ # Piped on stdin, not through an exported env var (BACKLOG 25):
34250
+ # an exported string shares argv's execve() size ceiling.
34251
+ printf '%s' "$test_suggestions" | python3 -c "
34252
+ import json, sys
34253
+ tests = json.loads(sys.stdin.read() or '[]')
34004
34254
  for t in tests:
34005
34255
  print(f\"- **{t['file']}**: {t['hint']} ({t['framework']})\")
34006
34256
  print(f\" - Suggested: \`{t['test_file']}\`\")
@@ -34066,15 +34316,15 @@ for t in tests:
34066
34316
  if [ "$ci_test_suggest" = true ] && [ -n "${test_suggestions:-}" ] && [ "$test_suggestions" != "[]" ]; then
34067
34317
  echo ""
34068
34318
  echo -e "${BOLD}Test Suggestions${NC}"
34069
- export LOKI_CI_TEST_SUGG="${test_suggestions}"
34070
- python3 -c "
34071
- import json, os
34072
- tests = json.loads(os.environ.get('LOKI_CI_TEST_SUGG', '[]'))
34319
+ # Piped on stdin, not through an exported env var (BACKLOG 25):
34320
+ # an exported string shares argv's execve() size ceiling.
34321
+ printf '%s' "$test_suggestions" | python3 -c "
34322
+ import json, sys
34323
+ tests = json.loads(sys.stdin.read() or '[]')
34073
34324
  for t in tests:
34074
34325
  print(f\" {t['file']} -> {t['test_file']} ({t['framework']})\")
34075
34326
  print(f\" {t['hint']}\")
34076
34327
  " 2>/dev/null || true
34077
- unset LOKI_CI_TEST_SUGG
34078
34328
  fi
34079
34329
 
34080
34330
  if [ "$exit_code" -eq 0 ]; then
@@ -34094,7 +34344,10 @@ for t in tests:
34094
34344
  # Generate GitHub-format report for comment
34095
34345
  local comment_body
34096
34346
  comment_body=$("$0" ci --pr --format github 2>/dev/null || echo "Loki CI report generation failed.")
34097
- gh pr comment "$ci_pr_number" --body "$comment_body" 2>/dev/null && \
34347
+ # Body goes through stdin (--body-file -), never as an argv element:
34348
+ # a large diff produces a large findings list, and a report body
34349
+ # passed via --body overflows ARG_MAX (exit 126) instead of posting.
34350
+ printf '%s' "$comment_body" | gh pr comment "$ci_pr_number" --body-file - 2>/dev/null && \
34098
34351
  echo -e "${GREEN}Posted review comment to PR #$ci_pr_number${NC}" >&2 || \
34099
34352
  echo -e "${YELLOW}Warning: Failed to post PR comment${NC}" >&2
34100
34353
  else
@@ -36466,7 +36719,23 @@ PYEOF
36466
36719
  if command -v jq &>/dev/null; then
36467
36720
  jq . "$pj"
36468
36721
  else
36469
- LOKI_PROOF_JSON="$pj" python3 -c "import json,os; print(json.dumps(json.load(open(os.environ['LOKI_PROOF_JSON'])), indent=2))"
36722
+ # D7: this is a verify-adjacent read of proof.json. -E stops
36723
+ # PYTHONPATH/sitecustomize.py from acting before this line, and
36724
+ # python3 is resolved to an absolute path first so an empty
36725
+ # PATH component cannot let a committed python3 shim in the
36726
+ # checkout answer instead of the real interpreter. The cwd is
36727
+ # dropped from sys.path before the real json/os import, so a
36728
+ # committed json.py/os.py cannot answer either.
36729
+ local _show_py3
36730
+ _show_py3="$(loki_verify_tool_path python3)" || {
36731
+ echo -e "${RED}python3 is unavailable${NC}" >&2
36732
+ exit 2
36733
+ }
36734
+ LOKI_PROOF_JSON="$pj" "$_show_py3" -E -c "
36735
+ import sys; sys.path[:] = [p for p in sys.path if p not in ('', '.')]
36736
+ import json, os
36737
+ print(json.dumps(json.load(open(os.environ['LOKI_PROOF_JSON'])), indent=2))
36738
+ "
36470
36739
  fi
36471
36740
  exit 0
36472
36741
  ;;
@@ -36788,6 +37057,13 @@ PYEOF
36788
37057
  local skip_confirm=0
36789
37058
  local visibility="--public"
36790
37059
  local hosted=0
37060
+ # D7: every python3 invocation below reads proof.json from inside
37061
+ # the checkout being shared. Resolve an absolute path once so an
37062
+ # empty PATH component cannot let a committed python3 shim in that
37063
+ # checkout answer instead of the real interpreter; each call site
37064
+ # also runs -E and drops the cwd from sys.path.
37065
+ local _share_py3
37066
+ _share_py3="$(loki_verify_tool_path python3)" || _share_py3=""
36791
37067
  while [[ $# -gt 0 ]]; do
36792
37068
  case "$1" in
36793
37069
  --yes|-y) skip_confirm=1; shift ;;
@@ -36843,8 +37119,11 @@ PYEOF
36843
37119
  echo ""
36844
37120
  echo "What will be shared:"
36845
37121
  echo " - ${html}"
36846
- if [ -f "$pj" ]; then
36847
- LOKI_PROOF_JSON="$pj" python3 - <<'PYEOF' 2>/dev/null || true
37122
+ if [ -f "$pj" ] && [ -n "$_share_py3" ]; then
37123
+ LOKI_PROOF_JSON="$pj" "$_share_py3" -E - <<'PYEOF' 2>/dev/null || true
37124
+ # The cwd is the checkout being shared: drop it from sys.path before any
37125
+ # other import, or a committed json.py/os.py could answer this preview.
37126
+ import sys; sys.path[:] = [p for p in sys.path if p not in ("", ".")]
36848
37127
  import json, os
36849
37128
  try:
36850
37129
  d = json.load(open(os.environ["LOKI_PROOF_JSON"]))
@@ -36891,9 +37170,12 @@ PYEOF
36891
37170
  # Print a ready-to-post one-line hook (real cost + url) so the user
36892
37171
  # can paste it straight to X/HN. When cost was not collected, omit
36893
37172
  # the cost (never fabricate a number, never print "$0.00").
36894
- if [ -n "${LOKI_LAST_GIST_URL:-}" ] && [ -f "$pj" ]; then
37173
+ if [ -n "${LOKI_LAST_GIST_URL:-}" ] && [ -f "$pj" ] && [ -n "$_share_py3" ]; then
36895
37174
  local hook
36896
- hook=$(LOKI_PROOF_JSON="$pj" LOKI_GIST_URL="$LOKI_LAST_GIST_URL" python3 - <<'PYEOF' 2>/dev/null || true
37175
+ hook=$(LOKI_PROOF_JSON="$pj" LOKI_GIST_URL="$LOKI_LAST_GIST_URL" "$_share_py3" -E - <<'PYEOF' 2>/dev/null || true
37176
+ # The cwd is the checkout being shared: drop it from sys.path before any
37177
+ # other import, or a committed json.py/os.py could answer this hook text.
37178
+ import sys; sys.path[:] = [p for p in sys.path if p not in ("", ".")]
36897
37179
  import json, os
36898
37180
  try:
36899
37181
  d = json.load(open(os.environ["LOKI_PROOF_JSON"]))
@@ -36969,7 +37251,10 @@ PYEOF
36969
37251
  # The badge links to the gist we just published (no new egress).
36970
37252
  # shields.io static/v1 query endpoint avoids structural hyphens.
36971
37253
  local badge
36972
- badge=$(LOKI_PROOF_JSON="$pj" LOKI_GIST_URL="$LOKI_LAST_GIST_URL" python3 - <<'PYEOF' 2>/dev/null || true
37254
+ badge=$(LOKI_PROOF_JSON="$pj" LOKI_GIST_URL="$LOKI_LAST_GIST_URL" "$_share_py3" -E - <<'PYEOF' 2>/dev/null || true
37255
+ # The cwd is the checkout being shared: drop it from sys.path before any
37256
+ # other import, or a committed json.py/os.py could answer this badge text.
37257
+ import sys; sys.path[:] = [p for p in sys.path if p not in ("", ".")]
36973
37258
  import json, os
36974
37259
  from urllib.parse import quote
36975
37260
  try: