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 +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/proof-generator.py +64 -0
- package/autonomy/lib/secure-scan.py +652 -0
- package/autonomy/loki +108 -0
- package/autonomy/run.sh +163 -0
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +2 -2
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v7.
|
|
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.
|
|
411
|
+
**v7.87.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
7.
|
|
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"
|
package/dashboard/__init__.py
CHANGED
package/docs/INSTALLATION.md
CHANGED
|
@@ -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.
|
|
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.
|
|
398
|
+
asklokesh/loki-mode:7.87.0 start ./my-spec.md
|
|
399
399
|
```
|
|
400
400
|
|
|
401
401
|
##### docker compose + .env (no host install)
|
package/loki-ts/dist/loki.js
CHANGED
|
@@ -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.
|
|
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=
|
|
799
|
+
//# debugId=7982E696DB64940A64756E2164756E21
|
package/mcp/__init__.py
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loki-mode",
|
|
3
3
|
"mcpName": "io.github.asklokesh/loki-mode",
|
|
4
|
-
"version": "7.
|
|
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.
|
|
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",
|