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 +1 -1
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/completion-council.sh +110 -34
- package/autonomy/crash.sh +6 -6
- package/autonomy/lib/done-recognition.sh +7 -0
- package/autonomy/lib/prd-enrich.sh +7 -0
- package/autonomy/lib/proof-verify.py +93 -21
- package/autonomy/lib/voter-agents.sh +7 -0
- package/autonomy/loki +404 -119
- package/autonomy/quickstart.sh +7 -0
- package/autonomy/run.sh +605 -30
- package/autonomy/verify.sh +114 -8
- package/bin/loki +12 -0
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +6 -5
- package/docs/air-gapped.md +39 -17
- package/docs/enterprise/security.md +10 -13
- package/docs/environment-variables.md +1 -0
- package/docs/exit-codes.md +76 -2
- package/docs/v10/BACKLOG.md +23 -0
- package/docs/v10/BOARD.md +61 -32
- package/docs/v10/CONTROL.md +36 -0
- package/docs/v10/DECISIONS.md +31 -0
- package/docs/v10/METRICS.md +78 -1
- package/docs/v10/PROGRESS.md +197 -0
- package/loki-ts/dist/loki.js +7 -4
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/loader.sh +10 -0
- package/tools/receipt-attest.py +1 -1
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
|
-
|
|
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": [{...}, ...]}
|
|
858
|
-
# never an accusation against the receipt.
|
|
859
|
-
#
|
|
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
|
|
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
|
-
|
|
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": [{...}, ...]}
|
|
950
|
-
# CHECKED, never an accusation.
|
|
951
|
-
#
|
|
952
|
-
#
|
|
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
|
|
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
|
-
|
|
8053
|
-
|
|
8054
|
-
|
|
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
|
-
|
|
8059
|
-
|
|
8060
|
-
|
|
8061
|
-
|
|
8062
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
8184
|
-
|
|
8185
|
-
|
|
8186
|
-
kill -
|
|
8187
|
-
|
|
8188
|
-
|
|
8189
|
-
|
|
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
|
|
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:
|
|
8244
|
-
#
|
|
8245
|
-
#
|
|
8246
|
-
#
|
|
8247
|
-
|
|
8248
|
-
#
|
|
8249
|
-
|
|
8250
|
-
|
|
8251
|
-
|
|
8252
|
-
|
|
8253
|
-
|
|
8254
|
-
|
|
8255
|
-
|
|
8256
|
-
|
|
8257
|
-
|
|
8258
|
-
|
|
8259
|
-
|
|
8260
|
-
|
|
8261
|
-
|
|
8262
|
-
#
|
|
8263
|
-
|
|
8264
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
13187
|
-
|
|
13188
|
-
|
|
13189
|
-
|
|
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
|
-
|
|
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
|
|
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": "
|
|
13394
|
+
"host": os.environ["_AG_TELHOST"],
|
|
13222
13395
|
"required": False,
|
|
13223
13396
|
"enabled": os.environ["_AG_TEL"] == "on",
|
|
13224
|
-
"disable": "loki telemetry off (
|
|
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
|
-
[ "$
|
|
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
|
-
|
|
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 "
|
|
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:
|
|
13295
|
-
echo "
|
|
13296
|
-
echo " and how to disable it
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
33789
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
34001
|
-
|
|
34002
|
-
|
|
34003
|
-
|
|
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
|
-
|
|
34070
|
-
|
|
34071
|
-
|
|
34072
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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"
|
|
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"
|
|
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:
|