loki-mode 9.18.2 → 9.19.1
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/loki +631 -24
- package/autonomy/trigger-server.py +74 -24
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +18 -0
- package/dashboard/static/index.html +144 -0
- package/docs/COMPETITIVE-SCORECARD.md +45 -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/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v9.
|
|
6
|
+
# Loki Mode v9.19.1
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -470,4 +470,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
|
|
|
470
470
|
|
|
471
471
|
---
|
|
472
472
|
|
|
473
|
-
**v9.
|
|
473
|
+
**v9.19.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
9.
|
|
1
|
+
9.19.1
|
package/autonomy/loki
CHANGED
|
@@ -684,7 +684,7 @@ loki_remote_submit() {
|
|
|
684
684
|
last="$status"
|
|
685
685
|
fi
|
|
686
686
|
case "$status" in
|
|
687
|
-
|
|
687
|
+
passed|failed|unknown|error|rejected*)
|
|
688
688
|
break
|
|
689
689
|
;;
|
|
690
690
|
esac
|
|
@@ -701,16 +701,22 @@ loki_remote_submit() {
|
|
|
701
701
|
return 1
|
|
702
702
|
fi
|
|
703
703
|
|
|
704
|
-
#
|
|
705
|
-
#
|
|
706
|
-
#
|
|
707
|
-
#
|
|
708
|
-
#
|
|
709
|
-
#
|
|
704
|
+
# Gate on the BUILD's outcome, not on "a build was launched". The server
|
|
705
|
+
# reports passed / failed / unknown as distinct terminal statuses (see
|
|
706
|
+
# JOB_TERMINAL_STATUSES in trigger-server.py). Anything that is not
|
|
707
|
+
# "passed" exits non-zero so a CI pipeline cannot go green on a failed
|
|
708
|
+
# build. "unknown" -- the build detached and its exit code was never
|
|
709
|
+
# observed -- fails CLOSED: an outcome nobody observed is not a pass.
|
|
710
710
|
case "$status" in
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
711
|
+
passed) return 0 ;;
|
|
712
|
+
unknown)
|
|
713
|
+
echo -e "${RED}loki: remote job $job_id outcome UNKNOWN${NC}" >&2
|
|
714
|
+
echo " The build detached and its exit code was never observed." >&2
|
|
715
|
+
echo " An unobserved outcome is not a pass; treating it as a failure." >&2
|
|
716
|
+
return 1
|
|
717
|
+
;;
|
|
718
|
+
"") echo -e "${RED}loki: job $job_id did not reach a terminal status${NC}" >&2; return 1 ;;
|
|
719
|
+
*) echo -e "${RED}loki: remote job $job_id failed: $status${NC}" >&2; return 1 ;;
|
|
714
720
|
esac
|
|
715
721
|
}
|
|
716
722
|
|
|
@@ -832,6 +838,17 @@ loki_remote_verify_receipt() {
|
|
|
832
838
|
return 1
|
|
833
839
|
fi
|
|
834
840
|
|
|
841
|
+
# Drift is REPORTED, never a verdict. This receipt was produced in a cluster
|
|
842
|
+
# pod and is being checked against a different tree, so drift is the normal
|
|
843
|
+
# result and says nothing about tampering -- docs/VERIFICATION-COST.md:
|
|
844
|
+
# hash_ok is integrity, `ok` folds in tree_drift and false-alarms here.
|
|
845
|
+
# Stated rather than suppressed: silently dropping a true fact because it is
|
|
846
|
+
# inconvenient is the same error as folding UNCHECKED into UNSIGNED.
|
|
847
|
+
if [ "$(printf '%s' "$out" | jq -r '[.diff_drift,.tree_drift]|index(true)!=null' 2>/dev/null)" = "true" ]; then
|
|
848
|
+
echo " Drift: this tree differs from the one the receipt records. Expected --"
|
|
849
|
+
echo " the build ran elsewhere. Not tampering, and not part of the verdict."
|
|
850
|
+
fi
|
|
851
|
+
|
|
835
852
|
# gpg_ok=false covers TWO facts that must never be conflated: the signature
|
|
836
853
|
# is cryptographically bad over this content (the receipt was ALTERED), and
|
|
837
854
|
# gpg could not evaluate it at all (we do not hold the public key). Only the
|
|
@@ -7950,12 +7967,23 @@ cmd_preview() {
|
|
|
7950
7967
|
# build started, so it has NO running-app precondition. Folding it into preview
|
|
7951
7968
|
# would force preview's running-app gate onto a feature that must not have one.
|
|
7952
7969
|
#
|
|
7953
|
-
# HARD INVARIANT (DEPLOY-PLAN LOCK 5 + BRANCH-LIFECYCLE LOCK B4): PRINT-ONLY
|
|
7954
|
-
# cmd_deploy NEVER runs a cloud CLI
|
|
7955
|
-
#
|
|
7956
|
-
# `command -v` ONLY. Loki advises; the human runs the printed command.
|
|
7957
|
-
#
|
|
7958
|
-
#
|
|
7970
|
+
# HARD INVARIANT (DEPLOY-PLAN LOCK 5 + BRANCH-LIFECYCLE LOCK B4): PRINT-ONLY BY
|
|
7971
|
+
# DEFAULT. Without --execute, cmd_deploy NEVER runs a cloud CLI
|
|
7972
|
+
# (vercel/netlify/flyctl/wrangler) -- not even `--version`. Tool detection is
|
|
7973
|
+
# `command -v` ONLY. Loki advises; the human runs the printed command.
|
|
7974
|
+
#
|
|
7975
|
+
# --execute (added for receipt-gated deploy) is the ONLY path that runs a deploy
|
|
7976
|
+
# command, and it is gated on an Evidence Receipt that VERIFIES this exact tree
|
|
7977
|
+
# (see the RECEIPT-GATED EXECUTION block below). The invariants that survive
|
|
7978
|
+
# unconditionally, with or without --execute:
|
|
7979
|
+
# - NEVER `git push`, NEVER `gh pr create`. Out of scope entirely.
|
|
7980
|
+
# - NEVER a destructive cloud operation (delete/destroy/teardown/drop),
|
|
7981
|
+
# refused even with a perfect receipt.
|
|
7982
|
+
# - NEVER reads or forwards cloud credentials. The deploy command inherits the
|
|
7983
|
+
# user's already-authenticated CLI session; Loki does not touch credential
|
|
7984
|
+
# files or secret-bearing env vars.
|
|
7985
|
+
# Only the clipboard tools (pbcopy/wl-copy/...), `command -v`, and -- under
|
|
7986
|
+
# --execute alone -- the gated deploy command itself may run.
|
|
7959
7987
|
#
|
|
7960
7988
|
# This whole block is contiguous (helpers + cmd_deploy) so a test can extract it
|
|
7961
7989
|
# by name anchor, mirroring tests/test-preview-public.sh.
|
|
@@ -8176,7 +8204,16 @@ $options
|
|
|
8176
8204
|
EOF
|
|
8177
8205
|
|
|
8178
8206
|
if [ "$printed" = true ]; then
|
|
8179
|
-
|
|
8207
|
+
# This sentence is TRUE only on the print-only path. Under --execute the
|
|
8208
|
+
# very next thing that happens may be the deploy running, and printing
|
|
8209
|
+
# "Loki does not deploy for you" immediately before Loki deploys is
|
|
8210
|
+
# exactly the kind of false statement this feature exists to not make.
|
|
8211
|
+
# _LOKI_DEPLOY_EXECUTING is set by cmd_deploy for this one decision.
|
|
8212
|
+
if [ "${_LOKI_DEPLOY_EXECUTING:-false}" = true ]; then
|
|
8213
|
+
echo "Reviewing this command against the evidence gate (--execute)."
|
|
8214
|
+
else
|
|
8215
|
+
echo "Loki does not deploy for you. Review, then run the command yourself."
|
|
8216
|
+
fi
|
|
8180
8217
|
if [ "$do_clip" = true ] && [ -n "$first_cmd" ]; then
|
|
8181
8218
|
_deploy_copy_clipboard "$first_cmd"
|
|
8182
8219
|
fi
|
|
@@ -8191,9 +8228,493 @@ EOF
|
|
|
8191
8228
|
return 1
|
|
8192
8229
|
}
|
|
8193
8230
|
|
|
8194
|
-
#
|
|
8231
|
+
# =============================================================================
|
|
8232
|
+
# RECEIPT-GATED EXECUTION (--execute). Everything below is INERT unless the user
|
|
8233
|
+
# passes --execute on THIS invocation; the default path stays print-only.
|
|
8234
|
+
#
|
|
8235
|
+
# The design idea: a deploy is an action a VERIFIED RECEIPT AUTHORIZES. Not
|
|
8236
|
+
# "let the agent run gcloud" -- the gate is the evidence artifact we already
|
|
8237
|
+
# ship, and it must hold on every one of four independent checks.
|
|
8238
|
+
#
|
|
8239
|
+
# FAIL CLOSED IS THE WHOLE CONTRACT. Every helper here answers "" or a REFUSE
|
|
8240
|
+
# reason when it cannot evaluate its check. An unevaluated gate is not a pass:
|
|
8241
|
+
# no receipt, no python3, no gpg, a non-repo dir and an unresolvable anchor all
|
|
8242
|
+
# REFUSE. That direction is deliberate -- a gate that cannot tell you whether
|
|
8243
|
+
# the tree was verified must not deploy it.
|
|
8244
|
+
# =============================================================================
|
|
8245
|
+
|
|
8246
|
+
# _deploy_receipt_verdict <proof.json>
|
|
8247
|
+
# Echoes exactly one of VERIFIED | UNSIGNED | UNCHECKED | TAMPERED, the same four
|
|
8248
|
+
# verdicts loki_remote_verify_receipt reports (see its header at loki:756).
|
|
8249
|
+
# The vocabulary is REUSED, not reinvented, because a second set of words for
|
|
8250
|
+
# the same four states is how they get collapsed into two.
|
|
8251
|
+
#
|
|
8252
|
+
# Deliberately NOT loki_remote_verify_receipt itself: that function returns 0 for
|
|
8253
|
+
# UNSIGNED, which is right for its caller (submit) and wrong here (integrity is
|
|
8254
|
+
# not provenance). We read the same signed/hash_ok/gpg_ok triple and let the
|
|
8255
|
+
# CALLER decide which verdicts authorize.
|
|
8256
|
+
#
|
|
8257
|
+
# hash_ok is read, NOT the top-level `ok` and NOT the exit code: `ok` folds in
|
|
8258
|
+
# tree_drift, and proof-verify.py exits 1 on drift alone. Gating on either would
|
|
8259
|
+
# refuse every honest receipt whose tree moved -- the false-alarm direction
|
|
8260
|
+
# docs/VERIFICATION-COST.md documents. Anything unreadable is TAMPERED-or-worse,
|
|
8261
|
+
# so the fallback verdict is UNCHECKED (never a pass).
|
|
8262
|
+
_deploy_receipt_verdict() {
|
|
8263
|
+
local pj="$1"
|
|
8264
|
+
local verifier="${_LOKI_SCRIPT_DIR}/lib/proof-verify.py"
|
|
8265
|
+
|
|
8266
|
+
[ -f "$pj" ] || { printf '%s' "UNCHECKED"; return 0; }
|
|
8267
|
+
if [ ! -f "$verifier" ] || ! command -v python3 >/dev/null 2>&1; then
|
|
8268
|
+
printf '%s' "UNCHECKED"; return 0
|
|
8269
|
+
fi
|
|
8270
|
+
|
|
8271
|
+
local out hash_ok gpg_ok signed=0
|
|
8272
|
+
out="$(python3 "$verifier" "$pj" "${TARGET_DIR:-.}" 2>/dev/null || true)"
|
|
8273
|
+
|
|
8274
|
+
# "The verifier did not RUN" and "the verifier says the hash is bad" are
|
|
8275
|
+
# different facts and must not collapse. If they do, a broken python3
|
|
8276
|
+
# produces the verdict TAMPERED -- an accusation of forgery for a check that
|
|
8277
|
+
# never executed. That is the same error the signed path is careful to avoid
|
|
8278
|
+
# with NO_PUBKEY (see loki:852): it sends a user hunting a forgery that never
|
|
8279
|
+
# happened, and trains them to shrug at a real TAMPERED. Unparseable output
|
|
8280
|
+
# is UNCHECKED, which still REFUSES -- fail-closed, honestly labelled.
|
|
8281
|
+
if [ -z "$out" ] || ! printf '%s' "$out" | python3 -c 'import json,sys;json.load(sys.stdin)' >/dev/null 2>&1; then
|
|
8282
|
+
printf '%s' "UNCHECKED"; return 0
|
|
8283
|
+
fi
|
|
8284
|
+
|
|
8285
|
+
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')"
|
|
8286
|
+
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')"
|
|
8287
|
+
|
|
8288
|
+
# Whether the receipt CARRIES a signature is read from the receipt, never
|
|
8289
|
+
# inferred from gpg_ok -- gpg_ok is "n/a" both for "no signature" and for
|
|
8290
|
+
# "gpg missing here", and conflating those mislabels a signed receipt.
|
|
8291
|
+
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
|
|
8292
|
+
signed=1
|
|
8293
|
+
fi
|
|
8294
|
+
|
|
8295
|
+
if [ "$hash_ok" != "true" ]; then printf '%s' "TAMPERED"; return 0; fi
|
|
8296
|
+
if [ "$signed" -eq 0 ]; then printf '%s' "UNSIGNED"; return 0; fi
|
|
8297
|
+
case "$gpg_ok" in
|
|
8298
|
+
true) printf '%s' "VERIFIED" ;;
|
|
8299
|
+
# Signed but the signature does not verify: contents or signature were
|
|
8300
|
+
# altered. A missing PUBLIC KEY is a different fact (uncheckable, not an
|
|
8301
|
+
# accusation), so it reports UNCHECKED -- which refuses here anyway.
|
|
8302
|
+
false) if command -v gpg >/dev/null 2>&1 && [ "$(loki_remote_gpg_status "$pj")" = "nopubkey" ]; then
|
|
8303
|
+
printf '%s' "UNCHECKED"
|
|
8304
|
+
else
|
|
8305
|
+
printf '%s' "TAMPERED"
|
|
8306
|
+
fi ;;
|
|
8307
|
+
*) printf '%s' "UNCHECKED" ;;
|
|
8308
|
+
esac
|
|
8309
|
+
return 0
|
|
8310
|
+
}
|
|
8311
|
+
|
|
8312
|
+
# _deploy_anchor_ok <proof.json> <dir>
|
|
8313
|
+
# Echoes "" when the receipt anchors to the tree in front of us, else a REFUSE
|
|
8314
|
+
# reason. Two checks, and BOTH are load-bearing:
|
|
8315
|
+
#
|
|
8316
|
+
# 1. resolve_anchor (autonomy/lib/outcome_ledger.py:122) -- sha algebra proving
|
|
8317
|
+
# base..head is reachable and produces exactly the receipt's file set.
|
|
8318
|
+
# 2. head_sha == live HEAD.
|
|
8319
|
+
#
|
|
8320
|
+
# Check 2 is NOT redundant, and this was MEASURED rather than assumed: on a
|
|
8321
|
+
# fixture where the tree advanced one commit past the receipt, resolve_anchor
|
|
8322
|
+
# still returned ("anchored", None) -- correctly, because it answers "can I
|
|
8323
|
+
# follow this receipt to measure outcomes", not "does this receipt describe the
|
|
8324
|
+
# tree I am about to ship". Without check 2 a receipt for an ANCESTOR commit
|
|
8325
|
+
# authorizes deploying code it never verified, which is the exact failure this
|
|
8326
|
+
# feature exists to prevent.
|
|
8327
|
+
_deploy_anchor_ok() {
|
|
8328
|
+
local pj="$1" dir="${2:-.}"
|
|
8329
|
+
command -v python3 >/dev/null 2>&1 || { printf '%s' "anchor could not be evaluated (python3 unavailable)"; return 0; }
|
|
8330
|
+
git -C "$dir" rev-parse --git-dir >/dev/null 2>&1 || { printf '%s' "anchor could not be evaluated (not a git repository)"; return 0; }
|
|
8331
|
+
|
|
8332
|
+
local live_head
|
|
8333
|
+
live_head="$(git -C "$dir" rev-parse HEAD 2>/dev/null || printf '')"
|
|
8334
|
+
[ -n "$live_head" ] || { printf '%s' "anchor could not be evaluated (no HEAD commit)"; return 0; }
|
|
8335
|
+
|
|
8336
|
+
# _LOKI_SCRIPT_DIR is a plain shell var, so it is passed EXPLICITLY here --
|
|
8337
|
+
# relying on it being exported would silently fall back to a bare "lib" path
|
|
8338
|
+
# and report every receipt as unevaluable.
|
|
8339
|
+
PJ="$pj" LIVE_HEAD="$live_head" LOKI_LIB="${_LOKI_SCRIPT_DIR}/lib" \
|
|
8340
|
+
python3 - "$dir" <<'PYANCHOR' 2>/dev/null || printf '%s' "anchor could not be evaluated (anchor check failed to run)"
|
|
8341
|
+
import json, os, sys
|
|
8342
|
+
sys.dont_write_bytecode = True
|
|
8343
|
+
sys.path.insert(0, os.environ.get("LOKI_LIB", ""))
|
|
8344
|
+
try:
|
|
8345
|
+
from outcome_ledger import resolve_anchor
|
|
8346
|
+
except Exception:
|
|
8347
|
+
print("anchor could not be evaluated (outcome_ledger unavailable)", end="")
|
|
8348
|
+
raise SystemExit(0)
|
|
8349
|
+
d = json.load(open(os.environ["PJ"]))
|
|
8350
|
+
git = (d.get("facts") or {}).get("git") or {}
|
|
8351
|
+
base, head = git.get("base_sha") or "", git.get("head_sha") or ""
|
|
8352
|
+
files = git.get("diff") or d.get("files_changed") or []
|
|
8353
|
+
if not isinstance(files, list):
|
|
8354
|
+
files = []
|
|
8355
|
+
state, reason = resolve_anchor(base, head, files, sys.argv[1])
|
|
8356
|
+
if state != "anchored":
|
|
8357
|
+
# TWO DIFFERENT FACTS, and collapsing them makes this gate unusable.
|
|
8358
|
+
#
|
|
8359
|
+
# Measured on this repository: 8 of 9 receipts carry NO facts.git.base_sha,
|
|
8360
|
+
# which matches the "0 of 9 anchored" figure already published in
|
|
8361
|
+
# docs/VERIFICATION-COST.md. Refusing both cases identically means the gate
|
|
8362
|
+
# rejects ~89% of real receipts on anchoring alone -- and a gate that always
|
|
8363
|
+
# refuses teaches users to reach for an override, which is worse than no
|
|
8364
|
+
# gate at all.
|
|
8365
|
+
#
|
|
8366
|
+
# UNANCHORED no baseline was recorded. We can prove neither match nor
|
|
8367
|
+
# mismatch. Refuse by DEFAULT, but let the caller override
|
|
8368
|
+
# per invocation, and record that nothing was proven.
|
|
8369
|
+
# MISMATCH a baseline WAS recorded and it does not resolve to this
|
|
8370
|
+
# tree. That is positive evidence of the wrong build, and no
|
|
8371
|
+
# override may bypass it.
|
|
8372
|
+
#
|
|
8373
|
+
# The override covers "unknown", never "known wrong". Emitting a machine
|
|
8374
|
+
# readable prefix so the caller can tell the two apart without parsing prose.
|
|
8375
|
+
#
|
|
8376
|
+
# DISCRIMINATED ON THE REASON NAME, not on `not base`. MEASURED: the receipt
|
|
8377
|
+
# this repo's gate actually selects carries base_sha=4b825dc6 (the empty
|
|
8378
|
+
# tree), so `base` is TRUTHY and a `not base` test files it under MISMATCH --
|
|
8379
|
+
# the one case blocking the repo would stay unoverridable. resolve_anchor
|
|
8380
|
+
# returns base_sha_empty (line 135) BEFORE the greenfield check (line 137),
|
|
8381
|
+
# so reaching greenfield_no_baseline proves base_sha was non-empty.
|
|
8382
|
+
#
|
|
8383
|
+
# Exactly two reasons mean "no baseline was recorded", and together they
|
|
8384
|
+
# cover all 9 receipts here (8 base_sha_empty, 1 greenfield_no_baseline).
|
|
8385
|
+
# Every OTHER reason keeps refusing, including with the flag set:
|
|
8386
|
+
# not_reachable_from_head, diff_range_mismatch -- positive evidence of a
|
|
8387
|
+
# DIFFERENT change. Mismatch by construction.
|
|
8388
|
+
# change_not_committed, sha_not_in_history, head_sha_empty, no_git -- the
|
|
8389
|
+
# check could not be evaluated, and this gate's stated doctrine is that
|
|
8390
|
+
# an unevaluable check is a refusal, not a pass.
|
|
8391
|
+
if reason in ("base_sha_empty", "greenfield_no_baseline"):
|
|
8392
|
+
print("UNANCHORED:receipt records no baseline (%s), so it cannot be tied "
|
|
8393
|
+
"to this tree" % reason, end="")
|
|
8394
|
+
else:
|
|
8395
|
+
print("receipt does not anchor to this tree (%s)" % (reason or "unresolvable"), end="")
|
|
8396
|
+
elif head != os.environ["LIVE_HEAD"]:
|
|
8397
|
+
# Anchored but STALE: the receipt verified an ancestor, not this tree.
|
|
8398
|
+
print("receipt records a different commit (%s) than HEAD (%s)"
|
|
8399
|
+
% (head[:12], os.environ["LIVE_HEAD"][:12]), end="")
|
|
8400
|
+
PYANCHOR
|
|
8401
|
+
return 0
|
|
8402
|
+
}
|
|
8403
|
+
|
|
8404
|
+
# _deploy_is_destructive <command>
|
|
8405
|
+
# Returns 0 (true) if the command names a destructive cloud operation. Refused
|
|
8406
|
+
# even under --execute with a perfect receipt: a receipt authorizes shipping the
|
|
8407
|
+
# verified tree, never tearing an environment down. Word-boundary matched so
|
|
8408
|
+
# "wrangler pages deploy dist" is not caught by "destroy" appearing in a path.
|
|
8409
|
+
_deploy_is_destructive() {
|
|
8410
|
+
printf '%s' "${1:-}" | grep -Eqi '(^|[^[:alnum:]_-])(delete|destroy|teardown|down|drop|rm|remove|purge|prune|wipe|scale-down)([^[:alnum:]_-]|$)'
|
|
8411
|
+
}
|
|
8412
|
+
|
|
8413
|
+
# _deploy_tree_clean <dir>
|
|
8414
|
+
# Echoes "" when the working tree is clean, else a REFUSE reason. A dirty tree
|
|
8415
|
+
# means the bytes about to ship are NOT the bytes that were verified, which is
|
|
8416
|
+
# precisely the failure the receipt gate exists to prevent. A non-repo is
|
|
8417
|
+
# UNEVALUABLE, not clean -- it lands in REFUSE with a named reason.
|
|
8418
|
+
_deploy_tree_clean() {
|
|
8419
|
+
local dir="${1:-.}"
|
|
8420
|
+
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; }
|
|
8421
|
+
local st rc=0
|
|
8422
|
+
st="$(git -C "$dir" status --porcelain 2>/dev/null)" || rc=$?
|
|
8423
|
+
# A git that could not report is UNEVALUABLE, never "clean" and never
|
|
8424
|
+
# "dirty": naming it dirty invents a specific finding out of a failed
|
|
8425
|
+
# measurement, and the user would go looking for edits that do not exist.
|
|
8426
|
+
[ "$rc" -eq 0 ] || { printf '%s' "working tree could not be evaluated (git status failed)"; return 0; }
|
|
8427
|
+
[ -z "$st" ] || printf '%s' "working tree is dirty ($(printf '%s\n' "$st" | grep -c '') uncommitted change(s))"
|
|
8428
|
+
return 0
|
|
8429
|
+
}
|
|
8430
|
+
|
|
8431
|
+
# _deploy_latest_receipt <dir>
|
|
8432
|
+
# Echoes the newest .loki/proofs/<id>/proof.json path, or "" if none exists.
|
|
8433
|
+
# Newest-by-name: proof ids are timestamp-prefixed, so lexical order is
|
|
8434
|
+
# chronological. Absence is not an error here; the caller turns "" into the
|
|
8435
|
+
# named REFUSE reason "no receipt".
|
|
8436
|
+
_deploy_latest_receipt() {
|
|
8437
|
+
local dir="${1:-.}"
|
|
8438
|
+
local proofs="$dir/${LOKI_DIR:-.loki}/proofs"
|
|
8439
|
+
# LOKI_DIR may already be absolute (it is elsewhere in this CLI); prefer it
|
|
8440
|
+
# directly when it resolves, else fall back to the dir-relative form.
|
|
8441
|
+
[ -d "${LOKI_DIR:-}/proofs" ] && proofs="${LOKI_DIR}/proofs"
|
|
8442
|
+
[ -d "$proofs" ] || { printf '%s' ""; return 0; }
|
|
8443
|
+
local newest=""
|
|
8444
|
+
local d
|
|
8445
|
+
for d in "$proofs"/*/; do
|
|
8446
|
+
[ -f "$d/proof.json" ] || continue
|
|
8447
|
+
newest="$d/proof.json"
|
|
8448
|
+
done
|
|
8449
|
+
printf '%s' "$newest"
|
|
8450
|
+
return 0
|
|
8451
|
+
}
|
|
8452
|
+
|
|
8453
|
+
# _deploy_record <dir> <command> <exit_code> <run_id> <anchor_sha>
|
|
8454
|
+
# Writes the executed deploy's own record. A deploy with no record of itself
|
|
8455
|
+
# defeats the entire premise of gating on evidence.
|
|
8456
|
+
#
|
|
8457
|
+
# NOT written into .loki/proofs/: a hand-written JSON there carries no integrity
|
|
8458
|
+
# hash, so `loki proof verify` would read it as a TAMPERED receipt -- a
|
|
8459
|
+
# self-inflicted false alarm. It lives in .loki/deploys/ where it cannot be
|
|
8460
|
+
# mistaken for a verifiable receipt.
|
|
8461
|
+
_deploy_record() {
|
|
8462
|
+
local dir="${1:-.}" command="$2" rc="$3" run_id="$4" anchor="$5"
|
|
8463
|
+
# WHICH of the three applied: anchored (the receipt was tied to this tree)
|
|
8464
|
+
# or unanchored_overridden (it was not, and the operator said go anyway).
|
|
8465
|
+
# "refused" needs no value: a refusal never reaches this function, so the
|
|
8466
|
+
# existence of a record already means one of the two above.
|
|
8467
|
+
local anchor_state="${6:-anchored}"
|
|
8468
|
+
local ddir="$dir/.loki/deploys"
|
|
8469
|
+
[ -d "${LOKI_DIR:-}" ] && ddir="${LOKI_DIR}/deploys"
|
|
8470
|
+
mkdir -p "$ddir" 2>/dev/null || return 0
|
|
8471
|
+
local f="$ddir/$(date -u +%Y%m%dT%H%M%SZ)-$$.json"
|
|
8472
|
+
DR_CMD="$command" DR_RC="$rc" DR_RUN="$run_id" DR_ANCHOR="$anchor" DR_OUT="$f" \
|
|
8473
|
+
DR_STATE="$anchor_state" \
|
|
8474
|
+
python3 -c '
|
|
8475
|
+
import json, os, datetime
|
|
8476
|
+
state = os.environ.get("DR_STATE") or "anchored"
|
|
8477
|
+
json.dump({
|
|
8478
|
+
"recorded_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
8479
|
+
"command": os.environ["DR_CMD"],
|
|
8480
|
+
"exit_code": int(os.environ["DR_RC"]),
|
|
8481
|
+
"authorizing_run_id": os.environ["DR_RUN"],
|
|
8482
|
+
"anchor_sha": os.environ["DR_ANCHOR"],
|
|
8483
|
+
"anchor_state": state,
|
|
8484
|
+
# Spelled out so a reader months later does not have to know the vocabulary
|
|
8485
|
+
# to judge how strong the authorization was.
|
|
8486
|
+
"anchor_proven": state == "anchored",
|
|
8487
|
+
}, open(os.environ["DR_OUT"], "w"), indent=2)
|
|
8488
|
+
' 2>/dev/null || return 0
|
|
8489
|
+
printf '%s' "$f"
|
|
8490
|
+
return 0
|
|
8491
|
+
}
|
|
8492
|
+
|
|
8493
|
+
# _deploy_first_installed_command <dir> <type>
|
|
8494
|
+
# Echoes the idiomatic deploy command for the FIRST installed CLI of this type,
|
|
8495
|
+
# or "" if none is installed. Same ordering and same canonical strings
|
|
8496
|
+
# _deploy_print_cloud_options prints, so --execute can never run a command that
|
|
8497
|
+
# differs from the one the advisory showed. `command -v` only; no CLI is run.
|
|
8498
|
+
_deploy_first_installed_command() {
|
|
8499
|
+
local dir="${1:-.}" type="${2:-}"
|
|
8500
|
+
local options provider cli command docs
|
|
8501
|
+
options="$(_deploy_options_for_type "$type")"
|
|
8502
|
+
while IFS='|' read -r provider cli command docs; do
|
|
8503
|
+
[ -n "$cli" ] || continue
|
|
8504
|
+
if command -v "$cli" >/dev/null 2>&1; then
|
|
8505
|
+
printf '%s' "$command"
|
|
8506
|
+
return 0
|
|
8507
|
+
fi
|
|
8508
|
+
done <<EOF
|
|
8509
|
+
$options
|
|
8510
|
+
EOF
|
|
8511
|
+
printf '%s' ""
|
|
8512
|
+
return 0
|
|
8513
|
+
}
|
|
8514
|
+
|
|
8515
|
+
# _deploy_try_execute <dir> <type>
|
|
8516
|
+
# The ONLY place a deploy command is ever run. Evaluates the gate, and either
|
|
8517
|
+
# executes + records, or REFUSES naming every failed check. Returns the deploy
|
|
8518
|
+
# command's exit code on execution, 1 on refusal.
|
|
8519
|
+
#
|
|
8520
|
+
# Ordering matters: the advisory has ALREADY been printed by the caller, so a
|
|
8521
|
+
# refusal still leaves the user with exactly the output they get today.
|
|
8522
|
+
_deploy_try_execute() {
|
|
8523
|
+
local dir="${1:-.}" type="${2:-}"
|
|
8524
|
+
|
|
8525
|
+
local command
|
|
8526
|
+
command="$(_deploy_first_installed_command "$dir" "$type")"
|
|
8527
|
+
if [ -z "$command" ]; then
|
|
8528
|
+
echo "" >&2
|
|
8529
|
+
echo -e "${RED}REFUSED: --execute found no installed deploy CLI for this ${type} project.${NC}" >&2
|
|
8530
|
+
echo " Nothing was run. Install one of the CLIs listed above and re-run." >&2
|
|
8531
|
+
return 1
|
|
8532
|
+
fi
|
|
8533
|
+
|
|
8534
|
+
local reasons
|
|
8535
|
+
reasons="$(_deploy_gate "$dir" "$command")"
|
|
8536
|
+
|
|
8537
|
+
# Split the marker out BEFORE the emptiness test: it is a fact about how
|
|
8538
|
+
# the gate passed, never a reason it failed. Left in, it would refuse every
|
|
8539
|
+
# overridden deploy and print the marker as a reason.
|
|
8540
|
+
local anchor_state="anchored"
|
|
8541
|
+
case "$reasons" in
|
|
8542
|
+
*"$_DEPLOY_ANCHOR_MARKER"*)
|
|
8543
|
+
anchor_state="unanchored_overridden"
|
|
8544
|
+
# Shell substitution, NOT `grep -v`: under `set -e` a grep that
|
|
8545
|
+
# filters out every line exits 1 and aborts the deploy silently --
|
|
8546
|
+
# measured, and it looked exactly like the gate refusing.
|
|
8547
|
+
reasons="${reasons//$_DEPLOY_ANCHOR_MARKER/}"
|
|
8548
|
+
# Drop the blank line the removal left WITHOUT joining the real
|
|
8549
|
+
# reasons together: sed deletes empty lines only, so a refusal that
|
|
8550
|
+
# also carries other reasons still prints them one per line.
|
|
8551
|
+
reasons="$(printf '%s' "$reasons" | sed '/^[[:space:]]*$/d')"
|
|
8552
|
+
;;
|
|
8553
|
+
esac
|
|
8554
|
+
|
|
8555
|
+
if [ -n "$reasons" ]; then
|
|
8556
|
+
echo ""
|
|
8557
|
+
echo -e "${RED}REFUSED: not deploying. The evidence gate did not pass.${NC}" >&2
|
|
8558
|
+
echo " Would have run: ${command}" >&2
|
|
8559
|
+
echo "" >&2
|
|
8560
|
+
# printf '%s\n', NOT '%s': command substitution strips the trailing
|
|
8561
|
+
# newline, and `read` then DISCARDS a final unterminated line -- so a
|
|
8562
|
+
# single-reason refusal printed no reasons at all. A refusal that names
|
|
8563
|
+
# nothing is the one thing this gate must never do.
|
|
8564
|
+
printf '%s\n' "$reasons" | while IFS= read -r r; do
|
|
8565
|
+
[ -n "$r" ] && echo " - ${r}" >&2
|
|
8566
|
+
done
|
|
8567
|
+
echo "" >&2
|
|
8568
|
+
echo " A check that could not be EVALUATED is a refusal, not a pass." >&2
|
|
8569
|
+
echo " Nothing was executed. The advisory command above is unchanged." >&2
|
|
8570
|
+
return 1
|
|
8571
|
+
fi
|
|
8572
|
+
|
|
8573
|
+
# Gate passed. Capture the authorizing receipt's identity for the record.
|
|
8574
|
+
local pj run_id anchor
|
|
8575
|
+
pj="$(_deploy_latest_receipt "$dir")"
|
|
8576
|
+
run_id="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("run_id") or "")' "$pj" 2>/dev/null || printf '')"
|
|
8577
|
+
anchor="$(git -C "$dir" rev-parse HEAD 2>/dev/null || printf '')"
|
|
8578
|
+
|
|
8579
|
+
echo ""
|
|
8580
|
+
if [ "$anchor_state" = "unanchored_overridden" ]; then
|
|
8581
|
+
# "VERIFIED this tree" would be FALSE here: the receipt's checks passed,
|
|
8582
|
+
# but nothing tied it to the code about to ship. Saying so is the whole
|
|
8583
|
+
# point of allowing the override rather than weakening the gate.
|
|
8584
|
+
echo -e "${YELLOW}Evidence gate PASSED with the anchor UNPROVEN (--allow-unanchored).${NC}"
|
|
8585
|
+
echo " Receipt ${run_id:-unknown} passed its own checks, but records no baseline,"
|
|
8586
|
+
echo " so it was NOT tied to this tree (${anchor:0:12}). You authorized this."
|
|
8587
|
+
else
|
|
8588
|
+
echo -e "${GREEN}Evidence gate PASSED.${NC} Receipt ${run_id:-unknown} VERIFIED this tree (${anchor:0:12})."
|
|
8589
|
+
fi
|
|
8590
|
+
echo "Running: ${command}"
|
|
8591
|
+
echo ""
|
|
8592
|
+
|
|
8593
|
+
# Run in the project dir. No credential env var is read, set, or forwarded:
|
|
8594
|
+
# the command inherits the user's already-authenticated CLI session as-is.
|
|
8595
|
+
#
|
|
8596
|
+
# eval is used because the canonical commands carry their own flags
|
|
8597
|
+
# ("vercel --prod", "netlify deploy --prod --dir=<out>") and must word-split.
|
|
8598
|
+
# The string is NOT user input: it comes from the hardcoded
|
|
8599
|
+
# _deploy_options_for_type table, and the only path here has already passed
|
|
8600
|
+
# the destructive-operation check in _deploy_gate.
|
|
8601
|
+
local rc=0
|
|
8602
|
+
( cd "$dir" && eval "$command" ) || rc=$?
|
|
8603
|
+
|
|
8604
|
+
local rec
|
|
8605
|
+
rec="$(_deploy_record "$dir" "$command" "$rc" "${run_id:-unknown}" "${anchor:-unknown}" \
|
|
8606
|
+
"$anchor_state")"
|
|
8607
|
+
echo ""
|
|
8608
|
+
if [ "$rc" -eq 0 ]; then
|
|
8609
|
+
echo -e "${GREEN}Deploy command exited 0.${NC}"
|
|
8610
|
+
else
|
|
8611
|
+
echo -e "${RED}Deploy command exited ${rc}.${NC}" >&2
|
|
8612
|
+
fi
|
|
8613
|
+
if [ -n "$rec" ]; then
|
|
8614
|
+
echo "Deploy record: ${rec}"
|
|
8615
|
+
else
|
|
8616
|
+
# The deploy ALREADY RAN. Failing to record it silently is the exact
|
|
8617
|
+
# thing this feature exists to prevent -- an executed deploy with no
|
|
8618
|
+
# evidence of itself -- so it is reported LOUDLY rather than swallowed.
|
|
8619
|
+
# Not an error exit: the deploy's own exit code is the honest verdict on
|
|
8620
|
+
# the deploy, and overriding it would misreport what happened.
|
|
8621
|
+
echo -e "${RED}WARNING: the deploy ran but its record could NOT be written.${NC}" >&2
|
|
8622
|
+
echo " Command: ${command}" >&2
|
|
8623
|
+
echo " Authorizing receipt run_id: ${run_id:-unknown}; anchor: ${anchor:-unknown}" >&2
|
|
8624
|
+
echo " Record this yourself: a deploy with no record of itself is unevidenced." >&2
|
|
8625
|
+
fi
|
|
8626
|
+
return "$rc"
|
|
8627
|
+
}
|
|
8628
|
+
|
|
8629
|
+
# Emitted by _deploy_gate (not a refusal reason) when the anchor was UNPROVEN
|
|
8630
|
+
# and the operator overrode it. The gate runs inside $( ), so this travels on
|
|
8631
|
+
# stdout; a global assigned in the subshell would not survive.
|
|
8632
|
+
_DEPLOY_ANCHOR_MARKER="__LOKI_ANCHOR_UNPROVEN__"
|
|
8633
|
+
|
|
8634
|
+
# _deploy_gate <dir> <command>
|
|
8635
|
+
# THE GATE. Echoes "" when all four checks pass, else the REFUSE reason(s), one
|
|
8636
|
+
# per line. Every check is evaluated so a refusal names everything wrong at once
|
|
8637
|
+
# rather than making the user re-run to discover the next problem.
|
|
8638
|
+
# The opt-in (check 4) is the CALLER's --execute flag and is not re-checked here.
|
|
8639
|
+
_deploy_gate() {
|
|
8640
|
+
local dir="${1:-.}" command="${2:-}"
|
|
8641
|
+
local reasons=""
|
|
8642
|
+
|
|
8643
|
+
# Hard limit, checked FIRST: no receipt authorizes a destructive operation.
|
|
8644
|
+
if _deploy_is_destructive "$command"; then
|
|
8645
|
+
reasons="${reasons}destructive operation refused: no receipt authorizes delete/destroy/teardown/drop
|
|
8646
|
+
"
|
|
8647
|
+
fi
|
|
8648
|
+
|
|
8649
|
+
# An UNFILLED <placeholder> is a command written for a HUMAN to complete,
|
|
8650
|
+
# not one to run. Executing it is at best a shell syntax error and at worst
|
|
8651
|
+
# an unintended redirection ("--dir=<build-output>" redirects stdin from a
|
|
8652
|
+
# file named build-output). Guessing the value would be worse still: we
|
|
8653
|
+
# would deploy a directory nobody chose.
|
|
8654
|
+
case "$command" in
|
|
8655
|
+
*"<"*">"*) reasons="${reasons}deploy command contains an unfilled placeholder -- run it yourself with the real value: ${command}
|
|
8656
|
+
" ;;
|
|
8657
|
+
esac
|
|
8658
|
+
|
|
8659
|
+
local pj
|
|
8660
|
+
pj="$(_deploy_latest_receipt "$dir")"
|
|
8661
|
+
if [ -z "$pj" ]; then
|
|
8662
|
+
reasons="${reasons}no Evidence Receipt found (nothing has verified this tree)
|
|
8663
|
+
"
|
|
8664
|
+
else
|
|
8665
|
+
local verdict
|
|
8666
|
+
verdict="$(_deploy_receipt_verdict "$pj")"
|
|
8667
|
+
case "$verdict" in
|
|
8668
|
+
VERIFIED) : ;;
|
|
8669
|
+
UNSIGNED) reasons="${reasons}receipt is UNSIGNED: integrity holds but provenance is unproven (set LOKI_PROOF_GPG_KEY; docs/SIGNED-RECEIPTS.md)
|
|
8670
|
+
" ;;
|
|
8671
|
+
UNCHECKED) reasons="${reasons}receipt is UNCHECKED: its signature could not be evaluated here, so nothing was proven
|
|
8672
|
+
" ;;
|
|
8673
|
+
TAMPERED) reasons="${reasons}receipt is TAMPERED: its integrity hash does not match its contents
|
|
8674
|
+
" ;;
|
|
8675
|
+
*) reasons="${reasons}receipt verdict could not be determined
|
|
8676
|
+
" ;;
|
|
8677
|
+
esac
|
|
8678
|
+
local anchor_reason
|
|
8679
|
+
anchor_reason="$(_deploy_anchor_ok "$pj" "$dir")"
|
|
8680
|
+
case "$anchor_reason" in
|
|
8681
|
+
UNANCHORED:*)
|
|
8682
|
+
# Nothing was PROVEN either way. Overridable per invocation, and
|
|
8683
|
+
# only the UNANCHORED-prefixed line is dropped: discarding the
|
|
8684
|
+
# whole anchor check (or skipping the call) would also swallow
|
|
8685
|
+
# the stale-receipt refusal, silently turning this flag into
|
|
8686
|
+
# "skip anchoring entirely".
|
|
8687
|
+
if [ "${LOKI_DEPLOY_ALLOW_UNANCHORED:-false}" = "true" ]; then
|
|
8688
|
+
# Emitted on STDOUT as a non-reason marker, not stored in a
|
|
8689
|
+
# variable: _deploy_gate is called inside $( ), so a global
|
|
8690
|
+
# assigned here dies with the subshell. The caller strips
|
|
8691
|
+
# this line before treating what remains as refusal reasons.
|
|
8692
|
+
reasons="${reasons}${_DEPLOY_ANCHOR_MARKER}
|
|
8693
|
+
"
|
|
8694
|
+
else
|
|
8695
|
+
reasons="${reasons}${anchor_reason#UNANCHORED:} (re-run with --allow-unanchored to deploy anyway; nothing about the tree will have been proven)
|
|
8696
|
+
"
|
|
8697
|
+
fi
|
|
8698
|
+
;;
|
|
8699
|
+
"") : ;;
|
|
8700
|
+
*) # Positive evidence of the wrong build. No flag bypasses this.
|
|
8701
|
+
reasons="${reasons}${anchor_reason}
|
|
8702
|
+
" ;;
|
|
8703
|
+
esac
|
|
8704
|
+
fi
|
|
8705
|
+
|
|
8706
|
+
local dirty
|
|
8707
|
+
dirty="$(_deploy_tree_clean "$dir")"
|
|
8708
|
+
[ -n "$dirty" ] && reasons="${reasons}${dirty}
|
|
8709
|
+
"
|
|
8710
|
+
|
|
8711
|
+
printf '%s' "$reasons"
|
|
8712
|
+
return 0
|
|
8713
|
+
}
|
|
8714
|
+
|
|
8715
|
+
# cmd_deploy [--dir <path>] [--no-clip] [--execute] [--help]
|
|
8195
8716
|
# Advisory orchestration: detect project type + CI/CD pipeline + installed cloud
|
|
8196
|
-
# CLIs, then PRINT the canonical deploy command(s). PRINT-ONLY
|
|
8717
|
+
# CLIs, then PRINT the canonical deploy command(s). PRINT-ONLY BY DEFAULT.
|
|
8197
8718
|
# Placed LAST in the contiguous deploy block (all helpers above it) so the SDET
|
|
8198
8719
|
# can extract from the first helper def to the close of cmd_deploy and capture the
|
|
8199
8720
|
# whole self-contained unit (mirrors test-preview-public.sh, where cmd_preview is
|
|
@@ -8201,31 +8722,90 @@ EOF
|
|
|
8201
8722
|
cmd_deploy() {
|
|
8202
8723
|
local dir="${TARGET_DIR:-.}"
|
|
8203
8724
|
local do_clip=true
|
|
8725
|
+
local do_execute=false
|
|
8726
|
+
# `local` so an AMBIENT LOKI_DEPLOY_ALLOW_UNANCHORED=true in the environment
|
|
8727
|
+
# cannot opt in: it is reset to false on every invocation and only the flag
|
|
8728
|
+
# below sets it. Same rule --execute already follows -- no env var or config
|
|
8729
|
+
# file alone weakens this gate.
|
|
8730
|
+
local LOKI_DEPLOY_ALLOW_UNANCHORED=false
|
|
8204
8731
|
|
|
8205
8732
|
# Arg parse (mirror cmd_preview: lenient on unknown args -> no behavior drift).
|
|
8206
8733
|
while [ $# -gt 0 ]; do
|
|
8207
8734
|
case "${1:-}" in
|
|
8208
8735
|
--help|-h|help)
|
|
8209
|
-
echo -e "${BOLD}Loki Mode --
|
|
8736
|
+
echo -e "${BOLD}Loki Mode -- deploy command advisory (print-only by default)${NC}"
|
|
8210
8737
|
echo ""
|
|
8211
|
-
echo "Usage: loki deploy [--dir <path>] [--no-clip]"
|
|
8738
|
+
echo "Usage: loki deploy [--dir <path>] [--no-clip] [--execute]"
|
|
8212
8739
|
echo ""
|
|
8213
8740
|
echo "Detects your project type and your installed cloud CLI (and any"
|
|
8214
8741
|
echo "CI/CD pipeline), then PRINTS the exact deploy command for YOU to run."
|
|
8215
|
-
echo "
|
|
8216
|
-
echo "even --version)
|
|
8217
|
-
echo "
|
|
8742
|
+
echo "By default this is print-only and advisory: it NEVER deploys and"
|
|
8743
|
+
echo "NEVER runs a cloud CLI (not even --version). It NEVER runs"
|
|
8744
|
+
echo "'git push' on ANY path. Detection is read-only. You run the"
|
|
8745
|
+
echo "printed command."
|
|
8218
8746
|
echo ""
|
|
8219
8747
|
echo "If a CI/CD pipeline (GitHub Actions, GitLab CI, Jenkins, CircleCI,"
|
|
8220
8748
|
echo "Azure Pipelines, Bitbucket) is detected, the primary advice is the"
|
|
8221
8749
|
echo "git push + pull-request path, because your pipeline deploys on merge."
|
|
8222
8750
|
echo ""
|
|
8751
|
+
echo -e "${BOLD}--execute: a deploy authorized by a verified receipt${NC}"
|
|
8752
|
+
echo ""
|
|
8753
|
+
echo "With --execute, Loki runs the detected deploy command ONLY when ALL"
|
|
8754
|
+
echo "of these hold. Any one failing REFUSES, and the refusal names which:"
|
|
8755
|
+
echo " 1. An Evidence Receipt exists for this tree and its integrity hash"
|
|
8756
|
+
echo " matches (hash_ok)."
|
|
8757
|
+
echo " 2. Its verdict is VERIFIED. UNSIGNED, UNCHECKED and TAMPERED all"
|
|
8758
|
+
echo " refuse -- integrity is not provenance."
|
|
8759
|
+
echo " 3. The receipt anchors to the commit being deployed. A receipt for"
|
|
8760
|
+
echo " a different commit does not authorize this deploy."
|
|
8761
|
+
echo " Two outcomes are distinguished here, and only one is overridable:"
|
|
8762
|
+
echo " UNANCHORED the receipt records no baseline, so neither a match"
|
|
8763
|
+
echo " nor a mismatch can be proven. Refuses by default;"
|
|
8764
|
+
echo " --allow-unanchored proceeds and records that the"
|
|
8765
|
+
echo " anchor was never proven."
|
|
8766
|
+
echo " MISMATCH a baseline IS recorded and resolves to a different"
|
|
8767
|
+
echo " tree. That is evidence of the wrong build, and"
|
|
8768
|
+
echo " --allow-unanchored does NOT bypass it."
|
|
8769
|
+
echo " 4. You passed --execute on THIS invocation. No env var or config"
|
|
8770
|
+
echo " file alone can opt in."
|
|
8771
|
+
echo ""
|
|
8772
|
+
echo "It FAILS CLOSED: if a check cannot be evaluated at all (no receipt,"
|
|
8773
|
+
echo "gpg unavailable, anchor unresolvable, not a git repo), that is a"
|
|
8774
|
+
echo "REFUSAL, not a pass. A refusal still prints the advisory command."
|
|
8775
|
+
echo ""
|
|
8776
|
+
echo "Hard limits, with or without --execute:"
|
|
8777
|
+
echo " - NEVER runs a destructive cloud operation (delete, destroy,"
|
|
8778
|
+
echo " teardown, drop). Refused even with a valid receipt."
|
|
8779
|
+
echo " - NEVER runs 'git push' and NEVER creates a PR. Out of scope."
|
|
8780
|
+
echo " - NEVER reads or forwards cloud credentials. The deploy command"
|
|
8781
|
+
echo " inherits your already-authenticated CLI session."
|
|
8782
|
+
echo " - REFUSES if the working tree is dirty: deploying something other"
|
|
8783
|
+
echo " than what was verified is the exact failure this prevents."
|
|
8784
|
+
echo ""
|
|
8785
|
+
echo "Every executed deploy writes its own record to .loki/deploys/:"
|
|
8786
|
+
echo "the command, its exit code, the authorizing receipt's run_id, the"
|
|
8787
|
+
echo "resolved anchor, and anchor_state -- so a reader can tell later"
|
|
8788
|
+
echo "whether the anchor was proven or explicitly overridden."
|
|
8789
|
+
echo ""
|
|
8223
8790
|
echo "Options:"
|
|
8224
8791
|
echo " --dir <path> Project directory to scan (default: current dir)"
|
|
8225
8792
|
echo " --no-clip Do not copy the idiomatic command to the clipboard"
|
|
8793
|
+
echo " --execute Run the deploy command if the receipt gate passes"
|
|
8794
|
+
echo " --allow-unanchored"
|
|
8795
|
+
echo " Proceed when the receipt records NO baseline (nothing"
|
|
8796
|
+
echo " about this tree is proven). Never bypasses a recorded"
|
|
8797
|
+
echo " mismatch. Per-invocation only; no env var opts in."
|
|
8226
8798
|
echo " --help, -h Show this help and exit"
|
|
8227
8799
|
return 0
|
|
8228
8800
|
;;
|
|
8801
|
+
--execute)
|
|
8802
|
+
do_execute=true
|
|
8803
|
+
;;
|
|
8804
|
+
--allow-unanchored)
|
|
8805
|
+
# Per-invocation, like --execute: exported for the gate to read,
|
|
8806
|
+
# never persisted and never settable by config alone.
|
|
8807
|
+
LOKI_DEPLOY_ALLOW_UNANCHORED=true
|
|
8808
|
+
;;
|
|
8229
8809
|
--dir)
|
|
8230
8810
|
# Guard against a missing OR flag-shaped value: an unguarded
|
|
8231
8811
|
# shift when --dir is the last arg underflows under set -e, and a
|
|
@@ -8246,6 +8826,10 @@ cmd_deploy() {
|
|
|
8246
8826
|
# Resolve to '.' if the chosen dir does not exist (honest, no crash).
|
|
8247
8827
|
[ -d "$dir" ] || dir="."
|
|
8248
8828
|
|
|
8829
|
+
# Tells the advisory printer which closing sentence is TRUE for this run.
|
|
8830
|
+
# Scoped to this invocation only.
|
|
8831
|
+
local _LOKI_DEPLOY_EXECUTING="$do_execute"
|
|
8832
|
+
|
|
8249
8833
|
# Detect CI/CD pipeline FIRST (BRANCH-LIFECYCLE LOCK B3 precedence).
|
|
8250
8834
|
local cicd=""
|
|
8251
8835
|
cicd="$(_deploy_detect_cicd "$dir" || true)"
|
|
@@ -8310,6 +8894,21 @@ cmd_deploy() {
|
|
|
8310
8894
|
: # No installed cloud CLI; the pipeline path already advised. Fine.
|
|
8311
8895
|
fi
|
|
8312
8896
|
fi
|
|
8897
|
+
# --execute applies to the CLOUD command only. git push / PR creation
|
|
8898
|
+
# stay out of scope entirely, so on the pipeline path --execute never
|
|
8899
|
+
# touches the primary (git) advice above -- it can only run the
|
|
8900
|
+
# secondary cloud command, and only if the receipt gate passes.
|
|
8901
|
+
if [ "$do_execute" = true ]; then
|
|
8902
|
+
if [ -n "$type" ]; then
|
|
8903
|
+
_deploy_try_execute "$dir" "$type"
|
|
8904
|
+
return $?
|
|
8905
|
+
fi
|
|
8906
|
+
echo "" >&2
|
|
8907
|
+
echo -e "${RED}REFUSED: --execute has nothing to run here.${NC}" >&2
|
|
8908
|
+
echo " No deployable project type detected, and Loki never runs 'git push'" >&2
|
|
8909
|
+
echo " or creates a PR. Run the git advice above yourself." >&2
|
|
8910
|
+
return 1
|
|
8911
|
+
fi
|
|
8313
8912
|
return 0
|
|
8314
8913
|
fi
|
|
8315
8914
|
|
|
@@ -8329,6 +8928,14 @@ cmd_deploy() {
|
|
|
8329
8928
|
# honest install hint) when NO matching CLI is installed.
|
|
8330
8929
|
local rc=0
|
|
8331
8930
|
_deploy_print_cloud_options "$dir" "$type" "$do_clip" "true" || rc=$?
|
|
8931
|
+
|
|
8932
|
+
# Execute ONLY if the user opted in on THIS invocation and the advisory
|
|
8933
|
+
# above found something to run. The advisory has already printed, so a
|
|
8934
|
+
# refusal below still leaves the user with today's output.
|
|
8935
|
+
if [ "$do_execute" = true ] && [ "$rc" -eq 0 ]; then
|
|
8936
|
+
_deploy_try_execute "$dir" "$type"
|
|
8937
|
+
return $?
|
|
8938
|
+
fi
|
|
8332
8939
|
return $rc
|
|
8333
8940
|
}
|
|
8334
8941
|
|
|
@@ -105,6 +105,19 @@ JOB_EVENT = "loki_job"
|
|
|
105
105
|
# storm cannot grow memory without limit.
|
|
106
106
|
DEFAULT_JOB_HISTORY = 1024
|
|
107
107
|
|
|
108
|
+
# Terminal job statuses a remote client can gate on. "queued" and "running"
|
|
109
|
+
# are deliberately absent: a client must be able to tell "not done yet" from
|
|
110
|
+
# "done and failed", which a single non-passed value could not express.
|
|
111
|
+
#
|
|
112
|
+
# Only JOB_STATUS_PASSED means the build itself succeeded. JOB_STATUS_UNKNOWN
|
|
113
|
+
# means the build detached past our wait window and its exit code was never
|
|
114
|
+
# observed -- an unobserved outcome is NOT a pass, and the client exits
|
|
115
|
+
# non-zero on it (fail closed).
|
|
116
|
+
JOB_STATUS_PASSED = "passed"
|
|
117
|
+
JOB_STATUS_FAILED = "failed"
|
|
118
|
+
JOB_STATUS_UNKNOWN = "unknown"
|
|
119
|
+
JOB_TERMINAL_STATUSES = (JOB_STATUS_PASSED, JOB_STATUS_FAILED, JOB_STATUS_UNKNOWN)
|
|
120
|
+
|
|
108
121
|
# A run id names a directory under .loki/proofs/. run.sh mints it as
|
|
109
122
|
# "run-<utc>-<pid>-<rand>" or "proof-<utc>-<pid>-<rand>"; this is the alphabet
|
|
110
123
|
# those forms use. Applied to the pointer file's contents (never to a request
|
|
@@ -338,21 +351,30 @@ def _reap_child(proc):
|
|
|
338
351
|
pass
|
|
339
352
|
|
|
340
353
|
|
|
341
|
-
|
|
342
|
-
|
|
354
|
+
# Outcome of a dispatch, as distinct from "did it launch".
|
|
355
|
+
#
|
|
356
|
+
# run_loki_command already distinguished all three of these and then threw two
|
|
357
|
+
# of them away by returning a bool. Collapsing "exited 0" and "still detached"
|
|
358
|
+
# into True is what made a remotely-submitted build that STARTS and then FAILS
|
|
359
|
+
# report success: the client had no value to gate on. UNKNOWN is not a pass --
|
|
360
|
+
# an outcome we could not observe must fail closed.
|
|
361
|
+
# ponytail: the outcome IS the terminal job status, so they are the same three
|
|
362
|
+
# strings rather than two enums plus a mapping table.
|
|
363
|
+
OUTCOME_PASSED = JOB_STATUS_PASSED # ran to completion and exited 0
|
|
364
|
+
OUTCOME_FAILED = JOB_STATUS_FAILED # launch failed, or exited non-zero
|
|
365
|
+
OUTCOME_UNKNOWN = JOB_STATUS_UNKNOWN # detached; exit code never observed
|
|
343
366
|
|
|
344
|
-
Returns True if the command was launched and exited 0 (or backgrounded
|
|
345
|
-
cleanly within the wait window), False if the launch failed or it exited
|
|
346
|
-
non-zero. The child is always waited on, so no zombies accumulate. stderr
|
|
347
|
-
is captured on failure so a broken dispatch is diagnosable.
|
|
348
367
|
|
|
349
|
-
|
|
350
|
-
|
|
368
|
+
def run_loki_outcome(args, dry_run=False):
|
|
369
|
+
"""Run a loki command and report WHICH of the three outcomes occurred.
|
|
370
|
+
|
|
371
|
+
Returns one of OUTCOME_PASSED / OUTCOME_FAILED / OUTCOME_UNKNOWN. This is
|
|
372
|
+
the honest version of run_loki_command, which answers only "did it launch".
|
|
351
373
|
"""
|
|
352
374
|
cmd = ["loki"] + args
|
|
353
375
|
if dry_run:
|
|
354
376
|
logging.info("[DRY-RUN] Would run: %s", " ".join(cmd))
|
|
355
|
-
return
|
|
377
|
+
return OUTCOME_PASSED
|
|
356
378
|
logging.info("Running: %s", " ".join(cmd))
|
|
357
379
|
try:
|
|
358
380
|
proc = subprocess.Popen(
|
|
@@ -362,18 +384,14 @@ def run_loki_command(args, dry_run=False):
|
|
|
362
384
|
)
|
|
363
385
|
except (FileNotFoundError, OSError) as e:
|
|
364
386
|
logging.error("Failed to launch %s: %s", " ".join(cmd), e)
|
|
365
|
-
return
|
|
387
|
+
return OUTCOME_FAILED
|
|
366
388
|
|
|
367
389
|
try:
|
|
368
|
-
# Wait (and thereby reap) the child. A --detach launch returns quickly;
|
|
369
|
-
# this bound only guards against a wedged launch.
|
|
370
390
|
_, stderr = proc.communicate(timeout=DISPATCH_WAIT_SECONDS)
|
|
371
391
|
except subprocess.TimeoutExpired:
|
|
372
|
-
#
|
|
373
|
-
#
|
|
374
|
-
#
|
|
375
|
-
# THIS process is alive, and the OS reparents it after we exit. This
|
|
376
|
-
# keeps the "always waited on, no zombies" guarantee honest.
|
|
392
|
+
# Detached past the wait window. The reaper collects the child, but we
|
|
393
|
+
# never see its exit code, so the outcome is genuinely UNKNOWN -- NOT a
|
|
394
|
+
# pass. Reporting success here is exactly the defect this replaces.
|
|
377
395
|
logging.info(
|
|
378
396
|
"Dispatch pid=%d still running after %ds; reaping in background",
|
|
379
397
|
proc.pid,
|
|
@@ -385,11 +403,11 @@ def run_loki_command(args, dry_run=False):
|
|
|
385
403
|
name="loki-trigger-reaper-%d" % proc.pid,
|
|
386
404
|
daemon=True,
|
|
387
405
|
).start()
|
|
388
|
-
return
|
|
406
|
+
return OUTCOME_UNKNOWN
|
|
389
407
|
|
|
390
408
|
if proc.returncode == 0:
|
|
391
409
|
logging.info("Dispatch pid=%d completed (exit 0)", proc.pid)
|
|
392
|
-
return
|
|
410
|
+
return OUTCOME_PASSED
|
|
393
411
|
|
|
394
412
|
stderr_text = ""
|
|
395
413
|
if stderr:
|
|
@@ -400,7 +418,28 @@ def run_loki_command(args, dry_run=False):
|
|
|
400
418
|
proc.returncode,
|
|
401
419
|
stderr_text or "(no stderr)",
|
|
402
420
|
)
|
|
403
|
-
return
|
|
421
|
+
return OUTCOME_FAILED
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def run_loki_command(args, dry_run=False):
|
|
425
|
+
"""Run a loki command synchronously and reap it; or print it if dry_run.
|
|
426
|
+
|
|
427
|
+
Returns True if the command was launched and exited 0 (or backgrounded
|
|
428
|
+
cleanly within the wait window), False if the launch failed or it exited
|
|
429
|
+
non-zero. The child is always waited on, so no zombies accumulate. stderr
|
|
430
|
+
is captured on failure so a broken dispatch is diagnosable.
|
|
431
|
+
|
|
432
|
+
This is invoked from worker threads, so blocking here does not block the
|
|
433
|
+
HTTP listener.
|
|
434
|
+
|
|
435
|
+
Kept as the bool view for the GitHub webhook handlers, which only care
|
|
436
|
+
whether a dispatch started. A caller that must know whether the BUILD
|
|
437
|
+
passed wants run_loki_outcome instead. Behaviour is unchanged: a detached
|
|
438
|
+
dispatch (UNKNOWN) still reads as True here, exactly as before.
|
|
439
|
+
"""
|
|
440
|
+
return run_loki_outcome(args, dry_run=dry_run) in (
|
|
441
|
+
OUTCOME_PASSED, OUTCOME_UNKNOWN,
|
|
442
|
+
)
|
|
404
443
|
|
|
405
444
|
|
|
406
445
|
def handle_issues_event(payload, dry_run=False):
|
|
@@ -496,9 +535,11 @@ def handle_job_event(payload, dry_run=False):
|
|
|
496
535
|
return None, "rejected (invalid spec)"
|
|
497
536
|
args = ["start", spec.strip(), "--detach"]
|
|
498
537
|
summary = "job %s: %s" % (payload.get("job_id", "?"), spec.strip())
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
538
|
+
# A remote submitter gates CI on this, so report the BUILD's outcome, not
|
|
539
|
+
# merely that a build was launched. "fired" (launched) and "passed" must
|
|
540
|
+
# never share a value.
|
|
541
|
+
status = run_loki_outcome(args, dry_run=dry_run)
|
|
542
|
+
if status != OUTCOME_FAILED:
|
|
502
543
|
send_notification("Trigger fired: %s" % summary)
|
|
503
544
|
return summary, status
|
|
504
545
|
|
|
@@ -732,7 +773,16 @@ class Dispatcher:
|
|
|
732
773
|
continue
|
|
733
774
|
try:
|
|
734
775
|
event_type, payload = item
|
|
735
|
-
|
|
776
|
+
# Honour job_id ONLY for a remotely-submitted job. A GitHub
|
|
777
|
+
# payload carries no job_id of its own, so accepting one from
|
|
778
|
+
# any payload let a holder of the WEBHOOK HMAC write into the
|
|
779
|
+
# /jobs status store -- overwriting a real job's terminal
|
|
780
|
+
# status (e.g. "passed" -> "fired") and re-introducing the
|
|
781
|
+
# false-green. That is a webhook credential reaching a /jobs
|
|
782
|
+
# capability, which the separate-credential design forbids.
|
|
783
|
+
job_id = (payload.get("job_id")
|
|
784
|
+
if event_type == JOB_EVENT and isinstance(payload, dict)
|
|
785
|
+
else None)
|
|
736
786
|
if job_id:
|
|
737
787
|
self.record_job(job_id, "running")
|
|
738
788
|
# Counted for every dispatch, including webhook builds that
|
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -206,6 +206,7 @@ _SENSITIVE_READ_PREFIXES = (
|
|
|
206
206
|
"/api/projects",
|
|
207
207
|
"/api/council",
|
|
208
208
|
"/api/proofs",
|
|
209
|
+
"/api/phases",
|
|
209
210
|
"/api/memory",
|
|
210
211
|
"/api/learnings",
|
|
211
212
|
"/api/learning",
|
|
@@ -12226,6 +12227,23 @@ def _proof_pr_url(run_dir: _Path) -> Optional[str]:
|
|
|
12226
12227
|
return None
|
|
12227
12228
|
|
|
12228
12229
|
|
|
12230
|
+
@app.get("/api/phases", dependencies=[Depends(auth.require_scope("read"))])
|
|
12231
|
+
async def phase_timeline():
|
|
12232
|
+
"""Measured phase segments for the active run, from real phase_change events.
|
|
12233
|
+
|
|
12234
|
+
dashboard/api_phases.py had NO importer and NO consumer: a complete
|
|
12235
|
+
phase-timeline module, tested and unreachable. This route is the wiring.
|
|
12236
|
+
|
|
12237
|
+
The envelope is returned VERBATIM. Reshaping it here would fork the honesty
|
|
12238
|
+
contract the module carries -- an unmeasured start is None (never 0), an
|
|
12239
|
+
unreadable log reads differently from an empty one, and `sampled` says the
|
|
12240
|
+
history can be missing phases shorter than one poll interval. A second
|
|
12241
|
+
format is a second place for those distinctions to die.
|
|
12242
|
+
"""
|
|
12243
|
+
from . import api_phases
|
|
12244
|
+
return api_phases.phase_history(str(_get_loki_dir()))
|
|
12245
|
+
|
|
12246
|
+
|
|
12229
12247
|
@app.get("/api/proofs", dependencies=[Depends(auth.require_scope("read"))])
|
|
12230
12248
|
async def list_proofs():
|
|
12231
12249
|
"""List proof-of-run artifacts for the active project's .loki/proofs/."""
|
|
@@ -1294,6 +1294,17 @@
|
|
|
1294
1294
|
<loki-spec-panel id="spec-panel"></loki-spec-panel>
|
|
1295
1295
|
</div>
|
|
1296
1296
|
<loki-overview id="overview"></loki-overview>
|
|
1297
|
+
<!-- PIPELINE. dashboard/api_phases.py parsed real phase_change events
|
|
1298
|
+
into measured segments and had NO importer in server.py and NO
|
|
1299
|
+
consumer here: a user could see a build finish or not, never WHICH
|
|
1300
|
+
PHASE it was in or where it was stuck. Lives on Overview because
|
|
1301
|
+
this SPA has no Activity section (no data-section="activity"), and
|
|
1302
|
+
Overview is the live-run home. Hidden until data arrives. -->
|
|
1303
|
+
<div id="pipeline-panel" style="display:none;margin-bottom:24px;">
|
|
1304
|
+
<h3 style="font-family: var(--loki-font-family, 'Inter', system-ui, -apple-system, sans-serif); font-size: 1.15rem; font-weight: 400; color: var(--loki-text-primary); margin-bottom: 12px;">Pipeline</h3>
|
|
1305
|
+
<div id="pipeline-list"></div>
|
|
1306
|
+
<div id="pipeline-note" style="font-size:11px;color:var(--loki-text-muted);margin-top:8px;"></div>
|
|
1307
|
+
</div>
|
|
1297
1308
|
<loki-rarv-timeline id="rarv-timeline"></loki-rarv-timeline>
|
|
1298
1309
|
<loki-session-diff id="session-diff"></loki-session-diff>
|
|
1299
1310
|
<div style="margin-top: 28px;">
|
|
@@ -16184,6 +16195,127 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
16184
16195
|
.catch(function () { /* leave hidden: no receipt surface is better than a wrong one */ });
|
|
16185
16196
|
};
|
|
16186
16197
|
|
|
16198
|
+
// PIPELINE. The measured phase timeline from /api/phases, which serves
|
|
16199
|
+
// dashboard/api_phases.py's envelope verbatim.
|
|
16200
|
+
//
|
|
16201
|
+
// WHAT IS NOT HERE, DELIBERATELY: a "not started" row. The envelope only
|
|
16202
|
+
// contains phases that HAVE started, and the runtime declares no canonical
|
|
16203
|
+
// ordered phase list -- _advance_current_phase takes any string, and the
|
|
16204
|
+
// envelope's own "sampled" flag says absence of a segment is not evidence
|
|
16205
|
+
// the phase did not occur. Drawing a fixed pipeline and marking the missing
|
|
16206
|
+
// rows "not started" would invent exactly the fiction api_phases.py was
|
|
16207
|
+
// written to replace (the old timeline rotated a hardcoded four-phase list
|
|
16208
|
+
// and gave each invented segment a Math.random() duration). The three
|
|
16209
|
+
// states we CAN measure are rendered distinctly, which is what keeps a
|
|
16210
|
+
// stalled build from reading as a healthy one:
|
|
16211
|
+
//
|
|
16212
|
+
// ENDED start and end both measured -- a real duration
|
|
16213
|
+
// RUNNING ongoing, no recorded end; elapsed is anchored to the server's
|
|
16214
|
+
// checked_at, never to the client clock
|
|
16215
|
+
// FAILED the phase the runtime itself named FAILED
|
|
16216
|
+
//
|
|
16217
|
+
// and a fourth the envelope reports separately: the leading phase, known to
|
|
16218
|
+
// have run but with no emitted start, so its duration is "not measured".
|
|
16219
|
+
//
|
|
16220
|
+
// An unmeasured duration renders "not measured", NEVER 0. A measured zero
|
|
16221
|
+
// (two events inside one second -- timestamps are second-granular) still
|
|
16222
|
+
// renders as 0s, so the guard is on the ENDPOINTS being absent, never on
|
|
16223
|
+
// the duration being falsy.
|
|
16224
|
+
window.loadPhases = function () {
|
|
16225
|
+
var panel = document.getElementById('pipeline-panel');
|
|
16226
|
+
var list = document.getElementById('pipeline-list');
|
|
16227
|
+
var note = document.getElementById('pipeline-note');
|
|
16228
|
+
if (!panel || !list) return;
|
|
16229
|
+
fetch('/api/phases', { headers: { 'Accept': 'application/json' } })
|
|
16230
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
16231
|
+
.then(function (d) {
|
|
16232
|
+
if (!d) return; // endpoint absent: leave hidden
|
|
16233
|
+
var rows = (d.segments || []).slice();
|
|
16234
|
+
if (d.leading_phase) rows.unshift(d.leading_phase);
|
|
16235
|
+
if (!rows.length) return; // no phase history: say nothing
|
|
16236
|
+
var html = '';
|
|
16237
|
+
for (var i = 0; i < rows.length; i++) {
|
|
16238
|
+
var x = rows[i] || {};
|
|
16239
|
+
// ESCAPED, unlike the verdict in loadReceipts. That comes from a
|
|
16240
|
+
// controlled set; a phase name does not -- api_phases.py passes
|
|
16241
|
+
// names through verbatim and _advance_current_phase accepts ANY
|
|
16242
|
+
// string, so the set is open and this is untrusted text going into
|
|
16243
|
+
// innerHTML on a dashboard that can be bound remotely.
|
|
16244
|
+
var raw = String(x.phase || 'UNKNOWN');
|
|
16245
|
+
var _d = document.createElement('div');
|
|
16246
|
+
_d.textContent = raw;
|
|
16247
|
+
var phase = _d.innerHTML;
|
|
16248
|
+
var failed = /^FAIL/i.test(raw);
|
|
16249
|
+
// The end a RUNNING segment is measured against is the server's
|
|
16250
|
+
// own checked_at. A client clock would drift against the log.
|
|
16251
|
+
var end = x.end;
|
|
16252
|
+
if (end === null || end === undefined) {
|
|
16253
|
+
if (x.ongoing) end = d.checked_at;
|
|
16254
|
+
}
|
|
16255
|
+
// Gate on the endpoints, not on the duration: end - start === 0 is
|
|
16256
|
+
// a real measurement (second-granular timestamps), and hiding it
|
|
16257
|
+
// as "-" would be the same lie in the other direction.
|
|
16258
|
+
var measured = (x.start !== null && x.start !== undefined
|
|
16259
|
+
&& end !== null && end !== undefined);
|
|
16260
|
+
var secs = measured ? Math.max(0, Math.round(end - x.start)) : null;
|
|
16261
|
+
var dur = measured
|
|
16262
|
+
? (secs < 60 ? (secs + 's')
|
|
16263
|
+
: (Math.floor(secs / 60) + 'm ' + (secs % 60) + 's'))
|
|
16264
|
+
: 'not measured';
|
|
16265
|
+
var state, col;
|
|
16266
|
+
if (failed) {
|
|
16267
|
+
state = 'FAILED'; col = 'var(--loki-error)';
|
|
16268
|
+
} else if (x.ongoing) {
|
|
16269
|
+
state = 'RUNNING'; col = 'var(--loki-warning)';
|
|
16270
|
+
} else if (x.start === null || x.start === undefined) {
|
|
16271
|
+
// The leading phase: it ran, but its start was never emitted, so
|
|
16272
|
+
// it has no coordinate on a time axis. Named, not drawn.
|
|
16273
|
+
state = 'RAN (start not recorded)'; col = 'var(--loki-text-muted)';
|
|
16274
|
+
} else {
|
|
16275
|
+
state = 'ENDED'; col = 'var(--loki-text-muted)';
|
|
16276
|
+
}
|
|
16277
|
+
var iter = (x.iteration === null || x.iteration === undefined)
|
|
16278
|
+
? '-' : ('iter ' + x.iteration);
|
|
16279
|
+
html += '<div style="display:flex;gap:12px;align-items:center;padding:6px 8px;'
|
|
16280
|
+
+ 'border-bottom:1px solid var(--loki-border);font-size:12px;">'
|
|
16281
|
+
+ '<span style="font-weight:600;min-width:130px;">' + phase + '</span>'
|
|
16282
|
+
+ '<span style="color:' + col + ';min-width:170px;">' + state + '</span>'
|
|
16283
|
+
+ '<span style="min-width:90px;">' + dur + '</span>'
|
|
16284
|
+
+ '<span style="color:var(--loki-text-muted);">' + iter + '</span>'
|
|
16285
|
+
+ '</div>';
|
|
16286
|
+
}
|
|
16287
|
+
list.innerHTML = html;
|
|
16288
|
+
if (note) {
|
|
16289
|
+
// The envelope's own caveats, surfaced. Sampled data presented as
|
|
16290
|
+
// complete is a claim the module explicitly refuses to make.
|
|
16291
|
+
var parts = [];
|
|
16292
|
+
// The VIEW's own age, not just the data's. A one-shot fetch of a
|
|
16293
|
+
// LIVE pipeline freezes: "TESTING RUNNING 1m 40s" would sit there
|
|
16294
|
+
// unchanged an hour later, which is the stalled-build-reads-as-
|
|
16295
|
+
// healthy shape arriving through the view instead of the data.
|
|
16296
|
+
// Polled below, and stamped here so a frozen row is self-labelling
|
|
16297
|
+
// even if the poll dies.
|
|
16298
|
+
if (d.checked_at) {
|
|
16299
|
+
parts.push('Measured as of '
|
|
16300
|
+
+ new Date(d.checked_at * 1000).toISOString().slice(11, 19) + 'Z.');
|
|
16301
|
+
}
|
|
16302
|
+
if (d.sampled) {
|
|
16303
|
+
parts.push('Sampled: the phase recorder polls, so a phase shorter '
|
|
16304
|
+
+ 'than one poll interval left no event and is absent here.');
|
|
16305
|
+
}
|
|
16306
|
+
if (d.freshness_s === null || d.freshness_s === undefined) {
|
|
16307
|
+
parts.push('Age of this data: not measured.');
|
|
16308
|
+
} else if (d.freshness_s > 120) {
|
|
16309
|
+
parts.push('STALE: the phase log was last written '
|
|
16310
|
+
+ Math.floor(d.freshness_s / 60) + 'm ago.');
|
|
16311
|
+
}
|
|
16312
|
+
note.textContent = parts.join(' ');
|
|
16313
|
+
}
|
|
16314
|
+
panel.style.display = 'block';
|
|
16315
|
+
})
|
|
16316
|
+
.catch(function () { /* leave hidden: no pipeline is better than a wrong one */ });
|
|
16317
|
+
};
|
|
16318
|
+
|
|
16187
16319
|
function poll() {
|
|
16188
16320
|
fetch('/api/proofs/summary', { headers: { 'Accept': 'application/json' } })
|
|
16189
16321
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
@@ -16228,6 +16360,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
16228
16360
|
// document and cannot see the SPA's manual data-loki-theme toggle, so we
|
|
16229
16361
|
// pass the resolved theme as a query param (?theme=dark|light); the
|
|
16230
16362
|
// standalone page reads it and matches. v7.18.0.
|
|
16363
|
+
// The pipeline is a LIVE view, so unlike loadReceipts (a history view where
|
|
16364
|
+
// one-shot is correct) it polls. Guarded on the Overview page being the
|
|
16365
|
+
// visible one so a user sitting on another section spends no requests.
|
|
16366
|
+
if (sectionId === 'overview') {
|
|
16367
|
+
loadPhases();
|
|
16368
|
+
if (!window._lokiPhasePoll) {
|
|
16369
|
+
window._lokiPhasePoll = setInterval(function () {
|
|
16370
|
+
var pg = document.getElementById('page-overview');
|
|
16371
|
+
if (pg && pg.classList.contains('active')) loadPhases();
|
|
16372
|
+
}, 15000);
|
|
16373
|
+
}
|
|
16374
|
+
}
|
|
16231
16375
|
if (sectionId === 'insights') { loadLearnings(); }
|
|
16232
16376
|
if (sectionId === 'cost') { loadBudget(); }
|
|
16233
16377
|
if (sectionId === 'trust') {
|
|
@@ -397,6 +397,51 @@ bash tests/test-competitor-verify-surface.sh # the installed-CLI a
|
|
|
397
397
|
loki proof verify <id> # re-hash a receipt, exit 1 on tamper
|
|
398
398
|
```
|
|
399
399
|
|
|
400
|
+
### 3c. Deploy surface, measured 2026-08-08
|
|
401
|
+
|
|
402
|
+
Ran on the four competitor CLIs installed on this machine. Method matters here:
|
|
403
|
+
a `--help` grep is NOT sufficient -- an earlier audit in this repo counted
|
|
404
|
+
aider's `--verify-ssl` as a verification capability, which it is not. So each
|
|
405
|
+
CLI was invoked as `<cli> deploy --help` and the result classified as a real
|
|
406
|
+
subcommand only when it exited 0 AND its output named the subcommand, rather
|
|
407
|
+
than falling through to generic help.
|
|
408
|
+
|
|
409
|
+
| CLI | `deploy` subcommand |
|
|
410
|
+
|---|---|
|
|
411
|
+
| claude | none `[measured]` |
|
|
412
|
+
| aider | none `[measured]` |
|
|
413
|
+
| opencode | none `[measured]` |
|
|
414
|
+
| codex | none `[measured]` |
|
|
415
|
+
| loki | present, and evidence-gated `[measured]` |
|
|
416
|
+
|
|
417
|
+
**4 of 4 installed competitor CLIs expose no deploy verb.** Loki's is gated on a
|
|
418
|
+
verified receipt: it refuses on UNSIGNED, TAMPERED, anchor mismatch, or a dirty
|
|
419
|
+
tree, and writes a receipt for the deploy itself naming the authorizing run_id.
|
|
420
|
+
|
|
421
|
+
Scope limits, same as everywhere else in this file. This covers CLIs INSTALLED
|
|
422
|
+
HERE. Factory, Devin, Replit and 8090 have no runnable binary on this machine
|
|
423
|
+
(`bash benchmarks/head-to-head-readiness.sh` reports 3 ready / 4 blocked), so no
|
|
424
|
+
claim is made about them. A hosted product may deploy without exposing a CLI
|
|
425
|
+
verb -- Replit Agent's documented headline capability is exactly that. This
|
|
426
|
+
measures a CLI surface, not a product capability, and it is not a quality
|
|
427
|
+
comparison.
|
|
428
|
+
|
|
429
|
+
Reproduce:
|
|
430
|
+
|
|
431
|
+
```bash
|
|
432
|
+
# Exit code ALONE is not the test: every CLI here exits 0 on `deploy --help`
|
|
433
|
+
# because an unknown subcommand falls through to generic help. The first
|
|
434
|
+
# version of this recipe did exactly that and reported all four as having a
|
|
435
|
+
# deploy command. The output must NAME the subcommand.
|
|
436
|
+
for c in claude aider opencode codex loki; do
|
|
437
|
+
out="$($c deploy --help 2>&1 | head -3)"
|
|
438
|
+
printf '%s' "$out" | grep -qiE "$c deploy|deploy -" \
|
|
439
|
+
&& echo "$c: real deploy subcommand" \
|
|
440
|
+
|| echo "$c: no deploy subcommand (fell through to generic help)"
|
|
441
|
+
done
|
|
442
|
+
bash autonomy/loki deploy --execute # refuses, naming each failed check
|
|
443
|
+
```
|
|
444
|
+
|
|
400
445
|
## 4. What we do NOT know
|
|
401
446
|
|
|
402
447
|
This section is mandatory and is not empty.
|
package/loki-ts/dist/loki.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var p_=Object.create;var{getPrototypeOf:d_,defineProperty:eK,getOwnPropertyNames:c_}=Object;var l_=Object.prototype.hasOwnProperty;function i_(Z){return this[Z]}var a_,s_,n_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?a_??=new WeakMap:s_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?p_(d_(Z)):{};let K=X||!Z||!Z.__esModule?eK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of c_(Z))if(!l_.call(K,$))eK(K,$,{get:i_.bind(Z,$),enumerable:!0});if(Y)J.set(Z,K);return K};var HQ=(Z,X)=>()=>(X||Z((X={exports:{}}).exports,X),X.exports);var o_=(Z)=>Z;function r_(Z,X){this[Z]=o_.bind(null,X)}var l0=(Z,X)=>{for(var Q in X)eK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:r_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var t0=import.meta.require;var xO={};l0(xO,{lokiDir:()=>j0,homeLokiDir:()=>R4,findRepoRootForVersion:()=>X$,REPO_ROOT:()=>i0});import{resolve as n7,dirname as Z$}from"path";import{fileURLToPath as t_}from"url";import{existsSync as UQ}from"fs";import{homedir as e_}from"os";function Zf(){let Z=kO;for(let X=0;X<6;X++){if(UQ(n7(Z,"VERSION"))&&UQ(n7(Z,"autonomy/run.sh")))return Z;let Q=Z$(Z);if(Q===Z)break;Z=Q}return n7(kO,"..","..","..")}function X$(Z){let X=Z;for(let Q=0;Q<6;Q++){if(UQ(n7(X,"VERSION"))&&UQ(n7(X,"autonomy/run.sh")))return X;let Y=Z$(X);if(Y===X)break;X=Y}return n7(Z,"..","..","..")}function j0(){return process.env.LOKI_DIR??n7(process.cwd(),".loki")}function R4(){return n7(e_(),".loki")}var kO,i0;var H8=p(()=>{kO=Z$(t_(import.meta.url));i0=Zf()});import{readFileSync as Xf}from"fs";import{resolve as Qf,dirname as Yf}from"path";import{fileURLToPath as Jf}from"url";function h3(){if(h5!==null)return h5;let Z="9.
|
|
2
|
+
var p_=Object.create;var{getPrototypeOf:d_,defineProperty:eK,getOwnPropertyNames:c_}=Object;var l_=Object.prototype.hasOwnProperty;function i_(Z){return this[Z]}var a_,s_,n_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?a_??=new WeakMap:s_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?p_(d_(Z)):{};let K=X||!Z||!Z.__esModule?eK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of c_(Z))if(!l_.call(K,$))eK(K,$,{get:i_.bind(Z,$),enumerable:!0});if(Y)J.set(Z,K);return K};var HQ=(Z,X)=>()=>(X||Z((X={exports:{}}).exports,X),X.exports);var o_=(Z)=>Z;function r_(Z,X){this[Z]=o_.bind(null,X)}var l0=(Z,X)=>{for(var Q in X)eK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:r_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var t0=import.meta.require;var xO={};l0(xO,{lokiDir:()=>j0,homeLokiDir:()=>R4,findRepoRootForVersion:()=>X$,REPO_ROOT:()=>i0});import{resolve as n7,dirname as Z$}from"path";import{fileURLToPath as t_}from"url";import{existsSync as UQ}from"fs";import{homedir as e_}from"os";function Zf(){let Z=kO;for(let X=0;X<6;X++){if(UQ(n7(Z,"VERSION"))&&UQ(n7(Z,"autonomy/run.sh")))return Z;let Q=Z$(Z);if(Q===Z)break;Z=Q}return n7(kO,"..","..","..")}function X$(Z){let X=Z;for(let Q=0;Q<6;Q++){if(UQ(n7(X,"VERSION"))&&UQ(n7(X,"autonomy/run.sh")))return X;let Y=Z$(X);if(Y===X)break;X=Y}return n7(Z,"..","..","..")}function j0(){return process.env.LOKI_DIR??n7(process.cwd(),".loki")}function R4(){return n7(e_(),".loki")}var kO,i0;var H8=p(()=>{kO=Z$(t_(import.meta.url));i0=Zf()});import{readFileSync as Xf}from"fs";import{resolve as Qf,dirname as Yf}from"path";import{fileURLToPath as Jf}from"url";function h3(){if(h5!==null)return h5;let Z="9.19.1";if(typeof Z==="string"&&Z.length>0)return h5=Z,h5;try{let X=Yf(Jf(import.meta.url)),Q=X$(X);h5=Xf(Qf(Q,"VERSION"),"utf-8").trim()}catch{h5="unknown"}return h5}var h5=null;var BQ=p(()=>{H8()});var _O={};l0(_O,{runOrThrow:()=>Tf,run:()=>E0,readStreamCapped:()=>NQ,commandVersion:()=>Cf,commandExists:()=>X9,ShellError:()=>Q$,MAX_STDOUT_BYTES:()=>bO});async function NQ(Z,X=bO){let Q=Z.getReader(),Y=new TextDecoder,J="",z=0;try{while(z<X){let{done:K,value:$}=await Q.read();if(K)break;if(!$)continue;if(z+=$.byteLength,z>X){let V=$.byteLength-(z-X);J+=Y.decode($.subarray(0,V),{stream:!0});break}J+=Y.decode($,{stream:!0})}J+=Y.decode()}finally{try{await Q.cancel()}catch{}Q.releaseLock()}return J}async function E0(Z,X={}){let Q=Bun.spawn({cmd:[...Z],stdout:"pipe",stderr:"pipe",env:X.env?{...process.env,...X.env}:process.env,cwd:X.cwd}),Y,J;if(X.timeoutMs&&X.timeoutMs>0)Y=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}J=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},X.timeoutMs);try{let[z,K,$]=await Promise.all([NQ(Q.stdout),new Response(Q.stderr).text(),Q.exited]);return{stdout:z,stderr:K,exitCode:$}}finally{if(Y)clearTimeout(Y);if(J)clearTimeout(J)}}async function Tf(Z,X={}){let Q=await E0(Z,X);if(Q.exitCode!==0)throw new Q$(`command failed (${Q.exitCode}): ${Z.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function X9(Z){let X=wf(Z),Q=await E0(["sh","-c",`command -v ${X}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function wf(Z){if(!/^[A-Za-z0-9._/-]+$/.test(Z))throw Error(`refused to shell-escape suspect token: ${Z}`);return Z}async function Cf(Z,X="--version"){if(!await X9(Z))return null;let Y=await E0([Z,X],{timeoutMs:5000});if(Y.exitCode!==0)return null;return((Y.stdout||Y.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var bO=16777216,Q$;var x9=p(()=>{Q$=class Q$ extends Error{message;exitCode;stdout;stderr;constructor(Z,X,Q,Y){super(Z);this.message=Z;this.exitCode=X;this.stdout=Q;this.stderr=Y;this.name="ShellError"}}});function o7(Z){return Ff?"":Z}var Ff,A0,F8,_0,$W0,a0,V8,Q9,h;var S6=p(()=>{Ff=(process.env.NO_COLOR??"").length>0;A0=o7("\x1B[0;31m"),F8=o7("\x1B[0;32m"),_0=o7("\x1B[1;33m"),$W0=o7("\x1B[0;34m"),a0=o7("\x1B[0;36m"),V8=o7("\x1B[1m"),Q9=o7("\x1B[2m"),h=o7("\x1B[0m")});import{existsSync as ff}from"fs";async function E7(){if(x4!==void 0)return x4;let Z="/opt/homebrew/bin/python3.12";if(ff(Z))return x4=Z,Z;let X=await X9("python3.12");if(X)return x4=X,X;let Q=await X9("python3");return x4=Q,Q}async function Y7(Z,X={}){let Q=await E7();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return E0([Q,"-c",Z],X)}var x4;var r7=p(()=>{x9()});var XA={};l0(XA,{runStatus:()=>Vh});import{existsSync as Y9,readFileSync as g3,readdirSync as aO,statSync as sO}from"fs";import{resolve as h8,basename as ef}from"path";import{homedir as Zh}from"os";function nO(Z){let X=Math.trunc(Z);if(X>=1e6)return`${(Math.trunc(X/1e6*10)/10).toFixed(1)}M`;if(X>=1000)return`${(Math.trunc(X/1000*10)/10).toFixed(1)}K`;return String(X)}function oO(Z,X,Q){if(X===0)return null;let Y=Math.trunc(Z*100/X),J=Math.trunc(Z*AQ/X);if(J>AQ)J=AQ;let z=AQ-J,K=F8;if(Y>=80)K=A0;else if(Y>=50)K=_0;let $="=".repeat(Math.max(0,J))+" ".repeat(Math.max(0,z)),V=nO(Z),W=nO(X);return` ${V8}${Q}${h} ${K}[${$}]${h} ${Y}% (${V} / ${W})`}async function Qh(){if(await X9("jq"))return!0;return process.stdout.write(`${A0}Error: jq is required but not installed.${h}
|
|
3
3
|
`),process.stdout.write(`Install with:
|
|
4
4
|
`),process.stdout.write(` brew install jq (macOS)
|
|
5
5
|
`),process.stdout.write(` apt install jq (Debian/Ubuntu)
|
|
@@ -1236,4 +1236,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
|
|
|
1236
1236
|
`),2}case"start":{let{runStart:Y}=await Promise.resolve().then(() => (g_(),v_));return Y(Q)}default:return process.stderr.write(`Unknown command: ${X}
|
|
1237
1237
|
`),process.stderr.write(m_),2}}lO();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var dV0=await pV0(Bun.argv.slice(2));process.exit(dV0);
|
|
1238
1238
|
|
|
1239
|
-
//# debugId=
|
|
1239
|
+
//# debugId=78ABA7C02A59114D64756E2164756E21
|
package/mcp/__init__.py
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loki-mode",
|
|
3
3
|
"mcpName": "io.github.asklokesh/loki-mode",
|
|
4
|
-
"version": "9.
|
|
4
|
+
"version": "9.19.1",
|
|
5
5
|
"description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"agent",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "loki-mode",
|
|
4
4
|
"displayName": "Loki Mode",
|
|
5
|
-
"version": "9.
|
|
5
|
+
"version": "9.19.1",
|
|
6
6
|
"description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Autonomi",
|