@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,128 @@
1
+ ---
2
+ name: maggie-spirit
3
+ description: Generate an AI brand's flat-pixel spirit (sprite) system - a logo mark that animates through a fixed 32-state base set, and per-agent avatar faces animated through the same states. Use for agent/brand avatars, logo marks, sprite-sheet and sprite-animation assets, and a shared motion vocabulary across Core and Service agents.
4
+ metadata:
5
+ version: 0.1.0
6
+ ---
7
+
8
+ # Maggie Spirit
9
+
10
+ Give an agent brand one flat-pixel visual language: a **logo mark** and a set of
11
+ **agent avatars** that all animate through the **same fixed catalog of 32
12
+ states** (power, work, comms, outcome, gov). Agents differ only by their
13
+ **motif/face** and their **accent** hue; the motion vocabulary, semantic palette
14
+ and status badges stay identical so a state reads the same everywhere.
15
+
16
+ Everything is deterministic (seeded by accent + persona) and self-contained
17
+ (Pillow only - no model calls, no per-image cost). It pairs with
18
+ [agent goal-led design](../../references/agent-goal-design.md): design the agent,
19
+ then give it a spirit.
20
+
21
+ Follow the [Maggie Memory Hook](../../references/memory-hook.md) at invocation
22
+ and completion.
23
+
24
+ ## When to use
25
+
26
+ - You are standing up a multi-agent product (a C-suite of AI cofounders, a set
27
+ of Service Apps) and want a consistent avatar + logo + status-animation set.
28
+ - You need a logo mark that can show live state (idle, working, deploying,
29
+ success, warning, secure, ...) rather than a single static glyph.
30
+ - You need sprite sheets / animation strips + a `states.json` manifest to drive
31
+ a web UI (the CSS `steps()` technique), not a one-off illustration.
32
+
33
+ For page/layout design use `maggie-design`; for marketplace templates use
34
+ `maggie-marketplace`; this skill produces the brand's character assets only.
35
+
36
+ ## The 32-state base set
37
+
38
+ Five groups, fixed across every agent so severity and status read the same:
39
+
40
+ - **power:** off, booting, idle, active, paused, offline
41
+ - **work:** thinking, loading, syncing, writing, streaming, queued, scanning, deploying
42
+ - **comms:** speaking, listening, notification, mention, recording
43
+ - **outcome:** success, warning, critical, blocked, error, retrying
44
+ - **gov:** awaiting, approved, rejected, secure, vulnerable, maintenance, celebrating
45
+
46
+ Each state carries its own frame count, fps, motion and optional badge; every
47
+ state animates (none is static). The per-state `[frames, fps]` are written to
48
+ `states.json` beside the images so a UI can play each strip exactly.
49
+
50
+ ## CLI
51
+
52
+ Run through the bundled tool so it uses the current project as the working
53
+ directory:
54
+
55
+ ```bash
56
+ # logo mark + 32-state animated base set (anim-*.png, mark-*.svg, sprite-sheet.png, states.json)
57
+ maggie tool maggie_spirit.py mark --out public/assets/brand --name oneman \
58
+ --accent "#ff1212" --title ONEMAN
59
+
60
+ # one agent avatar animated through all 32 states (+ stills + sheet + states.json)
61
+ maggie tool maggie_spirit.py agent --out public/assets/brand --name cto \
62
+ --accent "#3b82f6" --spec cto.persona.json
63
+
64
+ # a single still avatar face
65
+ maggie tool maggie_spirit.py avatar --out public/assets/brand/cto/face.png \
66
+ --accent "#3b82f6" --spec cto.persona.json
67
+
68
+ # the reference base set to copy and recolour: default mark + example cast + founder
69
+ maggie tool maggie_spirit.py demo --out public/assets/brand
70
+ ```
71
+
72
+ `persona.json` (all keys optional except implied by your design):
73
+
74
+ ```json
75
+ {
76
+ "gender": "m",
77
+ "hair": "#2a2018",
78
+ "skin": "light | mid | tan | deep",
79
+ "hairstyle": "short|slick|spiky|buzz|longwavy|longstraight|longbob|ponytail",
80
+ "eyes": "round|wide|lash|happy",
81
+ "mouth": "flat|smile|smirk|lips|open",
82
+ "prop": "glasses|shades|cigar|headset|earbuds|monocle|pen|bandana",
83
+ "propcol": "#f2c75a",
84
+ "hoodie": true, "band": "#e23b3b", "mask": "#12201a", "specs": true
85
+ }
86
+ ```
87
+
88
+ ## Output contract
89
+
90
+ For each target (`<out>/<name>/`):
91
+
92
+ - `anim-<state>.png` - one horizontal strip per state (frames laid left to right).
93
+ - `still-<state>.png` - a representative still per state (agents only).
94
+ - `mark-<state>.svg` - a crisp SVG still per state (mark only).
95
+ - `sprite-sheet.png` - an overview contact sheet.
96
+ - `states.json` - `{ title, accent, N, scale, groups, states: { <state>: {group, frames, fps, motion, badge} } }`.
97
+
98
+ ## Driving the animation in a UI
99
+
100
+ Flat-pixel, pixel-exact. Use CSS `steps()` with the per-state frame count, never
101
+ percentage-position stepping:
102
+
103
+ ```css
104
+ .sprite{
105
+ --fw: 48px; /* one frame = one cell */
106
+ width: var(--fw); height: var(--fw);
107
+ background: url(/assets/brand/cto/anim-active.png) no-repeat;
108
+ background-size: calc(var(--n) * var(--fw)) var(--fw);
109
+ image-rendering: pixelated;
110
+ animation: play calc(var(--n) / var(--fps) * 1s) steps(var(--n)) infinite;
111
+ }
112
+ @keyframes play { to { background-position-x: calc(-1 * var(--n) * var(--fw)); } }
113
+ ```
114
+
115
+ Set `--n` and `--fps` per state from `states.json`. If you build the sprite
116
+ element with `innerHTML` in an Astro page, style it with `<style is:global>` -
117
+ Astro's scoped styles do not reach runtime-injected nodes (see `maggie-design`).
118
+
119
+ ## Boundaries
120
+
121
+ - Deterministic and local: no LLM/image-model calls and no per-image billing.
122
+ Pillow is the only dependency.
123
+ - The mark is the brand's own; recolour via `--accent`, keep the semantic
124
+ palette (severity colours) fixed so status stays legible across agents.
125
+ - Do not hand-edit generated files; change the persona/accent and regenerate so
126
+ the whole set stays consistent.
127
+ - Store generated art under the host project's assets; keep source personas
128
+ (the `*.persona.json`) in the repo so the set is reproducible.
@@ -38,6 +38,11 @@ def project_root(args: argparse.Namespace) -> Path:
38
38
 
39
39
 
40
40
  def store_for(root: Path) -> MaggieDashStore:
41
+ # ONEMAN forbids SQLite: when MAGGIEDASH_DATABASE_URL is set, back the store
42
+ # with Postgres. Otherwise fall back to the local SQLite dev store.
43
+ dsn = os.environ.get("MAGGIEDASH_DATABASE_URL")
44
+ if dsn:
45
+ return MaggieDashStore(dsn=dsn)
41
46
  return MaggieDashStore(root / ".maggie" / "maggiedash.sqlite")
42
47
 
43
48
 
@@ -642,6 +647,9 @@ def command_recommend(args: argparse.Namespace) -> int:
642
647
  def command_init(args: argparse.Namespace) -> int:
643
648
  require_confirm(args)
644
649
  root = project_root(args)
650
+ if str(getattr(args, "adapter", "")).endswith("postgres") and not os.environ.get("MAGGIEDASH_DATABASE_URL"):
651
+ print("POSTGRES_DSN_REQUIRED: adapter is postgres but MAGGIEDASH_DATABASE_URL is not set.", file=sys.stderr)
652
+ return 2
645
653
  store = store_for(root)
646
654
  try:
647
655
  project = store.init_project(args.project_id, args.name, args.adapter, args.locale)
@@ -652,9 +660,9 @@ def command_init(args: argparse.Namespace) -> int:
652
660
  "name": args.name,
653
661
  "adapter": args.adapter,
654
662
  "locale": args.locale,
655
- "storage": str(store.path),
663
+ "storage": store.storage_label,
656
664
  }, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
657
- emit({"status": "initialized", "project": project, "contract": str(contract), "storage": str(store.path)})
665
+ emit({"status": "initialized", "project": project, "contract": str(contract), "storage": store.storage_label})
658
666
  return 0
659
667
  finally:
660
668
  store.close()
@@ -667,7 +675,7 @@ def command_status(args: argparse.Namespace) -> int:
667
675
  try:
668
676
  project = store.connection.execute("SELECT * FROM maggiedash_projects WHERE id=?", (project_id,)).fetchone()
669
677
  documents = store.connection.execute("SELECT id, kind, title, slug, status, updated_at FROM maggiedash_documents WHERE project_id=? ORDER BY updated_at DESC", (project_id,)).fetchall()
670
- emit({"status": "ready" if project else "not_initialized", "project": dict(project) if project else None, "documents": [dict(row) for row in documents], "storage": str(store.path)})
678
+ emit({"status": "ready" if project else "not_initialized", "project": dict(project) if project else None, "documents": [dict(row) for row in documents], "storage": store.storage_label})
671
679
  return 0 if project else 1
672
680
  finally:
673
681
  store.close()
@@ -680,7 +688,7 @@ def command_migrate(args: argparse.Namespace) -> int:
680
688
  try:
681
689
  store.connection.execute("PRAGMA user_version = 1")
682
690
  store.connection.commit()
683
- emit({"status": "migrated", "schemaVersion": "maggiedash-storage.v1", "storage": str(store.path)})
691
+ emit({"status": "migrated", "schemaVersion": "maggiedash-storage.v1", "storage": store.storage_label})
684
692
  return 0
685
693
  finally:
686
694
  store.close()
@@ -31,7 +31,7 @@ import maggie_security_core as core # noqa: E402
31
31
  AREA_MODULES = [
32
32
  "maggie_security_intel", "maggie_security_deps", "maggie_security_code", "maggie_security_web",
33
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",
34
+ "maggie_security_standards", "maggie_security_agent", "maggie_security_report", "maggie_security_scan", "maggie_security_mcp", "maggie_security_hosted",
35
35
  ]
36
36
 
37
37
  # Commands from the PRDs that are not built yet, with their milestone, so the
@@ -535,6 +535,53 @@ def semgrep_to_findings(ctx: core.Context, data: dict, project: Path) -> list[di
535
535
  return findings
536
536
 
537
537
 
538
+ # Bearer-inspired privacy lens (Bearer itself is Elastic-licensed and cannot be run
539
+ # or bundled, so its detection *patterns* are reimplemented as native rules): flag a
540
+ # log/print call that puts personal data in the output — a GDPR hazard (CWE-532).
541
+ PII_LOG_CALL = re.compile(
542
+ r"(?i)(console\.(log|info|warn|error|debug)|[^\w.](print|println|printf)\s*\(|fmt\.(Print|Printf|Println|Sprintf)|"
543
+ r"System\.out\.print|var_dump|print_r|error_log|\b(logger|log|logging|logr|slog|console)\s*\.\s*"
544
+ r"(info|debug|warn|warning|error|trace|notice|log)\b)")
545
+ PII_TOKEN = re.compile(
546
+ r"(?i)\b(e[-_]?mail(?:address|_address)?|ssn|social[_\s-]?security|passport(?:[_\s-]?(?:no|number))?|"
547
+ r"credit[_\s-]?card|card[_\s-]?number|cardnum|cvv|cvc|iban|phone[_\s-]?number|date[_\s-]?of[_\s-]?birth|"
548
+ r"\bdob\b|national[_\s-]?id|tax[_\s-]?id|driver'?s?[_\s-]?licen[cs]e)\b")
549
+ PII_SOURCE_EXT = re.compile(r"(?i)\.(js|jsx|ts|tsx|mjs|cjs|py|php|rb|go|java|kt|cs|scala|swift|rs|vue|svelte|ex|exs)$")
550
+
551
+
552
+ def native_pii_checks(ctx: core.Context, files: list[Path], project: Path) -> list[dict]:
553
+ """Logging of personal data (GDPR / CWE-532) — one finding per file, lines as evidence."""
554
+ findings = []
555
+ for path in files:
556
+ if not PII_SOURCE_EXT.search(path.name):
557
+ continue
558
+ text = read_text(path)
559
+ if not text:
560
+ continue
561
+ hits = []
562
+ for number, line in enumerate(text.splitlines(), 1):
563
+ if len(line) > 400:
564
+ continue
565
+ if PII_LOG_CALL.search(line) and PII_TOKEN.search(line):
566
+ hits.append((number, line.strip()[:200]))
567
+ if len(hits) >= 10:
568
+ break
569
+ if not hits:
570
+ continue
571
+ rel = path.relative_to(project).as_posix()
572
+ findings.append(core.make_finding(
573
+ kind="code", command="code-scan", primary_id="maggie.privacy.pii-in-logs", site=ctx.site,
574
+ title=f"Personal data may be written to logs ({rel})", severity="low", severity_source="maggie",
575
+ confidence="possible", cwe_ids=["CWE-532"], asvs_ids=["V7.1.1"], target={"path": rel}, tool="maggie-native",
576
+ rule_id="maggie.privacy.pii-in-logs",
577
+ evidence=[{"path": rel, "line": n, "excerpt": core.clean_excerpt(ln, 200)} for n, ln in hits[:5]],
578
+ remediation={"summary": "Avoid logging personal data (email, SSN, card, phone, DOB…). Redact or omit it, or log an opaque id instead. "
579
+ "Logs are often shipped to third parties and retained — this is a GDPR exposure.",
580
+ "steps": [], "snippets": [], "references": ["https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html"],
581
+ "cheat_sheet": "Logging"}))
582
+ return findings
583
+
584
+
538
585
  def native_config_checks(ctx: core.Context, files: list[Path], project: Path) -> list[dict]:
539
586
  """Two checks semgrep expresses poorly: Dockerfile final stage without USER, nginx serving dotfiles."""
540
587
  findings = []
@@ -598,7 +645,8 @@ def code_scan(ctx: core.Context):
598
645
  else:
599
646
  targets = [str(project / p) for p in (args.paths or ["."])]
600
647
  files = [Path(t) for t in targets if Path(t).is_file()] + [f for t in targets if Path(t).is_dir() for f in text_files(Path(t), project)]
601
- findings = native_config_checks(ctx, [f for f in files if not any(part in SKIP_DIRS for part in f.relative_to(project).parts)], project)
648
+ in_scope = [f for f in files if not any(part in SKIP_DIRS for part in f.relative_to(project).parts)]
649
+ findings = native_config_checks(ctx, in_scope, project) + native_pii_checks(ctx, in_scope, project)
602
650
  with tempfile.TemporaryDirectory() as tmp:
603
651
  out = Path(tmp) / "semgrep.json"
604
652
  argv = ["scan", "--json", "--json-output", str(out), "--timeout", "30", "--max-target-bytes", "1000000", "--quiet"]
@@ -361,6 +361,99 @@ def image(ctx: core.Context):
361
361
  return ctx.finish(findings, covered=[f"image:{ref}"])
362
362
 
363
363
 
364
+ # --------------------------------------------------------------------------- image-patch (copa remediation plan)
365
+
366
+ # image-patch completes the safe-patching story for containers: `image` detects the
367
+ # CVEs, image-patch turns a Trivy report into a reviewed copa command that rebuilds
368
+ # the image with the OS-package fixes applied — into a NEW tag, so the original is
369
+ # untouched. Plan only (like fix-plan/protect-plan): Maggie never runs copa; the
370
+ # operator reviews it and runs it where Docker/buildkit is available.
371
+
372
+
373
+ def _patched_tag(ref: str, override: str | None) -> str:
374
+ if override:
375
+ return override
376
+ name, _, _tag = ref.rpartition(":")
377
+ return f"{name}:patched" if name and "/" not in _tag else f"{ref}-patched"
378
+
379
+
380
+ def image_patch(ctx: core.Context) -> dict:
381
+ import shlex
382
+
383
+ args = ctx.args
384
+ ref = args.ref
385
+ tag = _patched_tag(ref, args.tag)
386
+ pkg_types = args.pkg_types
387
+ report = args.report or "scan.json"
388
+
389
+ fixable = total = None
390
+ if args.report:
391
+ data = core.read_json(Path(args.report))
392
+ fixed_ids, all_ids = set(), set()
393
+ for result in data.get("Results") or []:
394
+ for vuln in result.get("Vulnerabilities") or []:
395
+ vid = vuln.get("VulnerabilityID")
396
+ all_ids.add(vid)
397
+ if vuln.get("FixedVersion"):
398
+ fixed_ids.add(vid)
399
+ fixable, total = len(fixed_ids), len(all_ids)
400
+
401
+ scan_cmd = f"trivy image --quiet --scanners vuln --pkg-types os --format json -o {shlex.quote(report)} {shlex.quote(ref)}"
402
+ patch_cmd = f"copa patch -i {shlex.quote(ref)} -r {shlex.quote(report)} --pkg-types {pkg_types} -t {shlex.quote(tag)}"
403
+ verify_cmd = f"trivy image --quiet {shlex.quote(tag)}"
404
+
405
+ sh = [
406
+ "#!/usr/bin/env bash",
407
+ f"# Container patch plan for {ref} — generated {core.iso()[:10]} by maggie security image-patch.",
408
+ "# REVIEW BEFORE RUNNING. Maggie did not and will not run any of this.",
409
+ "# copa rebuilds the image with OS-package fixes into a NEW tag; the original image is untouched.",
410
+ "# Prerequisites: copa (maggie security tools install --tool copa) and a running Docker/buildkit.",
411
+ "set -euo pipefail",
412
+ "",
413
+ "# 1. Scan the image to a report copa can read:" if not args.report else f"# 1. Using the provided report {report}.",
414
+ ]
415
+ if not args.report:
416
+ sh.append(scan_cmd)
417
+ sh += [
418
+ "# 2. Patch OS packages into a new tag (original image untouched):",
419
+ patch_cmd,
420
+ "# 3. Re-scan the patched image to confirm the fixes, then test it before you deploy:",
421
+ verify_cmd,
422
+ "",
423
+ ]
424
+ script = "\n".join(sh).rstrip() + "\n"
425
+ if args.script:
426
+ path = Path(args.script)
427
+ path.parent.mkdir(parents=True, exist_ok=True)
428
+ path.write_text(script, encoding="utf-8")
429
+ path.chmod(0o755)
430
+
431
+ md = [f"# Container patch plan — {ref}", "",
432
+ "**Review before running — Maggie never runs copa.** copa rebuilds the image with OS-package "
433
+ "fixes into a **new tag** (`" + tag + "`); the original image is untouched. Needs Docker/buildkit.", ""]
434
+ if fixable is not None:
435
+ md.append(f"The report lists **{fixable} fixable** OS-package CVE(s)" + (f" of {total} total." if total else ".") + "")
436
+ md.append("")
437
+ md += ["```bash", (scan_cmd + "\n" if not args.report else "") + patch_cmd + "\n" + verify_cmd, "```", "",
438
+ "After patching: re-scan with `maggie security image --ref " + tag + "` and test the image before deploying."]
439
+ runbook = "\n".join(md)
440
+
441
+ return {"schema": "maggie-security-image-patch.v1", "ref": ref, "patched_tag": tag, "pkg_types": pkg_types,
442
+ "fixable": fixable, "total": total, "commands": {"scan": scan_cmd, "patch": patch_cmd, "verify": verify_cmd},
443
+ "script_path": str(args.script) if args.script else None,
444
+ "note": "Plan only. Maggie never runs copa; it rebuilds into a new tag, leaving the original image untouched.",
445
+ "markdown": runbook}
446
+
447
+
448
+ def _image_patch(p: argparse.ArgumentParser) -> None:
449
+ p.add_argument("ref", help="the image to patch, e.g. myapp:1.0")
450
+ p.add_argument("--report", help="a Trivy JSON report for the image (else the plan generates one with trivy)")
451
+ p.add_argument("--tag", help="output tag for the patched image (default <ref>-patched)")
452
+ p.add_argument("--pkg-types", choices=["os", "os,library"], default="os", dest="pkg_types",
453
+ help="os (default) or os,library to also patch application dependencies")
454
+ p.add_argument("--script", help="write the runnable plan to this path (chmod 755)")
455
+
456
+
364
457
  # --------------------------------------------------------------------------- preinstall (vet a package before installing it)
365
458
 
366
459
 
@@ -554,6 +647,8 @@ COMMANDS = [core.Command("deps", "known vulnerabilities in dependencies (every l
554
647
  ["osv-api", "retire-jsrepo", "kev", "epss"], configure, deps),
555
648
  core.Command("sbom", "CycloneDX SBOM of the project (or an image with syft)", [], _sbom, sbom, findings=False),
556
649
  core.Command("image", "container image vulnerabilities, secrets and best practices (trivy + dockle)", ["kev", "github-releases"], _image, image),
650
+ core.Command("image-patch", "a reviewed copa plan to rebuild a container image with OS-package fixes into a new tag; never applied by Maggie",
651
+ [], _image_patch, image_patch, findings=False, routine_safe=False),
557
652
  core.Command("preinstall", "vet an npm package before installing it (age, install scripts, typosquats, malware, vulnerabilities, Scorecard)",
558
653
  ["npm-registry", "osv-api", "scorecard", "kev", "epss"], _preinstall, preinstall, findings=False),
559
654
  core.Command("exposure-check", "known malicious packages already installed (OSV MAL- records and incident catalogs)", ["osv-api", "kev"],
@@ -95,11 +95,29 @@ SCHEMAS: dict[str, dict] = {
95
95
  "id": {"type": "string", "pattern": r"^msf_[0-9a-f]{24}$"}, "action": {"enum": ["accept", "false-positive", "reopen"]},
96
96
  "reason": {"type": "string", "maxLength": 300}, "until": {"type": "string", "pattern": r"^\d{4}-\d{2}-\d{2}$"},
97
97
  "confirm": {"type": "boolean"}, "confirmation": {"type": "string", "maxLength": 80}}},
98
+ "security_image_patch": {"type": "object", "required": ["ref"], "additionalProperties": False, "properties": {
99
+ "ref": {"type": "string", "pattern": r"^[a-z0-9][a-z0-9._/:-]*$", "description": "the image to patch, e.g. myapp:1.0"},
100
+ "report": {"type": "string", "maxLength": 300, "description": "a Trivy JSON report inside the project"},
101
+ "tag": {"type": "string", "maxLength": 200}, "pkg_types": {"enum": ["os", "os,library"]}}},
102
+ "security_scan": {"type": "object", "additionalProperties": False, "properties": {
103
+ "profile": {"type": "string", "maxLength": 20, "description": "override the detected site profile, e.g. wordpress, node-app"},
104
+ "domain": {"type": "string", "pattern": DOMAIN, "description": "verified domain for the web checks (headers, tls)"},
105
+ "kind": {"enum": ["weekly", "daily_ops"]}, "fix_plan": {"type": "boolean"}}},
106
+ "security_node_check": {"type": "object", "additionalProperties": False, "properties": {
107
+ "node_version": {"type": "string", "pattern": r"^\d+(\.\d+){0,2}$", "description": "Node.js version (default: the versions in the inventory)"}}},
108
+ "security_fix_plan": {"type": "object", "additionalProperties": False, "properties": {
109
+ "priority": {"enum": ["p0", "p1", "p2", "p3", "p4"], "description": "only include findings at or above this priority"},
110
+ "since": SINCE}},
111
+ "security_incident": {"type": "object", "required": ["action"], "additionalProperties": False, "properties": {
112
+ "action": {"enum": ["start", "log", "close"]}, "id": {"type": "string", "maxLength": 80},
113
+ "title": {"type": "string", "maxLength": 200}, "cve": {"type": "string", "maxLength": 40},
114
+ "finding": {"type": "string", "pattern": r"^msf_[0-9a-f]{24}$"}, "event": {"type": "string", "maxLength": 40},
115
+ "note": {"type": "string", "maxLength": 300}, "confirm": {"type": "boolean"}, "confirmation": {"type": "string", "maxLength": 80}}},
98
116
  }
99
117
  NETWORK_TOOLS = {"security_vuln_watch", "security_osv_query", "security_wp_check", "security_eol_check", "security_deps_scan",
100
118
  "security_domain_verify_check", "security_headers_check", "security_tls_check", "security_web_scan", "security_public_check",
101
119
  "security_uptime_status",
102
- "security_checklist_get", "security_package_vet", "security_exposure_check", "security_image_scan"}
120
+ "security_checklist_get", "security_package_vet", "security_exposure_check", "security_image_scan", "security_node_check", "security_scan"}
103
121
 
104
122
 
105
123
  def registry_path() -> Path:
@@ -269,6 +287,54 @@ class Server:
269
287
  return ["image", args.get("ref", "x")]
270
288
  if name == "security_sbom_build":
271
289
  return ["sbom"]
290
+ if name == "security_image_patch":
291
+ argv = ["image-patch"]
292
+ if dry:
293
+ return argv
294
+ argv.append(args["ref"])
295
+ if args.get("report"):
296
+ argv += ["--report", self.safe_log_path(args["report"])]
297
+ if args.get("tag"):
298
+ argv += ["--tag", args["tag"]]
299
+ if args.get("pkg_types"):
300
+ argv += ["--pkg-types", args["pkg_types"]]
301
+ return argv
302
+ if name == "security_scan":
303
+ argv = ["scan"]
304
+ if dry:
305
+ return argv
306
+ if args.get("profile"):
307
+ argv += ["--profile", args["profile"]]
308
+ if args.get("domain"):
309
+ argv += ["--domain", args["domain"]]
310
+ if args.get("kind"):
311
+ argv += ["--kind", args["kind"]]
312
+ if args.get("fix_plan"):
313
+ argv += ["--fix-plan"]
314
+ return argv
315
+ if name == "security_node_check":
316
+ argv = ["node-check"]
317
+ if not dry and args.get("node_version"):
318
+ argv += ["--node-version", args["node_version"]]
319
+ return argv
320
+ if name == "security_fix_plan":
321
+ argv = ["fix-plan"]
322
+ if dry:
323
+ return argv
324
+ if args.get("priority"):
325
+ argv += ["--priority", args["priority"]]
326
+ if args.get("since"):
327
+ argv += ["--since", args["since"]]
328
+ return argv
329
+ if name == "security_incident":
330
+ if dry:
331
+ return ["incident"]
332
+ argv = ["incident", args["action"]]
333
+ for key, flag in (("id", "--id"), ("title", "--title"), ("cve", "--cve"), ("finding", "--finding"),
334
+ ("event", "--event"), ("note", "--note")):
335
+ if args.get(key):
336
+ argv += [flag, args[key]]
337
+ return argv
272
338
  if name == "security_report_build":
273
339
  return ["report", "--kind", args.get("kind", "weekly"), "--audience", args.get("audience", "owner")]
274
340
  if name == "security_ledger_mark":
@@ -161,6 +161,32 @@ def why(finding: dict) -> str:
161
161
  return f"{severity} known flaw (CVE) affects the version you run."
162
162
 
163
163
 
164
+ # SSVC (CISA Stakeholder-Specific Vulnerability Categorization) gives each finding a
165
+ # defensible "do it now vs watch it" decision from signals we already have, rather
166
+ # than a bare severity number. A faithful simplification of the deployer tree:
167
+ # Exploitation (KEV→active, EPSS→likely) × Automatable (EPSS/critical) × Impact (severity).
168
+ SSVC_HELP = {"Act": "fix now", "Attend": "fix soon (this cycle)", "Track*": "watch closely", "Track": "routine"}
169
+
170
+
171
+ def ssvc_decision(finding: dict) -> dict:
172
+ kev = bool((finding.get("kev") or {}).get("listed"))
173
+ epss = (finding.get("epss") or {}).get("score") or 0
174
+ severity = finding.get("severity", "medium")
175
+ exploitation = "active" if kev else ("likely" if epss >= 0.1 else "none")
176
+ high_impact = severity in ("critical", "high")
177
+ automatable = epss >= 0.1 or severity == "critical"
178
+ if exploitation == "active":
179
+ decision = "Act" if high_impact else "Attend"
180
+ elif automatable and high_impact:
181
+ decision = "Attend"
182
+ elif high_impact or automatable:
183
+ decision = "Track*"
184
+ else:
185
+ decision = "Track"
186
+ return {"decision": decision, "exploitation": exploitation,
187
+ "automatable": bool(automatable), "impact": "high" if high_impact else "partial"}
188
+
189
+
164
190
  def how(finding: dict) -> str:
165
191
  steps = (finding.get("remediation") or {}).get("steps") or []
166
192
  step = next((s for s in steps if not s.startswith("Listed by CISA")), None)
@@ -298,7 +324,8 @@ def build(ctx: core.Context) -> dict:
298
324
  "verdict": verdict_info,
299
325
  "counts": {"new": sum(1 for f in findings if f["status"] == "new"), "open": sum(1 for f in findings if f["status"] == "open"),
300
326
  "fixed": len(fixed_ids), "by_priority": {p: sum(1 for f in actionable if f["priority"] == p) for p in core.PRIORITIES}},
301
- "actions": [{"finding_id": a["ids"][0], "finding_ids": a["ids"], "priority": a["priority"], "title": a["title"]}
327
+ "actions": [{"finding_id": a["ids"][0], "finding_ids": a["ids"], "priority": a["priority"], "title": a["title"],
328
+ "ssvc": ssvc_decision(a["lead"])["decision"]}
302
329
  for a in group_actions(actions)[:5]],
303
330
  "coverage": None,
304
331
  "checked": checked, "not_checked": missing, "degraded_sources": degraded,
@@ -401,10 +428,12 @@ def render(data: dict, findings: list[dict], audience: str, site: str, clip: boo
401
428
  lines.append(f" How: {action['how']}")
402
429
  if not owner:
403
430
  lead = action["lead"]
431
+ ssvc = ssvc_decision(lead)
404
432
  lines.append(f" {action['count']} finding(s): " + ", ".join(f"`{i}`" for i in action["ids"][:6])
405
433
  + (" …" if len(action["ids"]) > 6 else "") + f" · {', '.join((lead.get('aliases') or [])[:3])}"
406
434
  + (f" · CVSS {lead['cvss']['score']}" if lead.get("cvss") else "")
407
- + (f" · EPSS {lead['epss']['score']:.2f}" if lead.get("epss") else ""))
435
+ + (f" · EPSS {lead['epss']['score']:.2f}" if lead.get("epss") else "")
436
+ + f" · SSVC {ssvc['decision']} ({SSVC_HELP[ssvc['decision']]})")
408
437
  if len(urgent_actions) > 5:
409
438
  lines.append(f"\n…and {len(urgent_actions) - 5} more below.")
410
439
  lines.append("")