@topy-ai/maggie 0.7.61 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-zh-TW.md +2 -2
- package/README.md +5 -5
- package/bundled-references/agent-goal-design.md +92 -0
- package/bundled-skills/catalog.json +4 -0
- package/bundled-skills/maggie-deployment/SKILL.md +21 -1
- package/bundled-skills/maggie-design/SKILL.md +29 -1
- package/bundled-skills/maggie-security/SKILL.md +10 -2
- package/bundled-skills/maggie-security/references/commands.md +10 -3
- package/bundled-skills/maggie-security/references/explaining-findings.md +7 -0
- package/bundled-skills/maggie-security/references/tools-and-licences.md +8 -0
- package/bundled-skills/maggie-security/registry/security-tools.registry.json +748 -652
- package/bundled-skills/maggie-spirit/SKILL.md +128 -0
- package/bundled-tools/clis/maggie_dash.py +12 -4
- package/bundled-tools/clis/maggie_security.py +1 -1
- package/bundled-tools/clis/maggie_security_code.py +49 -1
- package/bundled-tools/clis/maggie_security_deps.py +95 -0
- package/bundled-tools/clis/maggie_security_mcp.py +67 -1
- package/bundled-tools/clis/maggie_security_report.py +31 -2
- package/bundled-tools/clis/maggie_security_scan.py +168 -0
- package/bundled-tools/clis/maggie_security_web.py +45 -1
- package/bundled-tools/clis/maggie_spirit.py +143 -0
- package/bundled-tools/clis/security_data/tools.lock.json +36 -7
- package/bundled-tools/runtime/maggie_dash_pg.py +126 -0
- package/bundled-tools/runtime/maggie_dash_store.py +17 -5
- package/bundled-tools/runtime/spirit_agent.py +262 -0
- package/bundled-tools/runtime/spirit_base.py +256 -0
- package/bundled-tools/runtime/spirit_face.py +222 -0
- package/package.json +1 -1
- package/references/agent-goal-design.md +92 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""``maggie security scan`` — one command that runs the right detectors for the
|
|
3
|
+
site's profile and then builds a report.
|
|
4
|
+
|
|
5
|
+
The suite has many focused detectors; chaining them by hand (vuln-watch → deps →
|
|
6
|
+
secrets → headers → tls → host-quick → … → report) is the main source of workflow
|
|
7
|
+
friction, and it is what a scheduled routine or an MCP agent would otherwise have
|
|
8
|
+
to script. ``scan`` reads the profile (from the inventory, or ``--profile``), runs
|
|
9
|
+
exactly the detectors that profile expects, and finishes with a report — each
|
|
10
|
+
detector in its own process, best-effort, so one that degrades or refuses does not
|
|
11
|
+
abort the scan. It never does anything the individual commands would not: web
|
|
12
|
+
checks still need a verified domain, host checks still need ``--ssh`` consent.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import maggie_security_core as core
|
|
24
|
+
import maggie_security_report as report
|
|
25
|
+
|
|
26
|
+
CLI = Path(__file__).resolve().parent / "maggie_security.py"
|
|
27
|
+
|
|
28
|
+
# Detectors that need a target beyond the project tree.
|
|
29
|
+
WEB = {"headers", "tls", "web-scan"} # need a verified domain
|
|
30
|
+
HOST = {"host-quick", "host-nginx-config", "host-impact"} # need --ssh ALIAS=user@host
|
|
31
|
+
# Interactive / heavy / uncontrolled-target commands are never auto-run by scan.
|
|
32
|
+
NEVER_AUTORUN = {"logs-remote", "host-audit", "zap-baseline", "public-check", "web-scan", "hosted-job"}
|
|
33
|
+
# Project detectors worth running for any profile even if EXPECTED omits them.
|
|
34
|
+
ALWAYS = ["secrets", "code-scan"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _argv_for(command: str, args: argparse.Namespace) -> tuple[list[str] | None, str | None]:
|
|
38
|
+
"""The sub-command argv for this scan, or (None, reason) to skip it."""
|
|
39
|
+
if command in WEB:
|
|
40
|
+
if not args.domain:
|
|
41
|
+
return None, "needs a verified domain (pass --domain)"
|
|
42
|
+
return [command, "--domain", args.domain], None
|
|
43
|
+
if command in HOST:
|
|
44
|
+
if not args.ssh:
|
|
45
|
+
return None, "needs a server (pass --ssh ALIAS=user@host)"
|
|
46
|
+
return [command, "--ssh", args.ssh], None
|
|
47
|
+
return [command], None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _findings_count(data: dict) -> int | None:
|
|
51
|
+
summary = (data or {}).get("summary") or {}
|
|
52
|
+
if not summary:
|
|
53
|
+
return None
|
|
54
|
+
return int(summary.get("new", 0)) + int(summary.get("open", 0))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def scan(ctx: core.Context) -> dict:
|
|
58
|
+
args = ctx.args
|
|
59
|
+
project = ctx.project
|
|
60
|
+
import maggie_security as dispatch
|
|
61
|
+
|
|
62
|
+
built = set(dispatch.area_commands())
|
|
63
|
+
|
|
64
|
+
def run_cmd(argv: list[str]) -> tuple[int, dict, str]:
|
|
65
|
+
full = [sys.executable, str(CLI), *argv, "--project", str(project), "--format", "json", "--max-items", "200"]
|
|
66
|
+
if args.offline:
|
|
67
|
+
full.append("--offline")
|
|
68
|
+
proc = subprocess.run(full, capture_output=True, text=True, timeout=1800, check=False)
|
|
69
|
+
try:
|
|
70
|
+
data = json.loads(proc.stdout) if proc.stdout.strip() else {}
|
|
71
|
+
except ValueError:
|
|
72
|
+
data = {}
|
|
73
|
+
return proc.returncode, (data if isinstance(data, dict) else {}), proc.stderr
|
|
74
|
+
|
|
75
|
+
steps: list[dict] = []
|
|
76
|
+
skipped: list[dict] = []
|
|
77
|
+
|
|
78
|
+
# 1. Inventory first, so the profile and every later detector see the same stack.
|
|
79
|
+
code, data, _ = run_cmd(["inventory"])
|
|
80
|
+
steps.append({"command": "inventory", "exit": code, "degraded": bool(data.get("degraded"))})
|
|
81
|
+
|
|
82
|
+
# 2. Decide the profile.
|
|
83
|
+
stack_path = ctx.state.path("inventory", "stack.json")
|
|
84
|
+
profile = args.profile
|
|
85
|
+
if not profile:
|
|
86
|
+
if stack_path.is_file():
|
|
87
|
+
profile = (core.read_json(stack_path).get("site") or {}).get("type") or "unknown"
|
|
88
|
+
else:
|
|
89
|
+
profile = core.detect_site_type(project)
|
|
90
|
+
|
|
91
|
+
# 3. Build the ordered detector plan from the profile, plus the always-useful ones.
|
|
92
|
+
plan: list[str] = []
|
|
93
|
+
for command in list(report.EXPECTED.get(profile, report.EXPECTED["unknown"])) + ALWAYS:
|
|
94
|
+
if command in built and command not in NEVER_AUTORUN and command not in plan:
|
|
95
|
+
plan.append(command)
|
|
96
|
+
|
|
97
|
+
# 4. Run each detector, best-effort.
|
|
98
|
+
for command in plan:
|
|
99
|
+
argv, reason = _argv_for(command, args)
|
|
100
|
+
if argv is None:
|
|
101
|
+
skipped.append({"command": command, "reason": reason})
|
|
102
|
+
continue
|
|
103
|
+
code, data, _ = run_cmd(argv)
|
|
104
|
+
steps.append({"command": command, "exit": code, "degraded": bool(data.get("degraded")),
|
|
105
|
+
"findings": _findings_count(data),
|
|
106
|
+
"refused": code in (core.EXIT_USAGE, core.EXIT_POLICY, core.EXIT_TOOL)})
|
|
107
|
+
|
|
108
|
+
# 5. Report (and optionally a fix-plan).
|
|
109
|
+
rcode, rdata, _ = run_cmd(["report"] + (["--kind", args.kind] if args.kind else []))
|
|
110
|
+
verdict = ((rdata.get("report") or {}).get("verdict") or {}) if rdata else {}
|
|
111
|
+
out: dict = {
|
|
112
|
+
"schema": "maggie-security-scan.v1", "site": ctx.site, "profile": profile,
|
|
113
|
+
"ran": [s["command"] for s in steps], "skipped": skipped, "steps": steps,
|
|
114
|
+
"degraded": [s["command"] for s in steps if s.get("degraded")],
|
|
115
|
+
"verdict": verdict, "report": rdata.get("report") if rdata else None,
|
|
116
|
+
}
|
|
117
|
+
if args.fix_plan:
|
|
118
|
+
_c, fdata, _e = run_cmd(["fix-plan"])
|
|
119
|
+
out["fix_plan"] = {"actions": fdata.get("actions"), "tiers": fdata.get("tiers")} if fdata else None
|
|
120
|
+
|
|
121
|
+
out["markdown"] = render_scan(out, rdata.get("markdown") if rdata else None)
|
|
122
|
+
# exit code: actionable report -> 1; else degraded -> 3; else 0
|
|
123
|
+
if verdict.get("code") in ("act-today", "possible-break-in"):
|
|
124
|
+
out["exit_code"] = core.EXIT_FINDINGS
|
|
125
|
+
elif out["degraded"]:
|
|
126
|
+
out["exit_code"] = core.EXIT_DEGRADED
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def render_scan(out: dict, report_md: str | None) -> str:
|
|
131
|
+
lines = [f"# Security scan — {out['site']} ({out['profile']})", ""]
|
|
132
|
+
ran = [s for s in out["steps"] if not s.get("refused")]
|
|
133
|
+
lines.append(f"Ran {len(ran)} checks" + (f", {len(out['degraded'])} degraded" if out["degraded"] else "")
|
|
134
|
+
+ (f", {len(out['skipped'])} skipped." if out["skipped"] else "."))
|
|
135
|
+
lines.append("")
|
|
136
|
+
lines += ["| Check | Result |", "|---|---|"]
|
|
137
|
+
for step in out["steps"]:
|
|
138
|
+
if step["command"] == "inventory":
|
|
139
|
+
result = "degraded" if step["degraded"] else "ok"
|
|
140
|
+
elif step.get("refused"):
|
|
141
|
+
result = "refused"
|
|
142
|
+
elif step.get("findings"):
|
|
143
|
+
result = f"{step['findings']} finding(s)"
|
|
144
|
+
elif step["degraded"]:
|
|
145
|
+
result = "incomplete"
|
|
146
|
+
else:
|
|
147
|
+
result = "no issues"
|
|
148
|
+
lines.append(f"| {step['command']} | {result} |")
|
|
149
|
+
for skip in out["skipped"]:
|
|
150
|
+
lines.append(f"| {skip['command']} | skipped: {skip['reason']} |")
|
|
151
|
+
lines.append("")
|
|
152
|
+
if out.get("verdict"):
|
|
153
|
+
lines += [f"**Verdict:** {out['verdict'].get('text', '')}", ""]
|
|
154
|
+
if report_md:
|
|
155
|
+
lines += ["---", "", report_md]
|
|
156
|
+
return "\n".join(lines)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def configure(parser: argparse.ArgumentParser) -> None:
|
|
160
|
+
parser.add_argument("--profile", choices=sorted(report.EXPECTED), help="override the detected site profile")
|
|
161
|
+
parser.add_argument("--domain", help="verified domain for the web checks (headers, tls)")
|
|
162
|
+
parser.add_argument("--ssh", help="ALIAS=user@host for the host checks (needs ssh-read consent)")
|
|
163
|
+
parser.add_argument("--kind", choices=["weekly", "daily_ops"], help="report kind to build at the end (default: weekly)")
|
|
164
|
+
parser.add_argument("--fix-plan", action="store_true", dest="fix_plan", help="also build a remediation fix-plan")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
COMMANDS = [core.Command("scan", "run the detectors for the site's profile, then build a report (one-shot; each detector best-effort)", [],
|
|
168
|
+
configure, scan, findings=False)]
|
|
@@ -453,6 +453,50 @@ def csp_problems(value: str) -> list[str]:
|
|
|
453
453
|
return problems
|
|
454
454
|
|
|
455
455
|
|
|
456
|
+
# Letter grade for HTTP security headers, using the additive model from
|
|
457
|
+
# mdn-http-observatory (MPL-2.0; score table and grade chart ported as data):
|
|
458
|
+
# start at 100, apply the single worst penalty per category, map to a letter.
|
|
459
|
+
GRADE_CHART = [(100, "A+"), (85, "A"), (80, "B+"), (70, "B"), (65, "B-"),
|
|
460
|
+
(60, "C+"), (50, "C"), (45, "C-"), (40, "D+"), (30, "D"), (25, "D-"), (0, "F")]
|
|
461
|
+
# rule_id stem (after the `header-` prefix) -> (category, penalty). Worst per category counts once.
|
|
462
|
+
HEADER_PENALTIES = {
|
|
463
|
+
"csp-missing": ("csp", -25), "csp-": ("csp", -10),
|
|
464
|
+
"hsts-missing": ("hsts", -20), "hsts-short": ("hsts", -10),
|
|
465
|
+
"xcto": ("xcto", -5),
|
|
466
|
+
"framing": ("framing", -20), "xfo-allow-from": ("framing", -20),
|
|
467
|
+
"referrer-missing": ("referrer", -5), "referrer-weak": ("referrer", -5),
|
|
468
|
+
"cors-wildcard-credentials": ("cors", -50),
|
|
469
|
+
"cookie-": ("cookie", -20),
|
|
470
|
+
"version-": ("disclosure", -5),
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def header_grade(findings: list[dict]) -> dict:
|
|
475
|
+
"""Observatory-style score (0–100) and letter grade from the header findings."""
|
|
476
|
+
worst: dict[str, int] = {}
|
|
477
|
+
for finding in findings:
|
|
478
|
+
stem = (finding["source"]["rule_id"] or "").removeprefix("header-")
|
|
479
|
+
match = HEADER_PENALTIES.get(stem) or next((v for k, v in HEADER_PENALTIES.items() if k.endswith("-") and stem.startswith(k)), None)
|
|
480
|
+
if match:
|
|
481
|
+
category, penalty = match
|
|
482
|
+
worst[category] = min(worst.get(category, 0), penalty)
|
|
483
|
+
score = max(0, min(100, 100 + sum(worst.values())))
|
|
484
|
+
grade = next(letter for threshold, letter in GRADE_CHART if score >= threshold)
|
|
485
|
+
return {"score": score, "grade": grade, "deductions": sorted(worst.items(), key=lambda kv: kv[1])}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def grade_finding(ctx: core.Context, url: str, findings: list[dict]) -> dict:
|
|
489
|
+
g = header_grade(findings)
|
|
490
|
+
gaps = ", ".join(c for c, _ in g["deductions"]) or "nothing major"
|
|
491
|
+
summary = (f"Security-header grade {g['grade']} ({g['score']}/100). "
|
|
492
|
+
+ (f"Biggest gaps: {gaps}. Fix the items below to raise it." if g["deductions"] else "No major header gaps."))
|
|
493
|
+
return core.make_finding(
|
|
494
|
+
kind="header", command="headers", primary_id="header-grade", site=ctx.site,
|
|
495
|
+
title=f"HTTP security headers grade: {g['grade']} ({g['score']}/100)", severity="info", severity_source="maggie",
|
|
496
|
+
confidence="confirmed", target={"url": url, "host": host_of(url)}, rule_id="header-grade",
|
|
497
|
+
remediation={"summary": summary, "steps": [], "snippets": [], "references": [], "cheat_sheet": None})
|
|
498
|
+
|
|
499
|
+
|
|
456
500
|
def grade_response(ctx: core.Context, url: str, response: dict, layer: str, https: bool) -> list[dict]:
|
|
457
501
|
headers = response["headers"]
|
|
458
502
|
first = lambda name: (headers.get(name) or [None])[0] # noqa: E731
|
|
@@ -530,7 +574,7 @@ def grade_response(ctx: core.Context, url: str, response: dict, layer: str, http
|
|
|
530
574
|
if origin and origin.strip() == "*" and (first("access-control-allow-credentials") or "").lower() == "true":
|
|
531
575
|
add("cors-wildcard-credentials", "CORS allows any origin with credentials", "high", ["V3.4.2"], kind="misconfiguration",
|
|
532
576
|
evidence="Access-Control-Allow-Origin: *; Allow-Credentials: true")
|
|
533
|
-
return findings
|
|
577
|
+
return [grade_finding(ctx, url, findings)] + findings
|
|
534
578
|
|
|
535
579
|
|
|
536
580
|
def headers_cmd(ctx: core.Context):
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""maggie_spirit — generate an AI brand's pixel spirit (sprite) system.
|
|
3
|
+
|
|
4
|
+
One flat-pixel visual language for an agent brand:
|
|
5
|
+
* a logo "mark" that animates through a fixed 32-state base set, and
|
|
6
|
+
* per-agent avatar faces that animate through the same 32 states.
|
|
7
|
+
|
|
8
|
+
Everything is deterministic (seeded by accent + persona) and self-contained
|
|
9
|
+
(Pillow only). See skills/maggie-spirit/SKILL.md for the workflow.
|
|
10
|
+
|
|
11
|
+
Subcommands:
|
|
12
|
+
mark --out DIR [--name mark] [--accent #ff1212] [--title ONEMAN] [--scale 9]
|
|
13
|
+
Logo mark + 32-state animated base set + sprite sheet + states.json.
|
|
14
|
+
agent --out DIR --name cto --accent #3b82f6 --spec persona.json
|
|
15
|
+
One agent avatar animated through all 32 states (+ sheet + states.json).
|
|
16
|
+
avatar --out FILE.png --accent #3b82f6 --spec persona.json [--scale 10]
|
|
17
|
+
A single still avatar face (no animation).
|
|
18
|
+
demo --out DIR
|
|
19
|
+
The reference spirit base set: the default mark + 8 example agents +
|
|
20
|
+
founder — a starter kit to copy and recolour.
|
|
21
|
+
|
|
22
|
+
persona.json (agent/avatar), all keys optional except noted:
|
|
23
|
+
{"gender":"m|f","hair":"#2a2018","skin":"light|mid|tan|deep",
|
|
24
|
+
"hairstyle":"short|slick|spiky|buzz|longwavy|longstraight|longbob|ponytail",
|
|
25
|
+
"eyes":"round|wide|lash|happy","mouth":"flat|smile|smirk|lips|open",
|
|
26
|
+
"prop":"glasses|shades|cigar|headset|earbuds|monocle|pen|bandana",
|
|
27
|
+
"propcol":"#f2c75a","hoodie":true,"band":"#e23b3b","mask":"#12201a","specs":true}
|
|
28
|
+
"""
|
|
29
|
+
import argparse, json, os, sys
|
|
30
|
+
|
|
31
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
32
|
+
RUNTIME = os.path.normpath(os.path.join(HERE, "..", "runtime"))
|
|
33
|
+
if RUNTIME not in sys.path:
|
|
34
|
+
sys.path.insert(0, RUNTIME)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
import spirit_base as B
|
|
38
|
+
import spirit_face as F
|
|
39
|
+
import spirit_agent as A
|
|
40
|
+
except ImportError as e: # pragma: no cover
|
|
41
|
+
sys.stderr.write(
|
|
42
|
+
"maggie_spirit needs Pillow and the spirit_* runtime modules.\n"
|
|
43
|
+
f" {e}\n pip install pillow\n")
|
|
44
|
+
raise SystemExit(2)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load_spec(path):
|
|
48
|
+
if not path:
|
|
49
|
+
return {}
|
|
50
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
51
|
+
spec = json.load(fh)
|
|
52
|
+
if not isinstance(spec, dict):
|
|
53
|
+
raise SystemExit("persona spec must be a JSON object")
|
|
54
|
+
return spec
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cmd_mark(a):
|
|
58
|
+
B.OUT_BASE = os.path.abspath(a.out)
|
|
59
|
+
os.makedirs(B.OUT_BASE, exist_ok=True)
|
|
60
|
+
meta = B.generate(B.Motif.oneman(), accent=a.accent, out=a.name,
|
|
61
|
+
title=a.title, scale=a.scale)
|
|
62
|
+
print(f"{a.title}: {len(meta['states'])} states -> {os.path.join(a.out, a.name)}/")
|
|
63
|
+
print("groups:", ", ".join(meta.get("groups", [])))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _persona(spec):
|
|
67
|
+
gender = spec.get("gender", "m")
|
|
68
|
+
params = {k: v for k, v in spec.items() if k != "gender"}
|
|
69
|
+
params.setdefault("hair", "#1c1c22")
|
|
70
|
+
params.setdefault("skin", "mid")
|
|
71
|
+
return gender, params
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_agent(a):
|
|
75
|
+
A.OUT_BASE = os.path.abspath(a.out)
|
|
76
|
+
os.makedirs(A.OUT_BASE, exist_ok=True)
|
|
77
|
+
gender, params = _persona(_load_spec(a.spec))
|
|
78
|
+
meta = A.gen_agent(a.name.lower(), a.accent, gender, params, scale=a.scale)
|
|
79
|
+
print(f"{a.name} {a.accent} {gender}: {len(meta['states'])} states -> "
|
|
80
|
+
f"{os.path.join(a.out, a.name.lower())}/")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_avatar(a):
|
|
84
|
+
gender, params = _persona(_load_spec(a.spec))
|
|
85
|
+
img = F.render(F.face(a.accent, gender=gender, **params), a.scale)
|
|
86
|
+
out = os.path.abspath(a.out)
|
|
87
|
+
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
|
|
88
|
+
img.save(out)
|
|
89
|
+
print(f"avatar -> {a.out} ({img.size[0]}x{img.size[1]})")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cmd_demo(a):
|
|
93
|
+
base = os.path.abspath(a.out)
|
|
94
|
+
os.makedirs(base, exist_ok=True)
|
|
95
|
+
B.OUT_BASE = base
|
|
96
|
+
A.OUT_BASE = base
|
|
97
|
+
B.generate(B.Motif.oneman(), accent="#ff1212", out="oneman", title="ONEMAN")
|
|
98
|
+
made = ["oneman (mark)"]
|
|
99
|
+
for role, acc, g, p in list(F.CAST) + [F.FOUNDER]:
|
|
100
|
+
A.gen_agent(role.lower(), acc, g, p)
|
|
101
|
+
made.append(role.lower())
|
|
102
|
+
print(f"spirit base set -> {a.out}/")
|
|
103
|
+
print("generated:", ", ".join(made))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(argv=None):
|
|
107
|
+
ap = argparse.ArgumentParser(prog="maggie_spirit", description=__doc__,
|
|
108
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
109
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
110
|
+
|
|
111
|
+
m = sub.add_parser("mark", help="logo mark + 32-state animated base set")
|
|
112
|
+
m.add_argument("--out", required=True)
|
|
113
|
+
m.add_argument("--name", default="mark")
|
|
114
|
+
m.add_argument("--accent", default="#ff1212")
|
|
115
|
+
m.add_argument("--title", default="ONEMAN")
|
|
116
|
+
m.add_argument("--scale", type=int, default=9)
|
|
117
|
+
m.set_defaults(fn=cmd_mark)
|
|
118
|
+
|
|
119
|
+
ag = sub.add_parser("agent", help="one agent avatar through all 32 states")
|
|
120
|
+
ag.add_argument("--out", required=True)
|
|
121
|
+
ag.add_argument("--name", required=True)
|
|
122
|
+
ag.add_argument("--accent", required=True)
|
|
123
|
+
ag.add_argument("--spec", help="persona.json")
|
|
124
|
+
ag.add_argument("--scale", type=int, default=8)
|
|
125
|
+
ag.set_defaults(fn=cmd_agent)
|
|
126
|
+
|
|
127
|
+
av = sub.add_parser("avatar", help="a single still avatar face")
|
|
128
|
+
av.add_argument("--out", required=True)
|
|
129
|
+
av.add_argument("--accent", required=True)
|
|
130
|
+
av.add_argument("--spec", help="persona.json")
|
|
131
|
+
av.add_argument("--scale", type=int, default=10)
|
|
132
|
+
av.set_defaults(fn=cmd_avatar)
|
|
133
|
+
|
|
134
|
+
d = sub.add_parser("demo", help="reference spirit base set (mark + examples)")
|
|
135
|
+
d.add_argument("--out", required=True)
|
|
136
|
+
d.set_defaults(fn=cmd_demo)
|
|
137
|
+
|
|
138
|
+
a = ap.parse_args(argv)
|
|
139
|
+
a.fn(a)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
main()
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": "maggie-security-tools-lock.v1",
|
|
3
3
|
"updated": "2026-10-24",
|
|
4
|
+
"generated_at": "2026-10-24T00:00:00Z",
|
|
4
5
|
"tools": {
|
|
5
6
|
"osv-scanner": {
|
|
6
7
|
"version": "2.6.0",
|
|
@@ -104,7 +105,7 @@
|
|
|
104
105
|
"assets": {
|
|
105
106
|
"linux-amd64": {
|
|
106
107
|
"url": "https://github.com/projectdiscovery/nuclei/releases/download/v3.4.10/nuclei_3.4.10_linux_amd64.zip",
|
|
107
|
-
"sha256": "
|
|
108
|
+
"sha256": "234c12cc5288af071abdcd6f854245b6067345556e1235cf96b76725c1004357",
|
|
108
109
|
"archive": "zip",
|
|
109
110
|
"member": "nuclei"
|
|
110
111
|
}
|
|
@@ -137,7 +138,7 @@
|
|
|
137
138
|
"assets": {
|
|
138
139
|
"linux-amd64": {
|
|
139
140
|
"url": "https://github.com/projectdiscovery/httpx/releases/download/v1.7.1/httpx_1.7.1_linux_amd64.zip",
|
|
140
|
-
"sha256": "
|
|
141
|
+
"sha256": "865806a4eec321368e365b419baa3abdc0deb0d4ba598fe85a548a7c8682eb30",
|
|
141
142
|
"archive": "zip",
|
|
142
143
|
"member": "httpx"
|
|
143
144
|
}
|
|
@@ -240,7 +241,7 @@
|
|
|
240
241
|
"assets": {
|
|
241
242
|
"linux-amd64": {
|
|
242
243
|
"url": "https://github.com/anchore/syft/releases/download/v1.33.0/syft_1.33.0_linux_amd64.tar.gz",
|
|
243
|
-
"sha256": "
|
|
244
|
+
"sha256": "adc1b944a827ed3432bcd9f1dbdbc8fa3c0dca7d3d449e7084c90248c2c6cb50",
|
|
244
245
|
"archive": "tar.gz",
|
|
245
246
|
"member": "syft"
|
|
246
247
|
}
|
|
@@ -259,7 +260,7 @@
|
|
|
259
260
|
"assets": {
|
|
260
261
|
"linux-amd64": {
|
|
261
262
|
"url": "https://github.com/anchore/grype/releases/download/v0.100.0/grype_0.100.0_linux_amd64.tar.gz",
|
|
262
|
-
"sha256": "
|
|
263
|
+
"sha256": "9f4ed948b596a1978ec609eaa5538a9f2558836799b7b0a81f729408daf23e03",
|
|
263
264
|
"archive": "tar.gz",
|
|
264
265
|
"member": "grype"
|
|
265
266
|
}
|
|
@@ -271,14 +272,14 @@
|
|
|
271
272
|
}
|
|
272
273
|
},
|
|
273
274
|
"trivy": {
|
|
274
|
-
"version": "0.
|
|
275
|
+
"version": "0.76.1",
|
|
275
276
|
"licence": "Apache-2.0",
|
|
276
277
|
"class": "bundle-ok",
|
|
277
278
|
"homepage": "https://github.com/aquasecurity/trivy",
|
|
278
279
|
"assets": {
|
|
279
280
|
"linux-amd64": {
|
|
280
|
-
"url": "https://github.com/aquasecurity/trivy/releases/download/v0.
|
|
281
|
-
"sha256": "
|
|
281
|
+
"url": "https://github.com/aquasecurity/trivy/releases/download/v0.76.1/trivy_0.76.1_Linux-64bit.tar.gz",
|
|
282
|
+
"sha256": "c7cbe3137fb8a48a17574bede3dd6beb16742d1e9772929940fc90082c39d9c0",
|
|
282
283
|
"archive": "tar.gz",
|
|
283
284
|
"member": "trivy"
|
|
284
285
|
}
|
|
@@ -290,11 +291,38 @@
|
|
|
290
291
|
"--server"
|
|
291
292
|
]
|
|
292
293
|
},
|
|
294
|
+
"copa": {
|
|
295
|
+
"version": "0.15.0",
|
|
296
|
+
"licence": "Apache-2.0",
|
|
297
|
+
"class": "bundle-ok",
|
|
298
|
+
"homepage": "https://github.com/project-copacetic/copacetic",
|
|
299
|
+
"binary": "copa",
|
|
300
|
+
"notes": "Patches OS-package CVEs in a container image from a Trivy report. Needs Docker/buildkit at run time. Used by `image-patch` (plan only; Maggie never runs it).",
|
|
301
|
+
"assets": {
|
|
302
|
+
"linux-amd64": {
|
|
303
|
+
"url": "https://github.com/project-copacetic/copacetic/releases/download/v0.15.0/copa_0.15.0_linux_amd64.tar.gz",
|
|
304
|
+
"sha256": "cff3fe9320f3bd24bafcfd0ce877ef57e23acad0836da0ac6c253919f71c9cad",
|
|
305
|
+
"archive": "tar.gz",
|
|
306
|
+
"member": "copa"
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
"required_flags": [],
|
|
310
|
+
"forbidden_flags": ["--push"]
|
|
311
|
+
},
|
|
293
312
|
"dockle": {
|
|
294
313
|
"version": "0.4.15",
|
|
295
314
|
"licence": "Apache-2.0",
|
|
296
315
|
"class": "bundle-ok",
|
|
297
316
|
"homepage": "https://github.com/goodwithtech/dockle",
|
|
317
|
+
"binary": "dockle",
|
|
318
|
+
"assets": {
|
|
319
|
+
"linux-amd64": {
|
|
320
|
+
"url": "https://github.com/goodwithtech/dockle/releases/download/v0.4.15/dockle_0.4.15_Linux-64bit.tar.gz",
|
|
321
|
+
"sha256": "8bfc9183d1d3d67800edb45bea4bb63ddf1d28bec5f89944075a07007d79caad",
|
|
322
|
+
"archive": "tar.gz",
|
|
323
|
+
"member": "dockle"
|
|
324
|
+
}
|
|
325
|
+
},
|
|
298
326
|
"required_flags": [],
|
|
299
327
|
"forbidden_flags": []
|
|
300
328
|
},
|
|
@@ -329,6 +357,7 @@
|
|
|
329
357
|
"licence": "MIT",
|
|
330
358
|
"class": "bundle-ok",
|
|
331
359
|
"homepage": "https://github.com/perplexityai/bumblebee",
|
|
360
|
+
"notes": "Optional deferred preinstall engine; not bundled. Install it yourself if you want it; preinstall works without it.",
|
|
332
361
|
"required_flags": [],
|
|
333
362
|
"forbidden_flags": []
|
|
334
363
|
},
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostgreSQL backend for the MaggieDash content store.
|
|
3
|
+
|
|
4
|
+
A thin compatibility shim that mimics the subset of the stdlib sqlite3
|
|
5
|
+
connection API that MaggieDashStore uses, so the same store code runs on
|
|
6
|
+
Postgres unchanged. It translates `?` placeholders to `%s`, intercepts the few
|
|
7
|
+
SQLite-only statements (PRAGMA foreign_keys / table_info / user_version and
|
|
8
|
+
`sqlite_master`), and returns rows that support both name and positional access
|
|
9
|
+
plus `dict(row)` — exactly like `sqlite3.Row`.
|
|
10
|
+
|
|
11
|
+
Used when a Postgres DSN is supplied (ONEMAN forbids SQLite anywhere); the
|
|
12
|
+
SQLite path in MaggieDashStore is untouched. Requires psycopg2 (imported
|
|
13
|
+
lazily, only when a Postgres backend is actually requested).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from typing import Any, Sequence
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Row(dict):
|
|
22
|
+
"""A dict that also supports positional access and `dict(row)`, like sqlite3.Row."""
|
|
23
|
+
__slots__ = ("_cols",)
|
|
24
|
+
|
|
25
|
+
def __init__(self, cols: Sequence[str], values: Sequence[Any]):
|
|
26
|
+
super().__init__(zip(cols, values))
|
|
27
|
+
self._cols = list(cols)
|
|
28
|
+
|
|
29
|
+
def __getitem__(self, key):
|
|
30
|
+
if isinstance(key, int):
|
|
31
|
+
return super().__getitem__(self._cols[key])
|
|
32
|
+
return super().__getitem__(key)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Result:
|
|
36
|
+
"""Cursor-like result supporting fetchone/fetchall/iteration over Row objects."""
|
|
37
|
+
def __init__(self, rows: list[Row]):
|
|
38
|
+
self._rows = rows
|
|
39
|
+
self._idx = 0
|
|
40
|
+
|
|
41
|
+
def fetchone(self):
|
|
42
|
+
if self._idx < len(self._rows):
|
|
43
|
+
row = self._rows[self._idx]
|
|
44
|
+
self._idx += 1
|
|
45
|
+
return row
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def fetchall(self):
|
|
49
|
+
rows = self._rows[self._idx:]
|
|
50
|
+
self._idx = len(self._rows)
|
|
51
|
+
return rows
|
|
52
|
+
|
|
53
|
+
def __iter__(self):
|
|
54
|
+
rows = self._rows[self._idx:]
|
|
55
|
+
self._idx = len(self._rows)
|
|
56
|
+
return iter(rows)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_PRAGMA_TABLE_INFO = re.compile(r"PRAGMA\s+table_info\s*\(\s*([^)]+?)\s*\)", re.IGNORECASE)
|
|
60
|
+
_USER_VERSION_SET = re.compile(r"PRAGMA\s+user_version\s*=\s*(\d+)", re.IGNORECASE)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PgConnection:
|
|
64
|
+
"""sqlite3.Connection-compatible shim backed by psycopg2."""
|
|
65
|
+
def __init__(self, dsn: str):
|
|
66
|
+
import psycopg2 # lazy: only when a Postgres backend is requested
|
|
67
|
+
self._conn = psycopg2.connect(dsn)
|
|
68
|
+
self._conn.autocommit = False
|
|
69
|
+
|
|
70
|
+
# -- the sqlite3.Connection surface MaggieDashStore relies on --
|
|
71
|
+
def execute(self, sql: str, params: Sequence[Any] | None = None) -> Result:
|
|
72
|
+
s = sql.strip()
|
|
73
|
+
up = s.upper()
|
|
74
|
+
if up.startswith("PRAGMA FOREIGN_KEYS"):
|
|
75
|
+
return Result([])
|
|
76
|
+
m = _PRAGMA_TABLE_INFO.search(s)
|
|
77
|
+
if m:
|
|
78
|
+
table = m.group(1).strip().strip('"').strip("'")
|
|
79
|
+
rows = self._run(
|
|
80
|
+
"SELECT column_name FROM information_schema.columns "
|
|
81
|
+
"WHERE table_schema='public' AND table_name=%s ORDER BY ordinal_position",
|
|
82
|
+
(table,))
|
|
83
|
+
# sqlite PRAGMA table_info columns: (cid, name, ...) -> row[1] is the name
|
|
84
|
+
return Result([Row(["cid", "name"], [i, r[0]]) for i, r in enumerate(rows)])
|
|
85
|
+
m = _USER_VERSION_SET.search(s)
|
|
86
|
+
if m:
|
|
87
|
+
self._run("CREATE TABLE IF NOT EXISTS maggiedash_meta (key TEXT PRIMARY KEY, value TEXT)")
|
|
88
|
+
self._run("INSERT INTO maggiedash_meta (key,value) VALUES ('user_version',%s) "
|
|
89
|
+
"ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value", (str(m.group(1)),))
|
|
90
|
+
return Result([])
|
|
91
|
+
if "FROM SQLITE_MASTER" in up:
|
|
92
|
+
rows = self._run("SELECT table_name FROM information_schema.tables WHERE table_schema='public'")
|
|
93
|
+
return Result([Row(["name"], [r[0]]) for r in rows])
|
|
94
|
+
# ordinary statement: translate placeholders and run
|
|
95
|
+
query = s.replace("?", "%s")
|
|
96
|
+
cur = self._conn.cursor()
|
|
97
|
+
try:
|
|
98
|
+
cur.execute(query, tuple(params) if params else None)
|
|
99
|
+
if cur.description is None:
|
|
100
|
+
return Result([])
|
|
101
|
+
cols = [d[0] for d in cur.description]
|
|
102
|
+
return Result([Row(cols, rec) for rec in cur.fetchall()])
|
|
103
|
+
finally:
|
|
104
|
+
cur.close()
|
|
105
|
+
|
|
106
|
+
def executescript(self, script: str) -> None:
|
|
107
|
+
cur = self._conn.cursor()
|
|
108
|
+
try:
|
|
109
|
+
cur.execute(script) # psycopg2 runs multiple ';'-separated statements
|
|
110
|
+
finally:
|
|
111
|
+
cur.close()
|
|
112
|
+
|
|
113
|
+
def _run(self, query: str, params: Sequence[Any] | None = None) -> list[tuple]:
|
|
114
|
+
cur = self._conn.cursor()
|
|
115
|
+
try:
|
|
116
|
+
cur.execute(query, tuple(params) if params else None)
|
|
117
|
+
return cur.fetchall() if cur.description is not None else []
|
|
118
|
+
finally:
|
|
119
|
+
cur.close()
|
|
120
|
+
|
|
121
|
+
def commit(self) -> None:
|
|
122
|
+
self._conn.commit()
|
|
123
|
+
|
|
124
|
+
def close(self) -> None:
|
|
125
|
+
if not self._conn.closed:
|
|
126
|
+
self._conn.close()
|
|
@@ -38,11 +38,23 @@ def checksum(value: dict[str, Any]) -> str:
|
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
class MaggieDashStore:
|
|
41
|
-
def __init__(self, path: Path):
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
def __init__(self, path: Path | None = None, *, dsn: str | None = None):
|
|
42
|
+
# Postgres backend (ONEMAN forbids SQLite) when a DSN is supplied;
|
|
43
|
+
# otherwise the stdlib SQLite backend, unchanged.
|
|
44
|
+
if dsn:
|
|
45
|
+
from maggie_dash_pg import PgConnection
|
|
46
|
+
self.backend = "postgres"
|
|
47
|
+
self.path = dsn
|
|
48
|
+
# Never expose credentials in contracts/reports/CLI output.
|
|
49
|
+
self.storage_label = "env:MAGGIEDASH_DATABASE_URL"
|
|
50
|
+
self.connection = PgConnection(dsn)
|
|
51
|
+
else:
|
|
52
|
+
self.backend = "sqlite"
|
|
53
|
+
self.path = path.resolve()
|
|
54
|
+
self.storage_label = str(self.path)
|
|
55
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
self.connection = sqlite3.connect(self.path)
|
|
57
|
+
self.connection.row_factory = sqlite3.Row
|
|
46
58
|
self.connection.execute("PRAGMA foreign_keys = ON")
|
|
47
59
|
self.connection.executescript(
|
|
48
60
|
"""
|