Files
privatae-bastion/client-go/uisanction.go
T
Privatae 4f8285ef18
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 3m36s
ci / fuzz (push) Successful in 3m35s
bastion 0.60.0
2026-11-02 00:10:58 +00:00

463 lines
19 KiB
Go

package main
// uisanction.go — the page where a person says which of the automated things on
// their machine are theirs.
//
// WHY IT EXISTS. Without a way for a user to say THIS ONE IS MINE, a policy
// engine's first act is to kill the user's own editor.
// sanction.go is the record and the predicate; this is
// the only surface that can produce one, because the act being recorded is a
// person looking at a list and recognising something.
//
// ── THERE IS NO KILL BUTTON ON THIS PAGE, AND THAT IS A DECISION ────────────
//
// Containment is host-directed today: `maybeContain` runs off the heartbeat
// response (run.go:575), the host decides and the box carries it out. Whether
// the LOCAL UI may act is an open product question — the local UI's design
// keeps the same question open for turning a granted capability off,
// and uigiven.go's header says why a page must not be shaped like a settings
// screen with the switches missing.
//
// Beyond that: MARKING SOMETHING AS YOURS IS NOT THE SAME ACT AS TERMINATING
// SOMETHING. One is a person vouching, the other takes their work away. Putting
// both on one page would put a kill one mis-click from a vouch, and would settle
// a decision that is not this unit's to settle. So there is no Stop, no
// Terminate, and no disabled control hinting at one — and a test drives the
// whole page with killProcess stubbed to prove nothing here can reach it.
//
// ── THE PAGE MUST NOT BECOME A WAY TO HIDE ──────────────────────────────────
//
// A sanction is the user vouching for something, so the page is built around
// the set that has NOT been vouched for:
//
// - The unsanctioned count is the headline, above everything else.
// - A sanctioned entry is still LISTED. It moves down the page and loses its
// emphasis; it never disappears. A control that makes things vanish is a
// control an attacker wants the user to use.
// - A mark whose program is not running right now is listed too, in its own
// section, with the only button that removes it. Without that, a record for
// something long uninstalled would sit on the machine for ever, vouching for
// an identity nobody can see and nobody can withdraw.
// - A mark that has EXPIRED — written before the identity moved to the
// kernel's executable path (sanction.go, RECORDS WRITTEN BEFORE THIS) — is
// listed in a third section that says plainly that it vouches for nothing
// and why. The user's own record of a decision is not something this program
// deletes on their behalf without saying so, and "your marks stopped
// counting" is exactly the kind of change a security page must not make
// silently.
// - A build with no enumerator says so instead of showing an empty list.
// the README section "Observe" calls this the quiet-versus-blind distinction and
// agents_other.go:5-10 is the whole reason agentScanSupported exists; a page
// that renders "0 unsanctioned" on a box that cannot look is the most
// reassuring sentence this program could print about the least known machine.
// - An unreadable registry is an alarm, never a tick. ui_approvals_flow_test.go:
// 113-151 is the same lesson on the same kind of page: a read that FAILED is
// not a machine with nothing on it.
//
// ── NO SECOND FACTOR, AND THE REASON IS STRUCTURAL ──────────────────────────
//
// Approving a host's request needs a code (ui.go:484). Marking your own editor
// as yours does not, for two reasons. The step-up exists to defend against a
// compromised HOST and nothing here is host-facing. And there is no machine-wide
// second factor to ask for: step-up is enrolled PER PAIRING, in that pairing's
// own DCN dir (ui.go:264-277), while a sanction belongs to the box. ui.go:474-483
// already argues the same shape for the approval throttle — a control that only
// costs the real user is not a control.
import (
"net/http"
"sort"
"strings"
)
// ── what the page shows ─────────────────────────────────────────────────────
// uiAgentRow is one observation, as a person reads it.
type uiAgentRow struct {
// ID is the sanction fingerprint (sanction.go). It is what the form posts,
// and it is deliberately NOT the pid: a pid posted back a second later may
// belong to something else entirely.
//
// EMPTY when the observation has no kernel executable path, because then
// there is no identity to post: addSanction would refuse it, and two
// unreadable processes of the same kind and signature would share the same
// fingerprint over an empty path. The template renders no button for an
// empty ID, so the page cannot offer a control that could only fail.
ID string
PID int
Kind string
Signature string
User string
Origin string
Cmdline string
// Exe is the file the kernel says this process is running — what the mark is
// actually recorded against. Shown beside the command line and never instead
// of it: they are different facts, and the page that asks someone to vouch
// for a program is the last place to conflate them.
Exe string
Endpoints []string
Evidence []evidence
Sanctioned bool
Why string
// Marked: a LOCAL record covers this, so Unmark is the action. Derived from
// the reason constant rather than by looking the record up again — asking
// the predicate is what stops this page drifting away from the gate, the
// same rule uigiven.go:36-41 sets for the caps matrix.
Marked bool
// Unknown: the registry could not be read, so Sanctioned is not a verdict.
// The page must not draw a tick for this (sanction.go, agentSanction).
Unknown bool
// App: contain.go:85 refuses to terminate an app however it is asked, so
// "not marked as yours" means something different here and the row says so.
App bool
}
// uiDormantSanction is a mark that matches nothing running right now. The same
// shape serves the EXPIRED list below — a record, what it named, when and by
// whom — because that is all either section shows; what differs is the sentence
// above them, which is the template's job.
type uiDormantSanction struct {
ID string
What string
Cmdline string
At string
By string
ByUser string
}
type uiAgentsView struct {
Version string
CheckedAt string
// OwnerCode: marking takes the owner's code (markCodeRefusal).
OwnerCode bool
Rows []uiAgentRow
Observed int
Unsanctioned int
Dormant []uiDormantSanction
// Expired: marks written before the identity moved to the kernel's
// executable path (sanction.go, RECORDS WRITTEN BEFORE THIS). They vouch for
// nothing and they are shown ANYWAY, because the alternative is this program
// quietly discarding a decision the user made and never mentioning it.
Expired []uiDormantSanction
// Blind: this build has no enumerator for its platform, so the list below is
// empty because nothing looked — not because nothing is running.
Blind bool
// ReadError is why the registry could not be read. Every row is then
// Unknown, and the page says so instead of counting.
ReadError string
// WalkError is why the process walk failed for this render: the list is
// empty because nothing looked — Blind for one pass, not for the platform.
WalkError string
Flash string
FlashClass string
}
// collectAgentsView asks the observe plane and the registry, once.
//
// observedAgents (contain.go:34) rather than scanAgents directly: it is the same
// var contain consults, so the page and the thing that kills can never be
// answering different questions — and it is the seam every test in this package
// already stubs.
//
// Re-read on every render, never cached, for the reason renderWith gives
// (ui.go:424-427): a stale copy of a security page is worse than no page,
// because it is the page the user trusted.
func collectAgentsView() uiAgentsView {
v := uiAgentsView{
Version: clientVersion,
CheckedAt: uiNow().Format("15:04:05"),
Blind: !agentScanSupported,
OwnerCode: stepupEnrolled(existingMachineFactorDir()),
}
if _, err := loadSanctions(); err != nil {
v.ReadError = err.Error()
}
seen := map[string]bool{}
agents, err := observedAgents()
if err != nil {
// The error wins over anything beside it: a list that arrived with an
// error is not believed, the same rule the kill guard holds.
v.WalkError = err.Error()
agents = nil
}
for _, a := range agents {
sanctioned, why := agentSanction(a)
id := sanctionIdentityOf(a)
row := uiAgentRow{
PID: a.PID,
Kind: a.Kind,
Signature: a.Signature,
User: a.User,
Origin: a.Origin,
Cmdline: a.Cmdline,
Exe: a.Exe,
Endpoints: a.LLMEndpoints,
Evidence: a.Evidence,
Sanctioned: sanctioned,
Why: why,
Marked: why == sanctionWhyLocal || why == sanctionWhyBoth,
Unknown: why == sanctionWhyUnreadable,
App: a.Kind == kindApp,
}
// No identity, no id to post. Asked of the identity rather than of the
// reason string, so the page cannot offer a mark button in some future
// state where the two disagree.
if id.usable() {
row.ID = id.fingerprint()
seen[row.ID] = true
}
v.Rows = append(v.Rows, row)
v.Observed++
if !sanctioned {
v.Unsanctioned++
}
}
sortAgentRows(v.Rows)
// Marks with nothing running behind them, and marks that no longer count at
// all. Skipped entirely when the registry could not be read, because an
// empty "you have marked nothing" list would be the reassuring half of the
// same lie the ReadError banner exists to stop. A failed WALK withholds
// only the dormant half — "not running right now" needs `seen` — while a
// mark that no longer counts is a fact about the record and is still said:
// silence about a control that changed meaning is the worse failure.
if v.ReadError == "" {
recs, err := sanctionRecords()
if err != nil {
v.ReadError = err.Error()
}
for _, r := range recs {
entry := uiDormantSanction{
ID: r.ID, What: r.describeMark(), Cmdline: r.Cmdline,
At: r.At.Local().Format(uiEventTime), By: r.By, ByUser: r.ByUser,
}
// EXPIRED IS TESTED FIRST, before `seen`, and the order is load
// bearing in two different ways — both measured by swapping it:
//
// a record whose key matches something running DISAPPEARS, because
// `seen` swallows it and the user is never told their mark stopped
// counting. (A hand-edited or truncated registry produces this.)
// a record whose key matches nothing lands under "marked
// as yours, but not running", which is the one sentence about it
// that is no longer true.
//
// The first is the worse of the two: silence about a control that
// changed meaning.
if r.stale() {
v.Expired = append(v.Expired, entry)
continue
}
if seen[r.ID] || v.WalkError != "" {
continue
}
v.Dormant = append(v.Dormant, entry)
}
}
return v
}
// sortAgentRows puts the page's subject first: everything nobody has vouched
// for, then everything that has been.
//
// Within the unsanctioned set, an APP sinks below the rest — contain.go:85
// refuses to terminate one however the host asks, so it can never be the thing
// this whole feature exists to make safe to stop. It is still counted and still
// listed; it simply does not lead a list a person reads top-down.
//
// The remaining keys (kind, signature, pid) exist so the order is STABLE across
// a 10-second poll. A list that reshuffles under the cursor is a list nobody
// clicks the right row in.
func sortAgentRows(rows []uiAgentRow) {
rank := func(r uiAgentRow) int {
switch {
case !r.Sanctioned && !r.App:
return 0
case !r.Sanctioned:
return 1
default:
return 2
}
}
sort.SliceStable(rows, func(i, j int) bool {
if a, b := rank(rows[i]), rank(rows[j]); a != b {
return a < b
}
if rows[i].Kind != rows[j].Kind {
return rows[i].Kind < rows[j].Kind
}
if rows[i].Signature != rows[j].Signature {
return rows[i].Signature < rows[j].Signature
}
return rows[i].PID < rows[j].PID
})
}
// ── the routes ──────────────────────────────────────────────────────────────
// agentRoutes registers this page. The shape copies setupRoutes
// (uisetup.go:199-212) exactly, including WHY the method check is inside each
// handler rather than in the mux pattern: Go's method patterns would answer a
// rebound host with 405 before uiRequestAllowed ever ran, and the rebinding wall
// has to be the outermost thing on every route.
func (u *uiServer) agentRoutes(mux *http.ServeMux) {
mux.HandleFunc("/agents", u.guarded(u.agentsPage))
mux.HandleFunc("/fragments/agents", u.guarded(u.agentsFragment))
mux.HandleFunc("/agents/mark", u.guarded(u.agentsMark))
mux.HandleFunc("/agents/unmark", u.guarded(u.agentsUnmark))
}
// renderAgents sets the SAME four headers renderWith does (ui.go:429-434) and
// not one fewer, for the reason uigiven.go:513-518 gives: renderWith is typed to
// uiStatus, widening it belongs to whoever owns that file, and the duplication
// is made safe by a test that compares the two responses header by header.
func (u *uiServer) renderAgents(w http.ResponseWriter, name string, v uiAgentsView) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; form-action 'self'")
w.Header().Set("X-Content-Type-Options", "nosniff")
if err := u.tpl.ExecuteTemplate(w, name, v); err != nil {
uilogf("render %s: %v\n", name, err)
}
}
func (u *uiServer) agentsPage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "GET only", http.StatusMethodNotAllowed)
return
}
u.renderAgents(w, "agents-page", collectAgentsView())
}
func (u *uiServer) agentsFragment(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "GET only", http.StatusMethodNotAllowed)
return
}
u.renderAgents(w, "agents", collectAgentsView())
}
// agentsFlash re-reads the machine and renders the fragment with an outcome
// line, exactly as renderWith does after an approve (ui.go:424-427). The state
// is always re-read: after a mark, the row the person clicked has changed sides.
func (u *uiServer) agentsFlash(w http.ResponseWriter, flash, class string) {
v := collectAgentsView()
v.Flash, v.FlashClass = flash, class
u.renderAgents(w, "agents", v)
}
// agentsMark records one observation as the user's.
//
// THE POSTED ID IS LOOKED UP AMONG WHAT IS RUNNING, never trusted as an
// identity. approvalTarget (ui.go:442-448) takes the same care with a posted
// host name and says why: the form is local and session-authed, but a handler
// that builds state out of a posted string is the shape of bug that turns a
// local UI into something wider. Here it also buys a real property — a person
// can only vouch for something the plane can SEE — so a mark can never be
// pre-planted for an identity that has not run yet.
//
// The cost is one extra scan per click (24ms, measured; see sanction.go). A
// click is a human, and the alternative is trusting the browser about which
// program it is talking about.
func (u *uiServer) agentsMark(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimSpace(r.FormValue("id"))
if id == "" {
u.agentsFlash(w, "Nothing was marked: that form carried no program.", "bad")
return
}
agents, err := observedAgents()
if err != nil {
// The right cause, not "not running any more": nothing was looked at.
u.agentsFlash(w, "Nothing was marked: this machine could not read its processes "+
"just now ("+err.Error()+"). Try again.", "bad")
return
}
for _, a := range agents {
if sanctionIdentityOf(a).fingerprint() != id {
continue
}
if why := markCodeRefusal(id, r.FormValue("code")); why != "" {
u.agentsFlash(w, why, "warn")
return
}
rec, err := addSanction(a, sanctionByLocalUI)
if err != nil {
u.agentsFlash(w, "Not marked: "+err.Error(), "bad")
return
}
u.agentsFlash(w, "Marked as yours: "+rec.sanctionIdentity.describe()+
". It stays marked across restarts, including this program's own updates.", "ok")
return
}
// Said as a fact about the machine rather than as an error about the form:
// between the render and the click the process may simply have exited, which
// is not the user doing anything wrong.
u.agentsFlash(w, "Nothing was marked: that program is not running on this "+
"machine any more. Only something this machine can see right now can be "+
"marked as yours.", "warn")
}
// agentsUnmark withdraws a mark.
//
// BY ID ALONE, with no scan. A mark must be withdrawable for a program that is
// no longer running — see dropSanction — or the dormant list would be a list of
// records with no way to remove any of them.
func (u *uiServer) agentsUnmark(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimSpace(r.FormValue("id"))
dropped, err := dropSanction(id)
switch {
case err != nil:
u.agentsFlash(w, "Not unmarked: "+err.Error(), "bad")
case !dropped:
u.agentsFlash(w, "Nothing to unmark: this machine holds no mark for that program.", "warn")
default:
u.agentsFlash(w, "Unmarked. Nothing at this machine vouches for that program now.", "ok")
}
}
// markCodeRefusal is why a mark may not be made, or "". Once the owner's code
// is set up (stepup_machine.go), marking a program as yours takes it: a mark
// takes the program off "not marked as yours" and out of the stopping rule,
// which is exactly what an agent clicking through a signed-in console in the
// person's browser would want, and the code is on the person's phone. The
// code is spent on an approval bound to this one program and burned at once.
// With no code set up, nothing is marked: set the code up first.
func markCodeRefusal(id, code string) string {
dir := existingMachineFactorDir()
if !stepupEnrolled(dir) {
// Vouching for a program is the act an agent driving the browser
// would most want, so it waits for the code rather than being a click.
return "Not marked: set up your code under Settings first. Marking a program as yours takes it."
}
code = strings.TrimSpace(code)
if code == "" {
return "Not marked: type the code from your authenticator to mark this as yours."
}
purpose := "local_mark|" + id
if err := purposeBad([]string{id}, nil); err != nil {
return "Not marked: " + err.Error()
}
approval, err := machineCodeSpend(dir, uiNow(), func() (string, error) {
return stepupIssueApproval(dir, code, purpose)
})
if err != nil {
return "Not marked: " + err.Error()
}
if err := stepupConsumeApproval(dir, approval, purpose); err != nil {
return "Not marked: " + err.Error()
}
return ""
}