@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.
Files changed (29) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +5 -5
  3. package/bundled-references/agent-goal-design.md +92 -0
  4. package/bundled-skills/catalog.json +4 -0
  5. package/bundled-skills/maggie-deployment/SKILL.md +21 -1
  6. package/bundled-skills/maggie-design/SKILL.md +29 -1
  7. package/bundled-skills/maggie-security/SKILL.md +10 -2
  8. package/bundled-skills/maggie-security/references/commands.md +10 -3
  9. package/bundled-skills/maggie-security/references/explaining-findings.md +7 -0
  10. package/bundled-skills/maggie-security/references/tools-and-licences.md +8 -0
  11. package/bundled-skills/maggie-security/registry/security-tools.registry.json +748 -652
  12. package/bundled-skills/maggie-spirit/SKILL.md +128 -0
  13. package/bundled-tools/clis/maggie_dash.py +12 -4
  14. package/bundled-tools/clis/maggie_security.py +1 -1
  15. package/bundled-tools/clis/maggie_security_code.py +49 -1
  16. package/bundled-tools/clis/maggie_security_deps.py +95 -0
  17. package/bundled-tools/clis/maggie_security_mcp.py +67 -1
  18. package/bundled-tools/clis/maggie_security_report.py +31 -2
  19. package/bundled-tools/clis/maggie_security_scan.py +168 -0
  20. package/bundled-tools/clis/maggie_security_web.py +45 -1
  21. package/bundled-tools/clis/maggie_spirit.py +143 -0
  22. package/bundled-tools/clis/security_data/tools.lock.json +36 -7
  23. package/bundled-tools/runtime/maggie_dash_pg.py +126 -0
  24. package/bundled-tools/runtime/maggie_dash_store.py +17 -5
  25. package/bundled-tools/runtime/spirit_agent.py +262 -0
  26. package/bundled-tools/runtime/spirit_base.py +256 -0
  27. package/bundled-tools/runtime/spirit_face.py +222 -0
  28. package/package.json +1 -1
  29. 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": "UNPINNED",
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": "UNPINNED",
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": "UNPINNED",
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": "UNPINNED",
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.67.2",
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.67.2/trivy_0.67.2_Linux-64bit.tar.gz",
281
- "sha256": "UNPINNED",
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
- self.path = path.resolve()
43
- self.path.parent.mkdir(parents=True, exist_ok=True)
44
- self.connection = sqlite3.connect(self.path)
45
- self.connection.row_factory = sqlite3.Row
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
  """