loki-mode 7.8.3 → 7.9.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/lib/proof-generator.py +721 -0
- package/autonomy/lib/proof-template.html +803 -0
- package/autonomy/lib/proof_redact.py +297 -0
- package/autonomy/loki +313 -2
- package/autonomy/run.sh +36 -0
- package/bin/loki +1 -1
- package/completions/_loki +9 -0
- package/completions/loki.bash +12 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +90 -0
- package/dashboard/static/proofs.html +119 -0
- package/docs/INSTALLATION.md +1 -1
- package/loki-ts/dist/loki.js +233 -170
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Redaction module for Loki Mode proof-of-run artifacts.
|
|
2
|
+
|
|
3
|
+
This is the single security chokepoint for the proof-of-run feature. The
|
|
4
|
+
generator assembles the full proof dict, then calls redact_tree() exactly
|
|
5
|
+
once before serialization. The HTML page is built only from the redacted
|
|
6
|
+
dict. If redaction did not run, the generator refuses to emit.
|
|
7
|
+
|
|
8
|
+
RULES_VERSION is part of the frozen schema; bump it only when the redaction
|
|
9
|
+
behavior changes in a way callers must be able to detect.
|
|
10
|
+
|
|
11
|
+
Patterns implemented (see R1-proof-of-run-PLAN.md REDACTION RULES):
|
|
12
|
+
- Anthropic keys (sk-ant-...) -> [REDACTED:ANTHROPIC_KEY]
|
|
13
|
+
- OpenAI-style keys (sk-...) -> [REDACTED:OPENAI_KEY]
|
|
14
|
+
- Google API keys (AI...) -> [REDACTED:GOOGLE_KEY]
|
|
15
|
+
- GitHub tokens (gh[pousr]_) -> [REDACTED:GITHUB_TOKEN]
|
|
16
|
+
- AWS access key ids (AKIA..) -> [REDACTED:AWS_KEY]
|
|
17
|
+
- AWS secret access keys -> [REDACTED:AWS_SECRET]
|
|
18
|
+
- Slack tokens (xox[baprs]-) -> [REDACTED:SLACK_TOKEN]
|
|
19
|
+
- Bearer tokens -> Bearer [REDACTED]
|
|
20
|
+
- JWTs (eyJ...) -> [REDACTED:JWT]
|
|
21
|
+
- PEM PRIVATE KEY blocks -> dropped whole -> [REDACTED:PRIVATE_KEY]
|
|
22
|
+
- .env / JSON / YAML secret assigns -> KEY=[REDACTED] / "key": "[REDACTED]"
|
|
23
|
+
- Connection-string credentials -> scheme://user:[REDACTED]@host
|
|
24
|
+
- Absolute user home paths -> ~ (or repo-relative when ctx HOME matches)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
|
|
29
|
+
RULES_VERSION = "1.0"
|
|
30
|
+
|
|
31
|
+
# Module-level context set via set_context() by the generator before
|
|
32
|
+
# redact_tree() runs. Lets path redaction prefer repo-relative output when a
|
|
33
|
+
# repo root is known. Context-free fallbacks always apply regardless.
|
|
34
|
+
_CTX = {"home": None, "repo_root": None}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def set_context(home=None, repo_root=None):
|
|
38
|
+
"""Provide optional context used by path redaction.
|
|
39
|
+
|
|
40
|
+
home: the user's home dir ($HOME). Absolute paths under it collapse to ~.
|
|
41
|
+
repo_root: the repository root. Absolute paths under it become repo-rel.
|
|
42
|
+
Both are best-effort; the generic /Users/<n>/ and /home/<n>/ collapse
|
|
43
|
+
runs even when this is never called.
|
|
44
|
+
"""
|
|
45
|
+
if home:
|
|
46
|
+
_CTX["home"] = home.rstrip("/")
|
|
47
|
+
if repo_root:
|
|
48
|
+
_CTX["repo_root"] = repo_root.rstrip("/")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def reset_context():
|
|
52
|
+
"""Clear context. Mainly for tests."""
|
|
53
|
+
_CTX["home"] = None
|
|
54
|
+
_CTX["repo_root"] = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Each entry: (compiled_regex, replacement). Order matters: the most specific
|
|
58
|
+
# patterns must run before broader ones (e.g. sk-ant- before sk-).
|
|
59
|
+
_PATTERNS = [
|
|
60
|
+
# Anthropic keys must precede the generic sk- rule.
|
|
61
|
+
(re.compile(r"sk-ant-[A-Za-z0-9_-]{20,}"), "[REDACTED:ANTHROPIC_KEY]"),
|
|
62
|
+
# GitHub tokens: ghp_, gho_, ghu_, ghs_, ghr_.
|
|
63
|
+
(re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"), "[REDACTED:GITHUB_TOKEN]"),
|
|
64
|
+
# Slack tokens: xoxb-, xoxa-, xoxp-, xoxr-, xoxs-.
|
|
65
|
+
(re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), "[REDACTED:SLACK_TOKEN]"),
|
|
66
|
+
# AWS access key id.
|
|
67
|
+
(re.compile(r"AKIA[0-9A-Z]{16}"), "[REDACTED:AWS_KEY]"),
|
|
68
|
+
# JWTs: three base64url segments separated by dots, starting eyJ.
|
|
69
|
+
(
|
|
70
|
+
re.compile(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"),
|
|
71
|
+
"[REDACTED:JWT]",
|
|
72
|
+
),
|
|
73
|
+
# Google API keys (broad per app_secrets.py:22).
|
|
74
|
+
(re.compile(r"AI[a-zA-Z0-9_-]{30,}"), "[REDACTED:GOOGLE_KEY]"),
|
|
75
|
+
# Generic OpenAI-style keys (after sk-ant- so it does not eat them).
|
|
76
|
+
(re.compile(r"sk-[A-Za-z0-9_-]{20,}"), "[REDACTED:OPENAI_KEY]"),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
# Bearer tokens: keep the scheme, redact the credential.
|
|
80
|
+
_BEARER = re.compile(r"(Bearer\s+)[A-Za-z0-9._~+/=-]{20,}")
|
|
81
|
+
|
|
82
|
+
# PEM PRIVATE KEY blocks (any -----BEGIN ... PRIVATE KEY----- ... END block).
|
|
83
|
+
# DOTALL so the body spanning newlines is matched and dropped whole.
|
|
84
|
+
_PEM = re.compile(
|
|
85
|
+
r"-----BEGIN[^-]*PRIVATE KEY-----.*?-----END[^-]*PRIVATE KEY-----",
|
|
86
|
+
re.DOTALL,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# AWS secret access key: 40-char base64-ish token. Anchored to a key hint so
|
|
90
|
+
# it does not nuke arbitrary 40-char strings. We look for an aws-secret style
|
|
91
|
+
# assignment and redact the value.
|
|
92
|
+
_AWS_SECRET_ASSIGN = re.compile(
|
|
93
|
+
r"(?i)(aws_secret_access_key|aws_secret)\s*[=:]\s*[\"']?[A-Za-z0-9/+=]{40}[\"']?"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Secret assignments: keep the key, redact the value. Mirrors the bash privacy
|
|
97
|
+
# guard at autonomy/run.sh:9047, extended to tolerate JSON / YAML-quoted forms.
|
|
98
|
+
#
|
|
99
|
+
# Three shapes are covered by one pattern:
|
|
100
|
+
# bare DB_PASSWORD=hunter2 DB_PASSWORD: hunter2
|
|
101
|
+
# JSON-quoted "db_password": "hunter2" "token":"abc"
|
|
102
|
+
# single-quote 'client_secret': 'abc'
|
|
103
|
+
#
|
|
104
|
+
# Group layout:
|
|
105
|
+
# 1 -> optional opening quote around the key (" or ' or none); a backref
|
|
106
|
+
# forces the closing quote to match so we never key off a stray quote.
|
|
107
|
+
# 2 -> the key itself (must contain a secret keyword, case-insensitive).
|
|
108
|
+
# 3 -> the separator run (= or :, with surrounding whitespace).
|
|
109
|
+
# 4 -> the value, either a fully-quoted string or a bare non-space run.
|
|
110
|
+
#
|
|
111
|
+
# The leading \b (placed AFTER the optional opening quote, BEFORE the key
|
|
112
|
+
# word-chars) anchors each attempt to a word boundary. Without it, the greedy
|
|
113
|
+
# [A-Za-z0-9_]* prefix would restart at every offset inside a long
|
|
114
|
+
# word-character run, making the scan O(n^2) (a ReDoS / proof-generator DoS on
|
|
115
|
+
# large diffs). With it the scan is linear.
|
|
116
|
+
_ENV_ASSIGN = re.compile(
|
|
117
|
+
r"(?i)"
|
|
118
|
+
r"([\"']?)" # 1: optional opening quote
|
|
119
|
+
r"\b" # anchor: keeps the scan linear
|
|
120
|
+
r"([A-Za-z0-9_]*"
|
|
121
|
+
r"(?:PASSPHRASE|PASS_PHRASE|PASSWORD|PASSWD|PWD|SECRET|TOKEN|API[_-]?KEY|PRIVATE_KEY"
|
|
122
|
+
r"|AWS_SECRET_ACCESS_KEY|AWS_SECRET|DB_PASS|CREDENTIAL|CLIENT_SECRET"
|
|
123
|
+
r"|ACCESS_TOKEN|REFRESH_TOKEN|AUTH(?!ORIZATION))"
|
|
124
|
+
# AUTH(?!ORIZATION): match auth / auth_token / oauth keys but NOT the HTTP
|
|
125
|
+
# "Authorization: Bearer <tok>" header, which the Bearer rule owns (it keeps
|
|
126
|
+
# the scheme word). "authorization_token" still matches via the TOKEN branch.
|
|
127
|
+
r"[A-Za-z0-9_]*)" # 2: key
|
|
128
|
+
r"\1" # matching closing quote
|
|
129
|
+
r"(\s*[=:]\s*)" # 3: separator
|
|
130
|
+
r"(\"[^\"]*\"|'[^']*'|\S+)" # 4: quoted-or-bare value
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _env_assign_sub(m):
|
|
135
|
+
"""Redact a secret assignment value, preserving key, separator and quotes.
|
|
136
|
+
|
|
137
|
+
Quoted values keep their surrounding quotes (so "k": "v" -> "k": "[REDACTED]"
|
|
138
|
+
stays valid JSON/YAML); bare values are replaced wholesale.
|
|
139
|
+
"""
|
|
140
|
+
open_q, key, sep, val = m.group(1), m.group(2), m.group(3), m.group(4)
|
|
141
|
+
if val and val[0] in "\"'" and len(val) >= 2 and val[-1] == val[0]:
|
|
142
|
+
q = val[0]
|
|
143
|
+
return open_q + key + open_q + sep + q + "[REDACTED]" + q
|
|
144
|
+
return open_q + key + open_q + sep + "[REDACTED]"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# Connection-string / URI credentials: scheme://user:PASSWORD@host -> redact
|
|
148
|
+
# the password component for ANY scheme, keeping scheme/user/host intact.
|
|
149
|
+
# The leading \b anchors each attempt; the negated classes ([^\s:/@] etc.) make
|
|
150
|
+
# each segment a single linear pass with no nested quantifier overlap.
|
|
151
|
+
# 1 -> scheme://user: 2 -> password 3 -> @
|
|
152
|
+
# Covers postgres://, mongodb://, redis:// (empty user), amqp://, https://, etc.
|
|
153
|
+
# A URL with no "user:pass@" credential (https://host/path) does not match.
|
|
154
|
+
# The password class is [^\s@]+ (stops only at the closing @) so passwords that
|
|
155
|
+
# contain "/" (e.g. postgres://user:p/ss@host) are still fully captured.
|
|
156
|
+
_URI_CREDENTIAL = re.compile(
|
|
157
|
+
r"\b([A-Za-z][A-Za-z0-9+.\-]*://[^\s:/@]*:)([^\s@]+)(@)"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Absolute user home paths -> ~ . Applied to every string (paths, diffs,
|
|
161
|
+
# brief, summaries). Windows form handled separately to keep the backslash
|
|
162
|
+
# class readable.
|
|
163
|
+
_UNIX_HOME = re.compile(r"/(?:Users|home)/[^/\s\"']+")
|
|
164
|
+
_WIN_HOME = re.compile(r"[Cc]:\\Users\\[^\\\s\"']+")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _redact_paths(s):
|
|
168
|
+
"""Collapse absolute user paths. Returns (new_string, count)."""
|
|
169
|
+
count = 0
|
|
170
|
+
|
|
171
|
+
def _unix_sub(m):
|
|
172
|
+
nonlocal count
|
|
173
|
+
count += 1
|
|
174
|
+
matched = m.group(0)
|
|
175
|
+
home = _CTX["home"]
|
|
176
|
+
repo_root = _CTX["repo_root"]
|
|
177
|
+
# Prefer repo-relative when the path is inside a known repo root.
|
|
178
|
+
# Note: m only captured /Users/<n> (no trailing path), so we cannot
|
|
179
|
+
# reconstruct the full path here. We collapse the home prefix to ~ and
|
|
180
|
+
# leave the remainder (handled by the caller operating on the whole
|
|
181
|
+
# string is not possible per-match, so ~ is the safe generic result).
|
|
182
|
+
if home and matched == home:
|
|
183
|
+
return "~"
|
|
184
|
+
if repo_root and matched == repo_root:
|
|
185
|
+
return "."
|
|
186
|
+
return "~"
|
|
187
|
+
|
|
188
|
+
def _win_sub(m):
|
|
189
|
+
nonlocal count
|
|
190
|
+
count += 1
|
|
191
|
+
return "~"
|
|
192
|
+
|
|
193
|
+
# First collapse a full $HOME / repo_root prefix anywhere in the string,
|
|
194
|
+
# which preserves the trailing path component (e.g. ~/git/x).
|
|
195
|
+
home = _CTX["home"]
|
|
196
|
+
repo_root = _CTX["repo_root"]
|
|
197
|
+
if repo_root and repo_root in s:
|
|
198
|
+
n = s.count(repo_root)
|
|
199
|
+
s = s.replace(repo_root, ".")
|
|
200
|
+
count += n
|
|
201
|
+
if home and home in s:
|
|
202
|
+
n = s.count(home)
|
|
203
|
+
s = s.replace(home, "~")
|
|
204
|
+
count += n
|
|
205
|
+
|
|
206
|
+
s, n1 = _UNIX_HOME.subn(_unix_sub, s)
|
|
207
|
+
s, n2 = _WIN_HOME.subn(_win_sub, s)
|
|
208
|
+
return s, count
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _redact_value(s):
|
|
212
|
+
"""Redact a single string. Returns (new_string, redactions_count).
|
|
213
|
+
|
|
214
|
+
Internal: counts every individual redaction (multiple secrets in one
|
|
215
|
+
string each increment the count) via re.subn.
|
|
216
|
+
"""
|
|
217
|
+
if not isinstance(s, str) or not s:
|
|
218
|
+
return s, 0
|
|
219
|
+
|
|
220
|
+
total = 0
|
|
221
|
+
|
|
222
|
+
# PEM blocks first: drop the whole block before any token-level rule can
|
|
223
|
+
# partially match inside it.
|
|
224
|
+
s, n = _PEM.subn("[REDACTED:PRIVATE_KEY]", s)
|
|
225
|
+
total += n
|
|
226
|
+
|
|
227
|
+
# AWS secret assignments (typed) before generic env-assign so the value
|
|
228
|
+
# is labelled rather than reduced to a generic [REDACTED].
|
|
229
|
+
s, n = _AWS_SECRET_ASSIGN.subn(
|
|
230
|
+
lambda m: m.group(1) + "=[REDACTED:AWS_SECRET]", s
|
|
231
|
+
)
|
|
232
|
+
total += n
|
|
233
|
+
|
|
234
|
+
# Token patterns (ordered most-specific-first).
|
|
235
|
+
for pat, repl in _PATTERNS:
|
|
236
|
+
s, n = pat.subn(repl, s)
|
|
237
|
+
total += n
|
|
238
|
+
|
|
239
|
+
# Bearer tokens (keep scheme).
|
|
240
|
+
s, n = _BEARER.subn(r"\1[REDACTED]", s)
|
|
241
|
+
total += n
|
|
242
|
+
|
|
243
|
+
# Connection-string / URI credentials: scheme://user:PASSWORD@ -> redact
|
|
244
|
+
# the password. Runs before the generic env-assign so the "scheme://user:"
|
|
245
|
+
# prefix is consumed and the assign rule does not double-process it.
|
|
246
|
+
s, n = _URI_CREDENTIAL.subn(r"\1[REDACTED]\3", s)
|
|
247
|
+
total += n
|
|
248
|
+
|
|
249
|
+
# Secret assignments (bare, JSON-quoted, YAML-quoted): keep key, redact value.
|
|
250
|
+
s, n = _ENV_ASSIGN.subn(_env_assign_sub, s)
|
|
251
|
+
total += n
|
|
252
|
+
|
|
253
|
+
# Absolute user paths.
|
|
254
|
+
s, n = _redact_paths(s)
|
|
255
|
+
total += n
|
|
256
|
+
|
|
257
|
+
return s, total
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def redact_value(s):
|
|
261
|
+
"""Public: redact a single string, returning only the redacted string."""
|
|
262
|
+
out, _ = _redact_value(s)
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def redact_tree(obj):
|
|
267
|
+
"""Recursively redact every string in a JSON-like structure.
|
|
268
|
+
|
|
269
|
+
Recurses both dict KEYS and dict VALUES, list items, and nested
|
|
270
|
+
structures. Returns (new_object, total_redactions_count).
|
|
271
|
+
"""
|
|
272
|
+
if isinstance(obj, str):
|
|
273
|
+
return _redact_value(obj)
|
|
274
|
+
|
|
275
|
+
if isinstance(obj, dict):
|
|
276
|
+
out = {}
|
|
277
|
+
total = 0
|
|
278
|
+
for k, v in obj.items():
|
|
279
|
+
new_k, ck = (k, 0)
|
|
280
|
+
if isinstance(k, str):
|
|
281
|
+
new_k, ck = _redact_value(k)
|
|
282
|
+
new_v, cv = redact_tree(v)
|
|
283
|
+
out[new_k] = new_v
|
|
284
|
+
total += ck + cv
|
|
285
|
+
return out, total
|
|
286
|
+
|
|
287
|
+
if isinstance(obj, (list, tuple)):
|
|
288
|
+
out = []
|
|
289
|
+
total = 0
|
|
290
|
+
for item in obj:
|
|
291
|
+
new_item, c = redact_tree(item)
|
|
292
|
+
out.append(new_item)
|
|
293
|
+
total += c
|
|
294
|
+
return out, total
|
|
295
|
+
|
|
296
|
+
# int, float, bool, None: nothing to redact.
|
|
297
|
+
return obj, 0
|
package/autonomy/loki
CHANGED
|
@@ -566,6 +566,7 @@ show_help() {
|
|
|
566
566
|
echo " code [cmd] Codebase intelligence (overview|symbols|deps|hotspots|diff)"
|
|
567
567
|
echo " report [opts] Session report generator (--format text|markdown|html, --output)"
|
|
568
568
|
echo " share [opts] Share session report as GitHub Gist (--private, --format)"
|
|
569
|
+
echo " proof [cmd] Inspect/share proof-of-run artifacts (list|show|open|share)"
|
|
569
570
|
echo " version Show version"
|
|
570
571
|
echo " help Show this help"
|
|
571
572
|
echo ""
|
|
@@ -13074,6 +13075,9 @@ main() {
|
|
|
13074
13075
|
share)
|
|
13075
13076
|
cmd_share "$@"
|
|
13076
13077
|
;;
|
|
13078
|
+
proof)
|
|
13079
|
+
cmd_proof "$@"
|
|
13080
|
+
;;
|
|
13077
13081
|
context|ctx)
|
|
13078
13082
|
cmd_context "$@"
|
|
13079
13083
|
;;
|
|
@@ -24504,12 +24508,26 @@ cmd_share() {
|
|
|
24504
24508
|
# Upload as gist
|
|
24505
24509
|
echo "Uploading session report..."
|
|
24506
24510
|
local gist_desc="Loki Mode session report ($(date +%Y-%m-%dT%H:%M:%S))"
|
|
24511
|
+
_loki_gist_upload "$tmpfile" "$gist_desc" "$visibility"
|
|
24512
|
+
}
|
|
24513
|
+
|
|
24514
|
+
# Internal helper: upload a single file as a GitHub Gist.
|
|
24515
|
+
# Args: $1=file path, $2=description, $3=visibility ("--public", "--private",
|
|
24516
|
+
# or "" for gh's default). NOTE: $3 is intentionally left unquoted at the
|
|
24517
|
+
# gh call site so an empty value collapses (the --private case sets it to "").
|
|
24518
|
+
# Removes the input file after the attempt. Prints "Shared: <url>" on success;
|
|
24519
|
+
# on failure prints the gh error and exits 1.
|
|
24520
|
+
_loki_gist_upload() {
|
|
24521
|
+
local file="$1"
|
|
24522
|
+
local desc="$2"
|
|
24523
|
+
local visibility="$3"
|
|
24524
|
+
|
|
24507
24525
|
local gist_url
|
|
24508
|
-
gist_url=$(gh gist create "$
|
|
24526
|
+
gist_url=$(gh gist create "$file" --desc "$desc" $visibility 2>&1)
|
|
24509
24527
|
local gist_exit=$?
|
|
24510
24528
|
|
|
24511
24529
|
# Cleanup temp file
|
|
24512
|
-
rm -f "$
|
|
24530
|
+
rm -f "$file"
|
|
24513
24531
|
|
|
24514
24532
|
if [ $gist_exit -ne 0 ]; then
|
|
24515
24533
|
echo -e "${RED}Failed to create gist${NC}"
|
|
@@ -24517,7 +24535,300 @@ cmd_share() {
|
|
|
24517
24535
|
exit 1
|
|
24518
24536
|
fi
|
|
24519
24537
|
|
|
24538
|
+
# Expose the URL to callers (e.g. cmd_proof prints a ready-to-post hook
|
|
24539
|
+
# after this returns). The shared "Shared:" line stays unchanged.
|
|
24540
|
+
LOKI_LAST_GIST_URL="$gist_url"
|
|
24520
24541
|
echo -e "${GREEN}Shared: ${gist_url}${NC}"
|
|
24521
24542
|
}
|
|
24522
24543
|
|
|
24544
|
+
# loki proof - inspect and share proof-of-run artifacts (.loki/proofs/<id>/).
|
|
24545
|
+
# Subcommands: list | show <id> | open <id> | share <id>.
|
|
24546
|
+
# The proof.json schema is frozen (R1 spec). Reads are tolerant of missing
|
|
24547
|
+
# keys (early/degraded proofs) and default to "-".
|
|
24548
|
+
cmd_proof() {
|
|
24549
|
+
local loki_dir="${LOKI_DIR:-.loki}"
|
|
24550
|
+
local proofs_dir="${loki_dir}/proofs"
|
|
24551
|
+
local sub="${1:-}"
|
|
24552
|
+
[ $# -gt 0 ] && shift
|
|
24553
|
+
|
|
24554
|
+
case "$sub" in
|
|
24555
|
+
""|--help|-h|help)
|
|
24556
|
+
echo -e "${BOLD}loki proof${NC} - inspect and share proof-of-run artifacts"
|
|
24557
|
+
echo ""
|
|
24558
|
+
echo "Usage: loki proof <subcommand> [args]"
|
|
24559
|
+
echo ""
|
|
24560
|
+
echo "Subcommands:"
|
|
24561
|
+
echo " list List proof-of-run artifacts in .loki/proofs/"
|
|
24562
|
+
echo " show <id> Pretty-print .loki/proofs/<id>/proof.json"
|
|
24563
|
+
echo " open <id> Open .loki/proofs/<id>/index.html in a browser"
|
|
24564
|
+
echo " share <id> Publish the proof page as a GitHub Gist (opt-in)"
|
|
24565
|
+
echo ""
|
|
24566
|
+
echo "Options for 'share':"
|
|
24567
|
+
echo " --yes Skip the redaction-preview confirmation prompt"
|
|
24568
|
+
echo " --private Create a secret gist (default: public)"
|
|
24569
|
+
echo " --hosted Reserved for hosted publishing (coming in R9)"
|
|
24570
|
+
echo ""
|
|
24571
|
+
echo "Proofs are generated automatically at run completion (LOKI_PROOF=0 to opt out)."
|
|
24572
|
+
[ "$sub" = "" ] && exit 1
|
|
24573
|
+
exit 0
|
|
24574
|
+
;;
|
|
24575
|
+
list)
|
|
24576
|
+
if [ ! -d "$proofs_dir" ]; then
|
|
24577
|
+
echo -e "${YELLOW}No proofs found.${NC} Run 'loki start' to generate one."
|
|
24578
|
+
exit 0
|
|
24579
|
+
fi
|
|
24580
|
+
local found=0
|
|
24581
|
+
local pj
|
|
24582
|
+
for pj in "$proofs_dir"/*/proof.json; do
|
|
24583
|
+
[ -f "$pj" ] || continue
|
|
24584
|
+
# Print the header lazily, only once a valid proof is found, so
|
|
24585
|
+
# an empty (or proof-less) proofs dir prints just the
|
|
24586
|
+
# "No proofs found" line -- matching the Bun route (proof.ts
|
|
24587
|
+
# listProofs, which returns before the header when rows is empty).
|
|
24588
|
+
if [ "$found" -eq 0 ]; then
|
|
24589
|
+
printf "%-26s %-20s %-10s %-9s %s\n" "RUN_ID" "GENERATED_AT" "VERDICT" "COST_USD" "FILES"
|
|
24590
|
+
fi
|
|
24591
|
+
found=1
|
|
24592
|
+
LOKI_PROOF_JSON="$pj" python3 - <<'PYEOF'
|
|
24593
|
+
import json, os
|
|
24594
|
+
p = os.environ["LOKI_PROOF_JSON"]
|
|
24595
|
+
try:
|
|
24596
|
+
with open(p) as f:
|
|
24597
|
+
d = json.load(f)
|
|
24598
|
+
except Exception:
|
|
24599
|
+
d = {}
|
|
24600
|
+
# Coerce missing AND explicitly-null fields to "-" so a null final_verdict /
|
|
24601
|
+
# cost.usd / count renders as "-" rather than Python's str(None) == "None".
|
|
24602
|
+
# This matches the Bun route (proof.ts str(): null/undefined -> "-").
|
|
24603
|
+
def s(v):
|
|
24604
|
+
return "-" if v is None else str(v)
|
|
24605
|
+
run_id = d.get("run_id")
|
|
24606
|
+
gen = d.get("generated_at")
|
|
24607
|
+
verdict = (d.get("council") or {}).get("final_verdict")
|
|
24608
|
+
cost = (d.get("cost") or {}).get("usd")
|
|
24609
|
+
files = (d.get("files_changed") or {}).get("count")
|
|
24610
|
+
print("{:<26} {:<20} {:<10} {:<9} {}".format(
|
|
24611
|
+
s(run_id), s(gen), s(verdict), s(cost), s(files)))
|
|
24612
|
+
PYEOF
|
|
24613
|
+
done
|
|
24614
|
+
if [ "$found" -eq 0 ]; then
|
|
24615
|
+
echo -e "${YELLOW}No proofs found.${NC} Run 'loki start' to generate one."
|
|
24616
|
+
fi
|
|
24617
|
+
exit 0
|
|
24618
|
+
;;
|
|
24619
|
+
show)
|
|
24620
|
+
local id="${1:-}"
|
|
24621
|
+
if [ -z "$id" ]; then
|
|
24622
|
+
echo -e "${RED}Missing proof id.${NC} Use 'loki proof list'."
|
|
24623
|
+
exit 2
|
|
24624
|
+
fi
|
|
24625
|
+
local pj="${proofs_dir}/${id}/proof.json"
|
|
24626
|
+
if [ ! -f "$pj" ]; then
|
|
24627
|
+
echo -e "${RED}Proof not found: ${id}${NC}"
|
|
24628
|
+
echo "Use 'loki proof list' to see available proofs."
|
|
24629
|
+
exit 1
|
|
24630
|
+
fi
|
|
24631
|
+
if command -v jq &>/dev/null; then
|
|
24632
|
+
jq . "$pj"
|
|
24633
|
+
else
|
|
24634
|
+
LOKI_PROOF_JSON="$pj" python3 -c "import json,os; print(json.dumps(json.load(open(os.environ['LOKI_PROOF_JSON'])), indent=2))"
|
|
24635
|
+
fi
|
|
24636
|
+
exit 0
|
|
24637
|
+
;;
|
|
24638
|
+
open)
|
|
24639
|
+
local id="${1:-}"
|
|
24640
|
+
if [ -z "$id" ]; then
|
|
24641
|
+
echo -e "${RED}Missing proof id.${NC} Use 'loki proof list'."
|
|
24642
|
+
exit 2
|
|
24643
|
+
fi
|
|
24644
|
+
local html="${proofs_dir}/${id}/index.html"
|
|
24645
|
+
if [ ! -f "$html" ]; then
|
|
24646
|
+
echo -e "${RED}Proof page not found: ${id}/index.html${NC}"
|
|
24647
|
+
echo "Use 'loki proof list' to see available proofs."
|
|
24648
|
+
exit 1
|
|
24649
|
+
fi
|
|
24650
|
+
echo -e "${GREEN}Opening proof: $html${NC}"
|
|
24651
|
+
if command -v open &>/dev/null; then
|
|
24652
|
+
open "$html"
|
|
24653
|
+
elif command -v xdg-open &>/dev/null; then
|
|
24654
|
+
xdg-open "$html"
|
|
24655
|
+
elif command -v start &>/dev/null; then
|
|
24656
|
+
start "$html"
|
|
24657
|
+
else
|
|
24658
|
+
echo ""
|
|
24659
|
+
echo "Could not detect browser opener."
|
|
24660
|
+
echo "Please open in browser: $html"
|
|
24661
|
+
fi
|
|
24662
|
+
exit 0
|
|
24663
|
+
;;
|
|
24664
|
+
share)
|
|
24665
|
+
local id=""
|
|
24666
|
+
local skip_confirm=0
|
|
24667
|
+
local visibility="--public"
|
|
24668
|
+
while [[ $# -gt 0 ]]; do
|
|
24669
|
+
case "$1" in
|
|
24670
|
+
--yes|-y) skip_confirm=1; shift ;;
|
|
24671
|
+
--private) visibility=""; shift ;;
|
|
24672
|
+
--public) visibility="--public"; shift ;;
|
|
24673
|
+
--hosted)
|
|
24674
|
+
echo -e "${RED}Hosted publishing is not available yet (coming in R9).${NC}"
|
|
24675
|
+
exit 1
|
|
24676
|
+
;;
|
|
24677
|
+
-*) echo -e "${RED}Unknown option: $1${NC}"; exit 1 ;;
|
|
24678
|
+
*) id="$1"; shift ;;
|
|
24679
|
+
esac
|
|
24680
|
+
done
|
|
24681
|
+
if [ -z "$id" ]; then
|
|
24682
|
+
echo -e "${RED}Missing proof id.${NC} Use 'loki proof list'."
|
|
24683
|
+
exit 2
|
|
24684
|
+
fi
|
|
24685
|
+
local html="${proofs_dir}/${id}/index.html"
|
|
24686
|
+
if [ ! -f "$html" ]; then
|
|
24687
|
+
echo -e "${RED}Proof page not found: ${id}/index.html${NC}"
|
|
24688
|
+
echo "Use 'loki proof list' to see available proofs."
|
|
24689
|
+
exit 1
|
|
24690
|
+
fi
|
|
24691
|
+
if ! command -v gh &>/dev/null; then
|
|
24692
|
+
echo -e "${RED}gh CLI not found${NC}"
|
|
24693
|
+
echo "Install the GitHub CLI to publish a proof:"
|
|
24694
|
+
echo " brew install gh # macOS"
|
|
24695
|
+
echo " sudo apt install gh # Ubuntu/Debian"
|
|
24696
|
+
echo " https://cli.github.com # Other platforms"
|
|
24697
|
+
exit 1
|
|
24698
|
+
fi
|
|
24699
|
+
if ! gh auth status &>/dev/null 2>&1; then
|
|
24700
|
+
echo -e "${RED}GitHub CLI not authenticated${NC}"
|
|
24701
|
+
echo "Run 'gh auth login' to authenticate, then try again."
|
|
24702
|
+
exit 1
|
|
24703
|
+
fi
|
|
24704
|
+
|
|
24705
|
+
# Redaction preview. The generator already redacts the proof
|
|
24706
|
+
# before writing index.html, so this is a transparency summary,
|
|
24707
|
+
# not a second redaction pass.
|
|
24708
|
+
local pj="${proofs_dir}/${id}/proof.json"
|
|
24709
|
+
local vis_label="public"
|
|
24710
|
+
[ -z "$visibility" ] && vis_label="secret"
|
|
24711
|
+
echo -e "${BOLD}Publishing proof '${id}' as a ${vis_label} GitHub Gist${NC}"
|
|
24712
|
+
echo ""
|
|
24713
|
+
echo "What will be shared:"
|
|
24714
|
+
echo " - ${html}"
|
|
24715
|
+
if [ -f "$pj" ]; then
|
|
24716
|
+
LOKI_PROOF_JSON="$pj" python3 - <<'PYEOF' 2>/dev/null || true
|
|
24717
|
+
import json, os
|
|
24718
|
+
try:
|
|
24719
|
+
d = json.load(open(os.environ["LOKI_PROOF_JSON"]))
|
|
24720
|
+
except Exception:
|
|
24721
|
+
d = {}
|
|
24722
|
+
cost = (d.get("cost") or {}).get("usd", "-")
|
|
24723
|
+
# usd is null when cost was not collected for this run. Show an honest
|
|
24724
|
+
# "not recorded" instead of the literal "None" (a credibility wart in the
|
|
24725
|
+
# preview the sharer reads right before publishing).
|
|
24726
|
+
if cost is None:
|
|
24727
|
+
cost = "not recorded"
|
|
24728
|
+
files = (d.get("files_changed") or {}).get("count", "-")
|
|
24729
|
+
verdict = (d.get("council") or {}).get("final_verdict", "-")
|
|
24730
|
+
red = d.get("redaction") or {}
|
|
24731
|
+
print(" - cost.usd: {}".format(cost))
|
|
24732
|
+
print(" - files_changed: {}".format(files))
|
|
24733
|
+
print(" - council verdict: {}".format(verdict))
|
|
24734
|
+
print(" - redaction: applied={} rules_version={} redactions_count={}".format(
|
|
24735
|
+
red.get("applied", False), red.get("rules_version", "-"), red.get("redactions_count", "-")))
|
|
24736
|
+
PYEOF
|
|
24737
|
+
fi
|
|
24738
|
+
echo ""
|
|
24739
|
+
echo -e "${YELLOW}Secrets, API keys, tokens, env values, and absolute paths have already been stripped by the generator.${NC}"
|
|
24740
|
+
echo ""
|
|
24741
|
+
|
|
24742
|
+
if [ "$skip_confirm" -ne 1 ]; then
|
|
24743
|
+
printf "Publish this proof to a %s gist? [y/N] " "$vis_label"
|
|
24744
|
+
local reply=""
|
|
24745
|
+
read -r reply
|
|
24746
|
+
case "$reply" in
|
|
24747
|
+
y|Y|yes|YES|Yes) ;;
|
|
24748
|
+
*) echo "Aborted. Nothing was published."; exit 0 ;;
|
|
24749
|
+
esac
|
|
24750
|
+
fi
|
|
24751
|
+
|
|
24752
|
+
# Copy to a temp file so _loki_gist_upload can remove it after.
|
|
24753
|
+
local tmpfile
|
|
24754
|
+
tmpfile=$(mktemp "/tmp/loki-proof-XXXXXX.html")
|
|
24755
|
+
cp "$html" "$tmpfile"
|
|
24756
|
+
echo "Uploading proof page..."
|
|
24757
|
+
local gist_desc="Loki Mode proof-of-run ${id}"
|
|
24758
|
+
_loki_gist_upload "$tmpfile" "$gist_desc" "$visibility"
|
|
24759
|
+
|
|
24760
|
+
# Print a ready-to-post one-line hook (real cost + url) so the user
|
|
24761
|
+
# can paste it straight to X/HN. When cost was not collected, omit
|
|
24762
|
+
# the cost (never fabricate a number, never print "$0.00").
|
|
24763
|
+
if [ -n "${LOKI_LAST_GIST_URL:-}" ] && [ -f "$pj" ]; then
|
|
24764
|
+
local hook
|
|
24765
|
+
hook=$(LOKI_PROOF_JSON="$pj" LOKI_GIST_URL="$LOKI_LAST_GIST_URL" python3 - <<'PYEOF' 2>/dev/null || true
|
|
24766
|
+
import json, os
|
|
24767
|
+
try:
|
|
24768
|
+
d = json.load(open(os.environ["LOKI_PROOF_JSON"]))
|
|
24769
|
+
except Exception:
|
|
24770
|
+
d = {}
|
|
24771
|
+
cost = (d.get("cost") or {})
|
|
24772
|
+
usd = cost.get("usd")
|
|
24773
|
+
|
|
24774
|
+
|
|
24775
|
+
def fmt_usd(v):
|
|
24776
|
+
if v is None:
|
|
24777
|
+
return None
|
|
24778
|
+
try:
|
|
24779
|
+
n = float(v)
|
|
24780
|
+
except Exception:
|
|
24781
|
+
return None
|
|
24782
|
+
s = ("%.4f" % n).rstrip("0").rstrip(".")
|
|
24783
|
+
if "." not in s:
|
|
24784
|
+
s += ".00"
|
|
24785
|
+
elif len(s.split(".")[1]) == 1:
|
|
24786
|
+
s += "0"
|
|
24787
|
+
return "$" + s
|
|
24788
|
+
|
|
24789
|
+
|
|
24790
|
+
def council_ratio(d):
|
|
24791
|
+
c = d.get("council") or {}
|
|
24792
|
+
if not c.get("enabled"):
|
|
24793
|
+
return None
|
|
24794
|
+
revs = c.get("reviewers") or []
|
|
24795
|
+
if not isinstance(revs, list) or not revs:
|
|
24796
|
+
return None
|
|
24797
|
+
ok = sum(1 for r in revs if isinstance(r, dict)
|
|
24798
|
+
and str(r.get("vote") or "").upper() in ("APPROVE", "APPROVED"))
|
|
24799
|
+
return ok, len(revs)
|
|
24800
|
+
|
|
24801
|
+
|
|
24802
|
+
u = fmt_usd(usd)
|
|
24803
|
+
lead = ("Built autonomously for " + u) if u is not None \
|
|
24804
|
+
else "Built autonomously by Loki Mode"
|
|
24805
|
+
parts = [lead]
|
|
24806
|
+
fc = (d.get("files_changed") or {}).get("count", 0)
|
|
24807
|
+
try:
|
|
24808
|
+
fc = int(fc)
|
|
24809
|
+
except Exception:
|
|
24810
|
+
fc = 0
|
|
24811
|
+
parts.append("%d file%s changed" % (fc, "" if fc == 1 else "s"))
|
|
24812
|
+
cr = council_ratio(d)
|
|
24813
|
+
if cr:
|
|
24814
|
+
parts.append("%d-of-%d reviewers approved" % (cr[0], cr[1]))
|
|
24815
|
+
print(" - ".join(parts) + " " + os.environ.get("LOKI_GIST_URL", ""))
|
|
24816
|
+
PYEOF
|
|
24817
|
+
)
|
|
24818
|
+
if [ -n "$hook" ]; then
|
|
24819
|
+
echo ""
|
|
24820
|
+
echo "Ready to post:"
|
|
24821
|
+
echo " ${hook}"
|
|
24822
|
+
fi
|
|
24823
|
+
fi
|
|
24824
|
+
exit 0
|
|
24825
|
+
;;
|
|
24826
|
+
*)
|
|
24827
|
+
echo -e "${RED}Unknown subcommand: ${sub}${NC}"
|
|
24828
|
+
echo "Run 'loki proof --help' for usage."
|
|
24829
|
+
exit 1
|
|
24830
|
+
;;
|
|
24831
|
+
esac
|
|
24832
|
+
}
|
|
24833
|
+
|
|
24523
24834
|
main "$@"
|