loki-mode 7.8.3 → 7.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 "$tmpfile" --desc "$gist_desc" $visibility 2>&1)
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 "$tmpfile"
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 "$@"