penthera 1.4.0 → 1.6.0

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 (114) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +148 -575
  3. package/bin/scan.js +19 -0
  4. package/docs/commands/README.md +23 -0
  5. package/docs/commands/action.md +21 -0
  6. package/docs/commands/agents.md +38 -0
  7. package/docs/commands/deps.md +115 -0
  8. package/docs/commands/fix.md +53 -0
  9. package/docs/commands/hook.md +69 -0
  10. package/docs/commands/install.md +53 -0
  11. package/docs/commands/scan.md +100 -0
  12. package/docs/commands/skill.md +58 -0
  13. package/docs/compare/README.md +25 -0
  14. package/docs/compare/github.md +60 -0
  15. package/docs/compare/semgrep.md +54 -0
  16. package/docs/compare/snyk.md +62 -0
  17. package/docs/compare/socket.md +78 -0
  18. package/docs/compare/zap.md +59 -0
  19. package/docs/compliance-cra.md +141 -0
  20. package/docs/compliance-nis2.md +3 -1
  21. package/docs/dashboard.md +244 -0
  22. package/docs/evidence.md +8 -4
  23. package/docs/examples/github-actions-deps.yml +4 -4
  24. package/docs/examples/github-actions-scan.yml +4 -4
  25. package/docs/index.md +11 -2
  26. package/docs/internal-audit.md +83 -0
  27. package/docs/marketing.md +151 -0
  28. package/docs/plan.md +101 -0
  29. package/docs/privacy.md +38 -0
  30. package/docs/references.bib +10 -0
  31. package/docs/references.md +1 -0
  32. package/docs/research/socket-positioning-notes.md +183 -0
  33. package/docs/use-cases/README.md +14 -0
  34. package/docs/use-cases/agencies.md +71 -0
  35. package/docs/use-cases/ai-coding-teams.md +81 -0
  36. package/docs/use-cases/eu-sme-compliance.md +68 -0
  37. package/docs/use-cases/open-source-maintainers.md +75 -0
  38. package/docs/use-cases/solo-founder.md +65 -0
  39. package/lib/blackbox/email-dns.js +5 -0
  40. package/lib/compliance/cra.js +525 -0
  41. package/lib/compliance/questionnaire.js +4 -2
  42. package/lib/deps/capabilities.js +49 -12
  43. package/lib/deps/index.js +314 -30
  44. package/lib/deps/license.js +329 -0
  45. package/lib/deps/lockfile.js +29 -3
  46. package/lib/deps/popular-pypi.json +1 -0
  47. package/lib/deps/python/capabilities.js +447 -0
  48. package/lib/deps/python/index.js +234 -0
  49. package/lib/deps/python/lockfiles.js +359 -0
  50. package/lib/deps/python/source.js +396 -0
  51. package/lib/deps/python/toml.js +219 -0
  52. package/lib/deps/report.js +54 -6
  53. package/lib/deps/source.js +149 -24
  54. package/lib/deps/spdx.json +1 -0
  55. package/lib/deps/typosquat.js +24 -13
  56. package/lib/fix/apply.js +83 -19
  57. package/lib/hook/index.js +345 -0
  58. package/lib/hook/policy.js +62 -0
  59. package/lib/install/index.js +59 -18
  60. package/lib/install/policy.js +27 -7
  61. package/lib/jurisdictions/nl.json +9 -0
  62. package/lib/memory/repo-suppress.js +11 -3
  63. package/lib/references.json +77 -0
  64. package/lib/remediation/index.js +9 -0
  65. package/lib/retirejs.js +69 -17
  66. package/lib/rules/regex-guard.js +368 -0
  67. package/lib/templates.js +13 -6
  68. package/lib/tls.js +7 -0
  69. package/lib/whitebox/aibom.js +384 -0
  70. package/lib/whitebox/frameworks.js +8 -5
  71. package/lib/whitebox/sbom.js +45 -10
  72. package/lib/whitebox/secrets.js +31 -8
  73. package/package.json +8 -3
  74. package/skills/penthera/SKILL.md +55 -8
  75. package/src/cli/deps.js +81 -6
  76. package/src/cli/evidence.js +14 -4
  77. package/src/cli/hook.js +131 -0
  78. package/src/cli/install.js +14 -5
  79. package/src/cli/report-outputs.js +25 -2
  80. package/src/cli/ui.js +89 -0
  81. package/src/cli.js +81 -11
  82. package/src/report/audience.js +10 -10
  83. package/src/report/evidence.js +33 -14
  84. package/src/report/footer.js +11 -0
  85. package/src/report/markdown.js +6 -3
  86. package/src/report/md-escape.js +16 -0
  87. package/src/reporter.js +1 -0
  88. package/src/scan-url.js +5 -1
  89. package/src/ui/api/data.js +309 -0
  90. package/src/ui/api/demo.js +464 -0
  91. package/src/ui/api/routes.js +134 -0
  92. package/src/ui/api/runs.js +243 -0
  93. package/src/ui/api/summarize.js +451 -0
  94. package/src/ui/security.js +153 -0
  95. package/src/ui/server.js +186 -0
  96. package/src/ui/static/app.css +723 -0
  97. package/src/ui/static/favicon.svg +1 -0
  98. package/src/ui/static/index.html +57 -0
  99. package/src/ui/static/js/api.js +29 -0
  100. package/src/ui/static/js/app.js +138 -0
  101. package/src/ui/static/js/charts.js +207 -0
  102. package/src/ui/static/js/copy.js +79 -0
  103. package/src/ui/static/js/dom.js +120 -0
  104. package/src/ui/static/js/format.js +42 -0
  105. package/src/ui/static/js/theme.js +10 -0
  106. package/src/ui/static/js/typer.js +99 -0
  107. package/src/ui/static/js/views/ai.js +68 -0
  108. package/src/ui/static/js/views/deps.js +96 -0
  109. package/src/ui/static/js/views/evidence.js +115 -0
  110. package/src/ui/static/js/views/findings.js +165 -0
  111. package/src/ui/static/js/views/history.js +92 -0
  112. package/src/ui/static/js/views/overview.js +184 -0
  113. package/src/ui/static/js/views/run.js +349 -0
  114. package/src/ui/static/js/views/settings.js +67 -0
@@ -0,0 +1,92 @@
1
+ import { h, put, panel, clear, empty, sevBadge, fig, whatNext } from "../dom.js";
2
+ import { trendChart, ganttChart } from "../charts.js";
3
+ import { stamp, ago, shortProject } from "../format.js";
4
+ import { SCREENS, EMPTY } from "../copy.js";
5
+
6
+ export async function render(root, { api, params, go }) {
7
+ const o = await api.get("/api/overview");
8
+ put(root, h("h1", {}, SCREENS.history.title), h("p", { class: "lede" }, SCREENS.history.lede));
9
+ if (!o.projects.length) { put(root, h("div", { class: "notice" }, empty(EMPTY.history))); return; }
10
+
11
+ const keys = o.projects.map((p) => p.key);
12
+ // Default to the project whose last two scans differ most: that is the interesting comparison.
13
+ const delta = (p) => (p.trend.length > 1 ? Math.abs(p.trend[p.trend.length - 1].total - p.trend[p.trend.length - 2].total) + 0.5 : 0);
14
+ let project = keys.includes(params.get("project")) ? params.get("project") : [...o.projects].sort((x, y) => delta(y) - delta(x))[0].key;
15
+ const projSel = h("select", { "aria-label": "Project" }, keys.map((k) => h("option", { value: k, selected: k === project }, shortProject(k))));
16
+ const aSel = h("select", { "aria-label": "Before" });
17
+ const bSel = h("select", { "aria-label": "After" });
18
+ const body = h("div");
19
+ put(root,
20
+ h("div", { class: "filters" },
21
+ h("label", { class: "field" }, h("span", {}, "Project"), projSel),
22
+ h("label", { class: "field" }, h("span", {}, "Before"), aSel),
23
+ h("label", { class: "field" }, h("span", {}, "After"), bSel),
24
+ ),
25
+ body,
26
+ );
27
+
28
+ let seq = 0;
29
+ async function load(a = null, b = null) {
30
+ const n = ++seq;
31
+ const q = new URLSearchParams({ project });
32
+ if (a != null) q.set("a", a);
33
+ if (b != null) q.set("b", b);
34
+ let hst;
35
+ try { hst = await api.get(`/api/history?${q}`); } catch (e) { clear(body).append(h("p", { class: "error" }, e.message)); return; }
36
+ if (n !== seq) return;
37
+ const opts = (sel, cur) => clear(sel).append(...hst.scans.map((sc) => h("option", { value: String(sc.i), selected: sc.i === cur.i }, `#${sc.i + 1} · ${stamp(sc.ts).slice(5)} · ${sc.open} open`)));
38
+ opts(aSel, hst.a);
39
+ opts(bSel, hst.b);
40
+ const one = hst.scans.length < 2;
41
+ clear(body).append(
42
+ one ? h("p", { class: "notice" }, EMPTY.history) : null,
43
+ h("div", { class: "strip" },
44
+ cell("Before", String(hst.a.open), `${stamp(hst.a.ts)} :: ${ago(hst.a.ts)}`),
45
+ cell("After", String(hst.b.open), `${stamp(hst.b.ts)} :: ${ago(hst.b.ts)}`),
46
+ cell("New", String(hst.new.length), "in after, not in before"),
47
+ cell("Fixed", String(hst.fixed.length), "in before, gone after", "ok"),
48
+ ),
49
+ panel(`Between scan ${hst.a.i + 1} and scan ${hst.b.i + 1}`, { meta: `${stamp(hst.b.ts)}` },
50
+ h("div", { class: "cols" },
51
+ col(`New · ${hst.new.length}`, hst.new, "Nothing new."),
52
+ col(`Fixed · ${hst.fixed.length}`, hst.fixed, "Nothing fixed between these two scans."),
53
+ col(`Still open · ${hst.still.length}`, hst.still, "Nothing carried over."),
54
+ )),
55
+ h("div", { class: "grid stack" }, panel("Timeline", { class: "span-12", meta: `${hst.lifetimes.filter((l) => l.open).length} open · ${hst.lifetimes.filter((l) => !l.open).length} fixed` },
56
+ h("div", { class: "table-wrap" }, ganttChart(hst.lifetimes, hst.scans)),
57
+ h("div", { class: "legend" },
58
+ h("span", {}, "▨ open, up to now"), h("span", {}, "█ open and critical"), h("span", {}, "□ fixed: first seen to last seen"), h("span", {}, "┊ a scan"),
59
+ ),
60
+ fig(`fig 1 :: every finding of ${shortProject(project)} across ${hst.scans.length} scans`, "from the reports folder"),
61
+ ),
62
+ panel("Open findings per scan", { class: "span-12", meta: `${hst.scans.length} scans` },
63
+ trendChart(hst.scans.map((sc) => ({ ts: sc.ts, total: sc.open, severe: sc.counts.critical + sc.counts.high })), { width: 1100, height: 200 }),
64
+ fig(`fig 2 :: ${shortProject(project)}, every scan in the reports folder`, `${hst.scans.length} scans`),
65
+ )),
66
+ whatNext([
67
+ { label: "Read what is still open", go: ["findings", { project }] },
68
+ { label: "Fix it with the agent", why: "then compare again", go: ["run", { kind: "loop", repo: project.startsWith("/") ? project : "" }] },
69
+ { label: "Create evidence for an auditor", go: ["evidence"] },
70
+ ], go),
71
+ );
72
+ }
73
+
74
+ projSel.addEventListener("change", () => { project = projSel.value; load(); });
75
+ aSel.addEventListener("change", () => load(Number(aSel.value), Number(bSel.value)));
76
+ bSel.addEventListener("change", () => load(Number(aSel.value), Number(bSel.value)));
77
+ await load();
78
+
79
+ function col(title, rows, none) {
80
+ return h("div", {}, h("h3", {}, title),
81
+ rows.length ? h("ul", { class: "rows" }, rows.map((r) => h("li", {}, h("span", { class: "code" }, r.severity.slice(0, 1).toUpperCase() + "+"), h("span", { class: ["t", r.severity === "critical" || r.severity === "high" ? "b" : null] }, r.title)))) : empty(none));
82
+ }
83
+
84
+ function list(title, rows, none) {
85
+ return panel(`${title} (${rows.length})`, {},
86
+ rows.length ? h("ul", { class: "list" }, rows.map((r) => h("li", {}, sevBadge(r.severity), h("span", { class: "grow t1" }, r.title)))) : empty(none));
87
+ }
88
+ }
89
+
90
+ function cell(label, value, sub, cls) {
91
+ return h("div", { class: "cell" }, h("span", { class: "label" }, label), h("div", { class: ["big", cls] }, value), h("div", { class: "sub" }, sub));
92
+ }
@@ -0,0 +1,184 @@
1
+ import { h, put, panel, empty, subwin, whatNext, fig } from "../dom.js";
2
+ import { severityBars, stackedBar, sparkline, trendChart, columns, wordmark } from "../charts.js";
3
+ import { ago, duration, shortProject, SEVERITIES, plural } from "../format.js";
4
+ import { SCREENS, START, NEXT, TAGLINE } from "../copy.js";
5
+
6
+ /** Merge per-project trends into one series by scan index from the end. */
7
+ function combinedTrend(projects) {
8
+ const len = Math.max(0, ...projects.map((p) => p.trend.length));
9
+ const out = [];
10
+ for (let i = 0; i < len; i++) {
11
+ let total = 0;
12
+ let severe = 0;
13
+ let ts = null;
14
+ for (const p of projects) {
15
+ const t = p.trend[p.trend.length - len + i] || p.trend[0];
16
+ if (!t) continue;
17
+ total += t.total;
18
+ severe += (t.critical || 0) + (t.high || 0);
19
+ if (!ts || t.ts > ts) ts = t.ts;
20
+ }
21
+ out.push({ ts, total, severe });
22
+ }
23
+ return out;
24
+ }
25
+
26
+ function about(meta, o) {
27
+ const facts = o.projects.length
28
+ ? [["projects", String(o.projects.length)], ["open", String(o.open)], ["fixed", String(o.fixed)], ["network", "none"]]
29
+ : [["projects", "0"], ["network", "none"], ["telemetry", "none"]];
30
+ return subwin("About penthera", { class: "about" },
31
+ h("div", { class: "subwin-body" },
32
+ wordmark(),
33
+ h("p", { class: "sr" }, "Penthera"),
34
+ h("div", { class: "tagline" }, TAGLINE),
35
+ h("div", { class: "facts" },
36
+ h("span", {}, `v${meta.version}`),
37
+ facts.map(([k, v]) => h("span", {}, `${k} `, h("b", {}, v))),
38
+ h("span", {}, meta.demo ? "demo data" : "reports from this machine"),
39
+ ),
40
+ ));
41
+ }
42
+
43
+ function startHere(meta, go) {
44
+ const folder = meta.projects?.[0] || "";
45
+ return h("section", { class: "steps", "aria-labelledby": "start-h" },
46
+ h("div", {},
47
+ h("h1", { id: "start-h" }, START.welcome),
48
+ h("p", { class: "lede" }, START.intro),
49
+ h("p", { class: "muted" }, START.gate),
50
+ ),
51
+ START.steps.map((st, i) => h("div", { class: "step" },
52
+ h("h3", {}, h("span", { class: "n" }, `Step ${i + 1}`), st.title),
53
+ h("p", { class: "muted" }, st.body),
54
+ h("button", { type: "button", class: i === 0 ? "btn primary" : "btn", onclick: () => go("run", { kind: st.kind, repo: folder }) }, `${st.action} ▸`),
55
+ )),
56
+ h("p", { class: "muted" }, `${START.demo} `, h("code", {}, "penthera ui --demo"), "."),
57
+ );
58
+ }
59
+
60
+ export async function render(root, { api, go, meta }) {
61
+ const o = await api.get("/api/overview");
62
+ put(root, about(meta, o));
63
+
64
+ if (!o.projects.length) {
65
+ put(root, startHere(meta, go));
66
+ return;
67
+ }
68
+
69
+ const total = SEVERITIES.reduce((n, s) => n + o.mix[s], 0);
70
+ const severe = o.mix.critical + o.mix.high;
71
+ put(root,
72
+ h("h1", {}, SCREENS.overview.title),
73
+ h("p", { class: "lede" }, SCREENS.overview.lede),
74
+ h("div", { class: "strip" },
75
+ cell("Open findings", String(o.open), `${severe} critical or high`),
76
+ cell("Fixed", String(o.fixed), "gone in a later scan, or verified by the loop", "ok"),
77
+ cell("Median time to fix", o.timeToFix.medianDays == null ? "-" : String(o.timeToFix.medianDays), plural(o.timeToFix.samples, "fix measured", "fixes measured"), null, o.timeToFix.medianDays == null ? null : "days"),
78
+ cell("Hosts contacted", String(o.egress.hosts.length), `${o.egress.blocked.length} blocked by offline mode`),
79
+ ),
80
+ );
81
+
82
+ const c = o.columns;
83
+ const rowsOf = (list, none) => (list.length
84
+ ? h("ul", { class: "rows" }, list.slice(0, 8).map((r) => h("li", {},
85
+ h("span", { class: "code" }, `${r.severity.slice(0, 1).toUpperCase()}+`),
86
+ h("button", { type: "button", class: ["row-btn", "t", r.severity === "critical" || r.severity === "high" ? "b" : null], onclick: () => go("findings", { project: r.project, id: r.id }) },
87
+ r.title, h("span", { class: "where" }, ` · ${shortProject(r.project)}`)))))
88
+ : empty(none));
89
+ put(root, panel("This week", { class: "week", meta: new Date().toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" }) },
90
+ h("div", { class: "cols" },
91
+ h("div", {}, h("h3", {}, `Fix first · ${c.fixFirst.length}`), rowsOf(c.fixFirst, "Nothing critical or high. Good.")),
92
+ h("div", {}, h("h3", {}, `New since the last scan · ${c.newest.length}`), rowsOf(c.newest, "Nothing new since the last scan.")),
93
+ h("div", {}, h("h3", {}, `Fixed since the last scan · ${c.fixed.length}`), rowsOf(c.fixed, "Nothing fixed since the last scan yet.")),
94
+ )));
95
+
96
+ const grid = h("div", { class: "grid" });
97
+ put(root, grid);
98
+
99
+ const rows = o.projects.map((p) => h("tr", {},
100
+ h("td", {}, h("span", { class: `grade grade-${p.grade}`, title: `score ${p.score}/100` }, p.grade)),
101
+ h("td", {},
102
+ h("button", { class: "row-btn trunc", type: "button", title: p.key, onclick: () => go("findings", { project: p.key }) }, shortProject(p.key)),
103
+ h("span", { class: "faint" }, p.mode || "")),
104
+ h("td", { class: "hide-narrow" }, stackedBar(p.counts, { width: 14 })),
105
+ h("td", {}, sparkline(p.trend.map((t) => t.total), { label: `open findings, ${shortProject(p.key)}` })),
106
+ h("td", { class: "num t1" }, String(p.open)),
107
+ h("td", { class: "num ok" }, String(p.fixed)),
108
+ h("td", { class: "num muted hide-narrow" }, h("button", { type: "button", class: "row-btn", onclick: () => go("history", { project: p.key }) }, ago(p.lastScan))),
109
+ ));
110
+ put(grid, panel("Posture per project", { class: "span-12", meta: plural(o.projects.length, "project") },
111
+ h("div", { class: "table-wrap" }, h("table", {},
112
+ h("thead", {}, h("tr", {}, ...["Grade", "Project", "Severity", "Trend", "Open", "Fixed", "Last scan"].map((t, i) => h("th", { class: [i === 4 || i === 5 || i === 6 ? "num" : null, i === 2 || i === 6 ? "hide-narrow" : null] }, t)))),
113
+ h("tbody", {}, rows),
114
+ )),
115
+ fig("fig 1 :: grade = 100 − 30/critical − 15/high − 5/medium − 1/low; any critical is an F", `${o.projects.length} projects`),
116
+ ));
117
+
118
+ put(grid, panel("Severity mix", { class: "span-5", meta: `${total} findings` },
119
+ stackedBar(o.mix, { width: 34 }),
120
+ severityBars(o.mix, { width: 18 }),
121
+ fig("fig 2 :: latest report per project", `${total} findings`),
122
+ ));
123
+
124
+ put(grid, panel("Findings over time", { class: "span-7", meta: "by scan" },
125
+ trendChart(combinedTrend(o.projects)),
126
+ h("div", { class: "legend" },
127
+ h("span", {}, h("span", { class: "sw" }), "open (critical to low)"),
128
+ h("span", {}, h("span", { class: "sw sw-severe" }), "critical + high"),
129
+ ),
130
+ fig("fig 3 :: summed across projects, by scan", "from reports + memory"),
131
+ ));
132
+
133
+ put(grid, panel("Time to fix", { class: "span-4", meta: o.timeToFix.medianDays == null ? "no data" : `median ${o.timeToFix.medianDays}d` },
134
+ columns(o.timeToFix.buckets, { label: "Fixes by time to fix" }),
135
+ fig("fig 4 :: first seen to gone", `${o.timeToFix.samples} fixes`),
136
+ ));
137
+
138
+ put(grid, panel("Hosts contacted", { class: "span-4", meta: `${o.egress.hosts.length} hosts` },
139
+ o.egress.hosts.length
140
+ ? h("ul", { class: "list" }, o.egress.hosts.map((x) => h("li", {},
141
+ h("span", { class: x.thirdParty ? "t1" : "ok" }, x.thirdParty ? "3P" : "OK"),
142
+ h("span", { class: "grow trunc t1", title: x.host }, x.host),
143
+ h("span", { class: "num muted" }, String(x.requests)),
144
+ )))
145
+ : empty("No requests recorded."),
146
+ o.egress.blocked.length ? h("div", {},
147
+ h("p", { class: "label" }, "Blocked (offline)"),
148
+ h("ul", { class: "list" }, o.egress.blocked.map((x) => h("li", {}, h("span", { class: "muted" }, "--"), h("span", { class: "grow trunc" }, x.host), h("span", { class: "num muted" }, String(x.requests))))),
149
+ ) : null,
150
+ fig("fig 5 :: latest scan per project", "3P = third party"),
151
+ ));
152
+
153
+ put(grid, panel("Last scans", { class: "span-4", meta: `${o.lastScans.length} newest` },
154
+ h("ul", { class: "list" }, o.lastScans.map((sc) => h("li", {},
155
+ h("span", { class: "muted" }, ago(sc.ts).padStart(7)),
156
+ h("span", { class: "grow trunc t1", title: sc.target }, shortProject(sc.target)),
157
+ worst(sc.counts),
158
+ h("span", { class: "num muted hide-narrow" }, duration(sc.durationMs)),
159
+ ))),
160
+ ));
161
+
162
+ if (o.loops.length) {
163
+ put(grid, panel("Agent loop runs", { class: "span-12", meta: plural(o.loops.length, "run") },
164
+ h("ul", { class: "list" }, o.loops.map((l) => h("li", {},
165
+ h("span", { class: "muted" }, ago(l.startedAt)),
166
+ h("span", { class: "grow t1" }, `${shortProject(l.target.repo)} :: ${l.findings.initial ?? "?"} → ${l.findings.final ?? "?"} findings`),
167
+ h("span", { class: "ok" }, `${l.totals.verified} verified`),
168
+ l.totals.failed ? h("span", { class: "bad" }, `${l.totals.failed} reverted`) : null,
169
+ h("span", { class: "muted hide-narrow" }, l.branch || ""),
170
+ ))),
171
+ ));
172
+ }
173
+
174
+ put(root, whatNext(NEXT.overview, go));
175
+ }
176
+
177
+ function worst(c) {
178
+ const sev = SEVERITIES.find((k) => c[k] > 0);
179
+ return sev ? h("span", { class: `sev sev-${sev}` }, `${c[sev]} ${sev}`) : h("span", { class: "ok" }, "clean");
180
+ }
181
+
182
+ function cell(label, value, sub, cls, unit) {
183
+ return h("div", { class: "cell" }, h("span", { class: "label" }, label), h("div", { class: ["big", cls] }, value, unit ? h("span", { class: "unit" }, unit) : null), h("div", { class: "sub" }, sub));
184
+ }
@@ -0,0 +1,349 @@
1
+ import { h, put, panel, clear, empty, diffBlock, announce, sevBadge, subwin, whatNext } from "../dom.js";
2
+ import { createTyper } from "../typer.js";
3
+ import { bars } from "../charts.js";
4
+ import { ago, duration, pct, shortProject } from "../format.js";
5
+ import { SCREENS, RUN_KINDS, NEXT, EMPTY } from "../copy.js";
6
+
7
+ const LOCAL = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|[a-z0-9-]+\.localhost)$/i;
8
+
9
+ function hostOf(raw) {
10
+ const v = String(raw || "").trim();
11
+ if (!v) return null;
12
+ try { return new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(v) ? v : `http://${v}`).hostname; } catch { return null; }
13
+ }
14
+
15
+ export async function render(root, { api, params, meta, go }) {
16
+ const [{ live, past }, mem] = await Promise.all([api.get("/api/runs"), api.get("/api/memory")]);
17
+ const projects = meta.projects || [];
18
+ const startKind = RUN_KINDS.some((k) => k.kind === params.get("kind")) ? params.get("kind") : "scan";
19
+
20
+ // ── Form ──
21
+ const radios = RUN_KINDS.map((k) => h("input", { type: "radio", name: "r-kind", value: k.kind, checked: k.kind === startKind }));
22
+ const kindOf = () => radios.find((r) => r.checked)?.value || "scan";
23
+ const repo = h("input", { type: "text", id: "r-repo", list: "r-projects", autocomplete: "off", spellcheck: "false", value: params.get("repo") || projects[0] || "", placeholder: "/path/to/project" });
24
+ const url = h("input", { type: "text", id: "r-url", autocomplete: "off", spellcheck: "false", value: params.get("url") || "", placeholder: "http://localhost:3000" });
25
+ const confirm = h("input", { type: "text", id: "r-confirm", autocomplete: "off", spellcheck: "false" });
26
+ const confirmHint = h("span", { class: "hint" });
27
+ const confirmWrap = h("label", { class: "field c12", for: "r-confirm", hidden: true }, h("span", {}, "Confirm the target"), confirm, confirmHint);
28
+ const iters = h("input", { type: "number", id: "r-iter", min: "1", max: "50", value: "5" });
29
+ const tests = h("input", { type: "checkbox", id: "r-tests" });
30
+ const urlWrap = h("label", { class: "field c4", for: "r-url" }, h("span", {}, "Running app URL ", h("span", { class: "hint" }, "(optional)")), url);
31
+ const iterWrap = h("label", { class: "field c2", for: "r-iter" }, h("span", {}, "Max rounds"), iters);
32
+ const testsWrap = h("label", { class: "check", for: "r-tests" }, tests, "also run the project's tests to verify");
33
+ const note = h("span", { class: "muted" });
34
+ const start = h("button", { type: "submit", class: "btn primary" });
35
+ const formErr = h("p", { class: "error", role: "alert", hidden: true });
36
+
37
+ const NOTES = {
38
+ scan: "Contacts only the URL's host (and nothing at all for a folder-only scan) while offline is on in Settings.",
39
+ deps: "Reads package-lock.json and node_modules. No network.",
40
+ agents: "Reads skills, MCP config, hooks and rule files. Read-only, no network.",
41
+ loop: "Always offline :: works on a local branch :: every diff waits for you :: never pushes.",
42
+ };
43
+ const sync = () => {
44
+ const k = kindOf();
45
+ const def = RUN_KINDS.find((x) => x.kind === k);
46
+ urlWrap.hidden = k === "deps" || k === "agents";
47
+ iterWrap.hidden = k !== "loop";
48
+ testsWrap.hidden = k !== "loop";
49
+ note.textContent = NOTES[k];
50
+ start.textContent = `${def.button} ▸`;
51
+ const host = urlWrap.hidden ? null : hostOf(url.value);
52
+ const remote = host && !LOCAL.test(host);
53
+ confirmWrap.hidden = !remote;
54
+ if (remote) {
55
+ confirm.placeholder = host;
56
+ confirmHint.textContent = `${host} is not on this machine. Type its name to confirm you own it or have written permission to test it.`;
57
+ }
58
+ };
59
+ url.addEventListener("input", sync);
60
+ for (const r of radios) r.addEventListener("change", sync);
61
+
62
+ const form = h("form", { class: "form-grid", novalidate: true },
63
+ h("div", { class: "c12" }, h("div", { class: "seg", role: "radiogroup", "aria-label": "What to run" },
64
+ RUN_KINDS.map((k, i) => h("label", {}, radios[i], h("span", {}, k.label))),
65
+ )),
66
+ h("label", { class: "field c6", for: "r-repo" }, h("span", {}, "Project folder"), repo, h("datalist", { id: "r-projects" }, projects.map((p) => h("option", { value: p })))),
67
+ urlWrap,
68
+ iterWrap,
69
+ confirmWrap,
70
+ h("div", { class: "c12 filters" }, testsWrap, note),
71
+ h("div", { class: "c12" }, start, formErr),
72
+ );
73
+
74
+ // ── Terminal ──
75
+ const term = h("div", { class: "term", role: "log", "aria-live": "off", "aria-label": "Run output", tabindex: "0" });
76
+ const statusChip = h("span", { class: "chip" }, "idle");
77
+ const stopBtn = h("button", { type: "button", class: "btn", disabled: true }, "Stop");
78
+ const approvalSlot = h("div");
79
+ const nextSlot = h("div");
80
+ const timeline = h("ol", { class: "timeline", "aria-label": "Rounds" });
81
+ const runInfo = h("div", { class: "muted" }, "tty :: idle");
82
+ const ttyTitle = h("span", { class: "title" }, "penthera :: tty");
83
+
84
+ const termWin = subwin("Terminal", { class: "span-8" },
85
+ h("div", { class: "term-head" }, runInfo, h("div", { class: "filters" }, statusChip, stopBtn)),
86
+ term,
87
+ approvalSlot,
88
+ );
89
+ termWin.querySelector(".titlebar .title").replaceWith(ttyTitle);
90
+
91
+ put(root,
92
+ h("h1", {}, SCREENS.run.title),
93
+ h("p", { class: "lede" }, SCREENS.run.lede),
94
+ h("div", { class: "grid" },
95
+ panel("What to run", { class: "span-12" }, form),
96
+ termWin,
97
+ h("div", { class: "span-4 grid" },
98
+ panel("Rounds", { class: "span-12" }, timeline),
99
+ panel("Memory", { class: "span-12" }, memoryPanel(mem.projects)),
100
+ ),
101
+ h("div", { class: "span-12" }, nextSlot),
102
+ panel("Past loop runs", { class: "span-12" }, pastRuns(past)),
103
+ ),
104
+ );
105
+
106
+ let typer = createTyper(term);
107
+ let es = null;
108
+ let run = null;
109
+ let pending = null;
110
+ const its = new Map();
111
+
112
+ timeline.append(h("li", {}, h("span", { class: "muted" }, "Rounds appear here when the fix loop runs.")));
113
+ typer.push("Ready. Pick what to run, check the folder, and press the button.", "dim");
114
+ typer.push("The fix loop stops at every diff :: [y] approve [n] skip [q] stop.", "dim");
115
+
116
+ function setStatus(st) {
117
+ statusChip.textContent = st;
118
+ statusChip.className = `chip ${st === "running" ? "on" : st === "awaiting you" ? "blue" : st === "error" ? "inv" : ""}`;
119
+ stopBtn.disabled = !(st === "running" || st === "awaiting you");
120
+ }
121
+
122
+ function itemFor(n, of) {
123
+ if (!its.has(n)) {
124
+ const fixes = h("ul", { class: "fixes" });
125
+ const head = h("div", { class: "t1" }, `Round ${n}`, of ? h("span", { class: "muted" }, ` of ${of}`) : null);
126
+ const sub = h("div", { class: "muted" }, "scanning…");
127
+ const li = h("li", {}, head, sub, fixes);
128
+ timeline.append(li);
129
+ its.set(n, { li, sub, fixes, current: null });
130
+ }
131
+ return its.get(n);
132
+ }
133
+ let lastIt = null;
134
+ const mark = (li, txt, cls) => { li.firstChild.textContent = txt; li.firstChild.className = cls; };
135
+
136
+ function onEvent(ev) {
137
+ const d = ev.data;
138
+ switch (ev.type) {
139
+ case "log": {
140
+ const t = d.text.replace(/^\n/, "");
141
+ const cls = /^penthera /.test(t) ? "cmd" : /verified and committed|done:|^(scan|deps|agents) done/.test(t) ? "ok"
142
+ : /failed verification|^error|not applied/.test(t) ? "bad" : /^\s*iteration \d+/.test(t) ? "hl" : /^\s{3,}/.test(t) || /\.\.\.$/.test(t) ? "dim" : null;
143
+ typer.push(cls === "dim" || cls === "bad" ? t.trim() : t, cls);
144
+ break;
145
+ }
146
+ case "iteration": lastIt = itemFor(d.n, d.of); break;
147
+ case "scanned": if (lastIt) lastIt.sub.textContent = `${d.findings} findings :: ${d.fixable} fixable :: ${d.suppressed} suppressed`; break;
148
+ case "approval": {
149
+ pending = d;
150
+ const it = itemFor(d.iteration);
151
+ const li = h("li", {}, h("span", { class: "blue" }, "??"), h("span", { class: "t" }, d.plan.title));
152
+ it.fixes.append(li);
153
+ it.current = li;
154
+ showApproval(d);
155
+ setStatus("awaiting you");
156
+ announce(`Diff ${d.index} of ${d.total} is waiting for you: ${d.plan.title}`);
157
+ break;
158
+ }
159
+ case "decision": {
160
+ pending = null;
161
+ clear(approvalSlot);
162
+ const it = lastIt || itemFor(1);
163
+ const m = { y: ["..", "muted"], n: ["--", "muted"], skip: ["~~", "muted"], quit: ["xx", "bad"] }[d.answer] || ["..", "muted"];
164
+ if (it.current) mark(it.current, ...m);
165
+ if (d.answer !== "y") it.current = null;
166
+ setStatus("running");
167
+ break;
168
+ }
169
+ case "fix": {
170
+ if (lastIt?.current) {
171
+ const [txt, cls] = d.status === "verified" ? ["OK", "ok"] : d.status === "failed" ? ["!!", "bad"] : ["--", "muted"];
172
+ mark(lastIt.current, txt, cls);
173
+ lastIt.current = null;
174
+ }
175
+ break;
176
+ }
177
+ case "status": {
178
+ if (d.status === "running") { setStatus("running"); break; }
179
+ setStatus(d.status);
180
+ clear(approvalSlot);
181
+ start.disabled = false;
182
+ const sum = d.summary;
183
+ if (sum?.totals) {
184
+ const t = sum.totals;
185
+ typer.push(`done: ${sum.stopReason} after ${t.iterations} round${t.iterations === 1 ? "" : "s"} (${duration(sum.durationMs)})`, "ok");
186
+ typer.push(`${t.verified} verified and committed :: ${t.failed} failed and reverted :: ${t.declined} skipped`, t.failed ? "bad" : "ok");
187
+ for (const it of its.values()) if (it.sub.textContent === "scanning…") it.sub.textContent = "nothing left to fix";
188
+ }
189
+ if (d.status === "done" || d.status === "stopped") {
190
+ clear(nextSlot).append(whatNext(NEXT[run.kind] || NEXT.scan, go));
191
+ }
192
+ announce(`Run ${d.status}`);
193
+ break;
194
+ }
195
+ default: break;
196
+ }
197
+ }
198
+
199
+ function showApproval(d) {
200
+ typer.flush();
201
+ const p = d.plan;
202
+ const decide = async (answer) => {
203
+ for (const b of approvalSlot.querySelectorAll("button")) b.disabled = true;
204
+ try {
205
+ await api.post(`/api/runs/${run.id}/decision`, { approvalId: d.approvalId, answer });
206
+ } catch (e) {
207
+ typer.push(`error: ${e.message}`, "bad");
208
+ for (const b of approvalSlot.querySelectorAll("button")) b.disabled = false;
209
+ }
210
+ };
211
+ const approve = h("button", { type: "button", class: "btn primary", onclick: () => decide("approve") }, "Apply this fix", h("span", { class: "k" }, "y"));
212
+ clear(approvalSlot).append(h("section", { class: "alert", role: "alertdialog", "aria-labelledby": "ap-h", "aria-describedby": "ap-d" },
213
+ h("div", { class: "icon", "aria-hidden": "true" }, "?"),
214
+ h("h3", { id: "ap-h" }, `Apply fix ${d.index} of ${d.total}?`),
215
+ h("p", { id: "ap-d", class: "t1" }, p.title),
216
+ h("p", { class: "muted" }, d.memory ? `Memory :: this recipe verified ${d.memory.verified} times, failed ${d.memory.failed} on this stack.` : "Memory :: no history for this recipe yet."),
217
+ h("div", { class: "body" },
218
+ h("dl", { class: "kv" },
219
+ h("dt", {}, "recipe"), h("dd", {}, `${p.recipe}${p.framework ? ` (${p.framework})` : ""} :: confidence ${p.confidence}`),
220
+ h("dt", {}, "files"), h("dd", {}, p.files.join(", ")),
221
+ h("dt", {}, "fixes"), h("dd", {}, p.findings.map((f) => h("div", {}, sevBadge(f.severity), " ", f.title))),
222
+ ),
223
+ diffBlock(p.diff),
224
+ p.redacted ? h("p", { class: "muted" }, "Secret values are masked here; the real patch uses them.") : null,
225
+ ),
226
+ h("div", { class: "actions" },
227
+ h("button", { type: "button", class: "btn", onclick: () => decide("stop") }, "Stop the loop", h("span", { class: "k" }, "q")),
228
+ h("button", { type: "button", class: "btn", onclick: () => decide("skip") }, "Skip", h("span", { class: "k" }, "n")),
229
+ approve,
230
+ ),
231
+ ));
232
+ approve.focus({ preventScroll: true });
233
+ approvalSlot.scrollIntoView({ block: "nearest" });
234
+ }
235
+
236
+ function attach(r) {
237
+ run = r;
238
+ const what = RUN_KINDS.find((k) => k.kind === r.kind)?.label.toLowerCase() || r.kind;
239
+ runInfo.textContent = `${what} :: ${r.repo ? shortProject(r.repo) : ""}${r.url ? ` + ${r.url}` : ""} :: started ${ago(r.startedAt)}`;
240
+ ttyTitle.textContent = `penthera :: tty :: ${what}`;
241
+ es?.close();
242
+ es = new EventSource(`/api/runs/${r.id}/stream`);
243
+ for (const type of ["log", "iteration", "scanned", "approval", "decision", "fix", "status"]) {
244
+ es.addEventListener(type, (m) => { try { onEvent(JSON.parse(m.data)); } catch { /* malformed event */ } });
245
+ }
246
+ es.addEventListener("end", () => { es.close(); es = null; });
247
+ }
248
+
249
+ form.addEventListener("submit", async (e) => {
250
+ e.preventDefault();
251
+ formErr.hidden = true;
252
+ start.disabled = true;
253
+ const kind = kindOf();
254
+ try {
255
+ const r = await api.post("/api/runs", {
256
+ kind, repo: repo.value, url: urlWrap.hidden ? "" : url.value, confirm: confirm.value,
257
+ maxIterations: Number(iters.value), runTests: kind === "loop" && tests.checked,
258
+ });
259
+ typer.stop();
260
+ clear(term);
261
+ clear(timeline);
262
+ clear(nextSlot);
263
+ its.clear();
264
+ lastIt = null;
265
+ typer = createTyper(term);
266
+ attach(r);
267
+ } catch (err) {
268
+ formErr.textContent = err.message;
269
+ formErr.hidden = false;
270
+ start.disabled = false;
271
+ if (err.status === 428) confirm.focus();
272
+ }
273
+ });
274
+
275
+ stopBtn.addEventListener("click", async () => {
276
+ if (!run) return;
277
+ stopBtn.disabled = true;
278
+ try { await api.post(`/api/runs/${run.id}/stop`); } catch (e) { typer.push(`error: ${e.message}`, "bad"); }
279
+ });
280
+
281
+ const onKey = (e) => {
282
+ if (!pending || e.metaKey || e.ctrlKey || e.altKey) return;
283
+ if (/^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement?.tagName || "")) return;
284
+ const btns = approvalSlot.querySelectorAll(".actions button");
285
+ const map = { q: 0, n: 1, y: 2 };
286
+ if (e.key in map && btns[map[e.key]] && !btns[map[e.key]].disabled) { e.preventDefault(); btns[map[e.key]].click(); }
287
+ };
288
+ document.addEventListener("keydown", onKey);
289
+
290
+ // Re-attach to a run that is still going (navigated away and back).
291
+ const running = live.find((r) => r.status === "running");
292
+ if (running) {
293
+ start.disabled = true;
294
+ typer.stop();
295
+ clear(term);
296
+ typer = createTyper(term);
297
+ attach(running);
298
+ }
299
+ sync();
300
+
301
+ return () => {
302
+ es?.close();
303
+ typer.stop();
304
+ document.removeEventListener("keydown", onKey);
305
+ };
306
+ }
307
+
308
+ function memoryPanel(projects) {
309
+ if (!projects.length) return empty(EMPTY.memory);
310
+ const recipes = new Map();
311
+ for (const p of projects) for (const r of p.recipes) {
312
+ const cur = recipes.get(r.recipe) || { verified: 0, failed: 0 };
313
+ cur.verified += r.verified;
314
+ cur.failed += r.failed;
315
+ recipes.set(r.recipe, cur);
316
+ }
317
+ const events = projects.reduce((n, p) => n + p.events, 0);
318
+ const verified = projects.reduce((n, p) => n + p.verifiedFixes, 0);
319
+ const failed = projects.reduce((n, p) => n + p.failedFixes, 0);
320
+ const broken = projects.filter((p) => p.chain !== "ok");
321
+ return h("div", {},
322
+ h("dl", { class: "kv" },
323
+ h("dt", {}, "projects"), h("dd", {}, String(projects.length)),
324
+ h("dt", {}, "events"), h("dd", {}, String(events)),
325
+ h("dt", {}, "chain"), h("dd", { class: broken.length ? "bad" : "ok" }, broken.length ? `${broken.length} broken :: ignored there` : "[OK] sha-256 hash chain"),
326
+ h("dt", {}, "fixes"), h("dd", {}, h("span", { class: "ok" }, `${verified} verified`), ` :: ${failed} failed`),
327
+ ),
328
+ h("p", { class: "label" }, "Recipe success"),
329
+ bars([...recipes].map(([recipe, r]) => ({ label: recipe, value: r.verified })), { width: 10 }),
330
+ h("p", { class: "panel-note" }, [...recipes].map(([recipe, r]) => `${recipe} ${pct(r.verified / Math.max(1, r.verified + r.failed))}`).join(" :: ")),
331
+ );
332
+ }
333
+
334
+ function pastRuns(past) {
335
+ if (!past.length) return empty(EMPTY.pastRuns);
336
+ return h("div", { class: "table-wrap" }, h("table", {},
337
+ h("thead", {}, h("tr", {}, ...["When", "Project", "Findings", "Verified", "Reverted", "Skipped", "Stopped because", "Branch"].map((t, i) => h("th", { class: [i >= 3 && i <= 5 ? "num" : null, i === 7 ? "hide-narrow" : null] }, t)))),
338
+ h("tbody", {}, past.map((l) => h("tr", {},
339
+ h("td", { class: "muted" }, ago(l.startedAt)),
340
+ h("td", { class: "trunc t1", title: l.target.repo || "" }, shortProject(l.target.repo)),
341
+ h("td", { class: "t1" }, `${l.findings.initial ?? "?"} → ${l.findings.final ?? "?"}`),
342
+ h("td", { class: "num ok" }, String(l.totals.verified)),
343
+ h("td", { class: ["num", l.totals.failed ? "bad" : "muted"] }, String(l.totals.failed)),
344
+ h("td", { class: "num muted" }, String(l.totals.declined + l.totals.skipped)),
345
+ h("td", { class: "muted" }, l.stopReason || "-"),
346
+ h("td", { class: "muted hide-narrow" }, l.branch || "-"),
347
+ ))),
348
+ ));
349
+ }