The local dashboard: penthera ui
Starter. For “can I see it all in one place?”
penthera ui opens Penthera in your browser: whether your app is safe to
ship, what to fix first, in plain words, and the fix loop that proposes a
change and waits for your OK. It looks like a small 1-bit computer: a menu
bar over three windows. It runs on 127.0.0.1 only, reads only the folders
you give it and ~/.penthera, and never contacts the network.
Start it
It prints a URL with a one-time token after the
#, which the browser never
sends over the network. Open it once: the page swaps the token for a session
that lives in that tab (a reload keeps it, a new tab or browser does not), and
the token stops working, so a second browser or a link that leaked into
history is refused. Restart penthera ui for a new link. Ctrl-C stops the
server.
First visit: setup
The first time a browser opens the dashboard, a three-screen setup runs (run it again from Penthera ▸ Set up…):- What Penthera does, in two sentences, and the rule: only check apps you own or have written permission to test.
- Simple or Advanced. Simple answers “is my app safe, and what do I do?” in plain words with one button at a time. Advanced shows everything. Switch any time in the Penthera menu or Settings; the choice is remembered in this browser.
- Your project: the folder (one of the
--projectfolders) and, optionally, where the app runs. Check my app now starts a first scan; Skip closes setup. Closing it early keeps Simple.
The view
Menu bar. The Penthera menu (the wordmark; the P alone on small screens) holds About, Set up, Settings, the mode and the theme. Simple mode adds Check (Check my app, Fix problems for me, the running check) and View. Advanced has File, Scan, Fix, Evidence and View instead. Every item shows its shortcut. On the right: the run in progress (blue, “Fix 1 of 3 waiting for you”, when a change waits), and Offline or Online for scans started here (click to switch). Projects (left,[ to hide). New scan… on top, then “All projects”
and every project with its A to F grade, open problems and last check. A
project with a run in progress shows a blinking square.
The document (centre). For a project, or all of them:
- The verdict: one sentence as the headline that answers “can I ship?”, such as “Not ready to ship: 1 critical problem to fix first.”, with the age of the check, the counts to fix and fixed, and the score out of 100 as a 10 × 10 grid of pixels (for all projects, the lowest score). One button is blue: Fix problems for me… in Simple, the first finding in Advanced. While a problem is open in the inspector, its Fix it for me… is the blue one instead.
- Simple: the problems, in plain words (Critical, Serious, Worth fixing, Minor), “Fix first” before “When you have time”, then what was fixed since the check before, folded into one line. No paths, categories or charts.
- Advanced: five tabs (
1to5). Findings has what changed since the last scan, a triage matrix of severity against how sure Penthera is (Confirmed, Likely, Needs a person; pick a cell to filter) and the full list (/searches,jandkmove). Dependencies has the capability diff, typosquats and install firewall decisions. AI tooling has the grade,penthera.lockdrift and hidden-content hits. History has open findings per scan, every run, the commits the fix loop made and the memory chain. Proof has the hosts the last scan contacted and evidence bundles: create, verify, and download the auditor, owner or developer report.
Esc) returns to the project. In
Simple mode it is one sentence (“Fix 2 of 3
is waiting for you to approve it.”, “Done. 2 fixes applied and checked, 1
undone.”) with the raw output folded under “Show what it is doing”. In Advanced
it is the transcript, with a gutter square per step: filled when done, hollow
on the step in progress.
The inspector (right, ] to hide) shows one thing. With a problem picked:
in Simple, what is wrong, how sure Penthera is, what to do and how to check
the fix, then Fix it for me…, with the evidence and the code change behind “Show the details”; in
Advanced, the same in plain words plus where, the evidence, sources and the
fix-recipe diff. Esc goes back. With nothing picked it shows the halftone
field: dark with dots when something serious is open, plain paper when
nothing is. The dotted desktop between the windows follows the same rule.
Every change waits in a window. When the fix loop has a change, a
pinstriped window opens over a heavy blur: “Penthera wants to change 1 file to
fix this:”, the problem, what it will do, and the change (folded in Simple).
Apply (y), Skip (n) or Stop (q). Closing the window decides
nothing; the change stays waiting, in the transcript and the menu bar. A diff
over 60,000 characters is cut, and the window says so; Apply (and y) then
stays off until you tick that you have not seen all of it. Every Apply names
the SHA-256 of the diff it showed, and the server applies nothing else.
Below 1180px the inspector becomes a drawer; below 760px the projects window
does too. A tap outside closes them.
User stories
Running things from the page
New scan… (orn) opens a window: what to run, the folder, an optional
URL and the offline switch. In Simple mode it offers two things, “Check my
app” and “Fix problems for me”; Advanced offers scan, dependencies, AI tooling
and the fix loop. It calls the same functions as the CLI (executeScan,
scanDeps, scanAgents, runLoop) with the same rules. The fix loop needs a clean git
tree on a branch, works on a local penthera/fix-<yyyymmdd-hhmm> branch, runs
only allow-listed local git commands with hooks off, is always offline with
egress limited to the URL’s host, and never pushes. On top of that:
- The project folder must be inside one of the
--projectfolders. - A URL that is not on this machine (anything but
localhost,127.x.x.x,[::1]and*.localhost) needs its hostname typed back before the run starts. Only scan what you own or have written permission to test. - One run at a time. Closing the tab does not stop a run; open it from the menu bar or the project’s History tab and its transcript replays and continues. Stop finishes the current step, and the fix branch holds only verified commits.
- Reports land in the reports folder (
loop-<timestamp>.json,deps_<timestamp>.json, and so on), so every screen picks them up. A write is refused when the reports folder (orevidence/in it) no longer resolves to the folder checked at startup, and evidence bundles are built in a private temp folder and renamed into place.
AI mode
A scan of a project folder has an AI mode switch, off. Turning it on opens a sheet, on every new scan: a picture of where your code goes (your code, Penthera, Ollama on this computer, and “internet” crossed out), the model picker, the same Model / Reads / Sends / Decides lines the terminal shows, and two buttons of equal weight, “Use<model> on this computer” and
“Not now” (Esc). A model that runs on ollama.com turns the last box into
“ollama.com” and needs the host typed before its button works. The server
asks Ollama for the model’s route itself, refuses an AI run without that
consent or with an older consent text, and logs each consent locally.
A run shows an “AI · local” (or “AI · ollama.com”) tag that opens to say
which model ran where; when it ends, the model’s unverified proposals are
folded away under their own heading and are not counted anywhere else.
Settings lists the consent log. See ai-mode.md.
Security model
The dashboard is a web server on your machine, so any page open in the same browser can send it requests. Every request passes, in this order:- Host check. The
Hostheader must be exactly127.0.0.1:<port>orlocalhost:<port>. This blocks DNS rebinding, whereattacker.examplere-resolves to 127.0.0.1 so the attacker’s page can read the response. It runs before the token check, so a rebinding page cannot even redeem the link. - Path check. No dot segments, encoded dots or slashes, backslashes or NUL bytes. Static files come from an allowlist built at startup; no part of a request path is ever joined onto a filesystem path.
- Session. The one-time token sits in the URL fragment, so it never
reaches a server or a log. The page posts it to
/api/sessiononce, gets a session id back, removes the token from the address bar and keeps the id in that tab’ssessionStorage. Every/apirequest sends it asAuthorization: Bearer; the server keeps only its SHA-256 and compares in constant time. There is no cookie: cookies are not bound to a port, so every other server on 127.0.0.1 you visit would receive one. The run transcript is read withfetch, notEventSource, so it carries the header too. The page and its scripts are served without a session (they hold no data). Requests without a session are rate-limited (429 withRetry-After). - Origin. Every
POSTneeds anOriginequal to the server’s own (andSec-Fetch-Site: same-originwhen the browser sends it) and a JSON body, so a cross-site form orfetchcannot change anything.
OPTIONS gets a 405. The page has no inline
script or style, loads nothing from another host (no CDN, fonts, analytics or
chart library), and never turns data into HTML: finding titles and evidence
come from the sites and repos you scanned, so they are only ever set as text.
Report downloads are served as text/markdown attachments from the same
checks; the audience is one of auditor, owner or developer, nothing else.
Limits: a request body over 64 KB gets a 413, a request must arrive within 30
seconds, and an idle connection closes after two minutes (the run transcript
excepted; it sends a keep-alive line instead). At most 16 transcripts stream
at once, and the 20 most recent finished runs are kept for replay.
Penthera scans its own dashboard clean with --profile standard, signed in and
not; tests/ui.test.js runs that scan on every test run, alongside tests for
the Host check, the token and bearer session, Origin, the headers, the rate limit and
path traversal.
Design
System 1-bit, blue ink: ink, paper and#0000ed, and nothing else. It should
feel like a security instrument from a calmer era of computing, honest about
what it knows.
- Windows. Every pane is a window with a 1px frame and a title bar. The
window that holds focus has the solid title bar, so you can see which one
j,kandEscact on. Only floating windows (setup, New scan, About, the approval) have the hard 3px shadow, over a heavy blur. The approval alone is pinstriped: pinstripes mean “waiting on you”. - No greys. Shades are dither patterns: solid, hatched, half, sparse, dotted, empty. Severity is a word plus its pattern, never colour alone.
- One accent.
#0000edmarks selection, the default button (with the classic outer ring), the focus ring, chart lines and links, and the menu bar when a change waits for you. On black it measures 2.2:1, so there it is only a fill with white on it (9.5:1), a frame or a ring; text in that hue uses#7a7aff(6.1:1). On paper#0000edis text-safe (9.3:1). Green is retired: OK is a ✓ and a word. - Type, three roles. Departure Mono (OFL,
self-hosted, 11 and 22px) for chrome, labels and numbers;
ui-monospacefor paths, evidence, diffs and transcripts; the system sans for sentences meant for people. - The logo, once per surface. The horizontal wordmark in the menu bar (the P mark below 760px), the P on a blue tile as the favicon, the wordmark large in About, setup and the first-run screen. Never the mark and the wordmark side by side, never in a window’s title bar.
- The halftone field. The one strange element, and it carries data: its density is the open risk. Blobs are seeded by the project, so each keeps its shape. Drawn on a canvas once per size or theme change, never animated.
- Both themes pass WCAG AA for every text node in every state (Simple and Advanced, every tab, a finding open, settings, menus, setup, New scan, About), measured in the browser, composited.
- Motion. Almost none. Run output types itself out and the cursor and the
live squares blink; with
prefers-reduced-motionall of that stops. Withprefers-reduced-transparencythe blur becomes a plain dark backdrop. - Keyboard.
nnew scan,/search,j/kfindings,1to5tabs (Advanced),[/]side windows,y/n/qanswer a change,Esccloses a menu, the detail, the search or a drawer. Every control is a real button or link; dialogs trap focus and give it back; screen readers get each log line whole.
assets/dashboard/ come from the demo data:
node scripts/dashboard-screenshots.mjs regenerates them with a local Chrome.



