penthera 1.7.0 → 1.8.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 (163) hide show
  1. package/CHANGELOG.md +315 -0
  2. package/README.md +139 -76
  3. package/SECURITY.md +8 -6
  4. package/docs/examples/pr-dependency-review.yml +1 -1
  5. package/lib/adaptive-probes.js +3 -3
  6. package/lib/advisories/evidence.js +248 -0
  7. package/lib/advisories/index.js +228 -0
  8. package/lib/advisories/products.js +83 -0
  9. package/lib/advisories/versions.js +65 -0
  10. package/lib/agents/hidden-content.js +122 -11
  11. package/lib/agents/index.js +26 -6
  12. package/lib/agents/lock.js +33 -5
  13. package/lib/agents/rules.js +33 -4
  14. package/lib/compliance/index.js +4 -4
  15. package/lib/crawler.js +12 -15
  16. package/lib/deps/capabilities.js +21 -5
  17. package/lib/deps/findings.js +309 -0
  18. package/lib/deps/index.js +13 -272
  19. package/lib/deps/iocs.js +74 -0
  20. package/lib/deps/lockfile.js +3 -8
  21. package/lib/deps/online.js +8 -3
  22. package/lib/deps/popular-packages.json.gz +0 -0
  23. package/lib/deps/popular-pypi.json.gz +0 -0
  24. package/lib/deps/python/capabilities.js +16 -3
  25. package/lib/deps/python/index.js +5 -1
  26. package/lib/deps/python/lockfiles.js +11 -5
  27. package/lib/deps/python/source.js +75 -26
  28. package/lib/deps/python/toml.js +15 -3
  29. package/lib/deps/source.js +28 -9
  30. package/lib/deps/typosquat.js +30 -5
  31. package/lib/fix/apply.js +253 -34
  32. package/lib/fix/index.js +7 -4
  33. package/lib/fix/model.js +25 -5
  34. package/lib/fix/util.js +6 -2
  35. package/lib/fuzzer.js +3 -3
  36. package/lib/git.js +65 -0
  37. package/lib/hook/index.js +157 -73
  38. package/lib/hook/policy.js +10 -4
  39. package/lib/install/index.js +4 -7
  40. package/lib/machine.js +0 -13
  41. package/lib/memory/store.js +11 -4
  42. package/lib/nuclei-loader.js +0 -25
  43. package/lib/owasp-wstg.js +4 -0
  44. package/lib/plugins.js +1 -1
  45. package/lib/provenance.js +1 -0
  46. package/lib/recon.js +12 -31
  47. package/lib/references.json +224 -0
  48. package/lib/retirejs.js +4 -4
  49. package/lib/rules/advisories/advisories.json +186 -0
  50. package/lib/rules/advisories/eol.json +413 -0
  51. package/lib/rules/advisories/pack.json +20 -0
  52. package/lib/rules/advisories/pack.json.sig +6 -0
  53. package/lib/rules/allowed_signers +13 -0
  54. package/lib/rules/core/agent-exec.json +29 -0
  55. package/lib/rules/core/agent-instructions.json +17 -0
  56. package/lib/rules/core/pack.json +30 -0
  57. package/lib/rules/core/pack.json.sig +6 -0
  58. package/lib/rules/core/secrets.json +161 -0
  59. package/lib/rules/core/templates.json +52 -0
  60. package/lib/rules/index.js +107 -0
  61. package/lib/rules/ioc-2025-2026/files.json +43 -0
  62. package/lib/rules/ioc-2025-2026/hosts.json +35 -0
  63. package/lib/rules/ioc-2025-2026/pack.json +25 -0
  64. package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
  65. package/lib/rules/ioc-2025-2026/packages.json +1048 -0
  66. package/lib/rules/pack.js +452 -0
  67. package/lib/rules/regex-guard.js +81 -9
  68. package/lib/rules/sshsig.js +205 -0
  69. package/lib/templates.js +24 -20
  70. package/lib/tmp.js +21 -0
  71. package/lib/whitebox/frameworks.js +1 -1
  72. package/lib/whitebox/secrets.js +117 -41
  73. package/package.json +12 -4
  74. package/scripts/rules/build-pack.mjs +80 -0
  75. package/scripts/rules/check-bundled.mjs +30 -0
  76. package/scripts/rules/sign-pack.sh +32 -0
  77. package/skills/penthera/SKILL.md +16 -15
  78. package/src/agent/git.js +147 -14
  79. package/src/agent/loop.js +165 -46
  80. package/src/cli/agent.js +11 -5
  81. package/src/cli/deps.js +0 -2
  82. package/src/cli/detect.js +14 -7
  83. package/src/cli/fix.js +26 -8
  84. package/src/cli/hook.js +2 -1
  85. package/src/cli/onboarding.js +14 -6
  86. package/src/cli/report-outputs.js +1 -0
  87. package/src/cli/rules.js +144 -0
  88. package/src/cli/ui.js +43 -10
  89. package/src/cli.js +24 -1
  90. package/src/report/evidence.js +105 -21
  91. package/src/report/footer.js +1 -1
  92. package/src/report/md-escape.js +8 -0
  93. package/src/scan-repo.js +6 -0
  94. package/src/scan-url.js +11 -3
  95. package/src/ui/api/data.js +39 -6
  96. package/src/ui/api/routes.js +44 -11
  97. package/src/ui/api/runs.js +32 -7
  98. package/src/ui/security.js +20 -23
  99. package/src/ui/server.js +52 -27
  100. package/src/ui/static/app.css +583 -213
  101. package/src/ui/static/favicon.svg +1 -1
  102. package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
  103. package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
  104. package/src/ui/static/index.html +5 -4
  105. package/src/ui/static/js/api.js +114 -6
  106. package/src/ui/static/js/app.js +81 -42
  107. package/src/ui/static/js/center.js +246 -133
  108. package/src/ui/static/js/charts.js +99 -18
  109. package/src/ui/static/js/copy.js +68 -9
  110. package/src/ui/static/js/dialog.js +152 -0
  111. package/src/ui/static/js/left.js +23 -60
  112. package/src/ui/static/js/menubar.js +170 -0
  113. package/src/ui/static/js/onboarding.js +104 -0
  114. package/src/ui/static/js/panels.js +186 -0
  115. package/src/ui/static/js/right.js +69 -188
  116. package/src/ui/static/js/state.js +36 -5
  117. package/src/ui/static/logo.svg +10 -0
  118. package/src/ui/static/mark.svg +3 -0
  119. package/src/utils/http.js +162 -13
  120. package/docs/METHODOLOGY.md +0 -375
  121. package/docs/commands/README.md +0 -24
  122. package/docs/commands/action.md +0 -21
  123. package/docs/commands/agents.md +0 -38
  124. package/docs/commands/deps.md +0 -115
  125. package/docs/commands/fix.md +0 -53
  126. package/docs/commands/hook.md +0 -69
  127. package/docs/commands/init.md +0 -114
  128. package/docs/commands/install.md +0 -53
  129. package/docs/commands/scan.md +0 -100
  130. package/docs/commands/skill.md +0 -58
  131. package/docs/compare/README.md +0 -25
  132. package/docs/compare/github.md +0 -60
  133. package/docs/compare/semgrep.md +0 -54
  134. package/docs/compare/snyk.md +0 -62
  135. package/docs/compare/socket.md +0 -78
  136. package/docs/compare/zap.md +0 -59
  137. package/docs/compliance-cra.md +0 -141
  138. package/docs/compliance-nis2.md +0 -168
  139. package/docs/dashboard.md +0 -187
  140. package/docs/evidence.md +0 -162
  141. package/docs/funding/due-diligence-checklist.md +0 -79
  142. package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
  143. package/docs/funding/partner-one-pager.md +0 -84
  144. package/docs/funding/sponsor-tiers.md +0 -81
  145. package/docs/index.md +0 -81
  146. package/docs/internal-audit.md +0 -83
  147. package/docs/local-loop.md +0 -276
  148. package/docs/marketing.md +0 -151
  149. package/docs/owasp-wstg-coverage.md +0 -79
  150. package/docs/plan.md +0 -102
  151. package/docs/privacy.md +0 -38
  152. package/docs/references.bib +0 -559
  153. package/docs/references.md +0 -119
  154. package/docs/release-plan.md +0 -228
  155. package/docs/research/socket-positioning-notes.md +0 -183
  156. package/docs/use-cases/README.md +0 -14
  157. package/docs/use-cases/agencies.md +0 -71
  158. package/docs/use-cases/ai-coding-teams.md +0 -81
  159. package/docs/use-cases/eu-sme-compliance.md +0 -68
  160. package/docs/use-cases/open-source-maintainers.md +0 -75
  161. package/docs/use-cases/solo-founder.md +0 -65
  162. package/lib/deps/popular-packages.json +0 -1
  163. package/lib/deps/popular-pypi.json +0 -1
@@ -1,23 +1,19 @@
1
- // The centre: one scrolling stream. A project (or all of them) as a compact
2
- // header, "Fix first" and the findings; a run as a transcript of tool output
3
- // with inline Apply / Skip / Stop; and the prompt bar that starts actions.
1
+ // The centre window: the document you are looking at. A project (or all of
2
+ // them) is a header and five tabs :: Findings, Dependencies, AI tooling,
3
+ // History, Proof. A run is a transcript of tool output with inline Apply /
4
+ // Skip / Stop. Runs start from the New scan dialog, not from here.
4
5
  import { h, clear, empty, sevBadge, diffBlock, announce } from "./dom.js";
5
6
  import { api } from "./api.js";
6
- import { state, set, projectOf, folderFor } from "./state.js";
7
+ import { state, set, projectOf, TABS, simple } from "./state.js";
7
8
  import { createTyper } from "./typer.js";
8
- import { stackedBar, sparkline, wordmark } from "./charts.js";
9
+ import { stackedBar, sparkline, matrix, halftone } from "./charts.js";
9
10
  import { ago, duration, shortProject, plural, SEVERITIES } from "./format.js";
10
- import { START, KINDS, NOTES, EMPTY } from "./copy.js";
11
+ import { START, EMPTY, SEV_WORD, verdict } from "./copy.js";
12
+ import { openWindow } from "./dialog.js";
13
+ import { depsTab, aiTab, historyTab, proofTab } from "./panels.js";
11
14
 
12
- const LOCAL = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|[a-z0-9-]+\.localhost)$/i;
13
15
  const SEV_RANK = Object.fromEntries(SEVERITIES.map((s, i) => [s, i]));
14
16
 
15
- function hostOf(raw) {
16
- const v = String(raw || "").trim();
17
- if (!v) return null;
18
- try { return new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(v) ? v : `http://${v}`).hostname; } catch { return null; }
19
- }
20
-
21
17
  // ── Stream state that outlives a redraw: the live run's typer and SSE ──
22
18
  let typer = null;
23
19
  let es = null;
@@ -25,6 +21,7 @@ let pending = null;
25
21
  let visible = [];
26
22
 
27
23
  export function stopStream() {
24
+ closeApproval();
28
25
  es?.close();
29
26
  es = null;
30
27
  typer?.stop();
@@ -38,17 +35,18 @@ export function renderCenter(el, ctx) {
38
35
  const title = state.sel.kind === "all" ? "All projects"
39
36
  : state.sel.kind === "project" ? shortProject(state.sel.key)
40
37
  : state.sel.kind === "run" ? `Run :: ${state.sel.run?.kind || ""}` : "Fix loop run";
41
- const toolbar = h("div", { class: "toolbar" },
42
- h("button", { type: "button", class: "icon", "aria-label": "Toggle the sidebar ([)", title: "Sidebar [", onclick: toggleLeft }, "☰"),
38
+ const doc = state.sel.kind === "all" || state.sel.kind === "project";
39
+ const toolbar = h("div", { class: "win-bar" },
40
+ h("button", { type: "button", class: "icon", "aria-label": "Show or hide the projects window ([)", title: "Projects [", onclick: toggleLeft }, "☰"),
43
41
  h("span", { class: "title" }, title),
44
42
  h("span", { class: "grow" }),
45
- state.sel.kind === "all" || state.sel.kind === "project" ? search : null,
46
- h("button", { type: "button", class: "icon", "aria-label": "Toggle the details panel (])", title: "Details ]", onclick: toggleRight }, "◫"),
43
+ doc && state.tab === "findings" ? search : null,
44
+ h("button", { type: "button", class: "icon", "aria-label": "Show or hide the inspector (])", title: "Inspector ]", onclick: toggleRight }, "◫"),
47
45
  );
48
- const stream = h("div", { class: "stream", id: "stream" });
46
+ const stream = h("div", { class: "stream pane-body", id: "stream" });
49
47
  const col = h("div", { class: "col" });
50
48
  stream.append(col);
51
- clear(el).append(toolbar, stream, promptBar(ctx));
49
+ clear(el).append(toolbar, stream);
52
50
 
53
51
  if (state.sel.kind === "run") return runView(col, ctx);
54
52
  stopStream();
@@ -56,12 +54,6 @@ export function renderCenter(el, ctx) {
56
54
  return projectView(col, ctx);
57
55
  }
58
56
 
59
- /** Redraw only the prompt bar (a refresh while a run is on screen must not rebuild its transcript). */
60
- export function renderBar(el, ctx) {
61
- const old = el.querySelector(".bar");
62
- if (old) old.replaceWith(promptBar(ctx));
63
- }
64
-
65
57
  /** Re-render only the findings list (search typing must not rebuild the toolbar). */
66
58
  export function refreshList(ctx) {
67
59
  const list = document.getElementById("findings-list");
@@ -84,20 +76,51 @@ function projectView(col, ctx) {
84
76
  const open = all ? o.open : p.open;
85
77
  const fixed = all ? o.fixed : p.fixed;
86
78
  const trend = all ? combinedTrend(o.projects) : p.trend.map((t) => t.total);
79
+ const easy = simple();
80
+ const when = ago(all ? o.lastScans[0]?.ts : p.lastScan);
87
81
  col.append(h("header", { class: "head" },
88
82
  h("span", { class: `grade lg grade-${all ? "x" : p.grade}`, title: all ? "" : `score ${p.score}/100` }, all ? "∑" : p.grade),
89
83
  h("h1", { title: key || "" }, all ? "All projects" : shortProject(key)),
90
- h("div", { class: "meta" }, all ? `${plural(o.projects.length, "project")} · newest scan ${ago(o.lastScans[0]?.ts)}` : `${p.mode || "scan"} · last scan ${ago(p.lastScan)} · ${plural(p.trend.length, "scan")}`),
84
+ h("div", { class: "meta" }, easy
85
+ ? (all ? `${plural(o.projects.length, "project")} · last checked ${when}` : `last checked ${when}`)
86
+ : (all ? `${plural(o.projects.length, "project")} · newest scan ${when}` : `${p.mode || "scan"} · last scan ${when} · ${plural(p.trend.length, "scan")}`)),
91
87
  h("div", { class: "right-bits" },
92
- stackedBar(counts, { width: 24 }),
93
- sparkline(trend, { label: "open findings per scan" }),
94
- h("div", { class: "counts" }, h("b", {}, String(open)), " open · ", h("span", { class: "ok" }, String(fixed)), " fixed"),
88
+ easy ? null : stackedBar(counts, { width: 24 }),
89
+ easy ? null : sparkline(trend, { label: "open findings per scan" }),
90
+ h("div", { class: "counts" }, h("b", {}, String(open)), easy ? " to fix · " : " open · ", h("span", { class: "ok" }, String(fixed)), " fixed"),
95
91
  ),
96
92
  ));
97
93
 
98
- // Since the last scan (the old History page, condensed).
94
+ // The verdict: one sentence, one button.
95
+ const mine = sorted(state.findings.filter((f) => !f.suppressed && (all || f.project === key)));
96
+ const v = verdict({ counts, open, top: mine[0], scanned: true, when });
97
+ col.append(h("section", { class: ["verdict", `v-${v.level}`], "aria-label": "Verdict" },
98
+ h("p", { class: "verdict-text" }, v.text),
99
+ h("p", { class: "verdict-sub muted" }, v.sub),
100
+ v.top ? h("div", { class: "actions start" },
101
+ h("button", { type: "button", class: "btn primary", onclick: () => ctx.openFinding(v.top) }, easy ? "Show me the first one ▸" : `Open: ${v.top.title}`),
102
+ easy && v.level !== "clear" ? h("button", { type: "button", class: "btn", onclick: () => ctx.newScan("loop", all ? null : key) }, "Fix problems for me…") : null) : null,
103
+ ));
104
+
105
+ if (easy) {
106
+ const list = h("ul", { class: "rows simple", id: "findings-list", "aria-label": "Problems to fix" });
107
+ col.append(h("section", { class: "section" }, h("h2", {}, h("span", {}, "To fix"), h("span", { id: "findings-count" })), list));
108
+ fillFindings(list, ctx);
109
+ col.append(h("p", { class: "muted prose more" }, "Looking for dependencies, AI tooling, history or evidence for an auditor? Switch to Advanced in the Penthera menu."));
110
+ return;
111
+ }
112
+
113
+ col.append(tabStrip(ctx));
114
+ const panel = h("div", { class: "tab-panel", role: "tabpanel", id: `tab-${state.tab}`, "aria-labelledby": `tabbtn-${state.tab}` });
115
+ col.append(panel);
116
+ if (state.tab === "deps") return depsTab(panel);
117
+ if (state.tab === "ai") return aiTab(panel);
118
+ if (state.tab === "history") return historyTab(panel, all ? null : key);
119
+ if (state.tab === "proof") return proofTab(panel, all ? null : key);
120
+
121
+ // Since the last scan.
99
122
  const since = h("div");
100
- col.append(since);
123
+ panel.append(since);
101
124
  if (!all && p.trend.length > 1) {
102
125
  api.get(`/api/history?project=${encodeURIComponent(key)}`).then((hst) => {
103
126
  if (state.sel.key !== key) return;
@@ -114,41 +137,72 @@ function projectView(col, ctx) {
114
137
  h("div", { class: "cols2" }, miniList(o.columns.newest, "Nothing new."), miniList(o.columns.fixed, "Nothing fixed.", true))));
115
138
  }
116
139
 
140
+ const pool = state.findings.filter((f) => !f.suppressed && (all || f.project === key));
141
+ panel.append(h("section", { class: "section" },
142
+ h("h2", {}, h("span", {}, "Triage :: severity × how sure"), h("span", {}, state.filter ? h("button", { type: "button", class: "linkish", onclick: () => set({ filter: null }) }, "clear filter ×") : "pick a cell to filter")),
143
+ matrix(pool, state.filter, (cell) => set({ filter: cell }))));
144
+
117
145
  const list = h("ul", { class: "rows", id: "findings-list", "aria-label": "Findings" });
118
- col.append(h("section", { class: "section" }, h("h2", {}, h("span", {}, "Findings"), h("span", { id: "findings-count" })), list));
146
+ panel.append(h("section", { class: "section" }, h("h2", {}, h("span", {}, "Findings"), h("span", { id: "findings-count" })), list));
119
147
  fillFindings(list, ctx);
120
148
  }
121
149
 
150
+ const sorted = (rows) => [...rows].sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity] || a.title.localeCompare(b.title));
151
+
152
+ /** Folder tabs: the selected one joins the page below it. Arrow keys move between them. */
153
+ function tabStrip(ctx) {
154
+ const strip = h("div", { class: "tabs", role: "tablist", "aria-label": "Project views" });
155
+ const btns = TABS.map((t, i) => {
156
+ const b = h("button", {
157
+ type: "button", role: "tab", class: "tab", id: `tabbtn-${t.id}`, "aria-selected": String(state.tab === t.id),
158
+ "aria-controls": `tab-${t.id}`, tabindex: state.tab === t.id ? "0" : "-1", title: `${t.label} ${t.key}`, onclick: () => ctx.openTab(t.id),
159
+ }, t.label);
160
+ b.addEventListener("keydown", (e) => {
161
+ if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
162
+ e.preventDefault();
163
+ const n = TABS[(i + (e.key === "ArrowRight" ? 1 : -1) + TABS.length) % TABS.length];
164
+ ctx.openTab(n.id);
165
+ queueMicrotask(() => document.getElementById(`tabbtn-${n.id}`)?.focus());
166
+ });
167
+ return b;
168
+ });
169
+ strip.append(...btns, h("span", { class: "tab-rest" }));
170
+ return strip;
171
+ }
172
+
122
173
  function fillFindings(list, ctx) {
123
174
  const all = state.sel.kind === "all";
124
175
  const q = state.query.trim().toLowerCase();
125
- const rows = state.findings
176
+ const easy = simple();
177
+ const fl = easy ? null : state.filter;
178
+ const rows = sorted(state.findings
126
179
  .filter((f) => !f.suppressed && (all || f.project === state.sel.key))
127
- .filter((f) => !q || `${f.title} ${f.category} ${f.location} ${f.project}`.toLowerCase().includes(q))
128
- .sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity] || a.title.localeCompare(b.title));
180
+ .filter((f) => !fl || (f.severity === fl.severity && (f.confidence || "likely") === fl.confidence))
181
+ .filter((f) => !q || `${f.title} ${f.category} ${f.location} ${f.project}`.toLowerCase().includes(q)));
129
182
  visible = rows;
130
183
  clear(list);
131
184
  let group = null;
132
185
  for (const f of rows) {
133
186
  const fixFirst = f.severity === "critical" || f.severity === "high";
134
- const g = fixFirst ? "Fix first" : "Then";
187
+ const g = fixFirst ? "Fix first" : easy ? "When you have time" : "Then";
135
188
  if (g !== group) {
136
189
  group = g;
137
- list.append(h("li", { class: "group-h" }, g === "Fix first" ? `Fix first :: ${rows.filter((x) => x.severity === "critical" || x.severity === "high").length}` : `Then :: ${rows.filter((x) => !(x.severity === "critical" || x.severity === "high")).length}`));
190
+ const n = rows.filter((x) => (x.severity === "critical" || x.severity === "high") === fixFirst).length;
191
+ list.append(h("li", { class: "group-h" }, `${g} :: ${n}`));
138
192
  }
139
193
  const cur = state.finding && state.finding.id === f.id && state.finding.project === f.project;
140
194
  list.append(h("li", {}, h("button", {
141
195
  type: "button", class: ["row", fixFirst ? "b" : null], "aria-current": String(Boolean(cur)), dataset: { id: f.id, project: f.project },
142
196
  onclick: () => ctx.openFinding(f),
143
197
  },
144
- sevBadge(f.severity),
198
+ easy ? h("span", { class: `sev sev-${f.severity}` }, SEV_WORD[f.severity] || f.severity) : sevBadge(f.severity),
145
199
  h("span", { class: "t" }, f.title),
146
- h("span", { class: "tag" }, all ? shortProject(f.project) : f.category || ""),
147
- f.location ? h("span", { class: "where" }, f.location) : null)));
200
+ h("span", { class: "tag" }, easy ? (all ? shortProject(f.project) : "▸") : all ? shortProject(f.project) : f.category || ""),
201
+ !easy && f.location ? h("span", { class: "where" }, f.location) : null)));
148
202
  }
149
203
  if (!rows.length) list.append(h("li", {}, empty(state.findings.length && q ? `${EMPTY.filtered}` : EMPTY.findings)));
150
204
  const count = document.getElementById("findings-count");
151
- if (count) count.textContent = q ? `${rows.length} match` : plural(rows.length, "open finding");
205
+ if (count) count.textContent = q || fl ? `${rows.length} match` : easy ? plural(rows.length, "problem") : plural(rows.length, "open finding");
152
206
  }
153
207
 
154
208
  /** j / k: move the selection and open the finding in the right panel. */
@@ -175,16 +229,22 @@ function combinedTrend(projects) {
175
229
  }
176
230
 
177
231
  function startView(col, ctx) {
232
+ const easy = simple();
233
+ const steps = easy ? [] : START.steps;
178
234
  col.append(h("div", { class: "start" },
179
- wordmark(),
180
- h("p", { class: "muted" }, START.gate),
235
+ h("span", { class: "logotype xl", role: "img", "aria-label": "Penthera" }),
236
+ halftone({ risk: 1, seed: "penthera" }),
181
237
  h("h1", {}, START.title),
182
- h("p", {}, START.intro),
183
- h("div", { class: "steps" }, START.steps.map((st, i) => h("div", { class: "step" },
238
+ h("p", { class: "prose" }, easy ? START.introSimple : START.intro),
239
+ h("div", { class: "actions start" },
240
+ h("button", { type: "button", class: "btn primary", onclick: () => ctx.setup() }, "Set up my project…"),
241
+ h("button", { type: "button", class: "btn", onclick: () => ctx.newScan("scan") }, easy ? "Check my app…" : "New scan…")),
242
+ steps.length ? h("div", { class: "steps" }, steps.map((st, i) => h("div", { class: "step" },
184
243
  h("span", { class: "n" }, `${i + 1}.`),
185
244
  h("h3", {}, st.title),
186
- h("button", { type: "button", class: "btn", onclick: () => ctx.focusBar(st.kind) }, "Choose ▸"),
187
- h("p", {}, st.body)))),
245
+ h("button", { type: "button", class: "btn", onclick: () => ctx.newScan(st.kind) }, `${st.title}…`),
246
+ h("p", {}, st.body)))) : null,
247
+ h("p", { class: "muted gate prose" }, START.gate),
188
248
  h("p", { class: "muted" }, `${START.demo} `, h("code", {}, "penthera ui --demo"), "."),
189
249
  ));
190
250
  }
@@ -193,8 +253,8 @@ function notScannedView(col, key, ctx) {
193
253
  col.append(h("div", { class: "start" },
194
254
  h("h1", {}, shortProject(key)),
195
255
  h("p", { class: "muted" }, key),
196
- h("p", {}, "Not scanned yet. Pick what to run in the bar below; the folder is this project."),
197
- h("div", { class: "actions" }, h("button", { type: "button", class: "btn", onclick: () => ctx.focusBar("scan") }, "Choose Scan ▸")),
256
+ h("p", {}, "Not scanned yet. Start with a scan of this folder; it reads only what is inside it."),
257
+ h("div", { class: "actions start" }, h("button", { type: "button", class: "btn primary", onclick: () => ctx.newScan("scan", key) }, "New scan…")),
198
258
  ));
199
259
  }
200
260
 
@@ -205,14 +265,23 @@ function runView(col, ctx) {
205
265
  if (!run) return;
206
266
  const status = h("span", { class: "chip" }, run.status);
207
267
  const stop = h("button", { type: "button", class: "btn small", disabled: run.status !== "running" }, "Stop");
208
- const lines = h("div", { class: "transcript", role: "log", "aria-live": "off", "aria-label": "Run output" });
268
+ const lines = h("div", { class: ["transcript", "gutter", run.status === "running" ? null : "done"], role: "log", "aria-live": "off", "aria-label": "Run output" });
209
269
  const tail = h("div");
270
+ const easy = simple();
271
+ // Simple mode: one sentence on what is happening, the raw output folded away.
272
+ const said = h("p", { class: "verdict-text", "aria-live": "polite" }, run.kind === "loop" ? "Looking for problems it can fix…" : "Checking your app… this takes a minute or two.");
273
+ const saidSub = h("p", { class: "verdict-sub muted" }, "You can leave this page; it keeps going.");
274
+ const tally = { verified: 0, failed: 0 };
210
275
  col.append(
211
276
  h("div", { class: "run-head" },
212
- h("h1", {}, `${({ scan: "Scan", deps: "Dependency check", agents: "AI tooling audit", loop: "Fix loop" })[run.kind] || run.kind}`),
277
+ h("h1", {}, easy ? ({ scan: "Checking your app", loop: "Fixing problems", deps: "Checking dependencies", agents: "Checking AI tooling" })[run.kind] || run.kind
278
+ : `${({ scan: "Scan", deps: "Dependency check", agents: "AI tooling audit", loop: "Fix loop" })[run.kind] || run.kind}`),
213
279
  h("span", { class: "muted" }, `${shortProject(run.repo || run.url)}${run.url && run.repo ? ` + ${run.url}` : ""} · ${ago(run.startedAt)}`),
214
- h("span", { class: "grow" }), status, stop),
215
- lines, tail,
280
+ h("span", { class: "grow" }), easy ? null : status, stop),
281
+ // Node.append() would print a null as the text "null", so leave it out.
282
+ ...(easy ? [h("section", { class: "verdict" }, said, saidSub)] : []),
283
+ tail,
284
+ easy ? h("details", { class: "more" }, h("summary", {}, "Show what it is doing"), lines) : lines,
216
285
  );
217
286
  const setStatus = (st) => {
218
287
  status.textContent = st;
@@ -228,7 +297,6 @@ function runView(col, ctx) {
228
297
  stopStream();
229
298
  typer = createTyper(lines);
230
299
  pending = null;
231
- es = new EventSource(`/api/runs/${run.id}/stream`);
232
300
  const scroll = () => { const st = document.getElementById("stream"); if (st) st.scrollTop = st.scrollHeight; };
233
301
  const onEvent = (ev) => {
234
302
  const d = ev.data;
@@ -242,31 +310,53 @@ function runView(col, ctx) {
242
310
  }
243
311
  case "approval": {
244
312
  pending = d;
313
+ set({ pending: { index: d.index, total: d.total } });
245
314
  typer.flush();
246
- tail.append(approvalDialog(run, d, typer));
315
+ clear(tail).append(waitingNote(run, d, typer));
316
+ said.textContent = `Fix ${d.index} of ${d.total} is waiting for you to approve it.`;
247
317
  setStatus("awaiting you");
248
318
  scroll();
249
- announce(`Fix ${d.index} of ${d.total} is waiting for you: ${d.plan.title}`);
319
+ // Replayed approvals are followed at once by their decision: open the window only if this one is still waiting.
320
+ setTimeout(() => { if (pending === d) openApproval(run, d, typer); }, 60);
250
321
  break;
251
322
  }
252
323
  case "decision": {
253
324
  pending = null;
325
+ set({ pending: null });
326
+ closeApproval();
254
327
  clear(tail);
255
328
  setStatus("running");
329
+ said.textContent = d.answer === "y" ? "Applying the fix and checking it worked…" : "Moving on…";
330
+ break;
331
+ }
332
+ case "fix": {
333
+ if (d.status === "verified") tally.verified++;
334
+ else if (d.status === "failed") tally.failed++;
335
+ saidSub.textContent = `${plural(tally.verified, "fix", "fixes")} applied and checked so far${tally.failed ? `, ${tally.failed} undone because it did not work` : ""}.`;
256
336
  break;
257
337
  }
258
338
  case "status": {
259
339
  if (d.status === "running") break;
340
+ if (state.pending) set({ pending: null });
341
+ closeApproval();
342
+ lines.classList.add("done");
260
343
  setStatus(d.status);
261
344
  clear(tail);
262
345
  const sum = d.summary;
346
+ if (d.status === "error") said.textContent = "Something went wrong. Open “Show what it is doing” for the reason.";
347
+ else if (d.status === "stopped") said.textContent = "Stopped. Everything applied so far was checked; nothing was published.";
348
+ else if (sum?.totals) {
349
+ const t = sum.totals;
350
+ said.textContent = `Done. ${plural(t.verified, "fix", "fixes")} applied and checked${t.failed ? `, ${t.failed} undone` : ""}${t.declined ? `, ${t.declined} skipped` : ""}.`;
351
+ } else said.textContent = "Done. Your project is up to date.";
352
+ saidSub.textContent = run.kind === "loop" ? "The changes are on a separate branch on this computer. Nothing was published." : "";
263
353
  if (sum?.totals) {
264
354
  const t = sum.totals;
265
355
  typer.push(`done: ${sum.stopReason} after ${plural(t.iterations, "round")} (${duration(sum.durationMs)})`, "ok");
266
356
  typer.push(`${t.verified} verified and committed :: ${t.failed} failed and reverted :: ${t.declined} skipped${sum.branch ? ` :: branch ${sum.branch}` : ""}`, t.failed ? "bad" : "ok");
267
357
  }
268
358
  tail.append(h("div", { class: "actions" },
269
- run.repo || run.url ? h("button", { type: "button", class: "btn primary", onclick: () => ctx.refresh().then(() => set({ sel: { kind: "project", key: run.repo || run.url } })) }, "Open the project ▸") : null));
359
+ run.repo || run.url ? h("button", { type: "button", class: "btn primary", onclick: () => ctx.refresh().then(() => set({ sel: { kind: "project", key: run.repo || run.url } })) }, easy ? "See my project ▸" : "Open the project ▸") : null));
270
360
  ctx.refresh();
271
361
  announce(`Run ${d.status}`);
272
362
  break;
@@ -274,50 +364,117 @@ function runView(col, ctx) {
274
364
  default: break;
275
365
  }
276
366
  };
277
- for (const type of ["log", "approval", "decision", "status"]) {
278
- es.addEventListener(type, (m) => { try { onEvent(JSON.parse(m.data)); } catch { /* malformed event */ } });
279
- }
280
- es.addEventListener("end", () => { es?.close(); es = null; });
367
+ // fetch-based stream (api.js): it carries the session header, EventSource cannot.
368
+ es = api.stream(`/api/runs/${run.id}/stream`, (type, data) => {
369
+ if (type === "end") { es?.close(); es = null; return; }
370
+ if (type === "error") { typer?.push("error: the run's event stream closed. Reload to reconnect.", "bad"); return; }
371
+ if (!["log", "approval", "decision", "fix", "status"].includes(type)) return;
372
+ try { onEvent(JSON.parse(data)); } catch { /* malformed event */ }
373
+ });
374
+ }
375
+
376
+ /** Hex SHA-256 of the diff text as shown; the server only applies the diff whose hash matches. */
377
+ async function sha256Hex(text) {
378
+ const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
379
+ return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
380
+ }
381
+
382
+ // ── The approval: a floating window, pinstriped because it waits on you ──
383
+
384
+ let approval = null; // { win, decide } while the window is open
385
+ let decideFn = null; // answers the waiting diff, window open or not
386
+
387
+ function closeApproval() {
388
+ approval?.win.close();
389
+ approval = null;
390
+ decideFn = null;
281
391
  }
282
392
 
283
- function approvalDialog(run, d, ty) {
393
+ function decider(run, d, ty) {
394
+ let busy = false;
284
395
  const p = d.plan;
285
- const decide = async (answer, box) => {
286
- for (const b of box.querySelectorAll("button")) b.disabled = true;
396
+ const shownDiff = String(p.diff || "");
397
+ const fn = async (answer) => {
398
+ if (busy) return;
399
+ // A cut diff is never applied by accident: Apply needs the box in the window ticked first.
400
+ if (answer === "approve" && p.truncated && !fn.acknowledged) { openApproval(run, d, ty); return; }
401
+ busy = true;
402
+ approval?.el?.querySelectorAll("button").forEach((b) => { b.disabled = true; });
287
403
  try {
288
- await api.post(`/api/runs/${run.id}/decision`, { approvalId: d.approvalId, answer });
404
+ const body = { approvalId: d.approvalId, answer };
405
+ if (answer === "approve") {
406
+ body.diffSha256 = await sha256Hex(shownDiff);
407
+ if (p.truncated) body.acknowledgeTruncated = fn.acknowledged === true;
408
+ }
409
+ await api.post(`/api/runs/${run.id}/decision`, body);
289
410
  } catch (e) {
290
411
  ty.push(`error: ${e.message}`, "bad");
291
- for (const b of box.querySelectorAll("button")) b.disabled = false;
412
+ approval?.el?.querySelectorAll("button").forEach((b) => { b.disabled = false; });
413
+ if (approval?.apply && p.truncated && !fn.acknowledged) approval.apply.disabled = true;
414
+ busy = false;
292
415
  }
293
416
  };
294
- const box = h("section", { class: "dialog", role: "alertdialog", "aria-labelledby": "ap-h", "aria-describedby": "ap-d" });
295
- const apply = h("button", { type: "button", class: "btn primary", onclick: () => decide("approve", box) }, "Apply", h("span", { class: "k" }, "y"));
296
- box.append(
297
- h("div", { class: "dialog-bar" }, h("span", {}, `Fix ${d.index} of ${d.total}`)),
298
- h("div", { class: "dialog-body" },
299
- h("h3", { id: "ap-h" }, p.title),
300
- h("p", { id: "ap-d", class: "muted" }, `${p.recipe}${p.framework ? ` (${p.framework})` : ""} · ${p.files.join(", ")} · ${d.memory ? `memory: ${d.memory.verified} verified, ${d.memory.failed} failed` : "no history for this recipe yet"}`),
301
- h("div", {}, p.findings.map((f) => h("div", {}, sevBadge(f.severity), " ", h("span", { class: "t1" }, f.title)))),
302
- diffBlock(p.diff),
303
- p.redacted ? h("p", { class: "muted" }, "Secret values are masked here; the real patch uses them.") : null,
304
- h("div", { class: "actions" },
305
- h("button", { type: "button", class: "btn", onclick: () => decide("stop", box) }, "Stop", h("span", { class: "k" }, "q")),
306
- h("button", { type: "button", class: "btn", onclick: () => decide("skip", box) }, "Skip", h("span", { class: "k" }, "n")),
307
- apply),
308
- ),
417
+ fn.acknowledged = false;
418
+ return fn;
419
+ }
420
+
421
+ /** Inline in the transcript: what is waiting, and a way back to the window. */
422
+ function waitingNote(run, d, ty) {
423
+ decideFn = decider(run, d, ty);
424
+ return h("div", { class: "wait-note" },
425
+ h("span", { class: "lamp live", "aria-hidden": "true" }),
426
+ h("span", { class: "grow" }, `Fix ${d.index} of ${d.total} is waiting for you: ${d.plan.title}`),
427
+ h("button", { type: "button", class: "btn primary", onclick: () => openApproval(run, d, ty) }, "Review the change…"));
428
+ }
429
+
430
+ function openApproval(run, d, ty) {
431
+ if (approval) return;
432
+ const p = d.plan;
433
+ const easy = simple();
434
+ const decide = decideFn || (decideFn = decider(run, d, ty));
435
+ const files = p.files.length;
436
+ const shownDiff = String(p.diff || "");
437
+ const diff = diffBlock(shownDiff);
438
+ const ack = p.truncated ? h("input", { type: "checkbox", id: "ap-ack", checked: decide.acknowledged || null }) : null;
439
+ const body = h("div", { class: "approval" },
440
+ h("h3", { id: "ap-h" }, easy ? `Penthera wants to change ${plural(files, "file")} to fix this:` : p.title),
441
+ h("ul", { class: "mini" }, p.findings.map((f) => h("li", {}, easy ? h("span", { class: `sev sev-${f.severity}` }, SEV_WORD[f.severity]) : sevBadge(f.severity), h("span", { class: "grow t1" }, f.title)))),
442
+ h("p", { id: "ap-d", class: easy ? "prose" : "muted" }, easy
443
+ ? `${p.title}. It changes ${p.files.join(", ")} on a separate branch, checks the problem is gone, and undoes the change if it is not. Nothing is published.`
444
+ : `${p.recipe}${p.framework ? ` (${p.framework})` : ""} · ${p.files.join(", ")} · ${d.memory ? `memory: ${d.memory.verified} verified, ${d.memory.failed} failed` : "no history for this recipe yet"}`),
445
+ easy && !p.truncated ? h("details", { class: "more" }, h("summary", {}, "Show the exact change"), diff) : diff,
446
+ p.truncated ? h("p", { class: "bad", role: "note" }, easy
447
+ ? "[!] This change is too long to show in full here. Ask a developer to look at the rest in the project before you apply it."
448
+ : `[!] Diff cut: showing the first ${shownDiff.length.toLocaleString()} of ${Number(p.fullLength).toLocaleString()} characters. Review the rest in the repo before applying.`) : null,
449
+ p.truncated ? h("label", { class: "check", for: "ap-ack" }, ack, h("span", { class: "prose" }, " I have not seen the whole change and want to apply it anyway")) : null,
450
+ p.redacted ? h("p", { class: "muted" }, "Secret values are masked here; the real patch uses them.") : null,
309
451
  );
310
- queueMicrotask(() => apply.focus({ preventScroll: true }));
311
- return box;
452
+ const apply = h("button", { type: "button", class: "btn primary", disabled: Boolean(ack && !ack.checked), onclick: () => decide("approve") }, easy ? "Apply this fix" : "Apply", h("kbd", {}, "y"));
453
+ ack?.addEventListener("change", () => { decide.acknowledged = ack.checked; apply.disabled = !ack.checked; });
454
+ body.append(h("div", { class: "actions" },
455
+ h("button", { type: "button", class: "btn", onclick: () => decide("stop") }, easy ? "Stop fixing" : "Stop", h("kbd", {}, "q")),
456
+ h("span", { class: "grow" }),
457
+ h("button", { type: "button", class: "btn", onclick: () => decide("skip") }, easy ? "Skip this one" : "Skip", h("kbd", {}, "n")),
458
+ apply));
459
+ const win = openWindow({ title: `Fix ${d.index} of ${d.total}`, label: `Fix ${d.index} of ${d.total} waiting for you`, body, wide: true, striped: true });
460
+ win.el.setAttribute("aria-describedby", "ap-d");
461
+ win.el.addEventListener("keydown", (e) => {
462
+ if (e.metaKey || e.ctrlKey || e.altKey || e.target instanceof HTMLInputElement) return;
463
+ const a = { y: "approve", n: "skip", q: "stop" }[e.key];
464
+ if (a) { e.preventDefault(); decide(a); }
465
+ });
466
+ win.el.addEventListener("close", () => { approval = null; }, { once: true });
467
+ approval = { win, el: win.el, apply };
468
+ announce(`Fix ${d.index} of ${d.total} is waiting for you: ${p.title}`);
469
+ (ack && !ack.checked ? ack : apply).focus();
312
470
  }
313
471
 
314
- /** y / n / q while a diff waits. */
472
+ /** y / n / q while a diff waits and its window is closed. */
315
473
  export function answerPending(key) {
316
- if (!pending) return false;
317
- const btns = document.querySelectorAll(".dialog .actions button");
318
- const i = { q: 0, n: 1, y: 2 }[key];
319
- if (i == null || !btns[i] || btns[i].disabled) return false;
320
- btns[i].click();
474
+ if (!pending || !decideFn) return false;
475
+ const a = { y: "approve", n: "skip", q: "stop" }[key];
476
+ if (!a) return false;
477
+ decideFn(a);
321
478
  return true;
322
479
  }
323
480
 
@@ -342,47 +499,3 @@ function pastRunView(col, ctx) {
342
499
  h("div", { class: "actions" }, h("button", { type: "button", class: "btn", onclick: () => set({ sel: { kind: "project", key: l.target.repo } }) }, "Open the project ▸")),
343
500
  );
344
501
  }
345
-
346
- // ── The prompt bar ──────────────────────────────────────────────────────
347
-
348
- function promptBar(ctx) {
349
- const kind = state.mode;
350
- const def = KINDS.find((k) => k.kind === kind);
351
- const usesUrl = kind === "scan" || kind === "loop";
352
- const input = h("input", { type: "text", id: "bar-input", autocomplete: "off", spellcheck: "false", placeholder: def.placeholder, "aria-label": usesUrl ? "Running app URL (optional)" : "Project folder", disabled: !usesUrl });
353
- if (usesUrl && state.sel.kind === "project" && /^https?:/.test(state.sel.key || "")) input.value = state.sel.key;
354
- const confirm = h("input", { type: "text", id: "bar-confirm", autocomplete: "off", spellcheck: "false", "aria-label": "Type the hostname to confirm" });
355
- const confirmRow = h("div", { class: "confirm", hidden: true }, h("span", { id: "confirm-q" }), confirm);
356
- const err = h("p", { class: "err", role: "alert", hidden: true });
357
- const folder = folderFor(state.sel.kind === "project" ? state.sel.key : null);
358
- // One primary action at a time: while a run is going, its dialog owns it.
359
- const busy = state.runs.live.some((r) => r.status === "running");
360
- const run = h("button", { type: "submit", class: busy ? "btn" : "btn primary", disabled: busy, title: busy ? "A run is in progress. Stop it or wait for it to finish." : null }, `${def.button} ▸`);
361
- const sync = () => {
362
- const host = usesUrl ? hostOf(input.value) : null;
363
- const remote = host && !LOCAL.test(host);
364
- confirmRow.hidden = !remote;
365
- if (remote) document.getElementById("confirm-q").textContent = `${host} is not on this machine. Type its name to confirm you own it or may test it:`;
366
- };
367
- input.addEventListener("input", sync);
368
- const form = h("form", { novalidate: true, "aria-label": "Start a run" }, input, run);
369
- form.addEventListener("submit", async (e) => {
370
- e.preventDefault();
371
- err.hidden = true;
372
- run.disabled = true;
373
- try {
374
- const r = await api.post("/api/runs", { kind, repo: folder, url: usesUrl ? input.value : "", confirm: confirm.value, maxIterations: 5 });
375
- await ctx.refresh();
376
- set({ sel: { kind: "run", key: r.id, run: r } });
377
- } catch (ex) {
378
- err.textContent = ex.message;
379
- err.hidden = false;
380
- run.disabled = false;
381
- if (ex.status === 428) confirm.focus();
382
- }
383
- });
384
- const modes = KINDS.map((k) => h("button", { type: "button", class: "mode", "aria-pressed": String(k.kind === kind), onclick: () => { set({ mode: k.kind }); ctx.focusBar(); } }, k.label));
385
- return h("div", { class: "bar" }, h("div", { class: "bar-inner" },
386
- h("div", { class: "modes", role: "group", "aria-label": "What to run" }, modes, h("span", { class: "note" }, `${shortProject(folder) || "no folder"} · ${NOTES[kind]}`)),
387
- form, confirmRow, err));
388
- }