loki-mode 7.86.0 → 7.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md 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 v7.86.0
6
+ # Loki Mode v7.87.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -408,4 +408,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
408
408
 
409
409
  ---
410
410
 
411
- **v7.86.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
411
+ **v7.87.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.86.0
1
+ 7.87.0
@@ -253,6 +253,52 @@ def _collect_build(loki_dir):
253
253
  return out
254
254
 
255
255
 
256
+ def _collect_security(loki_dir):
257
+ """Read .loki/quality/security-findings.json (the secure-by-default gate).
258
+
259
+ Deterministic FACT (pattern scan, not an LLM opinion). Tolerates an absent
260
+ file -> status not_run. Counts only ACTIVE (un-waived) findings; HIGH active
261
+ findings are the gap signal. Shape:
262
+ {ran, total, active, waived, high_active, status, findings:[{rule,severity}]}.
263
+ status: not_run (no scan) | clean (ran, no active findings) | findings
264
+ (ran, active findings present).
265
+ """
266
+ out = {
267
+ "ran": False, "total": 0, "active": 0, "waived": 0,
268
+ "high_active": 0, "status": "not_run", "findings": [],
269
+ }
270
+ raw = _read_json(
271
+ os.path.join(loki_dir, "quality", "security-findings.json"), default=None
272
+ )
273
+ if not isinstance(raw, dict):
274
+ return out
275
+ out["ran"] = True
276
+ findings = raw.get("findings") if isinstance(raw.get("findings"), list) else []
277
+ total = active = waived = high_active = 0
278
+ slim = []
279
+ for f in findings:
280
+ if not isinstance(f, dict):
281
+ continue
282
+ total += 1
283
+ is_waived = bool(f.get("waived"))
284
+ sev = str(f.get("severity") or "").upper()
285
+ if is_waived:
286
+ waived += 1
287
+ else:
288
+ active += 1
289
+ if sev == "HIGH":
290
+ high_active += 1
291
+ slim.append({"rule": str(f.get("rule") or ""), "severity": sev,
292
+ "waived": is_waived})
293
+ out["total"] = total
294
+ out["active"] = active
295
+ out["waived"] = waived
296
+ out["high_active"] = high_active
297
+ out["findings"] = slim
298
+ out["status"] = "findings" if active > 0 else "clean"
299
+ return out
300
+
301
+
256
302
  def _norm_tests_status(raw):
257
303
  """Map a recorded test status to {verified,failed,inconclusive,not_run}.
258
304
 
@@ -578,6 +624,7 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
578
624
 
579
625
  build = _collect_build(loki_dir)
580
626
  tests = _collect_tests(loki_dir)
627
+ security = _collect_security(loki_dir)
581
628
  evidence_gate = _collect_evidence_gate(loki_dir)
582
629
 
583
630
  deployed_url = os.environ.get("LOKI_DEPLOYED_URL") or None
@@ -611,6 +658,7 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
611
658
  {"name": g.get("name", ""), "status": g.get("status", "not_run")}
612
659
  for g in (quality_gates.get("gates") or [])
613
660
  ],
661
+ "security": security,
614
662
  "cost": cost,
615
663
  "meta": {
616
664
  "run_id": run_id,
@@ -711,6 +759,15 @@ def _compute_degraded(facts):
711
759
  out.append({"item": "quality_gate:%s" % g.get("name", ""),
712
760
  "status": g.get("status"),
713
761
  "reason": "gate %s" % g.get("status")})
762
+ # Secure-by-default gate: an ACTIVE (un-waived) HIGH security finding is a gap
763
+ # in the proof of done -- the receipt must surface it, never green-wash an app
764
+ # that ships a known-bad pattern. Waived findings are NOT a gap (the user
765
+ # accepted them with intent, recorded in the receipt).
766
+ sec = facts.get("security") or {}
767
+ if sec.get("ran") and (sec.get("high_active") or 0) > 0:
768
+ out.append({"item": "security", "status": "findings",
769
+ "reason": "%s un-waived HIGH security finding(s)"
770
+ % sec.get("high_active")})
714
771
  git = facts.get("git") or {}
715
772
  if not (git.get("diff") or {}).get("count"):
716
773
  out.append({"item": "git.diff", "status": "not_run",
@@ -736,11 +793,18 @@ def _compute_headline(facts, degraded):
736
793
  # negative signal than a not-run one: amber means "we did not check
737
794
  # everything", red means "something we checked did not pass". Conflating them
738
795
  # would let a failed test render amber, which understates the failure.
796
+ # An ACTIVE (un-waived) HIGH security finding is a hard failure too: shipping a
797
+ # known-bad pattern (a committed private key, a world-open datastore) is not a
798
+ # "gap", it is a verified-NO. Waived findings do not count (accepted with
799
+ # intent). This keeps the receipt honest about security, not just tests.
800
+ sec = facts.get("security") or {}
801
+ sec_high = bool(sec.get("ran") and (sec.get("high_active") or 0) > 0)
739
802
  any_failed = (
740
803
  tests.get("status") == "failed"
741
804
  or build.get("status") == "failed"
742
805
  or any(g.get("status") == "failed"
743
806
  for g in (facts.get("quality_gates") or []))
807
+ or sec_high
744
808
  )
745
809
  if any_failed:
746
810
  return "NOT VERIFIED"
@@ -0,0 +1,652 @@
1
+ #!/usr/bin/env python3
2
+ """Secure-by-default scan engine for Loki Mode (Loop 4, secure-by-default gate).
3
+
4
+ A standalone, high-precision scanner for a SMALL set of known-bad security
5
+ patterns in a generated app. The entire value of this scanner is precision: a
6
+ security gate that cries wolf is worse than none, because false positives turn
7
+ into friction and users learn to ignore it. So every rule here is written to
8
+ fire on the genuinely dangerous pattern AND to stay silent on the safe
9
+ equivalent. When in doubt we do NOT flag (a missed finding is a known, accepted
10
+ tradeoff for v1 trust; we never invent one).
11
+
12
+ The scanner is honest by construction:
13
+ - It never claims an app is "secure". It reports "N known-bad patterns found"
14
+ (or zero). Absence of these specific findings is not a guarantee of safety.
15
+ - Each finding carries an actionable fix: what is wrong, where (file:line),
16
+ and how to fix it.
17
+ - It scans only text files and skips binaries, vendored trees (node_modules,
18
+ .git, vendor, bundled dist of dependencies) and oversized files, so it is
19
+ cheap and deterministic.
20
+
21
+ The five v1 rules (see internal/LOOP4-SECURE-BY-DEFAULT-PLAN.md):
22
+ 1. private-key-committed (HIGH) a PEM private-key block in any text file
23
+ 2. secret-in-client-file (HIGH) a real secret literal in a browser-served file
24
+ 3. world-open-datastore (HIGH) a literal world-open datastore rule
25
+ 4. debug-in-prod (MEDIUM) a debug flag enabled in a production-config file
26
+ 5. cors-wildcard-credentials (MEDIUM) ACAO * together with ACAC true
27
+
28
+ CLI (mirrors dashboard/audit.py / proof-verify.py shim style):
29
+ python3 autonomy/lib/secure-scan.py <target_dir> [--json]
30
+ Prints a JSON result:
31
+ {
32
+ "rules_version": "1.0",
33
+ "findings": [
34
+ {"rule","file","line","severity","message","fix"}, ...
35
+ ],
36
+ "summary": {"total": N, "by_severity": {"HIGH": h, "MEDIUM": m}}
37
+ }
38
+ Exit codes:
39
+ 0 no findings
40
+ 1 one or more findings
41
+ 2 bad input (target dir missing / unreadable)
42
+ """
43
+
44
+ import json
45
+ import os
46
+ import re
47
+ import sys
48
+
49
+ RULES_VERSION = "1.0"
50
+
51
+ # --- scan limits -----------------------------------------------------------
52
+ # Cap per-file size so a giant generated bundle cannot stall the gate. 2 MiB is
53
+ # generous for source/config; real secrets live in small files and large minified
54
+ # vendor bundles are skipped anyway via SKIP_DIRS / extension filtering.
55
+ MAX_FILE_BYTES = 2 * 1024 * 1024
56
+
57
+ # Directories we never descend into: VCS metadata, installed dependencies, and
58
+ # vendored/build trees that are not the user's own authored code. Flagging a
59
+ # dependency's example key is pure noise.
60
+ SKIP_DIRS = {
61
+ ".git", "node_modules", "vendor", "bower_components",
62
+ ".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache",
63
+ ".tox", ".idea", ".vscode", ".gradle", ".terraform",
64
+ "site-packages", ".next", ".nuxt", ".cache",
65
+ }
66
+
67
+ # Extensions that are unambiguously binary; reading them as text is wasted work
68
+ # and the regexes are meaningless against them.
69
+ BINARY_EXT = {
70
+ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svg",
71
+ ".pdf", ".zip", ".gz", ".tar", ".tgz", ".bz2", ".xz", ".7z", ".rar",
72
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
73
+ ".mp3", ".mp4", ".mov", ".avi", ".mkv", ".wav", ".flac",
74
+ ".so", ".dylib", ".dll", ".exe", ".bin", ".o", ".a", ".class",
75
+ ".jar", ".war", ".pyc", ".pyo", ".wasm", ".db", ".sqlite", ".sqlite3",
76
+ }
77
+
78
+ # Web roots: a file under one of these directories is shipped to the browser.
79
+ # We match a path COMPONENT equal to one of these so "src/public/app.js" counts
80
+ # but "publication.js" does not.
81
+ WEB_ROOT_COMPONENTS = {"public", "static", "dist", "www", "build", "assets"}
82
+
83
+ # Client-side source extensions. A .html is always browser-served; .js / .mjs /
84
+ # .jsx / .ts(x) are browser-served only when they ALSO sit under a web root
85
+ # (server-side Node code is also .js and we must not flag a key in a server file
86
+ # here -- rule 1 covers PEM keys anywhere, but rule 2 is specifically about the
87
+ # leak-to-browser surface).
88
+ CLIENT_ALWAYS_EXT = {".html", ".htm"}
89
+ CLIENT_IF_WEBROOT_EXT = {".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx"}
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # rule 1: private key committed
94
+ # ---------------------------------------------------------------------------
95
+ # A PEM private-key header is real key material. The false-positive rate is
96
+ # near zero: the literal "-----BEGIN ... PRIVATE KEY-----" framing does not
97
+ # occur in normal source except in actual keys (or a deliberate test fixture,
98
+ # which is exactly what should be flagged anyway).
99
+ _PEM_PRIVATE_KEY = re.compile(
100
+ r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----"
101
+ )
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # rule 2: secret literal in a browser-served file
106
+ # ---------------------------------------------------------------------------
107
+ # Known-prefix secret shapes. These prefixes are vendor-issued and have a fixed
108
+ # alphabet/length, so matching them is high precision (unlike generic entropy
109
+ # heuristics, which are FP-prone and deliberately NOT used here).
110
+ _SECRET_PATTERNS = [
111
+ # AWS access key id
112
+ ("aws-access-key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
113
+ # OpenAI-style secret key (sk- followed by >=20 url-safe chars)
114
+ ("openai-key", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")),
115
+ # GitHub personal access token (classic): ghp_ + 36 chars
116
+ ("github-pat", re.compile(r"\bghp_[A-Za-z0-9]{36}\b")),
117
+ # Google API key: AIza + 35 url-safe chars
118
+ ("google-api-key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")),
119
+ # Slack bot token: xoxb- + token body
120
+ ("slack-bot-token", re.compile(r"\bxoxb-[0-9A-Za-z-]{10,}\b")),
121
+ ]
122
+
123
+ # Placeholder / example markers. If the matched secret (or its surrounding
124
+ # token) is an obvious placeholder, do NOT flag it. This is the core
125
+ # false-positive guard for rule 2: docs, .env.example files and templates are
126
+ # full of fake keys and flagging them is exactly the cry-wolf failure mode.
127
+ _PLACEHOLDER_TOKENS = (
128
+ "xxxx", "your-key", "your_key", "yourkey", "example", "examplekey",
129
+ "changeme", "change-me", "placeholder", "redacted", "dummy", "sample",
130
+ "test-key", "testkey", "fake", "todo", "replace", "insert",
131
+ )
132
+ _PLACEHOLDER_RE = re.compile(r"<[^>]+>|\{\{[^}]+\}\}|\$\{[^}]+\}")
133
+
134
+
135
+ def _is_placeholder_secret(secret):
136
+ """True if the matched secret literal is an obvious placeholder, not a real
137
+ credential. Conservative: only the well-known fake markers, plus an
138
+ all-same-character body (e.g. AKIAXXXXXXXXXXXXXXXX or all zeros)."""
139
+ low = secret.lower()
140
+ for tok in _PLACEHOLDER_TOKENS:
141
+ if tok in low:
142
+ return True
143
+ # strip the known prefix so we examine just the secret body
144
+ body = secret
145
+ for prefix in ("akia", "sk-", "ghp_", "aiza", "xoxb-"):
146
+ if low.startswith(prefix):
147
+ body = secret[len(prefix):]
148
+ break
149
+ if body:
150
+ # all-identical character (XXXX..., 0000..., AAAA...) => placeholder
151
+ if len(set(body.lower())) <= 1:
152
+ return True
153
+ # mostly x or 0 (a common redaction style) => placeholder
154
+ filler = sum(1 for c in body.lower() if c in "x0")
155
+ if filler >= max(1, int(len(body) * 0.8)):
156
+ return True
157
+ return False
158
+
159
+
160
+ def _markup_wraps_match(line, start, end):
161
+ """True only if a template placeholder (<...>, {{...}}, ${...}) CONTAINS the
162
+ matched secret span [start,end) -- i.e. the value itself is templated, like
163
+ key="${SECRET}" or "<YOUR_KEY>". This must NOT suppress a real secret that
164
+ merely shares a line with an unrelated tag (e.g. an HTML <script>/<meta>
165
+ element with a baked-in key), which the old whole-line check wrongly hid."""
166
+ for mm in _PLACEHOLDER_RE.finditer(line):
167
+ if mm.start() <= start and mm.end() >= end:
168
+ return True
169
+ return False
170
+
171
+
172
+ def _is_browser_served(rel_path):
173
+ """True if a file at rel_path is shipped to the browser.
174
+
175
+ .html is always browser-served. Client-script extensions count only when a
176
+ path component is a known web root, so a server-side foo.js does not trip
177
+ rule 2 (a server file leaking a key to the browser is the thing we are
178
+ detecting; a key in server-only code is a different, non-client concern)."""
179
+ ext = os.path.splitext(rel_path)[1].lower()
180
+ if ext in CLIENT_ALWAYS_EXT:
181
+ return True
182
+ if ext in CLIENT_IF_WEBROOT_EXT:
183
+ parts = {p.lower() for p in rel_path.replace("\\", "/").split("/")}
184
+ return bool(parts & WEB_ROOT_COMPONENTS)
185
+ return False
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # rule 3: world-open datastore (literal forms only)
190
+ # ---------------------------------------------------------------------------
191
+ # Firebase realtime/firestore rules granting unconditional read/write.
192
+ # Matches ".write": true / ".read": true (any whitespace, single or
193
+ # double quotes). This is the literal "anyone can read/write everything" rule.
194
+ # A rule whose value is a string expression (e.g. "auth != null") is NOT
195
+ # matched, because true is the only unconditional-open literal.
196
+ _FIREBASE_OPEN = re.compile(
197
+ r"""['"]\.(?:read|write)['"]\s*:\s*true\b"""
198
+ )
199
+ # S3 / bucket ACL granting public access.
200
+ _S3_PUBLIC_ACL = re.compile(
201
+ r"""['"]?(?:acl|ACL)['"]?\s*[:=]\s*['"](?:public-read|public-read-write)['"]"""
202
+ )
203
+ # AWS canned ACL constant used in CDK/SDK code. MUST be QUALIFIED by an AWS/S3
204
+ # context token so it does not fire on an app-domain identifier (e.g. an
205
+ # ACCESS_LEVELS enum, a Visibility enum, a permissions doc) that merely contains
206
+ # the words PUBLIC_READ. We require either a dotted SDK form
207
+ # (CannedAccessControl.PUBLIC_READ, BucketAccessControl.PUBLIC_READ,
208
+ # s3.X.PUBLIC_READ) or PUBLIC_READ adjacent to an s3/bucket/acl/canned token on
209
+ # the same match. Bare `PUBLIC_READ` (a common permission identifier) is ignored.
210
+ _S3_PUBLIC_ENUM = re.compile(
211
+ r"""(?:
212
+ (?:Canned[A-Za-z]*|BucketAccessControl|s3|S3)\s*\.\s*
213
+ (?:[A-Za-z_]+\s*\.\s*)?PUBLIC_READ(?:_WRITE)?\b
214
+ | \bPUBLIC_READ(?:_WRITE)?\b[^\n]{0,40}?\b(?:acl|ACL|[Bb]ucket|s3|S3|[Cc]anned)\b
215
+ | \b(?:acl|ACL|[Bb]ucket|s3|S3|[Cc]anned)\b[^\n]{0,40}?\bPUBLIC_READ(?:_WRITE)?\b
216
+ )""",
217
+ re.VERBOSE,
218
+ )
219
+ # Lines that are pure comments/docs must never trigger rule 3 (a SECURITY.md or a
220
+ # code comment warning AGAINST PUBLIC_READ is not a vulnerability).
221
+ _COMMENT_OR_DOC_LINE = re.compile(r"""^\s*(?:#|//|\*|<!--|>)""")
222
+ # Supabase: an anon/public policy combined with RLS disabled. We require BOTH
223
+ # the explicit disable AND a public/anon grant in the same file to avoid
224
+ # flagging a legitimately RLS-disabled internal table.
225
+ _SUPABASE_RLS_DISABLE = re.compile(
226
+ r"DISABLE\s+ROW\s+LEVEL\s+SECURITY", re.IGNORECASE
227
+ )
228
+ _SUPABASE_PUBLIC_GRANT = re.compile(
229
+ r"\bTO\s+(?:anon|public)\b", re.IGNORECASE
230
+ )
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # rule 4: debug enabled in production config
235
+ # ---------------------------------------------------------------------------
236
+ # Django settings DEBUG = True
237
+ _DJANGO_DEBUG = re.compile(r"^\s*DEBUG\s*=\s*True\b")
238
+ # Flask app.run(debug=True)
239
+ _FLASK_DEBUG = re.compile(r"\.run\s*\([^)]*\bdebug\s*=\s*True\b")
240
+ # FLASK_DEBUG / DEBUG env set to 1 / true (env-file or compose form)
241
+ _ENV_DEBUG = re.compile(
242
+ r"^\s*(?:FLASK_DEBUG|DJANGO_DEBUG)\s*[:=]\s*['\"]?(?:1|true|True)\b"
243
+ )
244
+
245
+ # A file is treated as production config when its name/path marks it so. dev /
246
+ # test / local files are excluded: flagging DEBUG=True in settings_dev.py is the
247
+ # cry-wolf failure for rule 4.
248
+ _PROD_NAME_RE = re.compile(
249
+ r"(?:^|/)(?:settings\.py|prod[._-]?[^/]*\.py|[^/]*\.production[^/]*|"
250
+ r"settings[._-]?prod[^/]*\.py|production\.py)$",
251
+ re.IGNORECASE,
252
+ )
253
+ _PROD_ENVFILE_RE = re.compile(r"(?:^|/)\.env\.production$", re.IGNORECASE)
254
+ _DEV_MARKER_RE = re.compile(
255
+ r"(?:^|/|[._-])(?:dev|development|test|tests|local|staging|sample|example|"
256
+ r"settings_dev|settings_local|settings_test)(?:[._/-]|$)",
257
+ re.IGNORECASE,
258
+ )
259
+
260
+
261
+ def _is_production_config(rel_path):
262
+ """True if rel_path looks like a production config file (and NOT a dev/test
263
+ one). The dev exclusion takes precedence: an explicitly dev-marked path is
264
+ never production even if it also matches a prod pattern."""
265
+ norm = rel_path.replace("\\", "/")
266
+ base = os.path.basename(norm)
267
+ if _DEV_MARKER_RE.search(norm):
268
+ # settings.py itself is not dev-marked; but settings_dev.py is excluded
269
+ return False
270
+ if _PROD_ENVFILE_RE.search(norm):
271
+ return True
272
+ if _PROD_NAME_RE.search(norm):
273
+ return True
274
+ # bare settings.py (Django default prod settings module)
275
+ if base.lower() == "settings.py":
276
+ return True
277
+ return False
278
+
279
+
280
+ # Rules 3 (world-open datastore) and 5 (CORS) are LITERAL-config rules: the
281
+ # patterns (".write": true, public-read, ACAO * + credentials) are only a
282
+ # vulnerability when they are ACTUAL configuration, never when they appear in
283
+ # prose, a docstring, a comment, or example text. Rather than try to strip every
284
+ # comment/string form out of arbitrary source (fragile, and an attacker can open
285
+ # a fake comment to mask a real finding -- a green-wash), we SCOPE these rules to:
286
+ # (a) recognized config file types/names, OR
287
+ # (b) a line that is itself a recognized config-call form (handled inline).
288
+ # A code file's docstring/comment is therefore never scanned for rules 3/5, and
289
+ # there is no block-tracking to evade. (rules 1/2/4 are unaffected and still scan
290
+ # everything: a private key or client secret is a leak in ANY file.)
291
+ _CONFIG_EXT = {
292
+ ".json", ".yaml", ".yml", ".toml", ".tf", ".tfvars", ".hcl",
293
+ ".conf", ".cfg", ".ini", ".rules", ".properties", ".env", ".sql",
294
+ }
295
+ _CONFIG_NAME_RE = re.compile(
296
+ r"""(?:
297
+ firebase\.json | firestore\.rules | database\.rules\.json
298
+ | storage\.rules | \.firebaserc
299
+ | nginx(?:\.conf)? | httpd\.conf | \.htaccess
300
+ | cors[-_.]? | s3[-_.]?(?:policy|bucket)
301
+ | serverless\.(?:yml|yaml) | vercel\.json | netlify\.toml
302
+ )""",
303
+ re.IGNORECASE | re.VERBOSE,
304
+ )
305
+
306
+
307
+ def _is_config_context_file(rel_path):
308
+ """True if the file is a configuration file (by extension or known name),
309
+ where a literal datastore/CORS rule is real config rather than prose."""
310
+ norm = rel_path.replace("\\", "/")
311
+ base = os.path.basename(norm).lower()
312
+ _, ext = os.path.splitext(base)
313
+ if ext in _CONFIG_EXT:
314
+ return True
315
+ if _CONFIG_NAME_RE.search(base):
316
+ return True
317
+ return False
318
+
319
+
320
+ # Recognized config-CALL forms in code files: these ARE configuration even in a
321
+ # .js/.py/.ts source, so rules 3/5 still apply to a line matching one. Kept tight
322
+ # to avoid matching prose: a real cors() middleware call, an nginx add_header, an
323
+ # AWS SDK ACL assignment, a firebase database().setRules-style call.
324
+ _CONFIG_CALL_RE = re.compile(
325
+ r"""(?:
326
+ \bcors\s*\( | \badd_header\b | BucketAccessControl | CannedAccessControl
327
+ | \bAccessControlAllow # camelCase header constant
328
+ # hyphenated header in a real SETTER context (res.setHeader(...),
329
+ # add_header, headers[...]=). NOT a bare `Header: value` colon form, which
330
+ # also appears in prose/comments in code files; the bare colon form is only
331
+ # trusted inside a real config FILE (handled by _file_is_config), never via
332
+ # this code-line config-call path.
333
+ | (?:setHeader|set_header|add_header|writeHead|headers?\s*\[)[^\n]{0,40}?Access-Control-Allow-(?:Origin|Credentials)
334
+ | setRules\s*\( | \bacl\s*[:=]
335
+ )""",
336
+ re.IGNORECASE | re.VERBOSE,
337
+ )
338
+
339
+
340
+ # ---------------------------------------------------------------------------
341
+ # rule 5: CORS wildcard with credentials
342
+ # ---------------------------------------------------------------------------
343
+ # The dangerous combination is ACAO * AND ACAC true in the same config. A bare
344
+ # ACAO * (without credentials) is a common, intentional public-API setting and
345
+ # is NOT flagged. We require both signals in the same file.
346
+ # Accept the wildcard quoted ('*' / "*") OR bare (the raw nginx/apache header
347
+ # form `add_header Access-Control-Allow-Origin *;` and `... -Origin: *`). Widening
348
+ # to bare * cannot false-positive on its own: rule 5 only fires when ACAC true is
349
+ # ALSO present in the same file (a bare ACAO * without credentials stays allowed).
350
+ _ACAO_WILDCARD = re.compile(
351
+ r"""Access-Control-Allow-Origin['"]?\s*[:=,]?\s*['"]?\*['"]?""",
352
+ re.IGNORECASE,
353
+ )
354
+ # Code/framework forms of a wildcard origin: express/cors `origin: "*"` AND
355
+ # flask-cors `origins="*"` (plural, = separator). Either signals an any-origin
356
+ # CORS policy in code.
357
+ _ACAO_WILDCARD_CODE = re.compile(
358
+ r"""\borigins?\s*[:=]\s*['"]\*['"]""",
359
+ re.IGNORECASE,
360
+ )
361
+ _ACAC_TRUE = re.compile(
362
+ r"""Access-Control-Allow-Credentials['"]?\s*[:=,]?\s*['"]?true['"]?""",
363
+ re.IGNORECASE,
364
+ )
365
+ # Code/framework forms of credentials-enabled: express/cors `credentials: true`
366
+ # AND flask-cors `supports_credentials=True`.
367
+ _ACAC_TRUE_CODE = re.compile(
368
+ r"""\b(?:credentials\s*[:=]\s*true|supports_credentials\s*=\s*true)\b""",
369
+ re.IGNORECASE,
370
+ )
371
+
372
+
373
+ # ---------------------------------------------------------------------------
374
+ # file walking / reading
375
+ # ---------------------------------------------------------------------------
376
+
377
+ def _iter_files(target_dir):
378
+ """Yield (abs_path, rel_path) for each candidate text file under target_dir,
379
+ skipping VCS/dependency/build trees, binary extensions and oversized files."""
380
+ for root, dirs, files in os.walk(target_dir):
381
+ # prune skip dirs in place so os.walk never descends into them
382
+ dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
383
+ for name in files:
384
+ ext = os.path.splitext(name)[1].lower()
385
+ if ext in BINARY_EXT:
386
+ continue
387
+ abs_path = os.path.join(root, name)
388
+ try:
389
+ if os.path.islink(abs_path):
390
+ continue
391
+ size = os.path.getsize(abs_path)
392
+ except OSError:
393
+ continue
394
+ if size > MAX_FILE_BYTES:
395
+ continue
396
+ rel_path = os.path.relpath(abs_path, target_dir)
397
+ yield abs_path, rel_path
398
+
399
+
400
+ def _read_lines(abs_path):
401
+ """Read a file as text lines. Returns a list of (lineno, text) or None if the
402
+ file is not decodable as UTF-8 text (treat as binary, skip)."""
403
+ try:
404
+ with open(abs_path, "r", encoding="utf-8") as f:
405
+ content = f.read()
406
+ except (UnicodeDecodeError, OSError):
407
+ return None
408
+ # A NUL byte is the strongest binary signal that slipped past the extension
409
+ # filter; skip such files.
410
+ if "\x00" in content:
411
+ return None
412
+ return list(enumerate(content.splitlines(), start=1))
413
+
414
+
415
+ # ---------------------------------------------------------------------------
416
+ # per-file scanning
417
+ # ---------------------------------------------------------------------------
418
+
419
+ def _finding(rule, rel_path, line, severity, message, fix):
420
+ return {
421
+ "rule": rule,
422
+ "file": rel_path,
423
+ "line": line,
424
+ "severity": severity,
425
+ "message": message,
426
+ "fix": fix,
427
+ }
428
+
429
+
430
+ def _scan_file(rel_path, lines):
431
+ """Return a list of findings for one file. `lines` is [(lineno, text), ...]."""
432
+ findings = []
433
+ browser_served = _is_browser_served(rel_path)
434
+ prod_config = _is_production_config(rel_path)
435
+
436
+ # rule 3 / rule 5 need whole-file context for the AND conditions.
437
+ saw_rls_disable = None # lineno of a DISABLE ROW LEVEL SECURITY
438
+ saw_public_grant = False
439
+ saw_acao_wildcard = None # lineno
440
+ saw_acac_true = False
441
+
442
+ # Rules 3/5 (literal datastore/CORS config) are scoped to config CONTEXT, so a
443
+ # docstring/comment/prose mention in a code or doc file is never scanned for
444
+ # them (root-level precision; no fragile comment/block stripping to evade).
445
+ _file_is_config = _is_config_context_file(rel_path)
446
+
447
+ for lineno, text in lines:
448
+ # --- rule 1: private key committed (any text file) ---------------
449
+ if _PEM_PRIVATE_KEY.search(text):
450
+ findings.append(_finding(
451
+ "private-key-committed", rel_path, lineno, "HIGH",
452
+ "A PEM private key block is present in this file.",
453
+ "Remove the private key from the repo, rotate the key "
454
+ "immediately (assume it is compromised), and load it at runtime "
455
+ "from a secret manager or an untracked file referenced via "
456
+ "environment variable.",
457
+ ))
458
+
459
+ # --- rule 2: secret literal in a browser-served file -------------
460
+ if browser_served:
461
+ for kind, pat in _SECRET_PATTERNS:
462
+ for m in pat.finditer(text):
463
+ secret = m.group(0)
464
+ if _is_placeholder_secret(secret):
465
+ continue
466
+ # Suppress only when template markup WRAPS the matched secret
467
+ # (a templated/example value like key="<YOUR_KEY>"), NOT when
468
+ # any unrelated tag is elsewhere on the line. The old
469
+ # whole-line check let a real secret inside an HTML line with
470
+ # any tag (e.g. <script>var k="AKIA...";</script>) slip
471
+ # through silently -- the common HTML inline-secret leak.
472
+ if _markup_wraps_match(text, m.start(), m.end()):
473
+ continue
474
+ findings.append(_finding(
475
+ "secret-in-client-file", rel_path, lineno, "HIGH",
476
+ "A live %s appears in a file served to the browser; "
477
+ "anyone who loads the page can read it." % kind,
478
+ "Remove the secret from client code, rotate it, and move "
479
+ "the call that needs it to a server-side endpoint or a "
480
+ "build-time secret that is never shipped to the client.",
481
+ ))
482
+
483
+ # Rules 3/5 fire ONLY in a real config context: a config file, OR a line
484
+ # that is itself a recognized config-call form. This makes prose,
485
+ # docstrings, comments, and example text in code/doc files invisible to
486
+ # the literal rules at the ROOT (no comment/string stripping to get wrong,
487
+ # no block-tracking to evade), while a real datastore/CORS misconfig in a
488
+ # .json/.yaml/.conf or a cors()/add_header/ACL line still fires.
489
+ # A config-call form makes a CODE line config-context -- but NOT if the
490
+ # line is a comment (a comment "call cors() with origin * carefully" is
491
+ # prose, not configuration; firing on it is cry-wolf). Config FILES are
492
+ # config-context regardless (their lines are data, not prose). A line in a
493
+ # config file that is a comment is also skipped via _COMMENT_OR_DOC_LINE.
494
+ _rel_lower = rel_path.lower()
495
+ _is_doc_file = _rel_lower.endswith((".md", ".mdx", ".rst", ".txt", ".adoc"))
496
+ _line_is_comment = bool(_COMMENT_OR_DOC_LINE.match(text))
497
+ # A config-call form makes a line config-context only in a non-doc,
498
+ # non-comment line (a doc/comment mentioning a header name is prose).
499
+ _config_call_line = (
500
+ not _is_doc_file and not _line_is_comment
501
+ and bool(_CONFIG_CALL_RE.search(text))
502
+ )
503
+ _config_ctx = (
504
+ (_file_is_config and not _is_doc_file and not _line_is_comment)
505
+ or _config_call_line
506
+ )
507
+ _rule3_skip = not _config_ctx
508
+
509
+ # --- rule 3a: Firebase world-open rule ---------------------------
510
+ if not _rule3_skip and _FIREBASE_OPEN.search(text):
511
+ findings.append(_finding(
512
+ "world-open-datastore", rel_path, lineno, "HIGH",
513
+ "A Firebase rule grants unconditional public read/write "
514
+ "(\".read\"/\".write\": true).",
515
+ "Replace the literal true with an auth condition, for example "
516
+ "\"auth != null\" or a per-document ownership check, so only "
517
+ "authorized users can access the data.",
518
+ ))
519
+ # --- rule 3b: S3 / bucket public ACL -----------------------------
520
+ if not _rule3_skip and (_S3_PUBLIC_ACL.search(text) or _S3_PUBLIC_ENUM.search(text)):
521
+ findings.append(_finding(
522
+ "world-open-datastore", rel_path, lineno, "HIGH",
523
+ "A storage bucket is configured with a public-read(-write) ACL.",
524
+ "Remove the public ACL, enable block-public-access on the "
525
+ "bucket, and serve objects through signed URLs or an "
526
+ "authenticated endpoint instead.",
527
+ ))
528
+ # --- rule 3c: Supabase RLS disabled (whole-file AND) -------------
529
+ # Also config-context gated (skip comments/prose/doc files), consistent
530
+ # with rules 3a/3b/5: an inline comment naming "DISABLE ROW LEVEL
531
+ # SECURITY" is prose, not a live migration statement.
532
+ if not _rule3_skip:
533
+ if saw_rls_disable is None and _SUPABASE_RLS_DISABLE.search(text):
534
+ saw_rls_disable = lineno
535
+ if _SUPABASE_PUBLIC_GRANT.search(text):
536
+ saw_public_grant = True
537
+
538
+ # --- rule 4: debug in production config --------------------------
539
+ if prod_config:
540
+ if (_DJANGO_DEBUG.search(text) or _FLASK_DEBUG.search(text)
541
+ or _ENV_DEBUG.search(text)):
542
+ findings.append(_finding(
543
+ "debug-in-prod", rel_path, lineno, "MEDIUM",
544
+ "Debug mode is enabled in a production configuration file; "
545
+ "debug mode leaks stack traces, settings and secrets to "
546
+ "users on error.",
547
+ "Set debug to False/0 for production (drive it from an "
548
+ "environment variable that defaults to off), and keep "
549
+ "debug-on only in dev/local config.",
550
+ ))
551
+
552
+ # --- rule 5: CORS wildcard + credentials (whole-file AND) --------
553
+ # Skip prose/doc lines (_rule3_skip = comment-only line or .md/.rst/.txt
554
+ # doc file): a README or comment WARNING about ACAO * + credentials is not
555
+ # a misconfiguration. Same prose-vs-config principle as rule 3.
556
+ if not _rule3_skip:
557
+ if saw_acao_wildcard is None and (
558
+ _ACAO_WILDCARD.search(text) or _ACAO_WILDCARD_CODE.search(text)):
559
+ saw_acao_wildcard = lineno
560
+ if _ACAC_TRUE.search(text) or _ACAC_TRUE_CODE.search(text):
561
+ saw_acac_true = True
562
+
563
+ # whole-file rule 3c verdict
564
+ if saw_rls_disable is not None and saw_public_grant:
565
+ findings.append(_finding(
566
+ "world-open-datastore", rel_path, saw_rls_disable, "HIGH",
567
+ "Row Level Security is disabled while a public/anon grant exists, "
568
+ "leaving the table world-accessible.",
569
+ "Re-enable Row Level Security (ENABLE ROW LEVEL SECURITY) and write "
570
+ "explicit policies that scope access to authenticated/authorized "
571
+ "users instead of granting to anon/public.",
572
+ ))
573
+
574
+ # whole-file rule 5 verdict (the dangerous combo, not a bare wildcard)
575
+ if saw_acao_wildcard is not None and saw_acac_true:
576
+ findings.append(_finding(
577
+ "cors-wildcard-credentials", rel_path, saw_acao_wildcard, "MEDIUM",
578
+ "CORS allows any origin (*) AND allows credentials; this exposes "
579
+ "authenticated responses to every website.",
580
+ "Do not combine a wildcard origin with credentials. Echo back a "
581
+ "specific allowlist of trusted origins when credentials are "
582
+ "required, or drop Allow-Credentials if the endpoint is truly "
583
+ "public.",
584
+ ))
585
+
586
+ return findings
587
+
588
+
589
+ # ---------------------------------------------------------------------------
590
+ # scan driver
591
+ # ---------------------------------------------------------------------------
592
+
593
+ def scan(target_dir):
594
+ """Scan target_dir and return the result dict.
595
+
596
+ Raises ValueError if target_dir is not a readable directory (the CLI maps
597
+ that to exit code 2)."""
598
+ if not os.path.isdir(target_dir):
599
+ raise ValueError("not a directory: %s" % target_dir)
600
+
601
+ findings = []
602
+ for abs_path, rel_path in _iter_files(target_dir):
603
+ lines = _read_lines(abs_path)
604
+ if lines is None:
605
+ continue
606
+ findings.extend(_scan_file(rel_path, lines))
607
+
608
+ # deterministic ordering: by file, then line, then rule
609
+ findings.sort(key=lambda f: (f["file"], f["line"], f["rule"]))
610
+
611
+ by_severity = {}
612
+ for f in findings:
613
+ by_severity[f["severity"]] = by_severity.get(f["severity"], 0) + 1
614
+
615
+ return {
616
+ "rules_version": RULES_VERSION,
617
+ "findings": findings,
618
+ "summary": {
619
+ "total": len(findings),
620
+ "by_severity": by_severity,
621
+ },
622
+ }
623
+
624
+
625
+ # ---------------------------------------------------------------------------
626
+ # CLI shim (mirrors dashboard/audit.py / proof-verify.py style)
627
+ # ---------------------------------------------------------------------------
628
+
629
+ def _cli(argv=None):
630
+ argv = list(sys.argv[1:] if argv is None else argv)
631
+ # --json is accepted for symmetry with the other shims; output is always
632
+ # JSON, so the flag is a no-op kept for a stable, predictable interface.
633
+ args = [a for a in argv if a != "--json"]
634
+ if not args or argv[0] in ("-h", "--help"):
635
+ print(json.dumps(
636
+ {"error": "usage: secure-scan.py <target_dir> [--json]"}))
637
+ return 2
638
+ target_dir = args[0]
639
+ try:
640
+ result = scan(target_dir)
641
+ except ValueError as exc:
642
+ print(json.dumps({"error": str(exc)}))
643
+ return 2
644
+ except Exception as exc: # defensive: never a traceback-as-UX
645
+ print(json.dumps({"error": "scan failed: %s" % exc}))
646
+ return 2
647
+ print(json.dumps(result, indent=2))
648
+ return 1 if result["summary"]["total"] > 0 else 0
649
+
650
+
651
+ if __name__ == "__main__":
652
+ sys.exit(_cli())
package/autonomy/loki CHANGED
@@ -16500,6 +16500,10 @@ main() {
16500
16500
  # Receipt surface): same subcommands (list/show/verify/open/share).
16501
16501
  cmd_proof "$@"
16502
16502
  ;;
16503
+ secure)
16504
+ # Secure-by-default gate surface: inspect findings + manage waivers.
16505
+ cmd_secure "$@"
16506
+ ;;
16503
16507
  bench)
16504
16508
  cmd_bench "$@"
16505
16509
  ;;
@@ -30434,6 +30438,110 @@ cmd_bench() {
30434
30438
  bash "$bench_sh" "$@"
30435
30439
  }
30436
30440
 
30441
+ # loki secure - the secure-by-default gate surface (v7.87.0).
30442
+ # Subcommands: list (show findings) | waive <rule> <file> [reason] | unwaive.
30443
+ # Waivers are written to .loki/quality/security-waivers.json, which the gate
30444
+ # (run.sh run_secure_scan) and the Evidence Receipt both READ + honor. The gate
30445
+ # is advisory by default; LOKI_SECURE_GATE=block makes un-waived HIGH findings
30446
+ # block. Honest: a waiver is RECORDED in the receipt (accepted with intent), never
30447
+ # silently hides a finding.
30448
+ cmd_secure() {
30449
+ local loki_dir="${LOKI_DIR:-.loki}"
30450
+ local quality_dir="${loki_dir}/quality"
30451
+ local findings_file="${quality_dir}/security-findings.json"
30452
+ local waivers_file="${quality_dir}/security-waivers.json"
30453
+ local sub="${1:-}"
30454
+ [ $# -gt 0 ] && shift
30455
+ case "$sub" in
30456
+ ""|--help|-h|help)
30457
+ echo -e "${BOLD}loki secure${NC} - secure-by-default gate: findings + waivers"
30458
+ echo ""
30459
+ echo "Usage: loki secure <subcommand> [args]"
30460
+ echo ""
30461
+ echo "Subcommands:"
30462
+ echo " list Show security findings from the last scan"
30463
+ echo " waive <rule> <file> [reason] Waive a finding (accepted with intent)"
30464
+ echo " unwaive <rule> <file> Remove a waiver"
30465
+ echo ""
30466
+ echo "The gate is advisory by default; set LOKI_SECURE_GATE=block to make"
30467
+ echo "un-waived HIGH findings block completion. Waivers are recorded in the"
30468
+ echo "Evidence Receipt (loki proof show) -- they are never hidden."
30469
+ [ "$sub" = "" ] && exit 1
30470
+ exit 0
30471
+ ;;
30472
+ list)
30473
+ if [ ! -f "$findings_file" ]; then
30474
+ echo -e "${YELLOW}No security scan results yet.${NC} Run 'loki start' (the gate runs in the review phase)."
30475
+ exit 0
30476
+ fi
30477
+ if command -v jq &>/dev/null; then
30478
+ jq '.findings' "$findings_file" 2>/dev/null || cat "$findings_file"
30479
+ else
30480
+ LOKI_SEC_F="$findings_file" python3 -c "import json,os; d=json.load(open(os.environ['LOKI_SEC_F'])); [print('%s [%s] %s%s -- %s' % (f.get('severity','?'), f.get('rule','?'), f.get('file','?'), (':'+str(f['line'])) if f.get('line') else '', f.get('fix',''))) for f in d.get('findings',[])] or print('No findings.')"
30481
+ fi
30482
+ exit 0
30483
+ ;;
30484
+ waive)
30485
+ local rule="${1:-}" file="${2:-}" reason="${3:-waived via loki secure}"
30486
+ if [ -z "$rule" ] || [ -z "$file" ]; then
30487
+ echo -e "${RED}Usage: loki secure waive <rule> <file> [reason]${NC}" >&2
30488
+ exit 2
30489
+ fi
30490
+ mkdir -p "$quality_dir"
30491
+ LOKI_SEC_W="$waivers_file" LOKI_SEC_RULE="$rule" LOKI_SEC_FILE="$file" LOKI_SEC_REASON="$reason" python3 - <<'PYW'
30492
+ import json, os
30493
+ p = os.environ["LOKI_SEC_W"]
30494
+ try:
30495
+ with open(p) as f: data = json.load(f)
30496
+ if not isinstance(data, dict): data = {}
30497
+ except Exception:
30498
+ data = {}
30499
+ waivers = data.get("waivers")
30500
+ if not isinstance(waivers, list): waivers = []
30501
+ rule, fl, reason = os.environ["LOKI_SEC_RULE"], os.environ["LOKI_SEC_FILE"], os.environ["LOKI_SEC_REASON"]
30502
+ if not any(w.get("rule") == rule and w.get("file") == fl for w in waivers if isinstance(w, dict)):
30503
+ waivers.append({"rule": rule, "file": fl, "reason": reason})
30504
+ data["waivers"] = waivers
30505
+ tmp = p + ".tmp"
30506
+ with open(tmp, "w") as f: json.dump(data, f, indent=2)
30507
+ os.replace(tmp, p)
30508
+ print("Waived %s on %s (recorded in the Evidence Receipt)." % (rule, fl))
30509
+ PYW
30510
+ exit $?
30511
+ ;;
30512
+ unwaive)
30513
+ local rule="${1:-}" file="${2:-}"
30514
+ if [ -z "$rule" ] || [ -z "$file" ]; then
30515
+ echo -e "${RED}Usage: loki secure unwaive <rule> <file>${NC}" >&2
30516
+ exit 2
30517
+ fi
30518
+ [ -f "$waivers_file" ] || { echo "No waivers to remove."; exit 0; }
30519
+ LOKI_SEC_W="$waivers_file" LOKI_SEC_RULE="$rule" LOKI_SEC_FILE="$file" python3 - <<'PYU'
30520
+ import json, os
30521
+ p = os.environ["LOKI_SEC_W"]
30522
+ try:
30523
+ with open(p) as f: data = json.load(f)
30524
+ except Exception:
30525
+ data = {}
30526
+ waivers = [w for w in (data.get("waivers") or [])
30527
+ if not (isinstance(w, dict) and w.get("rule") == os.environ["LOKI_SEC_RULE"]
30528
+ and w.get("file") == os.environ["LOKI_SEC_FILE"])]
30529
+ data["waivers"] = waivers
30530
+ tmp = p + ".tmp"
30531
+ with open(tmp, "w") as f: json.dump(data, f, indent=2)
30532
+ os.replace(tmp, p)
30533
+ print("Removed waiver for %s on %s." % (os.environ["LOKI_SEC_RULE"], os.environ["LOKI_SEC_FILE"]))
30534
+ PYU
30535
+ exit $?
30536
+ ;;
30537
+ *)
30538
+ echo -e "${RED}Unknown subcommand: secure $sub${NC}" >&2
30539
+ echo "Try: loki secure --help"
30540
+ exit 2
30541
+ ;;
30542
+ esac
30543
+ }
30544
+
30437
30545
  # loki proof - inspect and share proof-of-run artifacts (.loki/proofs/<id>/).
30438
30546
  # Subcommands: list | show <id> | open <id> | share <id>.
30439
30547
  # The proof.json schema is frozen (R1 spec). Reads are tolerant of missing
package/autonomy/run.sh CHANGED
@@ -7309,6 +7309,157 @@ SAFEOF
7309
7309
  fi
7310
7310
  }
7311
7311
 
7312
+ # ============================================================================
7313
+ # Secure-by-default scan (v7.87.0 - Loop 4)
7314
+ # Runs the high-precision rule engine (autonomy/lib/secure-scan.py) over the
7315
+ # generated app and reports known-bad security patterns.
7316
+ #
7317
+ # ADVISORY BY DEFAULT (mirrors the ktlint/detekt advisory linters above):
7318
+ # findings are reported via log_warn + the receipt json, but do NOT block. This
7319
+ # guarantees no existing build starts blocking on this new gate.
7320
+ #
7321
+ # OPT-IN BLOCK: only when LOKI_SECURE_GATE=block do un-waived HIGH findings
7322
+ # cause a blocking gate failure (return 1, same mechanism the other gates use).
7323
+ #
7324
+ # Waivers: .loki/quality/security-waivers.json ({"waivers":[{rule,file},...]})
7325
+ # is READ here and honored (matched findings recorded as waived, never counted
7326
+ # active). The waiver-write surface is a separate slice.
7327
+ #
7328
+ # Honest degrade: if python3 or secure-scan.py is absent, pass through cleanly
7329
+ # (no crash, no block), exactly like the optional linters.
7330
+ # ============================================================================
7331
+ run_secure_scan() {
7332
+ local loki_dir="${TARGET_DIR:-.}/.loki"
7333
+ local quality_dir="$loki_dir/quality"
7334
+ mkdir -p "$quality_dir"
7335
+
7336
+ local out_file="$quality_dir/security-findings.json"
7337
+ local waivers_file="$quality_dir/security-waivers.json"
7338
+ local scanner="$SCRIPT_DIR/lib/secure-scan.py"
7339
+
7340
+ # Honest pass-through if the engine or python3 is unavailable. Still write a
7341
+ # valid (empty) receipt so downstream consumers never read malformed JSON.
7342
+ if ! command -v python3 >/dev/null 2>&1 || [ ! -f "$scanner" ]; then
7343
+ cat > "$out_file" << 'SECEMPTY'
7344
+ {"rules_version":null,"findings":[],"summary":{"total":0,"by_severity":{}},"skipped":"scanner-unavailable"}
7345
+ SECEMPTY
7346
+ log_info "Security scan: secure-scan.py or python3 not available, skipping (pass-through)"
7347
+ return 0
7348
+ fi
7349
+
7350
+ # Run the scanner. exit 0 = no findings, 1 = findings, 2 = bad input.
7351
+ local raw rc=0
7352
+ raw=$(python3 "$scanner" "${TARGET_DIR:-.}" --json 2>/dev/null) || rc=$?
7353
+ if [ "$rc" -eq 2 ] || [ -z "$raw" ]; then
7354
+ cat > "$out_file" << 'SECEMPTY'
7355
+ {"rules_version":null,"findings":[],"summary":{"total":0,"by_severity":{}},"skipped":"scanner-error"}
7356
+ SECEMPTY
7357
+ log_info "Security scan: scanner returned no parseable output, skipping (pass-through)"
7358
+ return 0
7359
+ fi
7360
+
7361
+ # Apply waivers, build the receipt json, and emit a machine-readable verdict.
7362
+ # All policy lives in this one python pass so the bash stays bash-3.2 safe.
7363
+ # It prints a final line: ACTIVE_HIGH=<n>\tACTIVE_TOTAL=<n>\tWAIVED=<n>
7364
+ # and writes the enriched receipt (findings carry a "waived" bool).
7365
+ local verdict
7366
+ verdict=$(_SEC_RAW="$raw" _SEC_WAIVERS="$waivers_file" _SEC_OUT="$out_file" python3 -c '
7367
+ import json, os, sys
7368
+ raw = os.environ.get("_SEC_RAW", "")
7369
+ waivers_file = os.environ.get("_SEC_WAIVERS", "")
7370
+ out_file = os.environ.get("_SEC_OUT", "")
7371
+
7372
+ try:
7373
+ data = json.loads(raw)
7374
+ except Exception:
7375
+ data = {"rules_version": None, "findings": [], "summary": {"total": 0, "by_severity": {}}}
7376
+
7377
+ # Load waivers: {"waivers":[{"rule":..,"file":..}, ...]}. Match on rule+file.
7378
+ waived_set = set()
7379
+ try:
7380
+ with open(waivers_file) as f:
7381
+ wdoc = json.load(f)
7382
+ for w in wdoc.get("waivers", []):
7383
+ r = w.get("rule"); fl = w.get("file")
7384
+ if r is not None and fl is not None:
7385
+ waived_set.add((r, fl))
7386
+ except (OSError, json.JSONDecodeError, AttributeError):
7387
+ pass
7388
+
7389
+ findings = data.get("findings", []) or []
7390
+ active_high = 0
7391
+ active_total = 0
7392
+ waived_count = 0
7393
+ for fnd in findings:
7394
+ key = (fnd.get("rule"), fnd.get("file"))
7395
+ is_waived = key in waived_set
7396
+ fnd["waived"] = is_waived
7397
+ if is_waived:
7398
+ waived_count += 1
7399
+ else:
7400
+ active_total += 1
7401
+ if str(fnd.get("severity", "")).upper() == "HIGH":
7402
+ active_high += 1
7403
+
7404
+ data["waived"] = waived_count
7405
+ data["active"] = active_total
7406
+ try:
7407
+ with open(out_file, "w") as f:
7408
+ json.dump(data, f, indent=2)
7409
+ except OSError:
7410
+ pass
7411
+
7412
+ sys.stdout.write("ACTIVE_HIGH=%d\tACTIVE_TOTAL=%d\tWAIVED=%d" % (active_high, active_total, waived_count))
7413
+ ' 2>/dev/null) || verdict=""
7414
+
7415
+ if [ -z "$verdict" ]; then
7416
+ # python policy pass failed unexpectedly; preserve the raw scan as the
7417
+ # receipt so nothing is lost, and pass through (never crash the gate).
7418
+ printf '%s\n' "$raw" > "$out_file" 2>/dev/null || true
7419
+ log_info "Security scan: result recorded (policy pass unavailable, advisory)"
7420
+ return 0
7421
+ fi
7422
+
7423
+ local active_high active_total waived
7424
+ active_high=$(printf '%s' "$verdict" | sed -n 's/.*ACTIVE_HIGH=\([0-9]*\).*/\1/p')
7425
+ active_total=$(printf '%s' "$verdict" | sed -n 's/.*ACTIVE_TOTAL=\([0-9]*\).*/\1/p')
7426
+ waived=$(printf '%s' "$verdict" | sed -n 's/.*WAIVED=\([0-9]*\).*/\1/p')
7427
+ active_high=${active_high:-0}
7428
+ active_total=${active_total:-0}
7429
+ waived=${waived:-0}
7430
+
7431
+ if [ "$active_total" -eq 0 ]; then
7432
+ log_info "Security scan: no active findings (waived: $waived)"
7433
+ return 0
7434
+ fi
7435
+
7436
+ # Actionable advisory summary: rule + file + fix, from the receipt json.
7437
+ log_warn "Security scan: $active_total active finding(s) (HIGH: $active_high, waived: $waived)"
7438
+ _SEC_OUT="$out_file" python3 -c '
7439
+ import json, os
7440
+ try:
7441
+ with open(os.environ["_SEC_OUT"]) as f:
7442
+ data = json.load(f)
7443
+ except Exception:
7444
+ data = {"findings": []}
7445
+ for fnd in data.get("findings", []):
7446
+ if fnd.get("waived"):
7447
+ continue
7448
+ print(" [%s] %s %s:%s -- %s | fix: %s" % (
7449
+ fnd.get("severity", "?"), fnd.get("rule", "?"),
7450
+ fnd.get("file", "?"), fnd.get("line", "?"),
7451
+ fnd.get("message", ""), fnd.get("fix", "")))
7452
+ ' 2>/dev/null | while IFS= read -r line; do log_warn "$line"; done
7453
+
7454
+ # OPT-IN BLOCK: only un-waived HIGH findings block, and only when explicitly
7455
+ # enabled. Advisory default returns 0 (never surprise-blocks).
7456
+ if [ "${LOKI_SECURE_GATE:-advisory}" = "block" ] && [ "$active_high" -gt 0 ]; then
7457
+ log_warn "Security gate: $active_high un-waived HIGH finding(s) - BLOCK (LOKI_SECURE_GATE=block)"
7458
+ return 1
7459
+ fi
7460
+ return 0
7461
+ }
7462
+
7312
7463
  #===============================================================================
7313
7464
  # Gate Failure Tracking (v6.10.0)
7314
7465
  #===============================================================================
@@ -15262,6 +15413,18 @@ if __name__ == "__main__":
15262
15413
  log_warn "Static analysis FAILED ($sa_count consecutive) - findings injected into next iteration"
15263
15414
  fi
15264
15415
  fi
15416
+ # Secure-by-default scan (v7.87.0). Advisory by default (never
15417
+ # blocks); records .loki/quality/security-findings.json each
15418
+ # iteration. Blocks only on un-waived HIGH when LOKI_SECURE_GATE=block.
15419
+ log_info "Quality gate: security scan (advisory)..."
15420
+ if run_secure_scan; then
15421
+ clear_gate_failure "security_scan"
15422
+ else
15423
+ local sec_count
15424
+ sec_count=$(track_gate_failure "security_scan")
15425
+ gate_failures="${gate_failures}security_scan,"
15426
+ log_warn "Security gate BLOCKED ($sec_count consecutive) - un-waived HIGH findings (LOKI_SECURE_GATE=block)"
15427
+ fi
15265
15428
  # BUG-ST-002: Check pause signal between quality gates
15266
15429
  if [ -f "${TARGET_DIR:-.}/.loki/PAUSE" ] || [ -f "${TARGET_DIR:-.}/.loki/STOP" ]; then
15267
15430
  log_warn "Pause/stop signal detected between quality gates - deferring remaining gates"
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.86.0"
10
+ __version__ = "7.87.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v7.86.0
5
+ **Version:** v7.87.0
6
6
 
7
7
  ---
8
8
 
@@ -395,7 +395,7 @@ provider works inside the container. Provide auth with your Anthropic API key:
395
395
  # Run Loki Mode in Docker (Claude provider, API-key auth)
396
396
  docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
397
397
  -v $(pwd):/workspace -w /workspace \
398
- asklokesh/loki-mode:7.86.0 start ./my-spec.md
398
+ asklokesh/loki-mode:7.87.0 start ./my-spec.md
399
399
  ```
400
400
 
401
401
  ##### docker compose + .env (no host install)
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var QQ=Object.defineProperty;var ZQ=($)=>$;function zQ($,Q){this[$]=ZQ.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)QQ($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:zQ.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var q$=import.meta.require;var h1={};b(h1,{lokiDir:()=>P,homeLokiDir:()=>i$,findRepoRootForVersion:()=>t$,REPO_ROOT:()=>g});import{resolve as a,dirname as r$}from"path";import{fileURLToPath as XQ}from"url";import{existsSync as R$}from"fs";import{homedir as KQ}from"os";function qQ(){let $=b1;for(let Q=0;Q<6;Q++){if(R$(a($,"VERSION"))&&R$(a($,"autonomy/run.sh")))return $;let Z=r$($);if(Z===$)break;$=Z}return a(b1,"..","..","..")}function t$($){let Q=$;for(let Z=0;Z<6;Z++){if(R$(a(Q,"VERSION"))&&R$(a(Q,"autonomy/run.sh")))return Q;let z=r$(Q);if(z===Q)break;Q=z}return a($,"..","..","..")}function P(){return process.env.LOKI_DIR??a(process.cwd(),".loki")}function i$(){return a(KQ(),".loki")}var b1,g;var C=L(()=>{b1=r$(XQ(import.meta.url));g=qQ()});import{readFileSync as VQ}from"fs";import{resolve as JQ,dirname as UQ}from"path";import{fileURLToPath as WQ}from"url";function E$(){if(Q$!==null)return Q$;let $="7.86.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=UQ(WQ(import.meta.url)),Z=t$(Q);Q$=VQ(JQ(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var e$=L(()=>{C()});var g1={};b(g1,{runOrThrow:()=>HQ,run:()=>F,readStreamCapped:()=>m1,commandVersion:()=>BQ,commandExists:()=>f,ShellError:()=>$1,MAX_STDOUT_BYTES:()=>v1});async function m1($,Q=v1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:U}=await Z.read();if(K)break;if(!U)continue;if(q+=U.byteLength,q>Q){let J=U.byteLength-(q-Q);X+=z.decode(U.subarray(0,J),{stream:!0});break}X+=z.decode(U,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function F($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,U]=await Promise.all([m1(Z.stdout),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:U}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function HQ($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new $1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=GQ($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function GQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function BQ($,Q="--version"){if(!await f($))return null;let z=await F([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var v1=16777216,$1;var d=L(()=>{$1=class $1 extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function s($){return YQ?"":$}var YQ,O,S,_,_Z,I,k,h,V;var c=L(()=>{YQ=(process.env.NO_COLOR??"").length>0;O=s("\x1B[0;31m"),S=s("\x1B[0;32m"),_=s("\x1B[1;33m"),_Z=s("\x1B[0;34m"),I=s("\x1B[0;36m"),k=s("\x1B[1m"),h=s("\x1B[2m"),V=s("\x1B[0m")});import{existsSync as jQ}from"fs";async function Z$(){if(Y$!==void 0)return Y$;let $="/opt/homebrew/bin/python3.12";if(jQ($))return Y$=$,$;let Q=await f("python3.12");if(Q)return Y$=Q,Q;let Z=await f("python3");return Y$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return F([Z,"-c",$],Q)}var Y$;var V$=L(()=>{d()});var X0={};b(X0,{runStatus:()=>oQ});import{existsSync as y,readFileSync as U$,readdirSync as r1,statSync as t1}from"fs";import{resolve as D,basename as vQ}from"path";import{homedir as mQ}from"os";function i1($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function e1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*N$/Q);if(X>N$)X=N$;let q=N$-X,K=S;if(z>=80)K=O;else if(z>=50)K=_;let U="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=i1($),W=i1(Q);return` ${k}${Z}${V} ${K}[${U}]${V} ${z}% (${J} / ${W})`}async function fQ(){if(await f("jq"))return!0;return process.stdout.write(`${O}Error: jq is required but not installed.${V}
2
+ var QQ=Object.defineProperty;var ZQ=($)=>$;function zQ($,Q){this[$]=ZQ.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)QQ($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:zQ.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var q$=import.meta.require;var h1={};b(h1,{lokiDir:()=>P,homeLokiDir:()=>i$,findRepoRootForVersion:()=>t$,REPO_ROOT:()=>g});import{resolve as a,dirname as r$}from"path";import{fileURLToPath as XQ}from"url";import{existsSync as R$}from"fs";import{homedir as KQ}from"os";function qQ(){let $=b1;for(let Q=0;Q<6;Q++){if(R$(a($,"VERSION"))&&R$(a($,"autonomy/run.sh")))return $;let Z=r$($);if(Z===$)break;$=Z}return a(b1,"..","..","..")}function t$($){let Q=$;for(let Z=0;Z<6;Z++){if(R$(a(Q,"VERSION"))&&R$(a(Q,"autonomy/run.sh")))return Q;let z=r$(Q);if(z===Q)break;Q=z}return a($,"..","..","..")}function P(){return process.env.LOKI_DIR??a(process.cwd(),".loki")}function i$(){return a(KQ(),".loki")}var b1,g;var C=L(()=>{b1=r$(XQ(import.meta.url));g=qQ()});import{readFileSync as VQ}from"fs";import{resolve as JQ,dirname as UQ}from"path";import{fileURLToPath as WQ}from"url";function E$(){if(Q$!==null)return Q$;let $="7.87.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=UQ(WQ(import.meta.url)),Z=t$(Q);Q$=VQ(JQ(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var e$=L(()=>{C()});var g1={};b(g1,{runOrThrow:()=>HQ,run:()=>F,readStreamCapped:()=>m1,commandVersion:()=>BQ,commandExists:()=>f,ShellError:()=>$1,MAX_STDOUT_BYTES:()=>v1});async function m1($,Q=v1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:U}=await Z.read();if(K)break;if(!U)continue;if(q+=U.byteLength,q>Q){let J=U.byteLength-(q-Q);X+=z.decode(U.subarray(0,J),{stream:!0});break}X+=z.decode(U,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function F($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,U]=await Promise.all([m1(Z.stdout),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:U}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function HQ($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new $1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=GQ($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function GQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function BQ($,Q="--version"){if(!await f($))return null;let z=await F([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var v1=16777216,$1;var d=L(()=>{$1=class $1 extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function s($){return YQ?"":$}var YQ,O,S,_,_Z,I,k,h,V;var c=L(()=>{YQ=(process.env.NO_COLOR??"").length>0;O=s("\x1B[0;31m"),S=s("\x1B[0;32m"),_=s("\x1B[1;33m"),_Z=s("\x1B[0;34m"),I=s("\x1B[0;36m"),k=s("\x1B[1m"),h=s("\x1B[2m"),V=s("\x1B[0m")});import{existsSync as jQ}from"fs";async function Z$(){if(Y$!==void 0)return Y$;let $="/opt/homebrew/bin/python3.12";if(jQ($))return Y$=$,$;let Q=await f("python3.12");if(Q)return Y$=Q,Q;let Z=await f("python3");return Y$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return F([Z,"-c",$],Q)}var Y$;var V$=L(()=>{d()});var X0={};b(X0,{runStatus:()=>oQ});import{existsSync as y,readFileSync as U$,readdirSync as r1,statSync as t1}from"fs";import{resolve as D,basename as vQ}from"path";import{homedir as mQ}from"os";function i1($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function e1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*N$/Q);if(X>N$)X=N$;let q=N$-X,K=S;if(z>=80)K=O;else if(z>=50)K=_;let U="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=i1($),W=i1(Q);return` ${k}${Z}${V} ${K}[${U}]${V} ${z}% (${J} / ${W})`}async function fQ(){if(await f("jq"))return!0;return process.stdout.write(`${O}Error: jq is required but not installed.${V}
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)
@@ -796,4 +796,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
796
796
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
797
797
  `),process.stderr.write($Q),2}}s1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var qZ=await KZ(Bun.argv.slice(2));process.exit(qZ);
798
798
 
799
- //# debugId=5E11AD9E3DCEB72F64756E2164756E21
799
+ //# debugId=7982E696DB64940A64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.86.0'
60
+ __version__ = '7.87.0'
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": "7.86.0",
4
+ "version": "7.87.0",
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": "7.86.0",
5
+ "version": "7.87.0",
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",