loki-mode 9.18.4 → 9.20.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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/deadline.py +21 -1
- package/autonomy/lib/outcome_contract.py +128 -0
- package/autonomy/lib/proof-generator.py +45 -0
- package/autonomy/lib/proof-verify.py +53 -0
- package/autonomy/loki +980 -24
- package/autonomy/receipt_jwt.py +308 -0
- package/autonomy/run.sh +30 -0
- package/autonomy/trigger-server.py +186 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +83 -0
- package/dashboard/static/index.html +144 -0
- package/docs/COMPETITIVE-SCORECARD.md +76 -0
- package/docs/PROOF-PASSPORT.md +29 -0
- package/docs/SIGNED-RECEIPTS.md +111 -0
- package/docs/metrics.md +32 -0
- package/events/emit.sh +31 -0
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/tools/proof-passport.py +175 -0
- package/web-app/dist/assets/{AdminPage-CcCJ0Sjt.js → AdminPage-DNGNuAge.js} +1 -1
- package/web-app/dist/assets/{Avatar-DK8kmayw.js → Avatar-CqAveJaT.js} +1 -1
- package/web-app/dist/assets/{Badge-4uAWnemi.js → Badge-BtMejA0Z.js} +1 -1
- package/web-app/dist/assets/{Button-BBMk33tk.js → Button-CikxmqSs.js} +1 -1
- package/web-app/dist/assets/{ComparePage-bt9rwvST.js → ComparePage-BB8DJBWR.js} +1 -1
- package/web-app/dist/assets/EvidenceReceiptPanel-6oquKEjd.js +1 -0
- package/web-app/dist/assets/{GitHubIssuesPanel-WDbH47UM.js → GitHubIssuesPanel-5XLYWzWH.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-C2CiYtTx.js → GitHubPRsPanel-DUfmFYeu.js} +1 -1
- package/web-app/dist/assets/{HomePage-BQk-MUjn.js → HomePage-DZ4MmOdH.js} +5 -5
- package/web-app/dist/assets/{LoginPage-DMOZVGGL.js → LoginPage-VMzI6ROD.js} +1 -1
- package/web-app/dist/assets/{MagicPage-Bzp2Nt1z.js → MagicPage-CFbX5gWg.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-C39JVdsw.js → MetricsPage-DbL6xmRQ.js} +2 -2
- package/web-app/dist/assets/{NotFoundPage-6vT_U9UL.js → NotFoundPage-BUrjXPAR.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-BfFcZp-E.js → ProjectPage-iP6dJihg.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-CPMBf8Wt.js → ProjectsPage-BibN9bHl.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-BnNN6ETl.js → SettingsPage-C0WJp1hW.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-WDrMf-cx.js → ShowcasePage-DUv0Njp8.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-DX4jb2e8.js → SystemSettingsPage-BIaCB0Vn.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-BCfqcXzu.js → TeamsPage-BE0szv9r.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-CZvmimDj.js → TemplatesPage-BzkA9kat.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-BlRqFwWV.js → TerminalOutput-DBrdT_Fp.js} +1 -1
- package/web-app/dist/assets/{activity-CacZsUyr.js → activity-AbfFz38r.js} +1 -1
- package/web-app/dist/assets/{bell-DK2qtHnk.js → bell-DF6Z6y5U.js} +1 -1
- package/web-app/dist/assets/{bot-CkcUtHad.js → bot-DQ4np_3n.js} +1 -1
- package/web-app/dist/assets/{check-CbCPjX3M.js → check-RWuguCwE.js} +1 -1
- package/web-app/dist/assets/{chevron-left-5NUKWw3i.js → chevron-left-VpbQPOkC.js} +1 -1
- package/web-app/dist/assets/{circle-alert-S7uFoxC2.js → circle-alert-DwT82lJx.js} +1 -1
- package/web-app/dist/assets/{clock-CaQRrIrs.js → clock-BBlUa93G.js} +1 -1
- package/web-app/dist/assets/{cloud-DBAX6c0r.js → cloud-Dgbz3VCA.js} +1 -1
- package/web-app/dist/assets/{code-xml-De5-EXv3.js → code-xml-r6qqION5.js} +1 -1
- package/web-app/dist/assets/{copy-CUkT6k1v.js → copy-DMHj52B8.js} +1 -1
- package/web-app/dist/assets/{database-BAWf1Gwt.js → database-uJqHSHR1.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-Ji8zk86R.js → dollar-sign-BZagljek.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-ChtXoBwS.js → file-code-corner-DMopmhu0.js} +1 -1
- package/web-app/dist/assets/{file-plus-bFa37P76.js → file-plus-DU_hT_3w.js} +1 -1
- package/web-app/dist/assets/{folder-open-DhXpXscO.js → folder-open-BP5TtQAW.js} +1 -1
- package/web-app/dist/assets/{git-commit-horizontal-DVPeDQ3j.js → git-commit-horizontal-zFk_IobS.js} +1 -1
- package/web-app/dist/assets/{globe-BPZgPeeu.js → globe-mthCRtfp.js} +1 -1
- package/web-app/dist/assets/{hammer-jLCaujYH.js → hammer-BWaLBvSA.js} +1 -1
- package/web-app/dist/assets/{index-B-0iHBPO.js → index-BXA3UT6P.js} +3 -3
- package/web-app/dist/assets/{index-BfZSDej1.css → index-DgpUuqwR.css} +1 -1
- package/web-app/dist/assets/{layers-B1vsrsFW.js → layers-Q1LXV5fy.js} +1 -1
- package/web-app/dist/assets/{lightbulb-C-uLoq9Y.js → lightbulb-vRRNJ3dB.js} +1 -1
- package/web-app/dist/assets/{loader-circle-JTfD-ZuM.js → loader-circle-KgjeHEcV.js} +1 -1
- package/web-app/dist/assets/{lock-G9rxD4gZ.js → lock-BerKqno7.js} +1 -1
- package/web-app/dist/assets/{mail-BJ0PTN_V.js → mail-XAxTbprX.js} +1 -1
- package/web-app/dist/assets/{package-CXClfLOO.js → package-DLPKWAvW.js} +1 -1
- package/web-app/dist/assets/{plus-EoL5OCB7.js → plus-NMxxwHMn.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-BjREUnVq.js → refresh-cw-Dt42kYtM.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-DahWX07H.js → rotate-ccw-BCR4Tp9u.js} +1 -1
- package/web-app/dist/assets/{save-Dek3gCn1.js → save-sR4MJd42.js} +1 -1
- package/web-app/dist/assets/{server-D6V1BAia.js → server-SXkchokx.js} +1 -1
- package/web-app/dist/assets/{shield-alert-BtTK5Sxb.js → shield-alert-Bvgp6_Dz.js} +1 -1
- package/web-app/dist/assets/{trash-2-BT5o_g0r.js → trash-2-D0IGudH7.js} +1 -1
- package/web-app/dist/assets/{trending-down-D4Jk7KF3.js → trending-down-BbFFBq-v.js} +1 -1
- package/web-app/dist/assets/{trending-up-EQFTzhEo.js → trending-up-DDrJKL5i.js} +1 -1
- package/web-app/dist/assets/{upload-JfI5lCSE.js → upload-C54rjN8K.js} +1 -1
- package/web-app/dist/assets/{usePolling-BnhPUuGd.js → usePolling-D7fw4YSM.js} +1 -1
- package/web-app/dist/assets/{user-DSUiUYtj.js → user-Cg7fbzdh.js} +1 -1
- package/web-app/dist/index.html +2 -2
- package/web-app/server.py +78 -0
package/autonomy/loki
CHANGED
|
@@ -750,7 +750,11 @@ loki_remote_fetch_receipt() {
|
|
|
750
750
|
printf '%s' "$resp" > "$tmp" && mv "$tmp" "$dir/proof.json"
|
|
751
751
|
echo " Evidence Receipt: $dir/proof.json"
|
|
752
752
|
|
|
753
|
-
|
|
753
|
+
# The URL is passed so the receipt's attestation can be checked against that
|
|
754
|
+
# server's published JWKS. Without it the attestation is simply not checked
|
|
755
|
+
# and the verdict falls back to the gpg path, which is the pre-existing
|
|
756
|
+
# behaviour rather than a downgrade.
|
|
757
|
+
loki_remote_verify_receipt "$dir/proof.json" "$url"
|
|
754
758
|
}
|
|
755
759
|
|
|
756
760
|
# Verify a fetched receipt locally and print exactly what was and was not
|
|
@@ -801,6 +805,121 @@ PY
|
|
|
801
805
|
esac
|
|
802
806
|
}
|
|
803
807
|
|
|
808
|
+
# Check a receipt's JWKS attestation against the server's PUBLISHED key set.
|
|
809
|
+
#
|
|
810
|
+
# This is the half gpg cannot do over a network. gpg proves provenance only if
|
|
811
|
+
# you already hold the publisher's key, which a submitter who ran
|
|
812
|
+
# `loki start --remote` never does -- so the honest gpg outcome there is
|
|
813
|
+
# NOT CHECKED, and it stays that way until someone performs an out-of-band key
|
|
814
|
+
# import. Fetching /.well-known/jwks.json over the same TLS the API already
|
|
815
|
+
# uses removes that step.
|
|
816
|
+
#
|
|
817
|
+
# Prints "ok", "bad", or "" (could not check). Fails toward "" so an
|
|
818
|
+
# unreachable JWKS never manufactures a verdict in either direction -- an
|
|
819
|
+
# absent measurement is not evidence, and inventing a PASS here would be worse
|
|
820
|
+
# than inventing a FAIL.
|
|
821
|
+
loki_remote_attestation_status() {
|
|
822
|
+
local pj="$1" url="$2"
|
|
823
|
+
[ -n "$url" ] || { printf ''; return 0; }
|
|
824
|
+
command -v python3 >/dev/null 2>&1 || { printf ''; return 0; }
|
|
825
|
+
local tok
|
|
826
|
+
tok="$(jq -r '.verification.attestation // empty' "$pj" 2>/dev/null)"
|
|
827
|
+
[ -n "$tok" ] || { printf ''; return 0; }
|
|
828
|
+
python3 - "$pj" "$url" "${_LOKI_SCRIPT_DIR}" <<'PY' 2>/dev/null || printf ''
|
|
829
|
+
import json, sys, hashlib, urllib.request
|
|
830
|
+
sys.path.insert(0, sys.argv[3])
|
|
831
|
+
try:
|
|
832
|
+
from receipt_jwt import verify_attestation
|
|
833
|
+
except ImportError:
|
|
834
|
+
sys.exit(1) # -> "" : cannot check, never a verdict
|
|
835
|
+
proof = json.load(open(sys.argv[1]))
|
|
836
|
+
tok = (proof.get("verification") or {}).get("attestation") or ""
|
|
837
|
+
if not tok:
|
|
838
|
+
sys.exit(1)
|
|
839
|
+
try:
|
|
840
|
+
with urllib.request.urlopen(
|
|
841
|
+
sys.argv[2].rstrip("/") + "/.well-known/jwks.json", timeout=10) as r:
|
|
842
|
+
jwks = json.loads(r.read())
|
|
843
|
+
except Exception:
|
|
844
|
+
sys.exit(1) # unreachable JWKS is UNCHECKED, not a failure
|
|
845
|
+
ok, claims = verify_attestation(tok, jwks)
|
|
846
|
+
if not ok:
|
|
847
|
+
print("bad"); sys.exit(0)
|
|
848
|
+
# The signature verifying is not enough on its own: it must attest to THIS
|
|
849
|
+
# receipt. Recompute the digest the same way the receipt records it (with
|
|
850
|
+
# `verification` stripped) and require the signed claim to match, or a valid
|
|
851
|
+
# token lifted from a different run would vouch for these bytes.
|
|
852
|
+
proof.pop("verification", None)
|
|
853
|
+
canon = json.dumps(proof, sort_keys=True, separators=(",", ":")).encode()
|
|
854
|
+
print("ok" if claims.get("receipt_sha256") == hashlib.sha256(canon).hexdigest()
|
|
855
|
+
else "bad")
|
|
856
|
+
PY
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
# Check a receipt's attestation against a key set given as a URL OR a file.
|
|
860
|
+
#
|
|
861
|
+
# The file form is what makes this usable by a third party with no network and
|
|
862
|
+
# no Loki install: hand them proof.json and jwks.json and they can check both
|
|
863
|
+
# integrity and provenance themselves. That is the whole claim -- "a receipt you
|
|
864
|
+
# can check yourself" is false if checking requires our infrastructure.
|
|
865
|
+
#
|
|
866
|
+
# Prints "ok", "bad", "none" (no attestation present), or "" (could not check).
|
|
867
|
+
# "none" and "" are deliberately distinct: one is a fact about the receipt, the
|
|
868
|
+
# other is an absent measurement, and collapsing them would report an unchecked
|
|
869
|
+
# receipt as an unattested one.
|
|
870
|
+
# NOTE: this shares its verification logic with loki_remote_attestation_status
|
|
871
|
+
# above. The two differ only in their key-set source (that one is URL-only, from
|
|
872
|
+
# a live fetch during a --remote run; this one also accepts a FILE for the
|
|
873
|
+
# offline third-party case). If you change the digest-recompute rule, CHANGE
|
|
874
|
+
# BOTH -- they are covered by different tests
|
|
875
|
+
# (test-remote-attestation-verdict.sh and test-proof-verify-jwks.sh), so a
|
|
876
|
+
# one-sided edit passes one suite and silently weakens the other. Mutation
|
|
877
|
+
# testing found exactly this: a mutation applied to the first copy left every
|
|
878
|
+
# assertion of the second suite green.
|
|
879
|
+
loki_proof_attestation_check() {
|
|
880
|
+
local pj="$1" src="$2"
|
|
881
|
+
[ -n "$src" ] || { printf ''; return 0; }
|
|
882
|
+
command -v python3 >/dev/null 2>&1 || { printf ''; return 0; }
|
|
883
|
+
python3 - "$pj" "$src" "${_LOKI_SCRIPT_DIR}" <<'PY' 2>/dev/null || printf ''
|
|
884
|
+
import json, sys, os, hashlib, urllib.request
|
|
885
|
+
sys.path.insert(0, sys.argv[3])
|
|
886
|
+
try:
|
|
887
|
+
from receipt_jwt import verify_attestation
|
|
888
|
+
except ImportError:
|
|
889
|
+
sys.exit(1) # "" : cannot check
|
|
890
|
+
try:
|
|
891
|
+
proof = json.load(open(sys.argv[1]))
|
|
892
|
+
except Exception:
|
|
893
|
+
sys.exit(1)
|
|
894
|
+
tok = (proof.get("verification") or {}).get("attestation") or ""
|
|
895
|
+
if not tok:
|
|
896
|
+
print("none"); sys.exit(0) # a FACT about the receipt, not an error
|
|
897
|
+
src = sys.argv[2]
|
|
898
|
+
try:
|
|
899
|
+
if os.path.isfile(src):
|
|
900
|
+
jwks = json.load(open(src))
|
|
901
|
+
else:
|
|
902
|
+
if not src.startswith(("http://", "https://")):
|
|
903
|
+
sys.exit(1)
|
|
904
|
+
url = src if src.rstrip("/").endswith("jwks.json") else \
|
|
905
|
+
src.rstrip("/") + "/.well-known/jwks.json"
|
|
906
|
+
with urllib.request.urlopen(url, timeout=10) as r:
|
|
907
|
+
jwks = json.loads(r.read())
|
|
908
|
+
except Exception:
|
|
909
|
+
sys.exit(1) # "" : unreachable/unreadable, no verdict
|
|
910
|
+
ok, claims = verify_attestation(tok, jwks)
|
|
911
|
+
if not ok:
|
|
912
|
+
print("bad"); sys.exit(0)
|
|
913
|
+
# A verifying signature is not enough: it must attest to THESE bytes. Recompute
|
|
914
|
+
# the digest the way the receipt records it, so a token lifted from another run
|
|
915
|
+
# cannot vouch for this one.
|
|
916
|
+
proof.pop("verification", None)
|
|
917
|
+
canon = json.dumps(proof, sort_keys=True, separators=(",", ":")).encode()
|
|
918
|
+
print("ok" if claims.get("receipt_sha256") == hashlib.sha256(canon).hexdigest()
|
|
919
|
+
else "bad")
|
|
920
|
+
PY
|
|
921
|
+
}
|
|
922
|
+
|
|
804
923
|
loki_remote_verify_receipt() {
|
|
805
924
|
local pj="$1"
|
|
806
925
|
local verifier="${_LOKI_SCRIPT_DIR}/lib/proof-verify.py"
|
|
@@ -828,6 +947,10 @@ loki_remote_verify_receipt() {
|
|
|
828
947
|
# a gate that cries wolf every time trains users to ignore the verdict.
|
|
829
948
|
# Only hash_ok and gpg_ok are load-bearing here; drift is not our verdict.
|
|
830
949
|
local out hash_ok gpg_ok
|
|
950
|
+
# Second argument is the remote URL when this receipt came from a cluster;
|
|
951
|
+
# empty for a local receipt, which simply means no attestation is checked.
|
|
952
|
+
local _attest_url="${2:-}" _attest=""
|
|
953
|
+
_attest="$(loki_remote_attestation_status "$pj" "$_attest_url")"
|
|
831
954
|
out="$(python3 "$verifier" "$pj" "${TARGET_DIR:-.}" 2>/dev/null)"
|
|
832
955
|
hash_ok="$(printf '%s' "$out" | jq -r '.hash_ok // false' 2>/dev/null)"
|
|
833
956
|
gpg_ok="$(printf '%s' "$out" | jq -r '.gpg_ok | tostring' 2>/dev/null)"
|
|
@@ -849,6 +972,18 @@ loki_remote_verify_receipt() {
|
|
|
849
972
|
echo " the build ran elsewhere. Not tampering, and not part of the verdict."
|
|
850
973
|
fi
|
|
851
974
|
|
|
975
|
+
# An attestation that does not verify is an ACCUSATION, and it is checked
|
|
976
|
+
# before the gpg branches because it is the stronger statement: the server's
|
|
977
|
+
# own published key says this token does not cover these bytes. Either the
|
|
978
|
+
# receipt was altered after the server signed it, or the token was lifted
|
|
979
|
+
# from a different run. Both mean: do not trust this build.
|
|
980
|
+
if [ "$_attest" = "bad" ]; then
|
|
981
|
+
echo -e " ${RED}TAMPERED: the receipt's attestation does not verify against the server's published keys.${NC}" >&2
|
|
982
|
+
echo " Either these bytes were altered after signing, or the attestation" >&2
|
|
983
|
+
echo " belongs to a different run. Do not trust this build." >&2
|
|
984
|
+
return 1
|
|
985
|
+
fi
|
|
986
|
+
|
|
852
987
|
# gpg_ok=false covers TWO facts that must never be conflated: the signature
|
|
853
988
|
# is cryptographically bad over this content (the receipt was ALTERED), and
|
|
854
989
|
# gpg could not evaluate it at all (we do not hold the public key). Only the
|
|
@@ -866,6 +1001,17 @@ loki_remote_verify_receipt() {
|
|
|
866
1001
|
_why="$(loki_remote_gpg_status "$pj")"
|
|
867
1002
|
fi
|
|
868
1003
|
if [ "$_why" = "nopubkey" ]; then
|
|
1004
|
+
# JWKS exists precisely for this case: the gpg key is absent, but a
|
|
1005
|
+
# remote-issued receipt can still prove provenance against the
|
|
1006
|
+
# server's published key set, with no out-of-band import.
|
|
1007
|
+
if [ "$_attest" = "ok" ]; then
|
|
1008
|
+
echo -e " ${GREEN}VERIFIED${NC}: integrity hash matches and the receipt's attestation is valid."
|
|
1009
|
+
echo " Proven: these bytes are unaltered, and the build server signed them"
|
|
1010
|
+
echo " with a key published at ${_attest_url}/.well-known/jwks.json."
|
|
1011
|
+
echo " The gpg key is not in your keyring; the attestation covers provenance."
|
|
1012
|
+
echo " NOT proven: that the code is correct. A receipt records which checks ran."
|
|
1013
|
+
return 0
|
|
1014
|
+
fi
|
|
869
1015
|
echo -e " ${YELLOW}NOT CHECKED${NC}: the receipt is signed, but the signing key is not in your keyring." >&2
|
|
870
1016
|
echo " Nothing is wrong with the contents: the integrity hash matches. The" >&2
|
|
871
1017
|
echo " signature could not be evaluated, so PROVENANCE is unproven here." >&2
|
|
@@ -897,6 +1043,20 @@ loki_remote_verify_receipt() {
|
|
|
897
1043
|
return 0
|
|
898
1044
|
fi
|
|
899
1045
|
|
|
1046
|
+
# No gpg signature, but a verified attestation. This is the normal shape for
|
|
1047
|
+
# a cluster build: the receiver signs, the build never holds a gpg key, and
|
|
1048
|
+
# provenance is still proven -- so calling it UNSIGNED would understate what
|
|
1049
|
+
# was actually checked, the mirror of the error we refuse in the other
|
|
1050
|
+
# direction.
|
|
1051
|
+
if [ "$_attest" = "ok" ]; then
|
|
1052
|
+
echo -e " ${GREEN}VERIFIED${NC}: integrity hash matches and the receipt's attestation is valid."
|
|
1053
|
+
echo " Proven: these bytes are unaltered, and they were signed by the build"
|
|
1054
|
+
echo " server whose key is published at ${_attest_url}/.well-known/jwks.json."
|
|
1055
|
+
echo " Verify that server is one you trust."
|
|
1056
|
+
echo " NOT proven: that the code is correct. A receipt records which checks ran."
|
|
1057
|
+
return 0
|
|
1058
|
+
fi
|
|
1059
|
+
|
|
900
1060
|
echo -e " ${YELLOW}UNSIGNED${NC}: integrity hash matches. This is NOT proof of provenance."
|
|
901
1061
|
echo " Proven: the receipt is internally consistent with its own hash."
|
|
902
1062
|
echo " NOT proven: who produced it. An unsigned receipt is forgeable -- whoever"
|
|
@@ -1501,7 +1661,7 @@ show_landing() {
|
|
|
1501
1661
|
}
|
|
1502
1662
|
|
|
1503
1663
|
# Detect argument type for unified `loki start` (v6.84.0)
|
|
1504
|
-
# Returns one of: prd | issue | empty | unknown
|
|
1664
|
+
# Returns one of: prd | issue | repo | empty | unknown
|
|
1505
1665
|
# Logic:
|
|
1506
1666
|
# - empty arg -> "empty"
|
|
1507
1667
|
# - file path ending in .md/.json/.txt AND exists on disk -> "prd"
|
|
@@ -1565,7 +1725,10 @@ detect_arg_type() {
|
|
|
1565
1725
|
# BUT only if a file with that literal name does NOT exist on disk
|
|
1566
1726
|
# (ambiguity resolution: files-on-disk win over issue-number heuristic).
|
|
1567
1727
|
if [[ "$arg" =~ ^#?[0-9]+$ ]]; then
|
|
1568
|
-
if [ -
|
|
1728
|
+
if [ -d "$arg" ]; then
|
|
1729
|
+
# Directory on disk wins -- same rule as the -d branch below.
|
|
1730
|
+
echo "repo"
|
|
1731
|
+
elif [ -e "$arg" ]; then
|
|
1569
1732
|
# File on disk wins -- treat as PRD
|
|
1570
1733
|
echo "prd"
|
|
1571
1734
|
else
|
|
@@ -1574,6 +1737,20 @@ detect_arg_type() {
|
|
|
1574
1737
|
return 0
|
|
1575
1738
|
fi
|
|
1576
1739
|
|
|
1740
|
+
# An existing DIRECTORY is a repo to improve, not a spec to build from.
|
|
1741
|
+
# Before this, a directory fell into the "prd" branch below and was handed
|
|
1742
|
+
# to run.sh as a PRD path; it has no .md-family extension, so the
|
|
1743
|
+
# not-found guard skipped it and the run silently proceeded as if no spec
|
|
1744
|
+
# had been given. `loki start <dir>` now means "work on that repo".
|
|
1745
|
+
# ponytail: a directory named `spec.md` still returns "prd" -- the
|
|
1746
|
+
# extension branch at the top of this function wins. Deliberate: an
|
|
1747
|
+
# explicit spec extension is the stronger signal. Move this block above
|
|
1748
|
+
# that branch if directories-with-spec-names ever matter.
|
|
1749
|
+
if [ -d "$arg" ]; then
|
|
1750
|
+
echo "repo"
|
|
1751
|
+
return 0
|
|
1752
|
+
fi
|
|
1753
|
+
|
|
1577
1754
|
# An existing file on disk is unambiguously a PRD, even if its name has
|
|
1578
1755
|
# spaces -- a real path the user pointed at wins over the brief heuristic.
|
|
1579
1756
|
if [ -e "$arg" ]; then
|
|
@@ -1862,9 +2039,10 @@ cmd_start() {
|
|
|
1862
2039
|
--help|-h)
|
|
1863
2040
|
echo -e "${BOLD}loki start${NC} - Unified entry point for Loki Mode (v6.84.0)"
|
|
1864
2041
|
echo ""
|
|
1865
|
-
echo "Usage: loki start [PRD|ISSUE-REF] [options]"
|
|
2042
|
+
echo "Usage: loki start [REPO|PRD|ISSUE-REF] [options]"
|
|
1866
2043
|
echo ""
|
|
1867
2044
|
echo "Modes (auto-detected from input):"
|
|
2045
|
+
echo " REPO mode - existing directory -> one bounded verified improvement"
|
|
1868
2046
|
echo " PRD mode - path ending in .md/.json/.txt/.yaml -> build from PRD"
|
|
1869
2047
|
echo " ISSUE mode - GitHub/GitLab/Jira/Azure DevOps URL, owner/repo#N,"
|
|
1870
2048
|
echo " PROJ-123, #123, or bare number -> generate PRD from issue"
|
|
@@ -1873,6 +2051,7 @@ cmd_start() {
|
|
|
1873
2051
|
echo " no input - analyze current directory, auto-generate PRD"
|
|
1874
2052
|
echo ""
|
|
1875
2053
|
echo "Arguments:"
|
|
2054
|
+
echo " REPO Existing project directory"
|
|
1876
2055
|
echo " PRD Path to PRD file (.md/.json/.txt/.yaml)"
|
|
1877
2056
|
echo " ISSUE-REF Issue URL, number, or tracker key (see above)"
|
|
1878
2057
|
echo ""
|
|
@@ -1947,6 +2126,7 @@ cmd_start() {
|
|
|
1947
2126
|
echo ""
|
|
1948
2127
|
echo "Examples:"
|
|
1949
2128
|
echo " loki start # Interactive, analyze current dir"
|
|
2129
|
+
echo " loki start ./my-project # Improve an existing project"
|
|
1950
2130
|
echo " loki start \"build a todo app\" # BRIEF mode (zero-config fast run)"
|
|
1951
2131
|
echo " loki start --brief \"snake\" # BRIEF mode (single-word escape hatch)"
|
|
1952
2132
|
echo " loki start ./prd.md # PRD mode"
|
|
@@ -2557,6 +2737,22 @@ cmd_start() {
|
|
|
2557
2737
|
detected_type="empty"
|
|
2558
2738
|
fi
|
|
2559
2739
|
|
|
2740
|
+
# Resolve the directory while the positional path is still relative to the
|
|
2741
|
+
# caller, then enter it before creating any .loki state. Quoted `cd --`
|
|
2742
|
+
# keeps spaces and shell metacharacters inert.
|
|
2743
|
+
if [ "$detected_type" = "repo" ]; then
|
|
2744
|
+
local repo_target
|
|
2745
|
+
repo_target=$(cd -- "$positional_arg" 2>/dev/null && pwd -P) || {
|
|
2746
|
+
echo -e "${RED}Error: cannot enter repository directory: $positional_arg${NC}" >&2
|
|
2747
|
+
return 1
|
|
2748
|
+
}
|
|
2749
|
+
cd -- "$repo_target" || return 1
|
|
2750
|
+
if [ "$LOKI_DIR" = ".loki" ]; then
|
|
2751
|
+
LOKI_DIR="$repo_target/.loki"
|
|
2752
|
+
fi
|
|
2753
|
+
export LOKI_REPO_TARGET="$repo_target"
|
|
2754
|
+
fi
|
|
2755
|
+
|
|
2560
2756
|
# ISSUE mode: delegate to cmd_run's code path. To avoid duplicating ~450
|
|
2561
2757
|
# lines of issue-fetching + PR-creation logic, we re-dispatch through
|
|
2562
2758
|
# cmd_run with reconstructed args. cmd_run then calls cmd_start again
|
|
@@ -2638,12 +2834,43 @@ cmd_start() {
|
|
|
2638
2834
|
echo ""
|
|
2639
2835
|
fi
|
|
2640
2836
|
|
|
2837
|
+
# REPO mode: `loki start <directory>`. We already cd'd into the target
|
|
2838
|
+
# above, so this is the no-arg codebase path with one difference -- a
|
|
2839
|
+
# synthesized brief that bounds the work to ONE high-value improvement
|
|
2840
|
+
# instead of "build out the whole project".
|
|
2841
|
+
#
|
|
2842
|
+
# Deliberately does NOT call set_ttfv_lightweight_profile: that helper
|
|
2843
|
+
# turns the council and the code-review phase OFF, and the whole point of
|
|
2844
|
+
# this entry point is to hand back VERIFIED evidence. Scope is bounded by
|
|
2845
|
+
# the brief text, not by disabling the gates that check the work. The run
|
|
2846
|
+
# keeps full depth, so it reports LOKI_TTFV=repo -- the same honest
|
|
2847
|
+
# full-depth signal the no-arg path uses.
|
|
2848
|
+
if [ "$detected_type" = "repo" ]; then
|
|
2849
|
+
local version
|
|
2850
|
+
version=$(get_version)
|
|
2851
|
+
mkdir -p "$LOKI_DIR" 2>/dev/null || true
|
|
2852
|
+
find "$LOKI_DIR" -maxdepth 1 -name 'repo-prd-*.md' -mtime +1 -delete 2>/dev/null || true
|
|
2853
|
+
local repo_prd="$LOKI_DIR/repo-prd-$$.md"
|
|
2854
|
+
synthesize_repo_prd "$repo_prd"
|
|
2855
|
+
prd_file="$repo_prd"
|
|
2856
|
+
|
|
2857
|
+
echo -e "${BOLD}Loki Mode v$version - Repo improvement${NC}"
|
|
2858
|
+
echo ""
|
|
2859
|
+
echo -e "${CYAN}Repo:${NC} $LOKI_REPO_TARGET"
|
|
2860
|
+
echo -e "${DIM}Loki will inspect this project, pick one high-value improvement,${NC}"
|
|
2861
|
+
echo -e "${DIM}implement it, test it, and produce verified evidence.${NC}"
|
|
2862
|
+
echo -e "${DIM}Full depth: quality gates and completion council stay ON.${NC}"
|
|
2863
|
+
echo -e "${DIM}Scope it yourself instead with: loki start \"<what you want>\".${NC}"
|
|
2864
|
+
echo ""
|
|
2865
|
+
fi
|
|
2866
|
+
|
|
2641
2867
|
# R7: existing-repo no-arg path also gets the TTFV "what next" framing at the
|
|
2642
2868
|
# end of the run. Execution here is UNCHANGED (full-depth no-PRD codebase
|
|
2643
2869
|
# analysis + generated-PRD-reuse); we only add accurate end-of-run framing.
|
|
2644
2870
|
# The signal value ("repo") drives the full-depth wording in run.sh so the
|
|
2645
2871
|
# message does not falsely claim a lightweight pass.
|
|
2646
|
-
if [ "$detected_type" = "empty" ]
|
|
2872
|
+
if { [ "$detected_type" = "empty" ] || [ "$detected_type" = "repo" ]; } \
|
|
2873
|
+
&& [ -z "${LOKI_TTFV:-}" ]; then
|
|
2647
2874
|
export LOKI_TTFV=repo
|
|
2648
2875
|
fi
|
|
2649
2876
|
|
|
@@ -6307,6 +6534,34 @@ else
|
|
|
6307
6534
|
DASHBOARD_DEFAULT_HOST="127.0.0.1"
|
|
6308
6535
|
fi
|
|
6309
6536
|
|
|
6537
|
+
# EXPOSED BIND REQUIRES AUTH (issue #188).
|
|
6538
|
+
#
|
|
6539
|
+
# dashboard/auth.py:788 returns True -- allow -- when neither LOKI_ENTERPRISE_AUTH
|
|
6540
|
+
# nor OIDC is configured. That makes all 166 Depends(require_scope) endpoints
|
|
6541
|
+
# open. On loopback that is defensible: the trust boundary is the machine.
|
|
6542
|
+
# Bound to 0.0.0.0 it is not, and NEITHER the Dockerfile NOR the Helm chart set
|
|
6543
|
+
# any auth env, so an operator following the shipped chart gets an
|
|
6544
|
+
# unauthenticated dashboard on every interface without choosing it.
|
|
6545
|
+
#
|
|
6546
|
+
# Refuses to start rather than auto-minting a token. An auto-token an operator
|
|
6547
|
+
# never sees is worse than a refusal: they believe they are protected, and the
|
|
6548
|
+
# credential is sitting in a container they did not know to guard. A refusal is
|
|
6549
|
+
# loud, happens at deploy time rather than after exposure, and names the fix.
|
|
6550
|
+
#
|
|
6551
|
+
# LOKI_DASHBOARD_ALLOW_INSECURE_BIND=1 is the deliberate override, for a cluster
|
|
6552
|
+
# whose network policy already bounds ingress. Named so it appears in a diff and
|
|
6553
|
+
# a reviewer can ask why it is set.
|
|
6554
|
+
_loki_dashboard_bind_is_safe() {
|
|
6555
|
+
local _host="${1:-}"
|
|
6556
|
+
case "$_host" in
|
|
6557
|
+
127.0.0.1|localhost|::1|"") return 0 ;;
|
|
6558
|
+
esac
|
|
6559
|
+
[ "${LOKI_DASHBOARD_ALLOW_INSECURE_BIND:-}" = "1" ] && return 0
|
|
6560
|
+
[ "${LOKI_ENTERPRISE_AUTH:-}" = "true" ] || [ "${LOKI_ENTERPRISE_AUTH:-}" = "1" ] && return 0
|
|
6561
|
+
[ -n "${LOKI_OIDC_ISSUER:-}" ] && [ -n "${LOKI_OIDC_CLIENT_ID:-}" ] && return 0
|
|
6562
|
+
return 1
|
|
6563
|
+
}
|
|
6564
|
+
|
|
6310
6565
|
cmd_dashboard() {
|
|
6311
6566
|
local subcommand="${1:-}"
|
|
6312
6567
|
|
|
@@ -6461,6 +6716,23 @@ cmd_dashboard_start() {
|
|
|
6461
6716
|
esac
|
|
6462
6717
|
done
|
|
6463
6718
|
|
|
6719
|
+
# Refuse an exposed bind with auth disabled (issue #188). Checked AFTER
|
|
6720
|
+
# argument parsing so an explicit --host is covered, not just the default.
|
|
6721
|
+
if ! _loki_dashboard_bind_is_safe "$host"; then
|
|
6722
|
+
echo -e "${RED}Refusing to start: binding $host with authentication disabled.${NC}" >&2
|
|
6723
|
+
echo "" >&2
|
|
6724
|
+
echo " Every dashboard endpoint would be reachable on all interfaces with no" >&2
|
|
6725
|
+
echo " credential. dashboard/auth.py allows all requests when neither" >&2
|
|
6726
|
+
echo " LOKI_ENTERPRISE_AUTH nor OIDC is configured." >&2
|
|
6727
|
+
echo "" >&2
|
|
6728
|
+
echo " Pick one:" >&2
|
|
6729
|
+
echo " LOKI_ENTERPRISE_AUTH=true enable token auth (loki dashboard token create)" >&2
|
|
6730
|
+
echo " LOKI_OIDC_ISSUER + _CLIENT_ID enable OIDC" >&2
|
|
6731
|
+
echo " --host 127.0.0.1 bind loopback only" >&2
|
|
6732
|
+
echo " LOKI_DASHBOARD_ALLOW_INSECURE_BIND=1 accept the exposure deliberately" >&2
|
|
6733
|
+
exit 2
|
|
6734
|
+
fi
|
|
6735
|
+
|
|
6464
6736
|
# Validate port is numeric
|
|
6465
6737
|
if [[ ! "$port" =~ ^[0-9]+$ ]]; then
|
|
6466
6738
|
echo -e "${RED}Invalid port: $port${NC}"
|
|
@@ -7967,12 +8239,23 @@ cmd_preview() {
|
|
|
7967
8239
|
# build started, so it has NO running-app precondition. Folding it into preview
|
|
7968
8240
|
# would force preview's running-app gate onto a feature that must not have one.
|
|
7969
8241
|
#
|
|
7970
|
-
# HARD INVARIANT (DEPLOY-PLAN LOCK 5 + BRANCH-LIFECYCLE LOCK B4): PRINT-ONLY
|
|
7971
|
-
# cmd_deploy NEVER runs a cloud CLI
|
|
7972
|
-
#
|
|
7973
|
-
# `command -v` ONLY. Loki advises; the human runs the printed command.
|
|
7974
|
-
#
|
|
7975
|
-
#
|
|
8242
|
+
# HARD INVARIANT (DEPLOY-PLAN LOCK 5 + BRANCH-LIFECYCLE LOCK B4): PRINT-ONLY BY
|
|
8243
|
+
# DEFAULT. Without --execute, cmd_deploy NEVER runs a cloud CLI
|
|
8244
|
+
# (vercel/netlify/flyctl/wrangler) -- not even `--version`. Tool detection is
|
|
8245
|
+
# `command -v` ONLY. Loki advises; the human runs the printed command.
|
|
8246
|
+
#
|
|
8247
|
+
# --execute (added for receipt-gated deploy) is the ONLY path that runs a deploy
|
|
8248
|
+
# command, and it is gated on an Evidence Receipt that VERIFIES this exact tree
|
|
8249
|
+
# (see the RECEIPT-GATED EXECUTION block below). The invariants that survive
|
|
8250
|
+
# unconditionally, with or without --execute:
|
|
8251
|
+
# - NEVER `git push`, NEVER `gh pr create`. Out of scope entirely.
|
|
8252
|
+
# - NEVER a destructive cloud operation (delete/destroy/teardown/drop),
|
|
8253
|
+
# refused even with a perfect receipt.
|
|
8254
|
+
# - NEVER reads or forwards cloud credentials. The deploy command inherits the
|
|
8255
|
+
# user's already-authenticated CLI session; Loki does not touch credential
|
|
8256
|
+
# files or secret-bearing env vars.
|
|
8257
|
+
# Only the clipboard tools (pbcopy/wl-copy/...), `command -v`, and -- under
|
|
8258
|
+
# --execute alone -- the gated deploy command itself may run.
|
|
7976
8259
|
#
|
|
7977
8260
|
# This whole block is contiguous (helpers + cmd_deploy) so a test can extract it
|
|
7978
8261
|
# by name anchor, mirroring tests/test-preview-public.sh.
|
|
@@ -8193,7 +8476,16 @@ $options
|
|
|
8193
8476
|
EOF
|
|
8194
8477
|
|
|
8195
8478
|
if [ "$printed" = true ]; then
|
|
8196
|
-
|
|
8479
|
+
# This sentence is TRUE only on the print-only path. Under --execute the
|
|
8480
|
+
# very next thing that happens may be the deploy running, and printing
|
|
8481
|
+
# "Loki does not deploy for you" immediately before Loki deploys is
|
|
8482
|
+
# exactly the kind of false statement this feature exists to not make.
|
|
8483
|
+
# _LOKI_DEPLOY_EXECUTING is set by cmd_deploy for this one decision.
|
|
8484
|
+
if [ "${_LOKI_DEPLOY_EXECUTING:-false}" = true ]; then
|
|
8485
|
+
echo "Reviewing this command against the evidence gate (--execute)."
|
|
8486
|
+
else
|
|
8487
|
+
echo "Loki does not deploy for you. Review, then run the command yourself."
|
|
8488
|
+
fi
|
|
8197
8489
|
if [ "$do_clip" = true ] && [ -n "$first_cmd" ]; then
|
|
8198
8490
|
_deploy_copy_clipboard "$first_cmd"
|
|
8199
8491
|
fi
|
|
@@ -8208,9 +8500,493 @@ EOF
|
|
|
8208
8500
|
return 1
|
|
8209
8501
|
}
|
|
8210
8502
|
|
|
8211
|
-
#
|
|
8503
|
+
# =============================================================================
|
|
8504
|
+
# RECEIPT-GATED EXECUTION (--execute). Everything below is INERT unless the user
|
|
8505
|
+
# passes --execute on THIS invocation; the default path stays print-only.
|
|
8506
|
+
#
|
|
8507
|
+
# The design idea: a deploy is an action a VERIFIED RECEIPT AUTHORIZES. Not
|
|
8508
|
+
# "let the agent run gcloud" -- the gate is the evidence artifact we already
|
|
8509
|
+
# ship, and it must hold on every one of four independent checks.
|
|
8510
|
+
#
|
|
8511
|
+
# FAIL CLOSED IS THE WHOLE CONTRACT. Every helper here answers "" or a REFUSE
|
|
8512
|
+
# reason when it cannot evaluate its check. An unevaluated gate is not a pass:
|
|
8513
|
+
# no receipt, no python3, no gpg, a non-repo dir and an unresolvable anchor all
|
|
8514
|
+
# REFUSE. That direction is deliberate -- a gate that cannot tell you whether
|
|
8515
|
+
# the tree was verified must not deploy it.
|
|
8516
|
+
# =============================================================================
|
|
8517
|
+
|
|
8518
|
+
# _deploy_receipt_verdict <proof.json>
|
|
8519
|
+
# Echoes exactly one of VERIFIED | UNSIGNED | UNCHECKED | TAMPERED, the same four
|
|
8520
|
+
# verdicts loki_remote_verify_receipt reports (see its header at loki:756).
|
|
8521
|
+
# The vocabulary is REUSED, not reinvented, because a second set of words for
|
|
8522
|
+
# the same four states is how they get collapsed into two.
|
|
8523
|
+
#
|
|
8524
|
+
# Deliberately NOT loki_remote_verify_receipt itself: that function returns 0 for
|
|
8525
|
+
# UNSIGNED, which is right for its caller (submit) and wrong here (integrity is
|
|
8526
|
+
# not provenance). We read the same signed/hash_ok/gpg_ok triple and let the
|
|
8527
|
+
# CALLER decide which verdicts authorize.
|
|
8528
|
+
#
|
|
8529
|
+
# hash_ok is read, NOT the top-level `ok` and NOT the exit code: `ok` folds in
|
|
8530
|
+
# tree_drift, and proof-verify.py exits 1 on drift alone. Gating on either would
|
|
8531
|
+
# refuse every honest receipt whose tree moved -- the false-alarm direction
|
|
8532
|
+
# docs/VERIFICATION-COST.md documents. Anything unreadable is TAMPERED-or-worse,
|
|
8533
|
+
# so the fallback verdict is UNCHECKED (never a pass).
|
|
8534
|
+
_deploy_receipt_verdict() {
|
|
8535
|
+
local pj="$1"
|
|
8536
|
+
local verifier="${_LOKI_SCRIPT_DIR}/lib/proof-verify.py"
|
|
8537
|
+
|
|
8538
|
+
[ -f "$pj" ] || { printf '%s' "UNCHECKED"; return 0; }
|
|
8539
|
+
if [ ! -f "$verifier" ] || ! command -v python3 >/dev/null 2>&1; then
|
|
8540
|
+
printf '%s' "UNCHECKED"; return 0
|
|
8541
|
+
fi
|
|
8542
|
+
|
|
8543
|
+
local out hash_ok gpg_ok signed=0
|
|
8544
|
+
out="$(python3 "$verifier" "$pj" "${TARGET_DIR:-.}" 2>/dev/null || true)"
|
|
8545
|
+
|
|
8546
|
+
# "The verifier did not RUN" and "the verifier says the hash is bad" are
|
|
8547
|
+
# different facts and must not collapse. If they do, a broken python3
|
|
8548
|
+
# produces the verdict TAMPERED -- an accusation of forgery for a check that
|
|
8549
|
+
# never executed. That is the same error the signed path is careful to avoid
|
|
8550
|
+
# with NO_PUBKEY (see loki:852): it sends a user hunting a forgery that never
|
|
8551
|
+
# happened, and trains them to shrug at a real TAMPERED. Unparseable output
|
|
8552
|
+
# is UNCHECKED, which still REFUSES -- fail-closed, honestly labelled.
|
|
8553
|
+
if [ -z "$out" ] || ! printf '%s' "$out" | python3 -c 'import json,sys;json.load(sys.stdin)' >/dev/null 2>&1; then
|
|
8554
|
+
printf '%s' "UNCHECKED"; return 0
|
|
8555
|
+
fi
|
|
8556
|
+
|
|
8557
|
+
hash_ok="$(printf '%s' "$out" | python3 -c 'import json,sys;print(str(json.load(sys.stdin).get("hash_ok",False)).lower())' 2>/dev/null || printf 'false')"
|
|
8558
|
+
gpg_ok="$(printf '%s' "$out" | python3 -c 'import json,sys;print(str(json.load(sys.stdin).get("gpg_ok","n/a")).lower())' 2>/dev/null || printf 'n/a')"
|
|
8559
|
+
|
|
8560
|
+
# Whether the receipt CARRIES a signature is read from the receipt, never
|
|
8561
|
+
# inferred from gpg_ok -- gpg_ok is "n/a" both for "no signature" and for
|
|
8562
|
+
# "gpg missing here", and conflating those mislabels a signed receipt.
|
|
8563
|
+
if python3 -c 'import json,sys;sys.exit(0 if (json.load(open(sys.argv[1])).get("verification") or {}).get("gpg_signature") else 1)' "$pj" 2>/dev/null; then
|
|
8564
|
+
signed=1
|
|
8565
|
+
fi
|
|
8566
|
+
|
|
8567
|
+
if [ "$hash_ok" != "true" ]; then printf '%s' "TAMPERED"; return 0; fi
|
|
8568
|
+
if [ "$signed" -eq 0 ]; then printf '%s' "UNSIGNED"; return 0; fi
|
|
8569
|
+
case "$gpg_ok" in
|
|
8570
|
+
true) printf '%s' "VERIFIED" ;;
|
|
8571
|
+
# Signed but the signature does not verify: contents or signature were
|
|
8572
|
+
# altered. A missing PUBLIC KEY is a different fact (uncheckable, not an
|
|
8573
|
+
# accusation), so it reports UNCHECKED -- which refuses here anyway.
|
|
8574
|
+
false) if command -v gpg >/dev/null 2>&1 && [ "$(loki_remote_gpg_status "$pj")" = "nopubkey" ]; then
|
|
8575
|
+
printf '%s' "UNCHECKED"
|
|
8576
|
+
else
|
|
8577
|
+
printf '%s' "TAMPERED"
|
|
8578
|
+
fi ;;
|
|
8579
|
+
*) printf '%s' "UNCHECKED" ;;
|
|
8580
|
+
esac
|
|
8581
|
+
return 0
|
|
8582
|
+
}
|
|
8583
|
+
|
|
8584
|
+
# _deploy_anchor_ok <proof.json> <dir>
|
|
8585
|
+
# Echoes "" when the receipt anchors to the tree in front of us, else a REFUSE
|
|
8586
|
+
# reason. Two checks, and BOTH are load-bearing:
|
|
8587
|
+
#
|
|
8588
|
+
# 1. resolve_anchor (autonomy/lib/outcome_ledger.py:122) -- sha algebra proving
|
|
8589
|
+
# base..head is reachable and produces exactly the receipt's file set.
|
|
8590
|
+
# 2. head_sha == live HEAD.
|
|
8591
|
+
#
|
|
8592
|
+
# Check 2 is NOT redundant, and this was MEASURED rather than assumed: on a
|
|
8593
|
+
# fixture where the tree advanced one commit past the receipt, resolve_anchor
|
|
8594
|
+
# still returned ("anchored", None) -- correctly, because it answers "can I
|
|
8595
|
+
# follow this receipt to measure outcomes", not "does this receipt describe the
|
|
8596
|
+
# tree I am about to ship". Without check 2 a receipt for an ANCESTOR commit
|
|
8597
|
+
# authorizes deploying code it never verified, which is the exact failure this
|
|
8598
|
+
# feature exists to prevent.
|
|
8599
|
+
_deploy_anchor_ok() {
|
|
8600
|
+
local pj="$1" dir="${2:-.}"
|
|
8601
|
+
command -v python3 >/dev/null 2>&1 || { printf '%s' "anchor could not be evaluated (python3 unavailable)"; return 0; }
|
|
8602
|
+
git -C "$dir" rev-parse --git-dir >/dev/null 2>&1 || { printf '%s' "anchor could not be evaluated (not a git repository)"; return 0; }
|
|
8603
|
+
|
|
8604
|
+
local live_head
|
|
8605
|
+
live_head="$(git -C "$dir" rev-parse HEAD 2>/dev/null || printf '')"
|
|
8606
|
+
[ -n "$live_head" ] || { printf '%s' "anchor could not be evaluated (no HEAD commit)"; return 0; }
|
|
8607
|
+
|
|
8608
|
+
# _LOKI_SCRIPT_DIR is a plain shell var, so it is passed EXPLICITLY here --
|
|
8609
|
+
# relying on it being exported would silently fall back to a bare "lib" path
|
|
8610
|
+
# and report every receipt as unevaluable.
|
|
8611
|
+
PJ="$pj" LIVE_HEAD="$live_head" LOKI_LIB="${_LOKI_SCRIPT_DIR}/lib" \
|
|
8612
|
+
python3 - "$dir" <<'PYANCHOR' 2>/dev/null || printf '%s' "anchor could not be evaluated (anchor check failed to run)"
|
|
8613
|
+
import json, os, sys
|
|
8614
|
+
sys.dont_write_bytecode = True
|
|
8615
|
+
sys.path.insert(0, os.environ.get("LOKI_LIB", ""))
|
|
8616
|
+
try:
|
|
8617
|
+
from outcome_ledger import resolve_anchor
|
|
8618
|
+
except Exception:
|
|
8619
|
+
print("anchor could not be evaluated (outcome_ledger unavailable)", end="")
|
|
8620
|
+
raise SystemExit(0)
|
|
8621
|
+
d = json.load(open(os.environ["PJ"]))
|
|
8622
|
+
git = (d.get("facts") or {}).get("git") or {}
|
|
8623
|
+
base, head = git.get("base_sha") or "", git.get("head_sha") or ""
|
|
8624
|
+
files = git.get("diff") or d.get("files_changed") or []
|
|
8625
|
+
if not isinstance(files, list):
|
|
8626
|
+
files = []
|
|
8627
|
+
state, reason = resolve_anchor(base, head, files, sys.argv[1])
|
|
8628
|
+
if state != "anchored":
|
|
8629
|
+
# TWO DIFFERENT FACTS, and collapsing them makes this gate unusable.
|
|
8630
|
+
#
|
|
8631
|
+
# Measured on this repository: 8 of 9 receipts carry NO facts.git.base_sha,
|
|
8632
|
+
# which matches the "0 of 9 anchored" figure already published in
|
|
8633
|
+
# docs/VERIFICATION-COST.md. Refusing both cases identically means the gate
|
|
8634
|
+
# rejects ~89% of real receipts on anchoring alone -- and a gate that always
|
|
8635
|
+
# refuses teaches users to reach for an override, which is worse than no
|
|
8636
|
+
# gate at all.
|
|
8637
|
+
#
|
|
8638
|
+
# UNANCHORED no baseline was recorded. We can prove neither match nor
|
|
8639
|
+
# mismatch. Refuse by DEFAULT, but let the caller override
|
|
8640
|
+
# per invocation, and record that nothing was proven.
|
|
8641
|
+
# MISMATCH a baseline WAS recorded and it does not resolve to this
|
|
8642
|
+
# tree. That is positive evidence of the wrong build, and no
|
|
8643
|
+
# override may bypass it.
|
|
8644
|
+
#
|
|
8645
|
+
# The override covers "unknown", never "known wrong". Emitting a machine
|
|
8646
|
+
# readable prefix so the caller can tell the two apart without parsing prose.
|
|
8647
|
+
#
|
|
8648
|
+
# DISCRIMINATED ON THE REASON NAME, not on `not base`. MEASURED: the receipt
|
|
8649
|
+
# this repo's gate actually selects carries base_sha=4b825dc6 (the empty
|
|
8650
|
+
# tree), so `base` is TRUTHY and a `not base` test files it under MISMATCH --
|
|
8651
|
+
# the one case blocking the repo would stay unoverridable. resolve_anchor
|
|
8652
|
+
# returns base_sha_empty (line 135) BEFORE the greenfield check (line 137),
|
|
8653
|
+
# so reaching greenfield_no_baseline proves base_sha was non-empty.
|
|
8654
|
+
#
|
|
8655
|
+
# Exactly two reasons mean "no baseline was recorded", and together they
|
|
8656
|
+
# cover all 9 receipts here (8 base_sha_empty, 1 greenfield_no_baseline).
|
|
8657
|
+
# Every OTHER reason keeps refusing, including with the flag set:
|
|
8658
|
+
# not_reachable_from_head, diff_range_mismatch -- positive evidence of a
|
|
8659
|
+
# DIFFERENT change. Mismatch by construction.
|
|
8660
|
+
# change_not_committed, sha_not_in_history, head_sha_empty, no_git -- the
|
|
8661
|
+
# check could not be evaluated, and this gate's stated doctrine is that
|
|
8662
|
+
# an unevaluable check is a refusal, not a pass.
|
|
8663
|
+
if reason in ("base_sha_empty", "greenfield_no_baseline"):
|
|
8664
|
+
print("UNANCHORED:receipt records no baseline (%s), so it cannot be tied "
|
|
8665
|
+
"to this tree" % reason, end="")
|
|
8666
|
+
else:
|
|
8667
|
+
print("receipt does not anchor to this tree (%s)" % (reason or "unresolvable"), end="")
|
|
8668
|
+
elif head != os.environ["LIVE_HEAD"]:
|
|
8669
|
+
# Anchored but STALE: the receipt verified an ancestor, not this tree.
|
|
8670
|
+
print("receipt records a different commit (%s) than HEAD (%s)"
|
|
8671
|
+
% (head[:12], os.environ["LIVE_HEAD"][:12]), end="")
|
|
8672
|
+
PYANCHOR
|
|
8673
|
+
return 0
|
|
8674
|
+
}
|
|
8675
|
+
|
|
8676
|
+
# _deploy_is_destructive <command>
|
|
8677
|
+
# Returns 0 (true) if the command names a destructive cloud operation. Refused
|
|
8678
|
+
# even under --execute with a perfect receipt: a receipt authorizes shipping the
|
|
8679
|
+
# verified tree, never tearing an environment down. Word-boundary matched so
|
|
8680
|
+
# "wrangler pages deploy dist" is not caught by "destroy" appearing in a path.
|
|
8681
|
+
_deploy_is_destructive() {
|
|
8682
|
+
printf '%s' "${1:-}" | grep -Eqi '(^|[^[:alnum:]_-])(delete|destroy|teardown|down|drop|rm|remove|purge|prune|wipe|scale-down)([^[:alnum:]_-]|$)'
|
|
8683
|
+
}
|
|
8684
|
+
|
|
8685
|
+
# _deploy_tree_clean <dir>
|
|
8686
|
+
# Echoes "" when the working tree is clean, else a REFUSE reason. A dirty tree
|
|
8687
|
+
# means the bytes about to ship are NOT the bytes that were verified, which is
|
|
8688
|
+
# precisely the failure the receipt gate exists to prevent. A non-repo is
|
|
8689
|
+
# UNEVALUABLE, not clean -- it lands in REFUSE with a named reason.
|
|
8690
|
+
_deploy_tree_clean() {
|
|
8691
|
+
local dir="${1:-.}"
|
|
8692
|
+
git -C "$dir" rev-parse --git-dir >/dev/null 2>&1 || { printf '%s' "working tree could not be evaluated (not a git repository)"; return 0; }
|
|
8693
|
+
local st rc=0
|
|
8694
|
+
st="$(git -C "$dir" status --porcelain 2>/dev/null)" || rc=$?
|
|
8695
|
+
# A git that could not report is UNEVALUABLE, never "clean" and never
|
|
8696
|
+
# "dirty": naming it dirty invents a specific finding out of a failed
|
|
8697
|
+
# measurement, and the user would go looking for edits that do not exist.
|
|
8698
|
+
[ "$rc" -eq 0 ] || { printf '%s' "working tree could not be evaluated (git status failed)"; return 0; }
|
|
8699
|
+
[ -z "$st" ] || printf '%s' "working tree is dirty ($(printf '%s\n' "$st" | grep -c '') uncommitted change(s))"
|
|
8700
|
+
return 0
|
|
8701
|
+
}
|
|
8702
|
+
|
|
8703
|
+
# _deploy_latest_receipt <dir>
|
|
8704
|
+
# Echoes the newest .loki/proofs/<id>/proof.json path, or "" if none exists.
|
|
8705
|
+
# Newest-by-name: proof ids are timestamp-prefixed, so lexical order is
|
|
8706
|
+
# chronological. Absence is not an error here; the caller turns "" into the
|
|
8707
|
+
# named REFUSE reason "no receipt".
|
|
8708
|
+
_deploy_latest_receipt() {
|
|
8709
|
+
local dir="${1:-.}"
|
|
8710
|
+
local proofs="$dir/${LOKI_DIR:-.loki}/proofs"
|
|
8711
|
+
# LOKI_DIR may already be absolute (it is elsewhere in this CLI); prefer it
|
|
8712
|
+
# directly when it resolves, else fall back to the dir-relative form.
|
|
8713
|
+
[ -d "${LOKI_DIR:-}/proofs" ] && proofs="${LOKI_DIR}/proofs"
|
|
8714
|
+
[ -d "$proofs" ] || { printf '%s' ""; return 0; }
|
|
8715
|
+
local newest=""
|
|
8716
|
+
local d
|
|
8717
|
+
for d in "$proofs"/*/; do
|
|
8718
|
+
[ -f "$d/proof.json" ] || continue
|
|
8719
|
+
newest="$d/proof.json"
|
|
8720
|
+
done
|
|
8721
|
+
printf '%s' "$newest"
|
|
8722
|
+
return 0
|
|
8723
|
+
}
|
|
8724
|
+
|
|
8725
|
+
# _deploy_record <dir> <command> <exit_code> <run_id> <anchor_sha>
|
|
8726
|
+
# Writes the executed deploy's own record. A deploy with no record of itself
|
|
8727
|
+
# defeats the entire premise of gating on evidence.
|
|
8728
|
+
#
|
|
8729
|
+
# NOT written into .loki/proofs/: a hand-written JSON there carries no integrity
|
|
8730
|
+
# hash, so `loki proof verify` would read it as a TAMPERED receipt -- a
|
|
8731
|
+
# self-inflicted false alarm. It lives in .loki/deploys/ where it cannot be
|
|
8732
|
+
# mistaken for a verifiable receipt.
|
|
8733
|
+
_deploy_record() {
|
|
8734
|
+
local dir="${1:-.}" command="$2" rc="$3" run_id="$4" anchor="$5"
|
|
8735
|
+
# WHICH of the three applied: anchored (the receipt was tied to this tree)
|
|
8736
|
+
# or unanchored_overridden (it was not, and the operator said go anyway).
|
|
8737
|
+
# "refused" needs no value: a refusal never reaches this function, so the
|
|
8738
|
+
# existence of a record already means one of the two above.
|
|
8739
|
+
local anchor_state="${6:-anchored}"
|
|
8740
|
+
local ddir="$dir/.loki/deploys"
|
|
8741
|
+
[ -d "${LOKI_DIR:-}" ] && ddir="${LOKI_DIR}/deploys"
|
|
8742
|
+
mkdir -p "$ddir" 2>/dev/null || return 0
|
|
8743
|
+
local f="$ddir/$(date -u +%Y%m%dT%H%M%SZ)-$$.json"
|
|
8744
|
+
DR_CMD="$command" DR_RC="$rc" DR_RUN="$run_id" DR_ANCHOR="$anchor" DR_OUT="$f" \
|
|
8745
|
+
DR_STATE="$anchor_state" \
|
|
8746
|
+
python3 -c '
|
|
8747
|
+
import json, os, datetime
|
|
8748
|
+
state = os.environ.get("DR_STATE") or "anchored"
|
|
8749
|
+
json.dump({
|
|
8750
|
+
"recorded_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
8751
|
+
"command": os.environ["DR_CMD"],
|
|
8752
|
+
"exit_code": int(os.environ["DR_RC"]),
|
|
8753
|
+
"authorizing_run_id": os.environ["DR_RUN"],
|
|
8754
|
+
"anchor_sha": os.environ["DR_ANCHOR"],
|
|
8755
|
+
"anchor_state": state,
|
|
8756
|
+
# Spelled out so a reader months later does not have to know the vocabulary
|
|
8757
|
+
# to judge how strong the authorization was.
|
|
8758
|
+
"anchor_proven": state == "anchored",
|
|
8759
|
+
}, open(os.environ["DR_OUT"], "w"), indent=2)
|
|
8760
|
+
' 2>/dev/null || return 0
|
|
8761
|
+
printf '%s' "$f"
|
|
8762
|
+
return 0
|
|
8763
|
+
}
|
|
8764
|
+
|
|
8765
|
+
# _deploy_first_installed_command <dir> <type>
|
|
8766
|
+
# Echoes the idiomatic deploy command for the FIRST installed CLI of this type,
|
|
8767
|
+
# or "" if none is installed. Same ordering and same canonical strings
|
|
8768
|
+
# _deploy_print_cloud_options prints, so --execute can never run a command that
|
|
8769
|
+
# differs from the one the advisory showed. `command -v` only; no CLI is run.
|
|
8770
|
+
_deploy_first_installed_command() {
|
|
8771
|
+
local dir="${1:-.}" type="${2:-}"
|
|
8772
|
+
local options provider cli command docs
|
|
8773
|
+
options="$(_deploy_options_for_type "$type")"
|
|
8774
|
+
while IFS='|' read -r provider cli command docs; do
|
|
8775
|
+
[ -n "$cli" ] || continue
|
|
8776
|
+
if command -v "$cli" >/dev/null 2>&1; then
|
|
8777
|
+
printf '%s' "$command"
|
|
8778
|
+
return 0
|
|
8779
|
+
fi
|
|
8780
|
+
done <<EOF
|
|
8781
|
+
$options
|
|
8782
|
+
EOF
|
|
8783
|
+
printf '%s' ""
|
|
8784
|
+
return 0
|
|
8785
|
+
}
|
|
8786
|
+
|
|
8787
|
+
# _deploy_try_execute <dir> <type>
|
|
8788
|
+
# The ONLY place a deploy command is ever run. Evaluates the gate, and either
|
|
8789
|
+
# executes + records, or REFUSES naming every failed check. Returns the deploy
|
|
8790
|
+
# command's exit code on execution, 1 on refusal.
|
|
8791
|
+
#
|
|
8792
|
+
# Ordering matters: the advisory has ALREADY been printed by the caller, so a
|
|
8793
|
+
# refusal still leaves the user with exactly the output they get today.
|
|
8794
|
+
_deploy_try_execute() {
|
|
8795
|
+
local dir="${1:-.}" type="${2:-}"
|
|
8796
|
+
|
|
8797
|
+
local command
|
|
8798
|
+
command="$(_deploy_first_installed_command "$dir" "$type")"
|
|
8799
|
+
if [ -z "$command" ]; then
|
|
8800
|
+
echo "" >&2
|
|
8801
|
+
echo -e "${RED}REFUSED: --execute found no installed deploy CLI for this ${type} project.${NC}" >&2
|
|
8802
|
+
echo " Nothing was run. Install one of the CLIs listed above and re-run." >&2
|
|
8803
|
+
return 1
|
|
8804
|
+
fi
|
|
8805
|
+
|
|
8806
|
+
local reasons
|
|
8807
|
+
reasons="$(_deploy_gate "$dir" "$command")"
|
|
8808
|
+
|
|
8809
|
+
# Split the marker out BEFORE the emptiness test: it is a fact about how
|
|
8810
|
+
# the gate passed, never a reason it failed. Left in, it would refuse every
|
|
8811
|
+
# overridden deploy and print the marker as a reason.
|
|
8812
|
+
local anchor_state="anchored"
|
|
8813
|
+
case "$reasons" in
|
|
8814
|
+
*"$_DEPLOY_ANCHOR_MARKER"*)
|
|
8815
|
+
anchor_state="unanchored_overridden"
|
|
8816
|
+
# Shell substitution, NOT `grep -v`: under `set -e` a grep that
|
|
8817
|
+
# filters out every line exits 1 and aborts the deploy silently --
|
|
8818
|
+
# measured, and it looked exactly like the gate refusing.
|
|
8819
|
+
reasons="${reasons//$_DEPLOY_ANCHOR_MARKER/}"
|
|
8820
|
+
# Drop the blank line the removal left WITHOUT joining the real
|
|
8821
|
+
# reasons together: sed deletes empty lines only, so a refusal that
|
|
8822
|
+
# also carries other reasons still prints them one per line.
|
|
8823
|
+
reasons="$(printf '%s' "$reasons" | sed '/^[[:space:]]*$/d')"
|
|
8824
|
+
;;
|
|
8825
|
+
esac
|
|
8826
|
+
|
|
8827
|
+
if [ -n "$reasons" ]; then
|
|
8828
|
+
echo ""
|
|
8829
|
+
echo -e "${RED}REFUSED: not deploying. The evidence gate did not pass.${NC}" >&2
|
|
8830
|
+
echo " Would have run: ${command}" >&2
|
|
8831
|
+
echo "" >&2
|
|
8832
|
+
# printf '%s\n', NOT '%s': command substitution strips the trailing
|
|
8833
|
+
# newline, and `read` then DISCARDS a final unterminated line -- so a
|
|
8834
|
+
# single-reason refusal printed no reasons at all. A refusal that names
|
|
8835
|
+
# nothing is the one thing this gate must never do.
|
|
8836
|
+
printf '%s\n' "$reasons" | while IFS= read -r r; do
|
|
8837
|
+
[ -n "$r" ] && echo " - ${r}" >&2
|
|
8838
|
+
done
|
|
8839
|
+
echo "" >&2
|
|
8840
|
+
echo " A check that could not be EVALUATED is a refusal, not a pass." >&2
|
|
8841
|
+
echo " Nothing was executed. The advisory command above is unchanged." >&2
|
|
8842
|
+
return 1
|
|
8843
|
+
fi
|
|
8844
|
+
|
|
8845
|
+
# Gate passed. Capture the authorizing receipt's identity for the record.
|
|
8846
|
+
local pj run_id anchor
|
|
8847
|
+
pj="$(_deploy_latest_receipt "$dir")"
|
|
8848
|
+
run_id="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("run_id") or "")' "$pj" 2>/dev/null || printf '')"
|
|
8849
|
+
anchor="$(git -C "$dir" rev-parse HEAD 2>/dev/null || printf '')"
|
|
8850
|
+
|
|
8851
|
+
echo ""
|
|
8852
|
+
if [ "$anchor_state" = "unanchored_overridden" ]; then
|
|
8853
|
+
# "VERIFIED this tree" would be FALSE here: the receipt's checks passed,
|
|
8854
|
+
# but nothing tied it to the code about to ship. Saying so is the whole
|
|
8855
|
+
# point of allowing the override rather than weakening the gate.
|
|
8856
|
+
echo -e "${YELLOW}Evidence gate PASSED with the anchor UNPROVEN (--allow-unanchored).${NC}"
|
|
8857
|
+
echo " Receipt ${run_id:-unknown} passed its own checks, but records no baseline,"
|
|
8858
|
+
echo " so it was NOT tied to this tree (${anchor:0:12}). You authorized this."
|
|
8859
|
+
else
|
|
8860
|
+
echo -e "${GREEN}Evidence gate PASSED.${NC} Receipt ${run_id:-unknown} VERIFIED this tree (${anchor:0:12})."
|
|
8861
|
+
fi
|
|
8862
|
+
echo "Running: ${command}"
|
|
8863
|
+
echo ""
|
|
8864
|
+
|
|
8865
|
+
# Run in the project dir. No credential env var is read, set, or forwarded:
|
|
8866
|
+
# the command inherits the user's already-authenticated CLI session as-is.
|
|
8867
|
+
#
|
|
8868
|
+
# eval is used because the canonical commands carry their own flags
|
|
8869
|
+
# ("vercel --prod", "netlify deploy --prod --dir=<out>") and must word-split.
|
|
8870
|
+
# The string is NOT user input: it comes from the hardcoded
|
|
8871
|
+
# _deploy_options_for_type table, and the only path here has already passed
|
|
8872
|
+
# the destructive-operation check in _deploy_gate.
|
|
8873
|
+
local rc=0
|
|
8874
|
+
( cd "$dir" && eval "$command" ) || rc=$?
|
|
8875
|
+
|
|
8876
|
+
local rec
|
|
8877
|
+
rec="$(_deploy_record "$dir" "$command" "$rc" "${run_id:-unknown}" "${anchor:-unknown}" \
|
|
8878
|
+
"$anchor_state")"
|
|
8879
|
+
echo ""
|
|
8880
|
+
if [ "$rc" -eq 0 ]; then
|
|
8881
|
+
echo -e "${GREEN}Deploy command exited 0.${NC}"
|
|
8882
|
+
else
|
|
8883
|
+
echo -e "${RED}Deploy command exited ${rc}.${NC}" >&2
|
|
8884
|
+
fi
|
|
8885
|
+
if [ -n "$rec" ]; then
|
|
8886
|
+
echo "Deploy record: ${rec}"
|
|
8887
|
+
else
|
|
8888
|
+
# The deploy ALREADY RAN. Failing to record it silently is the exact
|
|
8889
|
+
# thing this feature exists to prevent -- an executed deploy with no
|
|
8890
|
+
# evidence of itself -- so it is reported LOUDLY rather than swallowed.
|
|
8891
|
+
# Not an error exit: the deploy's own exit code is the honest verdict on
|
|
8892
|
+
# the deploy, and overriding it would misreport what happened.
|
|
8893
|
+
echo -e "${RED}WARNING: the deploy ran but its record could NOT be written.${NC}" >&2
|
|
8894
|
+
echo " Command: ${command}" >&2
|
|
8895
|
+
echo " Authorizing receipt run_id: ${run_id:-unknown}; anchor: ${anchor:-unknown}" >&2
|
|
8896
|
+
echo " Record this yourself: a deploy with no record of itself is unevidenced." >&2
|
|
8897
|
+
fi
|
|
8898
|
+
return "$rc"
|
|
8899
|
+
}
|
|
8900
|
+
|
|
8901
|
+
# Emitted by _deploy_gate (not a refusal reason) when the anchor was UNPROVEN
|
|
8902
|
+
# and the operator overrode it. The gate runs inside $( ), so this travels on
|
|
8903
|
+
# stdout; a global assigned in the subshell would not survive.
|
|
8904
|
+
_DEPLOY_ANCHOR_MARKER="__LOKI_ANCHOR_UNPROVEN__"
|
|
8905
|
+
|
|
8906
|
+
# _deploy_gate <dir> <command>
|
|
8907
|
+
# THE GATE. Echoes "" when all four checks pass, else the REFUSE reason(s), one
|
|
8908
|
+
# per line. Every check is evaluated so a refusal names everything wrong at once
|
|
8909
|
+
# rather than making the user re-run to discover the next problem.
|
|
8910
|
+
# The opt-in (check 4) is the CALLER's --execute flag and is not re-checked here.
|
|
8911
|
+
_deploy_gate() {
|
|
8912
|
+
local dir="${1:-.}" command="${2:-}"
|
|
8913
|
+
local reasons=""
|
|
8914
|
+
|
|
8915
|
+
# Hard limit, checked FIRST: no receipt authorizes a destructive operation.
|
|
8916
|
+
if _deploy_is_destructive "$command"; then
|
|
8917
|
+
reasons="${reasons}destructive operation refused: no receipt authorizes delete/destroy/teardown/drop
|
|
8918
|
+
"
|
|
8919
|
+
fi
|
|
8920
|
+
|
|
8921
|
+
# An UNFILLED <placeholder> is a command written for a HUMAN to complete,
|
|
8922
|
+
# not one to run. Executing it is at best a shell syntax error and at worst
|
|
8923
|
+
# an unintended redirection ("--dir=<build-output>" redirects stdin from a
|
|
8924
|
+
# file named build-output). Guessing the value would be worse still: we
|
|
8925
|
+
# would deploy a directory nobody chose.
|
|
8926
|
+
case "$command" in
|
|
8927
|
+
*"<"*">"*) reasons="${reasons}deploy command contains an unfilled placeholder -- run it yourself with the real value: ${command}
|
|
8928
|
+
" ;;
|
|
8929
|
+
esac
|
|
8930
|
+
|
|
8931
|
+
local pj
|
|
8932
|
+
pj="$(_deploy_latest_receipt "$dir")"
|
|
8933
|
+
if [ -z "$pj" ]; then
|
|
8934
|
+
reasons="${reasons}no Evidence Receipt found (nothing has verified this tree)
|
|
8935
|
+
"
|
|
8936
|
+
else
|
|
8937
|
+
local verdict
|
|
8938
|
+
verdict="$(_deploy_receipt_verdict "$pj")"
|
|
8939
|
+
case "$verdict" in
|
|
8940
|
+
VERIFIED) : ;;
|
|
8941
|
+
UNSIGNED) reasons="${reasons}receipt is UNSIGNED: integrity holds but provenance is unproven (set LOKI_PROOF_GPG_KEY; docs/SIGNED-RECEIPTS.md)
|
|
8942
|
+
" ;;
|
|
8943
|
+
UNCHECKED) reasons="${reasons}receipt is UNCHECKED: its signature could not be evaluated here, so nothing was proven
|
|
8944
|
+
" ;;
|
|
8945
|
+
TAMPERED) reasons="${reasons}receipt is TAMPERED: its integrity hash does not match its contents
|
|
8946
|
+
" ;;
|
|
8947
|
+
*) reasons="${reasons}receipt verdict could not be determined
|
|
8948
|
+
" ;;
|
|
8949
|
+
esac
|
|
8950
|
+
local anchor_reason
|
|
8951
|
+
anchor_reason="$(_deploy_anchor_ok "$pj" "$dir")"
|
|
8952
|
+
case "$anchor_reason" in
|
|
8953
|
+
UNANCHORED:*)
|
|
8954
|
+
# Nothing was PROVEN either way. Overridable per invocation, and
|
|
8955
|
+
# only the UNANCHORED-prefixed line is dropped: discarding the
|
|
8956
|
+
# whole anchor check (or skipping the call) would also swallow
|
|
8957
|
+
# the stale-receipt refusal, silently turning this flag into
|
|
8958
|
+
# "skip anchoring entirely".
|
|
8959
|
+
if [ "${LOKI_DEPLOY_ALLOW_UNANCHORED:-false}" = "true" ]; then
|
|
8960
|
+
# Emitted on STDOUT as a non-reason marker, not stored in a
|
|
8961
|
+
# variable: _deploy_gate is called inside $( ), so a global
|
|
8962
|
+
# assigned here dies with the subshell. The caller strips
|
|
8963
|
+
# this line before treating what remains as refusal reasons.
|
|
8964
|
+
reasons="${reasons}${_DEPLOY_ANCHOR_MARKER}
|
|
8965
|
+
"
|
|
8966
|
+
else
|
|
8967
|
+
reasons="${reasons}${anchor_reason#UNANCHORED:} (re-run with --allow-unanchored to deploy anyway; nothing about the tree will have been proven)
|
|
8968
|
+
"
|
|
8969
|
+
fi
|
|
8970
|
+
;;
|
|
8971
|
+
"") : ;;
|
|
8972
|
+
*) # Positive evidence of the wrong build. No flag bypasses this.
|
|
8973
|
+
reasons="${reasons}${anchor_reason}
|
|
8974
|
+
" ;;
|
|
8975
|
+
esac
|
|
8976
|
+
fi
|
|
8977
|
+
|
|
8978
|
+
local dirty
|
|
8979
|
+
dirty="$(_deploy_tree_clean "$dir")"
|
|
8980
|
+
[ -n "$dirty" ] && reasons="${reasons}${dirty}
|
|
8981
|
+
"
|
|
8982
|
+
|
|
8983
|
+
printf '%s' "$reasons"
|
|
8984
|
+
return 0
|
|
8985
|
+
}
|
|
8986
|
+
|
|
8987
|
+
# cmd_deploy [--dir <path>] [--no-clip] [--execute] [--help]
|
|
8212
8988
|
# Advisory orchestration: detect project type + CI/CD pipeline + installed cloud
|
|
8213
|
-
# CLIs, then PRINT the canonical deploy command(s). PRINT-ONLY
|
|
8989
|
+
# CLIs, then PRINT the canonical deploy command(s). PRINT-ONLY BY DEFAULT.
|
|
8214
8990
|
# Placed LAST in the contiguous deploy block (all helpers above it) so the SDET
|
|
8215
8991
|
# can extract from the first helper def to the close of cmd_deploy and capture the
|
|
8216
8992
|
# whole self-contained unit (mirrors test-preview-public.sh, where cmd_preview is
|
|
@@ -8218,31 +8994,90 @@ EOF
|
|
|
8218
8994
|
cmd_deploy() {
|
|
8219
8995
|
local dir="${TARGET_DIR:-.}"
|
|
8220
8996
|
local do_clip=true
|
|
8997
|
+
local do_execute=false
|
|
8998
|
+
# `local` so an AMBIENT LOKI_DEPLOY_ALLOW_UNANCHORED=true in the environment
|
|
8999
|
+
# cannot opt in: it is reset to false on every invocation and only the flag
|
|
9000
|
+
# below sets it. Same rule --execute already follows -- no env var or config
|
|
9001
|
+
# file alone weakens this gate.
|
|
9002
|
+
local LOKI_DEPLOY_ALLOW_UNANCHORED=false
|
|
8221
9003
|
|
|
8222
9004
|
# Arg parse (mirror cmd_preview: lenient on unknown args -> no behavior drift).
|
|
8223
9005
|
while [ $# -gt 0 ]; do
|
|
8224
9006
|
case "${1:-}" in
|
|
8225
9007
|
--help|-h|help)
|
|
8226
|
-
echo -e "${BOLD}Loki Mode --
|
|
9008
|
+
echo -e "${BOLD}Loki Mode -- deploy command advisory (print-only by default)${NC}"
|
|
8227
9009
|
echo ""
|
|
8228
|
-
echo "Usage: loki deploy [--dir <path>] [--no-clip]"
|
|
9010
|
+
echo "Usage: loki deploy [--dir <path>] [--no-clip] [--execute]"
|
|
8229
9011
|
echo ""
|
|
8230
9012
|
echo "Detects your project type and your installed cloud CLI (and any"
|
|
8231
9013
|
echo "CI/CD pipeline), then PRINTS the exact deploy command for YOU to run."
|
|
8232
|
-
echo "
|
|
8233
|
-
echo "even --version)
|
|
8234
|
-
echo "
|
|
9014
|
+
echo "By default this is print-only and advisory: it NEVER deploys and"
|
|
9015
|
+
echo "NEVER runs a cloud CLI (not even --version). It NEVER runs"
|
|
9016
|
+
echo "'git push' on ANY path. Detection is read-only. You run the"
|
|
9017
|
+
echo "printed command."
|
|
8235
9018
|
echo ""
|
|
8236
9019
|
echo "If a CI/CD pipeline (GitHub Actions, GitLab CI, Jenkins, CircleCI,"
|
|
8237
9020
|
echo "Azure Pipelines, Bitbucket) is detected, the primary advice is the"
|
|
8238
9021
|
echo "git push + pull-request path, because your pipeline deploys on merge."
|
|
8239
9022
|
echo ""
|
|
9023
|
+
echo -e "${BOLD}--execute: a deploy authorized by a verified receipt${NC}"
|
|
9024
|
+
echo ""
|
|
9025
|
+
echo "With --execute, Loki runs the detected deploy command ONLY when ALL"
|
|
9026
|
+
echo "of these hold. Any one failing REFUSES, and the refusal names which:"
|
|
9027
|
+
echo " 1. An Evidence Receipt exists for this tree and its integrity hash"
|
|
9028
|
+
echo " matches (hash_ok)."
|
|
9029
|
+
echo " 2. Its verdict is VERIFIED. UNSIGNED, UNCHECKED and TAMPERED all"
|
|
9030
|
+
echo " refuse -- integrity is not provenance."
|
|
9031
|
+
echo " 3. The receipt anchors to the commit being deployed. A receipt for"
|
|
9032
|
+
echo " a different commit does not authorize this deploy."
|
|
9033
|
+
echo " Two outcomes are distinguished here, and only one is overridable:"
|
|
9034
|
+
echo " UNANCHORED the receipt records no baseline, so neither a match"
|
|
9035
|
+
echo " nor a mismatch can be proven. Refuses by default;"
|
|
9036
|
+
echo " --allow-unanchored proceeds and records that the"
|
|
9037
|
+
echo " anchor was never proven."
|
|
9038
|
+
echo " MISMATCH a baseline IS recorded and resolves to a different"
|
|
9039
|
+
echo " tree. That is evidence of the wrong build, and"
|
|
9040
|
+
echo " --allow-unanchored does NOT bypass it."
|
|
9041
|
+
echo " 4. You passed --execute on THIS invocation. No env var or config"
|
|
9042
|
+
echo " file alone can opt in."
|
|
9043
|
+
echo ""
|
|
9044
|
+
echo "It FAILS CLOSED: if a check cannot be evaluated at all (no receipt,"
|
|
9045
|
+
echo "gpg unavailable, anchor unresolvable, not a git repo), that is a"
|
|
9046
|
+
echo "REFUSAL, not a pass. A refusal still prints the advisory command."
|
|
9047
|
+
echo ""
|
|
9048
|
+
echo "Hard limits, with or without --execute:"
|
|
9049
|
+
echo " - NEVER runs a destructive cloud operation (delete, destroy,"
|
|
9050
|
+
echo " teardown, drop). Refused even with a valid receipt."
|
|
9051
|
+
echo " - NEVER runs 'git push' and NEVER creates a PR. Out of scope."
|
|
9052
|
+
echo " - NEVER reads or forwards cloud credentials. The deploy command"
|
|
9053
|
+
echo " inherits your already-authenticated CLI session."
|
|
9054
|
+
echo " - REFUSES if the working tree is dirty: deploying something other"
|
|
9055
|
+
echo " than what was verified is the exact failure this prevents."
|
|
9056
|
+
echo ""
|
|
9057
|
+
echo "Every executed deploy writes its own record to .loki/deploys/:"
|
|
9058
|
+
echo "the command, its exit code, the authorizing receipt's run_id, the"
|
|
9059
|
+
echo "resolved anchor, and anchor_state -- so a reader can tell later"
|
|
9060
|
+
echo "whether the anchor was proven or explicitly overridden."
|
|
9061
|
+
echo ""
|
|
8240
9062
|
echo "Options:"
|
|
8241
9063
|
echo " --dir <path> Project directory to scan (default: current dir)"
|
|
8242
9064
|
echo " --no-clip Do not copy the idiomatic command to the clipboard"
|
|
9065
|
+
echo " --execute Run the deploy command if the receipt gate passes"
|
|
9066
|
+
echo " --allow-unanchored"
|
|
9067
|
+
echo " Proceed when the receipt records NO baseline (nothing"
|
|
9068
|
+
echo " about this tree is proven). Never bypasses a recorded"
|
|
9069
|
+
echo " mismatch. Per-invocation only; no env var opts in."
|
|
8243
9070
|
echo " --help, -h Show this help and exit"
|
|
8244
9071
|
return 0
|
|
8245
9072
|
;;
|
|
9073
|
+
--execute)
|
|
9074
|
+
do_execute=true
|
|
9075
|
+
;;
|
|
9076
|
+
--allow-unanchored)
|
|
9077
|
+
# Per-invocation, like --execute: exported for the gate to read,
|
|
9078
|
+
# never persisted and never settable by config alone.
|
|
9079
|
+
LOKI_DEPLOY_ALLOW_UNANCHORED=true
|
|
9080
|
+
;;
|
|
8246
9081
|
--dir)
|
|
8247
9082
|
# Guard against a missing OR flag-shaped value: an unguarded
|
|
8248
9083
|
# shift when --dir is the last arg underflows under set -e, and a
|
|
@@ -8263,6 +9098,10 @@ cmd_deploy() {
|
|
|
8263
9098
|
# Resolve to '.' if the chosen dir does not exist (honest, no crash).
|
|
8264
9099
|
[ -d "$dir" ] || dir="."
|
|
8265
9100
|
|
|
9101
|
+
# Tells the advisory printer which closing sentence is TRUE for this run.
|
|
9102
|
+
# Scoped to this invocation only.
|
|
9103
|
+
local _LOKI_DEPLOY_EXECUTING="$do_execute"
|
|
9104
|
+
|
|
8266
9105
|
# Detect CI/CD pipeline FIRST (BRANCH-LIFECYCLE LOCK B3 precedence).
|
|
8267
9106
|
local cicd=""
|
|
8268
9107
|
cicd="$(_deploy_detect_cicd "$dir" || true)"
|
|
@@ -8327,6 +9166,21 @@ cmd_deploy() {
|
|
|
8327
9166
|
: # No installed cloud CLI; the pipeline path already advised. Fine.
|
|
8328
9167
|
fi
|
|
8329
9168
|
fi
|
|
9169
|
+
# --execute applies to the CLOUD command only. git push / PR creation
|
|
9170
|
+
# stay out of scope entirely, so on the pipeline path --execute never
|
|
9171
|
+
# touches the primary (git) advice above -- it can only run the
|
|
9172
|
+
# secondary cloud command, and only if the receipt gate passes.
|
|
9173
|
+
if [ "$do_execute" = true ]; then
|
|
9174
|
+
if [ -n "$type" ]; then
|
|
9175
|
+
_deploy_try_execute "$dir" "$type"
|
|
9176
|
+
return $?
|
|
9177
|
+
fi
|
|
9178
|
+
echo "" >&2
|
|
9179
|
+
echo -e "${RED}REFUSED: --execute has nothing to run here.${NC}" >&2
|
|
9180
|
+
echo " No deployable project type detected, and Loki never runs 'git push'" >&2
|
|
9181
|
+
echo " or creates a PR. Run the git advice above yourself." >&2
|
|
9182
|
+
return 1
|
|
9183
|
+
fi
|
|
8330
9184
|
return 0
|
|
8331
9185
|
fi
|
|
8332
9186
|
|
|
@@ -8346,6 +9200,14 @@ cmd_deploy() {
|
|
|
8346
9200
|
# honest install hint) when NO matching CLI is installed.
|
|
8347
9201
|
local rc=0
|
|
8348
9202
|
_deploy_print_cloud_options "$dir" "$type" "$do_clip" "true" || rc=$?
|
|
9203
|
+
|
|
9204
|
+
# Execute ONLY if the user opted in on THIS invocation and the advisory
|
|
9205
|
+
# above found something to run. The advisory has already printed, so a
|
|
9206
|
+
# refusal below still leaves the user with today's output.
|
|
9207
|
+
if [ "$do_execute" = true ] && [ "$rc" -eq 0 ]; then
|
|
9208
|
+
_deploy_try_execute "$dir" "$type"
|
|
9209
|
+
return $?
|
|
9210
|
+
fi
|
|
8349
9211
|
return $rc
|
|
8350
9212
|
}
|
|
8351
9213
|
|
|
@@ -14205,6 +15067,35 @@ $brief_text
|
|
|
14205
15067
|
BRIEFEOF
|
|
14206
15068
|
}
|
|
14207
15069
|
|
|
15070
|
+
# Build a bounded input for `loki start <repo-directory>`. This intentionally
|
|
15071
|
+
# contains no caller-controlled path: repository identity comes from cwd, while
|
|
15072
|
+
# the task remains stable, reviewable, and safe to render into a prompt.
|
|
15073
|
+
synthesize_repo_prd() {
|
|
15074
|
+
local out_file="$1"
|
|
15075
|
+
mkdir -p "$(dirname "$out_file")" 2>/dev/null || true
|
|
15076
|
+
cat > "$out_file" <<'REPOEOF'
|
|
15077
|
+
# Existing Repository Improvement
|
|
15078
|
+
|
|
15079
|
+
## Outcome
|
|
15080
|
+
Inspect this repository's instructions, product signals, and current tests.
|
|
15081
|
+
Select one small, high-value user-facing improvement and implement it completely.
|
|
15082
|
+
|
|
15083
|
+
## Success Criteria
|
|
15084
|
+
- The improvement has a clear user outcome and a bounded diff.
|
|
15085
|
+
- Focused tests or checks prove the changed behavior.
|
|
15086
|
+
- Existing unrelated work is preserved.
|
|
15087
|
+
- Loki's normal outcome evidence identifies what changed and what passed.
|
|
15088
|
+
|
|
15089
|
+
## Constraints
|
|
15090
|
+
- Make one coherent improvement, not a broad rewrite.
|
|
15091
|
+
- Reuse the repository's established architecture and dependencies.
|
|
15092
|
+
- Keep the change easy to review and roll back.
|
|
15093
|
+
- Do not claim deployment, adoption, or customer value without evidence.
|
|
15094
|
+
|
|
15095
|
+
**Mode:** Existing repository improvement (full verification)
|
|
15096
|
+
REPOEOF
|
|
15097
|
+
}
|
|
15098
|
+
|
|
14208
15099
|
# Quick mode - lightweight single-task execution
|
|
14209
15100
|
cmd_quick() {
|
|
14210
15101
|
# v7.6.3 B-11 fix: --help previously fell through to provider invocation
|
|
@@ -34288,6 +35179,13 @@ cmd_proof() {
|
|
|
34288
35179
|
echo " show <id> Pretty-print .loki/proofs/<id>/proof.json"
|
|
34289
35180
|
echo " verify <id> Re-check a receipt against the repo (tamper + drift);"
|
|
34290
35181
|
echo " exit 0 clean, 1 tamper/drift. Verify it yourself."
|
|
35182
|
+
echo ""
|
|
35183
|
+
echo "Options for 'verify':"
|
|
35184
|
+
echo " --human Render the verdict and every failure reason as prose"
|
|
35185
|
+
echo " --jwks <url|file> Also check the receipt's attestation against a published"
|
|
35186
|
+
echo " key set. Accepts a server URL or a local jwks.json, so a"
|
|
35187
|
+
echo " third party can check WHO produced a receipt with no API"
|
|
35188
|
+
echo " token, no gpg key import, and no network."
|
|
34291
35189
|
echo " md <id> Paste-able Markdown for a PR comment or Slack
|
|
34292
35190
|
open <id> Open .loki/proofs/<id>/index.html in a browser"
|
|
34293
35191
|
echo " share <id> Publish the proof page as a GitHub Gist (opt-in)"
|
|
@@ -34503,14 +35401,31 @@ PYEOF
|
|
|
34503
35401
|
# stdout verbatim, are unaffected. The flag is STRIPPED from the
|
|
34504
35402
|
# positional args rather than assumed at a fixed index, so it works
|
|
34505
35403
|
# before or after the id.
|
|
34506
|
-
|
|
34507
|
-
|
|
35404
|
+
# --jwks <url-or-file> additionally checks the receipt's
|
|
35405
|
+
# attestation against a published key set. This is the third-party
|
|
35406
|
+
# path: an auditor who was handed a proof.json can check WHO
|
|
35407
|
+
# produced it without running a build, without our API token, and
|
|
35408
|
+
# without importing a gpg key. A FILE is accepted as well as a URL
|
|
35409
|
+
# so an air-gapped reviewer, handed the receipt and the key set on
|
|
35410
|
+
# disk, can still verify -- requiring the network would exclude
|
|
35411
|
+
# exactly the reviewer who most needs this.
|
|
35412
|
+
local _pv_human=0 _pv_jwks="" _pv_args=()
|
|
35413
|
+
local _pv_a _pv_want_jwks=0
|
|
34508
35414
|
for _pv_a in "$@"; do
|
|
35415
|
+
if [ "$_pv_want_jwks" -eq 1 ]; then
|
|
35416
|
+
_pv_jwks="$_pv_a"; _pv_want_jwks=0; continue
|
|
35417
|
+
fi
|
|
34509
35418
|
case "$_pv_a" in
|
|
34510
35419
|
--human) _pv_human=1 ;;
|
|
35420
|
+
--jwks) _pv_want_jwks=1 ;;
|
|
35421
|
+
--jwks=*) _pv_jwks="${_pv_a#--jwks=}" ;;
|
|
34511
35422
|
*) _pv_args+=("$_pv_a") ;;
|
|
34512
35423
|
esac
|
|
34513
35424
|
done
|
|
35425
|
+
if [ "$_pv_want_jwks" -eq 1 ]; then
|
|
35426
|
+
echo -e "${RED}--jwks needs a URL or file path.${NC}" >&2
|
|
35427
|
+
exit 2
|
|
35428
|
+
fi
|
|
34514
35429
|
set -- "${_pv_args[@]+"${_pv_args[@]}"}"
|
|
34515
35430
|
|
|
34516
35431
|
local id="${1:-}"
|
|
@@ -34531,12 +35446,53 @@ PYEOF
|
|
|
34531
35446
|
echo -e "${RED}Verifier not found (autonomy/lib/proof-verify.py).${NC}"
|
|
34532
35447
|
exit 2
|
|
34533
35448
|
fi
|
|
35449
|
+
# `|| _pv_rc=$?` is load-bearing: this script runs under `set -e`
|
|
35450
|
+
# (loki:22), so a bare call to a verifier that exits 1 on drift --
|
|
35451
|
+
# its NORMAL result -- aborts the whole command before the exit
|
|
35452
|
+
# status can be read. Without the guard the attestation block below
|
|
35453
|
+
# is unreachable on exactly the receipts it exists to check.
|
|
35454
|
+
local _pv_rc=0
|
|
34534
35455
|
if [ "$_pv_human" -eq 1 ]; then
|
|
34535
|
-
python3 "$verifier" --human "$pj" "${TARGET_DIR:-.}"
|
|
35456
|
+
python3 "$verifier" --human "$pj" "${TARGET_DIR:-.}" || _pv_rc=$?
|
|
34536
35457
|
else
|
|
34537
|
-
python3 "$verifier" "$pj" "${TARGET_DIR:-.}"
|
|
35458
|
+
python3 "$verifier" "$pj" "${TARGET_DIR:-.}" || _pv_rc=$?
|
|
35459
|
+
fi
|
|
35460
|
+
|
|
35461
|
+
# The attestation check runs AFTER, and can only make the verdict
|
|
35462
|
+
# worse, never better. A valid attestation does not excuse a failed
|
|
35463
|
+
# integrity hash: the signature proves who produced the bytes, not
|
|
35464
|
+
# that the bytes are internally consistent. Folding the two together
|
|
35465
|
+
# would let a signed-but-tampered receipt exit 0.
|
|
35466
|
+
if [ -n "$_pv_jwks" ]; then
|
|
35467
|
+
local _pv_att
|
|
35468
|
+
_pv_att="$(loki_proof_attestation_check "$pj" "$_pv_jwks")"
|
|
35469
|
+
case "$_pv_att" in
|
|
35470
|
+
ok)
|
|
35471
|
+
echo "attestation: VERIFIED against ${_pv_jwks}" >&2
|
|
35472
|
+
echo " Proven: these bytes were signed by the holder of a key in that set." >&2
|
|
35473
|
+
echo " NOT proven: that the code is correct. A receipt records which checks ran." >&2
|
|
35474
|
+
;;
|
|
35475
|
+
bad)
|
|
35476
|
+
echo -e "${RED}attestation: FAILED -- the token does not cover these bytes.${NC}" >&2
|
|
35477
|
+
echo " Either the receipt was altered after signing, or the attestation" >&2
|
|
35478
|
+
echo " belongs to a different run. Do not trust this receipt." >&2
|
|
35479
|
+
_pv_rc=1
|
|
35480
|
+
;;
|
|
35481
|
+
none)
|
|
35482
|
+
echo -e "${YELLOW}attestation: ABSENT -- this receipt carries no attestation.${NC}" >&2
|
|
35483
|
+
echo " Nothing was checked against ${_pv_jwks}. The receipt may still be" >&2
|
|
35484
|
+
echo " gpg-signed; this says only that it has no JWKS attestation." >&2
|
|
35485
|
+
;;
|
|
35486
|
+
*)
|
|
35487
|
+
# Unreachable key set, unreadable file, missing python
|
|
35488
|
+
# deps. NOT a verdict: an absent measurement must never
|
|
35489
|
+
# read as either a pass or an accusation.
|
|
35490
|
+
echo -e "${YELLOW}attestation: NOT CHECKED -- could not read a key set from ${_pv_jwks}.${NC}" >&2
|
|
35491
|
+
echo " This is not a failure of the receipt. Nothing was proven either way." >&2
|
|
35492
|
+
;;
|
|
35493
|
+
esac
|
|
34538
35494
|
fi
|
|
34539
|
-
exit
|
|
35495
|
+
exit "$_pv_rc"
|
|
34540
35496
|
;;
|
|
34541
35497
|
open)
|
|
34542
35498
|
local id="${1:-}"
|