@topy-ai/maggie 0.7.55 → 0.7.57

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 (73) hide show
  1. package/README-zh-TW.md +7 -3
  2. package/README.md +42 -8
  3. package/bin/maggie.js +4 -0
  4. package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +2 -1
  5. package/bundled-contracts/maggie-security/finding-example-v1.json +89 -0
  6. package/bundled-contracts/maggie-security/finding-v1.schema.json +63 -0
  7. package/bundled-contracts/maggie-security/findings-set-example-v1.json +148 -0
  8. package/bundled-contracts/maggie-security/findings-set-v1.schema.json +32 -0
  9. package/bundled-contracts/maggie-security/protect-plan-v1.schema.json +28 -0
  10. package/bundled-contracts/maggie-security/report-v1.schema.json +29 -0
  11. package/bundled-contracts/maggie-security/stack-example-v1.json +66 -0
  12. package/bundled-contracts/maggie-security/stack-upload-v1.schema.json +22 -0
  13. package/bundled-contracts/maggie-security/stack-v1.schema.json +37 -0
  14. package/bundled-contracts/maggie-security/tools-lock-v1.schema.json +36 -0
  15. package/bundled-skills/catalog.json +4 -0
  16. package/bundled-skills/maggie-security/SKILL.md +173 -0
  17. package/bundled-skills/maggie-security/agents/openai.yaml +4 -0
  18. package/bundled-skills/maggie-security/data/LICENSE-NOTICE.md +24 -0
  19. package/bundled-skills/maggie-security/data/asvs-map.json +209 -0
  20. package/bundled-skills/maggie-security/data/fail2ban/filter.d/maggie-wordpress.conf +6 -0
  21. package/bundled-skills/maggie-security/data/semgrep/LICENSE-NOTICE.md +5 -0
  22. package/bundled-skills/maggie-security/data/semgrep/VERSION +1 -0
  23. package/bundled-skills/maggie-security/data/semgrep/rules/dockerfile.yaml +14 -0
  24. package/bundled-skills/maggie-security/data/semgrep/rules/generic.yaml +26 -0
  25. package/bundled-skills/maggie-security/data/semgrep/rules/js.yaml +216 -0
  26. package/bundled-skills/maggie-security/data/semgrep/rules/php-debug-ok.yaml +17 -0
  27. package/bundled-skills/maggie-security/data/semgrep/rules/php-debug.yaml +17 -0
  28. package/bundled-skills/maggie-security/data/semgrep/rules/php.yaml +101 -0
  29. package/bundled-skills/maggie-security/data/semgrep/tests/dockerfile.dockerfile +10 -0
  30. package/bundled-skills/maggie-security/data/semgrep/tests/generic.astro +11 -0
  31. package/bundled-skills/maggie-security/data/semgrep/tests/generic.conf +11 -0
  32. package/bundled-skills/maggie-security/data/semgrep/tests/js.jsx +95 -0
  33. package/bundled-skills/maggie-security/data/semgrep/tests/php-debug-ok.php +5 -0
  34. package/bundled-skills/maggie-security/data/semgrep/tests/php-debug.php +3 -0
  35. package/bundled-skills/maggie-security/data/semgrep/tests/php.php +55 -0
  36. package/bundled-skills/maggie-security/evals/evals.json +97 -0
  37. package/bundled-skills/maggie-security/references/commands.md +125 -0
  38. package/bundled-skills/maggie-security/references/cve-response.md +24 -0
  39. package/bundled-skills/maggie-security/references/explaining-findings.md +57 -0
  40. package/bundled-skills/maggie-security/references/findings.md +39 -0
  41. package/bundled-skills/maggie-security/references/ownership-and-consent.md +29 -0
  42. package/bundled-skills/maggie-security/references/site-types.md +22 -0
  43. package/bundled-skills/maggie-security/references/tools-and-licences.md +16 -0
  44. package/bundled-skills/maggie-security/references/workflows.md +23 -0
  45. package/bundled-skills/maggie-security/registry/security-tools.registry.json +654 -0
  46. package/bundled-skills/maggie-security/routines.example.json +27 -0
  47. package/bundled-tools/clis/maggie_deployment_readiness.py +17 -2
  48. package/bundled-tools/clis/maggie_security.py +342 -0
  49. package/bundled-tools/clis/maggie_security_agent.py +326 -0
  50. package/bundled-tools/clis/maggie_security_code.py +650 -0
  51. package/bundled-tools/clis/maggie_security_core.py +1512 -0
  52. package/bundled-tools/clis/maggie_security_deps.py +560 -0
  53. package/bundled-tools/clis/maggie_security_health.py +415 -0
  54. package/bundled-tools/clis/maggie_security_host.py +511 -0
  55. package/bundled-tools/clis/maggie_security_hosted.py +115 -0
  56. package/bundled-tools/clis/maggie_security_intel.py +1294 -0
  57. package/bundled-tools/clis/maggie_security_inventory.py +1023 -0
  58. package/bundled-tools/clis/maggie_security_logs.py +777 -0
  59. package/bundled-tools/clis/maggie_security_mcp.py +484 -0
  60. package/bundled-tools/clis/maggie_security_mirror.py +122 -0
  61. package/bundled-tools/clis/maggie_security_protect.py +519 -0
  62. package/bundled-tools/clis/maggie_security_report.py +506 -0
  63. package/bundled-tools/clis/maggie_security_standards.py +214 -0
  64. package/bundled-tools/clis/maggie_security_versions.py +381 -0
  65. package/bundled-tools/clis/maggie_security_web.py +1247 -0
  66. package/bundled-tools/clis/security_data/eol-snapshot.json +1 -0
  67. package/bundled-tools/clis/security_data/gitleaks-rules.json +3812 -0
  68. package/bundled-tools/clis/security_data/maltrail-lists.json +1 -0
  69. package/bundled-tools/clis/security_data/public-suffix-icann.txt +7374 -0
  70. package/bundled-tools/clis/security_data/retire-extractors.json +695 -0
  71. package/bundled-tools/clis/security_data/sigma-web.json +6514 -0
  72. package/bundled-tools/clis/security_data/tools.lock.json +358 -0
  73. package/package.json +1 -1
@@ -125,6 +125,19 @@ def validate_auth_canary(payload: dict[str, Any]) -> tuple[bool, str]:
125
125
  return True, "authenticated API canary passed"
126
126
 
127
127
 
128
+ def validate_security_gate(payload: dict[str, Any]) -> tuple[bool, str]:
129
+ """`maggie security report --kind gate --output FILE` (docs/maggie-security/12-workflows-prd.md section 7)."""
130
+ if payload.get("schema") != "maggie-security-report.v1" or payload.get("kind") != "gate" or not isinstance(payload.get("gate"), dict):
131
+ return False, "security gate evidence must come from `maggie security report --kind gate`"
132
+ gate = payload["gate"]
133
+ if gate.get("decision") == "block":
134
+ reasons = "; ".join(str(item.get("reason")) for item in gate.get("block") or [])
135
+ return False, f"security gate blocks the release: {reasons}"
136
+ if gate.get("decision") == "warn":
137
+ return True, "security gate passed with warnings: " + "; ".join(str(item.get("reason")) for item in gate.get("warn") or [])
138
+ return True, "security gate passed"
139
+
140
+
128
141
  VALIDATORS: dict[str, Callable[[dict[str, Any]], tuple[bool, str]]] = {
129
142
  "unitRegression": validate_unit,
130
143
  "packageSmoke": validate_package,
@@ -136,8 +149,9 @@ VALIDATORS: dict[str, Callable[[dict[str, Any]], tuple[bool, str]]] = {
136
149
  "releaseIdentity": validate_release_identity,
137
150
  "rollbackEvidence": validate_rollback_check,
138
151
  "authenticatedCanary": validate_auth_canary,
152
+ "securityGate": validate_security_gate,
139
153
  }
140
- OPTIONAL_CHECKS = ("runtimeParity", "hostHealth", "releaseIdentity", "rollbackEvidence", "authenticatedCanary")
154
+ OPTIONAL_CHECKS = ("runtimeParity", "hostHealth", "releaseIdentity", "rollbackEvidence", "authenticatedCanary", "securityGate")
141
155
 
142
156
 
143
157
  def inspect_check(name: str, path: Path, project: Path) -> dict[str, Any]:
@@ -198,6 +212,7 @@ def main() -> int:
198
212
  parser.add_argument("--release-identity", type=Path, help="maggie-release-identity.v1 evidence")
199
213
  parser.add_argument("--rollback-check", type=Path, help="maggie-deployment-rollback-check.v1 evidence")
200
214
  parser.add_argument("--auth-canary", type=Path, help="maggie-deployment-auth-canary.v1 evidence")
215
+ parser.add_argument("--security-report", type=Path, help="gate report from `maggie security report --kind gate --output FILE` (opt-in)")
201
216
  parser.add_argument("--output", type=Path)
202
217
  args = parser.parse_args()
203
218
  project = args.project.resolve()
@@ -207,7 +222,7 @@ def main() -> int:
207
222
  name: (getattr(args, option).resolve() if getattr(args, option).is_absolute() else project / getattr(args, option))
208
223
  for name, option in CHECKS
209
224
  }
210
- for name, option in (("runtimeParity", "runtime_parity"), ("hostHealth", "host_health"), ("releaseIdentity", "release_identity"), ("rollbackEvidence", "rollback_check"), ("authenticatedCanary", "auth_canary")):
225
+ for name, option in (("runtimeParity", "runtime_parity"), ("hostHealth", "host_health"), ("releaseIdentity", "release_identity"), ("rollbackEvidence", "rollback_check"), ("authenticatedCanary", "auth_canary"), ("securityGate", "security_report")):
211
226
  value = getattr(args, option)
212
227
  if value:
213
228
  paths[name] = value.resolve() if value.is_absolute() else project / value
@@ -0,0 +1,342 @@
1
+ #!/usr/bin/env python3
2
+ """``maggie security <command>``: one entry point for every security check.
3
+
4
+ Routes to the area modules (docs/maggie-security/00-foundation-prd.md section
5
+ 6.1) and owns the housekeeping commands:
6
+
7
+ - ``doctor``: what applies to this project, which tools and feeds are ready,
8
+ ownership, consents, ledger size and last runs. ``--fix`` only writes
9
+ ``.maggie/security/config.json`` and a ``.gitignore`` entry.
10
+ - ``tools list|install|verify``: the pinned external scanners.
11
+ - ``feeds status|sync|verify``: the local feed cache.
12
+ - ``ledger list|show|accept|false-positive|reopen|compact``: finding status
13
+ history; the marks are local file writes only.
14
+
15
+ Exit codes: 0 ok, 1 findings at/above --fail-on, 2 usage, 3 degraded,
16
+ 4 refused by policy, 5 tool missing or checksum mismatch.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import importlib
23
+ import json
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
28
+
29
+ import maggie_security_core as core # noqa: E402
30
+
31
+ AREA_MODULES = [
32
+ "maggie_security_intel", "maggie_security_deps", "maggie_security_code", "maggie_security_web",
33
+ "maggie_security_protect", "maggie_security_logs", "maggie_security_host", "maggie_security_health",
34
+ "maggie_security_standards", "maggie_security_agent", "maggie_security_report", "maggie_security_mcp", "maggie_security_hosted",
35
+ ]
36
+
37
+ # Commands from the PRDs that are not built yet, with their milestone, so the
38
+ # help screen and the skill can say "planned" instead of "unknown".
39
+ PLANNED: dict[str, str] = {}
40
+
41
+
42
+ def area_commands() -> dict[str, core.Command]:
43
+ commands: dict[str, core.Command] = {}
44
+ for name in AREA_MODULES:
45
+ try:
46
+ module = importlib.import_module(name)
47
+ except ModuleNotFoundError as error:
48
+ if error.name == name:
49
+ continue
50
+ raise
51
+ for command in getattr(module, "COMMANDS", []):
52
+ commands[command.name] = command
53
+ return commands
54
+
55
+
56
+ # --------------------------------------------------------------------------- doctor
57
+
58
+
59
+ def doctor(ctx: core.Context) -> dict:
60
+ project, state = ctx.project, ctx.state
61
+ config = state.config()
62
+ site_type = config.get("site_type") or core.detect_site_type(project)
63
+ domains = config.get("domains") or []
64
+ lock = core.load_lock(state)
65
+ tools = []
66
+ for name, spec in sorted((lock.get("tools") or {}).items()):
67
+ if spec.get("class") == "reference-only":
68
+ continue
69
+ _spec, path, how = core.resolve_tool(name, lock)
70
+ tools.append({"name": name, "version": spec["version"], "class": spec["class"], "how": how})
71
+ ownership = []
72
+ for domain in domains:
73
+ record_path = state.path("ownership", f"{domain}.json")
74
+ record = core.read_json(record_path) if record_path.is_file() else None
75
+ status = "unverified"
76
+ if record and record.get("verified_at"):
77
+ age = core.utcnow() - core.parse_time(record["verified_at"])
78
+ status = "verified" if age.days <= 90 else "expired"
79
+ ownership.append({"domain": domain, "status": status, "verified_at": (record or {}).get("verified_at")})
80
+ consent_path = state.path("consent.json")
81
+ consents = core.read_json(consent_path).get("grants", []) if consent_path.is_file() else []
82
+ ledger = core.Ledger(state.path("ledger.jsonl"))
83
+ history = ledger.history()
84
+ statuses: dict[str, int] = {}
85
+ for events in history.values():
86
+ status = core.Ledger.derive(events)["status"] or "unknown"
87
+ statuses[status] = statuses.get(status, 0) + 1
88
+ last_runs: dict[str, str] = {}
89
+ runs_dir = state.path("runs")
90
+ if runs_dir.is_dir():
91
+ for path in sorted(runs_dir.glob("*.json")):
92
+ try:
93
+ run = json.loads(path.read_text(encoding="utf-8"))["run"]
94
+ except (ValueError, KeyError, OSError):
95
+ continue
96
+ last_runs[run["command"]] = run["finished_at"]
97
+ available = sorted(area_commands())
98
+ applicable = [c for c in core.APPLICABLE.get(site_type, core.APPLICABLE["unknown"])]
99
+ gitignore = project / ".gitignore"
100
+ ignored = gitignore.is_file() and any(line.strip().rstrip("/*") in (".maggie/security", "/.maggie/security", ".maggie")
101
+ for line in gitignore.read_text(encoding="utf-8").splitlines())
102
+ problems = []
103
+ if not ignored and state.root.exists():
104
+ problems.append("`.maggie/security/` is not in .gitignore (run: maggie security doctor --fix)")
105
+ if not domains:
106
+ problems.append("no domains configured; web checks are skipped (set domains in .maggie/security/config.json)")
107
+ report = {
108
+ "schema": "maggie-security-status.v1",
109
+ "maggie_version": core.maggie_version(),
110
+ "project": str(project),
111
+ "site": ctx.site,
112
+ "site_type": site_type,
113
+ "domains": domains,
114
+ "commands": {"applicable": applicable, "available": available,
115
+ "applicable_not_built": [c for c in applicable if c not in available and c not in ("report",)]},
116
+ "tools": tools,
117
+ "feeds": core.FeedCache(state, ctx.guard).status(),
118
+ "ownership": ownership,
119
+ "consents": consents,
120
+ "ledger": {"ids": len(history), "by_status": statuses},
121
+ "last_runs": last_runs,
122
+ "gitignored": ignored,
123
+ "mirror": core.mirror_base(),
124
+ "problems": problems,
125
+ }
126
+ if getattr(ctx.args, "fix", False):
127
+ report["fixed"] = doctor_fix(project, state, site_type, ctx.site)
128
+ return report
129
+
130
+
131
+ def doctor_fix(project: Path, state: core.State, site_type: str, site: str) -> list[str]:
132
+ done = []
133
+ state.ensure()
134
+ config_path = state.path("config.json")
135
+ if not config_path.is_file():
136
+ core.write_json(config_path, {"schema": "maggie-security-config.v1", "site": site, "site_type": site_type, "domains": [],
137
+ "schedules": {}, "thresholds": {"fail_on": "high"}, "opt_ins": {}})
138
+ done.append("created .maggie/security/config.json")
139
+ gitignore = project / ".gitignore"
140
+ lines = gitignore.read_text(encoding="utf-8").splitlines() if gitignore.is_file() else []
141
+ wanted = [".maggie/security/*", "!.maggie/security/config.json"]
142
+ missing = [line for line in wanted if line not in lines]
143
+ if missing:
144
+ with gitignore.open("a", encoding="utf-8") as handle:
145
+ if lines and lines[-1].strip():
146
+ handle.write("\n")
147
+ handle.write("# maggie security state (host evidence, findings); config.json is safe to commit\n")
148
+ handle.write("\n".join(missing) + "\n")
149
+ done.append("added .maggie/security/ to .gitignore")
150
+ ledger = core.Ledger(state.path("ledger.jsonl"))
151
+ if ledger.path.is_file():
152
+ result = ledger.compact()
153
+ done.append(f"compacted ledger {result['before']} -> {result['after']} events")
154
+ return done
155
+
156
+
157
+ def doctor_text(report: dict) -> str:
158
+ lines = [f"maggie security doctor: {report['site']} ({report['site_type']})"]
159
+ lines.append(f" domains: {', '.join(report['domains']) or 'none'}")
160
+ lines.append(f" applicable: {', '.join(report['commands']['applicable'])}")
161
+ if report["commands"]["applicable_not_built"]:
162
+ lines.append(f" not built yet: {', '.join(report['commands']['applicable_not_built'])}")
163
+ for tool in report["tools"]:
164
+ lines.append(f" tool {tool['name']:<12} {tool['version']:<10} {tool['how']}")
165
+ for feed in report["feeds"]:
166
+ if feed["entries"]:
167
+ lines.append(f" feed {feed['id']:<16} {feed['fetched_at']}{' STALE' if feed['stale'] else ''}")
168
+ lines.append(f" ledger: {report['ledger']['ids']} findings {report['ledger']['by_status']}")
169
+ for problem in report["problems"]:
170
+ lines.append(f" ! {problem}")
171
+ for item in report.get("fixed", []):
172
+ lines.append(f" fixed: {item}")
173
+ return "\n".join(lines)
174
+
175
+
176
+ # --------------------------------------------------------------------------- tools, feeds, ledger
177
+
178
+
179
+ def tools_cmd(ctx: core.Context) -> dict:
180
+ args = ctx.args
181
+ lock = core.load_lock(ctx.state)
182
+ names = [args.tool] if args.tool else sorted(lock.get("tools", {}))
183
+ if args.action == "list":
184
+ return {"schema": "maggie-security-tools.v1", "platform": core.platform_key(), "cache": str(core.tools_cache_dir()),
185
+ "tools": [{"name": n, **{k: v for k, v in lock["tools"][n].items() if k in ("version", "licence", "class", "notes")},
186
+ "how": core.resolve_tool(n, lock)[2]} for n in names]}
187
+ if args.action == "verify":
188
+ results = [core.verify_tool(n, lock) for n in names if lock["tools"][n]["class"] != "reference-only"]
189
+ bad = [r for r in results if not r["ok"] and r["how"] != "missing"]
190
+ if bad:
191
+ raise core.ToolError("; ".join(f"{r['tool']}: {r['problem']}" for r in bad))
192
+ return {"schema": "maggie-security-tools.v1", "results": results}
193
+ if not args.tool:
194
+ raise core.UsageError("tools install needs --tool NAME")
195
+ result = core.install_tool(args.tool, lock, ctx.guard)
196
+ ctx.state.ensure()
197
+ core.write_json(ctx.state.path("tools.lock.json"), core.read_json(core.packaged_lock_path()))
198
+ return result
199
+
200
+
201
+ def feeds_cmd(ctx: core.Context) -> dict:
202
+ args = ctx.args
203
+ if args.action == "status":
204
+ return {"schema": "maggie-security-feeds.v1", "mirror": core.mirror_base(), "feeds": ctx.cache.status()}
205
+ if args.action == "mirror":
206
+ import maggie_security_mirror as mirror
207
+
208
+ if not args.dir:
209
+ raise core.UsageError("feeds mirror needs --dir DIR")
210
+ ecosystems = [e for e in (args.ecosystems or ",".join(mirror.DEFAULT_ECOSYSTEMS)).split(",") if e]
211
+ return mirror.build(ctx, Path(args.dir), ecosystems, args.days, args.to_project)
212
+ if args.action == "verify":
213
+ problems = ctx.cache.verify()
214
+ if problems:
215
+ raise core.DegradedError(json.dumps(problems))
216
+ return {"schema": "maggie-security-feeds.v1", "problems": []}
217
+ # sync: fetch fixed-URL sources (the ones that take parameters are fetched by their commands)
218
+ wanted = [args.source] if args.source else ["cvelistv5-delta", "kev", "node-core", "nuclei-cves"]
219
+ results = []
220
+ for source in wanted:
221
+ spec = core.SOURCES.get(source)
222
+ if not spec or "{" in spec["url"] or not spec["url"]:
223
+ raise core.UsageError(f"{source} cannot be synced on its own; it is fetched per query")
224
+ data = ctx.cache.get(source, spec["url"], ttl="0s" if args.force else None)
225
+ results.append({"id": source, "bytes": len(data) if data else 0})
226
+ degraded = [item for item in ctx.cache.report if item["status"] == "degraded"]
227
+ out = {"schema": "maggie-security-feeds.v1", "synced": results, "report": ctx.cache.report}
228
+ if degraded:
229
+ print(json.dumps(out, indent=2))
230
+ raise core.DegradedError(", ".join(d["id"] for d in degraded) + " unavailable")
231
+ return out
232
+
233
+
234
+ def ledger_cmd(ctx: core.Context) -> dict:
235
+ args = ctx.args
236
+ ledger = core.Ledger(ctx.state.path("ledger.jsonl"))
237
+ if args.action == "list":
238
+ items = []
239
+ for fid, events in ledger.history().items():
240
+ state = core.Ledger.derive(events)
241
+ if args.status and state["status"] != args.status:
242
+ continue
243
+ items.append({"id": fid, **state})
244
+ return {"schema": "maggie-security-ledger.v1", "items": items}
245
+ if args.action == "compact":
246
+ return ledger.compact()
247
+ if not args.id:
248
+ raise core.UsageError(f"ledger {args.action} needs --id")
249
+ if args.action == "show":
250
+ events = ledger.history().get(args.id)
251
+ if not events:
252
+ raise core.UsageError(f"unknown finding id {args.id}")
253
+ return {"id": args.id, **core.Ledger.derive(events), "events": events}
254
+ event = {"accept": "accepted-risk", "false-positive": "false-positive", "reopen": "reopened"}[args.action]
255
+ until = args.until
256
+ if event == "accepted-risk" and not until:
257
+ until = core.iso(core.utcnow() + core.parse_duration("90d"))[:10]
258
+ return ledger.mark(args.id, event, args.reason, until=until if event == "accepted-risk" else None)
259
+
260
+
261
+ HOUSEKEEPING = [
262
+ core.Command("doctor", "what applies to this project, tool and feed readiness, ownership, consents, ledger",
263
+ [], lambda p: p.add_argument("--fix", action="store_true", help="create config.json and the .gitignore entry"),
264
+ doctor, findings=False),
265
+ core.Command("tools", "pinned external scanners: list, install, verify", ["github-releases"],
266
+ lambda p: (p.add_argument("action", choices=["list", "install", "verify"]), p.add_argument("--tool")),
267
+ tools_cmd, findings=False),
268
+ core.Command("feeds", "local feed cache: status, sync, verify; mirror builds an offline copy of every feed", list(core.SOURCES),
269
+ lambda p: (p.add_argument("action", choices=["status", "sync", "verify", "mirror"]), p.add_argument("--source"),
270
+ p.add_argument("--force", action="store_true", help="ignore TTL"),
271
+ p.add_argument("--dir", help="mirror: target directory"),
272
+ p.add_argument("--ecosystems", help="mirror: OSV ecosystems to download (default the common eight)"),
273
+ p.add_argument("--days", type=int, default=7, help="mirror: days of CVE delta zips"),
274
+ p.add_argument("--to-project", action="store_true", help="mirror: also place OSV zips in .maggie/security/db/osv")),
275
+ feeds_cmd, findings=False),
276
+ core.Command("ledger", "finding status history (local marks only)", [],
277
+ lambda p: (p.add_argument("action", choices=["list", "show", "accept", "false-positive", "reopen", "compact"]),
278
+ p.add_argument("--id"), p.add_argument("--reason"), p.add_argument("--until", help="YYYY-MM-DD for accept (default +90d)"),
279
+ p.add_argument("--status", choices=core.STATUSES)),
280
+ ledger_cmd, findings=False),
281
+ ]
282
+
283
+
284
+ def build_parser(commands: dict[str, core.Command]) -> argparse.ArgumentParser:
285
+ parser = argparse.ArgumentParser(prog="maggie security", description="Maggie security checks (docs/maggie-security).")
286
+ sub = parser.add_subparsers(dest="command", required=True, metavar="<command>")
287
+ common = core.common_parent()
288
+ for command in commands.values():
289
+ p = sub.add_parser(command.name, parents=[common], help=command.help, description=command.help)
290
+ command.configure(p)
291
+ return parser
292
+
293
+
294
+ def main(argv: list[str] | None = None) -> int:
295
+ argv = list(sys.argv[1:] if argv is None else argv)
296
+ commands = {c.name: c for c in HOUSEKEEPING}
297
+ commands.update(area_commands())
298
+ if argv and argv[0] in PLANNED and argv[0] not in commands:
299
+ print(f"maggie security {argv[0]} is planned for {PLANNED[argv[0]]} and not built yet", file=sys.stderr)
300
+ return core.EXIT_USAGE
301
+ parser = build_parser(commands)
302
+ try:
303
+ args = parser.parse_args(argv)
304
+ except SystemExit as error:
305
+ return core.EXIT_OK if error.code == 0 else core.EXIT_USAGE
306
+ command = commands[args.command]
307
+ if args.explain_egress:
308
+ return core.explain_egress(command.sources, command.name)
309
+ try:
310
+ ctx = core.Context(args, command.name, command.sources)
311
+ if args.offline:
312
+ with core.socket_guard():
313
+ result = command.run(ctx)
314
+ else:
315
+ result = command.run(ctx)
316
+ if isinstance(result, dict) and "_raw" in result:
317
+ if result["_raw"] is not None:
318
+ print(result["_raw"])
319
+ return core.EXIT_OK
320
+ if not command.findings:
321
+ if isinstance(result, dict) and "_raw" in result:
322
+ if result["_raw"] is not None:
323
+ print(result["_raw"])
324
+ return core.EXIT_OK
325
+ fmt = core.output_format(args)
326
+ if fmt == "text" and command.name == "doctor":
327
+ print(doctor_text(result))
328
+ elif fmt == "md" and isinstance(result, dict) and "markdown" in result:
329
+ print(result["markdown"])
330
+ else:
331
+ print(json.dumps(result, indent=2, ensure_ascii=False))
332
+ return result.get("exit_code", core.EXIT_OK) if isinstance(result, dict) else core.EXIT_OK
333
+ if isinstance(result, int):
334
+ return result
335
+ return core.emit(ctx, result)
336
+ except core.SecurityError as error:
337
+ print(f"maggie security {args.command}: {error}", file=sys.stderr)
338
+ return error.code
339
+
340
+
341
+ if __name__ == "__main__":
342
+ sys.exit(main())