bastion 0.60.0
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 3m36s
ci / fuzz (push) Successful in 3m35s

This commit is contained in:
Privatae
2026-11-02 00:10:58 +00:00
parent 4a5c310482
commit 4f8285ef18
100 changed files with 11537 additions and 840 deletions
+7 -1
View File
@@ -163,9 +163,15 @@
{{else if .Sanctioned}}
<p class="obs-foot">Your host vouched for this one. There is nothing to unmark
here — the tag it carries was issued by your host and is withdrawn there.</p>
{{else if and .ID (not $.OwnerCode)}}
<p class="obs-foot">Marking a program as yours takes your code: <a href="/setup">set it up</a> first.</p>
{{else if .ID}}
<form hx-post="/agents/mark" hx-target="#agents" hx-swap="innerHTML">
<form hx-post="/agents/mark" hx-target="#agents" hx-swap="innerHTML" autocomplete="off">
<input type="hidden" name="id" value="{{.ID}}">
{{/* hx-preserve: this list is swapped every 10s, and a half-typed code
must survive it. */}}
<input class="code" id="mark-code-{{.ID}}" hx-preserve="true" name="code" inputmode="numeric" pattern="[0-9]*"
autocomplete="one-time-code" placeholder="6-digit code" aria-label="Your code">
<button class="go" type="submit">This one is mine</button>
</form>
{{else}}
+35 -5
View File
@@ -6,14 +6,16 @@
<meta name="referrer" content="no-referrer">
<title>Privatae — approvals</title>
<link rel="stylesheet" href="/assets/app.css">
{{if not .TrayView}}<link rel="stylesheet" href="/assets/home.css">{{end}}
<!-- Same CSP reasoning as page.html: htmx must not inject its indicator styles. -->
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<script src="/assets/htmx.min.js"></script>
</head>
<body>
<header>
<h1>Approvals</h1>
<p class="sub">What your hosts are waiting on you for.{{if not .TrayView}} <a href="/">This machine</a>{{end}}</p>
<h1>Waiting for you</h1>
<p class="sub">What is waiting on your answer: asked of this machine, or by a Host.</p>
{{if not .TrayView}}{{template "nav" "approvals"}}{{end}}
</header>
<!-- NOT polled, unlike the front page. The list is redrawn only when the person
@@ -54,11 +56,16 @@
<input type="hidden" name="id" value="{{.ID}}">
<button class="no" type="submit">Deny</button>
</form>
{{if $.Limited}}
<p class="approval-line">Approving signs with this device's key, which is the administrator's:
<code>sudo -H privatae-bastion ui</code></p>
{{else}}
<form hx-post="/device/approve" hx-target="#approvals" hx-swap="innerHTML">
<input type="hidden" name="id" value="{{.ID}}">
{{if .NeedsCode}}<input name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="your code" required>{{end}}
<button class="go" type="submit">Approve</button>
</form>
{{end}}
</div>
{{if .Plan}}
<p class="approval-foot">Asked {{.At}}. Approving signs this ship plan with this device's key:
@@ -70,7 +77,7 @@
{{end}}
</div>
{{end}}
{{if gt .NoCodeApprovals 1}}
{{if and (gt .NoCodeApprovals 1) (not .Limited)}}
{{/* "All" means all SHOWN: the form carries the ids drawn above and nothing
else, so an ask that lands after this was drawn is left waiting. A plan
that runs root work is never in it: it needs the code, one at a time. */}}
@@ -81,17 +88,40 @@
{{end}}
{{end}}
{{if .MachineAsks}}
<h3 class="urgent">Asked of this machine</h3>
{{range .MachineAsks}}
<div class="approval">
<div class="approval-head">Request <span class="id">{{.Short}}</span></div>
<p class="approval-line">{{.Text}}</p>
<div class="approval-acts">
<form hx-post="/approvals/machine/deny" hx-target="#approvals" hx-swap="innerHTML">
<input type="hidden" name="id" value="{{.ID}}">
<button class="no" type="submit">Deny</button>
</form>
<form hx-post="/approvals/machine/approve" hx-target="#approvals" hx-swap="innerHTML" autocomplete="off">
<input type="hidden" name="id" value="{{.ID}}">
<input class="code" name="code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code"
placeholder="your code" aria-label="Your code" required>
<button class="go" type="submit">Approve</button>
</form>
</div>
<p class="approval-foot">Asked {{.At}}. Approving takes your code; anything that can drive your browser can click Approve.</p>
</div>
{{end}}
{{end}}
{{if .Approvals}}
<h3 class="urgent">Waiting for your code on this machine</h3>
{{template "stepup-approvals" .}}
{{end}}
{{if and (not .DeviceApprovals) (not .Approvals)}}
{{if and (not .DeviceApprovals) (not .Approvals) (not .MachineAsks)}}
<p class="approval-line">Nothing is waiting for your approval.</p>
{{if .TrayView}}
<p class="approval-foot">When your Host asks for something on this machine, it shows here and
this window asks for a code from the authenticator app enrolled when this machine's bastion was set up.</p>
{{else if not .IsDevice}}
{{else if and (not .IsDevice) .Pairings}}
<p class="approval-foot">This machine is not your approval device, so asks from your
hosts are not answered here. Run <code>privatae-bastion device-init</code> on the machine
you want to approve from.</p>
+4
View File
@@ -119,6 +119,10 @@
<input type="hidden" name="family" value="{{.Name}}">
{{if .SwitchedOff}}
<input type="hidden" name="off" value="0">
{{/* hx-preserve: this page is swapped every 10s and a half-typed code
must survive it. Turning back on takes the owner's code. */}}
<input class="code" id="on-code-{{$h.Host}}-{{.Name}}" hx-preserve="true" name="code" inputmode="numeric"
pattern="[0-9]*" autocomplete="one-time-code" placeholder="your code" aria-label="Your code">
<button class="go" type="submit">Turn back on</button>
{{else}}
<input type="hidden" name="off" value="1">
+64
View File
@@ -0,0 +1,64 @@
/* The agents home page and an agent's page (uihome.go). */
.topnav { display: flex; gap: 1.1rem; margin: 0.9rem 0 0; font-size: 0.9rem; flex-wrap: wrap; }
.topnav a { color: var(--dim); text-decoration: none; padding-bottom: 0.2rem; border-bottom: 2px solid transparent; }
.topnav a:hover { color: var(--ink); }
.topnav a.on { color: var(--ink); border-bottom-color: var(--ok); }
.tally { margin: 0 0 1rem; color: var(--dim); }
h2.family { font-size: 0.95rem; font-weight: 600; margin: 1.6rem 0 0.6rem; }
.agent-row {
display: block; color: var(--ink); text-decoration: none;
background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--line);
border-radius: 6px; padding: 0.75rem 1rem; margin-bottom: 0.6rem;
}
a.agent-row:hover { border-color: #3d4452; }
.agent-row.waiting { border-left-color: var(--warn); }
.agent-row.service { opacity: 0.75; }
.agent-head { font-weight: 600; overflow-wrap: anywhere; }
.agent-line { margin: 0.15rem 0 0; font-size: 0.88rem; overflow-wrap: anywhere; }
.agent-line.urgent { color: var(--warn); }
.dim { color: var(--dim); }
.agent-line code, .panel code { font-size: 0.8rem; }
.flags { margin-top: 0.45rem; }
.flag {
display: inline-block; font-size: 0.75rem; padding: 0.05rem 0.5rem; margin-right: 0.35rem;
border-radius: 999px; border: 1px solid var(--line); color: var(--dim);
}
.flag.waiting, .flag.warn { color: var(--warn); border-color: #5a4524; }
.flag.bad { color: var(--bad); border-color: #5a2a27; }
.flag.ok { color: var(--ok); border-color: #2a4a33; }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 0.75rem 1rem; }
.panel p { margin: 0.25rem 0; }
.tool { padding: 0.35rem 0; border-bottom: 1px solid var(--line); }
.tool:last-child { border-bottom: 0; }
.problem { color: var(--warn); font-size: 0.88rem; }
.reason { font-size: 0.85rem; }
.facts dl { display: grid; grid-template-columns: 7rem 1fr; gap: 0.2rem 0.8rem; margin: 0 0 0.5rem; }
.facts dt { color: var(--dim); }
.facts dd { margin: 0; overflow-wrap: anywhere; }
.panel a, footer a { color: var(--ink); }
.acts { margin: 0 0 1rem; }
.acts form, .panel form { display: flex; gap: 0.5rem; align-items: center; margin: 0.4rem 0; }
.filters { display: flex; gap: 0.9rem; flex-wrap: wrap; font-size: 0.85rem; margin: 0 0 1rem; }
.filters a { color: var(--dim); text-decoration: none; }
.filters a.on { color: var(--ink); text-decoration: underline; }
.activity { display: grid; grid-template-columns: 7.5rem 1fr; gap: 0.8rem; padding: 0.45rem 0;
border-bottom: 1px solid var(--line); font-size: 0.9rem; }
.activity .when { color: var(--dim); white-space: nowrap; }
.activity .what { overflow-wrap: anywhere; }
.tool-sub { margin: 0.25rem 0 0 0.8rem; font-size: 0.88rem; }
.machine-sections { margin-top: 1.5rem; }
.inline-form { display: flex; gap: 0.5rem; align-items: center; margin: 0.35rem 0; flex-wrap: wrap; }
.start { background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--ok);
border-radius: 6px; padding: 0.75rem 1rem; margin-bottom: 1.2rem; }
.start h2 { font-size: 1rem; margin: 0 0 0.4rem; }
.start ol { margin: 0; padding-left: 1.2rem; }
.start li { margin: 0.25rem 0; }
.start li.done { color: var(--dim); }
.start li.done::marker { content: "✓ "; color: var(--ok); }
.start li.optional { color: var(--dim); }
.start a { color: var(--ink); }
+333
View File
@@ -0,0 +1,333 @@
{{/* The console's sections (plan Addendum 4). One list, used by every page
that has a header, so a section is added in one place. The argument is
the section the page belongs to. */}}
{{define "nav"}}<nav class="topnav">
<a href="/"{{if eq . "agents"}} class="on"{{end}}>Agents</a>
<a href="/approvals"{{if eq . "approvals"}} class="on"{{end}}>Waiting for you</a>
<a href="/activity"{{if eq . "activity"}} class="on"{{end}}>Activity</a>
<a href="/machine"{{if eq . "machine"}} class="on"{{end}}>This machine</a>
<a href="/setup"{{if eq . "settings"}} class="on"{{end}}>Settings</a>
</nav>{{end}}
{{define "home-page"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Privatae bastion — agents on this machine</title>
<link rel="stylesheet" href="/assets/app.css">
<link rel="stylesheet" href="/assets/home.css">
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<script src="/assets/htmx.min.js"></script>
</head>
<body>
<header>
<h1>Agents on this machine</h1>
<p class="sub">Every AI agent running here, and what each one is doing.</p>
{{template "nav" "agents"}}
</header>
{{/* Ten seconds: building this walks every process, reads each session's
record and the tail of its transcript. Nothing here changes faster. */}}
<main id="home" hx-get="/fragments/home" hx-trigger="every 10s" hx-swap="innerHTML">
{{template "home" .}}
</main>
<footer>
The detail behind each flag:
<a href="/policy">the stopping rule</a> &middot;
<a href="/agents">programs marked as yours</a> &middot;
<a href="/given">what this machine has given away</a>.<br>
privatae-bastion {{.Version}} &middot; served from this machine only and not reachable from anywhere else.
</footer>
</body>
</html>
{{end}}
{{define "home"}}
{{if .Blind}}
<div class="state bad">
<h2>This machine cannot watch itself.</h2>
<p>This build has no way to list processes on this platform, so an empty list means nothing looked, not
that nothing is running.</p>
</div>
{{end}}
{{if .WalkError}}
<div class="state bad">
<h2>The list of agents could not be read.</h2>
<p>{{.WalkError}}</p>
</div>
{{end}}
{{if .PerAccount}}
<div class="state warn">
<h2>This bastion runs in your own account.</h2>
<p>An AI agent running as you can stop it, and can read the secret behind your code. Install it for the
whole machine to keep agents out.</p>
</div>
{{end}}
{{if or (not .CodeSet) (not .BaselineSet)}}
<div class="start">
<h2>Getting started</h2>
<ol>
<li class="done">See what is running on this machine. That is this page, with no setup.</li>
<li{{if .CodeSet}} class="done"{{end}}>{{if .CodeSet}}Your code is set up.{{else}}<a href="/setup">Set up your code</a>, so you can stop an
agent and answer what agents ask. It takes an authenticator app on your phone.{{end}}</li>
<li{{if .BaselineSet}} class="done"{{end}}>{{if .BaselineSet}}A baseline is recorded.{{else}}<a href="/machine">Record a baseline</a> on
This machine, so anything new that starts here stands out.{{end}}</li>
<li class="optional">{{if .HostsPaired}}A Host is connected.{{else}}Optional: <a href="/setup">connect a Host</a>, if you
run one. Everything above works without it.{{end}}</li>
</ol>
</div>
{{end}}
{{if .Waiting}}
<div class="approval">
<div class="approval-head">{{.Waiting}} request{{if gt .Waiting 1}}s are{{else}} is{{end}} waiting for your approval</div>
<p class="approval-line"><a href="/approvals">Open Waiting for you</a> to read and answer {{if gt .Waiting 1}}them{{else}}it{{end}}.</p>
</div>
{{end}}
{{if not .WalkError}}
<p class="tally">
{{if or .Sessions .Others}}{{if .Sessions}}{{.Sessions}} session{{if gt .Sessions 1}}s{{end}}{{end}}{{if and .Sessions .Others}} &middot; {{end}}{{if .Others}}{{.Others}} other program{{if gt .Others 1}}s{{end}}{{end}}{{else}}No AI agents are running here right now.{{end}}
{{if .WaitingForYou}}<span class="flag waiting">{{.WaitingForYou}} waiting for you</span>{{end}}
{{if .NotYours}}<span class="flag">{{.NotYours}} not marked as yours</span>{{end}}
{{if .WouldStop}}<span class="flag bad">{{.WouldStop}} the stopping rule would stop</span>{{end}}
</p>
{{end}}
{{range .Families}}
<h2 class="family">{{.Heading}}</h2>
{{range .Rows}}
{{if .Service}}
<div class="agent-row service">
<div class="agent-head">{{.Head}}</div>
<div class="agent-line">{{.State}}</div>
</div>
{{else}}
<a class="agent-row{{if .WaitingForYou}} waiting{{end}}" href="/agent?pid={{.PID}}">
<div class="agent-head">{{.Head}}</div>
{{if .State}}<div class="agent-line{{if .WaitingForYou}} urgent{{end}}">{{.State}}</div>{{end}}
{{if .Where}}<div class="agent-line dim">{{.Where}}</div>{{end}}
{{if .RunningNow}}<div class="agent-line"><span class="dim">running now:</span> <code>{{.RunningNow}}</code></div>{{end}}
{{if .ToolServers}}<div class="agent-line dim">tool servers: {{.ToolServers}}</div>{{end}}
{{if or .PermissionsOff .NotYours .WouldStop}}
<div class="flags">
{{if .PermissionsOff}}<span class="flag bad">permission prompts off</span>{{end}}
{{if .NotYours}}<span class="flag">not marked as yours</span>{{end}}
{{if .WouldStop}}<span class="flag bad">the stopping rule would stop it</span>{{end}}
</div>
{{end}}
</a>
{{end}}
{{end}}
{{end}}
<p class="checked-at">Checked {{.CheckedAt}}. Names, titles and statuses are what each agent says about itself.</p>
{{end}}
{{define "agent-page"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Privatae bastion — {{if .Found}}{{.Family}}: {{.Row.Head}}{{else}}agent not running{{end}}</title>
<link rel="stylesheet" href="/assets/app.css">
<link rel="stylesheet" href="/assets/home.css">
</head>
<body>
<header>
{{template "nav" "agents"}}
{{if .Found}}
<h1>{{.Row.Head}}</h1>
<p class="sub">{{.Family}} &middot; pid {{.PID}}</p>
{{else}}
<h1>Not running</h1>
<p class="sub">No agent this bastion can see has pid {{.PID}} now. It may have finished. <a href="/">Back to the agents</a>.</p>
{{end}}
</header>
<main>
{{if .Flash}}<div class="flash {{.FlashClass}}">{{.Flash}}</div>{{end}}
{{if .Found}}
{{if and (not .Row.App) (not .Row.Service)}}
<div class="acts">
{{if .OwnerCode}}
<form method="post" action="/agent/stop" autocomplete="off">
<input type="hidden" name="pid" value="{{.PID}}">
<input type="hidden" name="signature" value="{{.Signature}}">
<input class="code" name="code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code"
placeholder="your code" aria-label="Your code" required>
<button class="no" type="submit">Stop this session</button>
</form>
<p class="dim">Stops it and every process it started. Your code is needed because anything that can drive your
browser can click this button.</p>
{{else}}
<p class="dim">To stop agents from here, <a href="/setup">set up your code</a> first.</p>
{{end}}
</div>
{{end}}
<h3>What it is doing</h3>
<div class="panel">
{{if .Row.State}}<p class="agent-line{{if .Row.WaitingForYou}} urgent{{end}}">{{.Row.State}}</p>{{end}}
{{if .Row.Where}}<p class="agent-line">{{.Row.Where}}</p>{{end}}
{{if .Row.RunningNow}}<p class="agent-line"><span class="dim">running now:</span> <code>{{.Row.RunningNow}}</code></p>{{end}}
{{if .Self}}{{if .Self.LastPrompt}}<p class="agent-line"><span class="dim">last asked:</span> “{{.Self.LastPrompt}}”</p>{{end}}{{end}}
{{if .Row.PermissionsOff}}<p class="agent-line urgent">Permission prompts are off for this session: it acts without asking first.</p>{{end}}
{{if .Self}}
<p class="note">The name, title, status and last prompt are what {{.Family}} says about this session, read from its
own files on this machine{{if not .Self.Verified}}. This platform cannot confirm the record belongs to this exact
process{{end}}.</p>
{{else}}
<p class="note">This agent keeps no session record this bastion can read, so it has no name or title here.</p>
{{end}}
</div>
<h3>Its MCP tool servers</h3>
<div class="panel">
{{if .Tools}}
{{range .Tools}}
<div class="tool">
<div><strong>{{.Name}}</strong> <span class="dim">in {{.Where}}</span></div>
{{range .Problems}}<div class="problem">{{.}}</div>{{else}}<div class="dim">no problems found</div>{{end}}
</div>
{{end}}
{{else}}
<p class="dim">No settings file this bastion can read gives this agent a tool server.</p>
{{end}}
{{if .Row.ToolServers}}<p class="dim">Running now: {{.Row.ToolServers}}</p>{{end}}
</div>
<h3>Whether it is yours</h3>
<div class="panel">
<p>{{if .Sanctioned}}<span class="flag ok">yours</span>{{else}}<span class="flag">not marked as yours</span>{{end}} {{.SanctionWhy}}</p>
{{if and (not .Sanctioned) .MarkID (not .Row.App) .OwnerCode}}
<form method="post" action="/agent/mark" autocomplete="off">
<input type="hidden" name="pid" value="{{.PID}}">
<input type="hidden" name="id" value="{{.MarkID}}">
<input class="code" name="code" inputmode="numeric" pattern="[0-9]*"
autocomplete="one-time-code" placeholder="your code" aria-label="Your code" required>
<button class="go" type="submit">This one is mine</button>
</form>
{{end}}
{{if and (not .Sanctioned) (not .OwnerCode)}}<p class="dim">Marking a program as yours takes <a href="/setup">your code</a>; set it up first.</p>{{end}}
<p class="dim">Every mark, and taking one back: <a href="/agents">programs marked as yours</a>.</p>
</div>
<h3>The stopping rule</h3>
<div class="panel">
<p><span class="flag {{.VerdictCls}}">{{.Verdict}}</span> The rule only rehearses: it stops nothing on its own.</p>
{{range .Reasons}}<p class="reason"><strong>{{.Term}}</strong> {{.Detail}}</p>{{end}}
</div>
<h3>How the bastion knows</h3>
<div class="panel facts">
<dl>
<dt>Kind</dt><dd>{{.Kind}}</dd>
{{if .Exe}}<dt>File</dt><dd><code>{{.Exe}}</code></dd>{{end}}
<dt>Command</dt><dd><code>{{.Cmdline}}</code></dd>
{{if .Origin}}<dt>Started by</dt><dd>{{.Origin}}</dd>{{end}}
{{if .Endpoints}}<dt>Talks to</dt><dd>{{range $i, $e := .Endpoints}}{{if $i}}, {{end}}{{$e}}{{end}}</dd>{{end}}
{{if .Self}}{{if .Self.SessionID}}<dt>Session</dt><dd><code>{{.Self.SessionID}}</code></dd>{{end}}{{end}}
</dl>
{{range .Evidence}}<p class="reason"><span class="dim">{{.Tier}}</span> {{.Signal}}{{if .Value}} &middot; {{.Value}}{{end}}</p>{{end}}
</div>
{{end}}
</main>
<footer>privatae-bastion {{.Version}} &middot; served from this machine only and not reachable from anywhere else.</footer>
</body>
</html>
{{end}}
{{define "activity-page"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Privatae bastion — activity on this machine</title>
<link rel="stylesheet" href="/assets/app.css">
<link rel="stylesheet" href="/assets/home.css">
</head>
<body>
<header>
<h1>Activity</h1>
<p class="sub">What happened on this machine in the last seven days, newest first.</p>
{{template "nav" "activity"}}
</header>
<main>
<p class="filters">
<a href="/activity"{{if eq .Kind ""}} class="on"{{end}}>Everything</a>
<a href="/activity?kind=stop"{{if eq .Kind "stop"}} class="on"{{end}}>Stops ({{index .Counts "stop"}})</a>
<a href="/activity?kind=approval"{{if eq .Kind "approval"}} class="on"{{end}}>Your approvals ({{index .Counts "approval"}})</a>
<a href="/activity?kind=host"{{if eq .Kind "host"}} class="on"{{end}}>What Hosts did ({{index .Counts "host"}})</a>
<a href="/activity?kind=change"{{if eq .Kind "change"}} class="on"{{end}}>Changes ({{index .Counts "change"}})</a>
</p>
{{range .Items}}
<div class="activity{{if .Refused}} refused{{end}}">
<span class="when">{{.At.Local.Format "Jan 2 15:04"}}</span>
<span class="what">{{if .Who}}<strong>{{.Who}}</strong> {{end}}{{.What}}{{if .Refused}} <span class="flag bad">refused</span>{{end}}</span>
</div>
{{else}}
<p class="dim">Nothing has happened here in the last seven days{{if .Kind}} of this kind{{end}}.</p>
{{end}}
<p class="note">Read from the records this bastion keeps on this machine. What a Host did here is also written to the
machine's own system log, where the Host cannot change it.</p>
</main>
<footer>privatae-bastion {{.Version}} &middot; served from this machine only and not reachable from anywhere else.</footer>
</body>
</html>
{{end}}
{{define "machine-schedules"}}
{{if .Items}}
{{range .Items}}
<div class="tool">
<div><strong>{{.Agent}}</strong> <span class="dim">{{if .Schedule}}runs {{.Schedule}} · {{end}}{{.Where}}{{if .User}} · as {{.User}}{{end}}</span></div>
<div><code>{{.Command}}</code></div>
</div>
{{end}}
{{else if .Supported}}
<p class="dim">No scheduled job on this machine starts an AI agent.</p>
{{else}}
<p class="dim">This platform's schedulers cannot be read by this build, so an empty list here means nothing looked.</p>
{{end}}
{{end}}
{{define "machine-tools"}}
{{range .}}
<div class="tool">
<div><strong>{{.Client}}</strong> <span class="dim">{{len .Servers}} tool server{{if gt (len .Servers) 1}}s{{end}}{{if .Bad}}, {{.Bad}} with a problem{{end}}</span></div>
{{range .Servers}}{{if .Problems}}
<div class="tool-sub"><span>{{.Name}}</span>{{range .Problems}}<div class="problem">{{.}}</div>{{end}}</div>
{{end}}{{end}}
</div>
{{else}}
<p class="dim">No MCP tool server is configured in any settings file this bastion can read.</p>
{{end}}
{{end}}
{{define "machine-baseline"}}
{{if .Flash}}<div class="flash {{.FlashClass}}">{{.Flash}}</div>{{end}}
{{if .Has}}
<p>Baseline recorded {{.TakenAt}}, {{.Entries}} entries.{{if .Note}} <span class="flag bad">{{.Note}}</span>{{end}}</p>
<form hx-post="/machine/compare" hx-target="#machine-baseline" hx-swap="innerHTML">
<button class="go" type="submit">Compare what runs now</button>
</form>
{{if .Compared}}
<p class="dim">Compared at {{.ComparedAt}}.{{if .WalkFailed}} The process walk failed ({{.WalkFailed}}): nothing was compared.{{end}}</p>
{{range .Findings}}<div class="problem">{{.}}</div>{{else}}{{if not .WalkFailed}}<p>Nothing running here is outside the baseline.</p>{{end}}{{end}}
<p class="dim">Something started in the last few seconds is judged on the next comparison, so a short command is not reported as new.</p>
{{end}}
{{else}}
<p class="dim">No baseline yet. A baseline records what normally runs here, so anything new stands out.</p>
{{end}}
{{if .OwnerCode}}
<form hx-post="/machine/baseline" hx-target="#machine-baseline" hx-swap="innerHTML" autocomplete="off">
<input class="code" name="code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code"
placeholder="your code" aria-label="Your code" required>
<button type="submit">{{if .Has}}Record a new baseline{{else}}Record the baseline{{end}}</button>
</form>
<p class="dim">Recording makes everything running now count as normal, so it takes your code.</p>
{{else}}
<p class="dim">To record a baseline, <a href="/setup">set up your code</a> first.</p>
{{end}}
{{end}}
+23 -18
View File
@@ -7,6 +7,7 @@
<title>Privatae bastion — this machine</title>
<link rel="stylesheet" href="/assets/app.css">
<link rel="stylesheet" href="/assets/given.css">
<link rel="stylesheet" href="/assets/home.css">
<!-- htmx injects its own indicator stylesheet with insertAdjacentHTML (it does
`<style>` + `.htmx-indicator{opacity:0...}` when includeIndicatorStyles is
true, which is its default). This page's CSP is `default-src 'self'` with no
@@ -25,26 +26,13 @@
<body>
<header>
<h1>This machine</h1>
<p class="sub">The bastion running here, and what it is doing.</p>
<!-- The links off this page, all in the header rather than the footer on
purpose. None of these is a footnote: which host may read this screen,
read these files or open a root shell here; the way in for someone who
has not set the machine up yet; what is running here that nobody has
vouched for; and what a rule would stop if it were allowed to. They sit
outside <main>, so the 3s htmx swap never touches them.
Five units each added one link without knowing about the others, and
each merge duplicated the block. Resolved by hand
at integration; the order below is deliberate — set it up, then what you
gave away, then what is on the disk, then what is running, then what a
rule would do about it. -->
<p class="sub">The bastion running here, and the Hosts it serves.</p>
{{template "nav" "machine"}}
<!-- The older pages, until the steps that replace them land (plan Addendum 4):
what each Host may do here, and the disk. -->
<p class="sub">
<a href="/approvals">Approvals</a> &middot;
<a href="/setup">Set this machine up</a> &middot;
<a href="/given">What this machine has given away</a> &middot;
<a href="/diskmap">This machine's disk</a> &middot;
<a href="/agents">Automated programs running here</a> &middot;
<a href="/policy">What would be stopped</a>
<a href="/diskmap">This machine's disk</a>
</p>
</header>
@@ -58,6 +46,23 @@
{{template "status" .}}
</main>
{{/* Loaded once when the page opens (uimachine.go): they read files and walk
processes, and nothing in them changes at the status block's pace. */}}
<main class="machine-sections">
<h3>Report</h3>
<div class="panel">
<form method="post" action="/machine/report"><button class="go" type="submit">Write report</button></form>
<p class="dim">One Markdown file: the agents running now, scheduled jobs that start one, MCP tool-server problems,
changes since your baseline, and the last seven days of activity. No AI model writes any of it.</p>
</div>
<h3>Scheduled jobs that start an agent</h3>
<div class="panel" hx-get="/fragments/machine/schedules" hx-trigger="load" hx-swap="innerHTML"><p class="dim">Reading…</p></div>
<h3>MCP tool servers on this machine</h3>
<div class="panel" hx-get="/fragments/machine/tools" hx-trigger="load" hx-swap="innerHTML"><p class="dim">Reading…</p></div>
<h3>Changes since your baseline</h3>
<div class="panel" id="machine-baseline" hx-get="/fragments/machine/baseline" hx-trigger="load" hx-swap="innerHTML"><p class="dim">Reading…</p></div>
</main>
<footer>
privatae-bastion {{.Version}} · this page is served from this machine only and
is not reachable from anywhere else.
+123 -33
View File
@@ -7,6 +7,7 @@
<title>Privatae bastion — set this machine up</title>
<link rel="stylesheet" href="/assets/app.css">
<link rel="stylesheet" href="/assets/setup.css">
<link rel="stylesheet" href="/assets/home.css">
<!-- htmx's own indicator stylesheet is injected with insertAdjacentHTML and is
blocked by this page's CSP (default-src 'self', no style-src escape, no
nonce). Turn the injection off rather than widen the policy — see
@@ -16,9 +17,9 @@
</head>
<body>
<header>
<h1>Set this machine up</h1>
<p class="sub">Pairing, a second factor, folders, the anchor and the owner name.
<a href="/">Back to what this machine is doing</a>.</p>
<h1>Settings</h1>
<p class="sub">Your code, connecting a Host, folders, the Host in charge and the owner name.</p>
{{template "nav" "settings"}}
</header>
<main id="setup">
@@ -45,35 +46,107 @@
it — an anchor on a local box means nothing, and a folder needs a host. */}}
<div class="host">
<dl class="host-facts">
<dt>Mode</dt><dd>{{.Mode}}</dd>
<dt>Anchor</dt>
<dt>This computer</dt>
<dd>{{if .Networked}}A shared machine: several Hosts may be connected, and the Host in charge approves each new one.
{{else}}Your own computer: one Host at most, chosen here.{{end}}</dd>
<dt>Host in charge</dt>
{{if .AnchorHost}}<dd>{{.AnchorHost}}{{if .AnchorOwner}} (owner “{{.AnchorOwner}}”){{end}}</dd>
{{else}}<dd>none{{if .AnchorRequired}} <span class="tag bad">required</span>{{end}}</dd>{{end}}
<dt>Owner</dt><dd>{{if .Owner}}{{.Owner}}{{else}}not claimed{{end}}</dd>
{{else if .AnchorRequired}}<dd>none <span class="tag bad">required</span> Until one is chosen, this machine serves no Host.</dd>
{{else}}<dd>not needed on your own computer</dd>{{end}}
<dt>Owner name</dt><dd>{{if .Owner}}{{.Owner}}{{else}}not set{{end}}</dd>
<dt>Installed</dt><dd>{{if .Whole}}for the whole machine{{else}}in your own account <span class="tag warn">agents running as you can stop it</span>{{end}}</dd>
</dl>
{{if .Hosts}}
<dl class="host-facts setup-hosts">
{{range .Hosts}}
<dt>{{.Name}}</dt>
<dd>
{{.State}}
{{if .IsAnchor}}<span class="tag ok">anchor</span>{{end}}
{{if .Active}}
{{if .Serving}}<span class="tag ok">serving</span>
{{else}}<span class="tag bad">not answering</span>{{end}}
{{end}}
{{if .Stepup}}<span class="tag ok">second factor</span>
{{else}}<span class="tag warn">no second factor</span>{{end}}
</dd>
{{end}}
</dl>
{{end}}
</div>
{{/* ── 1. pair ───────────────────────────────────────────────────────────── */}}
<h3>Pair a host</h3>
{{/* ── 0. the owner's own code (stepup_machine.go) ─────────────────────── */}}
<h3>Your code</h3>
<div class="host">
<p class="sub">A six-digit code from an authenticator app on your phone. Stopping an agent, marking one as
yours, and answering what is asked of this machine take it. An AI agent can click a button on this page
if it can drive your browser; it cannot read your phone.</p>
{{if and .Enrol .Enrol.Machine}}
<div class="qr-wrap">
<img class="qr" src="/setup/stepup/qr?e={{.Enrol.Handle}}" width="320" height="320"
alt="QR code for your code on this machine">
<div class="qr-side">
<p>Point your authenticator app at the square. It will appear as <strong>Privatae bastion</strong>.</p>
<form hx-post="/setup/stepup/confirm" hx-target="#setup" hx-swap="innerHTML" autocomplete="off">
<input type="hidden" name="enrol" value="{{.Enrol.Handle}}">
<input class="code" name="code" inputmode="numeric" pattern="[0-9]*"
autocomplete="one-time-code" placeholder="6-digit code"
aria-label="A code from the app you just scanned with">
<button class="go" type="submit">Confirm</button>
</form>
<p class="sub">Nothing is written to this machine until that code matches.</p>
<form hx-post="/setup/stepup/key" hx-target="#stepup-key" hx-swap="innerHTML">
<input type="hidden" name="enrol" value="{{.Enrol.Handle}}">
<button type="submit">Cannot scan? Show the key</button>
</form>
<div id="stepup-key"></div>
</div>
</div>
{{else if .OwnerCode}}
<p><span class="flag ok">set up</span> Your code is set up on this machine.</p>
{{if .Limited}}
<p class="sub">To replace it, at a terminal: <code>sudo -H privatae-bastion stepup-enroll --machine --rotate</code></p>
{{else}}
<form class="setup-form" hx-post="/setup/stepup/start" hx-target="#setup" hx-swap="innerHTML">
<input type="hidden" name="target" value="machine">
<label class="tick"><input type="checkbox" name="replace" value="1"> Replace it (the app set up now stops working)</label>
<button type="submit">Set up a new code</button>
</form>
{{end}}
{{else if .Limited}}
<p class="sub">Set it up once, at a terminal, as the machine's administrator, so no program running as you
can set it up first: <code>sudo -H privatae-bastion stepup-enroll --machine</code></p>
{{else}}
<form class="setup-form" hx-post="/setup/stepup/start" hx-target="#setup" hx-swap="innerHTML">
<input type="hidden" name="target" value="machine">
<button class="go" type="submit">Set up your code</button>
</form>
{{end}}
</div>
<h3>Connected Hosts</h3>
<div class="host">
{{range .Hosts}}
<div class="tool">
<div><strong>{{.Name}}</strong> <span class="dim">{{.State}}</span>
{{if .IsAnchor}}<span class="tag ok">in charge</span>{{end}}
{{if .Active}}{{if .Serving}}<span class="tag ok">connected</span>{{else}}<span class="tag bad">not answering</span>{{end}}{{end}}
{{if .QuarantinedUntil}}<span class="tag bad">quarantined until {{.QuarantinedUntil}}</span>{{end}}
</div>
<p class="sub"><a href="/given">What it may do here</a></p>
{{if $.Whole}}
{{if .QuarantinedUntil}}
<form class="inline-form" hx-post="/setup/host/release" hx-target="#setup" hx-swap="innerHTML" autocomplete="off">
<input type="hidden" name="host" value="{{.Name}}">
<input class="code" name="code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" placeholder="your code" required>
<button class="go" type="submit">Release from quarantine</button>
</form>
{{end}}
{{if not .IsAnchor}}
<form class="inline-form" hx-post="/setup/host/disconnect" hx-target="#setup" hx-swap="innerHTML">
<input type="hidden" name="host" value="{{.Name}}">
<label class="tick"><input type="checkbox" name="confirm" value="1"> disconnect {{.Name}}</label>
<button class="no" type="submit">Disconnect</button>
</form>
{{end}}
{{end}}
</div>
{{else}}
<p class="sub">No Host is connected. That is fine: the bastion watches this machine on its own. To connect one, use
a token from your Host below.</p>
{{end}}
{{if and .Hosts (not .Whole)}}<p class="sub">Disconnecting a Host or releasing one from quarantine needs the
machine's administrator; this bastion runs in your own account.</p>{{end}}
</div>
<h3>Connect a Host</h3>
<div class="host">
<p class="sub">The token comes from your host's “Set up a bastion”. It is used once.</p>
{{if .Limited}}<p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>sudo -H privatae-bastion ui</code></p>{{else}}
<form class="setup-form" hx-post="/setup/pair" hx-target="#setup" hx-swap="innerHTML"
autocomplete="off">
<label for="pair-token">Token</label>
@@ -90,15 +163,17 @@
is written <strong>pending</strong> and starts nothing until the anchor host
approves it. Nothing here needs the bastion restarted.
</div>
{{end}}
</div>
{{/* ── 2. second factor ──────────────────────────────────────────────────── */}}
<h3>A second factor on this machine</h3>
{{if .Hosts}}
<h3>A code for each Host</h3>
<div class="host">
<p class="sub">Once a factor is enrolled, your host can only reach a folder or move
files here after you approve the request on this machine. Your host never sees the code.</p>
{{if .Enrol}}
{{if and .Enrol (not .Enrol.Machine)}}
<div class="qr-wrap">
{{/* The handle addresses the enrolment on the server. The secret is never
in this page: it is in the pixels. */}}
@@ -123,6 +198,8 @@
<div id="stepup-key"></div>
</div>
</div>
{{else if and .Servable .Limited}}
<p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>sudo -H privatae-bastion ui</code></p>
{{else if .Servable}}
{{/* .Servable, not .Hosts: a pending pairing gets no state on this machine
at all, not even a directory (run.go:311), and a factor for it would
@@ -145,15 +222,17 @@
<p class="sub">No host here has its lanes open, and a second factor gates ops a
pairing without lanes cannot run. Nothing is created for a pending host on this
machine until the anchor host approves it.</p>
{{else}}
<p class="sub">Pair a host first — a factor is enrolled per host.</p>
{{end}}
</div>
{{end}}
{{if .Hosts}}
{{/* ── 3. folders ────────────────────────────────────────────────────────── */}}
<h3>Add a folder to your network</h3>
<div class="host">
{{if .Servable}}
{{if and .Servable .Limited}}
<p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>sudo -H privatae-bastion ui</code></p>
{{else if .Servable}}
<p class="sub">Your files stay on this machine. Your host keeps only the index, and
the draft does nothing until you approve it there.</p>
<form class="setup-form" hx-post="/setup/folder/propose" hx-target="#setup" hx-swap="innerHTML"
@@ -187,13 +266,17 @@
propose a folder to yet.</p>
{{end}}
</div>
{{end}}
{{if or .Networked .AnchorHost .Hosts}}
{{/* ── 4. the anchor ─────────────────────────────────────────────────────── */}}
<h3>Which host holds the anchor</h3>
<h3>Which Host is in charge (shared machines)</h3>
<div class="host">
<p class="sub">The anchor host is the one that approves every other host paired here.
It is a choice among the hosts this machine already serves.</p>
{{if .Hosts}}
{{if and .Hosts .Limited}}
<p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>sudo -H privatae-bastion ui</code></p>
{{else if .Hosts}}
<form class="setup-form" hx-post="/setup/anchor/choose" hx-target="#anchor-cmd" hx-swap="innerHTML">
{{range .Hosts}}
<label class="tick">
@@ -221,19 +304,26 @@
and the act stays at a terminal.
</div>
</div>
{{end}}
{{/* ── 5. the owner name ─────────────────────────────────────────────────── */}}
<h3>Who owns this machine</h3>
<div class="host">
<p class="sub">A plain name, shown on this machine's card on every host it serves.
It is optional and it grants nothing.</p>
<form class="setup-form" hx-post="/setup/owner/name" hx-target="#owner-cmd" hx-swap="innerHTML"
<form class="setup-form" hx-post="/setup/owner/name" hx-target="{{if and .Whole .OwnerCode}}#setup{{else}}#owner-cmd{{end}}" hx-swap="innerHTML"
autocomplete="off">
<label for="owner-name">Owner</label>
<input id="owner-name" class="wide" name="owner" type="text" autocomplete="off"
placeholder="{{if .Owner}}{{.Owner}}{{else}}a person or a team{{end}}">
{{if and .Whole .OwnerCode}}
<input class="code" name="code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" placeholder="your code" required>
<button class="go" type="submit">Set the owner name</button>
{{else if not .Whole}}
<button type="submit">Show me the line to run</button>
{{end}}
</form>
{{if and .Whole (not .OwnerCode)}}<p class="sub">Set up your code above, then set the name here.</p>{{end}}
<div id="owner-cmd">{{if .Cmd}}{{template "owner-cmd" .}}{{end}}</div>
<div class="note">
Root-gated for the same reason as the anchor: there is no authority above this
+2 -2
View File
@@ -53,8 +53,8 @@
<h3>Hosts this machine serves</h3>
{{if not .Pairings}}
<div class="host">
<div class="host-name">No host is paired yet.</div>
<p class="sub">Pair one with a token from your host's “Set up a bastion”.</p>
<div class="host-name">No Host is connected.</div>
<p class="sub">That is fine: the bastion watches this machine on its own. Connecting a Host is optional, under <a href="/setup">Settings</a>.</p>
</div>
{{end}}
{{range .Pairings}}