@topy-ai/maggie 0.7.59 → 0.7.61

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 CHANGED
@@ -143,7 +143,7 @@ structure drift 或重複 treatment product 時 fail closed。
143
143
  deployment、memory、feedback 和 MaggieDash。內容先 draft/review,外部寫入、
144
144
  publish 與 production deployment 需要明確確認。
145
145
 
146
- 目前 npm 版本為 `0.7.59`,包含 fix-plan、public-check、Maggie Security、batch 31 排程 agent routines、報告收件匣與無人值守 agent 限制、PHP runtime 盤點與主機鎖、pull 網站改寫、query 點子與社群佇列檢查,batch 30 MaggieDash 先裝核心、release privacy gate、feedback 備援、社群連結與圖片檢查、大量頁面產生與 app 檢查,batch 29 預約規則、部署、app QA、助理與社群功能,以及 batch 28 產品 app、assistant tools、mobile、product release、skills catalog 與 bridge host 功能、batch 26、27 feedback 修正與 idempotent `maggie update`與 Booking email-template 合約/CLI validator 同步。本版完成 2026-09-24 booking feedback batch:Calendar grid/free-slot entry 與 host modal focus-restoration evidence。本版新增 Starter marketplace provisioning plan;本版完成 feedback batches 22、23:加入 declarative browser interaction evidence、design markup/style/content-key gate、compiler source anchoring、entity double-escape protection、`update --help` read-only、noindex hypothetical indexability、可過期 baseline acknowledgement、DOM-only link extraction regression,以及 MaggieDash 0.2.10 的 content override、短期 browser session、CDN-safe public-session cache contracts。authentication 和 isolated dev server 仍由 host 負責。`maggie-booking` workflow、傳統 email/password、Stripe、Resend、public booking flow 及既有安全邊界維持不變;provider-bound video generation 與 transactional delivery 仍記錄在 issue #39,前一批延後功能仍在 issue #38,不會直接成為 active memory。0.7.33 記錄並審查一筆更正回饋;確認 0.7.32 已正確處理 batch index,沒有修改已驗證的 shared behavior。0.7.32 加入 feedback 一基索引支援、MaggieDash panel 的 source/freshness/error evidence、Consent Mode 與實際 write/no-op reconciliation、SEO baseline recapture、sitemap origin rebasing、content-diff scope、migration ledger reconciliation、deployment credential preflight、release provenance/runtime preflight、icon release gate、opt-in blog auto-publish gate,以及 least-privilege VPS deployer。也修復 top-level `maggie feedback` dispatcher parity。0.7.31 加入 update 差異摘要、atomic installer copy、subset install manifest merge,以及 inventory drift 時 doctor non-zero。0.7.30 補上 feedback tracking issue closeout gate 與 batch 14/15 audit ledger。0.7.29 修正 npm package landing page 使用英文 README;0.7.28 加入 versioned Gemini model policy、明確 fallback 與 provenance、bundled-first CLI
146
+ 目前 npm 版本為 `0.7.61`,包含 fix-plan 修補風險分級 T1–T4(判斷補丁是否安全套用)與 host-impact 補丁影響面量測(哪些服務需重啟、是否需重開機)、public-check、Maggie Security、batch 31 排程 agent routines、報告收件匣與無人值守 agent 限制、PHP runtime 盤點與主機鎖、pull 網站改寫、query 點子與社群佇列檢查,batch 30 MaggieDash 先裝核心、release privacy gate、feedback 備援、社群連結與圖片檢查、大量頁面產生與 app 檢查,batch 29 預約規則、部署、app QA、助理與社群功能,以及 batch 28 產品 app、assistant tools、mobile、product release、skills catalog 與 bridge host 功能、batch 26、27 feedback 修正與 idempotent `maggie update`與 Booking email-template 合約/CLI validator 同步。本版完成 2026-09-24 booking feedback batch:Calendar grid/free-slot entry 與 host modal focus-restoration evidence。本版新增 Starter marketplace provisioning plan;本版完成 feedback batches 22、23:加入 declarative browser interaction evidence、design markup/style/content-key gate、compiler source anchoring、entity double-escape protection、`update --help` read-only、noindex hypothetical indexability、可過期 baseline acknowledgement、DOM-only link extraction regression,以及 MaggieDash 0.2.10 的 content override、短期 browser session、CDN-safe public-session cache contracts。authentication 和 isolated dev server 仍由 host 負責。`maggie-booking` workflow、傳統 email/password、Stripe、Resend、public booking flow 及既有安全邊界維持不變;provider-bound video generation 與 transactional delivery 仍記錄在 issue #39,前一批延後功能仍在 issue #38,不會直接成為 active memory。0.7.33 記錄並審查一筆更正回饋;確認 0.7.32 已正確處理 batch index,沒有修改已驗證的 shared behavior。0.7.32 加入 feedback 一基索引支援、MaggieDash panel 的 source/freshness/error evidence、Consent Mode 與實際 write/no-op reconciliation、SEO baseline recapture、sitemap origin rebasing、content-diff scope、migration ledger reconciliation、deployment credential preflight、release provenance/runtime preflight、icon release gate、opt-in blog auto-publish gate,以及 least-privilege VPS deployer。也修復 top-level `maggie feedback` dispatcher parity。0.7.31 加入 update 差異摘要、atomic installer copy、subset install manifest merge,以及 inventory drift 時 doctor non-zero。0.7.30 補上 feedback tracking issue closeout gate 與 batch 14/15 audit ledger。0.7.29 修正 npm package landing page 使用英文 README;0.7.28 加入 versioned Gemini model policy、明確 fallback 與 provenance、bundled-first CLI
147
147
  dispatch、`maggie --version`、feedback batch review 聚合與重複偵測、marketplace enrichment evidence、booking
148
148
  worker/resolver evidence,以及 read-only migration preflight。它也包含 host-owned mobile app surface contract、signed-in
149
149
  camera-state QA、直接 Astro route resolution、correlated feedback batch,以及
package/README.md CHANGED
@@ -89,7 +89,9 @@ Maggie keeps the existing project foundation and asks for decisions before
89
89
  shared routes, analytics, or publishing boundaries change. The current
90
90
  package ships 23 installable skills and a local-first MaggieDash foundation.
91
91
 
92
- This package is `0.7.59`. It makes `maggie security host-audit` (the deep
92
+ This package is `0.7.61` — safe patching. `maggie security fix-plan` tags every action with a **fix-risk tier** T1–T4 — whether a patch is safe to apply, not just what to patch: T1 batch-safe distro/permission fixes, T2 restart+smoke-test, T3 config hardening that validates and auto-rolls-back, T4 major/EOL moves emitted as accepted-risk drafts to stage first. New `maggie security host-impact` (read-only) measures each patch's **blast radius** — which pending updates are security-only, which running services must restart to make a fix live (`needrestart`), and whether a reboot is pending — and feeds it back into the plan.
93
+
94
+ The 0.7.59 release made `maggie security host-audit` (the deep
93
95
  lynis audit over SSH) robust on hosts where a storage test can stall: lynis runs
94
96
  in its own process group under a watchdog, and the bundled profile skips the ZFS
95
97
  storage/filesystem tests that hang, so the audit completes instead of timing out.
@@ -109,7 +111,7 @@ interactive-only and never probes, submits or scans; the active scanners
109
111
  (`web-scan`, `zap-baseline`) and host checks stay verified-only.
110
112
 
111
113
  The 0.7.56 release added Maggie Security, a new
112
- `maggie-security` skill and `maggie security` command family (48 commands, 30
114
+ `maggie-security` skill and `maggie security` command family (49 commands, 30
113
115
  MCP tools, 7 contracts under `contracts/maggie-security/`).
114
116
 
115
117
  - **Advisories for the whole stack.** Packages (npm, pnpm, Yarn, Bun,
@@ -856,8 +858,8 @@ artifact schemas.
856
858
  Recommended upgrade sequence for the current release:
857
859
 
858
860
  ```bash
859
- npx @topy-ai/maggie@0.7.59 update --scope project --project . --force
860
- npx @topy-ai/maggie@0.7.59 cleanup --scope project --project .
861
+ npx @topy-ai/maggie@0.7.61 update --scope project --project . --force
862
+ npx @topy-ai/maggie@0.7.61 cleanup --scope project --project .
861
863
  ```
862
864
 
863
865
  Maintainers should pass npm credentials through the repository helper, never
@@ -50,7 +50,7 @@ Follow [Maggie Memory Hook](../../references/memory-hook.md) at invocation and c
50
50
  | "are we being attacked / were we hacked?" | `maggie security logs-traffic --file access.log`, `maggie security logs-sigma --file access.log`, `maggie security logs-ioc --file access.log`; servers: `maggie security logs-remote --ssh web1=user@host` | `security_logs_check` |
51
51
  | "is my server hardened?" | `maggie security host-quick --ssh web1=user@host` (or `--print-collector` for the person to run), `maggie security host-nginx-config --ssh web1=user@host`, deep: `maggie security host-audit --ssh web1=user@host` | `security_host_check` |
52
52
  | "protect my server" (CrowdSec / fail2ban / Cloudflare) | `maggie security protect-plan --host web1 [--engine crowdsec\|fail2ban]` → the person reviews and runs it under the host lock; afterwards `maggie security protect-plan verify --plan FILE --ssh web1=user@host` | `security_protect_plan` |
53
- | "give me the fixes to run" | after the checks: `maggie security fix-plan --script fix.sh --output RUNBOOK.md` — a reviewed runbook (file permissions, firewall, apt updates, intrusion prevention, nginx, EOL) built from the findings; the person reviews it and runs it themselves under the host lock. Maggie never applies it. | — |
53
+ | "give me the fixes to run" | after the checks: `maggie security fix-plan --script fix.sh --output RUNBOOK.md` — a reviewed runbook (file permissions, firewall, apt updates, intrusion prevention, nginx, EOL) built from the findings, each action tagged with a fix-risk tier T1–T4 (T1 batch-safe → T3 validate+auto-rollback → T4 stage-first/accepted-risk) so the operator knows what is safe to batch vs what must be tested first; the person reviews it and runs it themselves under the host lock. Maggie never applies it. Run `maggie security host-impact` first so the plan is blast-radius-aware (which services a patch affects, what must restart, whether a reboot is pending). | — |
54
54
  | "monitor uptime / tell me when the site is down" | `maggie security uptime-init` (verified domains) → `maggie security uptime-deploy --hostname status.example.com` (plan) → `maggie security uptime-status --gatus URL`; jobs report with `maggie security uptime-push` | `security_uptime_status` |
55
55
  | "how do we compare to OWASP?" / "threat model" | `maggie security checklist --level 1`, `maggie security threat-model --include-findings` | `security_checklist_get`, `security_threat_model` |
56
56
  | "is this npm package safe to add?" | `maggie security preinstall PKG[@VERSION]` before installing (W5); never install on `block` | `security_package_vet` |
@@ -91,6 +91,7 @@ Formats: nginx/Apache combined and vcombined, Caddy JSON, Cloudflare Logpush JSO
91
91
  | `maggie security host-quick --ssh web1=user@host \| --evidence FILE \| --print-collector` | SSH, firewall, automatic updates, fail2ban/CrowdSec and port alignment, databases listening publicly, Docker bypassing UFW, SUID, sudo logging, disk/memory; checks ported from vps-audit (MIT) | ssh-read |
92
92
  | `maggie security host-nginx-config --ssh web1=user@host \| --evidence FILE` | nginx -T and php -i rules: hidden files, PHP in uploads, PATH_INFO, old TLS/weak ciphers, HSTS, add_header inheritance, WordPress login limits, xmlrpc, key permissions | ssh-read |
93
93
  | `maggie security host-audit --ssh web1=user@host \| --evidence report.dat` | lynis (GPL, external) uploaded to a private /tmp folder under the host lock, removed afterwards (verified); hardening index and warnings | ssh-temp-write |
94
+ | `maggie security host-impact --ssh web1=user@host \| --evidence FILE \| --print-collector` | patch **blast radius** (read-only): which pending updates are security-only (`apt-get -s upgrade` origin parsing) vs not, which running services still use pre-update libraries and must restart (`needrestart -b` — a patched library in memory is still the old one until restart), whether a reboot is pending (`/var/run/reboot-required` + kernel comparison), and the port→process map. Writes `inventory/impact.json`, which `fix-plan` reads to sharpen its risk tiers and restart steps. Techniques ported from PatchMon (MIT). | ssh-read |
94
95
  | `maggie security incident start\|log\|close\|show\|list` | timeline for a CVE response; `report --kind incident` prints it | none |
95
96
 
96
97
  ## Protect and assure
@@ -122,5 +123,5 @@ Formats: nginx/Apache combined and vcombined, Caddy JSON, Cloudflare Logpush JSO
122
123
  | Command | What it does |
123
124
  |---|---|
124
125
  | `maggie security report --kind weekly\|daily_ops\|incident\|gate [--audience owner\|technical] [--save]` | Markdown + JSON report from the latest runs; `--save` writes the `report_save` payload |
125
- | `maggie security fix-plan [--priority p0..p4] [--script fix.sh] [--output RUNBOOK.md]` | a reviewed remediation runbook from the open findings: grouped by category (file permissions, firewall, package/apt updates, intrusion prevention, web-server config, EOL), each step the commands the findings themselves carry (never invented). Writes a shell script you run yourself, under `maggie deployment host-lock`, after a backup and with a second SSH session open. Maggie never applies it. |
126
+ | `maggie security fix-plan [--priority p0..p4] [--script fix.sh] [--output RUNBOOK.md]` | a reviewed remediation runbook from the open findings: grouped by category (file permissions, firewall, package/apt updates, intrusion prevention, web-server config, EOL), each step the commands the findings themselves carry (never invented). Every action is tagged with a **fix-risk tier** (T1 batch-safe distro/permission fixes → T2 restart+smoke-test → T3 config hardening that validates and auto-rolls-back → T4 major/EOL moves that are NOT auto-applied and are emitted as accepted-risk drafts to stage first); the apt batch backs up package state and verifies afterwards. If `host-impact` has run, the plan also shows the **blast radius** (which services each patch affects, what must restart, whether a reboot is pending) and its apt verify step restarts the services still running pre-update libraries. Writes a shell script you run yourself, under `maggie deployment host-lock`, after a backup and with a second SSH session open. Maggie never applies it. |
126
127
  | `maggie security mcp [--tools p0\|all] [--print-config claude\|codex]` | local stdio MCP server |
@@ -215,6 +215,125 @@ def host_quick(ctx: core.Context):
215
215
  return ctx.finish(findings, covered=[f"host:{host}", "*"])
216
216
 
217
217
 
218
+ # --------------------------------------------------------------------------- host-impact (patch blast-radius)
219
+
220
+ # The blast-radius collector answers "how big is the impact of patching X on this
221
+ # host?" — the input the fix-risk tiers need to judge a patch against what is
222
+ # actually running. Read-only. Techniques ported from PatchMon (apt security
223
+ # simulate + origin parsing; /var/run/reboot-required + kernel comparison;
224
+ # needrestart for the patched-but-not-restarted gap).
225
+ IMPACT_COLLECTOR = r"""set +e
226
+ s() { printf '\n### %s\n' "$1"; }
227
+ SUDO=""; [ "$(id -u)" != 0 ] && sudo -n true 2>/dev/null && SUDO="sudo -n"
228
+ s os-release; cat /etc/os-release 2>/dev/null
229
+ s security-updates; LANG=C apt-get -s -o Debug::NoLocking=1 upgrade 2>/dev/null | grep '^Inst ' | head -300
230
+ s reboot-required; [ -f /var/run/reboot-required ] && echo yes || echo no
231
+ s reboot-pkgs; cat /var/run/reboot-required.pkgs 2>/dev/null | head -50
232
+ s kernel; uname -r; ls -1 /boot/vmlinuz-* 2>/dev/null | sed 's#.*/vmlinuz-##' | sort -V | tail -1
233
+ s needrestart; $SUDO needrestart -b 2>/dev/null | grep -E '^NEEDRESTART-(KCUR|KEXP|KSTA|SVC):' | head -200
234
+ s listen; ss -H -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null
235
+ s running-services; systemctl list-units --type=service --state=running --no-legend 2>/dev/null | awk '{print $1}' | head -200
236
+ s timers; systemctl list-timers --all --no-legend 2>/dev/null | awk '{print $NF}' | grep -v '^$' | head -60
237
+ s cron; ls -1 /etc/cron.d /etc/cron.daily /etc/cron.hourly 2>/dev/null | grep -v ':$' | sort -u | head -40; $SUDO ls -1 /var/spool/cron/crontabs 2>/dev/null | head -20
238
+ """
239
+
240
+ LISTEN_PROC_RE = re.compile(r'users:\(\("([^"]+)"')
241
+ APT_INST_RE = re.compile(r"^Inst\s+(\S+)\s+(?:\[[^\]]*\]\s+)?\(\S+\s*([^)]*)\)") # name, then the origin tail
242
+
243
+
244
+ def parse_impact(sections: dict[str, str]) -> dict:
245
+ """Turn the collector sections into a patch blast-radius snapshot."""
246
+ def lines(name: str) -> list[str]:
247
+ return [l.strip() for l in sections.get(name, "").splitlines() if l.strip()]
248
+
249
+ security, pending = [], []
250
+ for line in lines("security-updates"):
251
+ m = APT_INST_RE.match(line)
252
+ if not m:
253
+ continue
254
+ name, origins = m.group(1), m.group(2) or ""
255
+ pending.append(name)
256
+ if "security" in origins.lower(): # only the origin tail — package names can contain "security"
257
+ security.append(name)
258
+
259
+ reboot = (sections.get("reboot-required", "").strip().lower().startswith("yes"))
260
+ reboot_pkgs = lines("reboot-pkgs")
261
+ kl = lines("kernel")
262
+ running_k = kl[0] if kl else None
263
+ latest_k = kl[1] if len(kl) > 1 else None
264
+
265
+ svc_restart, kcur, kexp, ksta = [], running_k, None, None
266
+ for line in lines("needrestart"):
267
+ tag, _, val = line.partition(":")
268
+ val = val.strip()
269
+ if tag == "NEEDRESTART-SVC":
270
+ svc_restart.append(val)
271
+ elif tag == "NEEDRESTART-KCUR":
272
+ kcur = val
273
+ elif tag == "NEEDRESTART-KEXP":
274
+ kexp = val
275
+ elif tag == "NEEDRESTART-KSTA":
276
+ ksta = val
277
+ if kexp and not latest_k:
278
+ latest_k = kexp
279
+ kernel_stale = bool(ksta == "3") or bool(running_k and latest_k and running_k != latest_k)
280
+
281
+ listening = []
282
+ for line in lines("listen"):
283
+ proc = LISTEN_PROC_RE.search(line)
284
+ addr = line.split()[3] if len(line.split()) > 3 else ""
285
+ port = addr.rsplit(":", 1)[-1] if ":" in addr else addr
286
+ if proc and port:
287
+ listening.append({"port": port, "process": proc.group(1)})
288
+
289
+ return {"schema": "maggie-security-impact.v1",
290
+ "reboot_required": reboot or kernel_stale, "reboot_pkgs": reboot_pkgs,
291
+ "kernel": {"running": kcur, "latest": latest_k, "stale": kernel_stale},
292
+ "services_need_restart": sorted(set(svc_restart)),
293
+ "security_updates": sorted(set(security)), "pending_updates": sorted(set(pending)),
294
+ "listening": listening, "running_services": lines("running-services"),
295
+ "timers": lines("timers"), "cron": lines("cron"),
296
+ "needrestart_available": bool(sections.get("needrestart", "").strip())}
297
+
298
+
299
+ def impact_findings(ctx: core.Context, snapshot: dict, host: str) -> list[dict]:
300
+ findings: list[dict] = []
301
+ svcs = snapshot["services_need_restart"]
302
+ if svcs:
303
+ shown = ", ".join(svcs[:8]) + (f" (+{len(svcs) - 8} more)" if len(svcs) > 8 else "")
304
+ findings.append(check(
305
+ "hq-needs-restart", f"{len(svcs)} running service(s) still use pre-update libraries — restart needed", "medium",
306
+ "A library was updated on disk but these services are still running the old copy in memory, so the fix is not live yet. "
307
+ "Restart them (or reboot if the kernel changed).", host=host, ctx=ctx,
308
+ evidence=shown, steps=["sudo needrestart -r a # review, then restart the services it lists"] +
309
+ [f"sudo systemctl restart {shlex.quote(s)}" for s in svcs[:8]]))
310
+ if snapshot["reboot_required"]:
311
+ why_pkgs = (": " + ", ".join(snapshot["reboot_pkgs"][:6])) if snapshot["reboot_pkgs"] else ""
312
+ kern = snapshot["kernel"]
313
+ detail = f"running {kern['running']}, installed {kern['latest']}" if kern.get("stale") and kern.get("latest") else why_pkgs
314
+ findings.append(check(
315
+ "hq-reboot-required", "The server needs a reboot to finish applying updates", "medium",
316
+ "A kernel or core library update is only fully applied after a reboot. Schedule one in a maintenance window.",
317
+ host=host, ctx=ctx, evidence=detail.strip(" :"),
318
+ steps=["# schedule a reboot in a maintenance window, then:", "sudo shutdown -r +1 'Maggie: applying security updates'"]))
319
+ return findings
320
+
321
+
322
+ def host_impact(ctx: core.Context):
323
+ if ctx.args.print_collector:
324
+ return {"_raw": IMPACT_COLLECTOR}
325
+ text, host = evidence_text(ctx, IMPACT_COLLECTOR)
326
+ sections = parse_sections(text)
327
+ if not sections:
328
+ raise core.UsageError("the evidence has no '### section' blocks; run the collector from --print-collector")
329
+ snapshot = parse_impact(sections)
330
+ snapshot.update({"host": host, "collected_at": core.iso()})
331
+ core.write_json(ctx.state.path("inventory", "impact.json"), snapshot)
332
+ findings = impact_findings(ctx, snapshot, host)
333
+ ctx.run["inputs"].update({"host": host, "impact": "inventory/impact.json"})
334
+ return ctx.finish(findings, covered=[f"host:{host}", "*"])
335
+
336
+
218
337
  # --------------------------------------------------------------------------- host-audit (lynis report.dat)
219
338
 
220
339
 
@@ -540,4 +659,6 @@ COMMANDS = [
540
659
  routine_safe=False),
541
660
  core.Command("host-nginx-config", "nginx and PHP configuration rules (hidden files, PHP in uploads, TLS, headers, rate limits)", [], _quick,
542
661
  host_nginx_config),
662
+ core.Command("host-impact", "patch blast-radius: which pending updates are security-only, what needs restart/reboot, and what is running "
663
+ "(read-only; feeds fix-plan's risk tiers)", [], _quick, host_impact),
543
664
  ]
@@ -73,7 +73,7 @@ SCHEMAS: dict[str, dict] = {
73
73
  "mode": {"enum": ["traffic", "attacks", "ioc"]}, "file": LOGFILE, "since": SINCE,
74
74
  "rules": {"type": "string", "pattern": r"^(web|proxy|emerging)(,(web|proxy|emerging))*$"}}},
75
75
  "security_host_check": {"type": "object", "required": ["host"], "additionalProperties": False, "properties": {
76
- "host": ALIAS, "depth": {"enum": ["quick", "nginx"]}}},
76
+ "host": ALIAS, "depth": {"enum": ["quick", "nginx", "impact"]}}},
77
77
  "security_protect_plan": {"type": "object", "required": ["host"], "additionalProperties": False, "properties": {
78
78
  "host": ALIAS, "engine": {"enum": ["auto", "crowdsec", "fail2ban", "cloudflare"]}, "site_type": {"enum": ["wordpress", "php-app", "node-app", "static", "workers"]}}},
79
79
  "security_uptime_status": {"type": "object", "additionalProperties": False, "properties": {"window": {"enum": ["1h", "24h", "7d", "30d"]}}},
@@ -229,7 +229,7 @@ class Server:
229
229
  argv += ["--rules", args["rules"]]
230
230
  return argv
231
231
  if name == "security_host_check":
232
- command = "host-nginx-config" if args.get("depth") == "nginx" else "host-quick"
232
+ command = {"nginx": "host-nginx-config", "impact": "host-impact"}.get(args.get("depth"), "host-quick")
233
233
  return [command] if dry else [command, "--ssh", f"{args['host']}={self.host_target(args['host'])}"]
234
234
  if name == "security_protect_plan":
235
235
  argv = ["protect-plan", "--host", args.get("host", "web1"), "--engine", args.get("engine", "auto")]
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
  import argparse
16
16
  import json
17
17
  import re
18
+ import shlex
18
19
  from pathlib import Path
19
20
 
20
21
  import maggie_security_core as core
@@ -546,6 +547,53 @@ def fix_category(finding: dict) -> str:
546
547
  return "other"
547
548
 
548
549
 
550
+ # Fix-risk tiers answer "will this patch break something?", a separate axis from
551
+ # "how urgent is the flaw?" (severity/KEV/EPSS). The operator uses both: a KEV
552
+ # critical justifies a riskier tier sooner, but the tier decides HOW to apply it.
553
+ FIX_TIERS = [
554
+ ("T1", "Safe to batch-apply",
555
+ "Distro security-only updates and file-permission fixes. ABI-stable within the release; very low breakage risk. Do these first."),
556
+ ("T2", "Apply, then restart and smoke-test",
557
+ "App package upgrades (no major-version change) and plan-only setups. Low–moderate risk: restart the affected service and check it serves."),
558
+ ("T3", "Apply one at a time — validate and auto-rollback",
559
+ "Config hardening (TLS, headers, firewall, vhosts). Changes behaviour; can break old clients or lock you out. Each step backs up, validates, and reverts on failure."),
560
+ ("T4", "Do NOT auto-apply — stage first",
561
+ "Major-version jumps and end-of-life moves. High compatibility risk. Test in staging, schedule a change window; record as accepted-risk until then."),
562
+ ]
563
+ TIER_LABEL = {t: f"{t} — {name}" for t, name, _ in FIX_TIERS}
564
+ TIER_ORDER = [t for t, _, _ in FIX_TIERS]
565
+
566
+
567
+ def _is_major_jump(current: str | None, fixed: str | None) -> bool:
568
+ """A different leading integer between the installed and fixed version — the
569
+ semver signal for a breaking upgrade (nginx 1.x→2.x, OpenSSL 1.1→3.0, Node 18→22)."""
570
+ def major(v: str | None) -> str | None:
571
+ m = re.match(r"\D*(\d+)", v or "")
572
+ return m.group(1) if m else None
573
+ cm, fm = major(current), major(fixed)
574
+ return bool(cm and fm and cm != fm)
575
+
576
+
577
+ def fix_tier(action: dict) -> str:
578
+ """Compatibility/reversibility tier T1–T4 for an action (see FIX_TIERS)."""
579
+ lead = action["lead"]
580
+ category = fix_category(lead)
581
+ if category == "end-of-life":
582
+ return "T4"
583
+ if category == "packages":
584
+ osfam = (action.get("ecosystem") or "").split(":")[0]
585
+ if osfam in ("Ubuntu", "Debian"):
586
+ return "T1" # distro security pocket, ABI-stable within the release
587
+ if _is_major_jump((lead.get("affected") or {}).get("version"), action.get("fix_version")):
588
+ return "T4" # app package with a breaking major-version jump
589
+ return "T2"
590
+ if category == "secrets-permissions":
591
+ return "T1"
592
+ if category in ("web-server", "firewall"):
593
+ return "T3" # config change that alters behaviour / can lock you out
594
+ return "T2"
595
+
596
+
549
597
  def fix_commands(action: dict) -> tuple[list[str], list[tuple[str, str]], list[str]]:
550
598
  """(shell commands, (kind, snippet) paste-ins, TODO notes) faithfully from the lead finding."""
551
599
  lead = action["lead"]
@@ -557,6 +605,98 @@ def fix_commands(action: dict) -> tuple[list[str], list[tuple[str, str]], list[s
557
605
  return commands, snippets, todos
558
606
 
559
607
 
608
+ # nginx directives valid in the http{} context can be dropped into conf.d; the
609
+ # rest (location/proxy/add_header) must live in a server{} block.
610
+ NGINX_HTTP_DIRECTIVES = {"ssl_protocols", "ssl_ciphers", "ssl_prefer_server_ciphers", "ssl_ecdh_curve",
611
+ "ssl_session_tickets", "ssl_session_timeout", "ssl_dhparam", "server_tokens"}
612
+ NGINX_FILE_RE = re.compile(r"(/etc/nginx/[\w./-]+\.conf)")
613
+
614
+
615
+ def nginx_fix_block(actions: list[dict]) -> tuple[list[str], list[str]]:
616
+ """Runnable nginx fixes: a global drop-in for http-context directives, and a
617
+ snippet file + a guarded, reversible `include` for server-context ones."""
618
+ http_dirs: list[tuple[str, dict]] = []
619
+ server_by_file: dict[str, list[str]] = {}
620
+ manual: list[dict] = []
621
+ for action in actions:
622
+ rem = action["lead"].get("remediation") or {}
623
+ snippets = [s.get("text", "").strip() for s in (rem.get("snippets") or []) if s.get("text")]
624
+ if not snippets:
625
+ manual.append(action)
626
+ continue
627
+ tp = (action["lead"].get("target") or {}).get("path") or ""
628
+ match = NGINX_FILE_RE.search(tp) or NGINX_FILE_RE.search(action["lead"].get("title", ""))
629
+ vfile = match.group(1) if match else None
630
+ for text in snippets:
631
+ name = text.split()[0] if text.split() else ""
632
+ if name in NGINX_HTTP_DIRECTIVES:
633
+ http_dirs.append((text, action))
634
+ elif vfile:
635
+ server_by_file.setdefault(vfile, [])
636
+ if text not in server_by_file[vfile]:
637
+ server_by_file[vfile].append(text)
638
+ else:
639
+ manual.append(action)
640
+ sh: list[str] = []
641
+ md: list[str] = []
642
+ if http_dirs:
643
+ directives = list(dict.fromkeys(d for d, _ in http_dirs))
644
+ fixes = "; ".join(dict.fromkeys(a["title"] for _, a in http_dirs))
645
+ body = "".join(d + "\n" for d in directives)
646
+ sh += [f"# Global TLS/hardening — fixes: {fixes}",
647
+ f"printf %s {shlex.quote(body)} | sudo tee /etc/nginx/conf.d/00-maggie-hardening.conf >/dev/null",
648
+ "sudo nginx -t && sudo systemctl reload nginx", ""]
649
+ md.append("- **nginx global hardening** → drop-in `/etc/nginx/conf.d/00-maggie-hardening.conf`: "
650
+ + ", ".join(f"`{d}`" for d in directives))
651
+ for vfile, directives in server_by_file.items():
652
+ stem = re.sub(r"[^\w.-]", "_", re.sub(r"\.conf$", "", vfile.rsplit("/", 1)[-1]))
653
+ snip = f"/etc/nginx/snippets/maggie-{stem}.conf"
654
+ heredoc = "\n".join(directives)
655
+ sh += [f"# Harden {vfile} (backs up, validates, auto-reverts on error)",
656
+ f"sudo tee {snip} >/dev/null <<'MAGGIE_SNIP'",
657
+ heredoc,
658
+ "MAGGIE_SNIP",
659
+ f"sudo cp -a {shlex.quote(vfile)} {shlex.quote(vfile + '.maggie.bak')}",
660
+ f"sudo sed -i -E '/^[[:space:]]*server[[:space:]]*\\{{[[:space:]]*$/a\\ include {snip};' {shlex.quote(vfile)}",
661
+ f"sudo nginx -t && sudo systemctl reload nginx || {{ echo 'nginx test failed — reverting'; "
662
+ f"sudo cp -a {shlex.quote(vfile + '.maggie.bak')} {shlex.quote(vfile)}; sudo systemctl reload nginx; }}", ""]
663
+ md.append(f"- **nginx vhost hardening** (`{vfile}` via `{snip}` + guarded include): "
664
+ + ", ".join(f"`{d}`" for d in directives))
665
+ for action in manual:
666
+ rem = action["lead"].get("remediation") or {}
667
+ sh += [f"# [{action['priority']}] {action['title']} — {action['why']}",
668
+ f"# TODO (manual): {rem.get('summary') or action['how']}", ""]
669
+ md.append(f"- **{action['title']}** — {rem.get('summary') or action['how']} _(manual)_")
670
+ return sh, md
671
+
672
+
673
+ def impact_summary_sh(impact: dict) -> list[str]:
674
+ """A blast-radius header for the runbook, from `host-impact`'s snapshot."""
675
+ svcs = impact.get("services_need_restart") or []
676
+ listening = impact.get("listening") or []
677
+ out = ["# =================== Blast radius (from host-impact) ===================",
678
+ f"# Pending updates: {len(impact.get('pending_updates') or [])} "
679
+ f"({len(impact.get('security_updates') or [])} security-only — safe to batch as T1).",
680
+ f"# Public-facing services (port -> process): "
681
+ + (", ".join(f"{l['port']}:{l['process']}" for l in listening[:8]) or "none detected") + ".",
682
+ f"# Needs restart after patching ({len(svcs)}): " + (", ".join(svcs[:8]) or "none") + ".",
683
+ f"# Reboot pending: {'YES — schedule a maintenance window' if impact.get('reboot_required') else 'no'}.",
684
+ "# A patch is not live in a running service until that service restarts (or the box reboots).", ""]
685
+ return out
686
+
687
+
688
+ def impact_summary_md(impact: dict) -> list[str]:
689
+ svcs = impact.get("services_need_restart") or []
690
+ listening = impact.get("listening") or []
691
+ return ["## Blast radius (from `host-impact`)", "",
692
+ f"- **Pending updates:** {len(impact.get('pending_updates') or [])} "
693
+ f"({len(impact.get('security_updates') or [])} security-only → safe to batch as T1)",
694
+ "- **Public-facing services:** " + (", ".join(f"`{l['port']}:{l['process']}`" for l in listening[:8]) or "none detected"),
695
+ f"- **Needs restart after patching ({len(svcs)}):** " + (", ".join(f"`{s}`" for s in svcs[:8]) or "none"),
696
+ f"- **Reboot pending:** {'**YES** — schedule a maintenance window' if impact.get('reboot_required') else 'no'}",
697
+ "", "_A patch is not live in a running service until that service restarts (or the box reboots)._", ""]
698
+
699
+
560
700
  def fix_plan(ctx: core.Context) -> dict:
561
701
  args = ctx.args
562
702
  since = resolve_since(ctx, args.since)
@@ -570,6 +710,9 @@ def fix_plan(ctx: core.Context) -> dict:
570
710
  for action in actions:
571
711
  buckets[fix_category(action["lead"])].append(action)
572
712
 
713
+ impact_path = ctx.state.path("inventory", "impact.json")
714
+ impact = core.read_json(impact_path) if impact_path.is_file() else None
715
+
573
716
  host = next((f.get("target", {}).get("host") for f in actionable if (f.get("target") or {}).get("host")), None)
574
717
  lock_target = host or "<user@host>"
575
718
  sh: list[str] = [
@@ -582,25 +725,73 @@ def fix_plan(ctx: core.Context) -> dict:
582
725
  "# 2. opening a SECOND SSH session (so a bad sshd/firewall change cannot lock you out),",
583
726
  f"# 3. taking the change lock: maggie deployment host-lock acquire --host {lock_target} --purpose fix-plan",
584
727
  "# Package upgrades belong in a maintenance window. Test nginx with `nginx -t` before every reload.",
728
+ "#",
729
+ "# FIX-RISK TIERS (will this patch break something? — separate from how urgent the flaw is):",
730
+ ] + [f"# {TIER_LABEL[t]}: {note}" for t, _, note in FIX_TIERS] + [
731
+ "# Recommended order: T1 first, then T2, then T3 one at a time; schedule T4 separately.",
585
732
  "set -euo pipefail",
586
733
  "",
587
734
  ]
588
735
  md: list[str] = [f"# Remediation runbook — {ctx.site}" + (f" ({host})" if host else ""), "",
589
736
  "Generated by `maggie security fix-plan`. **Review before running — Maggie never runs these.** "
590
737
  "Take a backup, keep a second SSH session open, and work under "
591
- f"`maggie deployment host-lock acquire --host {lock_target} --purpose fix-plan`.", ""]
738
+ f"`maggie deployment host-lock acquire --host {lock_target} --purpose fix-plan`.", "",
739
+ "**Fix-risk tiers** — how likely a patch is to break something (separate from how urgent the flaw is). "
740
+ "Apply T1 first, then T2, then T3 one at a time; schedule T4 separately.", ""]
741
+ md += [f"- **{TIER_LABEL[t]}** — {note}" for t, _, note in FIX_TIERS] + [""]
742
+ if impact:
743
+ sh += impact_summary_sh(impact)
744
+ md += impact_summary_md(impact)
745
+ else:
746
+ sh += ["# Blast radius not collected. For a sharper plan — which services a patch affects, what",
747
+ "# must restart (a patched library in memory is still the old one until restart), and whether",
748
+ "# a reboot is pending — run: maggie security host-impact --ssh "
749
+ + (f"{host}=<user@host>" if host else "<alias>=<user@host>"), ""]
750
+ md += ["> Run `maggie security host-impact` first for a blast-radius-aware plan "
751
+ "(which services each patch affects, what must restart, whether a reboot is pending).", ""]
752
+
592
753
  apt_packages: list[str] = []
593
754
  total = 0
755
+ tier_counts: dict[str, int] = {t: 0 for t in TIER_ORDER}
756
+ for action in actions:
757
+ tier_counts[fix_tier(action)] += 1
594
758
  for key, label, note in FIX_CATEGORIES:
595
759
  items = buckets.get(key) or []
596
760
  if not items:
597
761
  continue
598
762
  sh += [f"# =================== {label} ===================", f"# {note}", ""]
599
763
  md += [f"## {label}", f"_{note}_", ""]
764
+ if key == "web-server":
765
+ sh.append("# Tier: T3 — each fix below backs up, validates with `nginx -t`, and auto-reverts on error.")
766
+ sh.append("")
767
+ md.append(f"_Risk tier: **{TIER_LABEL['T3']}** — each fix validates and auto-reverts._")
768
+ md.append("")
769
+ block_sh, block_md = nginx_fix_block(items)
770
+ sh += block_sh
771
+ md += block_md
772
+ total += len(items)
773
+ continue
774
+ if key == "end-of-life":
775
+ sh.append(f"# Tier: {TIER_LABEL['T4']}. DO NOT run these as part of this script.")
776
+ sh.append("# Test each move in staging and schedule a change window. Until then, record an accepted risk:")
777
+ md.append(f"_Risk tier: **{TIER_LABEL['T4']}** — do not auto-apply. Stage first; record as accepted-risk until scheduled._")
778
+ md.append("")
779
+ for action in items:
780
+ total += 1
781
+ sh.append(f"# [{action['priority']}] {action['title']} — {action['why']}")
782
+ for fid in action["ids"]:
783
+ sh.append(f"# maggie security ledger accept --id {fid} "
784
+ f"--reason {shlex.quote(action['title'] + ' — major/EOL upgrade, staging first')}")
785
+ sh.append("")
786
+ md.append(f"- **{action['title']}** — {action['why']}")
787
+ md.append(f" - accept until staged: `maggie security ledger accept --id {action['ids'][0]} --reason \"…\"`")
788
+ md.append("")
789
+ continue
600
790
  for action in items:
601
791
  total += 1
602
- sh.append(f"# [{action['priority']}] {action['title']} — {action['why']}")
603
- md.append(f"- **{action['title']}** — {action['why']}")
792
+ tier = fix_tier(action)
793
+ sh.append(f"# [{action['priority']}][{tier}] {action['title']} — {action['why']}")
794
+ md.append(f"- **[{tier}] {action['title']}** — {action['why']}")
604
795
  if key == "packages":
605
796
  # Build the fix command ourselves; never copy the finding's step text
606
797
  # (it reads "apt-get install --only-upgrade X (fixed in Y)" — not valid shell).
@@ -630,11 +821,25 @@ def fix_plan(ctx: core.Context) -> dict:
630
821
  md.append("")
631
822
  if apt_packages:
632
823
  pkgs = " ".join(sorted(set(apt_packages)))
633
- insert = ["# Apply available Ubuntu/Debian security updates for the affected packages:",
824
+ insert = ["# Tier: T1 — distro security updates are ABI-stable within the release; safe to batch.",
825
+ "# Back up the package state first so you can roll back a bad upgrade:",
826
+ 'sudo dpkg --get-selections > "$HOME/maggie-pkg-state.$(date +%F).txt"',
634
827
  "sudo apt-get update",
635
828
  f"sudo apt-get install --only-upgrade {pkgs}",
636
829
  "# Or apply all pending security updates at once:",
637
- "# sudo unattended-upgrade -d # or: sudo apt-get upgrade", ""]
830
+ "# sudo unattended-upgrade -d # or: sudo apt-get upgrade",
831
+ "# Verify nothing broke after upgrading (and restart services if the kernel/libc changed):",
832
+ "systemctl list-units --state=failed --no-legend || true",
833
+ "sudo nginx -t 2>/dev/null && echo 'nginx config ok' || true"]
834
+ # A patched library on disk is not live until the services using it restart.
835
+ svcs = (impact or {}).get("services_need_restart") or []
836
+ insert += ["# Make the fix live — a patched library in memory is still the old one until restart:",
837
+ "sudo needrestart -r l # list what is still running old code; -r a to act on it"]
838
+ insert += [f"sudo systemctl restart {s}" for s in svcs[:8]]
839
+ if (impact or {}).get("reboot_required"):
840
+ insert += ["# A reboot is pending (kernel/core library) — schedule it in a maintenance window:",
841
+ "# sudo shutdown -r +1 'applying security updates'"]
842
+ insert += ["# If a service failed: downgrade the package (apt-get install pkg=<old>) or restore from the snapshot.", ""]
638
843
  anchor = sh.index("# =================== Package updates ===================") + 3
639
844
  sh[anchor:anchor] = insert
640
845
  md.append("### apt batch")
@@ -652,6 +857,7 @@ def fix_plan(ctx: core.Context) -> dict:
652
857
  out.write_text(runbook, encoding="utf-8")
653
858
  return {"schema": "maggie-security-fix-plan.v1", "site": ctx.site, "host": host, "generated_at": core.iso(),
654
859
  "actions": total, "apt_packages": sorted(set(apt_packages)),
860
+ "tiers": {t: tier_counts[t] for t in TIER_ORDER if tier_counts[t]},
655
861
  "script_path": str(args.script) if args.script else None,
656
862
  "note": "Review before running. Maggie never applies these changes.", "markdown": runbook}
657
863
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.59",
3
+ "version": "0.7.61",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",