bastion 0.60.0
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
package main
|
||||
|
||||
// activity.go — what happened on this machine, newest first, in one list:
|
||||
// the console's Activity page and `privatae-bastion activity [--since 7d]
|
||||
// [--json]` (plan Addendum 4, step 3).
|
||||
//
|
||||
// It adds no record of its own. It reads the ones this bastion already keeps,
|
||||
// each the authority for its own kind of fact:
|
||||
//
|
||||
// events.json stops, by the owner or a Host, and stops refused
|
||||
// machine-factor/ the owner's approvals and the stops they carried
|
||||
// audit.jsonl out or refused
|
||||
// <pairing>/audit.jsonl what each Host did here (files, stores, op runs)
|
||||
// mgmt_audit.jsonl each Host's management ops and the verdict
|
||||
// pairing_audit.jsonl Hosts paired, granted, revoked
|
||||
// sanctions programs marked as yours, and when
|
||||
// local-deny.json capabilities switched off for a Host, and when
|
||||
//
|
||||
// Each file is read from its tail (activityTailMax), so a long-lived machine
|
||||
// shows its recent past without reading years of log on every load.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
activityTailMax = 256 << 10
|
||||
activityLimit = 300
|
||||
)
|
||||
|
||||
type activityItem struct {
|
||||
At time.Time `json:"at"`
|
||||
Kind string `json:"kind"` // stop, approval, host, change
|
||||
Who string `json:"who,omitempty"`
|
||||
What string `json:"what"`
|
||||
Refused bool `json:"refused,omitempty"`
|
||||
}
|
||||
|
||||
// askedBy is ", asked by <who>" for a row about an agent's ask, else "".
|
||||
func askedBy(row map[string]any) string {
|
||||
if who := actStr(row, "who"); who != "" {
|
||||
return ", asked by " + who
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func collectActivity(since time.Time) []activityItem {
|
||||
var out []activityItem
|
||||
add := func(it activityItem) {
|
||||
if !it.At.Before(since) && it.What != "" {
|
||||
out = append(out, it)
|
||||
}
|
||||
}
|
||||
// Stops, as the page and the Host were told them.
|
||||
if p, err := localEventPath(); err == nil {
|
||||
for _, e := range loadLocalEvents(p) {
|
||||
add(activityItem{At: e.At, Kind: "stop", What: e.Text,
|
||||
Refused: strings.Contains(e.Text, "refused") || strings.Contains(e.Text, "It refused")})
|
||||
}
|
||||
}
|
||||
// The owner's approvals, and stops that did not happen (one that did is
|
||||
// already an event above).
|
||||
if dir := existingMachineFactorDir(); dir != "" {
|
||||
for _, row := range activityRows(filepath.Join(dir, "audit.jsonl")) {
|
||||
op, decision := actStr(row, "op"), actStr(row, "decision")
|
||||
switch {
|
||||
case op == localStopKind && strings.HasPrefix(decision, "not stopped"):
|
||||
target := actStr(row, "path") // "<pid> <signature>", as localStopOnce writes it
|
||||
if pid, sig, ok := strings.Cut(target, " "); ok {
|
||||
target = sig + " (pid " + pid + ")"
|
||||
}
|
||||
add(activityItem{At: rowTime(row), Kind: "stop", Who: "You", What: "approved stopping " + target +
|
||||
", but it was not stopped: " + strings.TrimPrefix(decision, "not stopped: "), Refused: true})
|
||||
case op == "local_baseline":
|
||||
add(activityItem{At: rowTime(row), Kind: "change", Who: "You", What: "recorded a new baseline"})
|
||||
case op == "stepup_approve":
|
||||
add(activityItem{At: rowTime(row), Kind: "approval", Who: "You",
|
||||
What: "approved " + purposeWords(strings.TrimPrefix(decision, "approved: ")) + askedBy(row)})
|
||||
case op == "stepup_deny":
|
||||
add(activityItem{At: rowTime(row), Kind: "approval", Who: "You", Refused: true,
|
||||
What: "denied " + purposeWords(strings.TrimPrefix(decision, "denied: ")) + askedBy(row)})
|
||||
case op == localStopAskOp:
|
||||
// An agent asked (agentask.go). Who is the asker in the words
|
||||
// the owner read on the request.
|
||||
add(activityItem{At: rowTime(row), Kind: "approval", Who: actStr(row, "who"), What: decision})
|
||||
case op == askHoldOp:
|
||||
who, what := "This machine", "held asks from "+actStr(row, "path")+" ("+decision+")"
|
||||
if decision == "lifted by the owner" {
|
||||
who, what = "You", "lifted the hold on asks from "+actStr(row, "path")
|
||||
}
|
||||
add(activityItem{At: rowTime(row), Kind: "approval", Who: who, What: what, Refused: who != "You"})
|
||||
}
|
||||
}
|
||||
}
|
||||
// What each Host did here.
|
||||
if cfgs, err := loadPairings(); err == nil {
|
||||
for _, c := range cfgs {
|
||||
if !c.active() {
|
||||
continue // nothing is created for a pending pairing (run.go)
|
||||
}
|
||||
for _, row := range activityRows(filepath.Join(c.dcnDir(), "audit.jsonl")) {
|
||||
decision := actStr(row, "decision")
|
||||
what := opWords(actStr(row, "op"))
|
||||
if p := actStr(row, "path"); p != "" {
|
||||
what += " " + p
|
||||
}
|
||||
if decision != "" {
|
||||
what += " (" + decision + ")"
|
||||
}
|
||||
add(activityItem{At: rowTime(row), Kind: "host", Who: "Host " + c.HostName, What: what,
|
||||
Refused: strings.HasPrefix(decision, "refused") || strings.HasPrefix(decision, "denied")})
|
||||
}
|
||||
}
|
||||
}
|
||||
if p, err := mgmtAuditPath(); err == nil {
|
||||
for _, row := range activityRows(p) {
|
||||
verdict := actStr(row, "verdict")
|
||||
what := "ran " + actStr(row, "op")
|
||||
if verdict != "" {
|
||||
what += " (" + verdict
|
||||
if why := actStr(row, "why"); why != "" && verdict != "ok" {
|
||||
what += ": " + why
|
||||
}
|
||||
what += ")"
|
||||
}
|
||||
add(activityItem{At: rowTime(row), Kind: "host", Who: "Host " + actStr(row, "self"), What: what,
|
||||
Refused: verdict != "" && verdict != "ok" && verdict != "allowed"})
|
||||
}
|
||||
}
|
||||
if d, err := configDir(); err == nil {
|
||||
for _, row := range activityRows(filepath.Join(d, auditFilePairing)) {
|
||||
add(activityItem{At: rowTime(row), Kind: "change", Who: "This machine",
|
||||
What: opWords(actStr(row, "op")) + " " + actStr(row, "subject") + ": " + actStr(row, "decision"),
|
||||
Refused: strings.HasPrefix(actStr(row, "decision"), "refused")})
|
||||
}
|
||||
}
|
||||
// The owner's own changes.
|
||||
if recs, err := sanctionRecords(); err == nil {
|
||||
for _, r := range recs {
|
||||
add(activityItem{At: r.At, Kind: "change", Who: "You", What: "marked " + r.describeMark() + " as yours"})
|
||||
}
|
||||
}
|
||||
localDenyMu.Lock()
|
||||
f, err := localDenyLoad()
|
||||
localDenyMu.Unlock()
|
||||
if err == nil {
|
||||
for key, at := range f.At {
|
||||
host, fam, _ := strings.Cut(key, "/")
|
||||
if t, err := time.Parse(time.RFC3339, at); err == nil {
|
||||
add(activityItem{At: t, Kind: "change", Who: "You", What: "switched " + fam + " off for " + host})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].At.After(out[j].At) })
|
||||
if len(out) > activityLimit {
|
||||
out = out[:activityLimit]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// activityRows is the JSON rows in the tail of a JSONL file.
|
||||
func activityRows(path string) []map[string]any {
|
||||
var rows []map[string]any
|
||||
for _, line := range tailLines(path, activityTailMax) {
|
||||
var m map[string]any
|
||||
if json.Unmarshal(line, &m) == nil {
|
||||
rows = append(rows, m)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func actStr(m map[string]any, k string) string { s, _ := m[k].(string); return cleanText(s, 300) }
|
||||
|
||||
func rowTime(m map[string]any) time.Time {
|
||||
t, _ := time.Parse(time.RFC3339, actStr(m, "ts"))
|
||||
return t
|
||||
}
|
||||
|
||||
// opWords names the ops a person meets most in words; anything else keeps its
|
||||
// name, which is what the audit file and the Host both call it.
|
||||
func opWords(op string) string {
|
||||
switch op {
|
||||
case "dcn_read", "read_range":
|
||||
return "read"
|
||||
case "dcn_list", "list":
|
||||
return "listed"
|
||||
case "dcn_stat", "stat":
|
||||
return "looked at"
|
||||
case "pair", "pairing_add":
|
||||
return "Host paired"
|
||||
case "pairing_grant", "pairing_grant_sync":
|
||||
return "Host granted"
|
||||
case "pairing_revoke", "unpair":
|
||||
return "Host removed"
|
||||
case "quarantine_clear":
|
||||
return "Host released from quarantine"
|
||||
case "claim_owner":
|
||||
return "owner name set to"
|
||||
case "stepup_approve":
|
||||
return "approved"
|
||||
}
|
||||
return op
|
||||
}
|
||||
|
||||
// purposeWords says an approval's purpose the way the request said it.
|
||||
func purposeWords(purpose string) string {
|
||||
if pid, sig, ok := parseLocalStopPurpose(purpose); ok {
|
||||
return fmt.Sprintf("stopping %s (pid %d)", sig, pid)
|
||||
}
|
||||
if kind, _, _ := strings.Cut(purpose, "|"); kind != "" {
|
||||
return "a request (" + kind + ")"
|
||||
}
|
||||
return "a request"
|
||||
}
|
||||
|
||||
// ── the command ─────────────────────────────────────────────────────────────
|
||||
|
||||
const activityUsage = "usage: privatae-bastion activity [--since 7d] [--json]"
|
||||
|
||||
func cmdActivity(args []string) error {
|
||||
sinceFlag, asJSON := "", false
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch {
|
||||
case args[i] == "--json":
|
||||
asJSON = true
|
||||
case args[i] == "--since" && i+1 < len(args):
|
||||
if _, err := parseSince(args[i+1]); err != nil {
|
||||
return err
|
||||
}
|
||||
sinceFlag = args[i+1]
|
||||
i++
|
||||
default:
|
||||
return errors.New(activityUsage)
|
||||
}
|
||||
}
|
||||
v, err := readMachine("activity", readOpts{Since: sinceFlag}, readActivity)
|
||||
if err != nil {
|
||||
return laneRefusalError("activity", err)
|
||||
}
|
||||
if asJSON {
|
||||
return printJSON(os.Stdout, v)
|
||||
}
|
||||
printActivity(os.Stdout, v.Activity)
|
||||
return nil
|
||||
}
|
||||
|
||||
// activityRead is `activity`: what happened here, newest first.
|
||||
type activityRead struct {
|
||||
Activity []activityItem `json:"activity"`
|
||||
}
|
||||
|
||||
func readActivity(o readOpts) (activityRead, error) {
|
||||
since, err := o.since(7 * 24 * time.Hour)
|
||||
if err != nil {
|
||||
return activityRead{}, err
|
||||
}
|
||||
items := collectActivity(time.Now().Add(-since))
|
||||
if items == nil {
|
||||
items = []activityItem{}
|
||||
}
|
||||
return activityRead{Activity: items}, nil
|
||||
}
|
||||
|
||||
// parseSince takes Go durations and whole days: 90m, 12h, 7d.
|
||||
func parseSince(s string) (time.Duration, error) {
|
||||
if d, ok := strings.CutSuffix(s, "d"); ok {
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(d, "%d", &n); err == nil && n > 0 && n <= 3650 {
|
||||
return time.Duration(n) * 24 * time.Hour, nil
|
||||
}
|
||||
}
|
||||
if d, err := time.ParseDuration(s); err == nil && d > 0 {
|
||||
return d, nil
|
||||
}
|
||||
return 0, fmt.Errorf("--since %q: give a duration like 12h or 7d", s)
|
||||
}
|
||||
|
||||
func printActivity(w io.Writer, items []activityItem) {
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(w, "Nothing has happened on this machine in that time.")
|
||||
return
|
||||
}
|
||||
for _, it := range items {
|
||||
who := it.Who
|
||||
if who != "" {
|
||||
who += " "
|
||||
}
|
||||
mark := ""
|
||||
if it.Refused {
|
||||
mark = " [refused]"
|
||||
}
|
||||
fmt.Fprintf(w, "%s %s%s%s\n", it.At.Local().Format("Jan 2 15:04"), who, it.What, mark)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// activityWorld writes the records the bastion keeps, one of each kind, at
|
||||
// known times. Everything in them is invented.
|
||||
func activityWorld(t *testing.T) {
|
||||
t.Helper()
|
||||
uiTestConfigDir(t)
|
||||
now := time.Now().UTC()
|
||||
p, err := localEventPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev, _ := json.Marshal([]localEvent{
|
||||
{Kind: eventContain, At: now.Add(-3 * time.Hour), Text: "You stopped aider (pid 27) running on this machine."},
|
||||
{Kind: eventContain, At: now.Add(-10 * 24 * time.Hour), Text: "You stopped goose (pid 9) running on this machine."},
|
||||
})
|
||||
if err := os.WriteFile(p, ev, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir, err := machineFactorDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dcnAudit(dir, "stepup_approve", 0, "", "approved: local_stop|27|aider")
|
||||
dcnAudit(dir, localStopKind, 0, "31 claude-code", "not stopped: pid 31 is now aider, not the claude-code that was approved")
|
||||
givenPairing(t, "h1.host.privatae.ai", pairingActive)
|
||||
if err := localDenySet("h1.host.privatae.ai", "dcn", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityGathersEveryRecordNewestFirstAndWithinTheWindow(t *testing.T) {
|
||||
activityWorld(t)
|
||||
items := collectActivity(time.Now().Add(-7 * 24 * time.Hour))
|
||||
var lines []string
|
||||
for _, it := range items {
|
||||
lines = append(lines, it.Who+" "+it.What)
|
||||
}
|
||||
all := strings.Join(lines, "\n")
|
||||
for _, want := range []string{
|
||||
"You stopped aider (pid 27) running on this machine.",
|
||||
"You approved stopping aider (pid 27)",
|
||||
"You approved stopping claude-code (pid 31), but it was not stopped: pid 31 is now aider",
|
||||
"You switched dcn off for h1.host.privatae.ai",
|
||||
} {
|
||||
if !strings.Contains(all, want) {
|
||||
t.Errorf("activity does not say %q:\n%s", want, all)
|
||||
}
|
||||
}
|
||||
if strings.Contains(all, "goose") {
|
||||
t.Error("a stop older than the window was listed")
|
||||
}
|
||||
for i := 1; i < len(items); i++ {
|
||||
if items[i].At.After(items[i-1].At) {
|
||||
t.Fatalf("not newest first: %v after %v", items[i].At, items[i-1].At)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheActivityPageFiltersByKind(t *testing.T) {
|
||||
activityWorld(t)
|
||||
u := uiTestServer(t)
|
||||
h, cookie := u.handler(), uiSignedIn(t, u)
|
||||
all := uiGet(t, h, "/activity", cookie).Body.String()
|
||||
if !strings.Contains(all, "You stopped aider") || !strings.Contains(all, "switched dcn off") {
|
||||
t.Errorf("the page:\n%s", firstLines(all))
|
||||
}
|
||||
stops := uiGet(t, h, "/activity?kind=stop", cookie).Body.String()
|
||||
if !strings.Contains(stops, "You stopped aider") || strings.Contains(stops, "switched dcn off") {
|
||||
t.Errorf("the stops filter let other kinds through")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSinceTakesDaysAndDurations(t *testing.T) {
|
||||
for in, want := range map[string]time.Duration{"7d": 7 * 24 * time.Hour, "12h": 12 * time.Hour, "90m": 90 * time.Minute} {
|
||||
if got, err := parseSince(in); err != nil || got != want {
|
||||
t.Errorf("%s = %v %v", in, got, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"0d", "-1h", "soon", "99999d"} {
|
||||
if _, err := parseSince(bad); err == nil {
|
||||
t.Errorf("%q was accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package main
|
||||
|
||||
// agentask.go — how something running as a person asks the machine's bastion
|
||||
// to stop an agent, when the bastion is not theirs.
|
||||
//
|
||||
// The bastion installed for the whole machine keeps its state in its own
|
||||
// account. An agent running as the person cannot write a request there, so
|
||||
// `stop` run by it had nowhere to ask, and asking is the one thing an agent
|
||||
// managing the bastion for the person must be able to do (2026-10-30).
|
||||
// This is the context socket's mechanism (contextsock.go) for a second
|
||||
// question: any local account may connect; WHO connected comes from the
|
||||
// kernel, never from the request; it may only ASK. The ask lands in the
|
||||
// owner's factor as a pending request (stepup_machine.go), shown on Waiting
|
||||
// for you and answered with the owner's code. Nothing here approves anything.
|
||||
//
|
||||
// Who asked is written into the request from the kernel's record of the
|
||||
// connecting process: its account, and on Linux its pid, walked up to the
|
||||
// agent it belongs to, so the owner reads "asked from Claude Code session
|
||||
// notes-b2", not a sentence the asker wrote.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
askSockName = "ask.sock"
|
||||
askMaxBytes = 4 << 10
|
||||
askSockInFlight = 16
|
||||
askMaxPendingPer = 5 // waiting requests one account may have at once
|
||||
)
|
||||
|
||||
type agentAskRequest struct {
|
||||
Op string `json:"op"` // "stop", "status" (askstatus.go), "console" (consolelane.go) or "read" (reads.go)
|
||||
PID int `json:"pid"`
|
||||
Request string `json:"request,omitempty"` // "status": the id `stop` printed
|
||||
// "read": which one, and its flags (readOpts).
|
||||
Read string `json:"read,omitempty"`
|
||||
Since string `json:"since,omitempty"`
|
||||
Prompts bool `json:"prompts,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Machine bool `json:"machine,omitempty"`
|
||||
}
|
||||
|
||||
type agentAskReply struct {
|
||||
OK bool `json:"ok"`
|
||||
Request string `json:"request,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
URL string `json:"url,omitempty"` // "console": the link that signs the owner in
|
||||
// "status": where the ask is (askStatus) and, once carried out, what
|
||||
// happened.
|
||||
State string `json:"state,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
}
|
||||
|
||||
func askSockPath() string { return filepath.Join(contextSockDir, askSockName) }
|
||||
|
||||
// askPeerPIDFn is the kernel's pid for the connecting process: SO_PEERCRED on
|
||||
// Linux, 0 where the platform does not say (then only the account is named).
|
||||
var askPeerPIDFn = askPeerPID
|
||||
|
||||
// askPeerIdentityFn is the kernel's account for the connecting process; a
|
||||
// variable so a suite run as root can stand in for the owner's account.
|
||||
var askPeerIdentityFn = contextPeerIdentity
|
||||
|
||||
func startAskSock(done <-chan struct{}) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return // a named pipe, as context.sock has, is not built yet
|
||||
}
|
||||
p := askSockPath()
|
||||
ln, err := contextSockListen(p)
|
||||
if err != nil {
|
||||
uilogf("ask socket not available (%v) — an agent running as another account cannot ask "+
|
||||
"this bastion to stop anything\n", err)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
<-done
|
||||
_ = ln.Close()
|
||||
_ = os.Remove(p)
|
||||
}()
|
||||
go func() {
|
||||
slots := make(chan struct{}, askSockInFlight)
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
go func() {
|
||||
defer func() { <-slots }()
|
||||
askSockServe(c)
|
||||
}()
|
||||
default:
|
||||
_ = c.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
uilogf("ask socket listening on %s\n", p)
|
||||
}
|
||||
|
||||
var askMu sync.Mutex // one ask filed at a time: the per-account count is read then written
|
||||
|
||||
func askSockServe(c net.Conn) {
|
||||
defer c.Close()
|
||||
_ = c.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
uid, user, err := askPeerIdentityFn(c)
|
||||
if err != nil {
|
||||
_ = json.NewEncoder(c).Encode(agentAskReply{Why: "this machine could not establish who is asking (" +
|
||||
err.Error() + "), so nothing was asked. An ask nobody can attribute is one nobody can answer."})
|
||||
return
|
||||
}
|
||||
var req agentAskRequest
|
||||
if err := json.NewDecoder(io.LimitReader(c, askMaxBytes)).Decode(&req); err != nil {
|
||||
_ = json.NewEncoder(c).Encode(agentAskReply{User: user, Why: "the request could not be read"})
|
||||
return
|
||||
}
|
||||
if req.Op == "console" {
|
||||
r := agentAskReply{User: user}
|
||||
if link, err := consoleLaneEntry(uid, user); err != nil {
|
||||
r.Why = err.Error()
|
||||
} else {
|
||||
r.OK, r.URL = true, link
|
||||
}
|
||||
_ = json.NewEncoder(c).Encode(r)
|
||||
return
|
||||
}
|
||||
if req.Op == "status" {
|
||||
_ = json.NewEncoder(c).Encode(askStatus(existingMachineFactorDir(), req.Request, user, false))
|
||||
return
|
||||
}
|
||||
if req.Op == "read" {
|
||||
_ = c.SetDeadline(time.Now().Add(readDeadline))
|
||||
_ = json.NewEncoder(c).Encode(serveRead(uid, user, req))
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(c).Encode(fileAsk(req, user, askPeerPIDFn(c)))
|
||||
}
|
||||
|
||||
// fileAsk turns one ask into a pending request of the machine, or says why not.
|
||||
func fileAsk(req agentAskRequest, user string, peerPID int) agentAskReply {
|
||||
r := agentAskReply{User: user}
|
||||
if req.Op != "stop" {
|
||||
r.Why = "this bastion is only asked to stop agents"
|
||||
return r
|
||||
}
|
||||
dir := existingMachineFactorDir()
|
||||
if !stepupEnrolled(dir) {
|
||||
r.Why = "the owner of this machine has not set up their code, so nothing can be asked of it yet"
|
||||
return r
|
||||
}
|
||||
now := time.Now()
|
||||
if until := askHeldUntil(dir, user, now); !until.IsZero() {
|
||||
r.Why = askHoldRefusal(user, until)
|
||||
return r
|
||||
}
|
||||
// A refusal that is the asker's own doing counts toward its hold.
|
||||
refuse := func(why string) agentAskReply {
|
||||
askStrike(dir, user, now)
|
||||
r.Why = why
|
||||
return r
|
||||
}
|
||||
observed := observedAgentMap()
|
||||
if why := stopRefusal(req.PID, observed); why != "" {
|
||||
return refuse(why)
|
||||
}
|
||||
a := observed[req.PID]
|
||||
purpose, err := purposeForLocalStop(req.PID, a.Signature)
|
||||
if err != nil {
|
||||
r.Why = err.Error()
|
||||
return r
|
||||
}
|
||||
view := buildAgentViews([]agentObs{a}, processTree())[0]
|
||||
details := map[string]any{"kind": localStopKind, "pid": strconv.Itoa(req.PID), "signature": a.Signature,
|
||||
"agent": familyLabel(a.Signature)}
|
||||
if view.Folder != "" {
|
||||
details["folder"] = view.Folder
|
||||
}
|
||||
if view.Self != nil {
|
||||
details["session"], details["title"] = view.Self.Name, view.Self.Title
|
||||
}
|
||||
by := "a program running as " + user
|
||||
if peerPID > 1 {
|
||||
if who, ok := askingAgent(peerPID, observed); ok {
|
||||
by = who + ", running as " + user
|
||||
}
|
||||
}
|
||||
details["asked_by"] = by
|
||||
details["asker"], details["target"] = askParties(user, peerPID, req.PID, observed)
|
||||
askMu.Lock()
|
||||
defer askMu.Unlock()
|
||||
// Asking again for a stop this account already has waiting is the same
|
||||
// request (stepupNewRequest hands it back), not a new one over the limit.
|
||||
if n, same := askPendingBy(dir, user, purpose); n >= askMaxPendingPer && !same {
|
||||
return refuse("you already have " + strconv.Itoa(askMaxPendingPer) + " requests waiting for the owner; " +
|
||||
"wait for them to be answered")
|
||||
}
|
||||
details["asked_by_account"] = user
|
||||
id, err := stepupNewRequest(dir, purpose, details)
|
||||
if errors.Is(err, errRefusedRecently) {
|
||||
return refuse("the machine's owner refused this same stop within the last 24 hours, so it cannot be " +
|
||||
"asked again yet")
|
||||
}
|
||||
if err != nil {
|
||||
r.Why = err.Error()
|
||||
return r
|
||||
}
|
||||
r.OK, r.Request, r.Text = true, id[:8], describeRequest(stepupRequest{Details: details})
|
||||
auditLocalStopAsk(dir, req.PID, a.Signature, details)
|
||||
return r
|
||||
}
|
||||
|
||||
// auditLocalStopAsk records that an ask was filed, naming who asked for what.
|
||||
func auditLocalStopAsk(dir string, pid int, signature string, details map[string]any) {
|
||||
dcnAuditWith(dir, localStopAskOp, 0, strconv.Itoa(pid)+" "+signature,
|
||||
"asked to stop "+describeStopTarget(details), askPartiesOf(details))
|
||||
}
|
||||
|
||||
const localStopAskOp = "local_stop_ask"
|
||||
|
||||
// askPendingBy is how many requests user has waiting, and whether one of them
|
||||
// is for purpose.
|
||||
func askPendingBy(dir, user, purpose string) (n int, same bool) {
|
||||
stepupMu.Lock()
|
||||
m := requestsLoad(dir)
|
||||
stepupMu.Unlock()
|
||||
for _, id := range sortedPending(m) {
|
||||
if a, _ := m[id].Details["asked_by_account"].(string); a == user {
|
||||
n++
|
||||
same = same || m[id].Purpose == purpose
|
||||
}
|
||||
}
|
||||
return n, same
|
||||
}
|
||||
|
||||
// askViaSock is the asking side: `stop` run where it cannot write the
|
||||
// machine's state itself.
|
||||
func askViaSock(req agentAskRequest) (agentAskReply, error) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return agentAskReply{}, errors.New("asking another account's bastion is not built for Windows yet")
|
||||
}
|
||||
c, err := net.DialTimeout("unix", askSockPath(), 3*time.Second)
|
||||
if err != nil {
|
||||
return agentAskReply{}, err
|
||||
}
|
||||
defer c.Close()
|
||||
_ = c.SetDeadline(time.Now().Add(15 * time.Second))
|
||||
if err := json.NewEncoder(c).Encode(req); err != nil {
|
||||
return agentAskReply{}, err
|
||||
}
|
||||
var r agentAskReply
|
||||
err = json.NewDecoder(io.LimitReader(c, askMaxBytes*4)).Decode(&r)
|
||||
return r, err
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func askPeerPID(c net.Conn) int {
|
||||
uc, ok := c.(*net.UnixConn)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
raw, err := uc.SyscallConn()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
pid := 0
|
||||
_ = raw.Control(func(fd uintptr) {
|
||||
if cred, err := unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED); err == nil {
|
||||
pid = int(cred.Pid)
|
||||
}
|
||||
})
|
||||
return pid
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import "net"
|
||||
|
||||
// Off Linux only the account is named; the asking agent is not walked to.
|
||||
func askPeerPID(net.Conn) int { return 0 }
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func askWorld(t *testing.T, enrol bool) string {
|
||||
t.Helper()
|
||||
uiTestConfigDir(t)
|
||||
stubStopWorld(t, []agentObs{
|
||||
{PID: 101, Kind: kindAgent, Signature: "aider", User: "u"},
|
||||
{PID: 102, Kind: kindAgent, Signature: "aider", User: "u"},
|
||||
{PID: 103, Kind: kindAgent, Signature: "aider", User: "u"},
|
||||
{PID: 104, Kind: kindAgent, Signature: "aider", User: "u"},
|
||||
{PID: 105, Kind: kindAgent, Signature: "aider", User: "u"},
|
||||
{PID: 106, Kind: kindAgent, Signature: "aider", User: "u"},
|
||||
{PID: 300, Kind: kindAgent, Signature: "claude-code", User: "u"},
|
||||
{PID: 4532, Kind: kindApp, Signature: "claude-desktop", User: "u"},
|
||||
}, map[int]int{310: 300, 311: 310}) // 311: a shell the claude-code session started
|
||||
prev := homeForUserFn
|
||||
t.Cleanup(func() { homeForUserFn = prev })
|
||||
homeForUserFn = func(string) string { return "" }
|
||||
d, err := machineFactorDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if enrol {
|
||||
enrolFactorAt(t, d, machineTestSecret)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// An ask from another account becomes a pending request naming the agent
|
||||
// that asked, from the kernel's pid, and approves nothing.
|
||||
func TestAnAskBecomesARequestNamingTheAgentThatAsked(t *testing.T) {
|
||||
d := askWorld(t, true)
|
||||
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
|
||||
if !r.OK {
|
||||
t.Fatalf("refused: %s", r.Why)
|
||||
}
|
||||
if !strings.Contains(r.Text, "Asked from Claude Code (pid 300), running as u.") {
|
||||
t.Errorf("the request reads %q", r.Text)
|
||||
}
|
||||
pending := sortedPending(requestsLoad(d))
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("pending %v", pending)
|
||||
}
|
||||
for _, a := range approvalsLoad(d) {
|
||||
t.Fatalf("an ask produced an approval: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAskIsRefusedWithoutTheOwnersCodeOrForAnApp(t *testing.T) {
|
||||
askWorld(t, false)
|
||||
if r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 0); r.OK || !strings.Contains(r.Why, "not set up their code") {
|
||||
t.Errorf("no owner code: %+v", r)
|
||||
}
|
||||
askWorld(t, true)
|
||||
if r := fileAsk(agentAskRequest{Op: "stop", PID: 4532}, "u", 0); r.OK || !strings.Contains(r.Why, "an app you are running") {
|
||||
t.Errorf("an app: %+v", r)
|
||||
}
|
||||
if r := fileAsk(agentAskRequest{Op: "approve", PID: 101}, "u", 0); r.OK {
|
||||
t.Errorf("an op other than stop was filed: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// One account cannot bury the owner in requests.
|
||||
func TestOneAccountHasAtMostFiveAsksWaiting(t *testing.T) {
|
||||
d := askWorld(t, true)
|
||||
for i := 0; i < askMaxPendingPer; i++ {
|
||||
if r := fileAsk(agentAskRequest{Op: "stop", PID: 101 + i}, "u", 0); !r.OK {
|
||||
t.Fatalf("ask %d refused: %s", i+1, r.Why)
|
||||
}
|
||||
}
|
||||
if r := fileAsk(agentAskRequest{Op: "stop", PID: 106}, "u", 0); r.OK || !strings.Contains(r.Why, "already have 5") {
|
||||
t.Errorf("the sixth: %+v", r)
|
||||
}
|
||||
if r := fileAsk(agentAskRequest{Op: "stop", PID: 106}, "other", 0); !r.OK {
|
||||
t.Errorf("another account was held to the first one's count: %s", r.Why)
|
||||
}
|
||||
// Asking again for the same agent is the same request, not another one:
|
||||
// it is handed back (so the asker can follow it), and nothing new is filed.
|
||||
before := len(sortedPending(requestsLoad(d)))
|
||||
if r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 0); !r.OK ||
|
||||
requestsLoad(d)[fullRequestID(t, d, r.Request)].Details["pid"] != "101" {
|
||||
t.Errorf("a repeat ask past the cap: %+v", r)
|
||||
}
|
||||
if after := len(sortedPending(requestsLoad(d))); after != before {
|
||||
t.Errorf("a repeat ask past the cap filed a new request: %d -> %d pending", before, after)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package main
|
||||
|
||||
// agents_cli.go — `privatae-bastion agents [--json] [--prompts]`: every agent
|
||||
// on this machine and what it is doing, for the person at it and for an agent
|
||||
// they ask to manage the bastion (agentsession.go builds each view).
|
||||
//
|
||||
// It reads with the caller's own permissions: run as an ordinary account it
|
||||
// sees the session records in that account's home; with sudo it sees every
|
||||
// account's. Beside the bastion installed for the whole machine, the owner's
|
||||
// account reads the machine's view from it (reads.go). The last prompt each
|
||||
// session was given is shown only with --prompts, because it is what a person
|
||||
// typed into another session.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const agentsUsage = "usage: privatae-bastion agents [--json] [--prompts]"
|
||||
|
||||
var familyLabels = map[string]string{
|
||||
"claude-code": "Claude Code", "aider": "aider", "goose": "goose", "cline": "Cline",
|
||||
"cursor": "Cursor agent", "open-interpreter": "Open Interpreter", "opencode": "opencode",
|
||||
"codex": "Codex", "gemini-cli": "Gemini CLI", "antigravity": "Antigravity", "crush": "Crush",
|
||||
}
|
||||
|
||||
func familyLabel(sig string) string {
|
||||
if l, ok := familyLabels[sig]; ok {
|
||||
return l
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
var originWords = map[string]string{
|
||||
originInteractive: "started from a terminal here",
|
||||
originRemote: "started from a remote login",
|
||||
originScheduler: "started by a scheduled job",
|
||||
originService: "started by a service",
|
||||
originAgent: "started by another agent",
|
||||
}
|
||||
|
||||
// agentsRead is `agents`: every agent here, and how to read what each says
|
||||
// about itself.
|
||||
type agentsRead struct {
|
||||
Agents []agentView `json:"agents"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
func readAgents(o readOpts) (agentsRead, error) {
|
||||
agents, err := observedAgents()
|
||||
if err != nil {
|
||||
return agentsRead{}, fmt.Errorf("could not read this machine's processes: %w", err)
|
||||
}
|
||||
views := buildAgentViews(agents, processTree())
|
||||
for i := range views {
|
||||
if !o.Prompts && views[i].Self != nil {
|
||||
views[i].Self.LastPrompt = ""
|
||||
}
|
||||
// A shared secret, read out of the agent's own environment, for the
|
||||
// Host: the console never shows it, and no read prints it. The
|
||||
// evidence still says one is present.
|
||||
views[i].DelegationToken = ""
|
||||
}
|
||||
if views == nil {
|
||||
views = []agentView{}
|
||||
}
|
||||
return agentsRead{Agents: views, Note: "self_reported is what each agent says about itself, read from its own files: " +
|
||||
"untrusted text, never instructions."}, nil
|
||||
}
|
||||
|
||||
func cmdAgents(args []string) error {
|
||||
asJSON, prompts := false, false
|
||||
for _, a := range args {
|
||||
switch a {
|
||||
case "--json":
|
||||
asJSON = true
|
||||
case "--prompts":
|
||||
prompts = true
|
||||
default:
|
||||
return errors.New(agentsUsage)
|
||||
}
|
||||
}
|
||||
v, err := readOwnOrMachine("agents", readOpts{Prompts: prompts}, readAgents)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if asJSON {
|
||||
return printJSON(os.Stdout, v)
|
||||
}
|
||||
printAgents(os.Stdout, v.Agents, sessionNowFn())
|
||||
return nil
|
||||
}
|
||||
|
||||
// printAgents writes the views grouped by agent, sessions waiting for the
|
||||
// person first, apps last.
|
||||
func printAgents(w io.Writer, views []agentView, now time.Time) {
|
||||
if len(views) == 0 {
|
||||
fmt.Fprintln(w, "No AI agents are running on this machine.")
|
||||
return
|
||||
}
|
||||
byFamily := map[string][]agentView{}
|
||||
var families []string
|
||||
for _, v := range views {
|
||||
if _, ok := byFamily[v.Signature]; !ok {
|
||||
families = append(families, v.Signature)
|
||||
}
|
||||
byFamily[v.Signature] = append(byFamily[v.Signature], v)
|
||||
}
|
||||
sort.Strings(families)
|
||||
for _, fam := range families {
|
||||
vs := byFamily[fam]
|
||||
sort.SliceStable(vs, func(i, j int) bool { return viewRank(vs[i]) < viewRank(vs[j]) })
|
||||
fmt.Fprintln(w, familyHeading(familyLabel(fam), vs))
|
||||
for _, v := range vs {
|
||||
printAgent(w, v, now)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
fmt.Fprintln(w, "Names, titles and statuses are what each agent says about itself.")
|
||||
}
|
||||
|
||||
func viewRank(v agentView) int {
|
||||
switch {
|
||||
case v.Role == "service":
|
||||
return 3
|
||||
case v.Kind == kindApp:
|
||||
return 4
|
||||
case v.Self != nil && v.Self.WaitingFor != "":
|
||||
return 0
|
||||
case v.Self != nil && v.Self.Status != "idle":
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
func familyHeading(label string, vs []agentView) string {
|
||||
sessions, waiting, services, apps, others := 0, 0, 0, 0, 0
|
||||
for _, v := range vs {
|
||||
switch {
|
||||
case v.Role == "service":
|
||||
services++
|
||||
case v.Kind == kindApp:
|
||||
apps++
|
||||
case v.Kind != kindAgent:
|
||||
others++
|
||||
default:
|
||||
sessions++
|
||||
if v.Self != nil && v.Self.WaitingFor != "" {
|
||||
waiting++
|
||||
}
|
||||
}
|
||||
}
|
||||
var parts []string
|
||||
if sessions > 0 {
|
||||
p := plural(sessions, "session", "sessions")
|
||||
if waiting > 0 {
|
||||
p += fmt.Sprintf(" (%d waiting for you)", waiting)
|
||||
}
|
||||
parts = append(parts, p)
|
||||
}
|
||||
if others > 0 {
|
||||
parts = append(parts, plural(others, "program", "programs"))
|
||||
}
|
||||
if apps > 0 {
|
||||
parts = append(parts, plural(apps, "app", "apps"))
|
||||
}
|
||||
if services > 0 {
|
||||
parts = append(parts, "its background service")
|
||||
}
|
||||
return label + ": " + strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func plural(n int, one, many string) string {
|
||||
if n == 1 {
|
||||
return "1 " + one
|
||||
}
|
||||
return fmt.Sprintf("%d %s", n, many)
|
||||
}
|
||||
|
||||
func printAgent(w io.Writer, v agentView, now time.Time) {
|
||||
if v.Role == "service" {
|
||||
fmt.Fprintf(w, " background service (pid %d): starts background sessions; not a session itself\n", v.PID)
|
||||
return
|
||||
}
|
||||
l := agentLinesOf(v, now)
|
||||
fmt.Fprintln(w, " "+l.Head)
|
||||
if l.State != "" {
|
||||
fmt.Fprintln(w, " "+l.State)
|
||||
}
|
||||
fmt.Fprintln(w, " "+l.Where+fmt.Sprintf(" · pid %d", v.PID))
|
||||
if len(v.RunningNow) > 0 {
|
||||
fmt.Fprintln(w, " running now: "+strings.Join(v.RunningNow, "; "))
|
||||
}
|
||||
if len(v.ToolServers) > 0 {
|
||||
fmt.Fprintln(w, " tool servers: "+strings.Join(v.ToolServers, ", "))
|
||||
}
|
||||
if v.Self != nil && v.Self.LastPrompt != "" {
|
||||
fmt.Fprintln(w, " last asked: \""+v.Self.LastPrompt+"\"")
|
||||
}
|
||||
}
|
||||
|
||||
// agentLines is one agent in words: the same three lines on the command line
|
||||
// and in the console (uihome.go), so the two can never describe it apart.
|
||||
type agentLines struct {
|
||||
Head string // the session's name and title, as the agent gives them
|
||||
State string // waiting for you / busy / idle, last active, permission mode
|
||||
Where string // folder and branch, started when and by whom, as which account
|
||||
}
|
||||
|
||||
func agentLinesOf(v agentView, now time.Time) agentLines {
|
||||
var l agentLines
|
||||
if v.Self != nil && v.Self.Name != "" {
|
||||
l.Head = v.Self.Name
|
||||
}
|
||||
if v.Self != nil && v.Self.Title != "" && v.Self.Title != v.Self.Name {
|
||||
if l.Head != "" {
|
||||
l.Head += " · "
|
||||
}
|
||||
l.Head += "\"" + v.Self.Title + "\""
|
||||
}
|
||||
if l.Head == "" {
|
||||
// No session record: name it by what it is and where it works.
|
||||
l.Head = familyLabel(v.Signature)
|
||||
if v.Folder != "" {
|
||||
l.Head += " in " + filepath.Base(v.Folder)
|
||||
}
|
||||
}
|
||||
if v.Kind == kindApp {
|
||||
l.Head += " — an app you are running"
|
||||
}
|
||||
var state []string
|
||||
if v.Self != nil {
|
||||
switch {
|
||||
case v.Self.WaitingFor != "":
|
||||
state = append(state, "waiting for you: "+v.Self.WaitingFor)
|
||||
case v.Self.Status != "":
|
||||
state = append(state, v.Self.Status)
|
||||
}
|
||||
if v.Self.Background {
|
||||
state = append(state, "background session")
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, v.Self.LastActive); err == nil {
|
||||
state = append(state, "last active "+ago(now.Sub(t)))
|
||||
}
|
||||
if m := v.Self.PermissionMode; m != "" && m != "default" {
|
||||
state = append(state, "permission mode: "+m)
|
||||
}
|
||||
}
|
||||
l.State = strings.Join(state, " · ")
|
||||
var where []string
|
||||
if v.Folder != "" {
|
||||
f := "in " + v.Folder
|
||||
if v.GitBranch != "" {
|
||||
f += " (" + v.GitBranch + ")"
|
||||
}
|
||||
where = append(where, f)
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, v.StartedAt); err == nil {
|
||||
where = append(where, "started "+ago(now.Sub(t)))
|
||||
}
|
||||
if o, ok := originWords[v.Origin]; ok {
|
||||
where = append(where, o)
|
||||
}
|
||||
if v.User != "" {
|
||||
where = append(where, "as "+v.User)
|
||||
}
|
||||
l.Where = strings.Join(where, " · ")
|
||||
return l
|
||||
}
|
||||
|
||||
func ago(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
case d < 48*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
}
|
||||
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
package main
|
||||
|
||||
// agentsession.go — what an agent is DOING, not only that it runs.
|
||||
//
|
||||
// "Seven Claude Code sessions on this machine" tells the owner nothing (the
|
||||
// owner, 2026-10-30). Each agent gets, beside the detection facts (agents.go):
|
||||
//
|
||||
// - the folder it works in, that folder's git branch, how long it has run,
|
||||
// and what it is running right now (its child commands, secrets redacted)
|
||||
// — read from the operating system, for every agent;
|
||||
// - what the agent says about itself, read from the agent's OWN state files
|
||||
// on this machine: for Claude Code its per-process session record
|
||||
// (~/.claude/sessions/<pid>.json: the session's name, whether it is idle,
|
||||
// working, or waiting for the person and on what) and the tail of its
|
||||
// transcript (the title Claude Code keeps for the session, its permission
|
||||
// mode, the last prompt).
|
||||
//
|
||||
// No model is asked anything. The title is the agent's own, kept by the agent.
|
||||
//
|
||||
// WHAT IS NEVER READ: a process's environment (where tokens live), the key
|
||||
// files beside the session records, and any transcript content other than
|
||||
// the title, the permission mode, the branch and the last prompt.
|
||||
//
|
||||
// TRUST. A session record is written by the agent, so it is the agent's
|
||||
// description of itself: an agent can name itself anything. It is shown as
|
||||
// what the agent says (json "self_reported"), never as what the bastion
|
||||
// found, and the detection facts stay the bastion's own. A record is used only
|
||||
// if it names this pid AND, where the platform can tell, this process's start
|
||||
// time (procStart against the kernel's), so a record left by a process that
|
||||
// has exited cannot be pinned on a new one that got its number.
|
||||
//
|
||||
// WHERE IT GOES. This is built for the console and the command line on this
|
||||
// machine. It is not in agentObs, so it is not in the heartbeat: nothing here
|
||||
// reaches a Host.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionRecordMax = 64 << 10 // a session record is a few hundred bytes
|
||||
transcriptTailMax = 400 << 10 // the title, mode and branch recur; the tail holds the latest
|
||||
titleMax = 120
|
||||
promptMax = 240
|
||||
nameMax = 60
|
||||
runningNowMax = 3
|
||||
runningNowWidth = 100
|
||||
)
|
||||
|
||||
// agentSelf is what the agent says about itself.
|
||||
type agentSelf struct {
|
||||
Source string `json:"source"`
|
||||
Verified bool `json:"verified"` // the record named this pid and its start time
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
WaitingFor string `json:"waiting_for,omitempty"`
|
||||
Background bool `json:"background,omitempty"`
|
||||
PermissionMode string `json:"permission_mode,omitempty"`
|
||||
LastActive string `json:"last_active,omitempty"`
|
||||
LastPrompt string `json:"last_prompt,omitempty"`
|
||||
}
|
||||
|
||||
// agentView is one agent as the console and `agents` show it.
|
||||
type agentView struct {
|
||||
agentObs
|
||||
// Role separates a session from the agent's own supervisor process
|
||||
// ("service"): Claude Code's background service starts background
|
||||
// sessions and is not one, and counting it as one is how a machine with
|
||||
// five sessions read as six.
|
||||
Role string `json:"role"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
GitBranch string `json:"git_branch,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
RunningNow []string `json:"running_now,omitempty"`
|
||||
// ToolServers are the long-running children the agent started with
|
||||
// itself (its MCP servers, by their program names), kept apart from
|
||||
// RunningNow so the commands of this moment are not buried under them.
|
||||
ToolServers []string `json:"tool_servers,omitempty"`
|
||||
Self *agentSelf `json:"self_reported,omitempty"`
|
||||
}
|
||||
|
||||
// Seams: the platform's answers, so the views are testable from fixtures.
|
||||
var (
|
||||
uidForUserFn = uidForUser
|
||||
procCwdFn = osProcCwd
|
||||
procStartedFn = osProcStarted
|
||||
homeForUserFn = homeForUser
|
||||
procCmdArgsFn = osProcCmdArgs
|
||||
sessionNowFn = time.Now
|
||||
agentViewsLimit = 200
|
||||
)
|
||||
|
||||
// uidForUser is the account number session files must belong to; -1 (no
|
||||
// check) where accounts are not numbers (Windows). ok is false when the
|
||||
// account cannot be resolved, and then nothing of the agent's is read.
|
||||
func uidForUser(name string) (int, bool) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return -1, true
|
||||
}
|
||||
u, err := user.Lookup(name)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(u.Uid)
|
||||
return n, err == nil
|
||||
}
|
||||
|
||||
func homeForUser(name string) string {
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := user.Lookup(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.HomeDir
|
||||
}
|
||||
|
||||
// buildAgentViews turns one scan into what a person reads.
|
||||
func buildAgentViews(agents []agentObs, tree map[int]int) []agentView {
|
||||
children := map[int][]int{}
|
||||
for c, p := range tree {
|
||||
children[p] = append(children[p], c)
|
||||
}
|
||||
out := make([]agentView, 0, len(agents))
|
||||
for i, a := range agents {
|
||||
if i >= agentViewsLimit {
|
||||
break
|
||||
}
|
||||
v := agentView{agentObs: a, Role: "session"}
|
||||
if a.Signature == "claude-code" && isClaudeCodeService(a.Cmdline) {
|
||||
v.Role = "service"
|
||||
}
|
||||
v.Folder = procCwdFn(a.PID)
|
||||
v.GitBranch = gitBranchOf(v.Folder)
|
||||
started, hasStart := procStartedFn(a.PID)
|
||||
if hasStart {
|
||||
v.StartedAt = started.UTC().Format(time.RFC3339)
|
||||
}
|
||||
v.RunningNow, v.ToolServers = runningNow(a.PID, children, started, hasStart)
|
||||
if a.Signature == "claude-code" && v.Role == "session" {
|
||||
if uid, ok := uidForUserFn(a.User); ok {
|
||||
v.Self = claudeCodeSelf(a.PID, homeForUserFn(a.User), uid)
|
||||
}
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isClaudeCodeService is Claude Code's background service, `claude daemon run`.
|
||||
func isClaudeCodeService(cmdline string) bool {
|
||||
f := strings.Fields(cmdline)
|
||||
for i := 0; i+1 < len(f); i++ {
|
||||
if f[i] == "daemon" && f[i+1] == "run" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var sessionIDShape = regexp.MustCompile(`^[0-9a-fA-F-]{8,64}$`)
|
||||
|
||||
// claudeCodeSelf reads the session record Claude Code keeps for this pid, and
|
||||
// the tail of that session's transcript. nil when there is none this bastion
|
||||
// can read (another account's home without root, an older Claude Code).
|
||||
//
|
||||
// Every file is read only if it is a regular file the agent's own account
|
||||
// owns (openOwned): the daemon may run as the machine's administrator, and a
|
||||
// link planted in one account's home must not make it read another's.
|
||||
func claudeCodeSelf(pid int, home string, uid int) *agentSelf {
|
||||
if home == "" {
|
||||
return nil
|
||||
}
|
||||
raw := readCappedOwned(filepath.Join(home, ".claude", "sessions", strconv.Itoa(pid)+".json"), sessionRecordMax, uid)
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
var rec struct {
|
||||
PID int `json:"pid"`
|
||||
SessionID string `json:"sessionId"`
|
||||
ProcStart string `json:"procStart"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
WaitingFor string `json:"waitingFor"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) != nil || rec.PID != pid {
|
||||
return nil
|
||||
}
|
||||
id, _, idOK := procIdentity(pid)
|
||||
if idOK && rec.ProcStart != id {
|
||||
return nil // another process's record: this number has changed hands
|
||||
}
|
||||
s := &agentSelf{
|
||||
Source: "Claude Code's session record",
|
||||
Verified: idOK,
|
||||
Name: cleanText(rec.Name, nameMax),
|
||||
Status: cleanText(rec.Status, 30),
|
||||
WaitingFor: cleanText(rec.WaitingFor, 60),
|
||||
Background: rec.Kind == "bg",
|
||||
}
|
||||
if rec.UpdatedAt > 0 {
|
||||
s.LastActive = time.UnixMilli(rec.UpdatedAt).UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !sessionIDShape.MatchString(rec.SessionID) {
|
||||
return s
|
||||
}
|
||||
s.SessionID = rec.SessionID
|
||||
matches, _ := filepath.Glob(filepath.Join(home, ".claude", "projects", "*", rec.SessionID+".jsonl"))
|
||||
sort.Strings(matches)
|
||||
if len(matches) == 0 {
|
||||
return s
|
||||
}
|
||||
for _, line := range tailLinesOwned(matches[0], transcriptTailMax, uid) {
|
||||
var e struct {
|
||||
Type string `json:"type"`
|
||||
AITitle string `json:"aiTitle"`
|
||||
PermissionMode string `json:"permissionMode"`
|
||||
LastPrompt string `json:"lastPrompt"`
|
||||
}
|
||||
if json.Unmarshal(line, &e) != nil {
|
||||
continue
|
||||
}
|
||||
switch e.Type {
|
||||
case "ai-title":
|
||||
if e.AITitle != "" {
|
||||
s.Title = cleanText(e.AITitle, titleMax)
|
||||
}
|
||||
case "permission-mode":
|
||||
if e.PermissionMode != "" {
|
||||
s.PermissionMode = cleanText(e.PermissionMode, 40)
|
||||
}
|
||||
case "last-prompt":
|
||||
if e.LastPrompt != "" {
|
||||
s.LastPrompt = cleanText(e.LastPrompt, promptMax)
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// toolServerWindow: a direct child started this soon after the agent is one
|
||||
// it started with itself (an MCP server), not a command of this moment.
|
||||
const toolServerWindow = 2 * time.Minute
|
||||
|
||||
// toolServerName is the program a tool server runs: past the interpreter
|
||||
// (agents.go's table) and its flags, the base name of what it was given.
|
||||
func toolServerName(args []string) string {
|
||||
name := binBase(args[0])
|
||||
if !interpreters[name] {
|
||||
return name
|
||||
}
|
||||
for _, a := range args[1:] {
|
||||
if !strings.HasPrefix(a, "-") {
|
||||
return cleanText(filepath.Base(a), 60)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// runningNow is what the agent is running at this moment: the commands at
|
||||
// the ends of its process tree, secrets redacted, newest pids first. The
|
||||
// direct children it started with itself come back apart, as tool servers.
|
||||
func runningNow(pid int, children map[int][]int, started time.Time, hasStart bool) ([]string, []string) {
|
||||
var tools []string
|
||||
// top: the agent's direct child each process descends from. A tool server
|
||||
// is often a grandchild (npx → sh → node), so it is the direct child's
|
||||
// start that says whether the agent started it with itself.
|
||||
top := map[int]int{}
|
||||
var leaves []int
|
||||
seen := map[int]bool{pid: true}
|
||||
queue := []int{pid}
|
||||
for len(queue) > 0 && len(seen) < 500 {
|
||||
p := queue[0]
|
||||
queue = queue[1:]
|
||||
kids := children[p]
|
||||
if len(kids) == 0 && p != pid {
|
||||
leaves = append(leaves, p)
|
||||
}
|
||||
for _, k := range kids {
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
queue = append(queue, k)
|
||||
if p == pid {
|
||||
top[k] = k
|
||||
} else {
|
||||
top[k] = top[p]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(leaves)))
|
||||
var out []string
|
||||
for _, l := range leaves {
|
||||
args := splitRetitled(procCmdArgsFn(l))
|
||||
if len(args) == 0 {
|
||||
continue
|
||||
}
|
||||
if hasStart {
|
||||
if t, ok := procStartedFn(top[l]); ok && t.Sub(started) < toolServerWindow {
|
||||
tools = append(tools, toolServerName(args))
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(out) == runningNowMax {
|
||||
continue
|
||||
}
|
||||
red, _ := redactSecretArgs(args)
|
||||
red[0] = filepath.Base(red[0])
|
||||
out = append(out, cleanText(strings.Join(red, " "), runningNowWidth))
|
||||
}
|
||||
return out, countedNames(tools)
|
||||
}
|
||||
|
||||
// splitRetitled undoes a retitled command line: a program that rewrote its
|
||||
// argv as one string (a browser, a server setting its title) arrives as a
|
||||
// single argument with spaces in it, and its "program" would otherwise be
|
||||
// whatever follows the last slash in the whole line.
|
||||
func splitRetitled(args []string) []string {
|
||||
if len(args) == 1 && strings.ContainsAny(args[0], " \t") {
|
||||
return strings.Fields(args[0])
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// countedNames sorts names and folds repeats: "chrome ×3".
|
||||
func countedNames(names []string) []string {
|
||||
n := map[string]int{}
|
||||
for _, x := range names {
|
||||
n[x]++
|
||||
}
|
||||
out := make([]string, 0, len(n))
|
||||
for x, c := range n {
|
||||
if c > 1 {
|
||||
x += " ×" + strconv.Itoa(c)
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// gitBranchOf reads the branch of the repository holding dir from its HEAD
|
||||
// file. It runs no git: the folder is the agent's, and so is its git config.
|
||||
func gitBranchOf(dir string) string {
|
||||
for i := 0; dir != "" && i < 40; i++ {
|
||||
g := filepath.Join(dir, ".git")
|
||||
if st, err := os.Lstat(g); err == nil {
|
||||
head := filepath.Join(g, "HEAD")
|
||||
if !st.IsDir() {
|
||||
// a worktree or submodule: ".git" is a file naming the real dir
|
||||
b := readCapped(g, 4096)
|
||||
rest, ok := strings.CutPrefix(strings.TrimSpace(string(b)), "gitdir:")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
gd := strings.TrimSpace(rest)
|
||||
if !filepath.IsAbs(gd) {
|
||||
gd = filepath.Join(dir, gd)
|
||||
}
|
||||
head = filepath.Join(gd, "HEAD")
|
||||
}
|
||||
h := strings.TrimSpace(string(readCapped(head, 4096)))
|
||||
if b, ok := strings.CutPrefix(h, "ref: refs/heads/"); ok {
|
||||
return cleanText(b, 80)
|
||||
}
|
||||
if len(h) >= 12 {
|
||||
return "detached at " + h[:12]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return ""
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// readCapped reads a small file without following a final symlink or
|
||||
// waiting on a pipe (openOwned, no owner check): .git/HEAD in an agent's
|
||||
// folder is that account's to plant anything in.
|
||||
func readCapped(path string, max int64) []byte { return readCappedOwned(path, max, -1) }
|
||||
|
||||
func readCappedOwned(path string, max int64, uid int) []byte {
|
||||
f, err := openOwned(path, uid)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
if st, err := f.Stat(); err != nil || !st.Mode().IsRegular() || st.Size() > max {
|
||||
return nil
|
||||
}
|
||||
b := make([]byte, max)
|
||||
n, _ := f.Read(b)
|
||||
return b[:n]
|
||||
}
|
||||
|
||||
// tailLines is the last max bytes of a file, split into whole lines.
|
||||
func tailLines(path string, max int64) [][]byte { return tailLinesOwned(path, max, -1) }
|
||||
|
||||
func tailLinesOwned(path string, max int64, uid int) [][]byte {
|
||||
f, err := openOwned(path, uid)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := f.Stat()
|
||||
if err != nil || !st.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
start := st.Size() - max
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
buf := make([]byte, st.Size()-start)
|
||||
n, _ := f.ReadAt(buf, start)
|
||||
lines := bytes.Split(buf[:n], []byte("\n"))
|
||||
if start > 0 && len(lines) > 0 {
|
||||
lines = lines[1:] // the first is cut in the middle
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// cleanText is agent-written text made safe to show: control characters out,
|
||||
// whitespace collapsed, cut to max runes with an ellipsis.
|
||||
func cleanText(s string, max int) string {
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) || r == '
' || r == '
' {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if r := []rune(s); len(r) > max {
|
||||
return string(r[:max-1]) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A pipe planted where .git/HEAD belongs must not stall the daemon: a plain
|
||||
// open would wait for a writer for ever.
|
||||
func TestAPipeInPlaceOfGitHEADDoesNotStall(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := syscall.Mkfifo(filepath.Join(repo, ".git", "HEAD"), 0o600); err != nil {
|
||||
t.Skip("cannot make a fifo here:", err)
|
||||
}
|
||||
done := make(chan string, 1)
|
||||
go func() { done <- gitBranchOf(repo) }()
|
||||
select {
|
||||
case got := <-done:
|
||||
if got != "" {
|
||||
t.Errorf("a pipe read as branch %q", got)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("reading .git/HEAD stalled on a pipe")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func osProcCwd(pid int) string {
|
||||
d, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/cwd")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func osProcCmdArgs(pid int) []string {
|
||||
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return splitNul(raw)
|
||||
}
|
||||
|
||||
// osProcStarted is the process's start: boot time (/proc/stat btime) plus its
|
||||
// start in clock ticks (procIdentity), at the USER_HZ of 100 Linux reports.
|
||||
func osProcStarted(pid int) (time.Time, bool) {
|
||||
id, _, ok := procIdentity(pid)
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
ticks, err := strconv.ParseInt(id, 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
raw, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "btime "); ok {
|
||||
boot, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(boot, 0).Add(time.Duration(ticks) * 10 * time.Millisecond), true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
var errNotOwnedRegular = errors.New("not a regular file owned by that account")
|
||||
|
||||
// openOwned opens a file the agent's account controls, for the daemon that
|
||||
// may be running as the machine's administrator. It never follows a final
|
||||
// symlink (O_NOFOLLOW), never waits on a pipe planted in its place
|
||||
// (O_NONBLOCK, then a regular-file check), and, when uid >= 0, refuses a
|
||||
// file another account owns, which also catches a symlinked directory on the
|
||||
// way: whatever the path walked through, the file it reached has to be the
|
||||
// agent account's own.
|
||||
func openOwned(path string, uid int) (*os.File, error) {
|
||||
fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK|syscall.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var st syscall.Stat_t
|
||||
if err := syscall.Fstat(fd, &st); err != nil {
|
||||
_ = syscall.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
if st.Mode&syscall.S_IFMT != syscall.S_IFREG || (uid >= 0 && int(st.Uid) != uid) {
|
||||
_ = syscall.Close(fd)
|
||||
return nil, errNotOwnedRegular
|
||||
}
|
||||
return os.NewFile(uintptr(fd), path), nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
// openOwned on Windows: no owner check by account number (accounts are SIDs
|
||||
// here), and a symlink needs privilege to create; the file must be regular.
|
||||
func openOwned(path string, _ int) (*os.File, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if st, err := f.Stat(); err != nil || !st.Mode().IsRegular() {
|
||||
_ = f.Close()
|
||||
return nil, errors.New("not a regular file")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Off Linux the folder and start time are not read yet; the agent's own
|
||||
// session record still is (claudeCodeSelf), unverified, since procIdentity has
|
||||
// no start time to check it against there.
|
||||
func osProcCwd(int) string { return "" }
|
||||
func osProcStarted(int) (time.Time, bool) { return time.Time{}, false }
|
||||
func osProcCmdArgs(pid int) []string { return strings.Fields(procCmdline(pid)) }
|
||||
@@ -0,0 +1,282 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const fixtureSession = "0a1b2c3d-1111-4222-8333-444455556666"
|
||||
|
||||
// fakeClaudeHome lays out what Claude Code keeps for one running session: the
|
||||
// per-process record and the session's transcript. All of it invented.
|
||||
func fakeClaudeHome(t *testing.T, pid int, procStart, transcript string) string {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
sess := filepath.Join(home, ".claude", "sessions")
|
||||
proj := filepath.Join(home, ".claude", "projects", "-work-shop")
|
||||
for _, d := range []string{sess, proj} {
|
||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
rec := `{"pid":` + itoa(pid) + `,"sessionId":"` + fixtureSession + `","cwd":"/work/shop","procStart":"` + procStart +
|
||||
`","kind":"interactive","name":"shop-a1","status":"waiting","waitingFor":"permission prompt","updatedAt":1793300000000}`
|
||||
if err := os.WriteFile(filepath.Join(sess, itoa(pid)+".json"), []byte(rec), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sess, itoa(pid)+".deadbeef.key"), []byte("NEVER-READ"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(proj, fixtureSession+".jsonl"), []byte(transcript), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return home
|
||||
}
|
||||
|
||||
func stubProcIdentity(t *testing.T, id string) {
|
||||
t.Helper()
|
||||
prev := procIdentity
|
||||
t.Cleanup(func() { procIdentity = prev })
|
||||
procIdentity = func(int) (string, bool, bool) { return id, false, true }
|
||||
}
|
||||
|
||||
const fixtureTranscript = `{"type":"ai-title","aiTitle":"Set up the shop database","sessionId":"x"}
|
||||
{"type":"user","message":{"content":"SECRET CONVERSATION BODY"}}
|
||||
{"type":"permission-mode","permissionMode":"bypassPermissions","sessionId":"x"}
|
||||
{"type":"last-prompt","lastPrompt":"add an index to orders","sessionId":"x"}
|
||||
{"type":"ai-title","aiTitle":"Add an index to the orders table","sessionId":"x"}
|
||||
`
|
||||
|
||||
func TestClaudeCodeSessionSaysWhatItIsDoing(t *testing.T) {
|
||||
stubProcIdentity(t, "5551")
|
||||
home := fakeClaudeHome(t, 4242, "5551", fixtureTranscript)
|
||||
s := claudeCodeSelf(4242, home, os.Getuid())
|
||||
if s == nil {
|
||||
t.Fatal("a session with a matching record was not read")
|
||||
}
|
||||
want := agentSelf{Source: "Claude Code's session record", Verified: true, SessionID: fixtureSession,
|
||||
Name: "shop-a1", Title: "Add an index to the orders table", Status: "waiting",
|
||||
WaitingFor: "permission prompt", PermissionMode: "bypassPermissions",
|
||||
LastActive: time.UnixMilli(1793300000000).UTC().Format(time.RFC3339),
|
||||
LastPrompt: "add an index to orders"}
|
||||
if *s != want {
|
||||
t.Errorf("got %+v\nwant %+v", *s, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A record left by a process that has exited must not be pinned on the new
|
||||
// process that got its number.
|
||||
func TestASessionRecordForAnEarlierProcessIsNotUsed(t *testing.T) {
|
||||
stubProcIdentity(t, "9999")
|
||||
home := fakeClaudeHome(t, 4242, "5551", fixtureTranscript)
|
||||
if s := claudeCodeSelf(4242, home, os.Getuid()); s != nil {
|
||||
t.Errorf("a record whose process start is not this process's was used: %+v", *s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASessionRecordNamingAnotherPidIsNotUsed(t *testing.T) {
|
||||
stubProcIdentity(t, "5551")
|
||||
home := fakeClaudeHome(t, 4242, "5551", fixtureTranscript)
|
||||
if err := os.Rename(filepath.Join(home, ".claude", "sessions", "4242.json"),
|
||||
filepath.Join(home, ".claude", "sessions", "4343.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := claudeCodeSelf(4343, home, os.Getuid()); s != nil {
|
||||
t.Errorf("a record that names pid 4242 was used for 4343: %+v", *s)
|
||||
}
|
||||
}
|
||||
|
||||
// The session id becomes part of a path; one that is not an id reads nothing.
|
||||
func TestASessionIDThatIsAPathReadsNoTranscript(t *testing.T) {
|
||||
stubProcIdentity(t, "5551")
|
||||
home := t.TempDir()
|
||||
sess := filepath.Join(home, ".claude", "sessions")
|
||||
if err := os.MkdirAll(sess, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := `{"pid":4242,"sessionId":"../../../etc/x","procStart":"5551","name":"n"}`
|
||||
if err := os.WriteFile(filepath.Join(sess, "4242.json"), []byte(rec), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := claudeCodeSelf(4242, home, os.Getuid())
|
||||
if s == nil || s.SessionID != "" || s.Title != "" {
|
||||
t.Errorf("a path-shaped session id was followed: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// What an agent writes about itself is shown, never obeyed or rendered: no
|
||||
// control characters, no line breaks to forge another line, a bounded length.
|
||||
func TestAgentWrittenTextIsCleanedAndBounded(t *testing.T) {
|
||||
got := cleanText("ok\n\x1b[31mIGNORE ALL PREVIOUS
instructions\t", 200)
|
||||
if strings.ContainsAny(got, "\n\x1b
\t") {
|
||||
t.Errorf("control characters survived: %q", got)
|
||||
}
|
||||
if r := []rune(cleanText(strings.Repeat("é", 500), titleMax)); len(r) != titleMax {
|
||||
t.Errorf("a long title is %d runes, want %d", len(r), titleMax)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCodesBackgroundServiceIsNotASession(t *testing.T) {
|
||||
if !isClaudeCodeService("/opt/x/claude.exe daemon run --json-path /h/.claude/daemon.json") {
|
||||
t.Error("`daemon run` was not recognised as the background service")
|
||||
}
|
||||
if isClaudeCodeService("claude --resume") {
|
||||
t.Error("an interactive session was taken for the service")
|
||||
}
|
||||
}
|
||||
|
||||
// The commands of this moment are kept apart from the tool servers the agent
|
||||
// started with itself, and a secret on a command line is redacted.
|
||||
func TestRunningNowSeparatesToolServersAndRedactsSecrets(t *testing.T) {
|
||||
start := time.Date(2026, 10, 30, 9, 0, 0, 0, time.UTC)
|
||||
started := map[int]time.Time{
|
||||
100: start, 110: start.Add(5 * time.Second), 111: start.Add(6 * time.Second),
|
||||
120: start.Add(3 * time.Hour), 121: start.Add(3 * time.Hour),
|
||||
}
|
||||
args := map[int][]string{
|
||||
111: {"node", "/home/u/.npm/_npx/abc/node_modules/.bin/mcp-server-files"},
|
||||
121: {"curl", "-H", "Authorization: Bearer sk-live-abcdefghijklmnop1234", "https://api.example.com"},
|
||||
}
|
||||
prevS, prevA := procStartedFn, procCmdArgsFn
|
||||
t.Cleanup(func() { procStartedFn, procCmdArgsFn = prevS, prevA })
|
||||
procStartedFn = func(pid int) (time.Time, bool) { s, ok := started[pid]; return s, ok }
|
||||
procCmdArgsFn = func(pid int) []string { return args[pid] }
|
||||
children := map[int][]int{100: {110, 120}, 110: {111}, 120: {121}}
|
||||
now, tools := runningNow(100, children, start, true)
|
||||
if len(tools) != 1 || tools[0] != "mcp-server-files" {
|
||||
t.Errorf("tool servers %v", tools)
|
||||
}
|
||||
if len(now) != 1 || !strings.HasPrefix(now[0], "curl") {
|
||||
t.Fatalf("running now %v", now)
|
||||
}
|
||||
if strings.Contains(now[0], "sk-live") {
|
||||
t.Errorf("a secret on a command line was shown: %q", now[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitBranchIsReadFromHEADWithoutRunningGit(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repo, ".git", "HEAD"), []byte("ref: refs/heads/fix/login\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sub := filepath.Join(repo, "src", "app")
|
||||
if err := os.MkdirAll(sub, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := gitBranchOf(sub); got != "fix/login" {
|
||||
t.Errorf("branch from a subfolder: %q", got)
|
||||
}
|
||||
// A worktree: .git is a file naming the real git dir.
|
||||
wt := t.TempDir()
|
||||
gd := filepath.Join(t.TempDir(), "wt-gitdir")
|
||||
if err := os.MkdirAll(gd, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(gd, "HEAD"), []byte("ref: refs/heads/feature\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(wt, ".git"), []byte("gitdir: "+gd+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := gitBranchOf(wt); got != "feature" {
|
||||
t.Errorf("branch in a worktree: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Sessions waiting for the person come first, and the heading counts the
|
||||
// background service apart from the sessions.
|
||||
func TestAgentsListPutsWaitingSessionsFirstAndCountsTheServiceApart(t *testing.T) {
|
||||
views := []agentView{
|
||||
{agentObs: agentObs{PID: 1, Signature: "claude-code"}, Role: "service"},
|
||||
{agentObs: agentObs{PID: 2, Kind: kindAgent, Signature: "claude-code"}, Role: "session",
|
||||
Self: &agentSelf{Name: "idle-one", Status: "idle"}},
|
||||
{agentObs: agentObs{PID: 3, Kind: kindAgent, Signature: "claude-code"}, Role: "session",
|
||||
Self: &agentSelf{Name: "waits", Title: "Rename the invoices", Status: "waiting", WaitingFor: "permission prompt"}},
|
||||
}
|
||||
var b bytes.Buffer
|
||||
printAgents(&b, views, time.Now())
|
||||
out := b.String()
|
||||
if !strings.HasPrefix(out, "Claude Code: 2 sessions (1 waiting for you), its background service\n") {
|
||||
t.Errorf("heading:\n%s", out)
|
||||
}
|
||||
if strings.Index(out, "waits") > strings.Index(out, "idle-one") {
|
||||
t.Errorf("the waiting session is not first:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "waiting for you: permission prompt") {
|
||||
t.Errorf("what it waits for is not said:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop takes a session's name, and the owner is asked about the session, not
|
||||
// a bare pid.
|
||||
func TestStopNamesTheSessionItAsksAbout(t *testing.T) {
|
||||
stubProcIdentity(t, "5551")
|
||||
home := fakeClaudeHome(t, 4242, "5551", fixtureTranscript)
|
||||
prevH, prevTree, prevU := homeForUserFn, processTree, uidForUserFn
|
||||
t.Cleanup(func() { homeForUserFn, processTree, uidForUserFn = prevH, prevTree, prevU })
|
||||
homeForUserFn = func(string) string { return home }
|
||||
uidForUserFn = func(string) (int, bool) { return os.Getuid(), true }
|
||||
processTree = func() map[int]int { return nil }
|
||||
observed := map[int]agentObs{4242: {PID: 4242, Kind: kindAgent, Signature: "claude-code", User: "u"}}
|
||||
pid, v, err := resolveStopTarget("shop-a1", observed)
|
||||
if err != nil || pid != 4242 {
|
||||
t.Fatalf("by name: %d %v", pid, err)
|
||||
}
|
||||
if _, _, err := resolveStopTarget("no-such", observed); err == nil {
|
||||
t.Error("an unknown session name resolved")
|
||||
}
|
||||
d := map[string]any{"kind": localStopKind, "pid": "4242", "signature": "claude-code",
|
||||
"agent": "Claude Code", "session": v.Self.Name, "title": v.Self.Title, "folder": "/work/shop"}
|
||||
got := describeRequest(stepupRequest{Details: d})
|
||||
want := `Stop Claude Code session shop-a1 "Add an index to the orders table" in /work/shop (pid 4242) on this machine, and every process it started.`
|
||||
if got != want {
|
||||
t.Errorf("the owner reads\n%s\nwant\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A program that rewrote its command line as one string is still named by
|
||||
// its program, not by whatever follows the last slash in the whole line, and
|
||||
// repeats fold into a count.
|
||||
func TestARetitledCommandLineIsSplitAndRepeatsFold(t *testing.T) {
|
||||
if got := splitRetitled([]string{"go test ./..."}); len(got) != 3 || got[0] != "go" {
|
||||
t.Errorf("split %q", got)
|
||||
}
|
||||
if got := splitRetitled([]string{"/usr/bin/go", "test", "./..."}); len(got) != 3 || got[0] != "/usr/bin/go" {
|
||||
t.Errorf("an ordinary argv was changed: %q", got)
|
||||
}
|
||||
if got := countedNames([]string{"chrome", "mcp-files", "chrome", "chrome"}); len(got) != 2 || got[0] != "chrome ×3" {
|
||||
t.Errorf("folded %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The daemon may read as the machine's administrator: a session file that is
|
||||
// a link, or that another account owns, is not read.
|
||||
func TestASessionFileThatIsALinkOrNotTheAgentsOwnIsNotRead(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("accounts are not numbers and links need privilege on Windows")
|
||||
}
|
||||
stubProcIdentity(t, "5551")
|
||||
home := fakeClaudeHome(t, 4242, "5551", fixtureTranscript)
|
||||
if s := claudeCodeSelf(4242, home, os.Getuid()+1); s != nil {
|
||||
t.Errorf("a record owned by another account was read: %+v", *s)
|
||||
}
|
||||
rec := filepath.Join(home, ".claude", "sessions", "4242.json")
|
||||
elsewhere := filepath.Join(t.TempDir(), "other.json")
|
||||
if err := os.Rename(rec, elsewhere); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(elsewhere, rec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := claudeCodeSelf(4242, home, os.Getuid()); s != nil {
|
||||
t.Errorf("a record reached through a link was read: %+v", *s)
|
||||
}
|
||||
}
|
||||
@@ -41,9 +41,15 @@ import (
|
||||
|
||||
const approvalsOwnerFile = "approvals_owner.json"
|
||||
|
||||
// The record names the owner one way per platform: a SID on Windows, a uid
|
||||
// on Linux and macOS, where the account is the one the console lane serves
|
||||
// (consolelane.go). Account is that uid's name when it was recorded, for
|
||||
// messages only; the uid is what is compared, since a name can be changed.
|
||||
type approvalsOwnerRecord struct {
|
||||
SID string `json:"sid"`
|
||||
SetAt string `json:"set_at"`
|
||||
SID string `json:"sid,omitempty"`
|
||||
UID *uint32 `json:"uid,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
SetAt string `json:"set_at"`
|
||||
}
|
||||
|
||||
var winSIDShape = regexp.MustCompile(`^S-1-[0-9]+(-[0-9]+)+$`)
|
||||
@@ -61,30 +67,54 @@ func approvalsOwnerPath() (string, error) {
|
||||
return filepath.Join(d, approvalsOwnerFile), nil
|
||||
}
|
||||
|
||||
// approvalsOwnerLoad reads the record. present is true when a record exists,
|
||||
// readable or not; sid is "" unless it is readable and names a person.
|
||||
func approvalsOwnerLoad() (sid string, present bool, err error) {
|
||||
// ownerRecordLoad reads the record as it is. present is true when a record
|
||||
// exists, readable or not.
|
||||
func ownerRecordLoad() (r approvalsOwnerRecord, present bool, err error) {
|
||||
p, err := approvalsOwnerPath()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
return r, false, err
|
||||
}
|
||||
raw, err := os.ReadFile(p)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return "", false, nil
|
||||
return r, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", true, err
|
||||
return r, true, err
|
||||
}
|
||||
var r approvalsOwnerRecord
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return "", true, fmt.Errorf("%s cannot be read: %w", p, err)
|
||||
return approvalsOwnerRecord{}, true, fmt.Errorf("%s cannot be read: %w", p, err)
|
||||
}
|
||||
return r, true, nil
|
||||
}
|
||||
|
||||
// approvalsOwnerLoad reads the record. present is true when a record exists,
|
||||
// readable or not; sid is "" unless it is readable and names a person.
|
||||
func approvalsOwnerLoad() (sid string, present bool, err error) {
|
||||
r, present, err := ownerRecordLoad()
|
||||
if err != nil || !present {
|
||||
return "", present, err
|
||||
}
|
||||
if !approvalsOwnerSIDOK(r.SID) {
|
||||
p, _ := approvalsOwnerPath()
|
||||
return "", true, fmt.Errorf("%s names no person's account", p)
|
||||
}
|
||||
return r.SID, true, nil
|
||||
}
|
||||
|
||||
// ownerAccountLoad is the Linux and macOS reading of the record: the owner's
|
||||
// uid, never root's (root needs no lane to its own bastion).
|
||||
func ownerAccountLoad() (uid uint32, account string, present bool, err error) {
|
||||
r, present, err := ownerRecordLoad()
|
||||
if err != nil || !present {
|
||||
return 0, "", present, err
|
||||
}
|
||||
if r.UID == nil || *r.UID == 0 {
|
||||
p, _ := approvalsOwnerPath()
|
||||
return 0, "", true, fmt.Errorf("%s names no person's account on this system", p)
|
||||
}
|
||||
return *r.UID, r.Account, true, nil
|
||||
}
|
||||
|
||||
// approvalsOwner is the SID the pipe serves, "" when none is recorded or the
|
||||
// record cannot be read: the pipe then serves nobody.
|
||||
func approvalsOwner() string {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The socket answers "status" with the kernel's name for the caller, so a
|
||||
// real connection reads the caller's own ask and nobody else's.
|
||||
func TestTheAskSocketAnswersStatusOnlyForTheAsker(t *testing.T) {
|
||||
askFlowWorld(t)
|
||||
me, err := user.Current()
|
||||
if err != nil {
|
||||
t.Skip("no account name for this process:", err)
|
||||
}
|
||||
mine := fileAsk(agentAskRequest{Op: "stop", PID: 101}, me.Username, 0)
|
||||
theirs := fileAsk(agentAskRequest{Op: "stop", PID: 102}, me.Username+"-other", 0)
|
||||
if !mine.OK || !theirs.OK {
|
||||
t.Fatalf("asks: %+v %+v", mine, theirs)
|
||||
}
|
||||
sock := filepath.Join(t.TempDir(), "ask.sock")
|
||||
ln, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go askSockServe(c)
|
||||
}
|
||||
}()
|
||||
ask := func(id string) agentAskReply {
|
||||
t.Helper()
|
||||
c, err := net.DialTimeout("unix", sock, 3*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
_ = json.NewEncoder(c).Encode(agentAskRequest{Op: "status", Request: id})
|
||||
var r agentAskReply
|
||||
if err := json.NewDecoder(c).Decode(&r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
if r := ask(mine.Request); !r.OK || r.State != askWaiting || r.User != me.Username {
|
||||
t.Errorf("my own ask over the socket: %+v", r)
|
||||
}
|
||||
if r := ask(theirs.Request); r.OK || r.State != askUnknown {
|
||||
t.Errorf("another account's ask over the socket: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// `stop` run by an agent (no terminal) files the ask and prints it as JSON;
|
||||
// `request` follows it to the outcome, with the exit status to branch on.
|
||||
func TestStopAndRequestFromAnAgent(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
prevAuth := machineAuthorityHeld
|
||||
t.Cleanup(func() { machineAuthorityHeld = prevAuth })
|
||||
machineAuthorityHeld = func() bool { return true } // holds the state: the direct path
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prevIn := os.Stdin
|
||||
os.Stdin = r // a pipe, not a terminal: an agent
|
||||
t.Cleanup(func() { os.Stdin = prevIn; _ = w.Close(); _ = r.Close() })
|
||||
|
||||
out := captureStdout(t)
|
||||
if err := cmdStop([]string{"101", "--json"}); err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
var v askView
|
||||
if err := json.Unmarshal([]byte(printedSince(out, 0, "}\n")), &v); err != nil || v.State != askWaiting ||
|
||||
v.Request == "" {
|
||||
t.Fatalf("stop --json printed %q (%v)", out(), err)
|
||||
}
|
||||
rows := auditRowsOf(t, d, localStopAskOp)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("filing rows: %v", rows)
|
||||
}
|
||||
|
||||
id := fullRequestID(t, d, v.Request)
|
||||
if _, err := stepupApproveRequest(d, id, codeFor(t, machineTestSecret)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
localStopOnce(d, time.Now())
|
||||
before := len(out())
|
||||
if err := cmdRequest([]string{v.Request}); err != nil {
|
||||
t.Fatalf("request after the stop: %v", err)
|
||||
}
|
||||
if got := printedSince(out, before, "done."); !strings.Contains(got, "approved by the machine's owner and done") {
|
||||
t.Errorf("request printed %q", got)
|
||||
}
|
||||
|
||||
// A second ask, denied: `request` exits 1 without an error line.
|
||||
second := mustAsk(t, 102)
|
||||
if err := stepupDenyRequest(d, fullRequestID(t, d, second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var q quietExit
|
||||
if err := cmdRequest([]string{second, "--json"}); !errors.As(err, &q) || q.code != 1 {
|
||||
t.Errorf("a denied ask: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// printedSince waits (briefly) for captured output past from to hold want:
|
||||
// captureStdout copies the pipe on its own goroutine.
|
||||
func printedSince(out func() string, from int, want string) string {
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
got := out()[from:]
|
||||
if strings.Contains(got, want) || time.Now().After(deadline) {
|
||||
return got
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func mustAsk(t *testing.T, pid int) string {
|
||||
t.Helper()
|
||||
r := fileAsk(agentAskRequest{Op: "stop", PID: pid}, procUser(os.Getpid()), 0)
|
||||
if !r.OK {
|
||||
t.Fatal(r.Why)
|
||||
}
|
||||
return r.Request
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// askFlowWorld is askWorld with each agent's origin set, so the audit's
|
||||
// "started by" can be checked: the asking claude-code session was started at
|
||||
// a terminal, the aider job by the scheduler.
|
||||
func askFlowWorld(t *testing.T) string {
|
||||
t.Helper()
|
||||
uiTestConfigDir(t)
|
||||
stubStopWorld(t, []agentObs{
|
||||
{PID: 101, Kind: kindAgent, Signature: "aider", User: "u", Origin: "scheduler"},
|
||||
{PID: 102, Kind: kindAgent, Signature: "aider", User: "u", Origin: "scheduler"},
|
||||
{PID: 103, Kind: kindAgent, Signature: "aider", User: "u", Origin: "scheduler"},
|
||||
{PID: 104, Kind: kindAgent, Signature: "aider", User: "u", Origin: "scheduler"},
|
||||
{PID: 105, Kind: kindAgent, Signature: "aider", User: "u", Origin: "scheduler"},
|
||||
{PID: 106, Kind: kindAgent, Signature: "aider", User: "u", Origin: "scheduler"},
|
||||
{PID: 300, Kind: kindAgent, Signature: "claude-code", User: "u", Origin: "interactive"},
|
||||
{PID: 4532, Kind: kindApp, Signature: "claude-desktop", User: "u"},
|
||||
}, map[int]int{310: 300, 311: 310})
|
||||
prev := homeForUserFn
|
||||
t.Cleanup(func() { homeForUserFn = prev })
|
||||
homeForUserFn = func(string) string { return "" }
|
||||
d, err := machineFactorDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enrolFactorAt(t, d, machineTestSecret)
|
||||
return d
|
||||
}
|
||||
|
||||
func fullRequestID(t *testing.T, d, prefix string) string {
|
||||
t.Helper()
|
||||
for id := range requestsLoad(d) {
|
||||
if strings.HasPrefix(id, prefix) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
t.Fatalf("no request %s", prefix)
|
||||
return ""
|
||||
}
|
||||
|
||||
// The M1 gate's loop: an agent asks, follows the ask, and learns what the
|
||||
// owner decided and what happened. Before, it got an id and nothing after.
|
||||
func TestAnAgentFollowsItsAskToTheOutcome(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
|
||||
if !r.OK {
|
||||
t.Fatalf("refused: %s", r.Why)
|
||||
}
|
||||
if s := askStatus(d, r.Request, "u", false); !s.OK || s.State != askWaiting {
|
||||
t.Fatalf("before the owner answers: %+v", s)
|
||||
}
|
||||
id := fullRequestID(t, d, r.Request)
|
||||
if _, err := stepupApproveRequest(d, id, codeFor(t, machineTestSecret)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := askStatus(d, r.Request, "u", false); s.State != askApproved {
|
||||
t.Fatalf("approved, not yet carried out: %+v", s)
|
||||
}
|
||||
localStopOnce(d, time.Now())
|
||||
s := askStatus(d, r.Request, "u", false)
|
||||
if s.State != askStopped || !strings.Contains(s.Outcome, "stopped aider (pid 101)") {
|
||||
t.Fatalf("after the stop: %+v", s)
|
||||
}
|
||||
if got := askSentence(askViewOf(s, false)); !strings.Contains(got, "The bastion stopped aider") {
|
||||
t.Errorf("the agent reads %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Another account learns nothing about an ask, not even that it exists.
|
||||
func TestAnotherAccountLearnsNothingOfAnAsk(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
|
||||
if !r.OK {
|
||||
t.Fatal(r.Why)
|
||||
}
|
||||
theirs := askStatus(d, r.Request, "other", false)
|
||||
never := askStatus(d, "ffffffff", "other", false)
|
||||
if theirs.OK || theirs.State != askUnknown || theirs.Text != "" {
|
||||
t.Fatalf("another account read %+v", theirs)
|
||||
}
|
||||
if strings.ReplaceAll(theirs.Why, r.Request, "X") != strings.ReplaceAll(never.Why, "ffffffff", "X") {
|
||||
t.Errorf("an existing id reads differently from one that never existed: %q vs %q", theirs.Why, never.Why)
|
||||
}
|
||||
if s := askStatus(d, r.Request, "", false); s.OK {
|
||||
t.Errorf("an unnamed asker read an ask: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// A deny says so, blocks the same ask, and blocks it for a day from the
|
||||
// deny, not from the ask.
|
||||
func TestADeniedAskBlocksADayFromTheDeny(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
|
||||
id := fullRequestID(t, d, r.Request)
|
||||
if err := stepupDenyRequest(d, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := askStatus(d, r.Request, "u", false); s.State != askDenied {
|
||||
t.Fatalf("after the deny: %+v", s)
|
||||
}
|
||||
again := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
|
||||
if again.OK || !strings.Contains(again.Why, "refused this same stop within the last 24 hours") {
|
||||
t.Fatalf("the same ask again: %+v", again)
|
||||
}
|
||||
|
||||
// Asked 25 h ago, denied a minute ago: still blocked.
|
||||
m := requestsLoad(d)
|
||||
q := m[id]
|
||||
q.At = time.Now().Add(-25 * time.Hour).UTC().Format(time.RFC3339)
|
||||
q.DeniedAt = time.Now().Add(-time.Minute).UTC().Format(time.RFC3339)
|
||||
m[id] = q
|
||||
if err := requestsSave(d, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := requestsLoad(d)[id]; !ok {
|
||||
t.Fatal("a deny a minute old stopped blocking because the ask was 25 h old")
|
||||
}
|
||||
q.DeniedAt = time.Now().Add(-25 * time.Hour).UTC().Format(time.RFC3339)
|
||||
m[id] = q
|
||||
_ = requestsSave(d, m)
|
||||
if _, ok := requestsLoad(d)[id]; ok {
|
||||
t.Fatal("a deny 25 h old still blocks")
|
||||
}
|
||||
}
|
||||
|
||||
// Five refused asks in ten minutes hold that account's asks; another account
|
||||
// is not held; the owner lifts it.
|
||||
func TestRefusedAsksHoldTheAccountThatAsked(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
for i := 0; i < quarantineStrikes; i++ {
|
||||
if r := fileAsk(agentAskRequest{Op: "stop", PID: 4532}, "u", 311); r.OK {
|
||||
t.Fatalf("an app was asked to stop: %+v", r)
|
||||
}
|
||||
}
|
||||
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
|
||||
if r.OK || !strings.Contains(r.Why, "on hold until") {
|
||||
t.Fatalf("after five refusals: %+v", r)
|
||||
}
|
||||
if r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "other", 0); !r.OK {
|
||||
t.Fatalf("another account was held: %s", r.Why)
|
||||
}
|
||||
if err := askQuarantineClear(d, "u"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := fileAsk(agentAskRequest{Op: "stop", PID: 102}, "u", 311); !r.OK {
|
||||
t.Fatalf("after the owner lifted it: %s", r.Why)
|
||||
}
|
||||
}
|
||||
|
||||
// What the owner refuses counts too: five denies hold the account.
|
||||
func TestOwnerDeniesCountTowardTheHold(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
for pid := 101; pid < 101+quarantineStrikes; pid++ {
|
||||
r := fileAsk(agentAskRequest{Op: "stop", PID: pid}, "u", 311)
|
||||
if !r.OK {
|
||||
t.Fatalf("ask %d: %s", pid, r.Why)
|
||||
}
|
||||
if err := stepupDenyRequest(d, fullRequestID(t, d, r.Request)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if r := fileAsk(agentAskRequest{Op: "stop", PID: 106}, "u", 311); r.OK || !strings.Contains(r.Why, "on hold") {
|
||||
t.Fatalf("after five denies: %+v", r)
|
||||
}
|
||||
// What the machine itself lacks is not the asker's doing.
|
||||
if until := askHeldUntil(d, "nobody", time.Now()); !until.IsZero() {
|
||||
t.Fatal("an account that never asked is held")
|
||||
}
|
||||
}
|
||||
|
||||
func auditRowsOf(t *testing.T, d, op string) []map[string]any {
|
||||
t.Helper()
|
||||
var out []map[string]any
|
||||
fh, err := os.Open(filepath.Join(d, "audit.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer fh.Close()
|
||||
sc := bufio.NewScanner(fh)
|
||||
for sc.Scan() {
|
||||
var m map[string]any
|
||||
if json.Unmarshal(sc.Bytes(), &m) == nil && m["op"] == op {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Every row about an agent's ask names the asking agent and the target, with
|
||||
// how each was started: filed, approved, carried out.
|
||||
func TestTheAuditNamesTheAskerAndTheTarget(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
|
||||
if _, err := stepupApproveRequest(d, fullRequestID(t, d, r.Request), codeFor(t, machineTestSecret)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
localStopOnce(d, time.Now())
|
||||
for _, op := range []string{localStopAskOp, "stepup_approve", localStopKind} {
|
||||
rows := auditRowsOf(t, d, op)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("%s: %d rows", op, len(rows))
|
||||
}
|
||||
raw, _ := json.Marshal(rows[0])
|
||||
var row struct {
|
||||
Asker struct {
|
||||
Account string `json:"account"`
|
||||
PID int `json:"pid"`
|
||||
Agent struct {
|
||||
Signature, Origin string
|
||||
PID int
|
||||
} `json:"agent"`
|
||||
} `json:"asker"`
|
||||
Target struct {
|
||||
Signature, Origin string
|
||||
PID int
|
||||
} `json:"target"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &row)
|
||||
a, g := row.Asker, row.Target
|
||||
if a.Account != "u" || a.PID != 311 || a.Agent.Signature != "claude-code" || a.Agent.PID != 300 ||
|
||||
a.Agent.Origin != "interactive" || g.Signature != "aider" || g.PID != 101 || g.Origin != "scheduler" {
|
||||
t.Errorf("%s row: %s", op, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The owner sees the ask and the deny in Activity.
|
||||
func TestActivityShowsAsksAndDenies(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
|
||||
if err := stepupDenyRequest(d, fullRequestID(t, d, r.Request)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var asked, denied bool
|
||||
for _, it := range collectActivity(time.Time{}) {
|
||||
if strings.Contains(it.Who, "Claude Code (pid 300)") && strings.Contains(it.What, "asked to stop aider") {
|
||||
asked = true
|
||||
}
|
||||
if it.Who == "You" && strings.HasPrefix(it.What, "denied ") && strings.Contains(it.What, "asked by Claude Code") &&
|
||||
it.Refused {
|
||||
denied = true
|
||||
}
|
||||
}
|
||||
if !asked || !denied {
|
||||
t.Fatalf("asked %v denied %v in %+v", asked, denied, collectActivity(time.Time{}))
|
||||
}
|
||||
}
|
||||
|
||||
// What an agent branches on: the exit status, and JSON it can parse.
|
||||
func TestAskExitStatusesAndJSON(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
v askView
|
||||
waited bool
|
||||
want int // -1: no error
|
||||
}{
|
||||
{askView{State: askStopped}, true, -1},
|
||||
{askView{State: askWaiting, Request: "abcd1234"}, false, -1},
|
||||
{askView{State: askWaiting, Request: "abcd1234"}, true, askExitWaiting},
|
||||
{askView{State: askApproved}, true, askExitWaiting},
|
||||
{askView{State: askDenied}, true, 1},
|
||||
{askView{State: askNotStopped}, false, 1},
|
||||
{askView{State: askRefused, Why: "x"}, false, 1},
|
||||
{askView{State: askUnknown, Why: "x"}, false, 1},
|
||||
} {
|
||||
var buf bytes.Buffer
|
||||
err := printAsk(&buf, c.v, true, c.waited)
|
||||
var q quietExit
|
||||
got := -1
|
||||
if errors.As(err, &q) {
|
||||
got = q.code
|
||||
} else if err != nil {
|
||||
t.Fatalf("%+v: %v", c.v, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("%s (waited %v): exit %d, want %d", c.v.State, c.waited, got, c.want)
|
||||
}
|
||||
var back askView
|
||||
if json.Unmarshal(buf.Bytes(), &back) != nil || back.State != c.v.State {
|
||||
t.Errorf("%s: --json printed %q", c.v.State, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Agents write the pid before the flags as often as after.
|
||||
func TestAskFlagsTakeThePositionalAnywhere(t *testing.T) {
|
||||
for _, args := range [][]string{{"101", "--wait", "5m", "--json"}, {"--json", "101", "--wait=5m"},
|
||||
{"--wait", "5m", "--json", "101"}} {
|
||||
key, wait, asJSON, err := askFlags("stop", args)
|
||||
if err != nil || key != "101" || wait != 5*time.Minute || !asJSON {
|
||||
t.Errorf("%v: %q %v %v %v", args, key, wait, asJSON, err)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{{}, {"101", "102"}, {"101", "--wait", "-1m"}, {"101", "--nope"}} {
|
||||
if _, _, _, err := askFlags("stop", args); err == nil {
|
||||
t.Errorf("%v was accepted", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A Host's request is the Host's own words. One that names an account on
|
||||
// this machine as its asker must not put that account in the owner's audit,
|
||||
// and the owner denying it must not count toward that account's hold.
|
||||
func TestAHostsRequestNeverStrikesALocalAccount(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
for i := 0; i < quarantineStrikes; i++ {
|
||||
gd := map[string]any{"kind": "dcn_store_grant", "server": "box1", "root": "/data/" + strconv.Itoa(i),
|
||||
"mask": []any{"read"}, "sweep": "hourly", "sensitive": false,
|
||||
"asked_by_account": "u", "asked_by": "You", "asker": map[string]any{"account": "u"}}
|
||||
gp, err := purposeFromDetails(gd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := stepupNewRequest(d, gp, gd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := stepupDenyRequest(d, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if until := askHeldUntil(d, "u", time.Now()); !until.IsZero() {
|
||||
t.Fatalf("denying a Host's requests held a local account until %s", until)
|
||||
}
|
||||
for _, row := range auditRowsOf(t, d, "stepup_deny") {
|
||||
if row["asker"] != nil || row["who"] != nil {
|
||||
t.Fatalf("a Host's request named a local asker in the audit: %v", row)
|
||||
}
|
||||
}
|
||||
// A critical action's own "target" is not an ask's target.
|
||||
if p := askPartiesOf(map[string]any{"kind": "critical_action", "target": "prod-db"}); p != nil {
|
||||
t.Fatalf("a Host's request carried ask parties: %v", p)
|
||||
}
|
||||
// At the Host's door: the fields only this machine writes are dropped.
|
||||
ls := map[string]any{"kind": localStopKind, "pid": "101", "signature": "aider", "asked_by_account": "u",
|
||||
"asker": map[string]any{}, "asked_by": "You", "target": map[string]any{}}
|
||||
hostAskDetails(ls)
|
||||
for _, k := range []string{"asked_by_account", "asker", "asked_by", "target"} {
|
||||
if _, ok := ls[k]; ok {
|
||||
t.Fatalf("a Host's local_stop kept %q: %v", k, ls)
|
||||
}
|
||||
}
|
||||
ca := map[string]any{"kind": "critical_action", "target": "prod-db", "asked_by_account": "u"}
|
||||
hostAskDetails(ca)
|
||||
if ca["target"] != "prod-db" || ca["asked_by_account"] != nil {
|
||||
t.Fatalf("a critical action keeps its target and loses the local asker: %v", ca)
|
||||
}
|
||||
}
|
||||
|
||||
// The holds file is keyed by kernel account names, and a user with
|
||||
// subordinate ids can present thousands. An account with nothing left to
|
||||
// remember (not held, no recent refusal, no hold within the forgiving day)
|
||||
// is dropped at the next write; the ones that still matter stay.
|
||||
func TestTheHoldsFileForgetsAccountsWithNothingToRemember(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
now := time.Now()
|
||||
at := func(ago time.Duration) string { return now.Add(-ago).UTC().Format(time.RFC3339) }
|
||||
m := map[string]*quarantineEntry{
|
||||
"stale": {Refusals: []string{at(time.Hour)}},
|
||||
"old-hold": {Strikes: 1, LastHold: at(48 * time.Hour)},
|
||||
"held": {Strikes: 1, Until: now.Add(time.Hour).UTC().Format(time.RFC3339), LastHold: at(-time.Hour)},
|
||||
"doubling": {Strikes: 2, LastHold: at(2 * time.Hour)},
|
||||
"recent-refus": {Refusals: []string{at(time.Minute)}},
|
||||
}
|
||||
if err := askQuarantineSave(d, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
askStrike(d, "new", now)
|
||||
got := askQuarantineLoad(d)
|
||||
for _, k := range []string{"stale", "old-hold"} {
|
||||
if _, ok := got[k]; ok {
|
||||
t.Fatalf("%s has nothing left to remember and was kept: %v", k, got)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"held", "doubling", "recent-refus", "new"} {
|
||||
if _, ok := got[k]; !ok {
|
||||
t.Fatalf("%s still matters and was dropped: %v", k, got)
|
||||
}
|
||||
}
|
||||
if got["doubling"].Strikes != 2 {
|
||||
t.Fatalf("the doubling was lost: %+v", got["doubling"])
|
||||
}
|
||||
}
|
||||
|
||||
// Two accounts asking for the same stop each get their own request, which
|
||||
// each alone can follow: handing the second one the first's id told it that
|
||||
// someone else had asked, and gave it an id it could not follow.
|
||||
func TestTwoAccountsAskingTheSameStopEachGetTheirOwn(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
a := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
|
||||
b := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "other", 0)
|
||||
if !a.OK || !b.OK {
|
||||
t.Fatalf("asks: %+v %+v", a, b)
|
||||
}
|
||||
if a.Request == b.Request {
|
||||
t.Fatalf("the second account was handed the first account's request %s", a.Request)
|
||||
}
|
||||
if s := askStatus(d, b.Request, "other", false); !s.OK || s.State != askWaiting {
|
||||
t.Fatalf("the second account cannot follow its own ask: %+v", s)
|
||||
}
|
||||
if again := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311); again.Request != a.Request {
|
||||
t.Errorf("the same account asking again got a new request %s, not %s", again.Request, a.Request)
|
||||
}
|
||||
// The owner's deny of that stop holds for everyone.
|
||||
if err := stepupDenyRequest(d, fullRequestID(t, d, a.Request)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "third", 0); r.OK {
|
||||
t.Errorf("a stop the owner refused was filed again by another account: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// An approved stop was acted on; if it could not be done, asking again is a
|
||||
// new request, not the old approved one for the rest of the day.
|
||||
func TestAStopThatFailedCanBeAskedAgain(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
first := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
|
||||
if _, err := stepupApproveRequest(d, fullRequestID(t, d, first.Request), codeFor(t, machineTestSecret)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
localStopOnce(d, time.Now().Add(localStopWindow+time.Minute)) // read too late: not stopped
|
||||
if s := askStatus(d, first.Request, "u", false); s.State != askNotStopped {
|
||||
t.Fatalf("the late stop: %+v", s)
|
||||
}
|
||||
again := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
|
||||
if !again.OK || again.Request == first.Request {
|
||||
t.Fatalf("asking again after a failed stop: %+v (first %s)", again, first.Request)
|
||||
}
|
||||
if s := askStatus(d, again.Request, "u", false); s.State != askWaiting {
|
||||
t.Errorf("the new ask: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// An account at its limit that asks again for a stop it already has waiting
|
||||
// gets that request back: it is not a sixth ask, and it is not a refusal to
|
||||
// count toward a hold.
|
||||
func TestAskingAgainAtTheLimitReturnsTheWaitingRequest(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
var first string
|
||||
for pid := 101; pid < 101+askMaxPendingPer; pid++ {
|
||||
r := fileAsk(agentAskRequest{Op: "stop", PID: pid}, "u", 311)
|
||||
if !r.OK {
|
||||
t.Fatalf("ask %d: %s", pid, r.Why)
|
||||
}
|
||||
if first == "" {
|
||||
first = r.Request
|
||||
}
|
||||
}
|
||||
for i := 0; i < quarantineStrikes; i++ {
|
||||
if r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311); !r.OK || r.Request != first {
|
||||
t.Fatalf("asking again for a waiting stop at the limit: %+v (want %s)", r, first)
|
||||
}
|
||||
}
|
||||
if until := askHeldUntil(d, "u", time.Now()); !until.IsZero() {
|
||||
t.Fatalf("repeating a waiting ask held the account until %v", until)
|
||||
}
|
||||
if r := fileAsk(agentAskRequest{Op: "stop", PID: 106}, "u", 311); r.OK || !strings.Contains(r.Why, "already have 5") {
|
||||
t.Errorf("a new sixth ask: %+v", r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
// askparties.go — who asked to stop what, in the form the audit keeps: the
|
||||
// asking account (the kernel's answer), the asking process and the agent it
|
||||
// descends from, and the target, each with how it was started (origin.go:
|
||||
// interactive, scheduler, service, ...). They are kept on the request itself
|
||||
// (Details "asker" and "target"), so every later row about that request —
|
||||
// approved, denied, carried out — names both, not only the row that filed it.
|
||||
|
||||
// askingAgentObs is the observed agent pid descends from, and its pid.
|
||||
func askingAgentObs(pid int, observed map[int]agentObs) (agentObs, int, bool) {
|
||||
parents := processTree()
|
||||
p := pid
|
||||
for i := 0; p > 1 && i < 64; i++ {
|
||||
if a, ok := observed[p]; ok && a.Kind != kindApp {
|
||||
return a, p, true
|
||||
}
|
||||
p = parents[p]
|
||||
}
|
||||
return agentObs{}, 0, false
|
||||
}
|
||||
|
||||
// askParties describes the asker and the target of one stop ask. askerPID is
|
||||
// the kernel's pid for the asking process, 0 where the platform does not say.
|
||||
func askParties(account string, askerPID, targetPID int, observed map[int]agentObs) (asker, target map[string]any) {
|
||||
asker = map[string]any{"account": account}
|
||||
if askerPID > 1 {
|
||||
asker["pid"] = askerPID
|
||||
if a, p, ok := askingAgentObs(askerPID, observed); ok {
|
||||
asker["agent"] = agentParty(a, p)
|
||||
}
|
||||
}
|
||||
target = agentParty(observed[targetPID], targetPID)
|
||||
return asker, target
|
||||
}
|
||||
|
||||
func agentParty(a agentObs, pid int) map[string]any {
|
||||
out := map[string]any{"pid": pid, "signature": a.Signature, "name": familyLabel(a.Signature)}
|
||||
if a.Origin != "" {
|
||||
out["origin"] = a.Origin
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// askPartiesOf is what a request carries about its asker and target, for an
|
||||
// audit row; nil for a request no local agent asked (a Host's).
|
||||
func askPartiesOf(d map[string]any) map[string]any {
|
||||
if kind, _ := d["kind"].(string); kind != localStopKind {
|
||||
return nil // "target" is a critical_action's own word, not a party
|
||||
}
|
||||
out := map[string]any{}
|
||||
for _, k := range []string{"asker", "target"} {
|
||||
if v, ok := d[k]; ok && v != nil {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
if by, _ := d["asked_by"].(string); by != "" {
|
||||
out["who"] = by
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hostAskDetails drops from a Host's request the fields only this machine
|
||||
// writes about a local agent's ask. A Host's details are its own words, so
|
||||
// without this it could name an account on this computer as the asker in the
|
||||
// owner's audit, and have the owner's deny count against that account.
|
||||
func hostAskDetails(d map[string]any) {
|
||||
delete(d, "asked_by_account")
|
||||
delete(d, "asker")
|
||||
if kind, _ := d["kind"].(string); kind == localStopKind {
|
||||
delete(d, "asked_by")
|
||||
delete(d, "target")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
// askquarantine.go — the machine holds an account whose asks keep being
|
||||
// refused, the way quarantine.go holds a Host pairing: five refused asks
|
||||
// inside ten minutes hold that account's asks for ten minutes, doubling on
|
||||
// every repeat, forgiven after a clean day (the same arithmetic,
|
||||
// quarantineEntry.strike). Asking is how an agent on this computer reaches
|
||||
// the owner, so an agent that keeps asking for what is refused is the local
|
||||
// form of a Host probing the management lane.
|
||||
//
|
||||
// Keyed by the asking account (the kernel's answer on ask.sock, not anything
|
||||
// the asker says), and kept in the owner's factor directory beside the
|
||||
// requests themselves, never on the heartbeat: which accounts on this
|
||||
// computer asked for what is not a Host's business.
|
||||
//
|
||||
// What strikes: the owner denying an ask, and refusals that are the asker's
|
||||
// own doing (not an agent this bastion sees, an ask the owner refused in the
|
||||
// last day, too many waiting). What does not: the owner having no code set
|
||||
// up, a store that could not be written — this machine's state, not the
|
||||
// asker's.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const askQuarantineFile = "ask_quarantine.json"
|
||||
|
||||
var askQuarantineMu sync.Mutex
|
||||
|
||||
func askQuarantinePath(dir string) string { return filepath.Join(dir, askQuarantineFile) }
|
||||
|
||||
func askQuarantineLoad(dir string) map[string]*quarantineEntry {
|
||||
m := map[string]*quarantineEntry{}
|
||||
if raw, err := os.ReadFile(askQuarantinePath(dir)); err == nil {
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func askQuarantineSave(dir string, m map[string]*quarantineEntry) error {
|
||||
raw, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return atomicWrite(askQuarantinePath(dir), raw)
|
||||
}
|
||||
|
||||
// askStrike counts one refused ask from account. The hold that starts on the
|
||||
// last strike is audited, so the owner sees it in Activity.
|
||||
func askStrike(dir, account string, now time.Time) {
|
||||
if dir == "" || account == "" {
|
||||
return
|
||||
}
|
||||
askQuarantineMu.Lock()
|
||||
defer askQuarantineMu.Unlock()
|
||||
m := askQuarantineLoad(dir)
|
||||
e := m[account]
|
||||
if e == nil {
|
||||
e = &quarantineEntry{}
|
||||
m[account] = e
|
||||
}
|
||||
held := e.strike(now)
|
||||
askQuarantinePrune(m, now)
|
||||
_ = askQuarantineSave(dir, m)
|
||||
if held {
|
||||
dcnAudit(dir, askHoldOp, 0, account, "held until "+e.Until+": "+strconv.Itoa(quarantineStrikes)+
|
||||
" refused asks within "+askWindowWords())
|
||||
}
|
||||
}
|
||||
|
||||
const askHoldOp = "local_ask_hold"
|
||||
|
||||
// askQuarantinePrune drops the accounts there is nothing left to remember
|
||||
// about: not held, no refusal inside the window, and no hold within the
|
||||
// forgiving day (strike would reset their doubling anyway). Accounts are the
|
||||
// kernel's names, and a user with subordinate ids can present thousands of
|
||||
// them, so without this the file grows by one entry per id that was ever
|
||||
// refused and is rewritten whole on every strike.
|
||||
func askQuarantinePrune(m map[string]*quarantineEntry, now time.Time) {
|
||||
for acct, e := range m {
|
||||
if e == nil {
|
||||
delete(m, acct)
|
||||
continue
|
||||
}
|
||||
if quarantinedAt(e, now) {
|
||||
continue
|
||||
}
|
||||
recent := false
|
||||
for _, ts := range e.Refusals {
|
||||
if t, err := time.Parse(time.RFC3339, ts); err == nil && now.Sub(t) < quarantineWindow {
|
||||
recent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if recent {
|
||||
continue
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, e.LastHold); err == nil && now.Sub(t) <= quarantineForgive {
|
||||
continue
|
||||
}
|
||||
delete(m, acct)
|
||||
}
|
||||
}
|
||||
|
||||
// askHeldUntil: when account may ask again, or the zero time.
|
||||
func askHeldUntil(dir, account string, now time.Time) time.Time {
|
||||
if dir == "" || account == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
askQuarantineMu.Lock()
|
||||
defer askQuarantineMu.Unlock()
|
||||
e := askQuarantineLoad(dir)[account]
|
||||
if !quarantinedAt(e, now) {
|
||||
return time.Time{}
|
||||
}
|
||||
t, _ := time.Parse(time.RFC3339, e.Until)
|
||||
return t
|
||||
}
|
||||
|
||||
// askHoldRefusal is what a held account is told.
|
||||
func askHoldRefusal(account string, until time.Time) string {
|
||||
return fmt.Sprintf("asks from %s are on hold until %s: %d of them were refused within %s. "+
|
||||
"The machine's owner can lift the hold", account, until.Local().Format("15:04"), quarantineStrikes,
|
||||
askWindowWords())
|
||||
}
|
||||
|
||||
func askWindowWords() string { return strconv.Itoa(int(quarantineWindow.Minutes())) + " minutes" }
|
||||
|
||||
// askQuarantineClear — the owner lifts the hold, and the strikes with it.
|
||||
func askQuarantineClear(dir, account string) error {
|
||||
askQuarantineMu.Lock()
|
||||
defer askQuarantineMu.Unlock()
|
||||
m := askQuarantineLoad(dir)
|
||||
if _, ok := m[account]; !ok {
|
||||
return fmt.Errorf("asks from %s are not held, and none of theirs were refused recently", account)
|
||||
}
|
||||
delete(m, account)
|
||||
if err := askQuarantineSave(dir, m); err != nil {
|
||||
return err
|
||||
}
|
||||
dcnAudit(dir, askHoldOp, 0, account, "lifted by the owner")
|
||||
return nil
|
||||
}
|
||||
|
||||
// cmdQuarantineClearAsker is `quarantine clear --asker <account>`. The holds
|
||||
// live in the owner's factor directory, so only the account that holds it —
|
||||
// the administrator, on a whole-machine install — finds them.
|
||||
func cmdQuarantineClearAsker(account string) error {
|
||||
if account == "" {
|
||||
return errors.New("which account? the hold names it")
|
||||
}
|
||||
dir := existingMachineFactorDir()
|
||||
if dir == "" {
|
||||
return errors.New("no holds on asks are kept for this account. On a machine the bastion is installed " +
|
||||
"for as a whole, they are the administrator's: `sudo -H privatae-bastion quarantine clear --asker " +
|
||||
account + "`")
|
||||
}
|
||||
if err := askQuarantineClear(dir, account); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("agents running as %s may ask this machine again\n", account)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package main
|
||||
|
||||
// askstatus.go — the asking side follows its ask to the end. An agent that
|
||||
// ran `stop` used to get a request id and nothing after it: the outcome was
|
||||
// kept under the approval id, which only the owner's side ever sees, so the
|
||||
// agent could not tell the person whether the stop happened.
|
||||
//
|
||||
// privatae-bastion stop <pid|session> [--wait 5m] [--json]
|
||||
// privatae-bastion request <id> [--wait 5m] [--json]
|
||||
//
|
||||
// Where the bastion runs as another account (installed for the whole
|
||||
// machine), both go through ask.sock (op "status"), which answers only the
|
||||
// account that asked: any other account is told what it would be told for an
|
||||
// id that never existed.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What an ask can be, as an agent reads it.
|
||||
const (
|
||||
askWaiting = "waiting" // the owner has not answered
|
||||
askApproved = "approved" // approved; the bastion is carrying it out
|
||||
askStopped = "stopped" // approved and done
|
||||
askNotStopped = "not_stopped" // approved, but it could not be done (outcome says why)
|
||||
askDenied = "denied" // the owner refused it
|
||||
askRefused = "refused" // nothing was asked (why says why)
|
||||
askUnknown = "unknown" // no such ask from this account, or it is older than a day
|
||||
)
|
||||
|
||||
var askPollEvery = 2 * time.Second
|
||||
|
||||
// askStatus is where the ask with this id prefix stands. anyAccount is for a
|
||||
// reader that holds the state itself (it could read the files anyway);
|
||||
// ask.sock always passes false, so an account sees only its own asks.
|
||||
func askStatus(dir, prefix, user string, anyAccount bool) agentAskReply {
|
||||
r := agentAskReply{User: user}
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
r.Why = "which request? give the id `stop` printed"
|
||||
return r
|
||||
}
|
||||
if dir == "" || (!anyAccount && user == "") {
|
||||
r.State, r.Why = askUnknown, "no request "+prefix+" was asked from your account"
|
||||
return r
|
||||
}
|
||||
stepupMu.Lock()
|
||||
m := requestsLoad(dir)
|
||||
results := localStopResultsLoad(dir)
|
||||
stepupMu.Unlock()
|
||||
var hit string
|
||||
for id, q := range m {
|
||||
if !strings.HasPrefix(id, prefix) {
|
||||
continue
|
||||
}
|
||||
if kind, _ := q.Details["kind"].(string); kind != localStopKind {
|
||||
continue
|
||||
}
|
||||
if acct, _ := q.Details["asked_by_account"].(string); !anyAccount && acct != user {
|
||||
continue
|
||||
}
|
||||
if hit != "" {
|
||||
r.Why = "'" + prefix + "' matches more than one request; give more of the id"
|
||||
return r
|
||||
}
|
||||
hit = id
|
||||
}
|
||||
if hit == "" {
|
||||
r.State, r.Why = askUnknown, "no request "+prefix+" was asked from your account in the last day"
|
||||
return r
|
||||
}
|
||||
q := m[hit]
|
||||
r.OK, r.Request, r.Text = true, hit[:8], describeRequest(q)
|
||||
r.State, r.Outcome = askState(q, results)
|
||||
return r
|
||||
}
|
||||
|
||||
func askState(q stepupRequest, results map[string]localStopResult) (state, outcome string) {
|
||||
switch q.State {
|
||||
case "pending":
|
||||
return askWaiting, ""
|
||||
case "denied":
|
||||
return askDenied, ""
|
||||
case "approved":
|
||||
res, ok := results[q.Approval]
|
||||
switch {
|
||||
case !ok:
|
||||
return askApproved, ""
|
||||
case strings.HasPrefix(res.Outcome, "not stopped"):
|
||||
return askNotStopped, res.Outcome
|
||||
default:
|
||||
return askStopped, res.Outcome
|
||||
}
|
||||
}
|
||||
return askUnknown, ""
|
||||
}
|
||||
|
||||
// askView is what `stop` and `request` print, and their --json.
|
||||
type askView struct {
|
||||
Request string `json:"request,omitempty"`
|
||||
State string `json:"state"`
|
||||
Text string `json:"text,omitempty"` // what the owner reads
|
||||
Outcome string `json:"outcome,omitempty"` // once carried out
|
||||
AskedAs string `json:"asked_as,omitempty"`
|
||||
Why string `json:"why,omitempty"` // refused or unknown
|
||||
}
|
||||
|
||||
func (v askView) settled() bool { return v.State != askWaiting && v.State != askApproved }
|
||||
|
||||
func askViewOf(r agentAskReply, filing bool) askView {
|
||||
v := askView{Request: r.Request, State: r.State, Text: r.Text, Outcome: r.Outcome, AskedAs: r.User, Why: r.Why}
|
||||
switch {
|
||||
case !r.OK && filing:
|
||||
v.State = askRefused
|
||||
case !r.OK && v.State == "":
|
||||
v.State = askUnknown
|
||||
case filing:
|
||||
v.State = askWaiting
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// asksGoThroughSock: the running bastion is not this account's (installed
|
||||
// for the whole machine), so asking and following an ask go over ask.sock,
|
||||
// which names the asker from the kernel.
|
||||
func asksGoThroughSock() bool { return !machineAuthorityHeld() && !uiDaemonListening() }
|
||||
|
||||
func askStatusFor(prefix string) (askView, error) {
|
||||
if asksGoThroughSock() {
|
||||
r, err := askViaSock(agentAskRequest{Op: "status", Request: prefix})
|
||||
if err != nil {
|
||||
return askView{}, fmt.Errorf("could not reach this machine's bastion (%v); `privatae-bastion status` "+
|
||||
"says whether it is running", err)
|
||||
}
|
||||
return askViewOf(r, false), nil
|
||||
}
|
||||
t, err := resolveStepupTarget("", true)
|
||||
if err != nil {
|
||||
return askView{}, err
|
||||
}
|
||||
return askViewOf(askStatus(t.dir, prefix, procUser(os.Getpid()), true), false), nil
|
||||
}
|
||||
|
||||
// askFollow polls until the ask settles or wait runs out.
|
||||
func askFollow(v askView, wait time.Duration) (askView, error) {
|
||||
deadline := time.Now().Add(wait)
|
||||
for !v.settled() && v.Request != "" && time.Now().Before(deadline) {
|
||||
time.Sleep(askPollEvery)
|
||||
nv, err := askStatusFor(v.Request)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
v = nv
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// quietExit ends a command with a status and nothing more printed: the
|
||||
// command has already said what happened.
|
||||
type quietExit struct{ code int }
|
||||
|
||||
func (q quietExit) Error() string { return fmt.Sprintf("exit status %d", q.code) }
|
||||
|
||||
// Exit statuses an agent can branch on: 0 stopped, or asked and not waited
|
||||
// for; 1 refused, denied, not stopped or unknown; 3 still waiting when --wait
|
||||
// ran out.
|
||||
const askExitWaiting = 3
|
||||
|
||||
func printAsk(w io.Writer, v askView, asJSON bool, waited bool) error {
|
||||
if asJSON {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(v)
|
||||
} else {
|
||||
fmt.Fprintln(w, askSentence(v))
|
||||
}
|
||||
switch {
|
||||
case v.State == askStopped:
|
||||
return nil
|
||||
case !v.settled() && !waited:
|
||||
return nil
|
||||
case !v.settled():
|
||||
return quietExit{askExitWaiting}
|
||||
}
|
||||
return quietExit{1}
|
||||
}
|
||||
|
||||
func askSentence(v askView) string {
|
||||
switch v.State {
|
||||
case askRefused:
|
||||
return "not asked: " + v.Why
|
||||
case askUnknown:
|
||||
return v.Why
|
||||
case askWaiting:
|
||||
s := "asked"
|
||||
if v.AskedAs != "" {
|
||||
s += ", as " + v.AskedAs
|
||||
}
|
||||
return s + ": " + v.Text + "\nIt waits for the machine owner's approval (request " + v.Request +
|
||||
"), on Waiting for you in the bastion console. Nothing is stopped until they approve it.\n" +
|
||||
"Follow it with `privatae-bastion request " + v.Request + " --wait 5m`."
|
||||
case askApproved:
|
||||
return "request " + v.Request + ": approved by the machine's owner; the bastion is carrying it out."
|
||||
case askStopped:
|
||||
return "request " + v.Request + ": approved by the machine's owner and done. " +
|
||||
strings.Replace(v.Outcome, "You stopped", "The bastion stopped", 1)
|
||||
case askNotStopped:
|
||||
return "request " + v.Request + ": approved by the machine's owner, but " + v.Outcome
|
||||
case askDenied:
|
||||
return "request " + v.Request + ": the machine's owner denied it. The same stop cannot be asked " +
|
||||
"again for 24 hours."
|
||||
}
|
||||
return "request " + v.Request + ": " + v.State
|
||||
}
|
||||
|
||||
// askFlags parses what `stop` and `request` share. It returns the one
|
||||
// positional argument.
|
||||
func askFlags(name string, args []string) (string, time.Duration, bool, error) {
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
wait := fs.Duration("wait", 0, "")
|
||||
asJSON := fs.Bool("json", false, "")
|
||||
// The id or pid may come before the flags (`stop 1234 --wait 5m`), as
|
||||
// agents write it.
|
||||
var pos []string
|
||||
rest := args
|
||||
for {
|
||||
if err := fs.Parse(rest); err != nil {
|
||||
return "", 0, false, err
|
||||
}
|
||||
if fs.NArg() == 0 {
|
||||
break
|
||||
}
|
||||
pos = append(pos, fs.Arg(0))
|
||||
rest = fs.Args()[1:]
|
||||
}
|
||||
if len(pos) != 1 {
|
||||
return "", 0, false, errors.New("give exactly one " + map[bool]string{true: "request id",
|
||||
false: "pid or session name"}[name == "request"])
|
||||
}
|
||||
if *wait < 0 {
|
||||
return "", 0, false, errors.New("--wait cannot be negative")
|
||||
}
|
||||
return pos[0], *wait, *asJSON, nil
|
||||
}
|
||||
|
||||
// cmdRequest is `privatae-bastion request <id> [--wait D] [--json]`.
|
||||
func cmdRequest(args []string) error {
|
||||
id, wait, asJSON, err := askFlags("request", args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v\nusage: %s", err, requestUsage)
|
||||
}
|
||||
v, err := askStatusFor(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wait > 0 {
|
||||
if v, err = askFollow(v, wait); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return printAsk(os.Stdout, v, asJSON, wait > 0)
|
||||
}
|
||||
|
||||
const requestUsage = "privatae-bastion request <id> [--wait 5m] [--json]"
|
||||
+21
-20
@@ -768,42 +768,43 @@ func cmdObserveBaseline(force bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// cmdObserveBaselineShow prints what this machine currently calls normal.
|
||||
func cmdObserveBaselineShow() error {
|
||||
f, ok, err := baselineLoad()
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return errors.New("this machine has no baseline yet — `privatae-bastion observe:baseline` " +
|
||||
// cmdObserveBaselineShow prints what this machine currently calls normal
|
||||
// (`baseline show`, machine_cli.go).
|
||||
func cmdObserveBaselineShow() error { return cmdBaseline([]string{"show"}) }
|
||||
|
||||
// printBaseline is `baseline show` in words.
|
||||
func printBaseline(w io.Writer, b baselineRead) error {
|
||||
if !b.Has {
|
||||
return errors.New("this machine has no baseline yet — `privatae-bastion baseline record` " +
|
||||
"takes one (as an administrator)")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("baseline taken %s by bastion %s, mode %s\n", f.TakenAt, f.BastionVersion, f.Mode)
|
||||
if note := baselineIntegrityNote(f, ok); note != "" {
|
||||
fmt.Printf("WARNING: %s\n", note)
|
||||
f := b.Baseline
|
||||
fmt.Fprintf(w, "baseline taken %s by bastion %s, mode %s\n", f.TakenAt, f.BastionVersion, f.Mode)
|
||||
if b.Integrity != "" {
|
||||
fmt.Fprintf(w, "WARNING: %s\n", b.Integrity)
|
||||
}
|
||||
// Provenance, judged the way the host judges it. Three lines a reader can
|
||||
// get: verified (and by whom), signed-but-failing (and which test failed),
|
||||
// or unsigned (and the reason the file itself records).
|
||||
switch {
|
||||
case f.Attestation != nil:
|
||||
if n := baselineAttestationNote(f); n != "" {
|
||||
fmt.Printf("WARNING: attestation: %s\n", n)
|
||||
if b.Attestation != "" {
|
||||
fmt.Fprintf(w, "WARNING: attestation: %s\n", b.Attestation)
|
||||
} else {
|
||||
fmt.Printf("signed by %s's pairing key; verified against its CA and this machine's fingerprint\n",
|
||||
fmt.Fprintf(w, "signed by %s's pairing key; verified against its CA and this machine's fingerprint\n",
|
||||
f.Attestation.SignedBy)
|
||||
}
|
||||
case f.UnsignedReason != "":
|
||||
fmt.Printf("unsigned: %s\n", f.UnsignedReason)
|
||||
fmt.Fprintf(w, "unsigned: %s\n", f.UnsignedReason)
|
||||
default:
|
||||
fmt.Printf("unsigned: taken by a bastion that did not sign accountings\n")
|
||||
fmt.Fprintf(w, "unsigned: taken by a bastion that did not sign accountings\n")
|
||||
}
|
||||
fmt.Printf("%d entries", len(f.Entries))
|
||||
fmt.Fprintf(w, "%d entries", len(f.Entries))
|
||||
if f.Unreadable > 0 {
|
||||
fmt.Printf(", %d without a readable executable (a baseline taken without "+
|
||||
fmt.Fprintf(w, ", %d without a readable executable (a baseline taken without "+
|
||||
"administrator rights sees less)", f.Unreadable)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Fprintln(w)
|
||||
for _, e := range f.Entries {
|
||||
who := e.Signature
|
||||
if who == "" {
|
||||
@@ -821,7 +822,7 @@ func cmdObserveBaselineShow() error {
|
||||
// against. Found by a test written for something else entirely.
|
||||
d = shortDigest(d)
|
||||
}
|
||||
fmt.Printf(" %s %-20s %-10s %s\n", e.FP, who, e.User, d)
|
||||
fmt.Fprintf(w, " %s %-20s %-10s %s\n", e.FP, who, e.User, d)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -553,6 +553,14 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
|
||||
"/fragments/context": "uicontext.go", "/context/scan": "uicontext.go",
|
||||
"/context/stop": "uicontext.go", "/context/ask": "uicontext.go",
|
||||
"/policy": "uipolicy.go", "/fragments/policy": "uipolicy.go",
|
||||
"/fragments/home": "uihome.go", "/agent": "uihome.go", "/machine": "uihome.go",
|
||||
"/agent/stop": "uihome.go", "/agent/mark": "uihome.go", "/activity": "uihome.go",
|
||||
"/fragments/machine/schedules": "uimachine.go", "/fragments/machine/tools": "uimachine.go",
|
||||
"/fragments/machine/baseline": "uimachine.go", "/machine/compare": "uimachine.go",
|
||||
"/machine/baseline": "uimachine.go",
|
||||
"/machine/report": "uimachine.go",
|
||||
"/setup/host/disconnect": "uisetup.go", "/setup/host/release": "uisetup.go",
|
||||
"/approvals/machine/approve": "uihome.go", "/approvals/machine/deny": "uihome.go",
|
||||
"/approvals": "uidevice.go|uitray.go", "/fragments/approvals": "uidevice.go|uitray.go",
|
||||
"/fragments/approvals-news": "uidevice.go|uitray.go", "/device/approve": "uidevice.go",
|
||||
"/device/deny": "uidevice.go", "/device/approve-shown": "uidevice.go",
|
||||
|
||||
@@ -15,12 +15,12 @@ import (
|
||||
)
|
||||
|
||||
// cmdStepupDrop revokes one unused approval this box issued for a pairing.
|
||||
func cmdStepupDrop(host, prefix string) error {
|
||||
cfg, err := stepupPairing(host)
|
||||
func cmdStepupDrop(host string, machine bool, prefix string) error {
|
||||
t, err := resolveStepupTarget(host, machine)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := cfg.dcnDir()
|
||||
dir := t.dir
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
return errors.New("which approval? (the id `approve` printed)")
|
||||
@@ -36,7 +36,7 @@ func cmdStepupDrop(host, prefix string) error {
|
||||
}
|
||||
switch len(hits) {
|
||||
case 0:
|
||||
return fmt.Errorf("no unused approval starts with %q for %s", prefix, cfg.HostName)
|
||||
return fmt.Errorf("no unused approval starts with %q %s", prefix, t.forWhom())
|
||||
case 1:
|
||||
default:
|
||||
return fmt.Errorf("%d approvals start with %q — give more of the id", len(hits), prefix)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
// consolelane.go — the machine owner's own account opens the console of the
|
||||
// bastion installed for the whole machine, without sudo (Linux and macOS).
|
||||
//
|
||||
// THE PROBLEM. Installed for the whole machine, the bastion runs as root and
|
||||
// its console signs in with root's ui.secret. `privatae-bastion ui` run by the
|
||||
// person reads their own account's state, and the console refuses that
|
||||
// account's key, so the person reached their own machine's console only
|
||||
// through sudo, and a browser started by sudo is root's, not theirs.
|
||||
//
|
||||
// THE LANE. ask.sock (agentask.go) already tells the daemon, from the kernel,
|
||||
// which account connected. Asked for "console" by the account recorded as the
|
||||
// machine's owner (owner_account.go), the daemon mints a one-use ticket of a
|
||||
// LIMITED kind and answers with the link; `ui` opens it in the person's own
|
||||
// browser. Any other account is refused, and no request text is trusted.
|
||||
//
|
||||
// WHAT A LIMITED SESSION MAY DO. Everything a person can see, everything that
|
||||
// only narrows reach (deny, switch off, disconnect, unmark), and everything
|
||||
// that takes the owner's code, which an agent running as the person cannot
|
||||
// read on this install. It is refused what widens access with no code today:
|
||||
// pairing a Host, setting up or rotating the owner's code, choosing the Host
|
||||
// in charge, proposing a folder, and the approval device's approvals. Those
|
||||
// stay with the administrator (`sudo -H privatae-bastion ui`). Codes typed for
|
||||
// a Host's requests in a limited session are throttled as the owner's are
|
||||
// (machineCodeSpend), because guessing is the only attack left there.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// consoleLaneTicketsMax is how many lane links may wait unopened at once.
|
||||
const consoleLaneTicketsMax = 16
|
||||
|
||||
// consoleLaneServer is the running console, for ask.sock to mint a ticket
|
||||
// on. nil until the console listens, and again after it stops.
|
||||
var consoleLaneServer atomic.Pointer[uiServer]
|
||||
|
||||
// consoleLaneRefusal says why account uid may not open the console through
|
||||
// the lane, or "" when it may.
|
||||
func consoleLaneRefusal(uid uint32, who string) string {
|
||||
owner, ownerName, present, err := ownerAccountLoad()
|
||||
switch {
|
||||
case err != nil:
|
||||
return "this machine's owner account cannot be read (" + err.Error() + "); the administrator opens " +
|
||||
"the console with: sudo -H privatae-bastion ui"
|
||||
case !present:
|
||||
return "no account is recorded as this machine's owner, so the console opens only for the administrator: " +
|
||||
"sudo -H privatae-bastion ui. To open it from your own account, the administrator records it once: " +
|
||||
"sudo -H privatae-bastion approvals-owner --account " + accountForHint(who)
|
||||
case owner != uid:
|
||||
name := ownerName
|
||||
if name == "" {
|
||||
name = "uid " + strconv.FormatUint(uint64(owner), 10)
|
||||
}
|
||||
return "this machine's console opens without sudo only for its owner's account (" + name + "), not " + who
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func accountForHint(who string) string {
|
||||
if who == "" || len(who) > 64 {
|
||||
return "<your account>"
|
||||
}
|
||||
return who
|
||||
}
|
||||
|
||||
// consoleLaneEntry is what the daemon answers an owner's "console" ask with:
|
||||
// the link that redeems a fresh limited ticket.
|
||||
func consoleLaneEntry(uid uint32, who string) (string, error) {
|
||||
if why := consoleLaneRefusal(uid, who); why != "" {
|
||||
return "", errors.New(why)
|
||||
}
|
||||
u := consoleLaneServer.Load()
|
||||
if u == nil || u.addr == "" {
|
||||
return "", errors.New("the bastion's console is not listening (no free port, or it is still starting)")
|
||||
}
|
||||
// A cap, so asking over and over cannot grow root's memory: a link lasts a
|
||||
// minute, and a person opens one at a time.
|
||||
tok, ok, err := u.limitedTickets.mintCapped(consoleLaneTicketsMax)
|
||||
if err != nil {
|
||||
return "", errors.New("could not mint a ticket")
|
||||
}
|
||||
if !ok {
|
||||
return "", errors.New("too many console links are waiting to be opened; open one, or wait a minute")
|
||||
}
|
||||
return "http://" + u.addr + "/?k=" + tok, nil
|
||||
}
|
||||
|
||||
// limitedSession reports whether the request's session came through the lane.
|
||||
func (u *uiServer) limitedSession(r *http.Request) bool {
|
||||
return u.limitedSessions != nil && u.limitedSessions.valid(uiSessionFrom(r))
|
||||
}
|
||||
|
||||
// signedIn reports whether the request carries either kind of session.
|
||||
func (u *uiServer) signedIn(r *http.Request) bool {
|
||||
tok := uiSessionFrom(r)
|
||||
return u.sessions.valid(tok) || (u.limitedSessions != nil && u.limitedSessions.valid(tok))
|
||||
}
|
||||
|
||||
const consoleLaneAdminOnly = "From your own account the console does not do this: it widens what reaches this machine " +
|
||||
"with no code, or reads beyond your own account. The machine's administrator does it: sudo -H privatae-bastion ui"
|
||||
|
||||
// adminOnly wraps a route a limited session may not use.
|
||||
func (u *uiServer) adminOnly(h http.HandlerFunc) http.HandlerFunc {
|
||||
return u.guarded(func(w http.ResponseWriter, r *http.Request) {
|
||||
if u.limitedSession(r) {
|
||||
http.Error(w, consoleLaneAdminOnly, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// consoleEntryForThisAccount is the link `ui` opens. An account with a
|
||||
// bastion of its own signs in with its own key (localUIEntryURL). An ordinary
|
||||
// account beside the machine's root bastion asks the lane, and is told why
|
||||
// not when it is not the owner's.
|
||||
func consoleEntryForThisAccount() (entry, addr string, err error) {
|
||||
if runtime.GOOS != "windows" && os.Geteuid() != 0 && !uiDaemonListening() {
|
||||
p := askSockPath()
|
||||
if why := contextSockDialable(p); why != "" {
|
||||
return "", "", errors.New(why)
|
||||
}
|
||||
if _, statErr := os.Stat(p); statErr == nil {
|
||||
r, err := askViaSock(agentAskRequest{Op: "console"})
|
||||
if err != nil {
|
||||
return "", "", errors.New("could not reach this machine's bastion at " + p + ": " + err.Error())
|
||||
}
|
||||
if !r.OK {
|
||||
return "", "", errors.New(r.Why)
|
||||
}
|
||||
u, err := url.Parse(r.URL)
|
||||
if err != nil || u.Scheme != "http" || !uiLoopbackHost(u.Host, u.Port()) {
|
||||
return "", "", errors.New("this machine's bastion answered with a link that is not its own console")
|
||||
}
|
||||
return r.URL, u.Host, nil
|
||||
}
|
||||
}
|
||||
return localUIEntryURL()
|
||||
}
|
||||
|
||||
// uiLimitedWriter marks a response as drawn for a limited session, so a page
|
||||
// shows the administrator's command in place of a form the session would be
|
||||
// refused (Settings, Approvals). It changes what is drawn, never what is
|
||||
// allowed: adminOnly decides that, from the session itself.
|
||||
type uiLimitedWriter struct{ http.ResponseWriter }
|
||||
|
||||
func limitedWriter(w http.ResponseWriter) bool {
|
||||
_, ok := w.(uiLimitedWriter)
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// laneOwner records uid as the machine's owner account, as the installer's
|
||||
// root step does, with machine authority faked and the account looked up as
|
||||
// an invented one.
|
||||
func laneOwner(t *testing.T, uid uint32) {
|
||||
t.Helper()
|
||||
prevAuth, prevLookup, prevState := machineAuthorityHeld, lookupOwnerAccount, ownerServiceState
|
||||
t.Cleanup(func() { machineAuthorityHeld, lookupOwnerAccount, ownerServiceState = prevAuth, prevLookup, prevState })
|
||||
machineAuthorityHeld = func() bool { return true }
|
||||
ownerServiceState = func() string { return "" }
|
||||
lookupOwnerAccount = func(who string) (*user.User, error) {
|
||||
return &user.User{Uid: strconv.FormatUint(uint64(uid), 10), Username: "sam"}, nil
|
||||
}
|
||||
if err := cmdOwnerAccount("sam", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// laneSignedIn redeems a ticket minted for the owner's lane, the way the
|
||||
// browser does, and returns the session cookie it sets.
|
||||
func laneSignedIn(t *testing.T, u *uiServer, uid uint32) string {
|
||||
t.Helper()
|
||||
u.addr = "127.0.0.1:" + uiTestPort
|
||||
prev := consoleLaneServer.Load()
|
||||
consoleLaneServer.Store(u)
|
||||
t.Cleanup(func() { consoleLaneServer.Store(prev) })
|
||||
link, err := consoleLaneEntry(uid, "sam")
|
||||
if err != nil {
|
||||
t.Fatalf("the owner's lane refused its owner: %v", err)
|
||||
}
|
||||
p, _ := url.Parse(link)
|
||||
w := uiGet(t, u.handler(), "/?"+p.RawQuery, "")
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("redeeming the lane's ticket = %d: %s", w.Code, firstLines(w.Body.String()))
|
||||
}
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == uiCookieName {
|
||||
return uiCookieName + "=" + c.Value
|
||||
}
|
||||
}
|
||||
t.Fatal("no session cookie")
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestTheLaneOpensOnlyForTheOwnersAccount(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
if why := consoleLaneRefusal(1000, "sam"); !strings.Contains(why, "approvals-owner --account sam") {
|
||||
t.Errorf("with no owner recorded the refusal does not say how to record one: %q", why)
|
||||
}
|
||||
laneOwner(t, 1000)
|
||||
if why := consoleLaneRefusal(1000, "sam"); why != "" {
|
||||
t.Errorf("the owner was refused: %q", why)
|
||||
}
|
||||
if why := consoleLaneRefusal(1001, "lee"); !strings.Contains(why, "only for its owner's account (sam)") {
|
||||
t.Errorf("another account was let in, or told the wrong thing: %q", why)
|
||||
}
|
||||
if _, err := consoleLaneEntry(1001, "lee"); err == nil {
|
||||
t.Error("a ticket was minted for an account that is not the owner's")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheOwnerAccountIsNotQuietlyHandedToAnother(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
laneOwner(t, 1000)
|
||||
if err := cmdOwnerAccount("sam", false); err != nil {
|
||||
t.Errorf("the same account again: %v", err)
|
||||
}
|
||||
lookupOwnerAccount = func(string) (*user.User, error) { return &user.User{Uid: "1001", Username: "lee"}, nil }
|
||||
if err := cmdOwnerAccount("lee", false); err == nil || !strings.Contains(err.Error(), "stays so") {
|
||||
t.Errorf("another account took the console: %v", err)
|
||||
}
|
||||
prev := ownerConsole
|
||||
t.Cleanup(func() { ownerConsole = prev })
|
||||
ownerConsole = func() bool { return false }
|
||||
if err := cmdOwnerAccount("lee", true); err == nil || !strings.Contains(err.Error(), "only at a terminal") {
|
||||
t.Errorf("--replace was taken from a pipe: %v", err)
|
||||
}
|
||||
if uid, _, _, _ := ownerAccountLoad(); uid != 1000 {
|
||||
t.Fatalf("the owner changed to uid %d", uid)
|
||||
}
|
||||
lookupOwnerAccount = func(string) (*user.User, error) { return &user.User{Uid: "0", Username: "root"}, nil }
|
||||
ownerConsole = func() bool { return true }
|
||||
if err := cmdOwnerAccount("root", true); err == nil || !strings.Contains(err.Error(), "root administers") {
|
||||
t.Errorf("root was recorded as the owner's account: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A limited session sees the console and does what takes the code or narrows
|
||||
// reach. What widens reach with no code is the administrator's, and the page
|
||||
// says so instead of offering it.
|
||||
func TestALaneSessionIsRefusedWhatWidensReachWithNoCode(t *testing.T) {
|
||||
u, admin := settingsWorld(t, true)
|
||||
laneOwner(t, 1000)
|
||||
lane := laneSignedIn(t, u, 1000)
|
||||
h := u.handler()
|
||||
|
||||
for _, p := range []string{"/", "/setup", "/approvals", "/activity", "/machine"} {
|
||||
if w := uiGet(t, h, p, lane); w.Code != http.StatusOK {
|
||||
t.Errorf("GET %s from the lane = %d", p, w.Code)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"/setup/pair", "/setup/stepup/start", "/setup/stepup/confirm", "/setup/stepup/key",
|
||||
"/setup/folder/propose", "/setup/anchor/choose", "/device/approve", "/device/approve-shown"} {
|
||||
w := uiPost(t, h, p, url.Values{"target": {"machine"}, "token": {"skb_x"}}, lane)
|
||||
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "sudo -H privatae-bastion ui") {
|
||||
t.Errorf("POST %s from the lane = %d %s", p, w.Code, firstLines(w.Body.String()))
|
||||
}
|
||||
}
|
||||
if w := uiGet(t, h, "/setup/stepup/qr?e=x", lane); w.Code != http.StatusForbidden {
|
||||
t.Errorf("the enrolment QR was served to the lane: %d", w.Code)
|
||||
}
|
||||
// The disk map, run as root, names files in every account's home. Its
|
||||
// stand-in is installed, so a POST that got through would walk nothing:
|
||||
// it blocks until the test ends.
|
||||
dm := dmTestInstall(t, nil)
|
||||
release := make(chan struct{})
|
||||
t.Cleanup(func() { close(release) })
|
||||
dm.scan = func(ctx context.Context, _ []string, _ int, _ func(dmProgress)) *dmResult {
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
for _, p := range []string{"/diskmap", "/fragments/diskmap"} {
|
||||
if w := uiGet(t, h, p, lane); w.Code != http.StatusForbidden {
|
||||
t.Errorf("GET %s from the lane = %d", p, w.Code)
|
||||
}
|
||||
}
|
||||
if w := uiPost(t, h, "/diskmap/scan", url.Values{}, lane); w.Code != http.StatusForbidden {
|
||||
t.Errorf("POST /diskmap/scan from the lane = %d", w.Code)
|
||||
}
|
||||
// Narrowing reach needs no code, from the lane too.
|
||||
uiPost(t, h, "/setup/host/disconnect", url.Values{"host": {"member.host.privatae.ai"}, "confirm": {"1"}}, lane)
|
||||
if cfgs, _ := loadPairings(); len(cfgs) != 0 {
|
||||
t.Error("the lane could not disconnect a Host")
|
||||
}
|
||||
// The same routes still work for the administrator's own session.
|
||||
if w := uiPost(t, h, "/setup/stepup/start", url.Values{"target": {"machine"}, "replace": {"1"}}, admin); w.Code != http.StatusOK {
|
||||
t.Errorf("the administrator was refused: %d %s", w.Code, firstLines(w.Body.String()))
|
||||
}
|
||||
body := uiGet(t, h, "/setup", lane).Body.String()
|
||||
if strings.Contains(body, `hx-post="/setup/pair"`) || strings.Contains(body, `hx-post="/setup/stepup/start"`) {
|
||||
t.Error("Settings offers the lane a form it would refuse")
|
||||
}
|
||||
if !strings.Contains(body, "sudo -H privatae-bastion stepup-enroll --machine --rotate") {
|
||||
t.Error("Settings does not tell the lane how the code is replaced")
|
||||
}
|
||||
if body := uiGet(t, h, "/setup", admin).Body.String(); !strings.Contains(body, `hx-post="/setup/pair"`) {
|
||||
t.Error("Settings lost the pairing form for the administrator")
|
||||
}
|
||||
}
|
||||
|
||||
// The folder check stats whatever path is posted, as the daemon, which is root
|
||||
// on a whole-machine install. The lane draws no folder form, so from the lane
|
||||
// it could only be used to learn what exists where the person's account cannot
|
||||
// look; it is the administrator's, like the proposal it serves.
|
||||
func TestALaneSessionCannotProbePathsThroughTheFolderCheck(t *testing.T) {
|
||||
u, admin := settingsWorld(t, true)
|
||||
laneOwner(t, 1000)
|
||||
lane := laneSignedIn(t, u, 1000)
|
||||
h := u.handler()
|
||||
dir := t.TempDir()
|
||||
w := uiPost(t, h, "/setup/folder/check", url.Values{"path": {dir}}, lane)
|
||||
if w.Code != http.StatusForbidden || strings.Contains(w.Body.String(), "A folder on this machine") {
|
||||
t.Errorf("the lane's folder check = %d %s", w.Code, firstLines(w.Body.String()))
|
||||
}
|
||||
if w := uiPost(t, h, "/setup/folder/check", url.Values{"path": {dir}}, admin); w.Code != http.StatusOK ||
|
||||
!strings.Contains(w.Body.String(), "A folder on this machine") {
|
||||
t.Errorf("the administrator's folder check = %d %s", w.Code, firstLines(w.Body.String()))
|
||||
}
|
||||
}
|
||||
|
||||
// Through the lane the Host factor's seed is out of reach, so wrong codes
|
||||
// typed for a Host's request are throttled there, and only there.
|
||||
func TestALaneSessionsWrongHostCodesAreThrottled(t *testing.T) {
|
||||
const host = "member.host.privatae.ai"
|
||||
_, id := uiTestApprovalBox(t, host)
|
||||
u := uiTestServer(t)
|
||||
laneOwner(t, 1000)
|
||||
lane := laneSignedIn(t, u, 1000)
|
||||
h := u.handler()
|
||||
var last string
|
||||
for i := 0; i < pipeFreeFailures+1; i++ {
|
||||
last = uiPost(t, h, "/approve", url.Values{"host": {host}, "id": {id}, "code": {"000000"}}, lane).Body.String()
|
||||
}
|
||||
if !strings.Contains(last, "wait") && !strings.Contains(last, "again in") {
|
||||
t.Errorf("the lane typed %d wrong codes with no wait: %s", pipeFreeFailures+1, firstLines(last))
|
||||
}
|
||||
admin := uiSignedIn(t, u)
|
||||
body := uiPost(t, h, "/approve", url.Values{"host": {host}, "id": {id}, "code": {"000000"}}, admin).Body.String()
|
||||
if strings.Contains(body, "again in") {
|
||||
t.Errorf("the administrator's page is throttled too: %s", firstLines(body))
|
||||
}
|
||||
}
|
||||
|
||||
// Links minted for the lane are capped, so an account asking in a loop cannot
|
||||
// grow the root daemon's memory; one opened frees its place.
|
||||
func TestTheLaneCapsTheLinksWaitingToBeOpened(t *testing.T) {
|
||||
u := uiTestServer(t)
|
||||
uiTestConfigDir(t)
|
||||
laneOwner(t, 1000)
|
||||
laneSignedIn(t, u, 1000) // sets the server for the lane; its link is used
|
||||
for i := 0; i < consoleLaneTicketsMax; i++ {
|
||||
if _, err := consoleLaneEntry(1000, "sam"); err != nil {
|
||||
t.Fatalf("link %d refused: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
if _, err := consoleLaneEntry(1000, "sam"); err == nil || !strings.Contains(err.Error(), "too many") {
|
||||
t.Fatalf("link %d was minted: %v", consoleLaneTicketsMax+1, err)
|
||||
}
|
||||
}
|
||||
+24
-8
@@ -136,13 +136,7 @@ func maybeContain(pairing, dcnDir string, heartbeatResponse []byte) {
|
||||
// fingerprint is in the accounting (one running as a service, say)
|
||||
// would otherwise be spared in silence — killTree returns 0 for a
|
||||
// spared root and a zero kill is not recorded.
|
||||
root := pid
|
||||
killed := killTree(pid, func(p int) bool {
|
||||
if p == root {
|
||||
return spareFromContain(p)
|
||||
}
|
||||
return guard(p)
|
||||
})
|
||||
killed := stopAgentTree(pid, guard)
|
||||
if killed > 0 {
|
||||
fmt.Printf("contain: terminated agent pid %d and %d child process(es) (host-directed)\n", pid, killed-1)
|
||||
// The Printf stays — under systemd it is journald, which is a real
|
||||
@@ -175,7 +169,13 @@ func hostPhrase(pairing string) string {
|
||||
// here rather than in events.go because this is the only place that still holds
|
||||
// the host, the signature and the count together.
|
||||
func containSentence(pairing, signature string, pid, children int) string {
|
||||
s := hostPhrase(pairing) + " stopped " + signature + " (pid " + strconv.Itoa(pid) +
|
||||
return stopSentence(hostPhrase(pairing), signature, pid, children)
|
||||
}
|
||||
|
||||
// stopSentence is containSentence with the one who stopped it named: a Host,
|
||||
// or "You" when the machine's owner did (stop_local.go).
|
||||
func stopSentence(subject, signature string, pid, children int) string {
|
||||
s := subject + " stopped " + signature + " (pid " + strconv.Itoa(pid) +
|
||||
") running on this machine"
|
||||
switch {
|
||||
case children == 0:
|
||||
@@ -208,3 +208,19 @@ func observedAgentMap() map[int]agentObs {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stopAgentTree stops one observed agent and everything beneath it, sparing
|
||||
// init, this bastion and its own local agent at the root, and the protected
|
||||
// set (containGuard) below it. It is the ONE stop path: a Host's order
|
||||
// (maybeContain) and the machine owner's own (stop_local.go) both come
|
||||
// through here, so what each may not touch cannot drift apart. It returns how
|
||||
// many processes the kill reached, the agent included.
|
||||
func stopAgentTree(pid int, guard func(int) bool) int {
|
||||
root := pid
|
||||
return killTree(pid, func(p int) bool {
|
||||
if p == root {
|
||||
return spareFromContain(p)
|
||||
}
|
||||
return guard(p)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -62,13 +62,20 @@ func contextSockListen(path string) (net.Listener, error) {
|
||||
|
||||
// contextPeerUser reads the connecting process's user from the kernel.
|
||||
func contextPeerUser(c net.Conn) (string, error) {
|
||||
_, who, err := contextPeerIdentity(c)
|
||||
return who, err
|
||||
}
|
||||
|
||||
// contextPeerIdentity is the connecting process's uid from the kernel, and
|
||||
// the account's name for messages (or "uid N" when it has none).
|
||||
func contextPeerIdentity(c net.Conn) (uint32, string, error) {
|
||||
uc, ok := c.(*net.UnixConn)
|
||||
if !ok {
|
||||
return "", errors.New("not a unix socket")
|
||||
return 0, "", errors.New("not a unix socket")
|
||||
}
|
||||
raw, err := uc.SyscallConn()
|
||||
if err != nil {
|
||||
return "", err
|
||||
return 0, "", err
|
||||
}
|
||||
var uid uint32
|
||||
var serr error
|
||||
@@ -76,16 +83,16 @@ func contextPeerUser(c net.Conn) (string, error) {
|
||||
uid, serr = contextPeerUID(fd)
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
return 0, "", err
|
||||
}
|
||||
if serr != nil {
|
||||
return "", serr
|
||||
return 0, "", serr
|
||||
}
|
||||
name := ""
|
||||
if u, err := user.LookupId(strconv.FormatUint(uint64(uid), 10)); err == nil {
|
||||
name = u.Username
|
||||
}
|
||||
return contextSockFmtUser(uid, name), nil
|
||||
return uid, contextSockFmtUser(uid, name), nil
|
||||
}
|
||||
|
||||
// contextSockDir is machine-wide and root-owned. See contextSockPath.
|
||||
|
||||
@@ -40,4 +40,8 @@ func contextPeerUser(c net.Conn) (string, error) {
|
||||
return "", errContextSockUnsupported
|
||||
}
|
||||
|
||||
func contextPeerIdentity(c net.Conn) (uint32, string, error) {
|
||||
return 0, "", errContextSockUnsupported
|
||||
}
|
||||
|
||||
var _ = os.Remove
|
||||
|
||||
+13
-4
@@ -190,10 +190,19 @@ func dcnSaveFile(dir string, f *dcnGrantFile) error {
|
||||
// accounting ("every read is logged for you"). Best-effort: auditing never blocks
|
||||
// a result, but a refusal is still a refusal whether or not the line lands.
|
||||
func dcnAudit(dir, op string, storeID int64, path, decision string) {
|
||||
rec, _ := json.Marshal(map[string]any{
|
||||
"ts": time.Now().UTC().Format(time.RFC3339), "op": op,
|
||||
"store_id": storeID, "path": path, "decision": decision,
|
||||
})
|
||||
dcnAuditWith(dir, op, storeID, path, decision, nil)
|
||||
}
|
||||
|
||||
// dcnAuditWith is dcnAudit with more fields on the row (an ask's asker and
|
||||
// target, askparties.go). They never replace the five every reader expects.
|
||||
func dcnAuditWith(dir, op string, storeID int64, path, decision string, extra map[string]any) {
|
||||
row := map[string]any{}
|
||||
for k, v := range extra {
|
||||
row[k] = v
|
||||
}
|
||||
row["ts"], row["op"] = time.Now().UTC().Format(time.RFC3339), op
|
||||
row["store_id"], row["path"], row["decision"] = storeID, path, decision
|
||||
rec, _ := json.Marshal(row)
|
||||
auditToSystemLog(auditFilePairingD, auditPairingOfDir(dir), rec)
|
||||
fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"),
|
||||
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
|
||||
+17
-20
@@ -20,9 +20,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -675,53 +675,50 @@ func runDeltaRefresh(ctx context.Context) {
|
||||
// ── the operator's own eyes ─────────────────────────────────────────────────
|
||||
|
||||
func cmdObserveDelta(asJSON bool) error {
|
||||
if err := requireMachineAuthority("observe:delta"); err != nil {
|
||||
return err
|
||||
}
|
||||
r := deltaPass(&digestCache{}, time.Now(), deltaRoleOperator)
|
||||
args := []string{"diff"}
|
||||
if asJSON {
|
||||
b, err := json.MarshalIndent(r, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(b))
|
||||
return nil
|
||||
args = append(args, "--json")
|
||||
}
|
||||
return cmdBaseline(args)
|
||||
}
|
||||
|
||||
// printDelta is `baseline diff` in words.
|
||||
func printDelta(w io.Writer, r deltaReport) error {
|
||||
switch r.Baseline {
|
||||
case deltaBaselineNone:
|
||||
return errors.New("this machine has no accounting to compare against — " +
|
||||
"`privatae-bastion observe:baseline` takes one (as an administrator)")
|
||||
case deltaBaselineEdit:
|
||||
fmt.Printf("WARNING: the accounting has been changed since it was taken — " +
|
||||
fmt.Fprintf(w, "WARNING: the accounting has been changed since it was taken — "+
|
||||
"what follows is measured against an edited file\n")
|
||||
}
|
||||
fmt.Printf("%d findings against the accounting taken %s (%s), from %d processes in %s\n",
|
||||
fmt.Fprintf(w, "%d findings against the accounting taken %s (%s), from %d processes in %s\n",
|
||||
len(r.Findings), r.BaselineTakenAt, r.Coverage, r.Scanned, r.Elapsed)
|
||||
switch {
|
||||
case r.WalkFailed != "" && len(r.Findings) > 0:
|
||||
// The binary rung read the disk; everything else did not happen. Said
|
||||
// so that the finding below is not discounted by "nothing was compared".
|
||||
fmt.Printf("the process walk failed this pass (%s): only the bastion-binary check ran — "+
|
||||
fmt.Fprintf(w, "the process walk failed this pass (%s): only the bastion-binary check ran — "+
|
||||
"the finding(s) below are real; nothing else was compared\n", r.WalkFailed)
|
||||
case r.WalkFailed != "":
|
||||
// The one sentence a failed walk must never print is the all-clear.
|
||||
fmt.Printf("NOTHING WAS COMPARED: the process walk failed this pass (%s) — "+
|
||||
fmt.Fprintf(w, "NOTHING WAS COMPARED: the process walk failed this pass (%s) — "+
|
||||
"the zero above means nothing looked, not that nothing differs\n", r.WalkFailed)
|
||||
case len(r.Findings) == 0:
|
||||
fmt.Printf("nothing on this machine is outside what was accounted for\n")
|
||||
fmt.Fprintf(w, "nothing on this machine is outside what was accounted for\n")
|
||||
}
|
||||
for _, ev := range r.Findings {
|
||||
fmt.Printf(" %-8s %-20s %s\n", ev.Level, ev.Kind, deltaOneLine(ev))
|
||||
fmt.Fprintf(w, " %-8s %-20s %s\n", ev.Level, ev.Kind, deltaOneLine(ev))
|
||||
for _, e := range ev.Evidence {
|
||||
fmt.Printf(" %-12s %s\n", e.Tier, e.Signal+": "+e.Value)
|
||||
fmt.Fprintf(w, " %-12s %s\n", e.Tier, e.Signal+": "+e.Value)
|
||||
}
|
||||
}
|
||||
if r.ShortLived > 0 || len(r.Filtered) > 0 {
|
||||
fmt.Printf("%d filtered as ordinary (%d came and went before they were old enough to report; %d since the accounting)\n",
|
||||
fmt.Fprintf(w, "%d filtered as ordinary (%d came and went before they were old enough to report; %d since the accounting)\n",
|
||||
len(r.Filtered), r.ShortLived, r.ShortLivedTotal)
|
||||
}
|
||||
for _, n := range r.Notes {
|
||||
fmt.Printf(" note: %s\n", n)
|
||||
fmt.Fprintf(w, " note: %s\n", n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ func statusOutput(t *testing.T) string {
|
||||
}
|
||||
stdout := os.Stdout
|
||||
os.Stdout = w
|
||||
serr := cmdStatus()
|
||||
serr := cmdStatus(nil)
|
||||
os.Stdout = stdout
|
||||
_ = w.Close()
|
||||
out, _ := io.ReadAll(r)
|
||||
|
||||
@@ -1083,3 +1083,36 @@ func TestTheExtractApprovalBindsStableFields(t *testing.T) {
|
||||
t.Fatal("a re-minted ticket must not change the purpose")
|
||||
}
|
||||
}
|
||||
|
||||
// The Host's door itself drops the fields only this machine writes: a
|
||||
// stepup_request that names a local account as its asker is filed without
|
||||
// it, so nothing downstream (the audit, a deny's strike) ever sees it.
|
||||
func TestAHostsDoorDropsTheLocalAskerFields(t *testing.T) {
|
||||
setupBox(t)
|
||||
ctx, dcn := t.TempDir(), t.TempDir()
|
||||
enrollFactor(t, dcn)
|
||||
hn, _ := os.Hostname()
|
||||
details := map[string]any{"kind": "mgmt_op", "op": putOp, "server": hn, "args": []any{putJob("").Args[0]},
|
||||
"asked_by_account": "u", "asker": map[string]any{"account": "u"}}
|
||||
purpose, err := purposeFromDetails(details)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(details)
|
||||
out, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "stepup_request",
|
||||
Args: []string{purpose, base64.StdEncoding.EncodeToString(raw)}}, ctx, dcn)
|
||||
if code != 0 {
|
||||
t.Fatalf("stepup_request: %d %s %s", code, out, errOut)
|
||||
}
|
||||
var reply struct{ Request string }
|
||||
_ = json.Unmarshal([]byte(out), &reply)
|
||||
r, ok := requestsLoad(dcn)[reply.Request]
|
||||
if !ok {
|
||||
t.Fatalf("no request filed: %s", out)
|
||||
}
|
||||
for _, k := range []string{"asked_by_account", "asker"} {
|
||||
if _, ok := r.Details[k]; ok {
|
||||
t.Fatalf("a Host's request kept %q: %v", k, r.Details)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,18 +19,24 @@ func TestInstallPs1PairShape(t *testing.T) {
|
||||
}
|
||||
s := string(raw)
|
||||
for _, want := range []string{
|
||||
"param([switch]$Networked, [string]$Pair)",
|
||||
"param([switch]$Networked, [switch]$Shared, [switch]$Personal, [switch]$JustMe, [string]$Pair,",
|
||||
"$Pair | & $exe pair -",
|
||||
"elseif ($rc -eq 3)",
|
||||
"Start-ScheduledTask -TaskName 'PrivataeBastion'",
|
||||
"Restart-Service -Name 'PrivataeBastion'",
|
||||
`Write-Host "Open a NEW terminal, then: privatae-bastion pair <token>"`,
|
||||
// The whole machine by default, through one UAC prompt; the code
|
||||
// reaches the elevated step in a file, and the owner by SID.
|
||||
"Start-Process -FilePath 'powershell.exe' -Verb RunAs",
|
||||
"[IO.File]::WriteAllText($pf, $Pair)",
|
||||
"& $exe approvals-owner --sid $owner",
|
||||
"if (-not $elevatedDone) {",
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("install.ps1 lacks %q", want)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"pair $Pair", "pair -- $Pair", "-ArgumentList $Pair", "Write-Host $Pair", "$Pair\"", "'$Pair'"} {
|
||||
for _, bad := range []string{"pair $Pair", "pair -- $Pair", "-ArgumentList $Pair", "Write-Host $Pair", "$Pair\"", "'$Pair'",
|
||||
"-Pair '$Pair'", "-Pair $Pair"} {
|
||||
if strings.Contains(s, bad) {
|
||||
t.Errorf("install.ps1 must never put the code in an argument or the output: %q", bad)
|
||||
}
|
||||
@@ -54,3 +60,17 @@ func TestInstallPs1PairBlockRunsUnderPwsh(t *testing.T) {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// The elevation block, run by pwsh with Windows' prompt stood in
|
||||
// (testdata/install_ps1_elevate.ps1). Skipped where pwsh is not installed.
|
||||
func TestInstallPs1ElevatesForTheWholeMachineUnderPwsh(t *testing.T) {
|
||||
pwsh, err := exec.LookPath("pwsh")
|
||||
if err != nil {
|
||||
t.Skip("pwsh is not installed: install.ps1's elevation is checked only by TestInstallPs1PairShape here")
|
||||
}
|
||||
out, err := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File",
|
||||
filepath.Join("testdata", "install_ps1_elevate.ps1"), filepath.Join("..", "deploy", "install.ps1")).CombinedOutput()
|
||||
if err != nil || !strings.HasPrefix(strings.TrimSpace(string(out)), "ok:") {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,31 @@ func installShPrivatePIDs(t *testing.T) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// installShHere is a copy of deploy/install.sh whose machine_install_present
|
||||
// looks in dir instead of the machine running the test, which may well have
|
||||
// the bastion installed for the whole machine. machine says whether the copy
|
||||
// finds one there.
|
||||
func installShHere(t *testing.T, dir string, machine bool) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fn := installShFunc(t, "machine_install_present")
|
||||
unit := filepath.Join(dir, "machine-unit")
|
||||
if machine {
|
||||
if err := os.WriteFile(unit, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
here := "machine_install_present() {\n [ -f '" + unit + "' ]\n}\n"
|
||||
out := filepath.Join(dir, "install.sh")
|
||||
if err := os.WriteFile(out, []byte(strings.Replace(string(raw), fn, here, 1)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pairShSetup is a stand-in machine for install.sh: a log directory, a
|
||||
// home, and a PATH directory of stand-ins for systemctl, loginctl and sleep,
|
||||
// each logging what it was given. unit is how the stand-in systemd reports
|
||||
@@ -138,9 +163,18 @@ exit 0
|
||||
`)
|
||||
s.write(t, filepath.Join(s.path, "loginctl"), "#!/bin/sh\necho \"$*\" >> \"$LOGS/loginctl\"\nexit \"$LINGER_RC\"\n")
|
||||
s.write(t, filepath.Join(s.path, "sleep"), "#!/bin/sh\nexit 0\n")
|
||||
// sudo is ALWAYS a stand-in. The suite runs as root, and the installer run
|
||||
// as a pretend ordinary account installs for the whole machine through
|
||||
// sudo: the real one would hand the published installer to root on the
|
||||
// machine running the test. This one refuses, as sudo does for an account
|
||||
// that may not use it, unless a test says otherwise (sudoStandIn).
|
||||
s.write(t, filepath.Join(s.path, "sudo"), sudoRefuses)
|
||||
return s
|
||||
}
|
||||
|
||||
// sudoRefuses is sudo for an account that may not use it.
|
||||
const sudoRefuses = "#!/bin/sh\necho \"sudo $*\" >> \"${LOGS:-/dev/null}\"/sudo 2>/dev/null\necho \"Sorry, user tester may not run sudo.\" >&2\nexit 1\n"
|
||||
|
||||
func (s *pairShSetup) write(t *testing.T, p, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(p, []byte(body), 0o700); err != nil {
|
||||
@@ -352,10 +386,7 @@ exit 0
|
||||
b, _ := os.ReadFile(p)
|
||||
s.write(t, p, strings.Replace(string(b), "#!/bin/sh\n", "#!/bin/sh\n"+record, 1))
|
||||
}
|
||||
sh, err := filepath.Abs(filepath.Join("..", "deploy", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sh := installShHere(t, s.dir, false)
|
||||
args := append(append([]string{}, wrap...), "sh", sh)
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Env = s.env("PRIVATAE_PAIR_CODE="+code, "STAND="+s.dir, "LINGER_RC=0", "UNIT=steady")
|
||||
@@ -384,6 +415,12 @@ exit 0
|
||||
if !strings.HasSuffix(strings.TrimSpace(string(out)), "https://privatae.ai/bastion/LICENSE.txt") || !strings.Contains(string(out), "This machine is paired.") {
|
||||
t.Fatalf("the ending: %s", out)
|
||||
}
|
||||
// sudo refused (the stand-in), so it is installed for this account, and
|
||||
// the installer says what that means.
|
||||
if !strings.Contains(readPairShLog(t, s.logs, "sudo"), " true") || !strings.Contains(string(out), "installed for your account only") ||
|
||||
!strings.Contains(string(out), "in a terminal, from an account that may use sudo") {
|
||||
t.Fatalf("the fall back to this account is not said:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A PRIVATAE_PAIR_CODE that is empty or not a whole pairing code is refused
|
||||
@@ -400,6 +437,7 @@ func TestInstallShRefusesABadPairCodeBeforeDownloading(t *testing.T) {
|
||||
for name, body := range map[string]string{
|
||||
"id": `case "$1" in -u) echo 1000 ;; -un) echo tester ;; *) echo "uid=1000(tester)" ;; esac`,
|
||||
"curl": `echo "REACHED-THE-DOWNLOAD" >&2; exit 1`,
|
||||
"sudo": `echo "REACHED-SUDO" >&2; exit 1`,
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(stand, name), []byte("#!/bin/sh\n"+body+"\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -424,7 +462,8 @@ func TestInstallShRefusesABadPairCodeBeforeDownloading(t *testing.T) {
|
||||
cmd.Env = append([]string{"PATH=" + stand + ":/usr/bin:/bin", "HOME=" + home, "TMPDIR=" + home}, c.env...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
ee, ok := err.(*exec.ExitError)
|
||||
if !ok || ee.ExitCode() != 2 || strings.Contains(string(out), "Fetching manifest") || strings.Contains(string(out), "REACHED-THE-DOWNLOAD") {
|
||||
if !ok || ee.ExitCode() != 2 || strings.Contains(string(out), "Fetching manifest") || strings.Contains(string(out), "REACHED-THE-DOWNLOAD") ||
|
||||
strings.Contains(string(out), "REACHED-SUDO") {
|
||||
t.Fatalf("%v %v: want exit 2 before the download, got %v\n%s", c.env, c.args, err, out)
|
||||
}
|
||||
if strings.Contains(string(out), code) || strings.Contains(string(out), "skb_") || strings.Contains(string(out), "networkd") {
|
||||
@@ -432,3 +471,223 @@ func TestInstallShRefusesABadPairCodeBeforeDownloading(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On a person's own account the installer installs for the whole machine
|
||||
// through sudo: sudo true first, then root (sudo -H) fetches the installer and
|
||||
// runs it with --personal, the pairing code on its standard input. sudo's
|
||||
// arguments never hold the code. What an earlier install for this account
|
||||
// alone left (never paired) is removed, so it does not run a second bastion.
|
||||
//
|
||||
// Nothing here runs as root on the machine running the test: the installer
|
||||
// root fetches is a stand-in that records what it was given, and sudo runs
|
||||
// it as the test's own account, in a PID namespace of its own.
|
||||
func TestInstallShInstallsForTheWholeMachineThroughSudo(t *testing.T) {
|
||||
wrap := installShPrivatePIDs(t)
|
||||
s := newPairShSetup(t)
|
||||
const code = "skp_eyJ2IjoxLCJ0IjoiSU5WRU5URUQtV0hPTEUifQ"
|
||||
s.write(t, filepath.Join(s.dir, "root-step.sh"), "echo \"$*\" > \"$LOGS/root-args\"\ncat > \"$LOGS/root-stdin\"\nexit 0\n")
|
||||
s.write(t, filepath.Join(s.path, "sudo"), "#!/bin/sh\necho \"$*\" >> \"$LOGS/sudo\"\ncase \"$1\" in -p) exit 0 ;; -H) shift ;; esac\nexec \"$@\"\n")
|
||||
for name, body := range map[string]string{
|
||||
"uname": `case "$1" in -s) echo Linux ;; -m) echo x86_64 ;; *) echo Linux ;; esac`,
|
||||
"id": `case "$1" in -u) echo 1000 ;; -un) echo tester ;; *) echo "uid=1000(tester)" ;; esac`,
|
||||
"curl": `echo "$*" >> "$LOGS/curl"; for a; do o=$a; done; u=""; for a; do case "$a" in https://*) u=$a ;; esac; done
|
||||
case "$u" in https://privatae.ai/bastion/install.sh) cp "$STAND/root-step.sh" "$o" ;; *) echo "REACHED-THE-DOWNLOAD" >&2; exit 1 ;; esac`,
|
||||
} {
|
||||
s.write(t, filepath.Join(s.path, name), "#!/bin/sh\n"+body+"\n")
|
||||
}
|
||||
unit := filepath.Join(s.home, ".config", "systemd", "user", "privatae-bastion.service")
|
||||
own := filepath.Join(s.home, ".local", "bin", "privatae-bastion")
|
||||
for _, p := range []string{unit, own} {
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.write(t, p, "an earlier install for this account\n")
|
||||
}
|
||||
sh := installShHere(t, s.dir, false)
|
||||
args := append(append([]string{}, wrap...), "sh", sh)
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Env = s.env("PRIVATAE_PAIR_CODE="+code, "STAND="+s.dir)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if got := readPairShLog(t, s.logs, "root-args"); strings.TrimSpace(got) != "--personal --pair-code-on-stdin" {
|
||||
t.Fatalf("the root step was given %q\n%s", got, out)
|
||||
}
|
||||
if got := readPairShLog(t, s.logs, "root-stdin"); got != code+"\n" {
|
||||
t.Fatalf("the root step's standard input: %q", got)
|
||||
}
|
||||
sudo := readPairShLog(t, s.logs, "sudo")
|
||||
if !strings.HasPrefix(sudo, "-p [bastion] sudo password for %u: true\n") || !strings.Contains(sudo, "/bin/sh -c ") {
|
||||
t.Fatalf("sudo was asked: %s\n%s", sudo, out)
|
||||
}
|
||||
if strings.Contains(sudo, code) || strings.Contains(string(out), code) {
|
||||
t.Fatalf("the code is in sudo's arguments or the output:\n%s\n%s", sudo, out)
|
||||
}
|
||||
// Root's step reads nothing from this account's home: sudo -H, so HOME is
|
||||
// root's, and curl -q, so no ~/.curlrc can point root's fetch elsewhere.
|
||||
if !strings.Contains(sudo, "\n-H /bin/sh -c ") {
|
||||
t.Errorf("the root step runs with this account's HOME (no sudo -H): %s", sudo)
|
||||
}
|
||||
if got := readPairShLog(t, s.logs, "curl"); !strings.HasPrefix(got, "-q ") {
|
||||
t.Errorf("root's fetch of the installer reads a curl config (no -q first): %q", got)
|
||||
}
|
||||
if strings.Contains(string(out), "Fetching manifest") || strings.Contains(string(out), "REACHED-THE-DOWNLOAD") {
|
||||
t.Fatalf("the installer also installed for this account:\n%s", out)
|
||||
}
|
||||
for _, p := range []string{unit, own} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
t.Errorf("%s is still there", p)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(readPairShLog(t, s.logs, "systemctl"), "--user disable --now privatae-bastion") {
|
||||
t.Error("this account's own unit was not stopped")
|
||||
}
|
||||
}
|
||||
|
||||
// An account whose own bastion is already paired keeps it on an upgrade:
|
||||
// sudo is never asked, and the installer goes on for this account.
|
||||
func TestInstallShKeepsAPairedAccountInstallWhereItIs(t *testing.T) {
|
||||
s := newPairShSetup(t)
|
||||
for name, body := range map[string]string{
|
||||
"uname": `case "$1" in -s) echo Linux ;; -m) echo x86_64 ;; *) echo Linux ;; esac`,
|
||||
"id": `case "$1" in -u) echo 1000 ;; -un) echo tester ;; *) echo "uid=1000(tester)" ;; esac`,
|
||||
"curl": `echo "REACHED-THE-DOWNLOAD" >&2; exit 1`,
|
||||
} {
|
||||
s.write(t, filepath.Join(s.path, name), "#!/bin/sh\n"+body+"\n")
|
||||
}
|
||||
pd := filepath.Join(s.home, ".config", "privatae-bastion", "pairings")
|
||||
if err := os.MkdirAll(pd, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.write(t, filepath.Join(pd, "member.host.privatae.ai.json"), "{}")
|
||||
cmd := exec.Command("sh", installShHere(t, s.dir, false))
|
||||
cmd.Env = s.env()
|
||||
out, _ := cmd.CombinedOutput()
|
||||
if got := readPairShLog(t, s.logs, "sudo"); got != "" {
|
||||
t.Fatalf("sudo was asked for a paired account install: %s", got)
|
||||
}
|
||||
if !strings.Contains(string(out), "Fetching manifest") {
|
||||
t.Fatalf("the installer did not go on for this account:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Beside a bastion installed for the whole machine, an account that cannot
|
||||
// use sudo gets no second bastion of its own (the machine's would refuse it),
|
||||
// asked for with --just-me or fallen back to.
|
||||
func TestInstallShInstallsNoSecondBastionBesideTheMachines(t *testing.T) {
|
||||
for _, args := range [][]string{nil, {"--just-me"}} {
|
||||
s := newPairShSetup(t)
|
||||
for name, body := range map[string]string{
|
||||
"uname": `case "$1" in -s) echo Linux ;; -m) echo x86_64 ;; *) echo Linux ;; esac`,
|
||||
"id": `case "$1" in -u) echo 1000 ;; -un) echo tester ;; *) echo "uid=1000(tester)" ;; esac`,
|
||||
"curl": `echo "REACHED-THE-DOWNLOAD" >&2; exit 1`,
|
||||
} {
|
||||
s.write(t, filepath.Join(s.path, name), "#!/bin/sh\n"+body+"\n")
|
||||
}
|
||||
cmd := exec.Command("sh", append([]string{installShHere(t, s.dir, true)}, args...)...)
|
||||
cmd.Env = s.env()
|
||||
out, err := cmd.CombinedOutput()
|
||||
if ee, ok := err.(*exec.ExitError); !ok || ee.ExitCode() != 1 || strings.Contains(string(out), "REACHED-THE-DOWNLOAD") ||
|
||||
!strings.Contains(string(out), "installed for the whole machine") {
|
||||
t.Fatalf("%v: want a refusal before the download, got %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The root step takes the pairing code on standard input only when told to,
|
||||
// checks it before anything is downloaded, and never echoes it. --just-me is
|
||||
// refused as root and beside --shared.
|
||||
//
|
||||
// "Root" here is a stand-in id; curl fails every download, so nothing past
|
||||
// the checks can run.
|
||||
func TestInstallShRootStepTakesTheCodeOnStdin(t *testing.T) {
|
||||
stand := t.TempDir()
|
||||
write := func(name, body string) {
|
||||
if err := os.WriteFile(filepath.Join(stand, name), []byte("#!/bin/sh\n"+body+"\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("curl", `echo "REACHED-THE-DOWNLOAD" >&2; exit 1`)
|
||||
write("sudo", `echo "REACHED-SUDO" >&2; exit 1`)
|
||||
write("uname", `case "$1" in -s) echo Linux ;; -m) echo x86_64 ;; *) echo Linux ;; esac`)
|
||||
run := func(uid, stdin string, args ...string) (string, int) {
|
||||
write("id", `case "$1" in -u) echo `+uid+` ;; -un) echo tester ;; *) echo "uid=`+uid+`" ;; esac`)
|
||||
home := t.TempDir()
|
||||
cmd := exec.Command("sh", append([]string{filepath.Join("..", "deploy", "install.sh")}, args...)...)
|
||||
cmd.Env = []string{"PATH=" + stand + ":/usr/bin:/bin", "HOME=" + home, "TMPDIR=" + home}
|
||||
cmd.Stdin = strings.NewReader(stdin)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
return string(out), ee.ExitCode()
|
||||
}
|
||||
return string(out), 0
|
||||
}
|
||||
const code = "skp_eyJ2IjoxLCJ0IjoiU1RESU4ifQ"
|
||||
out, rc := run("0", code+"\n", "--personal", "--pair-code-on-stdin")
|
||||
if rc != 1 || !strings.Contains(out, "REACHED-THE-DOWNLOAD") || strings.Contains(out, code) {
|
||||
t.Fatalf("a good code on stdin did not reach the download, or was echoed (%d):\n%s", rc, out)
|
||||
}
|
||||
for _, bad := range []string{"", "skp_\n", "skb_aG9tZQ\n", code + " x\n"} {
|
||||
out, rc := run("0", bad, "--personal", "--pair-code-on-stdin")
|
||||
if rc != 2 || strings.Contains(out, "REACHED") || (bad != "" && strings.Contains(out, strings.TrimSpace(bad))) {
|
||||
t.Fatalf("stdin %q: want exit 2 before the download, unechoed (%d):\n%s", bad, rc, out)
|
||||
}
|
||||
}
|
||||
if out, rc := run("1000", code+"\n", "--pair-code-on-stdin"); rc != 2 || strings.Contains(out, "REACHED") {
|
||||
t.Fatalf("an ordinary account's --pair-code-on-stdin was taken (%d):\n%s", rc, out)
|
||||
}
|
||||
if out, rc := run("0", "", "--just-me"); rc != 2 || !strings.Contains(out, "without sudo") {
|
||||
t.Fatalf("--just-me as root (%d):\n%s", rc, out)
|
||||
}
|
||||
if out, rc := run("1000", "", "--just-me", "--shared"); rc != 2 || strings.Contains(out, "REACHED") {
|
||||
t.Fatalf("--just-me --shared (%d):\n%s", rc, out)
|
||||
}
|
||||
}
|
||||
|
||||
// The owner's account is recorded on a person's own computer only. On a
|
||||
// shared machine the console stays the administrator's.
|
||||
func TestInstallShRecordsTheOwnerOnAPersonalInstallOnly(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
bin := filepath.Join(d, "privatae-bastion")
|
||||
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho \"$*\" >> \""+filepath.Join(d, "log")+"\"\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fns := installShFunc(t, "pair_as") + installShFunc(t, "record_owner")
|
||||
for _, c := range []struct {
|
||||
networked, sudoUID, want string
|
||||
}{{"0", "1000", "approvals-owner --account 1000\n"}, {"1", "1000", ""}, {"0", "0", ""}, {"0", "", ""}} {
|
||||
os.Remove(filepath.Join(d, "log"))
|
||||
cmd := exec.Command("sh", "-c", fns+`networked=$1; record_owner "$2" 1 linux`, "sh", c.networked, bin)
|
||||
cmd.Env = []string{"PATH=/usr/bin:/bin", "SUDO_UID=" + c.sudoUID}
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%+v: %v %s", c, err, out)
|
||||
}
|
||||
got, _ := os.ReadFile(filepath.Join(d, "log"))
|
||||
if string(got) != c.want {
|
||||
t.Errorf("networked=%s SUDO_UID=%q: recorded %q, want %q", c.networked, c.sudoUID, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The root step's own failure, whatever its exit code, is never read as sudo
|
||||
// refusing: the installer stops, and installs nothing for the account.
|
||||
func TestInstallShRootStepFailureIsNotAFallBack(t *testing.T) {
|
||||
s := newPairShSetup(t)
|
||||
s.write(t, filepath.Join(s.dir, "root-step.sh"), "exit 3\n")
|
||||
s.write(t, filepath.Join(s.path, "sudo"), "#!/bin/sh\nwhile [ \"$1\" = -H ]; do shift; done\ncase \"$1\" in -p) exit 0 ;; esac\nexec \"$@\"\n")
|
||||
for name, body := range map[string]string{
|
||||
"uname": `case "$1" in -s) echo Linux ;; -m) echo x86_64 ;; *) echo Linux ;; esac`,
|
||||
"id": `case "$1" in -u) echo 1000 ;; -un) echo tester ;; *) echo "uid=1000(tester)" ;; esac`,
|
||||
"curl": `for a; do o=$a; done; for a; do case "$a" in https://privatae.ai/bastion/install.sh) cp "$STAND/root-step.sh" "$o"; exit 0 ;; esac; done; echo "REACHED-THE-DOWNLOAD" >&2; exit 1`,
|
||||
} {
|
||||
s.write(t, filepath.Join(s.path, name), "#!/bin/sh\n"+body+"\n")
|
||||
}
|
||||
cmd := exec.Command("sh", installShHere(t, s.dir, false))
|
||||
cmd.Env = s.env("STAND=" + s.dir)
|
||||
out, err := cmd.CombinedOutput()
|
||||
ee, ok := err.(*exec.ExitError)
|
||||
if !ok || ee.ExitCode() != 1 || strings.Contains(string(out), "your account only") || strings.Contains(string(out), "REACHED-THE-DOWNLOAD") {
|
||||
t.Fatalf("want exit 1 and no install for the account, got %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
+87
-21
@@ -15,9 +15,10 @@ package main
|
||||
// the host's signed grant remains the ceiling it was before. That distinction is
|
||||
// the whole safety argument: a switch that could widen would make the machine a
|
||||
// place to escalate FROM, which inverts the product. So the state stored here is
|
||||
// a DENY LIST, never an allow list. An empty file means "nothing switched off",
|
||||
// which is exactly the behaviour that existed before this file, and an
|
||||
// unreadable file means the same — see localDenyLoad.
|
||||
// a DENY LIST, never an allow list. A missing file means "nothing switched
|
||||
// off", which is exactly the behaviour that existed before this file. A file
|
||||
// that exists but cannot be read means EVERYTHING switched off — see
|
||||
// localDenied.
|
||||
//
|
||||
// WHY THE MACHINE MAY DO THIS AT ALL. It already decides what it actually
|
||||
// executes: every op passes capsAllow (anchor.go) on the way in, and the box
|
||||
@@ -64,12 +65,11 @@ func localDenyPath() (string, error) {
|
||||
|
||||
// localDenyLoad reads the deny list.
|
||||
//
|
||||
// EVERY FAILURE RETURNS AN EMPTY LIST, and that direction is deliberate. A
|
||||
// deny list that cannot be read must not become a deny-everything: the machine
|
||||
// would stop serving its host because of a corrupt file, which is a far worse
|
||||
// outcome than a switch not taking effect. The page reports the read error
|
||||
// separately so the person is not left believing something is off when it is
|
||||
// not.
|
||||
// Every failure returns an empty list WITH the error, and what an error means
|
||||
// is decided by the caller, not here. The gate (localDenied) refuses every
|
||||
// family while the error stands; the page and --list say why and name
|
||||
// `switch --reset`. An empty list must never be read on its own as "nothing
|
||||
// is off": that is how a corrupt file used to switch everything back on.
|
||||
func localDenyLoad() (*localDenyFile, error) {
|
||||
p, err := localDenyPath()
|
||||
if err != nil {
|
||||
@@ -84,9 +84,7 @@ func localDenyLoad() (*localDenyFile, error) {
|
||||
}
|
||||
var f localDenyFile
|
||||
if err := json.Unmarshal(b, &f); err != nil {
|
||||
return &localDenyFile{Off: map[string][]string{}}, fmt.Errorf(
|
||||
"this machine's list of switched-off capabilities cannot be read (%w). "+
|
||||
"Nothing is being blocked by it while that is true", err)
|
||||
return &localDenyFile{Off: map[string][]string{}}, fmt.Errorf("%s is not valid JSON: %w", p, err)
|
||||
}
|
||||
if f.Off == nil {
|
||||
f.Off = map[string][]string{}
|
||||
@@ -146,13 +144,25 @@ func localSwitchFamilies() []string {
|
||||
}
|
||||
|
||||
// localDenied reports whether a family is switched off for a host here.
|
||||
//
|
||||
// A LIST THAT CANNOT BE READ SWITCHES EVERYTHING OFF. The person at the
|
||||
// machine switched something off; a file that will not parse cannot say what,
|
||||
// so the only answer that keeps their decision is all of it. The other
|
||||
// direction (read it as "nothing is off") turned a session or files switch
|
||||
// back on without anyone being told, and the host was served exactly what the
|
||||
// person had refused it. Recovery is at the machine: `switch --reset` sets the
|
||||
// unreadable file aside and starts an empty list.
|
||||
func localDenied(host, family string) bool {
|
||||
if host == "" || family == "" {
|
||||
return false
|
||||
}
|
||||
localDenyMu.Lock()
|
||||
defer localDenyMu.Unlock()
|
||||
f, _ := localDenyLoad()
|
||||
f, err := localDenyLoad()
|
||||
if err != nil {
|
||||
localDenyReportUnreadable(err)
|
||||
return true
|
||||
}
|
||||
for _, c := range f.Off[host] {
|
||||
if c == family {
|
||||
return true
|
||||
@@ -165,7 +175,11 @@ func localDenied(host, family string) bool {
|
||||
func localDenyFamilies(host string) []string {
|
||||
localDenyMu.Lock()
|
||||
defer localDenyMu.Unlock()
|
||||
f, _ := localDenyLoad()
|
||||
f, err := localDenyLoad()
|
||||
if err != nil {
|
||||
// The same answer the gate gives (localDenied): everything is off.
|
||||
return localSwitchFamilies()
|
||||
}
|
||||
out := append([]string(nil), f.Off[host]...)
|
||||
sort.Strings(out)
|
||||
return out
|
||||
@@ -257,10 +271,9 @@ func localDenyRefusal(family string) string {
|
||||
// localDenyIndex is every switched-off (host, family) plus WHY the list could
|
||||
// not be read, when it could not.
|
||||
//
|
||||
// It exists because the per-family localDenied() swallows that error by design
|
||||
// — it has to, since a gate must not fail closed on a corrupt file — and the
|
||||
// PAGE has the opposite obligation: it must say so, or it shows a person their
|
||||
// switches as not existing rather than as not working.
|
||||
// It exists because the per-family localDenied() answers only yes or no, and
|
||||
// the PAGE has to say why every switch reads off: without the reason a person
|
||||
// sees their machine refusing everything and no way out.
|
||||
func localDenyIndex() (map[string]bool, string) {
|
||||
localDenyMu.Lock()
|
||||
defer localDenyMu.Unlock()
|
||||
@@ -272,9 +285,62 @@ func localDenyIndex() (map[string]bool, string) {
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return out, "This machine's list of switched-off capabilities cannot be read (" +
|
||||
err.Error() + "). NOTHING below is being blocked by it while that is true — " +
|
||||
"anything you switched off is not in effect."
|
||||
return out, localDenyUnreadableText(err)
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
|
||||
// localDenyUnreadableText is the one sentence the page, --list and the
|
||||
// daemon's log give for an unreadable list.
|
||||
func localDenyUnreadableText(err error) string {
|
||||
return "This machine's list of switched-off capabilities cannot be read (" +
|
||||
err.Error() + "). Until it can, EVERY capability is refused to every host. " +
|
||||
"To start a fresh list, run: sudo -H privatae-bastion switch --reset " +
|
||||
"(the unreadable file is kept beside it)."
|
||||
}
|
||||
|
||||
// localDenyUnreadableLogged is the last unreadable-list error written to the
|
||||
// log, so the gate, which runs on every op, logs each distinct failure once
|
||||
// instead of once per op. Guarded by localDenyMu.
|
||||
var localDenyUnreadableLogged string
|
||||
|
||||
// localDenyReportUnreadable logs the refusal-of-everything once per distinct
|
||||
// error. Caller holds localDenyMu.
|
||||
func localDenyReportUnreadable(err error) {
|
||||
msg := err.Error()
|
||||
if msg == localDenyUnreadableLogged {
|
||||
return
|
||||
}
|
||||
localDenyUnreadableLogged = msg
|
||||
fmt.Printf("local switch: %s\n", localDenyUnreadableText(err))
|
||||
}
|
||||
|
||||
// localDenyReset sets an unreadable deny list aside and leaves no list, so
|
||||
// nothing is switched off. It refuses when the list reads fine: resetting a
|
||||
// good list would silently clear what the person switched off, and that is
|
||||
// done one family at a time with `switch <host> <family> on`. The old file is
|
||||
// renamed, never deleted, so whatever is still legible in it can be read back.
|
||||
func localDenyReset() (string, error) {
|
||||
return localDenyResetAt(time.Now().UTC())
|
||||
}
|
||||
|
||||
func localDenyResetAt(now time.Time) (string, error) {
|
||||
var kept string
|
||||
err := withLocalDenyLock(func() error { // holds localDenyMu and the file lock
|
||||
p, err := localDenyPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := localDenyLoad(); err == nil {
|
||||
return fmt.Errorf("the list of switched-off capabilities reads fine, so there is " +
|
||||
"nothing to reset; switch a family back on with: switch <host> <family> on")
|
||||
}
|
||||
kept = p + ".unreadable-" + now.Format("20060102T150405Z")
|
||||
if err := os.Rename(p, kept); err != nil {
|
||||
return fmt.Errorf("could not set the unreadable list aside: %w", err)
|
||||
}
|
||||
localDenyUnreadableLogged = ""
|
||||
return nil
|
||||
})
|
||||
return kept, err
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// anchoredBox is the state capsAllow needs: a real anchor with its key pinned,
|
||||
@@ -150,10 +151,11 @@ func TestTheSwitchAppliesToTheAnchorPairing(t *testing.T) {
|
||||
|
||||
// ── failure directions ──────────────────────────────────────────────────────
|
||||
|
||||
// AN UNREADABLE DENY LIST BLOCKS NOTHING. A corrupt file must not stop the
|
||||
// machine serving its host — that is a far worse outcome than a switch not
|
||||
// taking effect, and the page reports the read error separately.
|
||||
func TestAnUnreadableDenyListBlocksNothing(t *testing.T) {
|
||||
// AN UNREADABLE DENY LIST SWITCHES EVERYTHING OFF. The person switched
|
||||
// something off; a file that will not parse cannot say what, so every family
|
||||
// is refused until the person resets the list at the machine. It used to read
|
||||
// as "nothing is off", which handed the host back exactly what was refused.
|
||||
func TestAnUnreadableDenyListRefusesEverything(t *testing.T) {
|
||||
const self = "h1.host.privatae.ai"
|
||||
anchoredBox(t, self, allCaps)
|
||||
p, err := localDenyPath()
|
||||
@@ -163,14 +165,64 @@ func TestAnUnreadableDenyListBlocksNothing(t *testing.T) {
|
||||
if err := os.WriteFile(p, []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why := capsAllow(self, "observe:agents"); why != "" {
|
||||
t.Errorf("a corrupt deny list refused an op: %q. Failing closed here means one "+
|
||||
"bad file stops this machine serving its host.", why)
|
||||
for _, op := range []string{"observe:agents", "session:root"} {
|
||||
if why := capsAllow(self, op); why == "" {
|
||||
t.Errorf("a corrupt deny list let %s through: an unreadable list must "+
|
||||
"refuse everything, or a switch the person turned off is back on", op)
|
||||
}
|
||||
}
|
||||
// And it is reported rather than swallowed.
|
||||
if _, err := localDenyLoad(); err == nil {
|
||||
t.Error("the unreadable list was loaded without an error, so nothing can tell " +
|
||||
"the person their switches are not in effect")
|
||||
"the person why everything is refused")
|
||||
}
|
||||
if _, note := localDenyIndex(); !strings.Contains(note, "switch --reset") {
|
||||
t.Errorf("the page note does not name the way out: %q", note)
|
||||
}
|
||||
}
|
||||
|
||||
// A MISSING list is not an unreadable one: a machine nobody has switched
|
||||
// anything off on serves its grant.
|
||||
func TestAMissingDenyListBlocksNothing(t *testing.T) {
|
||||
const self = "h1.host.privatae.ai"
|
||||
anchoredBox(t, self, allCaps)
|
||||
if why := capsAllow(self, "observe:agents"); why != "" {
|
||||
t.Errorf("with no deny list at all an op was refused: %q", why)
|
||||
}
|
||||
}
|
||||
|
||||
// --reset sets an unreadable list aside (kept, never deleted) and serves the
|
||||
// grant again; it refuses on a list that reads fine, because that would
|
||||
// clear the person's switches without them choosing each one.
|
||||
func TestResetSetsAnUnreadableListAsideAndRefusesAGoodOne(t *testing.T) {
|
||||
const self = "h1.host.privatae.ai"
|
||||
anchoredBox(t, self, allCaps)
|
||||
p, err := localDenyPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := localDenySet(self, "dcn", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := localDenyReset(); err == nil {
|
||||
t.Fatal("a list that reads fine was reset, clearing a switch nobody turned on")
|
||||
}
|
||||
if !localDenied(self, "dcn") {
|
||||
t.Fatal("the refused reset lost the switch")
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 10, 30, 3, 0, 0, 0, time.UTC)
|
||||
kept, err := localDenyResetAt(now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b, err := os.ReadFile(kept); err != nil || string(b) != "{not json" {
|
||||
t.Errorf("the unreadable file was not kept at %s: %q %v", kept, string(b), err)
|
||||
}
|
||||
if why := capsAllow(self, "observe:agents"); why != "" {
|
||||
t.Errorf("after a reset the grant is still refused: %q", why)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,3 +329,28 @@ func TestSwitchingOffAHostThisMachineNeverHeardOfIsRefused(t *testing.T) {
|
||||
t.Errorf("a paired host was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Every reader of an unreadable list gives the gate's answer: everything is
|
||||
// off. The given page's box-wide card included, and localDenyFamilies too.
|
||||
func TestEveryReaderOfAnUnreadableListSaysEverythingIsOff(t *testing.T) {
|
||||
const self = "h1.host.privatae.ai"
|
||||
anchoredBox(t, self, allCaps)
|
||||
p, err := localDenyPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := localDenyFamilies(self); len(got) != len(localSwitchFamilies()) {
|
||||
t.Errorf("localDenyFamilies on an unreadable list: %v", got)
|
||||
}
|
||||
g := collectGiven()
|
||||
for _, h := range g.Hosts {
|
||||
for _, f := range h.Families {
|
||||
if !f.SwitchedOff {
|
||||
t.Errorf("%s: the %s card does not read as switched off", h.Host, f.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
package main
|
||||
|
||||
// machine_cli.go — the console's This machine page on the command line:
|
||||
// `schedules`, `tool-servers`, `posture` and `baseline show|diff|record`.
|
||||
// Each read is one value (reads.go), printed in the console's words or, with
|
||||
// --json, as itself.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── scheduled jobs that start an agent ──────────────────────────────────────
|
||||
|
||||
const schedulesUsage = "usage: privatae-bastion schedules [--json]"
|
||||
|
||||
type schedulesRead struct {
|
||||
Schedules []uiSchedule `json:"schedules"`
|
||||
Read bool `json:"schedulers_read"` // false: this platform's schedulers are not read by this build
|
||||
}
|
||||
|
||||
func readSchedules(readOpts) (schedulesRead, error) {
|
||||
s, ok := collectSchedules()
|
||||
return schedulesRead{Schedules: s, Read: ok}, nil
|
||||
}
|
||||
|
||||
func cmdSchedules(args []string) error {
|
||||
asJSON, err := jsonOnly(schedulesUsage, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := readOwnOrMachine("schedules", readOpts{}, readSchedules)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if asJSON {
|
||||
return printJSON(os.Stdout, v)
|
||||
}
|
||||
printSchedules(os.Stdout, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func printSchedules(w io.Writer, v schedulesRead) {
|
||||
switch {
|
||||
case len(v.Schedules) == 0 && v.Read:
|
||||
fmt.Fprintln(w, "No scheduled job on this machine starts an AI agent.")
|
||||
return
|
||||
case len(v.Schedules) == 0:
|
||||
fmt.Fprintln(w, "This platform's schedulers cannot be read by this build, so an empty list here means nothing looked.")
|
||||
return
|
||||
}
|
||||
for _, s := range v.Schedules {
|
||||
line := s.Agent + " "
|
||||
if s.Schedule != "" {
|
||||
line += "runs " + s.Schedule + " · "
|
||||
}
|
||||
line += s.Where
|
||||
if s.User != "" {
|
||||
line += " · as " + s.User
|
||||
}
|
||||
fmt.Fprintln(w, line)
|
||||
if s.Command != "" {
|
||||
fmt.Fprintln(w, " "+s.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── MCP tool servers and their problems ─────────────────────────────────────
|
||||
|
||||
const toolServersUsage = "usage: privatae-bastion tool-servers [--json]"
|
||||
|
||||
type toolServersRead struct {
|
||||
Clients []uiToolGroup `json:"clients"`
|
||||
}
|
||||
|
||||
func readToolServers(readOpts) (toolServersRead, error) {
|
||||
return toolServersRead{Clients: collectToolGroups()}, nil
|
||||
}
|
||||
|
||||
func cmdToolServers(args []string) error {
|
||||
asJSON, err := jsonOnly(toolServersUsage, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := readOwnOrMachine("tool-servers", readOpts{}, readToolServers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if asJSON {
|
||||
return printJSON(os.Stdout, v)
|
||||
}
|
||||
printToolServers(os.Stdout, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func printToolServers(w io.Writer, v toolServersRead) {
|
||||
if len(v.Clients) == 0 {
|
||||
fmt.Fprintln(w, "No MCP tool server is configured in any settings file this bastion can read.")
|
||||
return
|
||||
}
|
||||
for _, g := range v.Clients {
|
||||
head := g.Client + ": " + plural(len(g.Servers), "tool server", "tool servers")
|
||||
if g.Bad > 0 {
|
||||
head += fmt.Sprintf(", %d with a problem", g.Bad)
|
||||
}
|
||||
fmt.Fprintln(w, head)
|
||||
for _, s := range g.Servers {
|
||||
fmt.Fprintf(w, " %s (%s)\n", s.Name, s.Where)
|
||||
for _, p := range s.Problems {
|
||||
fmt.Fprintln(w, " "+p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── posture: the checks a Host runs on its own servers ──────────────────────
|
||||
|
||||
const postureUsage = "usage: privatae-bastion posture [--json]"
|
||||
|
||||
// postureNeedsAdmin is a firewall field read without the administrator:
|
||||
// ufw, nft and iptables refuse an ordinary account, and their refusals read
|
||||
// as "absent" or a stray word, so the field is not reported at all.
|
||||
const postureNeedsAdmin = "needs_administrator"
|
||||
|
||||
type postureRead struct {
|
||||
Supported bool `json:"supported"`
|
||||
OS string `json:"os"`
|
||||
AsAdmin bool `json:"read_as_administrator"`
|
||||
Checks *hostPosture `json:"checks,omitempty"`
|
||||
}
|
||||
|
||||
func readPosture(readOpts) (postureRead, error) {
|
||||
r := postureRead{Supported: observeHostSupported, OS: runtime.GOOS, AsAdmin: machineAuthorityHeld()}
|
||||
if !r.Supported {
|
||||
return r, nil
|
||||
}
|
||||
p := readHostPosture()
|
||||
if !r.AsAdmin {
|
||||
p.Ufw, p.Pubwall, p.Input, p.Ssh22 = postureNeedsAdmin, postureNeedsAdmin, postureNeedsAdmin, postureNeedsAdmin
|
||||
}
|
||||
r.Checks = &p
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func cmdPosture(args []string) error {
|
||||
asJSON, err := jsonOnly(postureUsage, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := readOwnOrMachine("posture", readOpts{}, readPosture)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if asJSON {
|
||||
return printJSON(os.Stdout, v)
|
||||
}
|
||||
printPosture(os.Stdout, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func printPosture(w io.Writer, v postureRead) {
|
||||
if !v.Supported || v.Checks == nil {
|
||||
fmt.Fprintf(w, "These checks read Linux's firewall, package manager and system log; this machine runs %s, "+
|
||||
"so nothing was read.\n", v.OS)
|
||||
return
|
||||
}
|
||||
for _, l := range postureLines(*v.Checks, v.AsAdmin) {
|
||||
fmt.Fprintln(w, l)
|
||||
}
|
||||
fmt.Fprintln(w, "These are the checks a Host runs on its own servers. A firewall other than ufw, "+
|
||||
"the pubwall nftables table or iptables' INPUT policy is not read.")
|
||||
}
|
||||
|
||||
// postureLines says each check in words, the firewall first.
|
||||
func postureLines(p hostPosture, asAdmin bool) []string {
|
||||
var out []string
|
||||
add := func(s string) { out = append(out, s) }
|
||||
unread := func(what, v string) { add(what + ": could not be read (" + v + ")") }
|
||||
if !asAdmin {
|
||||
add("Firewall: not read; it takes the administrator: sudo -H privatae-bastion posture")
|
||||
} else {
|
||||
switch p.Ufw {
|
||||
case "active":
|
||||
add("ufw firewall: on")
|
||||
case "inactive":
|
||||
add("ufw firewall: installed, off")
|
||||
case "absent":
|
||||
add("ufw firewall: not installed")
|
||||
default:
|
||||
unread("ufw firewall", p.Ufw)
|
||||
}
|
||||
switch p.Pubwall {
|
||||
case "absent": // Privatae's own table: most machines have none, and that says nothing
|
||||
case "drop+tdrop":
|
||||
add("pubwall (nftables): refuses by default, and its last rule refuses too")
|
||||
case "drop":
|
||||
add("pubwall (nftables): refuses by default")
|
||||
case "accept+tdrop":
|
||||
add("pubwall (nftables): admits by default; only its last rule refuses, so losing that rule opens it")
|
||||
case "accept":
|
||||
add("pubwall (nftables): admits by default, with no rule that refuses at the end")
|
||||
default:
|
||||
unread("pubwall (nftables)", p.Pubwall)
|
||||
}
|
||||
switch p.Input {
|
||||
case "DROP", "REJECT":
|
||||
add("iptables INPUT: refuses by default (" + p.Input + ")")
|
||||
case "ACCEPT":
|
||||
add("iptables INPUT: admits by default")
|
||||
case "absent":
|
||||
add("iptables: not installed")
|
||||
default:
|
||||
unread("iptables INPUT", p.Input)
|
||||
}
|
||||
switch p.Ssh22 {
|
||||
case "scoped":
|
||||
add("SSH (port 22): admitted only from listed addresses")
|
||||
case "unscoped":
|
||||
add("SSH (port 22): admitted from any address")
|
||||
case "unknown":
|
||||
add("SSH (port 22): no firewall rule for it was found")
|
||||
default:
|
||||
unread("SSH (port 22)", p.Ssh22)
|
||||
}
|
||||
}
|
||||
switch p.F2b {
|
||||
case "active":
|
||||
add("fail2ban: running")
|
||||
case "inactive", "failed":
|
||||
add("fail2ban: not running (" + p.F2b + ")")
|
||||
case "absent":
|
||||
add("fail2ban: not checked (no systemctl)")
|
||||
default:
|
||||
add("fail2ban: " + p.F2b)
|
||||
}
|
||||
switch {
|
||||
case p.Sec < 0:
|
||||
add("Security updates: could not be counted (the check reads apt-get)")
|
||||
case p.Sec == 0:
|
||||
add("Security updates: none waiting")
|
||||
default:
|
||||
add(fmt.Sprintf("Security updates: %d waiting", p.Sec))
|
||||
}
|
||||
if p.Reboot == "yes" {
|
||||
add("A restart is needed to finish installing updates")
|
||||
}
|
||||
if p.DiskPct >= 0 {
|
||||
add(fmt.Sprintf("Disk (/): %d%% used", p.DiskPct))
|
||||
}
|
||||
if p.Load >= 0 && p.Cpus > 0 {
|
||||
add(fmt.Sprintf("Load: %.2f over the last minute, %d cores", p.Load, p.Cpus))
|
||||
}
|
||||
if p.Jrn >= 0 {
|
||||
add("System log: last line " + (time.Duration(p.Jrn) * time.Second).String() + " ago")
|
||||
} else {
|
||||
add("System log: could not be read")
|
||||
}
|
||||
if p.Puv != "" && p.Puv != "absent" {
|
||||
add("Check after the last upgrade: " + p.Puv)
|
||||
}
|
||||
if len(p.Timeouts) > 0 {
|
||||
add("Did not finish in time: " + strings.Join(p.Timeouts, ", "))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── the baseline: record it, show it, compare against it ────────────────────
|
||||
|
||||
const baselineUsage = "usage: privatae-bastion baseline [show] [--json] | baseline diff [--json] | " +
|
||||
"sudo -H privatae-bastion baseline record [--force]"
|
||||
|
||||
type baselineRead struct {
|
||||
Has bool `json:"has"`
|
||||
Baseline *baselineFile `json:"baseline,omitempty"`
|
||||
Integrity string `json:"integrity_note,omitempty"`
|
||||
Attestation string `json:"attestation_note,omitempty"`
|
||||
}
|
||||
|
||||
func readBaseline(readOpts) (baselineRead, error) {
|
||||
f, ok, err := baselineLoad()
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return baselineRead{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return baselineRead{}, err
|
||||
}
|
||||
r := baselineRead{Has: true, Baseline: f, Integrity: baselineIntegrityNote(f, ok)}
|
||||
if f.Attestation != nil {
|
||||
r.Attestation = baselineAttestationNote(f)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// readBaselineDiff compares what runs now against the baseline. The
|
||||
// administrator's: a pass that cannot read what the baseline read reports
|
||||
// the difference between two accounts, not a change to the machine.
|
||||
func readBaselineDiff(readOpts) (deltaReport, error) {
|
||||
if err := requireMachineAuthority("baseline diff"); err != nil {
|
||||
return deltaReport{}, err
|
||||
}
|
||||
return deltaPass(&digestCache{}, time.Now(), deltaRoleOperator), nil
|
||||
}
|
||||
|
||||
func cmdBaseline(args []string) error {
|
||||
verb := "show"
|
||||
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
||||
verb, args = args[0], args[1:]
|
||||
}
|
||||
switch verb {
|
||||
case "record":
|
||||
force := false
|
||||
for _, a := range args {
|
||||
if a != "--force" {
|
||||
return errors.New(baselineUsage)
|
||||
}
|
||||
force = true
|
||||
}
|
||||
return cmdObserveBaseline(force)
|
||||
case "show":
|
||||
asJSON, err := jsonOnly(baselineUsage, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := readMachine("baseline", readOpts{}, readBaseline)
|
||||
if err != nil {
|
||||
return laneRefusalError("baseline show", err)
|
||||
}
|
||||
if asJSON {
|
||||
return printJSON(os.Stdout, v)
|
||||
}
|
||||
return printBaseline(os.Stdout, v)
|
||||
case "diff":
|
||||
asJSON, err := jsonOnly(baselineUsage, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := readMachine("baseline-diff", readOpts{}, readBaselineDiff)
|
||||
if err != nil {
|
||||
return laneRefusalError("baseline diff", err)
|
||||
}
|
||||
if asJSON {
|
||||
return printJSON(os.Stdout, v)
|
||||
}
|
||||
return printDelta(os.Stdout, v)
|
||||
}
|
||||
return errors.New(baselineUsage)
|
||||
}
|
||||
+153
-26
@@ -11,11 +11,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, `privatae-bastion — your bastion into your Privatae network.
|
||||
fmt.Fprintln(os.Stderr, `privatae-bastion — watches the AI agents on this machine, and connects it to a Host if you run one.
|
||||
|
||||
Usage:
|
||||
privatae-bastion pair <token> enroll this machine with a token from your host's "Set up a bastion".
|
||||
@@ -93,16 +94,22 @@ Usage:
|
||||
paired and anchored. Needs machine authority. --host-ca is the
|
||||
NEW Host's CA, refused when it is the one this box pinned (that
|
||||
Host is not gone); --force skips that check, at a terminal only
|
||||
privatae-bastion approvals-owner --sid <SID> [--replace]
|
||||
Windows, beside the system service: the one account whose tray
|
||||
may list and answer approvals. Machine authority; a different
|
||||
SID needs --replace, at an Administrator terminal only
|
||||
privatae-bastion uninstall [--delete-data]
|
||||
remove the service and the program. The state stays (pairings,
|
||||
keys, the audit log) unless --delete-data, confirmed at a terminal.
|
||||
Installed for the whole machine, it needs the administrator
|
||||
privatae-bastion approvals-owner --sid <SID> [--replace] (Windows)
|
||||
privatae-bastion approvals-owner --account <name> [--replace] (Linux, macOS)
|
||||
beside the system service, the one person's account it serves:
|
||||
on Windows the tray's approvals, on Linux and macOS the console
|
||||
(privatae-bastion ui) without sudo. Machine authority; another
|
||||
account needs --replace, at a terminal only
|
||||
sudo -H privatae-bastion claim-anchor --repin
|
||||
same host, new name: re-sign anchor.json from the
|
||||
pairing this box already holds, after the host's
|
||||
own address changed. No token. Cannot move the
|
||||
anchor anywhere.
|
||||
privatae-bastion approvals [--host <host>]
|
||||
privatae-bastion approvals [--host <host> | --machine] [--json]
|
||||
show what your host is asking to do on this machine, in plain words
|
||||
privatae-bastion approve [--host <host>] <id>
|
||||
approve one request with a code from the authenticator enrolled here
|
||||
@@ -131,11 +138,44 @@ Usage:
|
||||
privatae-bastion quarantine clear <host>
|
||||
reopen a host's write lanes this machine closed after repeated
|
||||
refusals (5 in 10 minutes)
|
||||
privatae-bastion quarantine clear --asker <account>
|
||||
let agents running as that account ask this machine again, after
|
||||
5 of their asks were refused in 10 minutes
|
||||
sudo -H privatae-bastion switch <host> <family> on|off
|
||||
switch one capability family off for a host on this machine,
|
||||
or clear that refusal (the console page's switch). Off only
|
||||
removes reach; on grants nothing past the host's signed grant
|
||||
privatae-bastion switch --list every paired host's families, on or off, one per line
|
||||
privatae-bastion agents [--json] [--prompts]
|
||||
every AI agent on this machine and what it is doing: its
|
||||
session name and title, whether it waits for you, its folder
|
||||
and branch, what it is running now (--prompts: its last prompt)
|
||||
privatae-bastion report [--since 7d] [--json] [--out FILE | --out -]
|
||||
write what this bastion knows as one Markdown file: agents,
|
||||
scheduled agents, MCP tool-server problems, the baseline, activity.
|
||||
--out - prints it
|
||||
privatae-bastion schedules [--json]
|
||||
scheduled jobs that start an AI agent with nobody at the keyboard
|
||||
privatae-bastion tool-servers [--json]
|
||||
the MCP tool servers configured here and what is wrong with each
|
||||
privatae-bastion posture [--json]
|
||||
Linux: firewall, SSH, fail2ban, security updates, disk, load
|
||||
privatae-bastion activity [--since 7d] [--json]
|
||||
what happened on this machine, newest first: stops, your
|
||||
approvals, what each Host did, and changes made here
|
||||
privatae-bastion stop <pid or session> [--wait 5m] [--json]
|
||||
stop an agent this bastion sees, and every process it started,
|
||||
with your code as the machine's owner (stepup-enroll --machine).
|
||||
Run by an agent, it only asks: you approve under Waiting for you
|
||||
in the console. --wait follows the ask until it is answered
|
||||
privatae-bastion request <id> [--wait 5m] [--json]
|
||||
where an ask stands: waiting, approved, stopped, not stopped or
|
||||
denied. Exit 0 stopped (or asked), 1 not, 3 still waiting
|
||||
From the machine's owner account, beside the bastion installed for
|
||||
the whole machine, the reads (status, agents, schedules,
|
||||
tool-servers, posture, baseline, activity, report, approvals)
|
||||
ask that bastion and show the whole machine
|
||||
privatae-bastion help this text
|
||||
privatae-bastion coders set [--max N] [--mem MB] [--cpus F] [--engines a,b|all] [--allow-lan h[:p],...]
|
||||
[--image-prefix <registry/path/>|default]
|
||||
privatae-bastion coders on | off | drop [--host H] | status [--json]
|
||||
@@ -153,15 +193,20 @@ Usage:
|
||||
privatae-bastion context choose what your files become on your network. Runs as
|
||||
you, so it shows exactly what you can already open, and asks
|
||||
your host before anything is added
|
||||
privatae-bastion status show every pairing + tunnel status
|
||||
privatae-bastion observe:baseline record what is NORMAL on this machine: every automated
|
||||
privatae-bastion status [--json] show every pairing + tunnel status
|
||||
privatae-bastion baseline record [--force]
|
||||
record what is NORMAL on this machine: every automated
|
||||
program, its user and a digest of the file it runs from.
|
||||
Administrator only. --force replaces an existing baseline
|
||||
privatae-bastion observe:baseline:show print the baseline this machine holds
|
||||
privatae-bastion observe:delta what is running here that the baseline does not hold:
|
||||
(also: observe:baseline)
|
||||
privatae-bastion baseline [show] [--json]
|
||||
print the baseline this machine holds (also: observe:baseline:show)
|
||||
privatae-bastion baseline diff [--json]
|
||||
what is running here that the baseline does not hold:
|
||||
new programs, replaced binaries, new standing jobs, with
|
||||
the ordinary ones filtered out. Administrator only.
|
||||
--json for the full record including what was filtered
|
||||
(also: observe:delta)
|
||||
privatae-bastion observe print what this machine would report about agents, automations
|
||||
and scheduled jobs on its next check-in (read-only, JSON)
|
||||
privatae-bastion backup key init | key show | reseal | requests | approve | deny |
|
||||
@@ -341,12 +386,15 @@ func main() {
|
||||
err = cmdBackup(os.Args[2:])
|
||||
case "stepup-enroll":
|
||||
args := os.Args[2:]
|
||||
host, rotate, consoleOnly := "", false, false
|
||||
host, machine, rotate, consoleOnly := "", false, false, false
|
||||
for len(args) > 0 {
|
||||
switch {
|
||||
case args[0] == "--host" && len(args) >= 2:
|
||||
host = args[1]
|
||||
args = args[2:]
|
||||
case args[0] == "--machine":
|
||||
machine = true
|
||||
args = args[1:]
|
||||
case args[0] == "--rotate":
|
||||
rotate = true
|
||||
args = args[1:]
|
||||
@@ -354,36 +402,42 @@ func main() {
|
||||
consoleOnly = true
|
||||
args = args[1:]
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "usage: privatae-bastion stepup-enroll [--host <host>] [--rotate] [--console-only]")
|
||||
fmt.Fprintln(os.Stderr, "usage: privatae-bastion stepup-enroll [--host <host> | --machine] [--rotate] [--console-only]")
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
err = cmdStepupEnroll(host, rotate, consoleOnly)
|
||||
err = cmdStepupEnroll(host, machine, rotate, consoleOnly)
|
||||
case "approvals", "approve", "deny":
|
||||
// The user confirms (or refuses) what their host asked for, ON this machine,
|
||||
// with the factor enrolled here. The host never handles the code.
|
||||
host, args := takeHostFlag(os.Args[2:])
|
||||
machine, args := takeMachineFlag(args)
|
||||
switch os.Args[1] {
|
||||
case "approvals":
|
||||
// approvals drop <id>: revoke an approval this box issued and has
|
||||
// not yet burned — the console can always take back what it gave.
|
||||
if len(args) >= 2 && args[0] == "drop" {
|
||||
err = cmdStepupDrop(host, args[1])
|
||||
err = cmdStepupDrop(host, machine, args[1])
|
||||
break
|
||||
}
|
||||
err = cmdStepupApprovals(host)
|
||||
asJSON, jerr := jsonOnly("usage: privatae-bastion approvals [--host <host> | --machine] [--json]", args)
|
||||
if jerr != nil {
|
||||
fmt.Fprintln(os.Stderr, jerr)
|
||||
os.Exit(2)
|
||||
}
|
||||
err = cmdStepupApprovals(host, machine, asJSON)
|
||||
case "approve":
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: privatae-bastion approve [--host <host>] <request id>")
|
||||
fmt.Fprintln(os.Stderr, "usage: privatae-bastion approve [--host <host> | --machine] <request id>")
|
||||
os.Exit(2)
|
||||
}
|
||||
err = cmdStepupApprove(host, args[0])
|
||||
err = cmdStepupApprove(host, machine, args[0])
|
||||
case "deny":
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: privatae-bastion deny [--host <host>] <request id>")
|
||||
fmt.Fprintln(os.Stderr, "usage: privatae-bastion deny [--host <host> | --machine] <request id>")
|
||||
os.Exit(2)
|
||||
}
|
||||
err = cmdStepupDeny(host, args[0])
|
||||
err = cmdStepupDeny(host, machine, args[0])
|
||||
}
|
||||
case "release-anchor":
|
||||
// An anchor whose Host is gone (anchor_release.go): machine authority
|
||||
@@ -410,26 +464,40 @@ func main() {
|
||||
err = cmdReleaseAnchor(host, caFile, force)
|
||||
case "approvals-owner":
|
||||
// The one account the tray's lane serves (approvals_owner.go).
|
||||
// On Linux and macOS, the account the console lane serves
|
||||
// (owner_account.go): --account.
|
||||
args := os.Args[2:]
|
||||
sid, replace := "", false
|
||||
sid, account, replace, bad := "", "", false, false
|
||||
for len(args) > 0 {
|
||||
switch {
|
||||
case args[0] == "--sid" && len(args) >= 2:
|
||||
sid = args[1]
|
||||
args = args[2:]
|
||||
case args[0] == "--account" && len(args) >= 2:
|
||||
account = args[1]
|
||||
args = args[2:]
|
||||
case args[0] == "--replace":
|
||||
replace = true
|
||||
args = args[1:]
|
||||
default:
|
||||
sid = ""
|
||||
bad = true
|
||||
args = nil
|
||||
}
|
||||
}
|
||||
if sid == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: privatae-bastion approvals-owner --sid <account SID> [--replace] (from an Administrator terminal)")
|
||||
windows := runtime.GOOS == "windows"
|
||||
if bad || (windows && (sid == "" || account != "")) || (!windows && (account == "" || sid != "")) {
|
||||
if windows {
|
||||
fmt.Fprintln(os.Stderr, "usage: privatae-bastion approvals-owner --sid <account SID> [--replace] (from an Administrator terminal)")
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "usage: sudo -H privatae-bastion approvals-owner --account <name> [--replace]")
|
||||
}
|
||||
os.Exit(2)
|
||||
}
|
||||
err = cmdApprovalsOwner(sid, replace)
|
||||
if windows {
|
||||
err = cmdApprovalsOwner(sid, replace)
|
||||
} else {
|
||||
err = cmdOwnerAccount(account, replace)
|
||||
}
|
||||
case "tray":
|
||||
// The signed-in person's icon beside a system service
|
||||
// (tray_pipe_windows.go), started at every sign-in.
|
||||
@@ -522,13 +590,25 @@ func main() {
|
||||
// starts a server of its own and stays in the foreground: it is not a
|
||||
// view of the daemon, it is a separate surface with a different owner.
|
||||
err = cmdContextUI()
|
||||
case "uninstall":
|
||||
// Removes the service and the program; keeps the state and the audit
|
||||
// log unless --delete-data (uninstall.go).
|
||||
del := false
|
||||
for _, a := range os.Args[2:] {
|
||||
if a != "--delete-data" {
|
||||
fmt.Fprintln(os.Stderr, "usage: privatae-bastion uninstall [--delete-data]")
|
||||
os.Exit(2)
|
||||
}
|
||||
del = true
|
||||
}
|
||||
err = cmdUninstall(del)
|
||||
case "ui":
|
||||
// Opens a browser at the RUNNING daemon's UI (ui.go). It starts no
|
||||
// server of its own: the UI belongs to the process that knows what is
|
||||
// live, and a second one would render a machine nobody is serving.
|
||||
err = cmdUI()
|
||||
case "status":
|
||||
err = cmdStatus()
|
||||
err = cmdStatus(os.Args[2:])
|
||||
// ── the approval device (device.go) and the write gate's console side ──
|
||||
case "device-init":
|
||||
err = cmdDeviceInit()
|
||||
@@ -622,15 +702,58 @@ func main() {
|
||||
case "quarantine":
|
||||
// quarantine clear <host>: lift the box's own hold on a pairing's
|
||||
// write lanes (quarantine.go) — the person at the machine, only.
|
||||
// quarantine clear --asker <account>: the same for an account whose
|
||||
// agents' asks were held (askquarantine.go).
|
||||
if len(os.Args) < 4 || os.Args[2] != "clear" {
|
||||
fmt.Fprintln(os.Stderr, "usage: privatae-bastion quarantine clear <host>")
|
||||
fmt.Fprintln(os.Stderr, "usage: privatae-bastion quarantine clear <host> | --asker <account>")
|
||||
os.Exit(2)
|
||||
}
|
||||
if os.Args[3] == "--asker" {
|
||||
if len(os.Args) != 5 {
|
||||
fmt.Fprintln(os.Stderr, "usage: privatae-bastion quarantine clear --asker <account>")
|
||||
os.Exit(2)
|
||||
}
|
||||
err = cmdQuarantineClearAsker(os.Args[4])
|
||||
break
|
||||
}
|
||||
err = cmdQuarantineClear(os.Args[3])
|
||||
case "coders":
|
||||
// coders set|on|off|drop|status (coders_cli.go): size this machine
|
||||
// for coder runs. Off by default.
|
||||
err = cmdCoders(os.Args[2:])
|
||||
case "report":
|
||||
// report [--since 7d] [--json] [--out FILE] (report.go).
|
||||
err = cmdReport(os.Args[2:])
|
||||
case "activity":
|
||||
// activity [--since 7d] [--json] (activity.go): what happened here.
|
||||
err = cmdActivity(os.Args[2:])
|
||||
case "agents":
|
||||
// agents [--json] [--prompts] (agents_cli.go): every agent here and
|
||||
// what it is doing — its session, folder, branch, what it runs now.
|
||||
err = cmdAgents(os.Args[2:])
|
||||
case "stop":
|
||||
// stop <pid> [--wait D] [--json] (stop_local.go): the machine's owner
|
||||
// stops an agent, with their own second factor; run by an agent it
|
||||
// only files the request.
|
||||
err = cmdStop(os.Args[2:])
|
||||
case "request":
|
||||
// request <id> [--wait D] [--json] (askstatus.go): where an ask stands.
|
||||
err = cmdRequest(os.Args[2:])
|
||||
case "schedules":
|
||||
// schedules [--json] (machine_cli.go): scheduled jobs that start an agent.
|
||||
err = cmdSchedules(os.Args[2:])
|
||||
case "tool-servers":
|
||||
// tool-servers [--json] (machine_cli.go): MCP tool servers and their problems.
|
||||
err = cmdToolServers(os.Args[2:])
|
||||
case "posture":
|
||||
// posture [--json] (machine_cli.go): the checks a Host runs on its servers.
|
||||
err = cmdPosture(os.Args[2:])
|
||||
case "baseline":
|
||||
// baseline show|diff [--json], baseline record [--force]
|
||||
// (machine_cli.go): observe:baseline* under the words people use.
|
||||
err = cmdBaseline(os.Args[2:])
|
||||
case "help", "-h", "--help":
|
||||
usage()
|
||||
case "switch":
|
||||
// switch <host> <family> on|off, switch --list (switch_cli.go): the
|
||||
// local switch without the console page, for a machine nobody sits at.
|
||||
@@ -648,6 +771,10 @@ func main() {
|
||||
// The command's audit rows reach the system log before the process ends
|
||||
// (auditlog.go queues them off the op's own path).
|
||||
auditFlush(auditFlushOnExit)
|
||||
var quiet quietExit
|
||||
if errors.As(err, &quiet) {
|
||||
os.Exit(quiet.code) // the command already said what happened
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -130,6 +130,10 @@ func TestMain(m *testing.M) {
|
||||
// /proc/101 on the machine running the suite and the decision would depend
|
||||
// on whatever process holds that number. The real-signal test opts back in.
|
||||
procIdentity = func(int) (string, bool, bool) { return "", false, false }
|
||||
// The reads go to the machine's own bastion when it runs as a system
|
||||
// service and the suite does not run as root (reads.go): never from the
|
||||
// suite, which reads its own isolated state. The lane's tests opt in.
|
||||
readsGoThroughLane = func() bool { return false }
|
||||
// THE SWEEP IS ONE TOO. A test runs the ops' startup sweep for real, and it
|
||||
// stops every op unit its names match: the suite's names are its own, so
|
||||
// it never stops an op the machine's own bastion is running.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
// owner_account.go — on Linux and macOS, the one account the machine's
|
||||
// bastion opens its console to without sudo (consolelane.go).
|
||||
//
|
||||
// Installed for the whole machine, the bastion runs as root and its console
|
||||
// signs in with root's ui.secret. The person who installed it works in their
|
||||
// own account, and before this record they reached the console only through
|
||||
// sudo. The record names that account by uid, in the service's own state,
|
||||
// where a process running as the person can neither read nor write it.
|
||||
//
|
||||
// WHO WRITES IT. The installer's root step, from the account sudo ran it for
|
||||
// (SUDO_UID), or an administrator by hand: `approvals-owner --account <name>`.
|
||||
// The rules are approvals_owner.go's for the Windows SID: the same account
|
||||
// again changes nothing; another account is refused unless --replace is
|
||||
// typed at a terminal; a record that cannot be read is never quietly replaced.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// lookupOwnerAccount finds an account by name, or by uid when given a number.
|
||||
var lookupOwnerAccount = func(who string) (*user.User, error) {
|
||||
if _, err := strconv.ParseUint(who, 10, 32); err == nil {
|
||||
return user.LookupId(who)
|
||||
}
|
||||
return user.Lookup(who)
|
||||
}
|
||||
|
||||
// privatae-bastion approvals-owner --account <name|uid> [--replace]
|
||||
func cmdOwnerAccount(who string, replace bool) error {
|
||||
if err := requireMachineAuthority("approvals-owner"); err != nil {
|
||||
return err
|
||||
}
|
||||
if why := ownerServiceState(); why != "" {
|
||||
return fmt.Errorf("approvals-owner records the owner in the system service's state: %s — nothing was changed", why)
|
||||
}
|
||||
who = strings.TrimSpace(who)
|
||||
u, err := lookupOwnerAccount(who)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no account %q on this machine — nothing was changed", who)
|
||||
}
|
||||
n, err := strconv.ParseUint(u.Uid, 10, 32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("account %s has a uid this system does not use (%q) — nothing was changed", u.Username, u.Uid)
|
||||
}
|
||||
uid := uint32(n)
|
||||
if uid == 0 {
|
||||
return errors.New("root administers this machine already and opens the console with its own key; " +
|
||||
"name the person's own account — nothing was changed")
|
||||
}
|
||||
if replace && !ownerConsole() {
|
||||
return errors.New("approvals-owner --replace hands this machine's console to another account, and is taken only " +
|
||||
"at a terminal; this one's input or output goes to a file or a pipe — nothing was changed")
|
||||
}
|
||||
cur, curName, present, err := ownerAccountLoad()
|
||||
switch {
|
||||
case err == nil && present && cur == uid:
|
||||
fmt.Printf("this machine's owner account is already %s\n", u.Username)
|
||||
return nil
|
||||
case present && !replace:
|
||||
was := curName
|
||||
if was == "" {
|
||||
was = "uid " + strconv.FormatUint(uint64(cur), 10)
|
||||
}
|
||||
if err != nil {
|
||||
was = "an account its record no longer names readably (" + err.Error() + ")"
|
||||
}
|
||||
return fmt.Errorf("this machine's owner account is %s, and stays so — nothing was changed.\n"+
|
||||
" To hand it to %s, run at a terminal: sudo -H privatae-bastion approvals-owner --account %s --replace",
|
||||
was, u.Username, u.Username)
|
||||
}
|
||||
p, err := approvalsOwnerPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(approvalsOwnerRecord{UID: &uid, Account: u.Username,
|
||||
SetAt: time.Now().UTC().Format(time.RFC3339)}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := atomicWrite(p, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("this machine's owner account is %s: `privatae-bastion ui` opens the console from it.\n"+
|
||||
" Anything that stops an agent or widens access still takes the owner's code.\n", u.Username)
|
||||
return nil
|
||||
}
|
||||
+39
-31
@@ -137,41 +137,49 @@ func noteRefusal(pairing, op, why string, write bool, now time.Time) {
|
||||
e = &quarantineEntry{}
|
||||
f.Pairing[pairing] = e
|
||||
}
|
||||
// A refusal DURING a hold is the hold working, not a new strike: the
|
||||
// quarantine refusal itself comes through here, and counting it would
|
||||
// re-arm the hold the moment it lapsed.
|
||||
if quarantinedAt(e, now) {
|
||||
_ = quarantineSave(f)
|
||||
return
|
||||
}
|
||||
// Recent write refusals only — the window slides.
|
||||
kept := e.Refusals[:0]
|
||||
for _, ts := range e.Refusals {
|
||||
if t, err := time.Parse(time.RFC3339, ts); err == nil && now.Sub(t) < quarantineWindow {
|
||||
kept = append(kept, ts)
|
||||
}
|
||||
}
|
||||
e.Refusals = append(kept, now.UTC().Format(time.RFC3339))
|
||||
if len(e.Refusals) >= quarantineStrikes && !quarantinedAt(e, now) {
|
||||
if t, err := time.Parse(time.RFC3339, e.LastHold); err == nil && now.Sub(t) > quarantineForgive {
|
||||
e.Strikes = 0
|
||||
}
|
||||
hold := quarantineBase
|
||||
for i := 0; i < e.Strikes && hold < quarantineMaxHold; i++ {
|
||||
hold *= 2
|
||||
}
|
||||
if hold > quarantineMaxHold {
|
||||
hold = quarantineMaxHold
|
||||
}
|
||||
e.Strikes++
|
||||
e.Until = now.Add(hold).UTC().Format(time.RFC3339)
|
||||
e.LastHold = e.Until
|
||||
e.Refusals = nil
|
||||
}
|
||||
e.strike(now)
|
||||
}
|
||||
_ = quarantineSave(f)
|
||||
}
|
||||
|
||||
// strike counts one refusal against e and starts a hold at the
|
||||
// quarantineStrikes-th inside the window. Reports whether a hold started.
|
||||
// Shared with the local askers' holds (askquarantine.go).
|
||||
func (e *quarantineEntry) strike(now time.Time) bool {
|
||||
// A refusal DURING a hold is the hold working, not a new strike: the
|
||||
// quarantine refusal itself comes through here, and counting it would
|
||||
// re-arm the hold the moment it lapsed.
|
||||
if quarantinedAt(e, now) {
|
||||
return false
|
||||
}
|
||||
// Recent refusals only — the window slides.
|
||||
kept := e.Refusals[:0]
|
||||
for _, ts := range e.Refusals {
|
||||
if t, err := time.Parse(time.RFC3339, ts); err == nil && now.Sub(t) < quarantineWindow {
|
||||
kept = append(kept, ts)
|
||||
}
|
||||
}
|
||||
e.Refusals = append(kept, now.UTC().Format(time.RFC3339))
|
||||
if len(e.Refusals) < quarantineStrikes {
|
||||
return false
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, e.LastHold); err == nil && now.Sub(t) > quarantineForgive {
|
||||
e.Strikes = 0
|
||||
}
|
||||
hold := quarantineBase
|
||||
for i := 0; i < e.Strikes && hold < quarantineMaxHold; i++ {
|
||||
hold *= 2
|
||||
}
|
||||
if hold > quarantineMaxHold {
|
||||
hold = quarantineMaxHold
|
||||
}
|
||||
e.Strikes++
|
||||
e.Until = now.Add(hold).UTC().Format(time.RFC3339)
|
||||
e.LastHold = e.Until
|
||||
e.Refusals = nil
|
||||
return true
|
||||
}
|
||||
|
||||
func quarantinedAt(e *quarantineEntry, now time.Time) bool {
|
||||
if e == nil || e.Until == "" {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
package main
|
||||
|
||||
// reads.go — what the machine's bastion knows, read by an agent the machine's
|
||||
// owner runs (plan Addendum 6, slice 2).
|
||||
//
|
||||
// Installed for the whole machine, the bastion runs as root and keeps its
|
||||
// records in root's state. An agent the person runs works in the person's
|
||||
// account: `status`, `activity` and `report` sent it to sudo or a browser,
|
||||
// and it has neither; `agents`, `schedules` and `tool-servers` showed it only
|
||||
// what that account can read. ask.sock (agentask.go) already knows from the
|
||||
// kernel which account connected. Asked for a read by the account recorded as
|
||||
// the machine's owner (owner_account.go), the daemon builds it with the
|
||||
// machine's own view and answers with it. That is what the owner's console
|
||||
// lane (consolelane.go) already shows that account, with one difference:
|
||||
// posture, which the console has no page for, adds the machine's firewall
|
||||
// checks, read as the administrator. The delegation tag an agent carries,
|
||||
// which the console never shows, is in no read at all (readAgents).
|
||||
// Any other account is refused; where a read means something with its own
|
||||
// permissions (the agents, schedules and tool servers it can see), it gets
|
||||
// that, and is told so.
|
||||
//
|
||||
// Each read is one value, built where the machine's state is and printed by
|
||||
// whoever asked: in words, or with --json as the value itself. The words and
|
||||
// the JSON are never built apart, so they cannot say different things.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// readMaxBytes bounds one read's answer. A report on a busy machine is
|
||||
// a few hundred KB; this is room for a pathological one.
|
||||
readMaxBytes = 16 << 20
|
||||
// readEnvelope is room in readMaxBytes for the reply around the view.
|
||||
readEnvelope = 64 << 10
|
||||
// readDeadline is how long the daemon spends on one read. Comparing
|
||||
// against the baseline walks every process and digests their files.
|
||||
readDeadline = 90 * time.Second
|
||||
// readReuse is how long a heavy read's answer is handed out again
|
||||
// rather than run again: an agent polling it costs one pass, not one
|
||||
// pass per poll.
|
||||
readReuse = 30 * time.Second
|
||||
)
|
||||
|
||||
// readOpts are the flags a read takes, the same ones on both sides.
|
||||
type readOpts struct {
|
||||
Since string `json:"since,omitempty"` // activity, report: 12h, 7d
|
||||
Prompts bool `json:"prompts,omitempty"` // agents: each session's last prompt
|
||||
Host string `json:"host,omitempty"` // approvals: a Host's requests
|
||||
Machine bool `json:"machine,omitempty"` // approvals: the machine's own
|
||||
}
|
||||
|
||||
func (o readOpts) since(def time.Duration) (time.Duration, error) {
|
||||
if o.Since == "" {
|
||||
return def, nil
|
||||
}
|
||||
return parseSince(o.Since)
|
||||
}
|
||||
|
||||
type machineRead struct {
|
||||
build func(readOpts) (any, error)
|
||||
heavy bool // walks every process or runs the package manager
|
||||
}
|
||||
|
||||
// machineReads are what the daemon answers over ask.sock, by name.
|
||||
var machineReads = map[string]machineRead{
|
||||
"status": {build: func(o readOpts) (any, error) { return readStatus(o) }},
|
||||
"agents": {build: func(o readOpts) (any, error) { return readAgents(o) }},
|
||||
"schedules": {build: func(o readOpts) (any, error) { return readSchedules(o) }},
|
||||
"tool-servers": {build: func(o readOpts) (any, error) { return readToolServers(o) }},
|
||||
"posture": {build: func(o readOpts) (any, error) { return readPosture(o) }, heavy: true},
|
||||
"baseline": {build: func(o readOpts) (any, error) { return readBaseline(o) }},
|
||||
"baseline-diff": {build: func(o readOpts) (any, error) { return readBaselineDiff(o) }, heavy: true},
|
||||
"activity": {build: func(o readOpts) (any, error) { return readActivity(o) }},
|
||||
"report": {build: func(o readOpts) (any, error) { return readReport(o) }, heavy: true},
|
||||
"approvals": {build: func(o readOpts) (any, error) { return readApprovals(o) }},
|
||||
}
|
||||
|
||||
// readsGoThroughLane: the machine's bastion is a system service this account
|
||||
// does not administer, so its state is not this process's to read.
|
||||
var readsGoThroughLane = func() bool {
|
||||
if runtime.GOOS == "windows" || machineAuthorityHeld() {
|
||||
return false // no ask pipe on Windows yet; the administrator reads directly
|
||||
}
|
||||
if runtime.GOOS == "linux" {
|
||||
return linuxSystemUnitPresent()
|
||||
}
|
||||
return systemService.installed
|
||||
}
|
||||
|
||||
// laneError is the machine's bastion not answering a read for this account:
|
||||
// refused (not the owner's account), or nothing answering at all (down).
|
||||
type laneError struct {
|
||||
why string
|
||||
down bool
|
||||
}
|
||||
|
||||
func (e *laneError) Error() string { return e.why }
|
||||
|
||||
// readMachine is the read `name`: built here when this process holds the
|
||||
// machine's state, else asked of the machine's bastion.
|
||||
func readMachine[T any](name string, o readOpts, build func(readOpts) (T, error)) (T, error) {
|
||||
if !readsGoThroughLane() {
|
||||
return build(o)
|
||||
}
|
||||
var v T
|
||||
err := readViaSock(name, o, &v)
|
||||
return v, err
|
||||
}
|
||||
|
||||
// readOwnOrMachine is readMachine for a read that means something with this
|
||||
// account's own permissions: refused by the machine's bastion, it reads what
|
||||
// this account can see, and says so on stderr.
|
||||
func readOwnOrMachine[T any](name string, o readOpts, build func(readOpts) (T, error)) (T, error) {
|
||||
v, err := readMachine(name, o, build)
|
||||
var le *laneError
|
||||
if !errors.As(err, &le) {
|
||||
return v, err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%s.\nShowing only what this account can read itself.\n", sentenceCase(le.why))
|
||||
return build(o)
|
||||
}
|
||||
|
||||
// sentenceCase starts s with a capital, for a reason said as a sentence.
|
||||
func sentenceCase(s string) string {
|
||||
if s == "" || s[0] < 'a' || s[0] > 'z' {
|
||||
return s
|
||||
}
|
||||
return string(s[0]-'a'+'A') + s[1:]
|
||||
}
|
||||
|
||||
// laneRefusalError is a read that only the machine's bastion can answer,
|
||||
// refused: why, and how the administrator reads it.
|
||||
func laneRefusalError(cmd string, err error) error {
|
||||
var le *laneError
|
||||
if !errors.As(err, &le) {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%s\n As the administrator: sudo -H privatae-bastion %s", le.why, cmd)
|
||||
}
|
||||
|
||||
// ── the asking side ─────────────────────────────────────────────────────────
|
||||
|
||||
// readSockPath is where the machine's bastion answers; a variable so a test
|
||||
// can stand one up elsewhere.
|
||||
var readSockPath = askSockPath
|
||||
|
||||
func readViaSock(name string, o readOpts, v any) error {
|
||||
p := readSockPath()
|
||||
c, err := net.DialTimeout("unix", p, 3*time.Second)
|
||||
if err != nil {
|
||||
return &laneError{down: true, why: "nothing answers on " + p + " (" + err.Error() + ")"}
|
||||
}
|
||||
defer c.Close()
|
||||
_ = c.SetDeadline(time.Now().Add(readDeadline + 10*time.Second))
|
||||
req := agentAskRequest{Op: "read", Read: name, Since: o.Since, Prompts: o.Prompts, Host: o.Host, Machine: o.Machine}
|
||||
if err := json.NewEncoder(c).Encode(req); err != nil {
|
||||
return &laneError{down: true, why: "the read could not be sent (" + err.Error() + ")"}
|
||||
}
|
||||
var r agentReadReply
|
||||
if err := json.NewDecoder(io.LimitReader(c, readMaxBytes)).Decode(&r); err != nil {
|
||||
return &laneError{down: true, why: "the machine's bastion did not answer the read (" + err.Error() + ")"}
|
||||
}
|
||||
if !r.OK {
|
||||
return &laneError{why: r.Why}
|
||||
}
|
||||
if err := json.Unmarshal(r.View, v); err != nil {
|
||||
return fmt.Errorf("the machine's bastion answered %s in a form this one cannot read (%v); "+
|
||||
"the two may be different versions", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── the answering side (the daemon) ─────────────────────────────────────────
|
||||
|
||||
type agentReadReply struct {
|
||||
OK bool `json:"ok"`
|
||||
Why string `json:"why,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
View json.RawMessage `json:"view,omitempty"`
|
||||
}
|
||||
|
||||
// readLaneRefusal says why account uid may not read through the lane, or "".
|
||||
// The owner's account only: the agents, folders and prompts of every
|
||||
// account's sessions are the owner's to see, not another account's.
|
||||
func readLaneRefusal(uid uint32, who string) string {
|
||||
owner, ownerName, present, err := ownerAccountLoad()
|
||||
switch {
|
||||
case err != nil:
|
||||
return "this machine's owner account cannot be read (" + err.Error() + ")"
|
||||
case !present:
|
||||
return "no account is recorded as this machine's owner, so its bastion answers reads only for the " +
|
||||
"administrator. To read from your own account, the administrator records it once: " +
|
||||
"sudo -H privatae-bastion approvals-owner --account " + accountForHint(who)
|
||||
case owner != uid:
|
||||
name := ownerName
|
||||
if name == "" {
|
||||
name = "uid " + strconv.FormatUint(uint64(owner), 10)
|
||||
}
|
||||
return "this machine's bastion answers reads only for its owner's account (" + name + "), not " + who
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// heavyReads runs one heavy read at a time and hands its answer out again
|
||||
// for readReuse.
|
||||
var heavyReads struct {
|
||||
sync.Mutex
|
||||
last map[string]heavyAnswer
|
||||
}
|
||||
|
||||
type heavyAnswer struct {
|
||||
at time.Time
|
||||
view []byte
|
||||
}
|
||||
|
||||
var readNow = time.Now
|
||||
|
||||
// readSlots is how many reads the daemon answers at once: fewer than
|
||||
// ask.sock's connections (askSockInFlight), so reads that walk every process
|
||||
// can never leave a stop ask with no connection to arrive on.
|
||||
var readSlots = make(chan struct{}, 4)
|
||||
|
||||
func serveRead(uid uint32, user string, req agentAskRequest) agentReadReply {
|
||||
r := agentReadReply{User: user}
|
||||
if why := readLaneRefusal(uid, user); why != "" {
|
||||
r.Why = why
|
||||
return r
|
||||
}
|
||||
select {
|
||||
case readSlots <- struct{}{}:
|
||||
defer func() { <-readSlots }()
|
||||
default:
|
||||
r.Why = "this machine's bastion is answering " + strconv.Itoa(cap(readSlots)) +
|
||||
" reads already; ask again in a moment"
|
||||
return r
|
||||
}
|
||||
spec, ok := machineReads[req.Read]
|
||||
if !ok {
|
||||
r.Why = "this bastion has no read called " + strconv.Quote(cleanText(req.Read, 40))
|
||||
return r
|
||||
}
|
||||
o := readOpts{Since: req.Since, Prompts: req.Prompts, Host: req.Host, Machine: req.Machine}
|
||||
view, err := runMachineRead(req.Read, spec, o)
|
||||
if err != nil {
|
||||
r.Why = err.Error()
|
||||
return r
|
||||
}
|
||||
if len(view) > readMaxBytes-readEnvelope {
|
||||
// Cut off at readMaxBytes, the asker would read a bastion that
|
||||
// does not answer.
|
||||
r.Why = fmt.Sprintf("this read's answer is %d MiB, more than the %d MiB one answer may be; "+
|
||||
"ask for a shorter period with --since", (len(view)+(1<<20)-1)>>20, readMaxBytes>>20)
|
||||
return r
|
||||
}
|
||||
r.OK, r.View = true, view
|
||||
return r
|
||||
}
|
||||
|
||||
func runMachineRead(name string, spec machineRead, o readOpts) ([]byte, error) {
|
||||
build := func() ([]byte, error) {
|
||||
v, err := spec.build(o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
if !spec.heavy {
|
||||
return build()
|
||||
}
|
||||
key, _ := json.Marshal(struct {
|
||||
N string
|
||||
O readOpts
|
||||
}{name, o})
|
||||
heavyReads.Lock()
|
||||
defer heavyReads.Unlock()
|
||||
if a, ok := heavyReads.last[string(key)]; ok && readNow().Sub(a.at) < readReuse {
|
||||
return a.view, nil
|
||||
}
|
||||
b, err := build()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if heavyReads.last == nil {
|
||||
heavyReads.last = map[string]heavyAnswer{}
|
||||
}
|
||||
for k, a := range heavyReads.last {
|
||||
if readNow().Sub(a.at) >= readReuse {
|
||||
delete(heavyReads.last, k)
|
||||
}
|
||||
}
|
||||
heavyReads.last[string(key)] = heavyAnswer{at: readNow(), view: b}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// ── flags ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// jsonOnly reads a command's flags when --json is its only one.
|
||||
func jsonOnly(usage string, args []string) (bool, error) {
|
||||
asJSON := false
|
||||
for _, a := range args {
|
||||
if a != "--json" {
|
||||
return false, errors.New(usage)
|
||||
}
|
||||
asJSON = true
|
||||
}
|
||||
return asJSON, nil
|
||||
}
|
||||
|
||||
func printJSON(w io.Writer, v any) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(v)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// laneSock stands the machine's bastion up on a real unix socket and points
|
||||
// this process's reads at it, as an agent beside the system service sees it.
|
||||
func laneSock(t *testing.T) {
|
||||
t.Helper()
|
||||
sock := filepath.Join(t.TempDir(), "ask.sock")
|
||||
ln, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go askSockServe(c)
|
||||
}
|
||||
}()
|
||||
prevPath, prevLane, prevID := readSockPath, readsGoThroughLane, askPeerIdentityFn
|
||||
t.Cleanup(func() { readSockPath, readsGoThroughLane, askPeerIdentityFn = prevPath, prevLane, prevID })
|
||||
readSockPath = func() string { return sock }
|
||||
readsGoThroughLane = func() bool { return true }
|
||||
// The kernel's answer, with this process's own uid read as laneTestUID:
|
||||
// the suite runs as root, which is never a machine's owner account.
|
||||
askPeerIdentityFn = func(c net.Conn) (uint32, string, error) {
|
||||
uid, who, err := contextPeerIdentity(c)
|
||||
if err == nil && uid == uint32(os.Getuid()) {
|
||||
uid = laneTestUID
|
||||
}
|
||||
return uid, who, err
|
||||
}
|
||||
}
|
||||
|
||||
const laneTestUID = 1000
|
||||
|
||||
// The owner's agent, with no sudo and no browser, reads the machine's status,
|
||||
// agents and activity from its bastion; the kernel's account decides it.
|
||||
func TestTheOwnersAgentReadsTheMachineThroughTheLane(t *testing.T) {
|
||||
askFlowWorld(t)
|
||||
laneOwner(t, laneTestUID)
|
||||
laneSock(t)
|
||||
out := captureStdout(t)
|
||||
|
||||
if err := cmdStatus([]string{"--json"}); err != nil {
|
||||
t.Fatalf("status: %v", err)
|
||||
}
|
||||
var s statusRead
|
||||
if err := json.Unmarshal([]byte(printedSince(out, 0, "}\n")), &s); err != nil || s.Version != clientVersion {
|
||||
t.Fatalf("status --json printed %q (%v)", out(), err)
|
||||
}
|
||||
before := len(out())
|
||||
if err := cmdAgents([]string{"--json"}); err != nil {
|
||||
t.Fatalf("agents: %v", err)
|
||||
}
|
||||
got := printedSince(out, before, "untrusted text")
|
||||
if !strings.Contains(got, `"signature": "aider"`) || !strings.Contains(got, `"signature": "claude-code"`) {
|
||||
t.Errorf("agents through the lane printed %q", got)
|
||||
}
|
||||
before = len(out())
|
||||
if err := cmdActivity(nil); err != nil {
|
||||
t.Fatalf("activity: %v", err)
|
||||
}
|
||||
if got := printedSince(out, before, "\n"); got == "" {
|
||||
t.Error("activity through the lane printed nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// Another account is told why and how the administrator reads it; the reads
|
||||
// that mean something with its own permissions fall back to those.
|
||||
func TestAnotherAccountIsRefusedTheMachinesReads(t *testing.T) {
|
||||
askFlowWorld(t)
|
||||
laneOwner(t, laneTestUID+1)
|
||||
laneSock(t)
|
||||
err := cmdStatus(nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "answers reads only for its owner's account") ||
|
||||
!strings.Contains(err.Error(), "sudo -H privatae-bastion status") {
|
||||
t.Errorf("status from another account: %v", err)
|
||||
}
|
||||
if err := cmdReport([]string{"--out", "-"}); err == nil || !strings.Contains(err.Error(), "sudo -H privatae-bastion report") {
|
||||
t.Errorf("report from another account: %v", err)
|
||||
}
|
||||
out := captureStdout(t)
|
||||
if err := cmdSchedules(nil); err != nil {
|
||||
t.Errorf("schedules from another account did not fall back to its own: %v", err)
|
||||
}
|
||||
if printedSince(out, 0, "\n") == "" {
|
||||
t.Error("schedules printed nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// Installed for the whole machine, with nothing answering on its socket:
|
||||
// status says so instead of failing.
|
||||
func TestStatusSaysTheServiceIsNotAnswering(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
prevPath, prevLane := readSockPath, readsGoThroughLane
|
||||
t.Cleanup(func() { readSockPath, readsGoThroughLane = prevPath, prevLane })
|
||||
readSockPath = func() string { return filepath.Join(t.TempDir(), "nothing.sock") }
|
||||
readsGoThroughLane = func() bool { return true }
|
||||
out := captureStdout(t)
|
||||
if err := cmdStatus(nil); err != nil {
|
||||
t.Fatalf("status: %v", err)
|
||||
}
|
||||
if got := printedSince(out, 0, "status privatae-bastion"); !strings.Contains(got, "NOT ANSWERING") ||
|
||||
!strings.Contains(got, "systemctl status privatae-bastion") {
|
||||
t.Errorf("status printed %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// report --out - writes the report to standard output.
|
||||
func TestReportToStandardOutput(t *testing.T) {
|
||||
homeTestWorld(t, true)
|
||||
out := captureStdout(t)
|
||||
if err := cmdReport([]string{"--out", "-"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := printedSince(out, 0, "No AI model wrote any of this report."); !strings.Contains(got, "# AI agents on ") {
|
||||
t.Errorf("report --out - printed %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An answer larger than the asking side reads is refused by the daemon with
|
||||
// the reason, not cut off mid-answer, which the asker read as a bastion that
|
||||
// does not answer at all (and status as NOT ANSWERING).
|
||||
func TestAnAnswerTooLargeToReadIsRefusedWithTheReason(t *testing.T) {
|
||||
askFlowWorld(t)
|
||||
laneOwner(t, laneTestUID)
|
||||
laneSock(t)
|
||||
machineReads["test-huge"] = machineRead{build: func(readOpts) (any, error) {
|
||||
return strings.Repeat("x", readMaxBytes), nil
|
||||
}}
|
||||
t.Cleanup(func() { delete(machineReads, "test-huge") })
|
||||
var v string
|
||||
err := readViaSock("test-huge", readOpts{}, &v)
|
||||
var le *laneError
|
||||
if !errors.As(err, &le) || le.down || !strings.Contains(le.why, "--since") {
|
||||
t.Errorf("a read too large for the asker: %#v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A read through the lane is answered for the machine's owner's account and
|
||||
// nobody else's: another account would see every account's sessions.
|
||||
func TestALaneReadIsAnsweredOnlyForTheOwnersAccount(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
if r := serveRead(1000, "sam", agentAskRequest{Op: "read", Read: "status"}); r.OK ||
|
||||
!strings.Contains(r.Why, "approvals-owner --account sam") {
|
||||
t.Errorf("with no owner recorded: %+v", r)
|
||||
}
|
||||
laneOwner(t, 1000)
|
||||
if r := serveRead(1000, "sam", agentAskRequest{Op: "read", Read: "status"}); !r.OK || len(r.View) == 0 {
|
||||
t.Errorf("the owner was refused: %+v", r)
|
||||
}
|
||||
if r := serveRead(1001, "lee", agentAskRequest{Op: "read", Read: "agents"}); r.OK || r.View != nil ||
|
||||
!strings.Contains(r.Why, "only for its owner's account (sam), not lee") {
|
||||
t.Errorf("another account: %+v", r)
|
||||
}
|
||||
if r := serveRead(1000, "sam", agentAskRequest{Op: "read", Read: "../ui.secret"}); r.OK ||
|
||||
!strings.Contains(r.Why, "no read called") {
|
||||
t.Errorf("a read that does not exist: %+v", r)
|
||||
}
|
||||
if r := serveRead(1000, "sam", agentAskRequest{Op: "read", Read: "activity", Since: "forever"}); r.OK {
|
||||
t.Errorf("a --since nobody can parse was answered: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// Reads that walk every process or run the package manager run one at a
|
||||
// time, and an agent polling one gets the last answer back for readReuse.
|
||||
func TestAHeavyReadIsReusedNotRunAgain(t *testing.T) {
|
||||
runs := 0
|
||||
spec := machineRead{heavy: true, build: func(readOpts) (any, error) { runs++; return map[string]int{"run": runs}, nil }}
|
||||
now := time.Now()
|
||||
prev := readNow
|
||||
t.Cleanup(func() { readNow = prev })
|
||||
readNow = func() time.Time { return now }
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := runMachineRead("test-heavy", spec, readOpts{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if runs != 1 {
|
||||
t.Errorf("three reads inside the window ran the pass %d times", runs)
|
||||
}
|
||||
if _, err := runMachineRead("test-heavy", spec, readOpts{Since: "1d"}); err != nil || runs != 2 {
|
||||
t.Errorf("a read with other flags was handed the first one's answer (runs %d, %v)", runs, err)
|
||||
}
|
||||
now = now.Add(readReuse)
|
||||
b, err := runMachineRead("test-heavy", spec, readOpts{})
|
||||
if err != nil || runs != 3 || !strings.Contains(string(b), `"run":3`) {
|
||||
t.Errorf("past the window: runs %d, %s, %v", runs, b, err)
|
||||
}
|
||||
light := machineRead{build: func(readOpts) (any, error) { runs++; return runs, nil }}
|
||||
_, _ = runMachineRead("test-light", light, readOpts{})
|
||||
_, _ = runMachineRead("test-light", light, readOpts{})
|
||||
if runs != 5 {
|
||||
t.Errorf("a light read was cached: runs %d", runs)
|
||||
}
|
||||
}
|
||||
|
||||
// Reads leave connections for stop asks: past readSlots, a read is turned
|
||||
// away, not queued.
|
||||
func TestReadsCannotTakeEveryConnection(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
laneOwner(t, 1000)
|
||||
if cap(readSlots) >= askSockInFlight {
|
||||
t.Fatalf("reads may hold %d of %d connections", cap(readSlots), askSockInFlight)
|
||||
}
|
||||
for i := 0; i < cap(readSlots); i++ {
|
||||
readSlots <- struct{}{}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for i := 0; i < cap(readSlots); i++ {
|
||||
<-readSlots
|
||||
}
|
||||
})
|
||||
if r := serveRead(1000, "sam", agentAskRequest{Op: "read", Read: "status"}); r.OK || !strings.Contains(r.Why, "ask again") {
|
||||
t.Errorf("a read past the slots: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// What crosses the lane is the value the words are printed from, so a read
|
||||
// through the lane prints exactly what the administrator's own run prints.
|
||||
func TestEveryReadPrintsTheSameAfterTheLane(t *testing.T) {
|
||||
homeTestWorld(t, true)
|
||||
agents, err := readAgents(readOpts{Prompts: true})
|
||||
if err != nil || len(agents.Agents) == 0 {
|
||||
t.Fatalf("agents: %+v %v", agents, err)
|
||||
}
|
||||
now := time.Now()
|
||||
printers := map[string]func(*bytes.Buffer, any){}
|
||||
values := map[string]any{}
|
||||
add := func(name string, v any, print func(*bytes.Buffer, any)) { values[name], printers[name] = v, print }
|
||||
|
||||
add("agents", agents, func(w *bytes.Buffer, v any) { printAgents(w, v.(agentsRead).Agents, now) })
|
||||
add("status", statusRead{Version: "9.9.9", Running: "partly", Mode: "networked",
|
||||
Anchor: &statusAnchor{Host: "home", Owner: "Sam", Since: "2026-10-01"},
|
||||
Pairings: []statusPairing{
|
||||
{Host: "home", State: "active", Transport: "local", Lanes: "/run/x.sock", Runs: "/runs", Local: "127.0.0.1:7878", Answering: true},
|
||||
{Host: "vm", State: "active", Transport: "tcp", Lanes: "127.0.0.1:8811", Local: "127.0.0.1:7879"},
|
||||
{Host: "cloud", State: "active", Transport: "tunnel", BastionIP: "2001:db8::2", HostIP: "2001:db8::1", Endpoint: "e:51820", Local: "127.0.0.1:7880"},
|
||||
{Host: "bad", State: "invalid", Problem: "no key"},
|
||||
}}, func(w *bytes.Buffer, v any) { printStatus(w, v.(statusRead)) })
|
||||
add("schedules", schedulesRead{Read: true, Schedules: []uiSchedule{{Agent: "aider", Schedule: "every day at 09:00",
|
||||
Where: "cron /etc/cron.d/a", User: "sam", Command: "aider --yes"}}}, func(w *bytes.Buffer, v any) { printSchedules(w, v.(schedulesRead)) })
|
||||
add("tool-servers", toolServersRead{Clients: []uiToolGroup{{Client: "Claude Code", Bad: 1, Servers: []uiToolServer{
|
||||
{Name: "fs", Where: "/home/sam/.claude.json", Problems: []string{"a key is written in plain text"}}, {Name: "git", Where: "x"}}}}},
|
||||
func(w *bytes.Buffer, v any) { printToolServers(w, v.(toolServersRead)) })
|
||||
add("posture", postureRead{Supported: true, OS: "linux", AsAdmin: true, Checks: &hostPosture{Ufw: "active",
|
||||
Pubwall: "accept+tdrop", Ssh22: "unscoped", F2b: "inactive", Input: "ACCEPT", Sec: 3, Load: 0.5, Cpus: 4,
|
||||
Reboot: "yes", DiskPct: 41, Jrn: 12, Puv: "ok", Timeouts: []string{"sec"}}},
|
||||
func(w *bytes.Buffer, v any) { printPosture(w, v.(postureRead)) })
|
||||
add("activity", activityRead{Activity: []activityItem{{At: now.Add(-time.Hour), Kind: "stop", Who: "Sam", What: "stopped aider", Refused: true}}},
|
||||
func(w *bytes.Buffer, v any) { printActivity(w, v.(activityRead).Activity) })
|
||||
add("approvals", approvalsRead{For: "for this machine", CodeSetUp: true, Waiting: []approvalWaiting{
|
||||
{ID: "abcd1234", AskedAt: "2026-11-01T10:00:00Z", Purpose: "local_stop", Text: "Stop aider (pid 101)."}}},
|
||||
func(w *bytes.Buffer, v any) { printApprovals(w, v.(approvalsRead)) })
|
||||
add("baseline-diff", deltaReport{Baseline: "intact", BaselineTakenAt: "2026-10-01", Coverage: "all-processes",
|
||||
Scanned: 300, Elapsed: "1.2s", ShortLived: 2, ShortLivedTotal: 5, Notes: []string{"a note"},
|
||||
Findings: []deltaEvent{{Level: "high", Kind: "program_new", Entry: baselineEntry{Kind: "process", Signature: "aider",
|
||||
User: "sam", Unit: "cron.service"}, Evidence: []evidence{{Tier: "strong", Signal: "exe", Value: "/usr/bin/aider"}}}},
|
||||
Filtered: []deltaEvent{{Level: "info"}}},
|
||||
func(w *bytes.Buffer, v any) { _ = printDelta(w, v.(deltaReport)) })
|
||||
rep := buildReport(now, now.Add(-7*24*time.Hour))
|
||||
add("report", rep, func(w *bytes.Buffer, v any) { renderReportMarkdown(w, v.(machineReport)) })
|
||||
|
||||
for name := range machineReads {
|
||||
if name == "baseline" {
|
||||
continue // TestBaselineShowPrintsTheSameAfterTheLane: it needs a file on disk
|
||||
}
|
||||
if _, ok := values[name]; !ok {
|
||||
t.Errorf("read %q has no case here", name)
|
||||
}
|
||||
}
|
||||
for name, v := range values {
|
||||
var before, after bytes.Buffer
|
||||
printers[name](&before, v)
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
back := zeroLike(v)
|
||||
if err := json.Unmarshal(b, back.ptr); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
printers[name](&after, back.get())
|
||||
if before.Len() == 0 {
|
||||
t.Errorf("%s printed nothing", name)
|
||||
}
|
||||
if before.String() != after.String() {
|
||||
t.Errorf("%s reads differently after the lane:\nbefore:\n%s\nafter:\n%s", name, before.String(), after.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaselineShowPrintsTheSameAfterTheLane(t *testing.T) {
|
||||
isolateConfigDir(t)
|
||||
writeV1Accounting(t)
|
||||
v, err := readBaseline(readOpts{})
|
||||
if err != nil || !v.Has {
|
||||
t.Fatalf("%+v %v", v, err)
|
||||
}
|
||||
var before, after bytes.Buffer
|
||||
if err := printBaseline(&before, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := json.Marshal(v)
|
||||
var back baselineRead
|
||||
if err := json.Unmarshal(b, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := printBaseline(&after, back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before.String() != after.String() || !strings.Contains(before.String(), "entries") {
|
||||
t.Errorf("before:\n%s\nafter:\n%s", before.String(), after.String())
|
||||
}
|
||||
}
|
||||
|
||||
// zeroLike is a fresh value of v's type to decode into.
|
||||
type decoded struct {
|
||||
ptr any
|
||||
get func() any
|
||||
}
|
||||
|
||||
func zeroLike(v any) decoded {
|
||||
switch v.(type) {
|
||||
case agentsRead:
|
||||
var x agentsRead
|
||||
return decoded{&x, func() any { return x }}
|
||||
case statusRead:
|
||||
var x statusRead
|
||||
return decoded{&x, func() any { return x }}
|
||||
case schedulesRead:
|
||||
var x schedulesRead
|
||||
return decoded{&x, func() any { return x }}
|
||||
case toolServersRead:
|
||||
var x toolServersRead
|
||||
return decoded{&x, func() any { return x }}
|
||||
case postureRead:
|
||||
var x postureRead
|
||||
return decoded{&x, func() any { return x }}
|
||||
case activityRead:
|
||||
var x activityRead
|
||||
return decoded{&x, func() any { return x }}
|
||||
case approvalsRead:
|
||||
var x approvalsRead
|
||||
return decoded{&x, func() any { return x }}
|
||||
case deltaReport:
|
||||
var x deltaReport
|
||||
return decoded{&x, func() any { return x }}
|
||||
case machineReport:
|
||||
var x machineReport
|
||||
return decoded{&x, func() any { return x }}
|
||||
}
|
||||
panic("no case")
|
||||
}
|
||||
|
||||
// Read from an ordinary account, the firewall's tools refuse and their
|
||||
// refusals read as "absent" or a stray word: those fields are not reported,
|
||||
// and the words say who can read them.
|
||||
func TestPostureReadWithoutTheAdministratorReportsNoFirewall(t *testing.T) {
|
||||
prevAuth, prevHave := machineAuthorityHeld, observeHave
|
||||
t.Cleanup(func() { machineAuthorityHeld, observeHave = prevAuth, prevHave })
|
||||
machineAuthorityHeld = func() bool { return false }
|
||||
observeHave = func(string) bool { return false }
|
||||
v, err := readPosture(readOpts{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !observeHostSupported {
|
||||
if v.Checks != nil {
|
||||
t.Errorf("checks off Linux: %+v", v.Checks)
|
||||
}
|
||||
return
|
||||
}
|
||||
p := v.Checks
|
||||
for _, f := range []string{p.Ufw, p.Pubwall, p.Input, p.Ssh22} {
|
||||
if f != postureNeedsAdmin {
|
||||
t.Errorf("a firewall field read without the administrator says %q: %+v", f, p)
|
||||
}
|
||||
}
|
||||
var b bytes.Buffer
|
||||
printPosture(&b, v)
|
||||
out := b.String()
|
||||
if !strings.Contains(out, "Firewall: not read; it takes the administrator") || strings.Contains(out, "ufw firewall:") {
|
||||
t.Errorf("printed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostureSaysEachCheckInWords(t *testing.T) {
|
||||
lines := strings.Join(postureLines(hostPosture{Ufw: "inactive", Pubwall: "absent", Ssh22: "scoped", F2b: "active",
|
||||
Input: "DROP", Sec: 0, Load: -1, Cpus: 2, Reboot: "no", DiskPct: -1, Jrn: -1, Puv: "absent"}, true), "\n")
|
||||
for _, want := range []string{"ufw firewall: installed, off", "iptables INPUT: refuses by default (DROP)",
|
||||
"SSH (port 22): admitted only from listed addresses", "fail2ban: running", "Security updates: none waiting",
|
||||
"System log: could not be read"} {
|
||||
if !strings.Contains(lines, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, lines)
|
||||
}
|
||||
}
|
||||
for _, not := range []string{"pubwall", "Disk", "Load", "restart", "upgrade"} {
|
||||
if strings.Contains(lines, not) {
|
||||
t.Errorf("%q printed for a check that said nothing:\n%s", not, lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNewReadCommandsRefuseStrayFlags(t *testing.T) {
|
||||
for name, cmd := range map[string]func([]string) error{"schedules": cmdSchedules, "tool-servers": cmdToolServers,
|
||||
"posture": cmdPosture, "status": cmdStatus, "baseline": cmdBaseline} {
|
||||
if err := cmd([]string{"--jsno"}); err == nil || !strings.Contains(err.Error(), "usage:") {
|
||||
t.Errorf("%s --jsno: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := cmdBaseline([]string{"record", "--now"}); err == nil || !strings.Contains(err.Error(), "usage:") {
|
||||
t.Errorf("baseline record --now: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Read through the lane, the report is built where the machine's marks are
|
||||
// and printed by an account that holds none of them: which agents are not
|
||||
// marked as yours travels with the report, so the list agrees with its own
|
||||
// summary instead of with the reading account's empty registry.
|
||||
func TestTheReportSaysWhatIsMarkedAsTheMachineSaysIt(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
mine := anObservedAgent("claude-code", "/usr/local/bin/claude", 4242)
|
||||
other := anObservedAgent("goose", "/usr/local/bin/goose", 4244)
|
||||
stubStopWorld(t, []agentObs{mine, other}, nil)
|
||||
prevHome := homeForUserFn
|
||||
t.Cleanup(func() { homeForUserFn = prevHome })
|
||||
homeForUserFn = func(string) string { return "" }
|
||||
if _, err := addSanction(mine, sanctionByLocalUI); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
b, err := json.Marshal(buildReport(now, now.Add(-time.Hour)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uiTestConfigDir(t) // the owner's own account: no registry of its own
|
||||
var back machineReport
|
||||
if err := json.Unmarshal(b, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var md bytes.Buffer
|
||||
renderReportMarkdown(&md, back)
|
||||
out := md.String()
|
||||
if !strings.Contains(out, "2 AI agent sessions running: 1 not marked as yours.") {
|
||||
t.Errorf("the summary:\n%s", out)
|
||||
}
|
||||
if n := strings.Count(out, "not marked as yours\n"); n != 1 {
|
||||
t.Errorf("%d agents listed as not marked as yours, where the machine marks one of two:\n%s", n, out)
|
||||
}
|
||||
}
|
||||
|
||||
// The delegation tag is a shared secret read out of another process's
|
||||
// environment, which the owner's account cannot read; the console only ever
|
||||
// says one is present (TestTheDelegationTagValueNeverReachesThePage). A read
|
||||
// through the lane says no more.
|
||||
func TestNoReadCarriesADelegationTag(t *testing.T) {
|
||||
const secret = "del_ThisIsTheHostsSecretTag"
|
||||
uiTestConfigDir(t)
|
||||
tagged := anObservedAgent("claude-code", "/usr/local/bin/claude", 4242)
|
||||
tagged.DelegationToken = secret
|
||||
stubStopWorld(t, []agentObs{tagged}, nil)
|
||||
prevHome := homeForUserFn
|
||||
t.Cleanup(func() { homeForUserFn = prevHome })
|
||||
homeForUserFn = func(string) string { return "" }
|
||||
laneOwner(t, 1000)
|
||||
for _, read := range []string{"agents", "report"} {
|
||||
r := serveRead(1000, "sam", agentAskRequest{Op: "read", Read: read, Prompts: true})
|
||||
if !r.OK || !strings.Contains(string(r.View), "4242") {
|
||||
t.Fatalf("%s: %+v", read, r)
|
||||
}
|
||||
if strings.Contains(string(r.View), secret) {
|
||||
t.Errorf("%s through the lane carries the delegation tag's value", read)
|
||||
}
|
||||
}
|
||||
// Nor the administrator's own run, and not the report file either: it is
|
||||
// written to be handed to someone.
|
||||
if v, err := readAgents(readOpts{}); err != nil || len(v.Agents) != 1 || v.Agents[0].DelegationToken != "" {
|
||||
t.Errorf("readAgents for the administrator: %+v %v", v, err)
|
||||
}
|
||||
if r, err := readReport(readOpts{}); err != nil || len(r.Agents) != 1 || r.Agents[0].DelegationToken != "" {
|
||||
t.Errorf("readReport for the administrator: %+v %v", r, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The skill an agent reads names the key tool-servers --json puts each
|
||||
// server's problems under; a key the JSON does not have reads as none.
|
||||
func TestTheSkillNamesTheKeyToolServersPrints(t *testing.T) {
|
||||
skill, err := os.ReadFile(filepath.Join("..", "docs", "skills", "bastion.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := regexp.MustCompile("what is wrong with each \\(`([A-Za-z_]+)`\\)").FindSubmatch(skill)
|
||||
if m == nil {
|
||||
t.Fatal("the skill no longer names the key tool-servers --json prints problems under")
|
||||
}
|
||||
b, _ := json.Marshal(toolServersRead{Clients: []uiToolGroup{{Client: "c", Servers: []uiToolServer{
|
||||
{Name: "fs", Problems: []string{"a key is written in plain text"}}}}}})
|
||||
if !strings.Contains(string(b), `"`+string(m[1])+`":["a key`) {
|
||||
t.Errorf("the skill says %q; tool-servers --json prints %s", m[1], b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package main
|
||||
|
||||
// report.go — `privatae-bastion report [--since 7d] [--json] [--out FILE|-]`
|
||||
// and the console's Write report: what this machine's bastion knows, as one
|
||||
// Markdown file a person can read, keep, or hand to someone (plan Addendum 4,
|
||||
// step 5; M0-T5). No model writes any of it: every sentence is built here
|
||||
// from what the bastion already reads, the same builders the console uses.
|
||||
//
|
||||
// Sections: the agents running now; scheduled jobs that start an agent; MCP
|
||||
// tool servers and their problems; what runs that the baseline does not
|
||||
// hold; what happened in the window. A summary of the problems leads it.
|
||||
//
|
||||
// Left out on purpose: each session's last prompt (what a person typed into
|
||||
// another session), and any secret: tool-server commands and scheduled
|
||||
// commands are redacted before they get here.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type reportBaseline struct {
|
||||
Has bool `json:"has"`
|
||||
TakenAt string `json:"taken_at,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Compared bool `json:"compared"`
|
||||
Why string `json:"why_not_compared,omitempty"`
|
||||
Findings []string `json:"findings,omitempty"`
|
||||
}
|
||||
|
||||
type machineReport struct {
|
||||
Machine string `json:"machine"`
|
||||
Generated time.Time `json:"generated"`
|
||||
Since time.Time `json:"since"`
|
||||
Version string `json:"bastion_version"`
|
||||
Summary []string `json:"summary"`
|
||||
Agents []reportAgent `json:"agents"`
|
||||
Schedules []uiSchedule `json:"scheduled_agents"`
|
||||
SchedOK bool `json:"schedulers_read"`
|
||||
ToolServers []uiToolGroup `json:"tool_servers"`
|
||||
Baseline reportBaseline `json:"baseline"`
|
||||
Activity []activityItem `json:"activity"`
|
||||
}
|
||||
|
||||
// reportAgent is one agent in the report, with whether it is marked as
|
||||
// yours. The mark is judged where the report is built, against the machine's
|
||||
// own registry (sanction.go): a report read through the owner's lane
|
||||
// (reads.go) is printed by an account that holds no registry, and judging it
|
||||
// there listed every agent as not marked as yours under a summary that
|
||||
// counted the machine's marks.
|
||||
type reportAgent struct {
|
||||
agentView
|
||||
NotYours bool `json:"not_marked_yours,omitempty"`
|
||||
}
|
||||
|
||||
func buildReport(now, since time.Time) machineReport {
|
||||
hn, _ := os.Hostname()
|
||||
r := machineReport{Machine: hn, Generated: now, Since: since, Version: clientVersion}
|
||||
if agents, err := observedAgents(); err == nil {
|
||||
for _, v := range buildAgentViews(agents, processTree()) {
|
||||
if v.Self != nil {
|
||||
v.Self.LastPrompt = ""
|
||||
}
|
||||
v.DelegationToken = "" // a shared secret for the Host, as readAgents leaves it out
|
||||
a := reportAgent{agentView: v}
|
||||
if v.Role != "service" && v.Kind != kindApp {
|
||||
ok, _ := agentSanction(v.agentObs)
|
||||
a.NotYours = !ok
|
||||
}
|
||||
r.Agents = append(r.Agents, a)
|
||||
}
|
||||
}
|
||||
r.Schedules, r.SchedOK = collectSchedules()
|
||||
r.ToolServers = collectToolGroups()
|
||||
if f, ok, err := baselineLoad(); err == nil {
|
||||
r.Baseline.Has, r.Baseline.TakenAt, r.Baseline.Note = true, f.TakenAt, baselineIntegrityNote(f, ok)
|
||||
if machineAuthorityHeld() {
|
||||
rep := deltaPass(&digestCache{}, now, deltaRoleOperator)
|
||||
if rep.WalkFailed != "" {
|
||||
r.Baseline.Why = "the process walk failed: " + rep.WalkFailed
|
||||
} else {
|
||||
r.Baseline.Compared = true
|
||||
for _, ev := range rep.Findings {
|
||||
r.Baseline.Findings = append(r.Baseline.Findings, ev.Level+": "+deltaOneLine(ev))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
r.Baseline.Why = "comparing needs the machine's administrator; run the report with sudo"
|
||||
}
|
||||
}
|
||||
r.Activity = collectActivity(since)
|
||||
r.Summary = reportSummary(r)
|
||||
return r
|
||||
}
|
||||
|
||||
// reportSummary is the problems first, in counts a person can act on.
|
||||
func reportSummary(r machineReport) []string {
|
||||
var s []string
|
||||
sessions, notYours, promptsOff, waiting := 0, 0, 0, 0
|
||||
for _, a := range r.Agents {
|
||||
if a.Role == "service" || a.Kind != kindAgent {
|
||||
continue
|
||||
}
|
||||
sessions++
|
||||
if a.NotYours {
|
||||
notYours++
|
||||
}
|
||||
if a.Self != nil && a.Self.PermissionMode == "bypassPermissions" {
|
||||
promptsOff++
|
||||
}
|
||||
if a.Self != nil && a.Self.WaitingFor != "" {
|
||||
waiting++
|
||||
}
|
||||
}
|
||||
line := fmt.Sprintf("%s running", plural(sessions, "AI agent session", "AI agent sessions"))
|
||||
var extra []string
|
||||
if notYours > 0 {
|
||||
extra = append(extra, fmt.Sprintf("%d not marked as yours", notYours))
|
||||
}
|
||||
if promptsOff > 0 {
|
||||
extra = append(extra, fmt.Sprintf("%d with permission prompts off", promptsOff))
|
||||
}
|
||||
if waiting > 0 {
|
||||
extra = append(extra, fmt.Sprintf("%d waiting for a person", waiting))
|
||||
}
|
||||
if len(extra) > 0 {
|
||||
line += ": " + strings.Join(extra, ", ")
|
||||
}
|
||||
s = append(s, line+".")
|
||||
if len(r.Schedules) > 0 {
|
||||
s = append(s, plural(len(r.Schedules), "scheduled job starts", "scheduled jobs start")+" an AI agent with nobody at the keyboard.")
|
||||
}
|
||||
plain, unpinned := 0, 0
|
||||
for _, g := range r.ToolServers {
|
||||
for _, t := range g.Servers {
|
||||
for _, p := range t.Problems {
|
||||
switch {
|
||||
case strings.Contains(p, "plain text"):
|
||||
plain++
|
||||
case strings.Contains(p, "no fixed version"):
|
||||
unpinned++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if plain > 0 {
|
||||
s = append(s, plural(plain, "MCP tool server has", "MCP tool servers have")+" a key written in plain text.")
|
||||
}
|
||||
if unpinned > 0 {
|
||||
s = append(s, plural(unpinned, "MCP tool server runs", "MCP tool servers run")+" a package with no fixed version.")
|
||||
}
|
||||
switch {
|
||||
case !r.Baseline.Has:
|
||||
s = append(s, "No baseline is recorded, so nothing new on this machine can be told apart from what was always there.")
|
||||
case r.Baseline.Compared && len(r.Baseline.Findings) > 0:
|
||||
s = append(s, plural(len(r.Baseline.Findings), "thing runs", "things run")+" that the baseline does not hold.")
|
||||
case r.Baseline.Compared:
|
||||
s = append(s, "Everything running is in the baseline.")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func renderReportMarkdown(w io.Writer, r machineReport) {
|
||||
p := func(format string, a ...any) { fmt.Fprintf(w, format, a...) }
|
||||
p("# AI agents on %s\n\n", mdText(r.Machine))
|
||||
p("Written %s by privatae-bastion %s. Activity covers %s to now. No AI model wrote any of this report.\n\n",
|
||||
r.Generated.Format("2006-01-02 15:04 MST"), mdText(r.Version), r.Since.Format("2006-01-02 15:04"))
|
||||
p("## Summary\n\n")
|
||||
for _, l := range r.Summary {
|
||||
p("- %s\n", mdText(l))
|
||||
}
|
||||
p("\n## Agents running now\n\n")
|
||||
if len(r.Agents) == 0 {
|
||||
p("None.\n")
|
||||
}
|
||||
for _, a := range r.Agents {
|
||||
if a.Role == "service" {
|
||||
p("- **%s background service** (pid %d): starts background sessions; not a session itself.\n",
|
||||
mdText(familyLabel(a.Signature)), a.PID)
|
||||
continue
|
||||
}
|
||||
l := agentLinesOf(a.agentView, r.Generated)
|
||||
p("- **%s**: %s\n", mdText(familyLabel(a.Signature)), mdText(l.Head))
|
||||
if l.State != "" {
|
||||
p(" - %s\n", mdText(l.State))
|
||||
}
|
||||
if l.Where != "" {
|
||||
p(" - %s · pid %d\n", mdText(l.Where), a.PID)
|
||||
}
|
||||
if len(a.RunningNow) > 0 {
|
||||
p(" - running now: `%s`\n", mdCode(strings.Join(a.RunningNow, "; ")))
|
||||
}
|
||||
if len(a.ToolServers) > 0 {
|
||||
p(" - tool servers: %s\n", mdText(strings.Join(a.ToolServers, ", ")))
|
||||
}
|
||||
if len(a.LLMEndpoints) > 0 {
|
||||
p(" - talks to: %s\n", mdText(strings.Join(a.LLMEndpoints, ", ")))
|
||||
}
|
||||
if a.NotYours {
|
||||
p(" - not marked as yours\n")
|
||||
}
|
||||
}
|
||||
p("\nNames, titles and statuses are what each agent says about itself.\n")
|
||||
p("\n## Scheduled jobs that start an agent\n\n")
|
||||
switch {
|
||||
case !r.SchedOK:
|
||||
p("This platform's schedulers could not be read, so this section is empty because nothing looked.\n")
|
||||
case len(r.Schedules) == 0:
|
||||
p("None.\n")
|
||||
}
|
||||
for _, s := range r.Schedules {
|
||||
p("- **%s** %s, from %s", mdText(s.Agent), mdText(s.Schedule), mdText(s.Where))
|
||||
if s.User != "" {
|
||||
p(", as %s", mdText(s.User))
|
||||
}
|
||||
p(": `%s`\n", mdCode(s.Command))
|
||||
}
|
||||
p("\n## MCP tool servers\n\n")
|
||||
if len(r.ToolServers) == 0 {
|
||||
p("None configured in any settings file this bastion can read.\n")
|
||||
}
|
||||
for _, g := range r.ToolServers {
|
||||
p("- **%s**: %s\n", mdText(g.Client), plural(len(g.Servers), "tool server", "tool servers"))
|
||||
for _, t := range g.Servers {
|
||||
if len(t.Problems) == 0 {
|
||||
p(" - %s: no problems found\n", mdText(t.Name))
|
||||
continue
|
||||
}
|
||||
for _, pr := range t.Problems {
|
||||
p(" - %s: %s\n", mdText(t.Name), mdText(pr))
|
||||
}
|
||||
}
|
||||
}
|
||||
p("\n## Changes since the baseline\n\n")
|
||||
switch {
|
||||
case !r.Baseline.Has:
|
||||
p("No baseline is recorded. Record one on the console's This machine page, or with " +
|
||||
"`sudo -H privatae-bastion observe:baseline`.\n")
|
||||
default:
|
||||
p("Baseline recorded %s.", mdText(r.Baseline.TakenAt))
|
||||
if r.Baseline.Note != "" {
|
||||
p(" **%s**", mdText(r.Baseline.Note))
|
||||
}
|
||||
p("\n\n")
|
||||
if !r.Baseline.Compared {
|
||||
p("Not compared: %s.\n", mdText(r.Baseline.Why))
|
||||
} else if len(r.Baseline.Findings) == 0 {
|
||||
p("Nothing running is outside the baseline.\n")
|
||||
}
|
||||
for _, f := range r.Baseline.Findings {
|
||||
p("- %s\n", mdText(f))
|
||||
}
|
||||
}
|
||||
p("\n## Activity\n\n")
|
||||
if len(r.Activity) == 0 {
|
||||
p("Nothing happened in this period.\n")
|
||||
}
|
||||
for _, it := range r.Activity {
|
||||
who := ""
|
||||
if it.Who != "" {
|
||||
who = it.Who + " "
|
||||
}
|
||||
refused := ""
|
||||
if it.Refused {
|
||||
refused = " (refused)"
|
||||
}
|
||||
p("- %s: %s%s\n", it.At.Local().Format("Jan 2 15:04"), mdText(who+it.What), refused)
|
||||
}
|
||||
}
|
||||
|
||||
// mdText keeps agent- and file-written text from becoming Markdown: no
|
||||
// links, images, emphasis or HTML can be smuggled in through a session title.
|
||||
func mdText(s string) string {
|
||||
r := strings.NewReplacer(`\`, `\\`, "`", "\\`", "*", `\*`, "_", `\_`, "[", `\[`, "]", `\]`,
|
||||
"<", "<", ">", ">", "!", `\!`, "#", `\#`, "|", `\|`)
|
||||
return r.Replace(cleanText(s, 400))
|
||||
}
|
||||
|
||||
func mdCode(s string) string { return strings.ReplaceAll(cleanText(s, 400), "`", "'") }
|
||||
|
||||
const reportUsage = "usage: privatae-bastion report [--since 7d] [--json] [--out FILE | --out -]"
|
||||
|
||||
// readReport is the report itself, built where the machine's state is.
|
||||
func readReport(o readOpts) (machineReport, error) {
|
||||
since, err := o.since(7 * 24 * time.Hour)
|
||||
if err != nil {
|
||||
return machineReport{}, err
|
||||
}
|
||||
now := time.Now()
|
||||
return buildReport(now, now.Add(-since)), nil
|
||||
}
|
||||
|
||||
func cmdReport(args []string) error {
|
||||
sinceFlag, asJSON, out := "", false, ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch {
|
||||
case args[i] == "--json":
|
||||
asJSON = true
|
||||
case args[i] == "--since" && i+1 < len(args):
|
||||
if _, err := parseSince(args[i+1]); err != nil {
|
||||
return err
|
||||
}
|
||||
sinceFlag, i = args[i+1], i+1
|
||||
case args[i] == "--out" && i+1 < len(args):
|
||||
out, i = args[i+1], i+1
|
||||
default:
|
||||
return errors.New(reportUsage)
|
||||
}
|
||||
}
|
||||
r, err := readMachine("report", readOpts{Since: sinceFlag}, readReport)
|
||||
if err != nil {
|
||||
return laneRefusalError("report", err)
|
||||
}
|
||||
if out == "-" {
|
||||
// To standard output, for an agent that reads it rather than keeps it.
|
||||
if asJSON {
|
||||
return printJSON(os.Stdout, r)
|
||||
}
|
||||
renderReportMarkdown(os.Stdout, r)
|
||||
return nil
|
||||
}
|
||||
now := r.Generated.Local()
|
||||
if out == "" {
|
||||
d, err := configDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ext := ".md"
|
||||
if asJSON {
|
||||
ext = ".json"
|
||||
}
|
||||
out = filepath.Join(d, "reports", "report-"+now.Format("2006-01-02-150405")+ext)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
fh, err := os.OpenFile(out, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if asJSON {
|
||||
enc := json.NewEncoder(fh)
|
||||
enc.SetIndent("", " ")
|
||||
err = enc.Encode(r)
|
||||
} else {
|
||||
renderReportMarkdown(fh, r)
|
||||
}
|
||||
if cerr := fh.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("report written to", out)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Agent-written text cannot become Markdown in the report: no link, image,
|
||||
// emphasis or HTML through a session title.
|
||||
func TestReportTextCannotBecomeMarkdown(t *testing.T) {
|
||||
got := mdText("[click](http://evil.example) <img src=x> **loud** `x` # h")
|
||||
for _, bad := range []string{"[click](", "<img", "**loud**", "`x`"} {
|
||||
if strings.Contains(got, bad) {
|
||||
t.Errorf("%q survived in %q", bad, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheReportLeadsWithProblemsAndLeavesOutPrompts(t *testing.T) {
|
||||
homeTestWorld(t, true)
|
||||
now := time.Now()
|
||||
var b bytes.Buffer
|
||||
renderReportMarkdown(&b, buildReport(now, now.Add(-7*24*time.Hour)))
|
||||
md := b.String()
|
||||
for _, want := range []string{"# AI agents on ", "## Summary",
|
||||
"1 AI agent session running: 1 not marked as yours, 1 with permission prompts off, 1 waiting for a person.",
|
||||
"No baseline is recorded", "## Agents running now", "Claude Code background service",
|
||||
"No AI model wrote any of this report."} {
|
||||
if !strings.Contains(md, want) {
|
||||
t.Errorf("the report does not say %q:\n%s", want, md)
|
||||
}
|
||||
}
|
||||
if strings.Contains(md, "PRIVATE PROMPT TEXT") {
|
||||
t.Error("a session's last prompt is in the report")
|
||||
}
|
||||
if strings.Contains(md, "<script>") {
|
||||
t.Error("an agent's title went into the report as markup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheConsoleHandsTheReportOverAsAFile(t *testing.T) {
|
||||
homeTestWorld(t, true)
|
||||
u := uiTestServer(t)
|
||||
w := uiPost(t, u.handler(), "/machine/report", url.Values{}, uiSignedIn(t, u))
|
||||
if cd := w.Header().Get("Content-Disposition"); !strings.HasPrefix(cd, "attachment;") {
|
||||
t.Errorf("Content-Disposition %q", cd)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "## Summary") {
|
||||
t.Errorf("body %q", firstLines(w.Body.String()))
|
||||
}
|
||||
}
|
||||
+176
-53
@@ -81,19 +81,22 @@ func cmdRun() error {
|
||||
recoverFrozenTrees()
|
||||
defer lock.release()
|
||||
|
||||
// Write the machine marker BEFORE the not-paired return below. A fresh box is
|
||||
// `enable --now` at install and crash-loops here until it is paired — and that
|
||||
// is exactly the window in which a `sudo`-without-`-H` pair happens. A marker
|
||||
// written after this return does not exist when it is needed.
|
||||
// Write the machine marker before any pairing is read. A fresh box is
|
||||
// `enable --now` at install, and the window before its first pairing is
|
||||
// exactly when a `sudo`-without-`-H` pair happens.
|
||||
_ = maybeWriteMarker()
|
||||
|
||||
// NO HOST IS A STEADY STATE, NOT AN ERROR. The bastion watches the AI
|
||||
// agents on this machine with or without a Host: the console, the agents
|
||||
// page, the baseline and the report all read this machine and nothing else.
|
||||
// It used to exit here ("not paired"), so a fresh install crash-looped under
|
||||
// its service manager until it was paired, and a machine nobody wanted to
|
||||
// connect to a Host had no bastion at all. superviseLoop starts a pairing
|
||||
// written later without a restart.
|
||||
cfgs, err := loadPairings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cfgs) == 0 {
|
||||
return fmt.Errorf("not paired — run `privatae-bastion pair <token>` first")
|
||||
}
|
||||
|
||||
// Clear any leftover from a prior self-update swap (the superseded binary can't
|
||||
// delete itself while running, so it's removed on the next start).
|
||||
@@ -137,15 +140,19 @@ func cmdRun() error {
|
||||
started++
|
||||
}
|
||||
}
|
||||
if started == 0 {
|
||||
for _, cfg := range cfgs {
|
||||
if cfg.invalid != "" {
|
||||
return fmt.Errorf("no pairing can be served — %s", cfg.invalid)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("no active pairing — every pairing is pending or revoked; the anchor host must approve one")
|
||||
switch {
|
||||
case len(cfgs) == 0:
|
||||
fmt.Println("no Host connected: watching this machine on its own. " +
|
||||
"Open the console with `privatae-bastion ui`.")
|
||||
case started == 0:
|
||||
// Pending, revoked or invalid pairings each said why in startPairing.
|
||||
// The machine is still watched; a pairing that becomes active is
|
||||
// started by superviseLoop.
|
||||
fmt.Println("no pairing is active yet: watching this machine on its own until one is.")
|
||||
}
|
||||
go superviseLoop(ctx)
|
||||
// The machine owner's approved stops (stop_local.go), Host or no Host.
|
||||
go localStopLoop(ctx)
|
||||
// The local UI (ui.go): ONE per machine, not one per pairing, because it
|
||||
// shows the whole box. Started last so it renders a daemon that is already
|
||||
// up, and non-fatal by construction — a machine that cannot bind the UI port
|
||||
@@ -1059,11 +1066,130 @@ func bastionListening(addr string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func cmdStatus() error {
|
||||
const statusUsage = "usage: privatae-bastion status [--json]"
|
||||
|
||||
// statusRead is what `status` says: whether the bastion is serving, and
|
||||
// where each pairing's lanes run.
|
||||
type statusRead struct {
|
||||
Version string `json:"version"`
|
||||
// Running: "yes", "partly" (some pairings answer) or "no".
|
||||
Running string `json:"running"`
|
||||
// NotAnswering: the machine's bastion is a system service and nothing
|
||||
// answered this account on its socket; why.
|
||||
NotAnswering string `json:"not_answering,omitempty"`
|
||||
Pairings []statusPairing `json:"pairings"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Anchor *statusAnchor `json:"anchor,omitempty"`
|
||||
}
|
||||
|
||||
type statusPairing struct {
|
||||
Host string `json:"host"`
|
||||
State string `json:"state"`
|
||||
Problem string `json:"problem,omitempty"`
|
||||
// Transport: "local" (a Host on this machine, over its socket), "tcp"
|
||||
// (a Host with no platform, over TCP) or "tunnel" (WireGuard).
|
||||
Transport string `json:"transport,omitempty"`
|
||||
Lanes string `json:"lanes,omitempty"`
|
||||
Runs string `json:"runs,omitempty"`
|
||||
BastionIP string `json:"bastion_tunnel_ip,omitempty"`
|
||||
HostIP string `json:"host_tunnel_ip,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
Local string `json:"local"`
|
||||
Answering bool `json:"answering"`
|
||||
}
|
||||
|
||||
type statusAnchor struct {
|
||||
Host string `json:"host"`
|
||||
Owner string `json:"owner"`
|
||||
Since string `json:"since"`
|
||||
}
|
||||
|
||||
func readStatus(readOpts) (statusRead, error) {
|
||||
cfgs, err := loadPairings()
|
||||
if err != nil || len(cfgs) == 0 {
|
||||
fmt.Println("not paired — run `privatae-bastion pair <token>`")
|
||||
return nil
|
||||
if err != nil {
|
||||
return statusRead{}, fmt.Errorf("this machine's pairings cannot be read: %w", err)
|
||||
}
|
||||
s := statusRead{Version: clientVersion, Running: "no", Pairings: []statusPairing{}}
|
||||
if len(cfgs) == 0 {
|
||||
// With no Host the only thing listening is the console, so that is
|
||||
// what says whether the daemon is up.
|
||||
if uiDaemonListening() {
|
||||
s.Running = "yes"
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
running := 0
|
||||
for _, cfg := range cfgs {
|
||||
p := statusPairing{Host: cfg.HostName, State: cfg.stateName(), Local: cfg.ListenAddr,
|
||||
Answering: bastionListening(cfg.ListenAddr)}
|
||||
if p.Answering {
|
||||
running++
|
||||
}
|
||||
switch {
|
||||
case cfg.invalid != "":
|
||||
p.Problem = cfg.invalid
|
||||
case cfg.unixTransport():
|
||||
p.Transport, p.Lanes, p.Runs = "local", cfg.LaneSocket, cfg.RunsDir
|
||||
case cfg.tcpTransport():
|
||||
p.Transport, p.Lanes = "tcp", cfg.LaneAddr
|
||||
default:
|
||||
p.Transport, p.BastionIP, p.HostIP, p.Endpoint = "tunnel", cfg.BastionTunnelIP, cfg.HostTunnelIP, cfg.Endpoint
|
||||
}
|
||||
s.Pairings = append(s.Pairings, p)
|
||||
}
|
||||
switch {
|
||||
case running == len(cfgs):
|
||||
s.Running = "yes"
|
||||
case running > 0:
|
||||
s.Running = "partly"
|
||||
}
|
||||
s.Mode = boxMode()
|
||||
if a := currentAnchor(); a != nil {
|
||||
s.Anchor = &statusAnchor{Host: a.AnchorHost, Owner: a.Owner, Since: a.ClaimedAt}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func cmdStatus(args []string) error {
|
||||
asJSON, err := jsonOnly(statusUsage, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s, err := readMachine("status", readOpts{}, readStatus)
|
||||
var le *laneError
|
||||
if errors.As(err, &le) && le.down {
|
||||
// Installed for the whole machine, and nothing answers: that is
|
||||
// the status, not a failure to find it.
|
||||
s, err = statusRead{Version: clientVersion, Running: "no", NotAnswering: le.why, Pairings: []statusPairing{}}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return laneRefusalError("status", err)
|
||||
}
|
||||
if asJSON {
|
||||
return printJSON(os.Stdout, s)
|
||||
}
|
||||
printStatus(os.Stdout, s)
|
||||
return nil
|
||||
}
|
||||
|
||||
func printStatus(w io.Writer, s statusRead) {
|
||||
if s.NotAnswering != "" {
|
||||
check := "privatae-bastion service status"
|
||||
if runtime.GOOS == "linux" {
|
||||
check = "systemctl status privatae-bastion"
|
||||
}
|
||||
fmt.Fprintf(w, "NOT ANSWERING — this machine's bastion runs for the whole machine, as a system service, "+
|
||||
"and %s.\nSee whether the service runs: %s\n", s.NotAnswering, check)
|
||||
return
|
||||
}
|
||||
if len(s.Pairings) == 0 {
|
||||
if s.Running == "yes" {
|
||||
fmt.Fprintln(w, "RUNNING on its own: no Host is connected. It watches the AI agents on this "+
|
||||
"machine; open the console with `privatae-bastion ui`.")
|
||||
} else {
|
||||
fmt.Fprintf(w, "NOT RUNNING, and no Host is connected. Start it with `%s`.\n", runCommandHint())
|
||||
}
|
||||
return
|
||||
}
|
||||
// IS IT RUNNING, AND WHERE — the first thing status should answer and the
|
||||
// one thing it did not.
|
||||
@@ -1074,54 +1200,51 @@ func cmdStatus() error {
|
||||
// a self-update the bastion has NO console at all — it is serving, and there
|
||||
// is nothing on screen that says so. status printed pairings and mode and
|
||||
// never once said whether a daemon was alive.
|
||||
running := 0
|
||||
for _, cfg := range cfgs {
|
||||
if bastionListening(cfg.ListenAddr) {
|
||||
running++
|
||||
served := 0
|
||||
for _, p := range s.Pairings {
|
||||
if p.Answering {
|
||||
served++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case running == len(cfgs):
|
||||
fmt.Printf("RUNNING — serving %d host(s) on this machine.\n", len(cfgs))
|
||||
case running > 0:
|
||||
fmt.Printf("PARTLY RUNNING — %d of %d hosts are being served; the rest are not answering.\n",
|
||||
running, len(cfgs))
|
||||
switch s.Running {
|
||||
case "yes":
|
||||
fmt.Fprintf(w, "RUNNING — serving %d host(s) on this machine.\n", len(s.Pairings))
|
||||
case "partly":
|
||||
fmt.Fprintf(w, "PARTLY RUNNING — %d of %d hosts are being served; the rest are not answering.\n",
|
||||
served, len(s.Pairings))
|
||||
default:
|
||||
fmt.Printf("NOT RUNNING — nothing is serving these pairings. Start it with `%s`.\n", runCommandHint())
|
||||
fmt.Fprintf(w, "NOT RUNNING — nothing is serving these pairings. Start it with `%s`.\n", runCommandHint())
|
||||
}
|
||||
if running > 0 && runtime.GOOS == "windows" {
|
||||
fmt.Println(" (No window? After a self-update it restarts itself detached, so it")
|
||||
fmt.Println(" has no console. Task Manager → privatae-bastion.exe is where it lives.)")
|
||||
if served > 0 && runtime.GOOS == "windows" {
|
||||
fmt.Fprintln(w, " (No window? After a self-update it restarts itself detached, so it")
|
||||
fmt.Fprintln(w, " has no console. Task Manager → privatae-bastion.exe is where it lives.)")
|
||||
}
|
||||
fmt.Println()
|
||||
for _, cfg := range cfgs {
|
||||
if cfg.invalid != "" {
|
||||
fmt.Printf("paired with: %s [%s]\n problem: %s\n", cfg.HostName, cfg.stateName(), cfg.invalid)
|
||||
fmt.Fprintln(w)
|
||||
for _, p := range s.Pairings {
|
||||
if p.Problem != "" {
|
||||
fmt.Fprintf(w, "paired with: %s [%s]\n problem: %s\n", p.Host, p.State, p.Problem)
|
||||
continue
|
||||
}
|
||||
live := "not answering"
|
||||
if bastionListening(cfg.ListenAddr) {
|
||||
if p.Answering {
|
||||
live = "answering"
|
||||
}
|
||||
if cfg.unixTransport() {
|
||||
fmt.Printf("paired with: %s [%s]\n lanes: %s (a Host on this machine)\n runs: %s\n local: http://%s (%s)\n",
|
||||
cfg.HostName, cfg.stateName(), cfg.LaneSocket, cfg.RunsDir, cfg.ListenAddr, live)
|
||||
continue
|
||||
switch p.Transport {
|
||||
case "local":
|
||||
fmt.Fprintf(w, "paired with: %s [%s]\n lanes: %s (a Host on this machine)\n runs: %s\n local: http://%s (%s)\n",
|
||||
p.Host, p.State, p.Lanes, p.Runs, p.Local, live)
|
||||
case "tcp":
|
||||
fmt.Fprintf(w, "paired with: %s [%s]\n lanes: %s (a Host over TCP, name %s)\n runs: none over TCP\n local: http://%s (%s)\n",
|
||||
p.Host, p.State, p.Lanes, tcpServerName, p.Local, live)
|
||||
default:
|
||||
fmt.Fprintf(w, "paired with: %s [%s]\n tunnel: %s -> %s via %s\n local: http://%s (%s)\n",
|
||||
p.Host, p.State, p.BastionIP, p.HostIP, p.Endpoint, p.Local, live)
|
||||
}
|
||||
if cfg.tcpTransport() {
|
||||
fmt.Printf("paired with: %s [%s]\n lanes: %s (a Host over TCP, name %s)\n runs: none over TCP\n local: http://%s (%s)\n",
|
||||
cfg.HostName, cfg.stateName(), cfg.LaneAddr, tcpServerName, cfg.ListenAddr, live)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("paired with: %s [%s]\n tunnel: %s -> %s via %s\n local: http://%s (%s)\n",
|
||||
cfg.HostName, cfg.stateName(), cfg.BastionTunnelIP, cfg.HostTunnelIP, cfg.Endpoint,
|
||||
cfg.ListenAddr, live)
|
||||
}
|
||||
fmt.Printf("mode: %s\n", boxMode())
|
||||
if a := currentAnchor(); a != nil {
|
||||
fmt.Printf("anchor: %s (owner %q, since %s)\n", a.AnchorHost, a.Owner, a.ClaimedAt)
|
||||
fmt.Fprintf(w, "mode: %s\n", s.Mode)
|
||||
if a := s.Anchor; a != nil {
|
||||
fmt.Fprintf(w, "anchor: %s (owner %q, since %s)\n", a.Host, a.Owner, a.Since)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ollamaModels reports whether a local ollama with the embeddings API answers
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// serveConsoleAt runs a console with `secret` on a fresh loopback port and
|
||||
// records that port in this account's ui.addr, as a running daemon does.
|
||||
func serveConsoleAt(t *testing.T, secret string) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, err := newUIServer(fmt.Sprint(ln.Addr().(*net.TCPAddr).Port), secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &http.Server{Handler: u.handler()}
|
||||
go func() { _ = h.Serve(ln) }()
|
||||
t.Cleanup(func() { _ = h.Close() })
|
||||
p, err := uiAddrPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(ln.Addr().String()), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// With no Host, `status` decides "running" from the console. A console that
|
||||
// answers on the recorded port but belongs to another account's bastion (the
|
||||
// system service beside a person's own, say) is not this one running.
|
||||
func TestStatusWithNoHostCountsOnlyThisAccountsOwnConsole(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
if _, err := loadOrCreateUISecret(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serveConsoleAt(t, "another-accounts-secret-aaaaaaaaaaaaaaaaaaaa")
|
||||
if uiDaemonListening() {
|
||||
t.Fatal("another account's console on the recorded port read as this bastion running")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusWithNoHostSeesItsOwnRunningConsole(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
secret, err := loadOrCreateUISecret()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serveConsoleAt(t, secret)
|
||||
if !uiDaemonListening() {
|
||||
t.Fatal("this account's own console, up and answering, did not read as running")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusWithNoHostAndNoDaemonIsNotRunning(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
if uiDaemonListening() {
|
||||
t.Fatal("nothing recorded and nothing listening read as running")
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func TestStatusReportsWhetherItIsRunning(t *testing.T) {
|
||||
t.Fatal("a real listener must read as running — this is the whole probe")
|
||||
}
|
||||
// And cmdStatus itself must not explode on either path.
|
||||
if err := cmdStatus(); err != nil {
|
||||
if err := cmdStatus(nil); err != nil {
|
||||
t.Fatalf("status must not error while running: %v", err)
|
||||
}
|
||||
_ = os.Stdout
|
||||
|
||||
+148
-39
@@ -41,7 +41,9 @@ import (
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -87,8 +89,15 @@ type stepupRequest struct {
|
||||
// Delivered: the approval id was handed to the pod once. The same ask
|
||||
// again after that is a new act (a new request), not a spent id.
|
||||
Delivered bool `json:"delivered,omitempty"`
|
||||
// DeniedAt: when the owner refused it. A refusal blocks the same ask for
|
||||
// stepupRequestTTL from HERE, not from the ask: denied 23 h after it was
|
||||
// asked, it used to block for one hour.
|
||||
DeniedAt string `json:"denied_at,omitempty"`
|
||||
}
|
||||
|
||||
// errRefusedRecently: the owner refused this same ask within the last day.
|
||||
var errRefusedRecently = errors.New("you refused this on this machine; it cannot be asked again for 24h")
|
||||
|
||||
func stepupPath(dir string) string { return filepath.Join(dir, "stepup.json") }
|
||||
func approvalsPath(dir string) string { return filepath.Join(dir, "approvals.json") }
|
||||
func requestsPath(dir string) string { return filepath.Join(dir, "stepup_requests.json") }
|
||||
@@ -408,6 +417,8 @@ func purposeFromDetails(d map[string]any) (string, error) {
|
||||
return 0
|
||||
}
|
||||
switch str("kind") {
|
||||
case localStopKind:
|
||||
return purposeForLocalStop(int(num("pid")), str("signature"))
|
||||
case "dcn_store_grant":
|
||||
sens, _ := d["sensitive"].(bool)
|
||||
return purposeForGrant(&dcnTerms{Server: str("server"), Root: str("root"), Mask: strs(d["mask"]),
|
||||
@@ -529,6 +540,12 @@ func describeRequest(r stepupRequest) string {
|
||||
return out
|
||||
}
|
||||
switch str("kind") {
|
||||
case localStopKind:
|
||||
out := "Stop " + describeStopTarget(d) + " on this machine, and every process it started."
|
||||
if by := str("asked_by"); by != "" {
|
||||
out += " Asked from " + by + "."
|
||||
}
|
||||
return out
|
||||
case "dcn_store_grant":
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Your host asks to reach the folder %s on %s\n", str("root"), str("server"))
|
||||
@@ -886,7 +903,11 @@ func requestsLoad(dir string) map[string]stepupRequest {
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
}
|
||||
for id, r := range out {
|
||||
if t, err := time.Parse(time.RFC3339, r.At); err != nil || time.Since(t) > stepupRequestTTL {
|
||||
since := r.At
|
||||
if r.State == "denied" && r.DeniedAt != "" {
|
||||
since = r.DeniedAt
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, since); err != nil || time.Since(t) > stepupRequestTTL {
|
||||
delete(out, id)
|
||||
}
|
||||
}
|
||||
@@ -928,10 +949,30 @@ func stepupNewRequest(dir, purpose string, details map[string]any) (string, erro
|
||||
// the map is walked in: two passes, deny first.
|
||||
for _, r := range m {
|
||||
if r.Purpose == purpose && r.State == "denied" {
|
||||
return "", fmt.Errorf("you refused this on this machine; it cannot be asked again for 24h")
|
||||
return "", errRefusedRecently
|
||||
}
|
||||
}
|
||||
localStop := false
|
||||
if kind, _ := details["kind"].(string); kind == localStopKind {
|
||||
localStop = true
|
||||
}
|
||||
asker, _ := details["asked_by_account"].(string)
|
||||
for id, r := range m {
|
||||
if localStop {
|
||||
// A local stop belongs to the account that asked it, which alone
|
||||
// can follow it (askstatus.go): handing another account the same
|
||||
// id told it that someone else had asked, and gave it an id it
|
||||
// could not follow. An approved stop was acted on already, so the
|
||||
// same stop asked again is a new request (it used to return the
|
||||
// approved id for a day, so a stop that failed could not be asked
|
||||
// again). The 24 h block after a deny, above, holds for every
|
||||
// account: the owner refused that stop, not that asker.
|
||||
if theirs, _ := r.Details["asked_by_account"].(string); r.Purpose == purpose &&
|
||||
r.State == "pending" && theirs == asker {
|
||||
return id, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if r.Purpose == purpose && r.State == "pending" {
|
||||
return id, nil
|
||||
}
|
||||
@@ -989,7 +1030,7 @@ func stepupApproveRequest(dir, id, code string) (string, error) {
|
||||
// A code was accepted, on whichever lane: the tray's count of wrong codes
|
||||
// starts again (approvalpipe.go).
|
||||
pipeThrottleReset(dir)
|
||||
dcnAudit(dir, "stepup_approve", 0, "", "approved: "+r.Purpose)
|
||||
dcnAuditWith(dir, "stepup_approve", 0, "", "approved: "+r.Purpose, askPartiesOf(r.Details))
|
||||
return approval, nil
|
||||
}
|
||||
|
||||
@@ -1004,10 +1045,21 @@ func stepupDenyRequest(dir, id string) error {
|
||||
if r.State != "pending" {
|
||||
return fmt.Errorf("request is %s, not pending", r.State)
|
||||
}
|
||||
r.State = "denied"
|
||||
r.State, r.DeniedAt = "denied", time.Now().UTC().Format(time.RFC3339)
|
||||
m[id] = r
|
||||
dcnAudit(dir, "stepup_deny", 0, "", "denied: "+r.Purpose)
|
||||
return requestsSave(dir, m)
|
||||
dcnAuditWith(dir, "stepup_deny", 0, "", "denied: "+r.Purpose, askPartiesOf(r.Details))
|
||||
if err := requestsSave(dir, m); err != nil {
|
||||
return err
|
||||
}
|
||||
// An agent on this machine asked, and the owner said no: a strike
|
||||
// against the account that asked (askquarantine.go). Only a local stop
|
||||
// is an agent's ask; a Host's request never strikes an account here.
|
||||
if kind, _ := r.Details["kind"].(string); kind == localStopKind {
|
||||
if acct, _ := r.Details["asked_by_account"].(string); acct != "" {
|
||||
askStrike(dir, acct, time.Now())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── the mgmt ops: stepup_request <purpose> <details_b64>, stepup_status <id> ──
|
||||
@@ -1035,6 +1087,7 @@ func handleStepupRequest(ws *websocket.Conn, dir string, args []string) {
|
||||
sendMgmtExit(ws, 1)
|
||||
return
|
||||
}
|
||||
hostAskDetails(details)
|
||||
id, err := stepupNewRequest(dir, args[0], details)
|
||||
if err != nil {
|
||||
dcnAudit(dir, "stepup_request", 0, "", "refused: "+err.Error())
|
||||
@@ -1166,7 +1219,7 @@ func enrolServiceStateRefusal(goos string, installed, authority bool, err error)
|
||||
// secret is shown only on the console this elevated process owns, never
|
||||
// written to a file or a pipe its output may have been sent to, and saved only
|
||||
// in the system service's state. Both are checked BEFORE a secret exists.
|
||||
func cmdStepupEnroll(host string, rotate, consoleOnly bool) error {
|
||||
func cmdStepupEnroll(host string, machine, rotate, consoleOnly bool) error {
|
||||
if consoleOnly {
|
||||
if !enrolConsole() {
|
||||
return fmt.Errorf("stepup-enroll --console-only shows the secret only on a console, and this one's input or output " +
|
||||
@@ -1176,16 +1229,13 @@ func cmdStepupEnroll(host string, rotate, consoleOnly bool) error {
|
||||
return fmt.Errorf("stepup-enroll --console-only enrols only into the system service's state: %s — nothing was enrolled", why)
|
||||
}
|
||||
}
|
||||
cfg, err := stepupPairing(host)
|
||||
t, err := resolveStepupTarget(host, machine)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := cfg.dcnDir()
|
||||
if dir == "" {
|
||||
return fmt.Errorf("cannot open this pairing's state directory")
|
||||
}
|
||||
dir := t.dir
|
||||
if stepupEnrolled(dir) && !rotate {
|
||||
fmt.Println("a second factor is already enrolled for", cfg.HostName, "on this machine (use --rotate to replace it)")
|
||||
fmt.Println("a second factor is already enrolled " + t.forWhom() + " (use --rotate to replace it)")
|
||||
return nil
|
||||
}
|
||||
var secret [20]byte
|
||||
@@ -1194,8 +1244,12 @@ func cmdStepupEnroll(host string, rotate, consoleOnly bool) error {
|
||||
}
|
||||
b32 := strings.TrimRight(base32.StdEncoding.EncodeToString(secret[:]), "=")
|
||||
hn, _ := os.Hostname()
|
||||
label := url.PathEscape("Privatae NAC:" + hn + " for " + cfg.HostName)
|
||||
uri := "otpauth://totp/" + label + "?secret=" + b32 + "&issuer=" + url.QueryEscape("Privatae NAC") +
|
||||
issuer, label := "Privatae NAC", "Privatae NAC:"+hn+" for "+t.host
|
||||
if t.machine() {
|
||||
// The owner's own factor: named for the machine, not for a Host.
|
||||
issuer, label = "Privatae bastion", "Privatae bastion:"+hn
|
||||
}
|
||||
uri := "otpauth://totp/" + url.PathEscape(label) + "?secret=" + b32 + "&issuer=" + url.QueryEscape(issuer) +
|
||||
"&digits=6&period=30"
|
||||
fmt.Println("Add this second factor to your authenticator app — it lives on THIS machine only:")
|
||||
fmt.Println()
|
||||
@@ -1223,6 +1277,16 @@ func cmdStepupEnroll(host string, rotate, consoleOnly bool) error {
|
||||
if err := stepupSave(dir, f); err != nil {
|
||||
return err
|
||||
}
|
||||
if t.machine() {
|
||||
// A new code at an administrator terminal is the person: the
|
||||
// console's count of wrong codes starts again (machineCodeSpend).
|
||||
stepupMu.Lock()
|
||||
pipeThrottleReset(dir)
|
||||
stepupMu.Unlock()
|
||||
fmt.Println("enrolled. This is your code as the owner of this machine: what is asked of the machine itself,")
|
||||
fmt.Println("rather than by a Host, waits for it. `privatae-bastion approvals" + t.flag() + "` shows what is waiting.")
|
||||
return nil
|
||||
}
|
||||
fmt.Println("enrolled. From now on, your host can only reach a folder or move files on this machine after")
|
||||
fmt.Println("you approve the request HERE: `privatae-bastion approvals` shows what it asks, `privatae-bastion approve` confirms it.")
|
||||
return nil
|
||||
@@ -1240,31 +1304,68 @@ func sortedPending(m map[string]stepupRequest) []string {
|
||||
}
|
||||
|
||||
// privatae-bastion approvals [--host <host>]
|
||||
func cmdStepupApprovals(host string) error {
|
||||
cfg, err := stepupPairing(host)
|
||||
// approvalsRead is `approvals`: what waits for the code of one second factor.
|
||||
type approvalsRead struct {
|
||||
For string `json:"for"` // "for this machine", or a Host's
|
||||
Host string `json:"host,omitempty"` // "" is the machine's owner
|
||||
CodeSetUp bool `json:"code_set_up"`
|
||||
Waiting []approvalWaiting `json:"waiting"`
|
||||
}
|
||||
|
||||
type approvalWaiting struct {
|
||||
ID string `json:"id"` // what approve and deny take
|
||||
AskedAt string `json:"asked_at"`
|
||||
Purpose string `json:"purpose"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func readApprovals(o readOpts) (approvalsRead, error) {
|
||||
t, err := resolveStepupTarget(o.Host, o.Machine)
|
||||
if err != nil {
|
||||
return err
|
||||
return approvalsRead{}, err
|
||||
}
|
||||
dir := cfg.dcnDir()
|
||||
if !stepupEnrolled(dir) {
|
||||
fmt.Println("no second factor enrolled on this machine for", cfg.HostName, "— run `privatae-bastion stepup-enroll` first")
|
||||
return nil
|
||||
r := approvalsRead{For: t.forWhom(), Host: t.host, CodeSetUp: stepupEnrolled(t.dir), Waiting: []approvalWaiting{}}
|
||||
if !r.CodeSetUp {
|
||||
return r, nil
|
||||
}
|
||||
stepupMu.Lock()
|
||||
m := requestsLoad(dir)
|
||||
m := requestsLoad(t.dir)
|
||||
stepupMu.Unlock()
|
||||
ids := sortedPending(m)
|
||||
if len(ids) == 0 {
|
||||
fmt.Println("nothing waiting for your approval on this machine (for", cfg.HostName+")")
|
||||
return nil
|
||||
for _, id := range sortedPending(m) {
|
||||
r.Waiting = append(r.Waiting, approvalWaiting{ID: id[:8], AskedAt: m[id].At, Purpose: m[id].Purpose,
|
||||
Text: describeRequest(m[id])})
|
||||
}
|
||||
for _, id := range ids {
|
||||
fmt.Printf("[%s] asked %s\n%s\n\n", id[:8], m[id].At, describeRequest(m[id]))
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func cmdStepupApprovals(host string, machine, asJSON bool) error {
|
||||
r, err := readMachine("approvals", readOpts{Host: host, Machine: machine}, readApprovals)
|
||||
if err != nil {
|
||||
return laneRefusalError("approvals", err)
|
||||
}
|
||||
fmt.Println("approve one: privatae-bastion approve <id> deny one: privatae-bastion deny <id>")
|
||||
if asJSON {
|
||||
return printJSON(os.Stdout, r)
|
||||
}
|
||||
printApprovals(os.Stdout, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
func printApprovals(w io.Writer, r approvalsRead) {
|
||||
flag := stepupTarget{host: r.Host}.flag()
|
||||
if !r.CodeSetUp {
|
||||
fmt.Fprintln(w, "no second factor enrolled "+r.For+" — run `privatae-bastion stepup-enroll"+flag+"` first")
|
||||
return
|
||||
}
|
||||
if len(r.Waiting) == 0 {
|
||||
fmt.Fprintln(w, "nothing waiting for your approval "+r.For)
|
||||
return
|
||||
}
|
||||
for _, q := range r.Waiting {
|
||||
fmt.Fprintf(w, "[%s] asked %s\n%s\n\n", q.ID, q.AskedAt, q.Text)
|
||||
}
|
||||
fmt.Fprintln(w, "approve one: privatae-bastion approve"+flag+" <id> deny one: privatae-bastion deny"+flag+" <id>")
|
||||
}
|
||||
|
||||
func findRequest(m map[string]stepupRequest, prefix string) (string, error) {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
@@ -1285,15 +1386,15 @@ func findRequest(m map[string]stepupRequest, prefix string) (string, error) {
|
||||
return hit, nil
|
||||
}
|
||||
|
||||
// privatae-bastion approve [--host <host>] <request id>
|
||||
func cmdStepupApprove(host, prefix string) error {
|
||||
cfg, err := stepupPairing(host)
|
||||
// privatae-bastion approve [--host <host> | --machine] <request id>
|
||||
func cmdStepupApprove(host string, machine bool, prefix string) error {
|
||||
t, err := resolveStepupTarget(host, machine)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := cfg.dcnDir()
|
||||
dir := t.dir
|
||||
if !stepupEnrolled(dir) {
|
||||
return fmt.Errorf("no second factor enrolled on this machine — run `privatae-bastion stepup-enroll` first")
|
||||
return fmt.Errorf("no second factor enrolled %s — run `privatae-bastion stepup-enroll%s` first", t.forWhom(), t.flag())
|
||||
}
|
||||
stepupMu.Lock()
|
||||
m := requestsLoad(dir)
|
||||
@@ -1314,17 +1415,21 @@ func cmdStepupApprove(host, prefix string) error {
|
||||
if _, err := stepupApproveRequest(dir, id, code); err != nil {
|
||||
return err
|
||||
}
|
||||
if t.machine() {
|
||||
fmt.Println("approved.")
|
||||
return nil
|
||||
}
|
||||
fmt.Println("approved. Your host picks this up on its next check-in (within a minute) — or click Approve again on your Host.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// privatae-bastion deny [--host <host>] <request id>
|
||||
func cmdStepupDeny(host, prefix string) error {
|
||||
cfg, err := stepupPairing(host)
|
||||
// privatae-bastion deny [--host <host> | --machine] <request id>
|
||||
func cmdStepupDeny(host string, machine bool, prefix string) error {
|
||||
t, err := resolveStepupTarget(host, machine)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := cfg.dcnDir()
|
||||
dir := t.dir
|
||||
stepupMu.Lock()
|
||||
m := requestsLoad(dir)
|
||||
stepupMu.Unlock()
|
||||
@@ -1335,6 +1440,10 @@ func cmdStepupDeny(host, prefix string) error {
|
||||
if err := stepupDenyRequest(dir, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if t.machine() {
|
||||
fmt.Println("denied.")
|
||||
return nil
|
||||
}
|
||||
fmt.Println("denied — the host is told on its next check-in")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func enrolAt(t *testing.T, console bool, serviceRefusal string, answer func(prin
|
||||
}
|
||||
printed <- b.String()
|
||||
}()
|
||||
err := cmdStepupEnroll("", false, true)
|
||||
err := cmdStepupEnroll("", false, false, true)
|
||||
_ = outW.Close()
|
||||
_ = inW.Close()
|
||||
select {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package main
|
||||
|
||||
// stepup_machine.go — the second factor that belongs to the machine's OWNER,
|
||||
// not to a Host.
|
||||
//
|
||||
// Every factor before this file was enrolled per Host pairing (stepupPairing,
|
||||
// cfg.dcnDir): it proves the person approved what THAT Host asked. A bastion
|
||||
// with no Host had no factor at all, so nothing asked of the machine itself
|
||||
// could ever be proved to come from a person: not stopping an agent from the
|
||||
// console or the command line, and not a request an agent on this machine
|
||||
// makes through the command line. The command line cannot tell a person at a
|
||||
// terminal from an agent driving one, so the code from the owner's
|
||||
// authenticator is the only proof there is.
|
||||
//
|
||||
// It is the same mechanism in a different directory: the same secret file,
|
||||
// the same request and approval stores, the same verify-and-burn
|
||||
// (stepup.go). Only where it lives changes: <configDir>/machine-factor, beside
|
||||
// the pairings rather than inside one, because it outlives every pairing.
|
||||
//
|
||||
// Which one a command uses: --machine names it; with no Host paired it is the
|
||||
// only one there is, so it is the default; otherwise a command without
|
||||
// --machine keeps meaning a Host's factor, exactly as before.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// stepupTarget is where one second factor and the requests it approves live.
|
||||
type stepupTarget struct {
|
||||
dir string
|
||||
host string // the Host pairing; "" is the machine's owner
|
||||
}
|
||||
|
||||
func (t stepupTarget) machine() bool { return t.host == "" }
|
||||
|
||||
// forWhom names the factor in a sentence: "for <host> on this machine" or
|
||||
// "for this machine".
|
||||
func (t stepupTarget) forWhom() string {
|
||||
if t.machine() {
|
||||
return "for this machine"
|
||||
}
|
||||
return "for " + t.host + " on this machine"
|
||||
}
|
||||
|
||||
// flag is what a printed command needs to reach the same factor again.
|
||||
func (t stepupTarget) flag() string {
|
||||
if t.machine() {
|
||||
return " --machine"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// machineFactorDir is the owner's factor directory, created 0700.
|
||||
func machineFactorDir() (string, error) {
|
||||
d, err := configDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Join(d, "machine-factor")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("cannot create this machine's second-factor directory: %w", err)
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// resolveStepupTarget picks the factor a command means: the owner's with
|
||||
// --machine or when no Host is paired, otherwise a Host's (stepupPairing).
|
||||
func resolveStepupTarget(host string, machine bool) (stepupTarget, error) {
|
||||
if machine && host != "" {
|
||||
return stepupTarget{}, errors.New("--machine and --host name two different second factors; give one")
|
||||
}
|
||||
if !machine && host == "" {
|
||||
if cfgs, err := loadPairings(); err == nil && len(cfgs) == 0 {
|
||||
machine = true
|
||||
}
|
||||
}
|
||||
if machine {
|
||||
dir, err := machineFactorDir()
|
||||
if err != nil {
|
||||
return stepupTarget{}, err
|
||||
}
|
||||
return stepupTarget{dir: dir}, nil
|
||||
}
|
||||
cfg, err := stepupPairing(host)
|
||||
if err != nil {
|
||||
return stepupTarget{}, err
|
||||
}
|
||||
dir := cfg.dcnDir()
|
||||
if dir == "" {
|
||||
return stepupTarget{}, fmt.Errorf("cannot open this pairing's state directory")
|
||||
}
|
||||
return stepupTarget{dir: dir, host: cfg.HostName}, nil
|
||||
}
|
||||
|
||||
// takeMachineFlag removes --machine from args.
|
||||
func takeMachineFlag(args []string) (bool, []string) {
|
||||
machine := false
|
||||
rest := make([]string, 0, len(args))
|
||||
for _, a := range args {
|
||||
if a == "--machine" {
|
||||
machine = true
|
||||
continue
|
||||
}
|
||||
rest = append(rest, a)
|
||||
}
|
||||
return machine, rest
|
||||
}
|
||||
|
||||
// machineCodeMu makes the throttle's check, the attempt and the count one
|
||||
// step, as pipeApproveMu does for the tray.
|
||||
var machineCodeMu sync.Mutex
|
||||
|
||||
// machineCodeSpend is every code typed into the console for the owner's
|
||||
// factor: spend checks the code; wrong ones are counted and throttled.
|
||||
//
|
||||
// WHY THE CONSOLE IS THROTTLED HERE when the Host approvals page is not
|
||||
// (ui.go, approve): that page's reasoning is that whoever reaches it can read
|
||||
// the seed. The owner's code exists for the opposite case, an agent driving the
|
||||
// person's browser, which on a whole-machine install cannot read
|
||||
// <configDir>/machine-factor and so can only guess. Unthrottled, a page script
|
||||
// posting guesses finds a six-digit code within hours. The tray's numbers
|
||||
// (approvalpipe.go): pipeFreeFailures free, then one per pipeSlowEvery, none
|
||||
// after pipeLockAfter until a code is accepted at an administrator terminal
|
||||
// (stepupApproveRequest resets the count).
|
||||
func machineCodeSpend(dir string, now time.Time, spend func() (string, error)) (string, error) {
|
||||
return codeSpend(dir, now, machineCodeLockedHint, spend)
|
||||
}
|
||||
|
||||
// The way back from a lockout, named for whichever factor locked: the
|
||||
// owner's, or a Host pairing's typed through the owner's lane (consolelane.go).
|
||||
const (
|
||||
machineCodeLockedHint = "At an administrator terminal, answer one waiting request (privatae-bastion approve --machine <id>) or " +
|
||||
"set up a new code (privatae-bastion stepup-enroll --machine --rotate), and it takes codes again"
|
||||
hostCodeLockedHint = "At an administrator terminal, answer one waiting request of that Host's " +
|
||||
"(sudo -H privatae-bastion approve <id>), and it takes codes again"
|
||||
)
|
||||
|
||||
// codeSpend is machineCodeSpend for any factor's directory.
|
||||
func codeSpend(dir string, now time.Time, lockedHint string, spend func() (string, error)) (string, error) {
|
||||
machineCodeMu.Lock()
|
||||
defer machineCodeMu.Unlock()
|
||||
// The daemon (the console) and a terminal (`stop`) are two processes:
|
||||
// a file lock makes read-count, try, write-count one step across both,
|
||||
// so codes typed in both at once cannot lose a failure.
|
||||
if lf, err := os.OpenFile(filepath.Join(dir, "code-spend.lock"), os.O_CREATE|os.O_RDWR, 0o600); err == nil {
|
||||
defer lf.Close() // closing releases the lock
|
||||
if err := flockFile(lf); err != nil {
|
||||
return "", fmt.Errorf("could not take the code lock: %w", err)
|
||||
}
|
||||
}
|
||||
stepupMu.Lock()
|
||||
thr := pipeThrottleLoad(dir)
|
||||
stepupMu.Unlock()
|
||||
if thr.Failures >= pipeLockAfter {
|
||||
return "", fmt.Errorf("%d wrong codes in a row were typed here, so this console takes no more codes. %s",
|
||||
thr.Failures, lockedHint)
|
||||
}
|
||||
if why := pipeThrottleRefusal(thr, now); why != "" {
|
||||
return "", errors.New(why)
|
||||
}
|
||||
id, err := spend()
|
||||
if err != nil {
|
||||
if pipeCodeError(err) {
|
||||
pipeThrottleFail(dir, now)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
stepupMu.Lock()
|
||||
pipeThrottleReset(dir)
|
||||
stepupMu.Unlock()
|
||||
return id, nil
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
machineTestSecret = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"
|
||||
hostTestSecret = "KRSXG5CTMVRXEZLUKRSXG5CTMVRXEZLU"
|
||||
)
|
||||
|
||||
func codeFor(t *testing.T, secretB32 string) string {
|
||||
t.Helper()
|
||||
raw, err := b32decode(secretB32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return hotp(raw, time.Now().Unix()/stepupPeriod)
|
||||
}
|
||||
|
||||
func enrolFactorAt(t *testing.T, dir, secretB32 string) {
|
||||
t.Helper()
|
||||
f := &stepupFile{SecretB32: secretB32, EnrolledAt: time.Now().UTC().Format(time.RFC3339), LastCounter: -1}
|
||||
if err := stepupSave(dir, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// With no Host paired, the owner's factor is the only one there is, so a
|
||||
// command that names none reaches it. It used to refuse ("not paired yet"),
|
||||
// which left a bastion on its own with no way to prove a person approved
|
||||
// anything.
|
||||
func TestWithNoHostTheSecondFactorIsTheMachineOwners(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
tg, err := resolveStepupTarget("", false)
|
||||
if err != nil {
|
||||
t.Fatalf("no Host paired and no factor reachable: %v", err)
|
||||
}
|
||||
if !tg.machine() {
|
||||
t.Fatalf("with no Host the factor named %q, not the machine's", tg.host)
|
||||
}
|
||||
d, _ := configDir()
|
||||
if tg.dir != filepath.Join(d, "machine-factor") {
|
||||
t.Errorf("the owner's factor lives at %s", tg.dir)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
st, err := os.Stat(tg.dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Mode().Perm() != 0o700 {
|
||||
t.Errorf("the owner's factor directory is %v, not private", st.Mode().Perm())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With a Host paired, a bare command keeps meaning that Host's factor, as it
|
||||
// always did; --machine reaches the owner's; both at once is refused rather
|
||||
// than guessed.
|
||||
func TestMachineAndHostFactorsAreNamedApart(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
const host = "h1.host.privatae.ai"
|
||||
givenPairing(t, host, pairingActive)
|
||||
tg, err := resolveStepupTarget("", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tg.machine() || tg.host != host {
|
||||
t.Errorf("with one Host paired a bare command reached %+v, not that Host's factor", tg)
|
||||
}
|
||||
mt, err := resolveStepupTarget("", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !mt.machine() || mt.dir == tg.dir {
|
||||
t.Errorf("--machine reached %+v, the same place as the Host's factor", mt)
|
||||
}
|
||||
if _, err := resolveStepupTarget(host, true); err == nil {
|
||||
t.Error("--machine with --host was accepted; one of them was silently ignored")
|
||||
}
|
||||
}
|
||||
|
||||
// The owner's factor and a Host's are two secrets: a code from the Host's
|
||||
// authenticator approves nothing asked of the machine, and the other way round.
|
||||
func TestAHostsCodeDoesNotApproveWhatIsAskedOfTheMachine(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
const host = "h1.host.privatae.ai"
|
||||
givenPairing(t, host, pairingActive)
|
||||
ht, err := resolveStepupTarget(host, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mt, err := resolveStepupTarget("", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enrolFactorAt(t, ht.dir, hostTestSecret)
|
||||
enrolFactorAt(t, mt.dir, machineTestSecret)
|
||||
// Refused for the right reason: both are enrolled, so "not enrolled"
|
||||
// would mean this test proves nothing.
|
||||
if _, err := stepupIssueApproval(mt.dir, codeFor(t, hostTestSecret), "test|machine"); err == nil ||
|
||||
!strings.Contains(err.Error(), "code refused") {
|
||||
t.Errorf("the Host's code against the machine's factor: %v, want code refused", err)
|
||||
}
|
||||
if _, err := stepupIssueApproval(ht.dir, codeFor(t, machineTestSecret), "test|host"); err == nil ||
|
||||
!strings.Contains(err.Error(), "code refused") {
|
||||
t.Errorf("the owner's code against the Host's factor: %v, want code refused", err)
|
||||
}
|
||||
if _, err := stepupIssueApproval(mt.dir, codeFor(t, machineTestSecret), "test|machine"); err != nil {
|
||||
t.Errorf("the owner's own code did not approve: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The console's code boxes are reached by anything driving the person's
|
||||
// browser, which on a whole-machine install cannot read the owner's secret:
|
||||
// guessing is its only way in, so wrong codes there are throttled, as on the
|
||||
// tray (approvalpipe.go). Before this, every console act took unlimited
|
||||
// guesses at a six-digit code.
|
||||
func TestWrongCodesAtTheConsoleAreThrottled(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
d, err := machineFactorDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enrolFactorAt(t, d, machineTestSecret)
|
||||
for i := 0; i < pipeFreeFailures; i++ {
|
||||
if why := markCodeRefusal("fp1", "000000"); !strings.Contains(why, "code refused") {
|
||||
t.Fatalf("wrong code %d: %q", i+1, why)
|
||||
}
|
||||
}
|
||||
right := codeFor(t, machineTestSecret)
|
||||
if why := markCodeRefusal("fp1", right); !strings.Contains(why, "wrong codes in a row") {
|
||||
t.Errorf("after %d wrong codes a mark still took a code: %q", pipeFreeFailures, why)
|
||||
}
|
||||
if why := ownerCodeRefusal(right, "local_owner|x"); !strings.Contains(why, "wrong codes in a row") {
|
||||
t.Errorf("after %d wrong codes an owner act still took a code: %q", pipeFreeFailures, why)
|
||||
}
|
||||
if msg, _ := recordBaselineFromConsole(right); !strings.Contains(msg, "wrong codes in a row") {
|
||||
t.Errorf("after %d wrong codes a baseline still took a code: %q", pipeFreeFailures, msg)
|
||||
}
|
||||
// The wait passes: the right code is taken and the count starts again.
|
||||
if _, err := machineCodeSpend(d, time.Now().Add(pipeSlowEvery+time.Second), func() (string, error) {
|
||||
return stepupIssueApproval(d, right, "local_mark|fp1")
|
||||
}); err != nil {
|
||||
t.Fatalf("after the wait the right code was refused: %v", err)
|
||||
}
|
||||
if f := pipeThrottleLoad(d); f.Failures != 0 {
|
||||
t.Errorf("an accepted code left %d failures counted", f.Failures)
|
||||
}
|
||||
// Locked after pipeLockAfter: no wait opens it.
|
||||
_ = atomicWrite(pipeThrottlePath(d), []byte(`{"failures": 100, "last": "2000-01-01T00:00:00Z"}`))
|
||||
if why := markCodeRefusal("fp1", codeFor(t, machineTestSecret)); !strings.Contains(why, "takes no more codes") {
|
||||
t.Errorf("a locked console took a code: %q", why)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
package main
|
||||
|
||||
// stop_local.go — the machine's owner stops an agent, with no Host involved.
|
||||
//
|
||||
// Until this file only a Host could stop an agent (contain.go), so a bastion
|
||||
// on its own could see an agent and not stop it. It was decided on 2026-10-30
|
||||
// that the person at the machine may, confirmed with their own second factor
|
||||
// (stepup_machine.go).
|
||||
//
|
||||
// HOW IT RUNS. Asking and doing are two processes:
|
||||
//
|
||||
// `stop <pid>` reads the agent from a fresh scan, files a request in the
|
||||
// owner's factor directory naming the pid AND the agent's
|
||||
// signature, and, at a terminal, takes the owner's code for it.
|
||||
// Run by an agent (no terminal), it only files the request; the
|
||||
// owner approves it with `approve --machine`.
|
||||
// the daemon finds the approval (localStopLoop), burns it, scans again,
|
||||
// and stops the agent only if that pid still wears the
|
||||
// signature that was approved, through stopAgentTree: the same
|
||||
// tree kill and the same spared set as a Host's order.
|
||||
//
|
||||
// The daemon does the stop, not the command, because the daemon holds the
|
||||
// machine's record of what was stopped (events.go keeps it in memory, and a
|
||||
// second process writing the same file would be overwritten) and thaws a
|
||||
// half-done kill on its next start (killtree.go).
|
||||
//
|
||||
// An approval that is more than localStopWindow old when the daemon reads it
|
||||
// is burned without acting: a stop runs when the person asked for it, never
|
||||
// later, when the daemon happens to start and the pid may be someone else's.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
localStopKind = "local_stop"
|
||||
localStopWindow = 2 * time.Minute
|
||||
localStopTick = 2 * time.Second
|
||||
localStopWait = 15 * time.Second
|
||||
localStopKeep = 50
|
||||
stopUsage = "usage: privatae-bastion stop <session name or pid> [--wait 5m] [--json] " +
|
||||
"(as `privatae-bastion agents` lists them)"
|
||||
)
|
||||
|
||||
// purposeForLocalStop binds an approval to one pid and the signature it wore
|
||||
// when the owner was asked.
|
||||
func purposeForLocalStop(pid int, signature string) (string, error) {
|
||||
if pid <= 1 {
|
||||
return "", fmt.Errorf("no pid to stop")
|
||||
}
|
||||
if strings.TrimSpace(signature) == "" {
|
||||
return "", fmt.Errorf("no agent signature to bind the stop to")
|
||||
}
|
||||
if err := purposeBad([]string{signature}, nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return localStopKind + "|" + strconv.Itoa(pid) + "|" + signature, nil
|
||||
}
|
||||
|
||||
func parseLocalStopPurpose(p string) (int, string, bool) {
|
||||
parts := strings.SplitN(p, "|", 3)
|
||||
if len(parts) != 3 || parts[0] != localStopKind {
|
||||
return 0, "", false
|
||||
}
|
||||
pid, err := strconv.Atoi(parts[1])
|
||||
if err != nil || pid <= 1 || parts[2] == "" {
|
||||
return 0, "", false
|
||||
}
|
||||
return pid, parts[2], true
|
||||
}
|
||||
|
||||
// stopRefusal is why a scan does not let pid be stopped, or "". The same lines
|
||||
// a Host's order holds (contain.go): never init or this bastion, never a
|
||||
// process the scan did not see as an agent, never an app the person is using.
|
||||
func stopRefusal(pid int, observed map[int]agentObs) string {
|
||||
if pid <= 1 || pid == os.Getpid() {
|
||||
return "that process is not an agent this machine may stop"
|
||||
}
|
||||
a, ok := observed[pid]
|
||||
if !ok {
|
||||
return "pid " + strconv.Itoa(pid) + " is not an agent this bastion sees running"
|
||||
}
|
||||
if a.Kind == kindApp {
|
||||
return a.Signature + " is an app you are running, not an agent"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// carryOutLocalStop is the daemon's half: scan, check the approved signature
|
||||
// still holds the pid, stop through the shared path, record it.
|
||||
func carryOutLocalStop(pid int, signature string) string {
|
||||
observed := observedAgentMap()
|
||||
if why := stopRefusal(pid, observed); why != "" {
|
||||
return "not stopped: " + why
|
||||
}
|
||||
if got := observed[pid].Signature; got != signature {
|
||||
return fmt.Sprintf("not stopped: pid %d is now %s, not the %s that was approved", pid, got, signature)
|
||||
}
|
||||
view, _ := processFindings()
|
||||
_, _, guard := containGuard(observed, view.protected)
|
||||
killed := stopAgentTree(pid, guard)
|
||||
if killed == 0 {
|
||||
return "not stopped: it had already exited, or it is one this bastion never stops"
|
||||
}
|
||||
sentence := stopSentence("You", signature, pid, killed-1)
|
||||
fmt.Printf("stop: terminated agent pid %d and %d child process(es) (the machine's owner)\n", pid, killed-1)
|
||||
recordLocalEvent(eventContain, sentence)
|
||||
return sentence
|
||||
}
|
||||
|
||||
// existingMachineFactorDir is the owner's factor directory if it exists. The
|
||||
// loop never creates it: no directory means no factor, so nothing to carry out.
|
||||
func existingMachineFactorDir() string {
|
||||
d, err := configDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
dir := filepath.Join(d, "machine-factor")
|
||||
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
|
||||
return ""
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func localStopLoop(ctx context.Context) {
|
||||
t := time.NewTicker(localStopTick)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
localStopOnce(existingMachineFactorDir(), time.Now())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// localStopOnce carries out every unused local-stop approval in dir, once.
|
||||
// Each is burned BEFORE it acts (stepupConsumeApproval), so a second pass, or
|
||||
// a second process, cannot act on it again.
|
||||
func localStopOnce(dir string, now time.Time) {
|
||||
if dir == "" {
|
||||
return
|
||||
}
|
||||
stepupMu.Lock()
|
||||
m := approvalsLoad(dir)
|
||||
stepupMu.Unlock()
|
||||
ids := make([]string, 0, len(m))
|
||||
for id := range m {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
a := m[id]
|
||||
if a.Used {
|
||||
continue
|
||||
}
|
||||
pid, sig, ok := parseLocalStopPurpose(a.Purpose)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := stepupConsumeApproval(dir, id, a.Purpose); err != nil {
|
||||
continue
|
||||
}
|
||||
at, err := time.Parse(time.RFC3339, a.At)
|
||||
var outcome string
|
||||
if err != nil || now.Sub(at) > localStopWindow {
|
||||
outcome = fmt.Sprintf("not stopped: the approval was more than %s old when this bastion read it; ask again",
|
||||
localStopWindow)
|
||||
} else {
|
||||
outcome = carryOutLocalStop(pid, sig)
|
||||
}
|
||||
dcnAuditWith(dir, localStopKind, 0, strconv.Itoa(pid)+" "+sig, outcome,
|
||||
askPartiesOf(requestForApproval(dir, id).Details))
|
||||
localStopRecordResult(dir, id, outcome, now)
|
||||
}
|
||||
}
|
||||
|
||||
// requestForApproval is the request an approval answered, if one did (the
|
||||
// owner at a terminal approves with no request in between).
|
||||
func requestForApproval(dir, approval string) stepupRequest {
|
||||
stepupMu.Lock()
|
||||
defer stepupMu.Unlock()
|
||||
for _, q := range requestsLoad(dir) {
|
||||
if q.Approval == approval {
|
||||
return q
|
||||
}
|
||||
}
|
||||
return stepupRequest{}
|
||||
}
|
||||
|
||||
type localStopResult struct {
|
||||
Outcome string `json:"outcome"`
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
func localStopResultsPath(dir string) string { return filepath.Join(dir, "stop_results.json") }
|
||||
|
||||
func localStopResultsLoad(dir string) map[string]localStopResult {
|
||||
out := map[string]localStopResult{}
|
||||
if b, err := os.ReadFile(localStopResultsPath(dir)); err == nil {
|
||||
_ = json.Unmarshal(b, &out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// localStopRecordResult keeps what happened to each approval for the command
|
||||
// that is waiting on it, the newest localStopKeep.
|
||||
func localStopRecordResult(dir, approval, outcome string, now time.Time) {
|
||||
stepupMu.Lock()
|
||||
defer stepupMu.Unlock()
|
||||
m := localStopResultsLoad(dir)
|
||||
m[approval] = localStopResult{Outcome: outcome, At: now.UTC().Format(time.RFC3339)}
|
||||
if len(m) > localStopKeep {
|
||||
ids := make([]string, 0, len(m))
|
||||
for id := range m {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return m[ids[i]].At < m[ids[j]].At })
|
||||
for _, id := range ids[:len(m)-localStopKeep] {
|
||||
delete(m, id)
|
||||
}
|
||||
}
|
||||
if b, err := json.MarshalIndent(m, "", " "); err == nil {
|
||||
_ = atomicWrite(localStopResultsPath(dir), b)
|
||||
}
|
||||
}
|
||||
|
||||
// cmdStop is `privatae-bastion stop <pid>`.
|
||||
func cmdStop(args []string) error {
|
||||
key, wait, asJSON, err := askFlags("stop", args)
|
||||
if err != nil {
|
||||
return errors.New(err.Error() + "\n" + stopUsage)
|
||||
}
|
||||
// An agent reading --json gets its refusal as JSON too.
|
||||
refused := func(why string) error {
|
||||
if asJSON {
|
||||
return printAsk(os.Stdout, askView{State: askRefused, Why: why}, true, false)
|
||||
}
|
||||
return errors.New("not stopped: " + why)
|
||||
}
|
||||
observed := observedAgentMap()
|
||||
pid, view, err := resolveStopTarget(strings.TrimSpace(key), observed)
|
||||
if err != nil {
|
||||
return refused(err.Error())
|
||||
}
|
||||
if why := stopRefusal(pid, observed); why != "" {
|
||||
return refused(why)
|
||||
}
|
||||
sig := observed[pid].Signature
|
||||
if asksGoThroughSock() {
|
||||
// The running bastion is not this account's (installed for the whole
|
||||
// machine): ask it over its socket, which names the asker from the
|
||||
// kernel. Only the owner answers.
|
||||
v, err := stopAskViaSock(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return finishAsk(v, wait, asJSON)
|
||||
}
|
||||
t, err := resolveStepupTarget("", true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !stepupEnrolled(t.dir) {
|
||||
return errors.New("stopping an agent needs your code as this machine's owner, and none is set up yet: " +
|
||||
"run `privatae-bastion stepup-enroll --machine`")
|
||||
}
|
||||
purpose, err := purposeForLocalStop(pid, sig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
details := map[string]any{"kind": localStopKind, "pid": strconv.Itoa(pid), "signature": sig}
|
||||
// What the owner reads beside the pid: which session this is. Display
|
||||
// only; the purpose binds the pid and the signature.
|
||||
details["agent"] = familyLabel(sig)
|
||||
if view.Folder != "" {
|
||||
details["folder"] = view.Folder
|
||||
}
|
||||
if view.Self != nil {
|
||||
details["session"] = view.Self.Name
|
||||
details["title"] = view.Self.Title
|
||||
}
|
||||
details["asked_by"] = askerOf(os.Getppid(), observed)
|
||||
if !stdinIsConsole() {
|
||||
// An agent, a script, a pipe: it may ask, never answer. It runs as
|
||||
// the account that holds this bastion's state, so no hold applies:
|
||||
// it could rewrite the files a hold is kept in.
|
||||
acct := procUser(os.Getpid())
|
||||
details["asked_by_account"] = acct
|
||||
details["asker"], details["target"] = askParties(acct, os.Getpid(), pid, observed)
|
||||
id, err := stepupNewRequest(t.dir, purpose, details)
|
||||
if errors.Is(err, errRefusedRecently) {
|
||||
return refused("the machine's owner refused this same stop within the last 24 hours, so it " +
|
||||
"cannot be asked again yet")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auditLocalStopAsk(t.dir, pid, sig, details)
|
||||
return finishAsk(askView{Request: id[:8], State: askWaiting, AskedAs: acct,
|
||||
Text: describeRequest(stepupRequest{Details: details})}, wait, asJSON)
|
||||
}
|
||||
// The owner at a terminal: the code is the approval, with no request in
|
||||
// between, so an agent's earlier ask for the same stop that the owner
|
||||
// denied (which blocks that request for a day) cannot block the owner.
|
||||
fmt.Println(describeRequest(stepupRequest{Details: details}))
|
||||
fmt.Print("Stop it? Enter the code from your authenticator (or leave empty to cancel): ")
|
||||
code, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if strings.TrimSpace(code) == "" {
|
||||
fmt.Println("cancelled — nothing stopped")
|
||||
return nil
|
||||
}
|
||||
approval, err := machineCodeSpend(t.dir, time.Now(), func() (string, error) {
|
||||
return stepupIssueApproval(t.dir, code, purpose)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !uiDaemonListening() {
|
||||
// Burn it: a stop runs now or not at all.
|
||||
_ = stepupConsumeApproval(t.dir, approval, purpose)
|
||||
return fmt.Errorf("the bastion is not running, so nothing was stopped. Start it with `%s` and ask again",
|
||||
runCommandHint())
|
||||
}
|
||||
if r, ok := waitStopResult(t.dir, approval, localStopWait); ok {
|
||||
fmt.Println(r.Outcome)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("approved, but the bastion has not carried it out after %s; `privatae-bastion status` says whether it is running. "+
|
||||
"The approval lapses %s after it was given", localStopWait, localStopWindow)
|
||||
}
|
||||
|
||||
// resolveStopTarget takes a pid or a session's name as `agents` shows it. A
|
||||
// name must match exactly one running session.
|
||||
func resolveStopTarget(key string, observed map[int]agentObs) (int, agentView, error) {
|
||||
if key == "" {
|
||||
return 0, agentView{}, errors.New(stopUsage)
|
||||
}
|
||||
list := make([]agentObs, 0, len(observed))
|
||||
for _, a := range observed {
|
||||
list = append(list, a)
|
||||
}
|
||||
views := buildAgentViews(list, processTree())
|
||||
if pid, err := strconv.Atoi(key); err == nil {
|
||||
for _, v := range views {
|
||||
if v.PID == pid {
|
||||
return pid, v, nil
|
||||
}
|
||||
}
|
||||
return pid, agentView{}, nil // stopRefusal says why
|
||||
}
|
||||
var hits []agentView
|
||||
for _, v := range views {
|
||||
if v.Self != nil && v.Self.Name == key {
|
||||
hits = append(hits, v)
|
||||
}
|
||||
}
|
||||
switch len(hits) {
|
||||
case 0:
|
||||
return 0, agentView{}, fmt.Errorf("no running session is named %q; `privatae-bastion agents` lists them", key)
|
||||
case 1:
|
||||
return hits[0].PID, hits[0], nil
|
||||
}
|
||||
return 0, agentView{}, fmt.Errorf("%d sessions are named %q; give the pid instead", len(hits), key)
|
||||
}
|
||||
|
||||
// describeStopTarget names the agent the way a person recognises it.
|
||||
func describeStopTarget(d map[string]any) string {
|
||||
str := func(k string) string { s, _ := d[k].(string); return clean(s) }
|
||||
who := str("agent")
|
||||
if who == "" {
|
||||
who = str("signature")
|
||||
}
|
||||
if n := str("session"); n != "" {
|
||||
who += " session " + n
|
||||
}
|
||||
if t := str("title"); t != "" && t != str("session") {
|
||||
who += " \"" + t + "\""
|
||||
}
|
||||
if f := str("folder"); f != "" {
|
||||
who += " in " + f
|
||||
}
|
||||
return who + " (pid " + str("pid") + ")"
|
||||
}
|
||||
|
||||
// askerOf names who ran this command: the agent among its ancestors when
|
||||
// there is one (so the owner reads "asked from Claude Code session shop-a1",
|
||||
// not a bare account), else the account at a terminal. It is what the
|
||||
// command saw; on a per-account install the asker could have written the
|
||||
// request file itself, so it is a description, not a proof.
|
||||
func askerOf(ppid int, observed map[int]agentObs) string {
|
||||
if who, ok := askingAgent(ppid, observed); ok {
|
||||
return who
|
||||
}
|
||||
if u := procUser(os.Getpid()); u != "" {
|
||||
return "a terminal, as " + u
|
||||
}
|
||||
return "a terminal"
|
||||
}
|
||||
|
||||
// askingAgent is the observed agent that pid descends from, named the way the
|
||||
// owner knows it, if there is one.
|
||||
func askingAgent(pid int, observed map[int]agentObs) (string, bool) {
|
||||
a, p, ok := askingAgentObs(pid, observed)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
v := buildAgentViews([]agentObs{a}, processTree())[0]
|
||||
who := familyLabel(a.Signature)
|
||||
if v.Self != nil && v.Self.Name != "" {
|
||||
who += " session " + v.Self.Name
|
||||
}
|
||||
return who + " (pid " + strconv.Itoa(p) + ")", true
|
||||
}
|
||||
|
||||
func stopAskViaSock(pid int) (askView, error) {
|
||||
r, err := askViaSock(agentAskRequest{Op: "stop", PID: pid})
|
||||
if err != nil {
|
||||
return askView{}, fmt.Errorf("could not reach this machine's bastion to ask (%v); `privatae-bastion status` "+
|
||||
"says whether it is running", err)
|
||||
}
|
||||
return askViewOf(r, true), nil
|
||||
}
|
||||
|
||||
// finishAsk prints a filed (or refused) ask, following it first for --wait.
|
||||
func finishAsk(v askView, wait time.Duration, asJSON bool) error {
|
||||
if wait > 0 && v.State == askWaiting {
|
||||
var err error
|
||||
if v, err = askFollow(v, wait); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return printAsk(os.Stdout, v, asJSON, wait > 0)
|
||||
}
|
||||
|
||||
// waitStopResult waits for the outcome of one approved stop. Whoever carries
|
||||
// it out (the daemon's loop, or the console handler calling localStopOnce)
|
||||
// writes the outcome; this only reads it, so a stop the loop picked up first
|
||||
// is reported, not "not carried out".
|
||||
func waitStopResult(dir, approval string, max time.Duration) (localStopResult, bool) {
|
||||
deadline := time.Now().Add(max)
|
||||
for {
|
||||
stepupMu.Lock()
|
||||
r, ok := localStopResultsLoad(dir)[approval]
|
||||
stepupMu.Unlock()
|
||||
if ok || time.Now().After(deadline) {
|
||||
return r, ok
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// stubStopWorld replaces the scan, the process tree, the accounting and the
|
||||
// kill with fixed answers and returns the list the kill appends to, so a test
|
||||
// decides who would die without signalling anything real.
|
||||
func stubStopWorld(t *testing.T, agents []agentObs, tree map[int]int) *[]int {
|
||||
t.Helper()
|
||||
prevObs, prevTree, prevKill, prevFind := observedAgents, processTree, killProcess, processFindings
|
||||
prevCwd, prevStart, prevArgs := procCwdFn, procStartedFn, procCmdArgsFn
|
||||
t.Cleanup(func() {
|
||||
observedAgents, processTree, killProcess, processFindings = prevObs, prevTree, prevKill, prevFind
|
||||
procCwdFn, procStartedFn, procCmdArgsFn = prevCwd, prevStart, prevArgs
|
||||
})
|
||||
// Nothing about the real machine's processes leaks into the decision.
|
||||
procCwdFn = func(int) string { return "" }
|
||||
procStartedFn = func(int) (time.Time, bool) { return time.Time{}, false }
|
||||
procCmdArgsFn = func(int) []string { return nil }
|
||||
observedAgents = func() ([]agentObs, error) { return agents, nil }
|
||||
processTree = func() map[int]int { return tree }
|
||||
processFindings = func() (findingView, string) { return findingView{}, "" }
|
||||
killed := &[]int{}
|
||||
killProcess = func(pid int) error { *killed = append(*killed, pid); return nil }
|
||||
return killed
|
||||
}
|
||||
|
||||
var stopTestAgents = []agentObs{
|
||||
{PID: 101, Kind: kindAgent, Signature: "claude-code"},
|
||||
{PID: 4532, Kind: kindApp, Signature: "claude-desktop"},
|
||||
}
|
||||
|
||||
func TestLocalStopPurposeBindsThePidAndTheSignature(t *testing.T) {
|
||||
p, err := purposeForLocalStop(101, "claude-code")
|
||||
if err != nil || p != "local_stop|101|claude-code" {
|
||||
t.Fatalf("purpose %q %v", p, err)
|
||||
}
|
||||
got, err := purposeFromDetails(map[string]any{"kind": localStopKind, "pid": "101", "signature": "claude-code"})
|
||||
if err != nil || got != p {
|
||||
t.Errorf("the request's details give %q (%v), the stop it approves is %q", got, err, p)
|
||||
}
|
||||
if pid, sig, ok := parseLocalStopPurpose(p); !ok || pid != 101 || sig != "claude-code" {
|
||||
t.Errorf("parse(%q) = %d %q %v", p, pid, sig, ok)
|
||||
}
|
||||
if _, err := purposeForLocalStop(101, "a|b"); err == nil {
|
||||
t.Error("a signature holding the field separator was accepted")
|
||||
}
|
||||
if _, err := purposeForLocalStop(1, "init"); err == nil {
|
||||
t.Error("a stop of pid 1 got a purpose")
|
||||
}
|
||||
}
|
||||
|
||||
// The owner's stop holds the same lines as a Host's order: never an app the
|
||||
// person is using, never something the scan did not see as an agent, never
|
||||
// init or this bastion.
|
||||
func TestLocalStopRefusesWhatAHostsOrderRefuses(t *testing.T) {
|
||||
killed := stubStopWorld(t, stopTestAgents, map[int]int{4533: 4532})
|
||||
cases := map[int]string{
|
||||
4532: "an app you are running",
|
||||
303: "not an agent this bastion sees",
|
||||
1: "not an agent this machine may stop",
|
||||
os.Getpid(): "not an agent this machine may stop",
|
||||
}
|
||||
for pid, want := range cases {
|
||||
sig := "claude-desktop"
|
||||
if got := carryOutLocalStop(pid, sig); !strings.Contains(got, want) {
|
||||
t.Errorf("stop %d: %q, want it refused as %q", pid, got, want)
|
||||
}
|
||||
}
|
||||
if len(*killed) != 0 {
|
||||
t.Errorf("refused stops signalled %v", *killed)
|
||||
}
|
||||
}
|
||||
|
||||
// Approved for one agent, carried out only on that agent: a pid that now
|
||||
// wears another signature is somebody else.
|
||||
func TestLocalStopNeedsTheSignatureThatWasApproved(t *testing.T) {
|
||||
killed := stubStopWorld(t, []agentObs{{PID: 101, Kind: kindAgent, Signature: "aider"}}, nil)
|
||||
if got := carryOutLocalStop(101, "claude-code"); !strings.Contains(got, "not the claude-code that was approved") {
|
||||
t.Errorf("a pid that changed hands: %q", got)
|
||||
}
|
||||
if len(*killed) != 0 {
|
||||
t.Errorf("signalled %v", *killed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStopTakesTheTreeAndSaysYouDidIt(t *testing.T) {
|
||||
killed := stubStopWorld(t, stopTestAgents, map[int]int{102: 101, 103: 102, 4533: 4532})
|
||||
before := len(localEventList)
|
||||
got := carryOutLocalStop(101, "claude-code")
|
||||
sort.Ints(*killed)
|
||||
if want := []int{101, 102, 103}; len(*killed) != 3 || (*killed)[0] != 101 || (*killed)[2] != 103 {
|
||||
t.Fatalf("killed %v, want %v", *killed, want)
|
||||
}
|
||||
if !strings.HasPrefix(got, "You stopped claude-code (pid 101)") {
|
||||
t.Errorf("outcome %q", got)
|
||||
}
|
||||
if len(localEventList) != before+1 || !strings.HasPrefix(localEventList[len(localEventList)-1].Text, "You stopped") {
|
||||
t.Error("the stop was not recorded on the machine")
|
||||
}
|
||||
}
|
||||
|
||||
// The daemon carries out an approval once, and never one older than the
|
||||
// window: a stop runs when the person asked for it, not when the daemon
|
||||
// happens to start.
|
||||
func TestLocalStopLoopActsOnceAndNeverLate(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
killed := stubStopWorld(t, stopTestAgents, map[int]int{102: 101})
|
||||
dir, err := machineFactorDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enrolFactorAt(t, dir, machineTestSecret)
|
||||
purpose, _ := purposeForLocalStop(101, "claude-code")
|
||||
approval, err := stepupIssueApproval(dir, codeFor(t, machineTestSecret), purpose)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if existingMachineFactorDir() != dir {
|
||||
t.Fatalf("the loop would read %q, the factor is at %q", existingMachineFactorDir(), dir)
|
||||
}
|
||||
now := time.Now()
|
||||
localStopOnce(dir, now)
|
||||
if len(*killed) != 2 {
|
||||
t.Fatalf("first pass killed %v, want the agent and its child", *killed)
|
||||
}
|
||||
localStopOnce(dir, now.Add(time.Second))
|
||||
if len(*killed) != 2 {
|
||||
t.Fatalf("the same approval acted twice: %v", *killed)
|
||||
}
|
||||
if r := localStopResultsLoad(dir)[approval]; !strings.HasPrefix(r.Outcome, "You stopped") {
|
||||
t.Errorf("the waiting command would read %q", r.Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStopLoopBurnsALateApprovalWithoutActing(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
killed := stubStopWorld(t, stopTestAgents, nil)
|
||||
dir, err := machineFactorDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enrolFactorAt(t, dir, machineTestSecret)
|
||||
purpose, _ := purposeForLocalStop(101, "claude-code")
|
||||
approval, err := stepupIssueApproval(dir, codeFor(t, machineTestSecret), purpose)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
localStopOnce(dir, time.Now().Add(localStopWindow+time.Minute))
|
||||
if len(*killed) != 0 {
|
||||
t.Fatalf("a late approval acted: %v", *killed)
|
||||
}
|
||||
if r := localStopResultsLoad(dir)[approval]; !strings.Contains(r.Outcome, "ask again") {
|
||||
t.Errorf("the late approval's outcome: %q", r.Outcome)
|
||||
}
|
||||
if a := approvalsLoad(dir)[approval]; !a.Used {
|
||||
t.Error("the late approval was left usable")
|
||||
}
|
||||
}
|
||||
|
||||
// A stop asked from the command line is an ordinary request in the owner's
|
||||
// factor: `approvals --machine` lists it in plain words.
|
||||
func TestALocalStopIsAnOrdinaryRequestOfTheMachine(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
dir, err := machineFactorDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
purpose, _ := purposeForLocalStop(101, "claude-code")
|
||||
details := map[string]any{"kind": localStopKind, "pid": "101", "signature": "claude-code"}
|
||||
id, err := stepupNewRequest(dir, purpose, details)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := requestsLoad(dir)[id]
|
||||
if r.State != "pending" || r.Purpose != purpose {
|
||||
t.Fatalf("request %+v", r)
|
||||
}
|
||||
if got := describeRequest(r); got != "Stop claude-code (pid 101) on this machine, and every process it started." {
|
||||
t.Errorf("the owner reads %q", got)
|
||||
}
|
||||
}
|
||||
+25
-6
@@ -29,13 +29,29 @@ import (
|
||||
)
|
||||
|
||||
const switchUsage = "usage: sudo -H privatae-bastion switch <host> <family> on|off\n" +
|
||||
" privatae-bastion switch --list"
|
||||
" privatae-bastion switch --list\n" +
|
||||
" sudo -H privatae-bastion switch --reset (only when the list cannot be read)"
|
||||
|
||||
// cmdSwitch parses the arguments and runs one switch, or the listing.
|
||||
// cmdSwitch parses the arguments and runs one switch, the listing, or the
|
||||
// reset of an unreadable list.
|
||||
func cmdSwitch(args []string) error {
|
||||
if len(args) == 1 && args[0] == "--list" {
|
||||
return switchList(os.Stdout)
|
||||
}
|
||||
if len(args) == 1 && args[0] == "--reset" {
|
||||
// The same authority as switching anything back on: a reset leaves
|
||||
// nothing switched off.
|
||||
if err := requireMachineAuthority("switch"); err != nil {
|
||||
return err
|
||||
}
|
||||
kept, err := localDenyReset()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("The unreadable list is kept at %s. Nothing is switched off now; "+
|
||||
"switch off again whatever you had off: switch <host> <family> off\n", kept)
|
||||
return nil
|
||||
}
|
||||
if len(args) != 3 {
|
||||
return errors.New(switchUsage)
|
||||
}
|
||||
@@ -113,10 +129,9 @@ func switchList(w io.Writer) error {
|
||||
f, readErr := localDenyLoad()
|
||||
localDenyMu.Unlock()
|
||||
if readErr != nil {
|
||||
// The same direction the page takes (localDenyIndex): say so, because
|
||||
// every family below then reads "on" while the person may believe
|
||||
// something is off.
|
||||
fmt.Fprintf(w, "warning: %v\n", readErr)
|
||||
// The gate refuses everything while the list cannot be read
|
||||
// (localDenied), so every line says off, and the warning says why.
|
||||
fmt.Fprintf(w, "warning: %s\n", localDenyUnreadableText(readErr))
|
||||
}
|
||||
names := hostNames(cfgs)
|
||||
sort.Strings(names)
|
||||
@@ -127,6 +142,10 @@ func switchList(w io.Writer) error {
|
||||
off[fam] = true
|
||||
}
|
||||
for _, fam := range fams {
|
||||
if readErr != nil {
|
||||
fmt.Fprintln(w, host+" "+fam+" off")
|
||||
continue
|
||||
}
|
||||
if off[fam] {
|
||||
line := host + " " + fam + " off"
|
||||
if at := f.At[host+"/"+fam]; at != "" {
|
||||
|
||||
+33
-5
@@ -99,17 +99,30 @@ func serviceStateEnv(goos string, installed, authority bool, systemRoot string)
|
||||
|
||||
// serviceGuardedCommands would each act on a bastion in the caller's own
|
||||
// profile. Beside a system service that is a second bastion (pair, run) or a
|
||||
// view of an empty one (status, and `switch --list`, which would print the
|
||||
// caller's own pairings and switches as though they were the machine's).
|
||||
var serviceGuardedCommands = map[string]bool{"pair": true, "run": true, "status": true, "switch": true}
|
||||
// view of an empty one (status, `switch --list`, activity and report, which
|
||||
// would print the caller's own records as though they were the machine's).
|
||||
var serviceGuardedCommands = map[string]bool{"pair": true, "run": true, "status": true, "switch": true,
|
||||
"activity": true, "report": true}
|
||||
|
||||
// serviceLaneReads are the guarded commands that, on Linux and macOS, read
|
||||
// the machine's bastion itself over its socket (reads.go) rather than a
|
||||
// bastion of the caller's own: not refused here, and refused there for any
|
||||
// account but the owner's.
|
||||
var serviceLaneReads = map[string]bool{"status": true, "activity": true, "report": true}
|
||||
|
||||
// serviceGuard refuses a guarded command from an account that does not
|
||||
// administer a machine whose bastion is a system service.
|
||||
//
|
||||
// On Linux the service is the systemd unit, and the caller says whether it is
|
||||
// there (linuxSystemUnitPresent). Before the bastion was installed for the
|
||||
// whole machine by default, Linux was left out: `status` from the person's
|
||||
// account then said NOT RUNNING and told them to `run` a second, empty
|
||||
// bastion of their own.
|
||||
func serviceGuard(goos, cmd string, installed, authority bool) error {
|
||||
if !installed || authority || !serviceGuardedCommands[cmd] {
|
||||
return nil
|
||||
}
|
||||
if goos != "darwin" && goos != "windows" {
|
||||
if serviceLaneReads[cmd] && goos != "windows" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("this machine's bastion runs as a system service, and it belongs to whoever administers the machine.\n"+
|
||||
@@ -117,6 +130,17 @@ func serviceGuard(goos, cmd string, installed, authority bool) error {
|
||||
" To work with the machine's bastion, %s", cmd, adminHowToFor(goos))
|
||||
}
|
||||
|
||||
// linuxSystemUnitPath is where the installers write the bastion's systemd unit
|
||||
// (deploy/install.sh; `npx cseries` writes the same unit).
|
||||
const linuxSystemUnitPath = "/etc/systemd/system/privatae-bastion.service"
|
||||
|
||||
// linuxSystemUnitPresent: the bastion is installed for the whole machine on
|
||||
// this Linux box. A variable so the suite decides it.
|
||||
var linuxSystemUnitPresent = func() bool {
|
||||
_, err := os.Stat(linuxSystemUnitPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// launchdPlist is the launchd job for the daemon. KeepAlive restarts it
|
||||
// whenever it exits, which is also how a self-update takes effect: update.go
|
||||
// exits after swapping the binary, exactly as it does under systemd's
|
||||
@@ -350,7 +374,11 @@ func adoptSystemServiceState() {
|
||||
|
||||
// guardServiceFork is serviceGuard for this process.
|
||||
func guardServiceFork(cmd string) error {
|
||||
return serviceGuard(runtime.GOOS, cmd, systemService.installed, systemService.authority)
|
||||
installed := systemService.installed
|
||||
if runtime.GOOS == "linux" {
|
||||
installed = linuxSystemUnitPresent()
|
||||
}
|
||||
return serviceGuard(runtime.GOOS, cmd, installed, systemService.authority)
|
||||
}
|
||||
|
||||
// cmdService is `privatae-bastion service install|uninstall|status`.
|
||||
|
||||
@@ -164,9 +164,17 @@ func TestWindowsSystemAppDataIsLocalSystemsProfile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServiceGuardRefusesAUserspaceBastionBesideTheService(t *testing.T) {
|
||||
for _, goos := range []string{"darwin", "windows"} {
|
||||
for _, cmd := range []string{"pair", "run", "status", "switch"} {
|
||||
for _, goos := range []string{"darwin", "windows", "linux"} {
|
||||
for _, cmd := range []string{"pair", "run", "status", "switch", "activity", "report"} {
|
||||
err := serviceGuard(goos, cmd, true, false)
|
||||
if (cmd == "status" || cmd == "activity" || cmd == "report") && goos != "windows" {
|
||||
// These read the machine's bastion over its socket (reads.go),
|
||||
// which answers the owner's account and refuses any other.
|
||||
if err != nil {
|
||||
t.Errorf("%s: `%s` was refused before it could ask the machine's bastion: %v", goos, cmd, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf("%s: `%s` from an ordinary account beside a system service was allowed", goos, cmd)
|
||||
continue
|
||||
@@ -178,7 +186,7 @@ func TestServiceGuardRefusesAUserspaceBastionBesideTheService(t *testing.T) {
|
||||
if goos == "windows" && strings.Contains(msg, "sudo") {
|
||||
t.Errorf("windows: the refusal tells a Windows user to run sudo: %s", msg)
|
||||
}
|
||||
if goos == "darwin" && !strings.Contains(msg, "sudo -H") {
|
||||
if goos != "windows" && !strings.Contains(msg, "sudo -H") {
|
||||
t.Errorf("darwin: the refusal does not say how: %s", msg)
|
||||
}
|
||||
// The administrator, the service itself, and a machine with no
|
||||
@@ -197,10 +205,6 @@ func TestServiceGuardRefusesAUserspaceBastionBesideTheService(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Linux's service is its systemd unit; nothing here changes how it behaves.
|
||||
if err := serviceGuard("linux", "pair", true, false); err != nil {
|
||||
t.Errorf("linux: refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeTree is a pathOwnerMode over a fixed table, standing in for a Mac's disk.
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
# Runs install.ps1's elevation block (an ordinary account installing for the
|
||||
# whole machine) with Start-Process stood in: Windows' prompt answered yes, or
|
||||
# declined. pwsh on Linux:
|
||||
# pwsh -NoProfile -File testdata/install_ps1_elevate.ps1 ../deploy/install.ps1
|
||||
# Exits 0 when every case holds, and prints the first that does not.
|
||||
param([string]$Script)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$src = Get-Content -Raw $Script
|
||||
$m = [regex]::Match($src, '(?ms)^if \(\$Service -and -not \$isAdmin\) \{\r?\n.*?^\}\r?\n')
|
||||
if (-not $m.Success) { Write-Output 'FAIL: install.ps1 has no elevation block'; exit 1 }
|
||||
# The account's SID comes from a Windows API pwsh on Linux does not have.
|
||||
$text = $m.Value.Replace('([Security.Principal.WindowsIdentity]::GetCurrent()).User.Value', "'S-1-5-21-1-2-3-1001'")
|
||||
$block = [scriptblock]::Create($text)
|
||||
|
||||
# A temp folder with quotes in its name, as under a profile like O'Brien, or
|
||||
# O’Brien typed with a typographic apostrophe, which PowerShell also reads
|
||||
# as a single quote.
|
||||
$dir = Join-Path ([IO.Path]::GetTempPath()) ("o'brien-o$([char]0x2019)neil two-spaces-" + [guid]::NewGuid().ToString())
|
||||
New-Item -ItemType Directory -Path $dir | Out-Null
|
||||
$env:LOCALAPPDATA = $dir
|
||||
$env:TMPDIR = $dir
|
||||
function Write-Host { param($Object) $script:out += "$Object`n" }
|
||||
|
||||
function Run-Case([string]$answer, [bool]$installed, [string]$pair) {
|
||||
$script:out = ''; $script:cmd = ''; $script:pairSeen = ''
|
||||
$Service = $true; $isAdmin = $false; $serviceInstalled = $installed; $Shared = $false
|
||||
$elevatedDone = $false; $fellBack = $false; $Pair = $pair
|
||||
$installScript = 'https://privatae.ai/bastion/install.ps1'
|
||||
function Start-Process {
|
||||
param($FilePath, $Verb, [switch]$Wait, [switch]$PassThru, $WindowStyle, $ArgumentList)
|
||||
if ($answer -eq 'no') { throw 'The operation was canceled by the user.' }
|
||||
if ($ArgumentList[-2] -ne '-EncodedCommand') { throw "the elevated command is not passed encoded: $($ArgumentList -join ' ')" }
|
||||
$script:cmd = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($ArgumentList[-1]))
|
||||
# The elevated command must parse as PowerShell, whatever the temp
|
||||
# folder is called.
|
||||
$errs = $null; $toks = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseInput($script:cmd, [ref]$toks, [ref]$errs)
|
||||
if ($errs) { throw "the elevated command does not parse: $($errs[0].Message)" }
|
||||
# Each value as the elevated PowerShell reads it: the string after
|
||||
# the named parameter, quotes undone by the parser itself.
|
||||
$arg = {
|
||||
param($name)
|
||||
foreach ($c in $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.CommandAst] }, $true)) {
|
||||
$el = $c.CommandElements
|
||||
for ($i = 0; $i -lt $el.Count - 1; $i++) {
|
||||
if ($el[$i] -is [System.Management.Automation.Language.CommandParameterAst] -and $el[$i].ParameterName -eq $name) {
|
||||
return $el[$i + 1].Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$pfArg = & $arg 'PairFile'
|
||||
if ($pfArg) { $script:pairSeen = [IO.File]::ReadAllText($pfArg) }
|
||||
$log = & $arg 'LiteralPath'
|
||||
Set-Content -LiteralPath $log -Value 'Installed: C:\Program Files\Privatae\privatae-bastion.exe'
|
||||
return [pscustomobject]@{ ExitCode = 0 }
|
||||
}
|
||||
$threw = $null
|
||||
try { . $block } catch { $threw = $_.Exception.Message }
|
||||
return @{ threw = $threw; out = $script:out; cmd = $script:cmd; pairSeen = $script:pairSeen;
|
||||
service = $Service; done = $elevatedDone; fellBack = $fellBack }
|
||||
}
|
||||
function Fail([string]$why) { Write-Output "FAIL: $why"; exit 1 }
|
||||
|
||||
$r = Run-Case 'yes' $false 'skp_INVENTEDCODE'
|
||||
if ($r.threw -or -not $r.done) { Fail "a yes did not finish: $($r.threw)" }
|
||||
if ($r.cmd -match 'skp_') { Fail "the code is in the elevated command line: $($r.cmd)" }
|
||||
if ($r.pairSeen -ne 'skp_INVENTEDCODE') { Fail "the elevated step could not read the code: '$($r.pairSeen)'" }
|
||||
if ($r.cmd -notmatch "-Personal -OwnerSid 'S-1-5-21-1-2-3-1001'") { Fail "kind or owner: $($r.cmd)" }
|
||||
if ($r.cmd -notmatch "o''brien-o$([char]0x2019)$([char]0x2019)neil") { Fail "the temp folder's quotes are not doubled: $($r.cmd)" }
|
||||
if ($r.out -notmatch 'Installed: C:\\Program Files') { Fail "the elevated step's output is not shown: $($r.out)" }
|
||||
if (Get-ChildItem -LiteralPath $dir -Filter 'privatae-bastion-pair-*' -ErrorAction SilentlyContinue) { Fail 'the code file was left behind' }
|
||||
|
||||
$r = Run-Case 'no' $false ''
|
||||
if ($r.threw -or $r.done -or $r.service -or -not $r.fellBack -or $r.out -notmatch 'installed for your account only') { Fail "a no must fall back to this account: $($r.threw) $($r.out)" }
|
||||
|
||||
$r = Run-Case 'no' $true ''
|
||||
if (-not $r.threw -or $r.threw -notmatch 'installed for the whole machine') { Fail "beside the service, a no must install nothing: $($r.threw)" }
|
||||
|
||||
Remove-Item -Recurse -Force $dir
|
||||
Write-Output 'ok: install.ps1 elevates for the whole machine, passes the code in a file, shows the output, falls back on a no'
|
||||
+8
-5
@@ -5,8 +5,11 @@
|
||||
param([string]$Script)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$src = Get-Content -Raw $Script
|
||||
$m = [regex]::Match($src, '(?ms)^if \(\$Pair\) \{\r?\n.*?^\}\r?\n')
|
||||
if (-not $m.Success) { Write-Output 'FAIL: install.ps1 has no top-level if ($Pair) { … } block'; exit 1 }
|
||||
# The block that pairs: of every `if ($Pair) { … }`, the one that hands the
|
||||
# code to `pair -`.
|
||||
$m = [regex]::Matches($src, '(?ms)^( *)if \(\$Pair\) \{\r?\n.*?^\1\}\r?\n') |
|
||||
Where-Object { $_.Value.Contains('$Pair | & $exe pair -') } | Select-Object -First 1
|
||||
if (-not $m) { Write-Output 'FAIL: install.ps1 has no if ($Pair) { … } block that pairs'; exit 1 }
|
||||
$block = [scriptblock]::Create($m.Value)
|
||||
|
||||
$dir = Join-Path ([IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString())
|
||||
@@ -26,11 +29,11 @@ function Get-Process { @() }
|
||||
function Start-Sleep { }
|
||||
function Write-Host { param($Object) $script:out += "$Object`n" }
|
||||
|
||||
function Run-Case([string]$rc, [bool]$networked) {
|
||||
function Run-Case([string]$rc, [bool]$service) {
|
||||
$script:calls = @(); $script:out = ''
|
||||
if (Test-Path $log) { Remove-Item $log }
|
||||
$env:FAKE_RC = $rc
|
||||
$Pair = 'skp_INVENTEDCODE'; $Networked = $networked
|
||||
$Pair = 'skp_INVENTEDCODE'; $Service = $service
|
||||
$threw = $null
|
||||
try { . $block } catch { $threw = $_.Exception.Message }
|
||||
$l = if (Test-Path $log) { Get-Content -Raw $log } else { '' }
|
||||
@@ -52,7 +55,7 @@ $r = Run-Case '1' $false
|
||||
if (-not $r.threw -or $r.calls -ne '') { Fail "a failed pair must throw before starting anything: $($r.threw) $($r.calls)" }
|
||||
|
||||
$r = Run-Case '0' $true
|
||||
if ($r.threw -or $r.calls -ne 'restart-service' -or $r.out -notmatch 'Windows service PrivataeBastion') { Fail "networked restarts the service: $($r.threw) $($r.calls)" }
|
||||
if ($r.threw -or $r.calls -ne 'restart-service' -or $r.out -notmatch 'Windows service PrivataeBastion') { Fail "the service is restarted: $($r.threw) $($r.calls)" }
|
||||
|
||||
Remove-Item -Recurse -Force $dir
|
||||
Write-Output 'ok: install.ps1 -Pair pairs over stdin, treats exit 3 as success, stops on a failure, restarts task or service'
|
||||
|
||||
+115
-20
@@ -52,10 +52,10 @@ import (
|
||||
// UI belongs to the box, not to any host it serves.
|
||||
func uilogf(format string, a ...any) { fmt.Printf("[ui] "+format, a...) }
|
||||
|
||||
//go:embed uiassets/htmx.min.js uiassets/app.css uiassets/given.css uiassets/setup.css uiassets/diskmap.css uiassets/agents.css uiassets/policy.css uiassets/context.css
|
||||
//go:embed uiassets/htmx.min.js uiassets/app.css uiassets/given.css uiassets/setup.css uiassets/diskmap.css uiassets/agents.css uiassets/policy.css uiassets/context.css uiassets/home.css
|
||||
var uiStaticFS embed.FS
|
||||
|
||||
//go:embed uiassets/page.html uiassets/status.html uiassets/given.html uiassets/setup.html uiassets/diskmap.html uiassets/agents.html uiassets/policy.html uiassets/context.html uiassets/approvals.html
|
||||
//go:embed uiassets/page.html uiassets/status.html uiassets/given.html uiassets/setup.html uiassets/diskmap.html uiassets/agents.html uiassets/policy.html uiassets/context.html uiassets/approvals.html uiassets/home.html
|
||||
var uiTemplateFS embed.FS
|
||||
|
||||
// uiPortHigh is the port the UI prefers, and it is one BELOW the pairing range
|
||||
@@ -131,6 +131,10 @@ const uiEventRecentFor = 7 * 24 * time.Hour
|
||||
const uiEventTime = "2 Jan 2006, 15:04"
|
||||
|
||||
type uiStatus struct {
|
||||
// Limited: drawn for the owner's lane (consolelane.go), which may not
|
||||
// answer the approval device's requests.
|
||||
Limited bool
|
||||
|
||||
Version string
|
||||
State string
|
||||
StateClass string
|
||||
@@ -148,6 +152,9 @@ type uiStatus struct {
|
||||
// device (uidevice.go). IsDevice says whether it is one at all.
|
||||
DeviceApprovals []uiDeviceApproval
|
||||
IsDevice bool
|
||||
// MachineAsks: requests asked of this machine itself, answered with the
|
||||
// owner's code (stepup_machine.go): today, stops an agent asked for.
|
||||
MachineAsks []uiMachineAsk
|
||||
// ApprovalsView: this render is for the approvals popup, so the step-up
|
||||
// cards post back into it rather than into the front page.
|
||||
ApprovalsView bool
|
||||
@@ -198,6 +205,7 @@ func collectUIStatus() uiStatus {
|
||||
// pairings, so a machine whose pairing list cannot be read still shows the
|
||||
// asks waiting on it.
|
||||
s.IsDevice = isApprovalDevice()
|
||||
s.MachineAsks = machineAsks()
|
||||
s.DeviceApprovals = pendingDeviceApprovals()
|
||||
|
||||
// The three things this machine already knew and told nobody standing at it.
|
||||
@@ -250,8 +258,10 @@ func collectUIStatus() uiStatus {
|
||||
|
||||
switch {
|
||||
case len(cfgs) == 0:
|
||||
s.State, s.StateClass = "NOT PAIRED", "warn"
|
||||
s.StateNote = "No host is paired with this machine yet."
|
||||
// No Host is a steady state, not a fault (run.go): the bastion watches
|
||||
// this machine on its own.
|
||||
s.State, s.StateClass = "ON ITS OWN", "ok"
|
||||
s.StateNote = "No Host is connected. The bastion watches the AI agents on this machine on its own."
|
||||
case running == len(cfgs):
|
||||
s.State, s.StateClass = "RUNNING", "ok"
|
||||
s.StateNote = fmt.Sprintf("Serving %d host(s) from this machine.", len(cfgs))
|
||||
@@ -347,6 +357,12 @@ type uiServer struct {
|
||||
tickets *uiTokenStore
|
||||
sessions *uiTokenStore
|
||||
tpl *template.Template
|
||||
// addr is where the daemon's console listens, for links minted on the
|
||||
// owner's lane; limitedTickets and limitedSessions are that lane's own
|
||||
// (consolelane.go), kept apart so a lane session is never an admin one.
|
||||
addr string
|
||||
limitedTickets *uiTokenStore
|
||||
limitedSessions *uiTokenStore
|
||||
|
||||
// contextUI marks the USER-RUN surface (contextui.go). It selects a
|
||||
// different ROUTE SET — not a different permission check, and that
|
||||
@@ -380,16 +396,18 @@ func newUIServer(port, secret string) (*uiServer, error) {
|
||||
tpl, err := template.ParseFS(uiTemplateFS,
|
||||
"uiassets/page.html", "uiassets/status.html", "uiassets/given.html",
|
||||
"uiassets/setup.html", "uiassets/diskmap.html", "uiassets/agents.html", "uiassets/policy.html",
|
||||
"uiassets/context.html", "uiassets/approvals.html")
|
||||
"uiassets/context.html", "uiassets/approvals.html", "uiassets/home.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &uiServer{
|
||||
port: port,
|
||||
secret: secret,
|
||||
tickets: newUITokenStore(uiTicketTTL),
|
||||
sessions: newUITokenStore(uiSessionTTL),
|
||||
tpl: tpl,
|
||||
port: port,
|
||||
secret: secret,
|
||||
tickets: newUITokenStore(uiTicketTTL),
|
||||
sessions: newUITokenStore(uiSessionTTL),
|
||||
tpl: tpl,
|
||||
limitedTickets: newUITokenStore(uiTicketTTL),
|
||||
limitedSessions: newUITokenStore(uiSessionTTL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -466,6 +484,9 @@ func (u *uiServer) handler() http.Handler {
|
||||
// would have stopped over the past week. Two routes, both reads — there is
|
||||
// no mutating route in that unit at all, which is the point of it.
|
||||
u.policyRoutes(mux)
|
||||
// The agents and what each is doing (uihome.go): the home page, one page
|
||||
// per agent, and the machine's state page that used to be home.
|
||||
u.homeRoutes(mux)
|
||||
mux.HandleFunc("/", u.root)
|
||||
return mux
|
||||
}
|
||||
@@ -477,11 +498,14 @@ func (u *uiServer) guarded(h http.HandlerFunc) http.HandlerFunc {
|
||||
http.Error(w, why, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !u.sessions.valid(uiSessionFrom(r)) {
|
||||
if !u.signedIn(r) {
|
||||
http.Error(w, "not signed in — open this page from `privatae-bastion "+u.verb()+"`",
|
||||
http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if u.limitedSession(r) {
|
||||
w = uiLimitedWriter{w}
|
||||
}
|
||||
h(w, r)
|
||||
}
|
||||
}
|
||||
@@ -500,12 +524,18 @@ func (u *uiServer) root(w http.ResponseWriter, r *http.Request) {
|
||||
// the ticket is not left in the address bar, the history or a screenshot.
|
||||
// It is one-use and dies in a minute anyway; this is the second lock.
|
||||
if k := r.URL.Query().Get("k"); k != "" {
|
||||
// A ticket from the owner's lane opens a limited session, and only
|
||||
// that (consolelane.go).
|
||||
store := u.sessions
|
||||
if !u.tickets.redeem(k) {
|
||||
http.Error(w, "that link has already been used or has expired — "+
|
||||
"run `privatae-bastion "+u.verb()+"` again", http.StatusForbidden)
|
||||
return
|
||||
if u.limitedTickets == nil || !u.limitedTickets.redeem(k) {
|
||||
http.Error(w, "that link has already been used or has expired — "+
|
||||
"run `privatae-bastion "+u.verb()+"` again", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
store = u.limitedSessions
|
||||
}
|
||||
tok, err := u.sessions.mint()
|
||||
tok, err := store.mint()
|
||||
if err != nil {
|
||||
http.Error(w, "could not start a session", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -517,7 +547,7 @@ func (u *uiServer) root(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, uiLanding(r.URL.Query().Get("to")), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if !u.sessions.valid(uiSessionFrom(r)) {
|
||||
if !u.signedIn(r) {
|
||||
http.Error(w, "not signed in — open this page from `privatae-bastion "+u.verb()+"`",
|
||||
http.StatusUnauthorized)
|
||||
return
|
||||
@@ -533,7 +563,7 @@ func (u *uiServer) root(w http.ResponseWriter, r *http.Request) {
|
||||
u.approvalsPage(w, r)
|
||||
return
|
||||
}
|
||||
u.render(w, "page.html")
|
||||
u.homePage(w, r)
|
||||
}
|
||||
|
||||
func (u *uiServer) statusFragment(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -564,6 +594,7 @@ func (u *uiServer) renderStatus(w http.ResponseWriter, name, flash, class string
|
||||
s := u.status()
|
||||
s.Flash, s.FlashClass = flash, class
|
||||
s.ApprovalsView = approvalsView
|
||||
s.Limited = limitedWriter(w)
|
||||
if err := u.tpl.ExecuteTemplate(w, name, s); err != nil {
|
||||
uilogf("render %s: %v\n", name, err)
|
||||
}
|
||||
@@ -636,7 +667,19 @@ func (u *uiServer) approve(w http.ResponseWriter, r *http.Request) {
|
||||
u.renderAnswer(w, r, "Enter the code from the authenticator enrolled on this machine.", "warn")
|
||||
return
|
||||
}
|
||||
if _, err := stepupApproveRequest(cfg.dcnDir(), id, code); err != nil {
|
||||
approveOne := func() (string, error) { return stepupApproveRequest(cfg.dcnDir(), id, code) }
|
||||
if u.limitedSession(r) {
|
||||
// Through the owner's lane the seed is out of the caller's reach, so
|
||||
// the reasoning above no longer holds: codes are throttled here as the
|
||||
// owner's are (consolelane.go).
|
||||
dir := cfg.dcnDir()
|
||||
approveOne = func() (string, error) {
|
||||
return codeSpend(dir, time.Now(), hostCodeLockedHint, func() (string, error) {
|
||||
return stepupApproveRequest(dir, id, code)
|
||||
})
|
||||
}
|
||||
}
|
||||
if _, err := approveOne(); err != nil {
|
||||
u.renderAnswer(w, r, "Not approved: "+err.Error(), "bad")
|
||||
return
|
||||
}
|
||||
@@ -716,6 +759,9 @@ func (u *uiServer) signOut(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
u.sessions.drop(uiSessionFrom(r))
|
||||
if u.limitedSessions != nil {
|
||||
u.limitedSessions.drop(uiSessionFrom(r))
|
||||
}
|
||||
uiClearSessionCookie(w)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
@@ -730,7 +776,7 @@ func (u *uiServer) serveAsset(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/assets/")
|
||||
switch name {
|
||||
case "htmx.min.js", "app.css", "given.css", "setup.css", "diskmap.css", "agents.css",
|
||||
"policy.css", "context.css":
|
||||
"policy.css", "context.css", "home.css":
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -796,6 +842,8 @@ func startUIServer(ctx context.Context) {
|
||||
// they are — that code has early returns, and a person's ability to ask
|
||||
// must not depend on a free TCP port.
|
||||
startContextSock(ctx.Done())
|
||||
// Asks from other accounts to stop an agent (agentask.go).
|
||||
startAskSock(ctx.Done())
|
||||
secret, err := loadOrCreateUISecret()
|
||||
if err != nil {
|
||||
uilogf("not started (%v)\n", err)
|
||||
@@ -822,15 +870,18 @@ func startUIServer(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
srv.addr = addr
|
||||
if p, err := uiAddrPath(); err == nil {
|
||||
_ = os.WriteFile(p, []byte(addr), 0o600)
|
||||
}
|
||||
consoleLaneServer.Store(srv)
|
||||
h := &http.Server{
|
||||
Handler: srv.handler(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
consoleLaneServer.CompareAndSwap(srv, nil)
|
||||
sh, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = h.Shutdown(sh)
|
||||
@@ -907,7 +958,7 @@ func localUIEntryURLTo(to string) (entry, addr string, err error) {
|
||||
// belongs to the running daemon, because that is the process that knows what is
|
||||
// live.
|
||||
func cmdUI() error {
|
||||
entry, addr, err := localUIEntryURL()
|
||||
entry, addr, err := consoleEntryForThisAccount()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -935,3 +986,47 @@ func openBrowser(url string) error {
|
||||
hideConsole(cmd)
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// uiDaemonListening reports whether THIS account's bastion daemon is up. It is
|
||||
// how `status` tells a running bastion with no Host from one that is not
|
||||
// running: with no pairing there is no pairing listener to probe.
|
||||
//
|
||||
// A port answering is not enough. ui.addr can outlive its daemon, and the port
|
||||
// it names can then belong to another account's bastion (the system service
|
||||
// beside a person's own, say), which would read as "running" when this one is
|
||||
// not. So the console must accept this account's own ui.secret, the same proof
|
||||
// `ui` uses (localUIEntryURLTo). The ticket that proves it lapses unused after
|
||||
// uiTicketTTL.
|
||||
func uiDaemonListening() bool {
|
||||
p, err := uiAddrPath()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
addr := strings.TrimSpace(string(b))
|
||||
if !bastionListening(addr) {
|
||||
return false
|
||||
}
|
||||
sp, err := uiSecretPath()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
sb, err := os.ReadFile(sp)
|
||||
if err != nil {
|
||||
return false // a daemon that ran under this state would have written it
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, "http://"+addr+"/auth/ticket", nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(sb)))
|
||||
resp, err := (&http.Client{Timeout: 2 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
+29
-3
@@ -105,7 +105,8 @@ func TestTheFrontDoorFlow(t *testing.T) {
|
||||
t.Fatalf("no session cookie was set: %q", cookie)
|
||||
}
|
||||
|
||||
// 4. The page renders, and renders THIS machine.
|
||||
// 4. The home page is the agents on this machine (uihome.go), and the
|
||||
// machine's own state, which was home before it, renders at /machine.
|
||||
r = uiReq(http.MethodGet, "/")
|
||||
r.Header.Set("Cookie", cookie)
|
||||
w = httptest.NewRecorder()
|
||||
@@ -113,6 +114,18 @@ func TestTheFrontDoorFlow(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("/ with a session = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
for _, want := range []string{"Agents on this machine", `hx-get="/fragments/home"`, `href="/machine"`} {
|
||||
if !strings.Contains(w.Body.String(), want) {
|
||||
t.Errorf("the home page does not contain %q", want)
|
||||
}
|
||||
}
|
||||
r = uiReq(http.MethodGet, "/machine")
|
||||
r.Header.Set("Cookie", cookie)
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("/machine with a session = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{
|
||||
"member.host.privatae.ai", // the pairing
|
||||
@@ -271,7 +284,14 @@ func TestEveryRouteRefusesARebindingHost(t *testing.T) {
|
||||
// is exactly what a rebinding page would want here: this one names
|
||||
// every automated program on the machine and adds the machine's own
|
||||
// opinion of which it would stop.
|
||||
"/policy", "/fragments/policy", "/assets/policy.css"} {
|
||||
"/policy", "/fragments/policy", "/assets/policy.css",
|
||||
// The agents home and one agent's page (uihome.go): each session's
|
||||
// name, title, folder, what it is running, and its last prompt.
|
||||
"/fragments/home", "/agent?pid=1", "/machine", "/assets/home.css",
|
||||
"/agent/stop", "/agent/mark", "/approvals/machine/approve", "/approvals/machine/deny", "/activity",
|
||||
"/fragments/machine/schedules", "/fragments/machine/tools", "/fragments/machine/baseline",
|
||||
"/machine/compare", "/machine/baseline", "/machine/report",
|
||||
"/setup/host/disconnect", "/setup/host/release"} {
|
||||
r := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
r.Host = "evil.example:" + uiTestPort // resolves to 127.0.0.1, is not 127.0.0.1
|
||||
r.Header.Set("Cookie", uiCookieName+"="+tok)
|
||||
@@ -352,7 +372,13 @@ func TestNothingAboutTheMachineLeaksWithoutASession(t *testing.T) {
|
||||
"/agents", "/fragments/agents", "/agents/mark", "/agents/unmark",
|
||||
// The policy page names the same programs and adds this machine's own
|
||||
// opinion of which of them it would stop.
|
||||
"/policy", "/fragments/policy"} {
|
||||
"/policy", "/fragments/policy",
|
||||
// Every session's name, title and last prompt (uihome.go).
|
||||
"/fragments/home", "/agent?pid=1", "/machine",
|
||||
"/agent/stop", "/agent/mark", "/approvals/machine/approve", "/approvals/machine/deny", "/activity",
|
||||
"/fragments/machine/schedules", "/fragments/machine/tools", "/fragments/machine/baseline",
|
||||
"/machine/compare", "/machine/baseline", "/machine/report",
|
||||
"/setup/host/disconnect", "/setup/host/release"} {
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, uiReq(http.MethodGet, path))
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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); }
|
||||
@@ -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> ·
|
||||
<a href="/agents">programs marked as yours</a> ·
|
||||
<a href="/given">what this machine has given away</a>.<br>
|
||||
privatae-bastion {{.Version}} · 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}} · {{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}} · 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}} · {{.Value}}{{end}}</p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</main>
|
||||
<footer>privatae-bastion {{.Version}} · 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}} · 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}}
|
||||
@@ -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> ·
|
||||
<a href="/setup">Set this machine up</a> ·
|
||||
<a href="/given">What this machine has given away</a> ·
|
||||
<a href="/diskmap">This machine's disk</a> ·
|
||||
<a href="/agents">Automated programs running here</a> ·
|
||||
<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
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -205,6 +205,23 @@ func (s *uiTokenStore) valid(tok string) bool {
|
||||
return ok && !uiNow().After(exp)
|
||||
}
|
||||
|
||||
// mintCapped is mint, refused once max tokens are live, counted and minted
|
||||
// under one lock so callers at the same instant cannot pass max together.
|
||||
func (s *uiTokenStore) mintCapped(max int) (string, bool, error) {
|
||||
tok, err := genSecret(32)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sweepLocked()
|
||||
if len(s.m) >= max {
|
||||
return "", false, nil
|
||||
}
|
||||
s.m[tok] = uiNow().Add(s.ttl)
|
||||
return tok, true, nil
|
||||
}
|
||||
|
||||
func (s *uiTokenStore) drop(tok string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -129,9 +129,9 @@ func (u *uiServer) deviceRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/approvals", u.guarded(u.approvalsPage))
|
||||
mux.HandleFunc("/fragments/approvals", u.guarded(u.approvalsFragment))
|
||||
mux.HandleFunc("/fragments/approvals-news", u.guarded(u.approvalsNews))
|
||||
mux.HandleFunc("/device/approve", u.guarded(u.deviceApproveOne))
|
||||
mux.HandleFunc("/device/approve", u.adminOnly(u.deviceApproveOne))
|
||||
mux.HandleFunc("/device/deny", u.guarded(u.deviceDenyOne))
|
||||
mux.HandleFunc("/device/approve-shown", u.guarded(u.deviceApproveShown))
|
||||
mux.HandleFunc("/device/approve-shown", u.adminOnly(u.deviceApproveShown))
|
||||
}
|
||||
|
||||
func (u *uiServer) approvalsPage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -253,6 +253,9 @@ func TestATicketLandsOnlyOnANamedPage(t *testing.T) {
|
||||
// file lying about — the device lane only answers where the key is.
|
||||
func TestOnlyTheApprovalDeviceShowsAsks(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
// A Host is paired: the note is about asks from Hosts, and a machine with
|
||||
// none has no reason to hear about an approval device.
|
||||
uiTestPairing(t, "member.host.privatae.ai")
|
||||
p, err := deviceRequestsPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -941,10 +941,13 @@ func dmLegendBytes(bytes map[string]int64, label string) int64 {
|
||||
// TestNothingAboutTheMachineLeaksWithoutASession as well as in the rebinding
|
||||
// walk.
|
||||
func (u *uiServer) diskMapRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/diskmap", u.guarded(u.diskMapPage))
|
||||
mux.HandleFunc("/fragments/diskmap", u.guarded(u.diskMapFragment))
|
||||
mux.HandleFunc("/diskmap/scan", u.guarded(u.diskMapScan))
|
||||
mux.HandleFunc("/diskmap/stop", u.guarded(u.diskMapStop))
|
||||
// The administrator's only: run as root, the map names files in every
|
||||
// account's home, and the owner's lane is a person's account, where any
|
||||
// agent running as them reaches it too (consolelane.go).
|
||||
mux.HandleFunc("/diskmap", u.adminOnly(u.diskMapPage))
|
||||
mux.HandleFunc("/fragments/diskmap", u.adminOnly(u.diskMapFragment))
|
||||
mux.HandleFunc("/diskmap/scan", u.adminOnly(u.diskMapScan))
|
||||
mux.HandleFunc("/diskmap/stop", u.adminOnly(u.diskMapStop))
|
||||
}
|
||||
|
||||
func (u *uiServer) diskMapPage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
+15
-2
@@ -547,7 +547,9 @@ func collectGiven() uiGiven {
|
||||
// either way — and the page has to tell the two APART: "the host
|
||||
// was never granted this" and "you switched it off" are different
|
||||
// facts, and only one of them has a way back.
|
||||
f.SwitchedOff = off[c.HostName+"/"+fam.Name]
|
||||
// An unreadable list switches every family off (localDenied), and
|
||||
// DenyNote says why and how to reset it.
|
||||
f.SwitchedOff = off[c.HostName+"/"+fam.Name] || denyNote != ""
|
||||
if f.Held {
|
||||
h.HeldCount++
|
||||
}
|
||||
@@ -568,7 +570,7 @@ func collectGiven() uiGiven {
|
||||
// the question the directives actually face.
|
||||
d := uiDirectivesCard
|
||||
d.Held = anchorOnly(c.HostName, localFamilyDirectives) == "" && !localDenied(c.HostName, localFamilyDirectives)
|
||||
d.SwitchedOff = off[c.HostName+"/"+localFamilyDirectives]
|
||||
d.SwitchedOff = off[c.HostName+"/"+localFamilyDirectives] || denyNote != ""
|
||||
if d.Held {
|
||||
h.HeldCount++
|
||||
}
|
||||
@@ -636,6 +638,17 @@ func (u *uiServer) givenSwitch(w http.ResponseWriter, r *http.Request) {
|
||||
host := strings.TrimSpace(r.FormValue("host"))
|
||||
family := strings.TrimSpace(r.FormValue("family"))
|
||||
off := r.FormValue("off") == "1"
|
||||
// Switching OFF only takes reach away and needs nothing. Turning it back
|
||||
// ON gives the Host its reach again (up to its grant), which the
|
||||
// terminal's `switch ... on` asks the administrator for; on this page it
|
||||
// takes the owner's code, because anything driving the browser can press
|
||||
// the button.
|
||||
if !off {
|
||||
if why := ownerCodeRefusal(r.FormValue("code"), "local_switch_on|"+host+"|"+family); why != "" {
|
||||
u.renderGivenFlash(w, "given", "Not turned back on: "+why, "warn")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := localDenySet(host, family, off); err != nil {
|
||||
u.renderGivenFlash(w, "given", "Nothing was changed: "+err.Error(), "bad")
|
||||
|
||||
@@ -969,12 +969,24 @@ func TestSwitchingThroughTheRouteFlipsTheStateAndSaysSo(t *testing.T) {
|
||||
}
|
||||
|
||||
// AND BACK ON — the inverted-drift case: if these two ever do the same
|
||||
// thing, one of them is silently wrong.
|
||||
// thing, one of them is silently wrong. Back on gives the Host its reach
|
||||
// again, so it takes the owner's code: none set up, nothing changes.
|
||||
w = uiPost(t, h, "/given/switch", url.Values{
|
||||
"host": {host}, "family": {"dcn"}, "off": {"0"},
|
||||
}, cookie)
|
||||
if !localDenied(host, "dcn") || !strings.Contains(w.Body.String(), "Set up your code") {
|
||||
t.Errorf("turned back on with no owner code: %q", dmSnip(w.Body.String(), "flash"))
|
||||
}
|
||||
d, err := machineFactorDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enrolFactorAt(t, d, machineTestSecret)
|
||||
w = uiPost(t, h, "/given/switch", url.Values{
|
||||
"host": {host}, "family": {"dcn"}, "off": {"0"}, "code": {codeFor(t, machineTestSecret)},
|
||||
}, cookie)
|
||||
if localDenied(host, "dcn") {
|
||||
t.Error("posting off=0 did not clear the denial")
|
||||
t.Error("posting off=0 with the owner's code did not clear the denial")
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "grants nothing") {
|
||||
t.Errorf("turning it back on does not say it grants nothing: %q",
|
||||
@@ -982,10 +994,9 @@ func TestSwitchingThroughTheRouteFlipsTheStateAndSaysSo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// AN UNREADABLE LIST IS SAID OUT LOUD. The gate must fail OPEN on a corrupt
|
||||
// file — one bad file must not stop the machine serving its host — so the page
|
||||
// carries the opposite obligation: say it, or the person is shown their
|
||||
// switches as not existing rather than as not working.
|
||||
// AN UNREADABLE LIST IS SAID OUT LOUD. The gate refuses every family while
|
||||
// the list cannot be read (localDenied), so the page must say why and name the
|
||||
// way out, or the person sees their machine refusing everything for no reason.
|
||||
func TestAnUnreadableDenyListIsReportedOnThePage(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
const host = "member.host.privatae.ai"
|
||||
@@ -1008,9 +1019,12 @@ func TestAnUnreadableDenyListIsReportedOnThePage(t *testing.T) {
|
||||
t.Errorf("the page renders every capability as granted while the switches are "+
|
||||
"silently not in effect, and never says so: %q", dmSnip(body, "granted"))
|
||||
}
|
||||
if !strings.Contains(body, "not in effect") {
|
||||
t.Error("the note does not say what it MEANS for the person — that what they " +
|
||||
"switched off is not being blocked")
|
||||
if !strings.Contains(body, "EVERY capability is refused") {
|
||||
t.Error("the note does not say what it MEANS for the person — that every " +
|
||||
"capability is refused until the list is reset")
|
||||
}
|
||||
if !strings.Contains(body, "switch --reset") {
|
||||
t.Error("the note does not name the way out")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
package main
|
||||
|
||||
// uihome.go — the console's home page: the agents on this machine and what
|
||||
// each one is doing, and one page per agent.
|
||||
//
|
||||
// Plan: /srv/work/plans/HANDOFF-bastion-first-2026-10-30.md, Addendum 4, step 1.
|
||||
// The home page is built from buildAgentViews (agentsession.go), the same
|
||||
// data `privatae-bastion agents` prints, worded by the same agentLinesOf, so
|
||||
// the console and the command line cannot describe an agent differently. Two
|
||||
// facts that each had a page of their own sit on every row: whether the person
|
||||
// has marked the program as theirs (sanction.go) and whether the dry-run rule
|
||||
// would stop it (policy.go). Those pages stay, as the detail behind each fact.
|
||||
//
|
||||
// Nothing on these pages acts. Stopping an agent and marking it as yours come
|
||||
// with the owner's code on the page (step 2).
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type uiHomeRow struct {
|
||||
PID int
|
||||
Service bool
|
||||
App bool
|
||||
Head string
|
||||
State string
|
||||
Where string
|
||||
RunningNow string
|
||||
ToolServers string
|
||||
WaitingForYou bool
|
||||
PermissionsOff bool
|
||||
NotYours bool
|
||||
WouldStop bool
|
||||
}
|
||||
|
||||
type uiHomeFamily struct {
|
||||
Heading string
|
||||
Rows []uiHomeRow
|
||||
}
|
||||
|
||||
type uiHomeView struct {
|
||||
Version string
|
||||
CheckedAt string
|
||||
Blind bool
|
||||
WalkError string
|
||||
// PerAccount: the daemon runs in a person's own account, so an agent
|
||||
// running as that person can stop it and read its code's secret
|
||||
// (ui.go approve). Said on every load, because it changes what every
|
||||
// approval on this console is worth.
|
||||
PerAccount bool
|
||||
// The first-run steps (step 7): shown until the owner's code is set up
|
||||
// and a baseline is recorded, the two things that make the rest work.
|
||||
CodeSet bool
|
||||
BaselineSet bool
|
||||
HostsPaired bool
|
||||
Waiting int
|
||||
Sessions int
|
||||
Others int // browser drivers and unknown model clients: running, not sessions
|
||||
WaitingForYou int
|
||||
NotYours int
|
||||
WouldStop int
|
||||
Families []uiHomeFamily
|
||||
}
|
||||
|
||||
// homeWaitingFn counts the requests /approvals shows; a var so the suite can
|
||||
// say how many without a pairing.
|
||||
var homeWaitingFn = func() int {
|
||||
s := collectUIStatus()
|
||||
return len(s.Approvals) + len(s.DeviceApprovals) + len(s.MachineAsks)
|
||||
}
|
||||
|
||||
func collectHomeView(now time.Time) uiHomeView {
|
||||
v := uiHomeView{
|
||||
Version: clientVersion,
|
||||
CheckedAt: now.Format("15:04:05"),
|
||||
Blind: !agentScanSupported,
|
||||
PerAccount: !machineAuthorityHeld(),
|
||||
Waiting: homeWaitingFn(),
|
||||
CodeSet: stepupEnrolled(existingMachineFactorDir()),
|
||||
}
|
||||
if _, _, err := baselineLoad(); err == nil {
|
||||
v.BaselineSet = true
|
||||
}
|
||||
if cfgs, err := loadPairings(); err == nil && len(cfgs) > 0 {
|
||||
v.HostsPaired = true
|
||||
}
|
||||
agents, err := observedAgents()
|
||||
if err != nil {
|
||||
v.WalkError = err.Error()
|
||||
return v
|
||||
}
|
||||
byPID := map[int]agentObs{}
|
||||
for _, a := range agents {
|
||||
byPID[a.PID] = a
|
||||
}
|
||||
views := buildAgentViews(agents, processTree())
|
||||
fams := map[string]*uiHomeFamily{}
|
||||
var order []string
|
||||
byFam := map[string][]agentView{}
|
||||
for _, av := range views {
|
||||
if _, ok := fams[av.Signature]; !ok {
|
||||
fams[av.Signature] = &uiHomeFamily{}
|
||||
order = append(order, av.Signature)
|
||||
}
|
||||
byFam[av.Signature] = append(byFam[av.Signature], av)
|
||||
}
|
||||
sort.Strings(order)
|
||||
for _, sig := range order {
|
||||
vs := byFam[sig]
|
||||
sort.SliceStable(vs, func(i, j int) bool {
|
||||
if a, b := viewRank(vs[i]), viewRank(vs[j]); a != b {
|
||||
return a < b
|
||||
}
|
||||
return vs[i].PID < vs[j].PID
|
||||
})
|
||||
f := fams[sig]
|
||||
f.Heading = familyHeading(familyLabel(sig), vs)
|
||||
for _, av := range vs {
|
||||
row := homeRow(av, byPID[av.PID], now)
|
||||
if !row.Service && !row.App && av.Kind != kindAgent {
|
||||
v.Others++
|
||||
}
|
||||
if !row.Service && !row.App && av.Kind == kindAgent {
|
||||
v.Sessions++
|
||||
if row.WaitingForYou {
|
||||
v.WaitingForYou++
|
||||
}
|
||||
if row.NotYours {
|
||||
v.NotYours++
|
||||
}
|
||||
if row.WouldStop {
|
||||
v.WouldStop++
|
||||
}
|
||||
}
|
||||
f.Rows = append(f.Rows, row)
|
||||
}
|
||||
v.Families = append(v.Families, *f)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func homeRow(av agentView, a agentObs, now time.Time) uiHomeRow {
|
||||
r := uiHomeRow{PID: av.PID, Service: av.Role == "service", App: av.Kind == kindApp}
|
||||
if r.Service {
|
||||
r.Head = "Background service"
|
||||
r.State = "starts background sessions; not a session itself"
|
||||
return r
|
||||
}
|
||||
l := agentLinesOf(av, now)
|
||||
r.Head, r.State, r.Where = l.Head, l.State, l.Where
|
||||
r.RunningNow = strings.Join(av.RunningNow, "; ")
|
||||
r.ToolServers = strings.Join(av.ToolServers, ", ")
|
||||
if av.Self != nil {
|
||||
r.WaitingForYou = av.Self.WaitingFor != ""
|
||||
r.PermissionsOff = av.Self.PermissionMode == "bypassPermissions"
|
||||
}
|
||||
sanctioned, _ := agentSanction(a)
|
||||
r.NotYours = !sanctioned && !r.App
|
||||
r.WouldStop = policyEvaluate(a).Decision == policyWouldStop
|
||||
return r
|
||||
}
|
||||
|
||||
// ── one agent ───────────────────────────────────────────────────────────────
|
||||
|
||||
type uiToolServer struct {
|
||||
Name string `json:"name"`
|
||||
Where string `json:"where"`
|
||||
Problems []string `json:"problems,omitempty"`
|
||||
}
|
||||
|
||||
type uiAgentPage struct {
|
||||
Version string
|
||||
Flash string
|
||||
FlashClass string
|
||||
OwnerCode bool // the owner's code is set up: Stop and the mark are offered
|
||||
Signature string // posted back with Stop, so a pid that changed hands is refused
|
||||
MarkID string // the sanction fingerprint the mark posts; "" when none can be made
|
||||
Found bool
|
||||
PID int
|
||||
Family string
|
||||
Row uiHomeRow
|
||||
Self *agentSelf
|
||||
Kind string
|
||||
Exe string
|
||||
Cmdline string
|
||||
Origin string
|
||||
Endpoints []string
|
||||
Evidence []evidence
|
||||
Tools []uiToolServer
|
||||
Sanctioned bool
|
||||
SanctionWhy string
|
||||
Verdict string
|
||||
VerdictCls string
|
||||
Reasons []policyReason
|
||||
}
|
||||
|
||||
func collectAgentPage(pid int, now time.Time) uiAgentPage {
|
||||
p := uiAgentPage{Version: clientVersion, PID: pid, OwnerCode: stepupEnrolled(existingMachineFactorDir())}
|
||||
agents, err := observedAgents()
|
||||
if err != nil {
|
||||
return p
|
||||
}
|
||||
var a agentObs
|
||||
for _, x := range agents {
|
||||
if x.PID == pid {
|
||||
a, p.Found = x, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !p.Found {
|
||||
return p
|
||||
}
|
||||
views := buildAgentViews([]agentObs{a}, processTree())
|
||||
av := views[0]
|
||||
p.Family = familyLabel(a.Signature)
|
||||
p.Row = homeRow(av, a, now)
|
||||
p.Self = av.Self
|
||||
p.Kind, p.Exe, p.Cmdline, p.Origin = a.Kind, a.Exe, a.Cmdline, a.Origin
|
||||
p.Signature = a.Signature
|
||||
if id := sanctionIdentityOf(a); id.usable() {
|
||||
p.MarkID = id.fingerprint()
|
||||
}
|
||||
p.Endpoints, p.Evidence = a.LLMEndpoints, a.Evidence
|
||||
p.Sanctioned, p.SanctionWhy = agentSanction(a)
|
||||
pv := policyEvaluate(a)
|
||||
p.Verdict, p.VerdictCls, p.Reasons = policyWord(pv.Decision), policyClass(pv.Decision), pv.Reasons
|
||||
servers, _ := readMCPConfigs()
|
||||
p.Tools = toolServersFor(a.Signature, homeForUserFn(a.User), av.Folder, servers)
|
||||
return p
|
||||
}
|
||||
|
||||
// toolServersFor is the tool servers this agent reads: its own client's
|
||||
// settings files in its account's home, or inside the folder it works in.
|
||||
func toolServersFor(signature, home, folder string, servers []mcpServerObs) []uiToolServer {
|
||||
var out []uiToolServer
|
||||
for _, s := range servers {
|
||||
if s.Client != signature || s.Path == "" {
|
||||
continue
|
||||
}
|
||||
if !pathUnder(s.Path, home) && !pathUnder(s.Path, folder) {
|
||||
continue
|
||||
}
|
||||
t := uiToolServer{Name: s.Name, Where: s.Path}
|
||||
if t.Name == "" {
|
||||
t.Name = s.PackageName
|
||||
}
|
||||
if s.SecretInPlaintext {
|
||||
t.Problems = append(t.Problems, "a key is written in plain text in "+s.Path)
|
||||
}
|
||||
if s.PackagePinned == "no" && s.PackageName != "" {
|
||||
t.Problems = append(t.Problems, "runs "+s.PackageName+
|
||||
" with no fixed version, so whatever is published next is what runs")
|
||||
}
|
||||
if s.RunningState == "running_not_declared" {
|
||||
t.Problems = append(t.Problems, "running, but not in any settings file this machine can read")
|
||||
}
|
||||
if s.Disabled {
|
||||
t.Problems = nil
|
||||
t.Name += " (turned off)"
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return len(out[i].Problems) > len(out[j].Problems) })
|
||||
return out
|
||||
}
|
||||
|
||||
func pathUnder(p, dir string) bool {
|
||||
if dir == "" || p == "" {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(dir, p)
|
||||
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
|
||||
}
|
||||
|
||||
// ── routes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (u *uiServer) homeRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/fragments/home", u.guarded(u.homeFragment))
|
||||
mux.HandleFunc("/agent", u.guarded(u.agentPage))
|
||||
// The two acts on an agent's page, each taking the owner's code.
|
||||
mux.HandleFunc("/agent/stop", u.guarded(u.agentStop))
|
||||
mux.HandleFunc("/agent/mark", u.guarded(u.agentMark))
|
||||
// This machine's sections (uimachine.go).
|
||||
u.machineRoutes(mux)
|
||||
// What happened here, newest first (activity.go).
|
||||
mux.HandleFunc("/activity", u.guarded(u.activityPage))
|
||||
// Answering what is asked of this machine itself, on Waiting for you.
|
||||
mux.HandleFunc("/approvals/machine/approve", u.guarded(u.machineApprove))
|
||||
mux.HandleFunc("/approvals/machine/deny", u.guarded(u.machineDeny))
|
||||
// The machine's state page, which was the home page until the agents
|
||||
// took that place.
|
||||
mux.HandleFunc("/machine", u.guarded(u.machinePage))
|
||||
}
|
||||
|
||||
// renderView sets the same four headers every page here sets (renderStatus).
|
||||
func (u *uiServer) renderView(w http.ResponseWriter, name string, v any) {
|
||||
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) homePage(w http.ResponseWriter, r *http.Request) {
|
||||
u.renderView(w, "home-page", collectHomeView(uiNow()))
|
||||
}
|
||||
|
||||
func (u *uiServer) homeFragment(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
u.renderView(w, "home", collectHomeView(uiNow()))
|
||||
}
|
||||
|
||||
func (u *uiServer) agentPage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
pid, err := strconv.Atoi(r.URL.Query().Get("pid"))
|
||||
if err != nil || pid <= 0 {
|
||||
http.Error(w, "which agent? open it from the Agents page", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
u.renderView(w, "agent-page", collectAgentPage(pid, uiNow()))
|
||||
}
|
||||
|
||||
func (u *uiServer) machinePage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
u.render(w, "page.html")
|
||||
}
|
||||
|
||||
// stopFromConsole stops one agent for the person at this console: the agent
|
||||
// they were looking at (pid AND signature, so a number that changed hands
|
||||
// since the page was drawn is refused), with their code, through the same
|
||||
// approval and the same carry-out as `stop` (stop_local.go). The console runs
|
||||
// in the daemon, so the stop is carried out here and now and recorded in the
|
||||
// machine's activity, rather than waiting for the loop's next tick.
|
||||
func stopFromConsole(pid int, signature, code string, now time.Time) (string, string) {
|
||||
observed := observedAgentMap()
|
||||
if why := stopRefusal(pid, observed); why != "" {
|
||||
return "Not stopped: " + why + ".", "warn"
|
||||
}
|
||||
if got := observed[pid].Signature; got != signature {
|
||||
return "Not stopped: pid " + strconv.Itoa(pid) + " is now " + got + ", not the " + signature +
|
||||
" this page showed. Open it again from the Agents page.", "warn"
|
||||
}
|
||||
dir := existingMachineFactorDir()
|
||||
if !stepupEnrolled(dir) {
|
||||
return "Not stopped: set up your code under Settings first. Stopping an agent takes it.", "warn"
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return "Not stopped: type the code from your authenticator.", "warn"
|
||||
}
|
||||
purpose, err := purposeForLocalStop(pid, signature)
|
||||
if err != nil {
|
||||
return "Not stopped: " + err.Error() + ".", "bad"
|
||||
}
|
||||
approval, err := machineCodeSpend(dir, now, func() (string, error) {
|
||||
return stepupIssueApproval(dir, code, purpose)
|
||||
})
|
||||
if err != nil {
|
||||
return "Not stopped: " + err.Error() + ".", "bad"
|
||||
}
|
||||
localStopOnce(dir, now)
|
||||
r, ok := waitStopResult(dir, approval, 5*time.Second)
|
||||
if !ok {
|
||||
return "Approved, but the stop was not carried out. Look under Activity.", "warn"
|
||||
}
|
||||
if strings.HasPrefix(r.Outcome, "not stopped") {
|
||||
return r.Outcome, "warn"
|
||||
}
|
||||
return r.Outcome, "ok"
|
||||
}
|
||||
|
||||
func (u *uiServer) agentStop(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
pid, err := strconv.Atoi(r.FormValue("pid"))
|
||||
if err != nil || pid <= 1 {
|
||||
http.Error(w, "which agent? open it from the Agents page", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
now := uiNow()
|
||||
flash, class := stopFromConsole(pid, r.FormValue("signature"), r.FormValue("code"), now)
|
||||
p := collectAgentPage(pid, now)
|
||||
p.Flash, p.FlashClass = flash, class
|
||||
u.renderView(w, "agent-page", p)
|
||||
}
|
||||
|
||||
func (u *uiServer) agentMark(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
pid, err := strconv.Atoi(r.FormValue("pid"))
|
||||
if err != nil || pid <= 1 {
|
||||
http.Error(w, "which agent? open it from the Agents page", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.FormValue("id"))
|
||||
flash, class := "Nothing was marked: that program is not running on this machine any more.", "warn"
|
||||
if agents, err := observedAgents(); err == nil {
|
||||
for _, a := range agents {
|
||||
if a.PID != pid || sanctionIdentityOf(a).fingerprint() != id {
|
||||
continue
|
||||
}
|
||||
if why := markCodeRefusal(id, r.FormValue("code")); why != "" {
|
||||
flash, class = why, "warn"
|
||||
break
|
||||
}
|
||||
if rec, err := addSanction(a, sanctionByLocalUI); err != nil {
|
||||
flash, class = "Not marked: "+err.Error(), "bad"
|
||||
} else {
|
||||
flash, class = "Marked as yours: "+rec.sanctionIdentity.describe()+".", "ok"
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
p := collectAgentPage(pid, uiNow())
|
||||
p.Flash, p.FlashClass = flash, class
|
||||
u.renderView(w, "agent-page", p)
|
||||
}
|
||||
|
||||
// ── what is asked of this machine ───────────────────────────────────────────
|
||||
|
||||
type uiMachineAsk struct {
|
||||
ID string
|
||||
Short string
|
||||
Text string
|
||||
At string
|
||||
}
|
||||
|
||||
// machineAsks is every pending request in the owner's factor, oldest first.
|
||||
func machineAsks() []uiMachineAsk {
|
||||
dir := existingMachineFactorDir()
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
stepupMu.Lock()
|
||||
m := requestsLoad(dir)
|
||||
stepupMu.Unlock()
|
||||
var out []uiMachineAsk
|
||||
for _, id := range sortedPending(m) {
|
||||
short := id
|
||||
if len(short) > 8 {
|
||||
short = short[:8]
|
||||
}
|
||||
out = append(out, uiMachineAsk{ID: id, Short: short, Text: describeRequest(m[id]), At: m[id].At})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// machineApprove answers one request asked of this machine with the owner's
|
||||
// code, and carries out what it approved at once (a stop: localStopOnce), so
|
||||
// the person sees what happened rather than a promise.
|
||||
func (u *uiServer) machineApprove(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
dir := existingMachineFactorDir()
|
||||
if !stepupEnrolled(dir) {
|
||||
u.renderApprovals(w, "approvals", "Set up your code under Settings first.", "warn")
|
||||
return
|
||||
}
|
||||
code := strings.TrimSpace(r.FormValue("code"))
|
||||
if code == "" {
|
||||
u.renderApprovals(w, "approvals", "Type the code from your authenticator.", "warn")
|
||||
return
|
||||
}
|
||||
id := r.FormValue("id")
|
||||
approval, err := machineCodeSpend(dir, uiNow(), func() (string, error) {
|
||||
return stepupApproveRequest(dir, id, code)
|
||||
})
|
||||
if err != nil {
|
||||
u.renderApprovals(w, "approvals", "Not approved: "+err.Error()+".", "bad")
|
||||
return
|
||||
}
|
||||
localStopOnce(dir, uiNow())
|
||||
if res, ok := waitStopResult(dir, approval, 5*time.Second); ok {
|
||||
class := "ok"
|
||||
if strings.HasPrefix(res.Outcome, "not stopped") {
|
||||
class = "warn"
|
||||
}
|
||||
u.renderApprovals(w, "approvals", res.Outcome, class)
|
||||
return
|
||||
}
|
||||
u.renderApprovals(w, "approvals", "Approved.", "ok")
|
||||
}
|
||||
|
||||
// machineDeny refuses one. No code: refusing is always the safe direction.
|
||||
func (u *uiServer) machineDeny(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
dir := existingMachineFactorDir()
|
||||
if dir == "" || stepupDenyRequest(dir, r.FormValue("id")) != nil {
|
||||
u.renderApprovals(w, "approvals", "That request is no longer waiting.", "warn")
|
||||
return
|
||||
}
|
||||
u.renderApprovals(w, "approvals", "Denied. Nothing was done.", "ok")
|
||||
}
|
||||
|
||||
type uiActivityView struct {
|
||||
Version string
|
||||
Kind string // the filter: "" (all), stop, approval, host, change
|
||||
Items []activityItem
|
||||
Counts map[string]int
|
||||
}
|
||||
|
||||
func (u *uiServer) activityPage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
all := collectActivity(uiNow().Add(-7 * 24 * time.Hour))
|
||||
v := uiActivityView{Version: clientVersion, Counts: map[string]int{}}
|
||||
switch k := r.URL.Query().Get("kind"); k {
|
||||
case "stop", "approval", "host", "change":
|
||||
v.Kind = k
|
||||
}
|
||||
for _, it := range all {
|
||||
v.Counts[it.Kind]++
|
||||
if v.Kind == "" || it.Kind == v.Kind {
|
||||
v.Items = append(v.Items, it)
|
||||
}
|
||||
}
|
||||
u.renderView(w, "activity-page", v)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// actsWorld: one agent with a child, the owner's code set up, nothing real
|
||||
// signalled.
|
||||
func actsWorld(t *testing.T) (dir string, killed *[]int) {
|
||||
t.Helper()
|
||||
uiTestConfigDir(t)
|
||||
killed = stubStopWorld(t, []agentObs{{PID: 101, Kind: kindAgent, Signature: "claude-code", User: "u"}},
|
||||
map[int]int{102: 101})
|
||||
prev := homeForUserFn
|
||||
t.Cleanup(func() { homeForUserFn = prev })
|
||||
homeForUserFn = func(string) string { return "" }
|
||||
d, err := machineFactorDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enrolFactorAt(t, d, machineTestSecret)
|
||||
return d, killed
|
||||
}
|
||||
|
||||
func TestStopFromTheConsoleTakesTheCodeAndTheAgentItShowed(t *testing.T) {
|
||||
_, killed := actsWorld(t)
|
||||
now := time.Now()
|
||||
if f, _ := stopFromConsole(101, "claude-code", "", now); !strings.Contains(f, "type the code") {
|
||||
t.Errorf("no code: %q", f)
|
||||
}
|
||||
if f, _ := stopFromConsole(101, "aider", codeFor(t, machineTestSecret), now); !strings.Contains(f, "is now claude-code") {
|
||||
t.Errorf("a pid that changed hands: %q", f)
|
||||
}
|
||||
if f, _ := stopFromConsole(101, "claude-code", codeFor(t, hostTestSecret), now); !strings.Contains(f, "code refused") {
|
||||
t.Errorf("a wrong code: %q", f)
|
||||
}
|
||||
if len(*killed) != 0 {
|
||||
t.Fatalf("a refused stop signalled %v", *killed)
|
||||
}
|
||||
f, class := stopFromConsole(101, "claude-code", codeFor(t, machineTestSecret), now)
|
||||
if class != "ok" || !strings.HasPrefix(f, "You stopped claude-code (pid 101)") || len(*killed) != 2 {
|
||||
t.Errorf("stop: %q %s, killed %v", f, class, *killed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopFromTheConsoleNeedsTheCodeSetUp(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
killed := stubStopWorld(t, []agentObs{{PID: 101, Kind: kindAgent, Signature: "claude-code"}}, nil)
|
||||
if f, _ := stopFromConsole(101, "claude-code", "123456", time.Now()); !strings.Contains(f, "set up your code") {
|
||||
t.Errorf("no code set up: %q", f)
|
||||
}
|
||||
if len(*killed) != 0 {
|
||||
t.Fatal("stopped with no code set up")
|
||||
}
|
||||
}
|
||||
|
||||
// A mark takes the owner's code; with none set up, nothing is marked.
|
||||
func TestAMarkTakesTheCodeOnceItIsSetUp(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
if why := markCodeRefusal("fp1", "123456"); !strings.Contains(why, "set up your code") {
|
||||
t.Errorf("with no code set up a mark was made: %q", why)
|
||||
}
|
||||
d, err := machineFactorDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enrolFactorAt(t, d, machineTestSecret)
|
||||
if why := markCodeRefusal("fp1", ""); !strings.Contains(why, "type the code") {
|
||||
t.Errorf("no code: %q", why)
|
||||
}
|
||||
if why := markCodeRefusal("fp1", codeFor(t, hostTestSecret)); !strings.Contains(why, "code refused") {
|
||||
t.Errorf("a wrong code: %q", why)
|
||||
}
|
||||
if why := markCodeRefusal("fp1", codeFor(t, machineTestSecret)); why != "" {
|
||||
t.Errorf("the owner's code: %q", why)
|
||||
}
|
||||
}
|
||||
|
||||
// A stop an agent asked for waits on Waiting for you in plain words, and the
|
||||
// owner's code there carries it out.
|
||||
func TestWaitingForYouAnswersWhatIsAskedOfTheMachine(t *testing.T) {
|
||||
d, killed := actsWorld(t)
|
||||
purpose, _ := purposeForLocalStop(101, "claude-code")
|
||||
id, err := stepupNewRequest(d, purpose, map[string]any{"kind": localStopKind, "pid": "101",
|
||||
"signature": "claude-code", "agent": "Claude Code", "session": "shop-a1",
|
||||
"asked_by": "Claude Code session notes-b2 (pid 300)"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := uiTestServer(t)
|
||||
h, cookie := u.handler(), uiSignedIn(t, u)
|
||||
page := uiGet(t, h, "/approvals", cookie).Body.String()
|
||||
for _, want := range []string{"Asked of this machine", "Stop Claude Code session shop-a1 (pid 101)",
|
||||
"Asked from Claude Code session notes-b2 (pid 300)."} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Errorf("Waiting for you does not say %q", want)
|
||||
}
|
||||
}
|
||||
w := uiPost(t, h, "/approvals/machine/approve", url.Values{"id": {id}, "code": {codeFor(t, machineTestSecret)}}, cookie)
|
||||
if body := w.Body.String(); !strings.Contains(body, "You stopped claude-code (pid 101)") {
|
||||
t.Errorf("approve answered %q", firstLines(body))
|
||||
}
|
||||
if len(*killed) != 2 {
|
||||
t.Errorf("killed %v", *killed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDenyingWhatIsAskedOfTheMachineTakesNoCodeAndDoesNothing(t *testing.T) {
|
||||
d, killed := actsWorld(t)
|
||||
purpose, _ := purposeForLocalStop(101, "claude-code")
|
||||
id, err := stepupNewRequest(d, purpose, map[string]any{"kind": localStopKind, "pid": "101", "signature": "claude-code"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := uiTestServer(t)
|
||||
w := uiPost(t, u.handler(), "/approvals/machine/deny", url.Values{"id": {id}}, uiSignedIn(t, u))
|
||||
if !strings.Contains(w.Body.String(), "Denied. Nothing was done.") || len(*killed) != 0 {
|
||||
t.Errorf("deny: %q, killed %v", firstLines(w.Body.String()), *killed)
|
||||
}
|
||||
if r := requestsLoad(d)[id]; r.State == "pending" {
|
||||
t.Error("the request is still waiting")
|
||||
}
|
||||
}
|
||||
|
||||
// The owner's code is set up from the Settings page with no Host at all:
|
||||
// start, read the key, confirm with a code from it.
|
||||
func TestTheOwnersCodeIsSetUpFromSettingsWithNoHost(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
u := uiTestServer(t)
|
||||
h, cookie := u.handler(), uiSignedIn(t, u)
|
||||
start := uiPost(t, h, "/setup/stepup/start", url.Values{"target": {"machine"}}, cookie).Body.String()
|
||||
m := regexp.MustCompile(`name="enrol" value="([^"]+)"`).FindStringSubmatch(start)
|
||||
if m == nil || !strings.Contains(start, "Privatae bastion") {
|
||||
t.Fatalf("no enrolment started:\n%s", firstLines(start))
|
||||
}
|
||||
key := uiPost(t, h, "/setup/stepup/key", url.Values{"enrol": {m[1]}}, cookie).Body.String()
|
||||
k := regexp.MustCompile(`[A-Z2-7]{32}`).FindString(key)
|
||||
if k == "" {
|
||||
t.Fatalf("no key shown:\n%s", firstLines(key))
|
||||
}
|
||||
done := uiPost(t, h, "/setup/stepup/confirm", url.Values{"enrol": {m[1]}, "code": {codeFor(t, k)}}, cookie).Body.String()
|
||||
if !strings.Contains(done, "Your code is set up") {
|
||||
t.Errorf("confirm answered:\n%s", firstLines(done))
|
||||
}
|
||||
if !stepupEnrolled(existingMachineFactorDir()) {
|
||||
t.Error("the owner's code was not written")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// homeTestWorld: one Claude Code session waiting for the person, with
|
||||
// permission prompts off and agent-written text that tries to be markup; its
|
||||
// background service; and an app. Every name and title here is invented.
|
||||
func homeTestWorld(t *testing.T, authority bool) {
|
||||
t.Helper()
|
||||
uiTestConfigDir(t)
|
||||
stubProcIdentity(t, "5551")
|
||||
home := fakeClaudeHome(t, 4242, "5551", `{"type":"ai-title","aiTitle":"<script>alert(1)</script> Rename invoices","sessionId":"x"}
|
||||
{"type":"permission-mode","permissionMode":"bypassPermissions","sessionId":"x"}
|
||||
{"type":"last-prompt","lastPrompt":"PRIVATE PROMPT TEXT","sessionId":"x"}
|
||||
`)
|
||||
prevObs, prevTree, prevHome, prevAuth, prevWait := observedAgents, processTree, homeForUserFn, machineAuthorityHeld, homeWaitingFn
|
||||
prevUID := uidForUserFn
|
||||
t.Cleanup(func() {
|
||||
observedAgents, processTree, homeForUserFn, machineAuthorityHeld, homeWaitingFn = prevObs, prevTree, prevHome, prevAuth, prevWait
|
||||
uidForUserFn = prevUID
|
||||
})
|
||||
uidForUserFn = func(string) (int, bool) { return os.Getuid(), true }
|
||||
observedAgents = func() ([]agentObs, error) {
|
||||
return []agentObs{
|
||||
{PID: 4242, Kind: kindAgent, Signature: "claude-code", User: "u", Cmdline: "claude", Origin: originRemote},
|
||||
{PID: 4300, Kind: kindAgent, Signature: "claude-code", User: "u", Cmdline: "claude.exe daemon run --spawned"},
|
||||
{PID: 5000, Kind: kindApp, Signature: "claude-code", User: "u", Cmdline: "Claude"},
|
||||
}, nil
|
||||
}
|
||||
processTree = func() map[int]int { return nil }
|
||||
homeForUserFn = func(string) string { return home }
|
||||
machineAuthorityHeld = func() bool { return authority }
|
||||
homeWaitingFn = func() int { return 2 }
|
||||
}
|
||||
|
||||
func TestTheHomePageCountsSessionsNotTheServiceOrApps(t *testing.T) {
|
||||
homeTestWorld(t, true)
|
||||
v := collectHomeView(time.Now())
|
||||
if v.Sessions != 1 || v.WaitingForYou != 1 || v.NotYours != 1 {
|
||||
t.Errorf("sessions %d, waiting %d, not yours %d; want 1, 1, 1", v.Sessions, v.WaitingForYou, v.NotYours)
|
||||
}
|
||||
if len(v.Families) != 1 || !strings.HasPrefix(v.Families[0].Heading, "Claude Code: 1 session (1 waiting for you)") {
|
||||
t.Fatalf("families %+v", v.Families)
|
||||
}
|
||||
rows := v.Families[0].Rows
|
||||
if !rows[0].WaitingForYou || !rows[0].PermissionsOff || rows[0].PID != 4242 {
|
||||
t.Errorf("the waiting session is not first, flagged: %+v", rows[0])
|
||||
}
|
||||
var service bool
|
||||
for _, r := range rows {
|
||||
service = service || (r.Service && r.PID == 4300)
|
||||
}
|
||||
if !service {
|
||||
t.Error("the background service is not shown as one")
|
||||
}
|
||||
if v.PerAccount || v.Waiting != 2 {
|
||||
t.Errorf("per-account %v, waiting %d", v.PerAccount, v.Waiting)
|
||||
}
|
||||
}
|
||||
|
||||
// What an agent writes about itself is text on this page, never markup, and
|
||||
// its last prompt is not on the home page at all.
|
||||
func TestTheHomePageShowsAgentTextAsTextAndNoPrompts(t *testing.T) {
|
||||
homeTestWorld(t, true)
|
||||
u := uiTestServer(t)
|
||||
body := uiGet(t, u.handler(), "/", uiSignedIn(t, u)).Body.String()
|
||||
if strings.Contains(body, "<script>alert(1)") {
|
||||
t.Fatal("an agent's title was rendered as markup")
|
||||
}
|
||||
if !strings.Contains(body, "<script>alert(1)</script> Rename invoices") {
|
||||
t.Errorf("the title is not shown, escaped:\n%s", firstLines(body))
|
||||
}
|
||||
if strings.Contains(body, "PRIVATE PROMPT TEXT") {
|
||||
t.Error("a session's last prompt is on the home page")
|
||||
}
|
||||
for _, want := range []string{"waiting for you: permission prompt", "permission prompts off",
|
||||
"Background service", "2 requests are waiting for your approval", `href="/agent?pid=4242"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the home page does not say %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A bastion in the person's own account says what that costs, on every load.
|
||||
func TestTheHomePageSaysWhenTheBastionRunsInYourOwnAccount(t *testing.T) {
|
||||
homeTestWorld(t, false)
|
||||
u := uiTestServer(t)
|
||||
body := uiGet(t, u.handler(), "/fragments/home", uiSignedIn(t, u)).Body.String()
|
||||
if !strings.Contains(body, "This bastion runs in your own account.") {
|
||||
t.Error("a per-account bastion did not say so")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAgentsPageShowsItsSessionAndItsLastPrompt(t *testing.T) {
|
||||
homeTestWorld(t, true)
|
||||
u := uiTestServer(t)
|
||||
w := uiGet(t, u.handler(), "/agent?pid=4242", uiSignedIn(t, u))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("/agent = %d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"shop-a1", "PRIVATE PROMPT TEXT", "Permission prompts are off",
|
||||
"what Claude Code says about this session", "The stopping rule"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the agent page does not say %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "<script>alert(1)") {
|
||||
t.Fatal("an agent's title was rendered as markup on its page")
|
||||
}
|
||||
gone := uiGet(t, u.handler(), "/agent?pid=999", uiSignedIn(t, u)).Body.String()
|
||||
if !strings.Contains(gone, "Not running") {
|
||||
t.Error("a pid that is no agent did not say so")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAgentsToolServersAreItsOwnClientsAndSayWhatIsWrong(t *testing.T) {
|
||||
servers := []mcpServerObs{
|
||||
{Client: "claude-code", Path: "/home/u/.claude.json", Name: "files", SecretInPlaintext: true,
|
||||
PackageName: "mcp-files", PackagePinned: "no"},
|
||||
{Client: "claude-code", Path: "/work/shop/.mcp.json", Name: "db", PackagePinned: "yes"},
|
||||
{Client: "cursor", Path: "/home/u/.cursor/mcp.json", Name: "other-client"},
|
||||
{Client: "claude-code", Path: "/home/someone-else/.claude.json", Name: "not-this-account"},
|
||||
}
|
||||
got := toolServersFor("claude-code", "/home/u", "/work/shop", servers)
|
||||
if len(got) != 2 || got[0].Name != "files" || got[1].Name != "db" {
|
||||
t.Fatalf("tool servers %+v", got)
|
||||
}
|
||||
if len(got[0].Problems) != 2 || !strings.Contains(got[0].Problems[0], "plain text") ||
|
||||
!strings.Contains(got[0].Problems[1], "no fixed version") {
|
||||
t.Errorf("problems %v", got[0].Problems)
|
||||
}
|
||||
}
|
||||
|
||||
// The first run says what to do next, and stops saying it once the owner's
|
||||
// code and a baseline are in place.
|
||||
func TestTheHomePageWalksAFirstRunThroughCodeAndBaseline(t *testing.T) {
|
||||
homeTestWorld(t, true)
|
||||
u := uiTestServer(t)
|
||||
h, cookie := u.handler(), uiSignedIn(t, u)
|
||||
body := uiGet(t, h, "/fragments/home", cookie).Body.String()
|
||||
for _, want := range []string{"Getting started", "Set up your code", "Record a baseline", "connect a Host"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("a first run does not say %q", want)
|
||||
}
|
||||
}
|
||||
d, err := machineFactorDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enrolFactorAt(t, d, machineTestSecret)
|
||||
if f, _ := recordBaselineFromConsole(codeFor(t, machineTestSecret)); !strings.HasPrefix(f, "Recorded") {
|
||||
t.Fatalf("baseline: %q", f)
|
||||
}
|
||||
if body := uiGet(t, h, "/fragments/home", cookie).Body.String(); strings.Contains(body, "Getting started") {
|
||||
t.Error("the first-run steps still show with the code and a baseline in place")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package main
|
||||
|
||||
// uimachine.go — the sections of This machine (plan Addendum 4, step 4):
|
||||
// scheduled jobs that start an agent, the MCP tool servers configured here
|
||||
// and what is wrong with them, and what runs that the baseline does not
|
||||
// hold. Each loads once when the page opens (hx-trigger="load"), apart from
|
||||
// the machine's state above them, which refreshes every three seconds: these
|
||||
// read files and walk processes, and none of them changes that fast.
|
||||
//
|
||||
// Recording a baseline takes the owner's code. It decides what counts as
|
||||
// normal here, and everything in it stops being reported, so it is the act
|
||||
// an agent clicking through a signed-in console would most want.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type uiSchedule struct {
|
||||
Agent string `json:"agent"`
|
||||
Schedule string `json:"schedule"`
|
||||
Where string `json:"where"`
|
||||
User string `json:"user,omitempty"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
func collectSchedules() ([]uiSchedule, bool) {
|
||||
entries, scan := scanSchedulers()
|
||||
var out []uiSchedule
|
||||
for _, e := range entries {
|
||||
args, _ := redactSecretArgs(strings.Fields(e.Command))
|
||||
out = append(out, uiSchedule{
|
||||
Agent: familyLabel(e.Signature), Schedule: cronWords(e.Schedule), Where: e.Kind + " " + e.Name,
|
||||
User: e.User, Command: cleanText(strings.Join(args, " "), 160),
|
||||
})
|
||||
}
|
||||
return out, scan.Supported
|
||||
}
|
||||
|
||||
type uiToolGroup struct {
|
||||
Client string `json:"client"`
|
||||
Servers []uiToolServer `json:"servers"`
|
||||
Bad int `json:"with_problems"`
|
||||
}
|
||||
|
||||
// toolProblems is one tool server's problems in plain words: the same
|
||||
// sentences an agent's page uses (toolServersFor).
|
||||
func toolProblems(s mcpServerObs) []string {
|
||||
if s.Disabled {
|
||||
return nil
|
||||
}
|
||||
var p []string
|
||||
if s.SecretInPlaintext {
|
||||
p = append(p, "a key is written in plain text in "+s.Path)
|
||||
}
|
||||
if s.PackagePinned == "no" && s.PackageName != "" {
|
||||
p = append(p, "runs "+s.PackageName+" with no fixed version, so whatever is published next is what runs")
|
||||
}
|
||||
if s.RunningState == "running_not_declared" {
|
||||
p = append(p, "running, but not in any settings file this machine can read")
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func collectToolGroups() []uiToolGroup {
|
||||
servers, _ := readMCPConfigs()
|
||||
by := map[string]*uiToolGroup{}
|
||||
var order []string
|
||||
for _, s := range servers {
|
||||
c := s.Client
|
||||
if c == "" {
|
||||
c = "not in any settings file"
|
||||
}
|
||||
g, ok := by[c]
|
||||
if !ok {
|
||||
g = &uiToolGroup{Client: familyLabel(c)}
|
||||
by[c] = g
|
||||
order = append(order, c)
|
||||
}
|
||||
name := s.Name
|
||||
if name == "" {
|
||||
name = s.PackageName
|
||||
}
|
||||
t := uiToolServer{Name: name, Where: s.Path, Problems: toolProblems(s)}
|
||||
if len(t.Problems) > 0 {
|
||||
g.Bad++
|
||||
}
|
||||
g.Servers = append(g.Servers, t)
|
||||
}
|
||||
sort.Strings(order)
|
||||
var out []uiToolGroup
|
||||
for _, c := range order {
|
||||
g := by[c]
|
||||
sort.SliceStable(g.Servers, func(i, j int) bool { return len(g.Servers[i].Problems) > len(g.Servers[j].Problems) })
|
||||
out = append(out, *g)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── the baseline ────────────────────────────────────────────────────────────
|
||||
|
||||
type uiBaselineView struct {
|
||||
Flash string
|
||||
FlashClass string
|
||||
Has bool
|
||||
TakenAt string
|
||||
Entries int
|
||||
Note string
|
||||
OwnerCode bool
|
||||
Compared bool
|
||||
ComparedAt string
|
||||
State string
|
||||
Findings []string
|
||||
WalkFailed string
|
||||
}
|
||||
|
||||
// lastDelta keeps the last comparison for the page: a delta pass walks every
|
||||
// process and digests their files, so it runs when asked, not on every load.
|
||||
var lastDelta struct {
|
||||
sync.Mutex
|
||||
r *deltaReport
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func collectBaselineView() uiBaselineView {
|
||||
v := uiBaselineView{OwnerCode: stepupEnrolled(existingMachineFactorDir())}
|
||||
if f, ok, err := baselineLoad(); err == nil {
|
||||
v.Has, v.TakenAt, v.Entries, v.Note = true, f.TakenAt, len(f.Entries), baselineIntegrityNote(f, ok)
|
||||
}
|
||||
lastDelta.Lock()
|
||||
r, at := lastDelta.r, lastDelta.at
|
||||
lastDelta.Unlock()
|
||||
if r != nil {
|
||||
v.Compared, v.ComparedAt, v.State, v.WalkFailed = true, at.Local().Format("15:04:05"), r.Baseline, r.WalkFailed
|
||||
for _, ev := range r.Findings {
|
||||
v.Findings = append(v.Findings, ev.Level+": "+deltaOneLine(ev))
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (u *uiServer) machineRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/fragments/machine/schedules", u.guarded(u.machineSchedules))
|
||||
mux.HandleFunc("/fragments/machine/tools", u.guarded(u.machineTools))
|
||||
mux.HandleFunc("/fragments/machine/baseline", u.guarded(u.machineBaseline))
|
||||
mux.HandleFunc("/machine/compare", u.guarded(u.machineCompare))
|
||||
mux.HandleFunc("/machine/baseline", u.guarded(u.machineRecordBaseline))
|
||||
mux.HandleFunc("/machine/report", u.guarded(u.machineReport))
|
||||
}
|
||||
|
||||
// machineReport writes the report (report.go) and hands it to the browser as
|
||||
// a file. A POST: writing it walks every process and compares the baseline.
|
||||
func (u *uiServer) machineReport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
now := uiNow()
|
||||
rep := buildReport(now, now.Add(-7*24*time.Hour))
|
||||
name := "bastion-report-" + now.Format("2006-01-02-1504") + ".md"
|
||||
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
renderReportMarkdown(w, rep)
|
||||
}
|
||||
|
||||
func (u *uiServer) machineSchedules(w http.ResponseWriter, r *http.Request) {
|
||||
s, ok := collectSchedules()
|
||||
u.renderView(w, "machine-schedules", map[string]any{"Items": s, "Supported": ok})
|
||||
}
|
||||
|
||||
func (u *uiServer) machineTools(w http.ResponseWriter, r *http.Request) {
|
||||
u.renderView(w, "machine-tools", collectToolGroups())
|
||||
}
|
||||
|
||||
func (u *uiServer) machineBaseline(w http.ResponseWriter, r *http.Request) {
|
||||
u.renderView(w, "machine-baseline", collectBaselineView())
|
||||
}
|
||||
|
||||
func (u *uiServer) machineCompare(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
rep := deltaPass(&digestCache{}, time.Now(), deltaRoleOperator)
|
||||
lastDelta.Lock()
|
||||
lastDelta.r, lastDelta.at = &rep, time.Now()
|
||||
lastDelta.Unlock()
|
||||
u.renderView(w, "machine-baseline", collectBaselineView())
|
||||
}
|
||||
|
||||
func (u *uiServer) machineRecordBaseline(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
flash, class := recordBaselineFromConsole(r.FormValue("code"))
|
||||
v := collectBaselineView()
|
||||
v.Flash, v.FlashClass = flash, class
|
||||
u.renderView(w, "machine-baseline", v)
|
||||
}
|
||||
|
||||
// recordBaselineFromConsole takes the owner's code, then the same baseline
|
||||
// `observe:baseline --force` takes (collectBaseline, baselineSave).
|
||||
func recordBaselineFromConsole(code string) (string, string) {
|
||||
dir := existingMachineFactorDir()
|
||||
if !stepupEnrolled(dir) {
|
||||
return "Set up your code under Settings first. Recording a baseline takes it.", "warn"
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return "Type the code from your authenticator.", "warn"
|
||||
}
|
||||
approval, err := machineCodeSpend(dir, uiNow(), func() (string, error) {
|
||||
return stepupIssueApproval(dir, code, "local_baseline")
|
||||
})
|
||||
if err != nil {
|
||||
return "Not recorded: " + err.Error() + ".", "bad"
|
||||
}
|
||||
if err := stepupConsumeApproval(dir, approval, "local_baseline"); err != nil {
|
||||
return "Not recorded: " + err.Error() + ".", "bad"
|
||||
}
|
||||
f, _, _, _, err := collectBaseline()
|
||||
if err != nil {
|
||||
return "Not recorded: " + err.Error() + ".", "bad"
|
||||
}
|
||||
if err := baselineSave(f); err != nil {
|
||||
return "Not recorded: " + err.Error() + ".", "bad"
|
||||
}
|
||||
dcnAudit(dir, "local_baseline", 0, "", "recorded: "+f.TakenAt)
|
||||
lastDelta.Lock()
|
||||
lastDelta.r = nil
|
||||
lastDelta.Unlock()
|
||||
return "Recorded. What runs here now is the baseline; anything new from here on is reported.", "ok"
|
||||
}
|
||||
|
||||
// cronWords says the common cron schedules in words; anything else stays as
|
||||
// written, which is what the file says.
|
||||
func cronWords(spec string) string {
|
||||
f := strings.Fields(spec)
|
||||
if len(f) != 5 {
|
||||
return spec
|
||||
}
|
||||
min, hour, dom, mon, dow := f[0], f[1], f[2], f[3], f[4]
|
||||
num := func(s string) (int, bool) {
|
||||
n, err := strconv.Atoi(s)
|
||||
return n, err == nil
|
||||
}
|
||||
days := map[string]string{"0": "Sunday", "1": "Monday", "2": "Tuesday", "3": "Wednesday", "4": "Thursday",
|
||||
"5": "Friday", "6": "Saturday", "7": "Sunday"}
|
||||
m, mOK := num(min)
|
||||
h, hOK := num(hour)
|
||||
switch {
|
||||
case strings.HasPrefix(min, "*/") && hour == "*" && dom == "*" && mon == "*" && dow == "*":
|
||||
return "every " + strings.TrimPrefix(min, "*/") + " minutes"
|
||||
case min == "*" && hour == "*" && dom == "*" && mon == "*" && dow == "*":
|
||||
return "every minute"
|
||||
case mOK && hour == "*" && dom == "*" && mon == "*" && dow == "*":
|
||||
return fmt.Sprintf("every hour at :%02d", m)
|
||||
case mOK && hOK && dom == "*" && mon == "*" && dow == "*":
|
||||
return fmt.Sprintf("every day at %02d:%02d", h, m)
|
||||
case mOK && hOK && dom == "*" && mon == "*" && days[dow] != "":
|
||||
return fmt.Sprintf("every %s at %02d:%02d", days[dow], h, m)
|
||||
}
|
||||
return spec
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Recording a baseline decides what counts as normal, so it takes the
|
||||
// owner's code: none set up, none typed, or the wrong one, and nothing is
|
||||
// written.
|
||||
func TestRecordingABaselineTakesTheOwnersCode(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
if f, _ := recordBaselineFromConsole("123456"); !strings.Contains(f, "Set up your code") {
|
||||
t.Errorf("no code set up: %q", f)
|
||||
}
|
||||
d, err := machineFactorDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enrolFactorAt(t, d, machineTestSecret)
|
||||
if f, _ := recordBaselineFromConsole(""); !strings.Contains(f, "Type the code") {
|
||||
t.Errorf("no code typed: %q", f)
|
||||
}
|
||||
if f, _ := recordBaselineFromConsole(codeFor(t, hostTestSecret)); !strings.Contains(f, "code refused") {
|
||||
t.Errorf("a wrong code: %q", f)
|
||||
}
|
||||
if _, _, err := baselineLoad(); err == nil {
|
||||
t.Fatal("a baseline was written without the owner's code")
|
||||
}
|
||||
f, class := recordBaselineFromConsole(codeFor(t, machineTestSecret))
|
||||
if class != "ok" {
|
||||
t.Fatalf("the owner's code: %q", f)
|
||||
}
|
||||
if _, _, err := baselineLoad(); err != nil {
|
||||
t.Errorf("recorded, but no baseline to load: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolServerProblemsAreSaidInPlainWords(t *testing.T) {
|
||||
p := toolProblems(mcpServerObs{Path: "/h/.claude.json", SecretInPlaintext: true, PackageName: "mcp-x",
|
||||
PackagePinned: "no", RunningState: "running_not_declared"})
|
||||
if len(p) != 3 || !strings.Contains(p[0], "plain text in /h/.claude.json") ||
|
||||
!strings.Contains(p[1], "no fixed version") || !strings.Contains(p[2], "not in any settings file") {
|
||||
t.Errorf("problems %q", p)
|
||||
}
|
||||
if p := toolProblems(mcpServerObs{SecretInPlaintext: true, Disabled: true}); len(p) != 0 {
|
||||
t.Errorf("a turned-off server reported %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThisMachineLoadsItsSections(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
u := uiTestServer(t)
|
||||
h, cookie := u.handler(), uiSignedIn(t, u)
|
||||
page := uiGet(t, h, "/machine", cookie).Body.String()
|
||||
for _, frag := range []string{"/fragments/machine/schedules", "/fragments/machine/tools", "/fragments/machine/baseline"} {
|
||||
if !strings.Contains(page, `hx-get="`+frag+`"`) {
|
||||
t.Errorf("This machine does not load %s", frag)
|
||||
}
|
||||
if w := uiGet(t, h, frag, cookie); w.Code != http.StatusOK {
|
||||
t.Errorf("%s = %d", frag, w.Code)
|
||||
}
|
||||
}
|
||||
if body := uiGet(t, h, "/fragments/machine/baseline", cookie).Body.String(); !strings.Contains(body, "No baseline yet") {
|
||||
t.Errorf("baseline section: %q", firstLines(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommonCronSchedulesAreSaidInWords(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"0 3 * * *": "every day at 03:00",
|
||||
"*/15 * * * *": "every 15 minutes",
|
||||
"5 * * * *": "every hour at :05",
|
||||
"30 9 * * 1": "every Monday at 09:30",
|
||||
"0 3 1 * *": "0 3 1 * *",
|
||||
"@daily": "@daily",
|
||||
} {
|
||||
if got := cronWords(in); got != want {
|
||||
t.Errorf("%q = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,8 @@ type uiDormantSanction struct {
|
||||
type uiAgentsView struct {
|
||||
Version string
|
||||
CheckedAt string
|
||||
// OwnerCode: marking takes the owner's code (markCodeRefusal).
|
||||
OwnerCode bool
|
||||
|
||||
Rows []uiAgentRow
|
||||
Observed int
|
||||
@@ -169,6 +171,7 @@ func collectAgentsView() uiAgentsView {
|
||||
Version: clientVersion,
|
||||
CheckedAt: uiNow().Format("15:04:05"),
|
||||
Blind: !agentScanSupported,
|
||||
OwnerCode: stepupEnrolled(existingMachineFactorDir()),
|
||||
}
|
||||
if _, err := loadSanctions(); err != nil {
|
||||
v.ReadError = err.Error()
|
||||
@@ -381,6 +384,10 @@ func (u *uiServer) agentsMark(w http.ResponseWriter, r *http.Request) {
|
||||
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")
|
||||
@@ -419,3 +426,37 @@ func (u *uiServer) agentsUnmark(w http.ResponseWriter, r *http.Request) {
|
||||
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 ""
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ import (
|
||||
func agentsTestBox(t *testing.T, seen ...agentObs) {
|
||||
t.Helper()
|
||||
uiTestConfigDir(t)
|
||||
// Marking takes the owner's code (markCodeRefusal); the box has one.
|
||||
d, err := machineFactorDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enrolFactorAt(t, d, machineTestSecret)
|
||||
prev := observedAgents
|
||||
t.Cleanup(func() { observedAgents = prev })
|
||||
observedAgents = func() ([]agentObs, error) { return seen, nil }
|
||||
@@ -122,7 +128,7 @@ func TestMarkingAndUnmarkingThroughThePage(t *testing.T) {
|
||||
cookie := uiSignedIn(t, u)
|
||||
id := sanctionIdentityOf(mine).fingerprint()
|
||||
|
||||
w := uiPost(t, h, "/agents/mark", url.Values{"id": {id}}, cookie)
|
||||
w := uiPost(t, h, "/agents/mark", url.Values{"id": {id}, "code": {codeFor(t, machineTestSecret)}}, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("mark = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -207,7 +213,7 @@ func TestNothingOnThisPageCanTerminateAnything(t *testing.T) {
|
||||
id := sanctionIdentityOf(mine).fingerprint()
|
||||
|
||||
body := agentsGet(t, h, "/agents", cookie).Body.String()
|
||||
uiPost(t, h, "/agents/mark", url.Values{"id": {id}}, cookie)
|
||||
uiPost(t, h, "/agents/mark", url.Values{"id": {id}, "code": {codeFor(t, machineTestSecret)}}, cookie)
|
||||
uiPost(t, h, "/agents/unmark", url.Values{"id": {id}}, cookie)
|
||||
if len(killed) != 0 {
|
||||
t.Fatalf("this page terminated %v — containment is host-directed and marking "+
|
||||
|
||||
+114
-13
@@ -95,6 +95,9 @@ const uiEnrolTTL = 10 * time.Minute
|
||||
// console — and that reason does not apply in a browser, so the default here is
|
||||
// the stricter one.
|
||||
type uiEnrol struct {
|
||||
// Machine: the owner's own code (stepup_machine.go), not a Host's. Host
|
||||
// is empty then.
|
||||
Machine bool
|
||||
Host string
|
||||
SecretB32 string
|
||||
URI string
|
||||
@@ -198,15 +201,23 @@ type uiSetup struct {
|
||||
func (u *uiServer) setupRoutes(mux *http.ServeMux) *uiSetup {
|
||||
s := &uiSetup{u: u, enrols: newUIEnrolStore()}
|
||||
mux.HandleFunc("/setup", u.guarded(s.page))
|
||||
mux.HandleFunc("/setup/pair", u.guarded(s.pair))
|
||||
mux.HandleFunc("/setup/stepup/start", u.guarded(s.stepupStart))
|
||||
mux.HandleFunc("/setup/stepup/qr", u.guarded(s.stepupQR))
|
||||
mux.HandleFunc("/setup/stepup/key", u.guarded(s.stepupKey))
|
||||
mux.HandleFunc("/setup/stepup/confirm", u.guarded(s.stepupConfirm))
|
||||
mux.HandleFunc("/setup/folder/check", u.guarded(s.folderCheck))
|
||||
mux.HandleFunc("/setup/folder/propose", u.guarded(s.folderPropose))
|
||||
mux.HandleFunc("/setup/anchor/choose", u.guarded(s.anchorChoose))
|
||||
mux.HandleFunc("/setup/pair", u.adminOnly(s.pair))
|
||||
mux.HandleFunc("/setup/stepup/start", u.adminOnly(s.stepupStart))
|
||||
mux.HandleFunc("/setup/stepup/qr", u.adminOnly(s.stepupQR))
|
||||
mux.HandleFunc("/setup/stepup/key", u.adminOnly(s.stepupKey))
|
||||
mux.HandleFunc("/setup/stepup/confirm", u.adminOnly(s.stepupConfirm))
|
||||
// The folder check stats any path the page posts, as the daemon: root on a
|
||||
// whole-machine install. It exists only to help fill in the proposal,
|
||||
// which is the administrator's, so it is the administrator's too; from the
|
||||
// owner's lane it would tell a program running as the person what exists
|
||||
// in folders that account cannot read.
|
||||
mux.HandleFunc("/setup/folder/check", u.adminOnly(s.folderCheck))
|
||||
mux.HandleFunc("/setup/folder/propose", u.adminOnly(s.folderPropose))
|
||||
mux.HandleFunc("/setup/anchor/choose", u.adminOnly(s.anchorChoose))
|
||||
mux.HandleFunc("/setup/owner/name", u.guarded(s.ownerName))
|
||||
// The owner's acts on a Host (uisetup_owner.go).
|
||||
mux.HandleFunc("/setup/host/disconnect", u.guarded(s.hostDisconnect))
|
||||
mux.HandleFunc("/setup/host/release", u.guarded(s.hostRelease))
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -217,17 +228,21 @@ type uiSetupHost struct {
|
||||
State string
|
||||
Active bool
|
||||
IsAnchor bool
|
||||
Serving bool // its loopback control port answers — add-folder goes there
|
||||
Stepup bool // a second factor is enrolled for this pairing
|
||||
// QuarantinedUntil: this machine refuses the Host's writes until then
|
||||
// (quarantine.go); Release takes the owner's code.
|
||||
QuarantinedUntil string
|
||||
Serving bool // its loopback control port answers — add-folder goes there
|
||||
Stepup bool // a second factor is enrolled for this pairing
|
||||
}
|
||||
|
||||
// uiEnrolView is an enrolment in flight. The SECRET IS NOT IN IT: the handle
|
||||
// addresses it server-side and the QR route turns it into pixels. Key is filled
|
||||
// only by the one route the person has to press a button to reach.
|
||||
type uiEnrolView struct {
|
||||
Handle string
|
||||
Host string
|
||||
Key string
|
||||
Handle string
|
||||
Machine bool
|
||||
Host string
|
||||
Key string
|
||||
}
|
||||
|
||||
// uiFolderCheck is one typed path resolved against the real filesystem, before
|
||||
@@ -258,6 +273,15 @@ type uiSetupView struct {
|
||||
AnchorOwner string
|
||||
AnchorRequired bool
|
||||
Owner string
|
||||
// OwnerCode: the machine owner's code is set up (stepup_machine.go).
|
||||
OwnerCode bool
|
||||
// Whole: the bastion runs for the whole machine, so the owner acts that
|
||||
// need the machine's administrator can be done from this page.
|
||||
Whole bool
|
||||
// Limited: opened from the owner's own account (consolelane.go). Setting
|
||||
// up the code, connecting a Host, the Host in charge and folder proposals
|
||||
// are the administrator's there.
|
||||
Limited bool
|
||||
|
||||
// ReadError is why the pairing list could not be read — see uiGiven's copy
|
||||
// of this field. Returning the bare view here showed an empty setup page
|
||||
@@ -291,6 +315,8 @@ func (s *uiSetup) view() uiSetupView {
|
||||
if o := currentOwnerClaim(); o != nil {
|
||||
v.Owner = o.Owner
|
||||
}
|
||||
v.OwnerCode = stepupEnrolled(existingMachineFactorDir())
|
||||
v.Whole = machineAuthorityHeld()
|
||||
cfgs, err := loadPairings()
|
||||
if err != nil {
|
||||
v.ReadError = err.Error()
|
||||
@@ -303,6 +329,9 @@ func (s *uiSetup) view() uiSetupView {
|
||||
Active: c.active(),
|
||||
IsAnchor: v.AnchorHost != "" && c.HostName == v.AnchorHost,
|
||||
}
|
||||
if until := quarantinedUntil(c.HostName, time.Now()); !until.IsZero() {
|
||||
h.QuarantinedUntil = until.Local().Format("Jan 2 15:04")
|
||||
}
|
||||
// dcnDir() is MkdirAll-on-read (config.go:115), so asking a PENDING
|
||||
// pairing whether it has a factor would create its state directory —
|
||||
// the side effect runPairing guards every one of these behind
|
||||
@@ -325,6 +354,7 @@ func (s *uiSetup) view() uiSetupView {
|
||||
// into that one because the view model differs; TestTheSetupPageIsHeldToTheSame
|
||||
// HeadersAsTheStatusPage is what stops the two drifting apart.
|
||||
func (s *uiSetup) render(w http.ResponseWriter, name string, v uiSetupView) {
|
||||
v.Limited = limitedWriter(w)
|
||||
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'")
|
||||
@@ -522,6 +552,10 @@ func (s *uiSetup) stepupStart(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r.FormValue("target") == "machine" {
|
||||
s.stepupStartMachine(w, r)
|
||||
return
|
||||
}
|
||||
cfg, err := setupHost(r.FormValue("host"))
|
||||
if err != nil {
|
||||
s.flash(w, err.Error(), "bad")
|
||||
@@ -664,6 +698,10 @@ func (s *uiSetup) stepupConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
s.flash(w, "Type a code from the authenticator you just scanned with.", "warn")
|
||||
return
|
||||
}
|
||||
if e.Machine {
|
||||
s.stepupConfirmMachine(w, handle, e, code)
|
||||
return
|
||||
}
|
||||
cfg, err := setupHost(e.Host)
|
||||
if err != nil {
|
||||
s.flash(w, err.Error(), "bad")
|
||||
@@ -951,6 +989,10 @@ func (s *uiSetup) ownerName(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
cmdView := func(c uiCommand) { s.render(w, "owner-cmd", uiSetupView{Cmd: &c}) }
|
||||
name := strings.TrimSpace(r.FormValue("owner"))
|
||||
if machineAuthorityHeld() && stepupEnrolled(existingMachineFactorDir()) {
|
||||
s.ownerNameHere(w, name, r.FormValue("code"))
|
||||
return
|
||||
}
|
||||
// claimNameOK (anchor.go:121) is the real rule: it is what stops a name
|
||||
// being read as more than one field of the signed input.
|
||||
if err := claimNameOK(name); err != nil {
|
||||
@@ -988,3 +1030,62 @@ func (s *uiSetup) ownerName(w http.ResponseWriter, r *http.Request) {
|
||||
func uiSetupTemplates() []string {
|
||||
return []string{"setup-page", "setup", "folder-check", "stepup-key", "anchor-cmd", "owner-cmd"}
|
||||
}
|
||||
|
||||
// ── the owner's own code ────────────────────────────────────────────────────
|
||||
|
||||
// stepupStartMachine is stepupStart for the machine owner's code: the same
|
||||
// in-memory secret, QR and confirm-before-write, written to the owner's
|
||||
// factor directory (stepup_machine.go) instead of a pairing's. It needs no
|
||||
// Host, which is the point: on a machine with none it is the only code there is.
|
||||
func (s *uiSetup) stepupStartMachine(w http.ResponseWriter, r *http.Request) {
|
||||
if stepupEnrolled(existingMachineFactorDir()) && r.FormValue("replace") == "" {
|
||||
s.flash(w, "Your code is already set up on this machine. Tick “replace it” to set up a new one — "+
|
||||
"the authenticator set up now stops working.", "warn")
|
||||
return
|
||||
}
|
||||
var secret [20]byte
|
||||
if _, err := rand.Read(secret[:]); err != nil {
|
||||
s.flash(w, "Could not generate a secret on this machine: "+err.Error(), "bad")
|
||||
return
|
||||
}
|
||||
b32 := strings.TrimRight(base32.StdEncoding.EncodeToString(secret[:]), "=")
|
||||
hn, _ := os.Hostname()
|
||||
// The same URI the CLI builds for --machine (stepup.go cmdStepupEnroll).
|
||||
uri := "otpauth://totp/" + url.PathEscape("Privatae bastion:"+hn) + "?secret=" + b32 +
|
||||
"&issuer=" + url.QueryEscape("Privatae bastion") + "&digits=6&period=30"
|
||||
handle, err := s.enrols.put(&uiEnrol{Machine: true, SecretB32: b32, URI: uri, Expires: uiNow().Add(uiEnrolTTL)})
|
||||
if err != nil {
|
||||
s.flash(w, "Could not start setting up your code: "+err.Error(), "bad")
|
||||
return
|
||||
}
|
||||
v := s.view()
|
||||
v.Enrol = &uiEnrolView{Handle: handle, Machine: true}
|
||||
v.Flash = "Scan the square with your authenticator app, then type a code from it below. " +
|
||||
"Nothing is written to this machine until that code matches."
|
||||
v.FlashClass = "ok"
|
||||
s.render(w, "setup", v)
|
||||
}
|
||||
|
||||
func (s *uiSetup) stepupConfirmMachine(w http.ResponseWriter, handle string, e *uiEnrol, code string) {
|
||||
if stepupMatchedCounter(e.SecretB32, code, time.Now()) < 0 {
|
||||
s.flash(w, "That code did not match — nothing was set up. Check the app is showing a code for "+
|
||||
"“Privatae bastion”, and try the next one.", "bad")
|
||||
return
|
||||
}
|
||||
dir, err := machineFactorDir()
|
||||
if err != nil {
|
||||
s.flash(w, "Could not open this machine's place for your code: "+err.Error(), "bad")
|
||||
return
|
||||
}
|
||||
if err := stepupSave(dir, &stepupFile{
|
||||
SecretB32: e.SecretB32,
|
||||
EnrolledAt: time.Now().UTC().Format(time.RFC3339),
|
||||
LastCounter: -1,
|
||||
}); err != nil {
|
||||
s.flash(w, "Could not write your code to this machine: "+err.Error(), "bad")
|
||||
return
|
||||
}
|
||||
s.enrols.drop(handle)
|
||||
s.flash(w, "Your code is set up. Stopping an agent, marking one as yours, and answering what is "+
|
||||
"asked of this machine now take a code from that app.", "ok")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
// uisetup_owner.go — owner acts on Settings that used to end in "the line to
|
||||
// run": naming the owner, disconnecting a Host, releasing one from
|
||||
// quarantine. Each needs the machine's administrator, so each is offered only
|
||||
// when the bastion runs for the whole machine (the console then runs as
|
||||
// that administrator), and each that widens what a Host may do takes the
|
||||
// owner's code: anything driving the person's browser can press a button.
|
||||
// Disconnecting only takes reach away, so it takes a tick, not a code.
|
||||
// Every act is written to pairing_audit.jsonl, so it shows in Activity.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ownerCodeRefusal spends the owner's code on one act, or says why not.
|
||||
func ownerCodeRefusal(code, purpose string) string {
|
||||
dir := existingMachineFactorDir()
|
||||
if !stepupEnrolled(dir) {
|
||||
return "Set up your code under Settings first."
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return "Type the code from your authenticator."
|
||||
}
|
||||
approval, err := machineCodeSpend(dir, uiNow(), func() (string, error) {
|
||||
return stepupIssueApproval(dir, code, purpose)
|
||||
})
|
||||
if err != nil {
|
||||
return "Not done: " + err.Error() + "."
|
||||
}
|
||||
if err := stepupConsumeApproval(dir, approval, purpose); err != nil {
|
||||
return "Not done: " + err.Error() + "."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *uiSetup) ownerNameHere(w http.ResponseWriter, name, code string) {
|
||||
if err := claimNameOK(name); err != nil {
|
||||
s.flash(w, "That name will be refused: "+err.Error()+".", "bad")
|
||||
return
|
||||
}
|
||||
if err := purposeBad([]string{name}, nil); err != nil {
|
||||
s.flash(w, "That name cannot be used: "+err.Error()+".", "bad")
|
||||
return
|
||||
}
|
||||
if why := ownerCodeRefusal(code, "local_owner|"+name); why != "" {
|
||||
s.flash(w, why, "warn")
|
||||
return
|
||||
}
|
||||
if _, err := writeOwnerClaim(name); err != nil {
|
||||
s.flash(w, "Not set: "+err.Error()+".", "bad")
|
||||
return
|
||||
}
|
||||
pairingAudit("claim_owner", name, "set by the owner at this machine's console")
|
||||
s.flash(w, "The owner name is now “"+name+"”. It shows on this machine's card on every Host it serves, "+
|
||||
"and grants nothing.", "ok")
|
||||
}
|
||||
|
||||
func (s *uiSetup) hostDisconnect(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
host := strings.TrimSpace(r.FormValue("host"))
|
||||
if r.FormValue("confirm") == "" {
|
||||
s.flash(w, "Tick the box to confirm disconnecting "+host+". Connecting it again needs a new token from it.", "warn")
|
||||
return
|
||||
}
|
||||
name, err := unpairHost(host)
|
||||
if err != nil {
|
||||
s.flash(w, "Not disconnected: "+err.Error()+".", "bad")
|
||||
return
|
||||
}
|
||||
pairingAudit("unpair", name, "disconnected by the owner at this machine's console")
|
||||
s.flash(w, name+" is disconnected: its tunnel and lanes are down. What it kept on this machine stays on disk.", "ok")
|
||||
}
|
||||
|
||||
func (s *uiSetup) hostRelease(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
host := strings.TrimSpace(r.FormValue("host"))
|
||||
if _, err := setupHost(host); err != nil {
|
||||
s.flash(w, err.Error(), "bad")
|
||||
return
|
||||
}
|
||||
if !machineAuthorityHeld() {
|
||||
s.flash(w, "Releasing a Host needs the machine's administrator; this bastion runs in your own account.", "warn")
|
||||
return
|
||||
}
|
||||
if why := ownerCodeRefusal(r.FormValue("code"), "local_release|"+host); why != "" {
|
||||
s.flash(w, why, "warn")
|
||||
return
|
||||
}
|
||||
quarantineClear(host)
|
||||
pairingAudit("quarantine_clear", host, "released by the owner at this machine's console")
|
||||
s.flash(w, host+" may ask this machine for changes again, each with your approval as before.", "ok")
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// settingsWorld: the bastion runs for the whole machine, the owner's code is
|
||||
// set up, and one Host is connected. The Host's name is invented.
|
||||
func settingsWorld(t *testing.T, whole bool) (*uiServer, string) {
|
||||
t.Helper()
|
||||
uiTestPairing(t, "member.host.privatae.ai")
|
||||
d, err := machineFactorDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enrolFactorAt(t, d, machineTestSecret)
|
||||
prev := machineAuthorityHeld
|
||||
t.Cleanup(func() { machineAuthorityHeld = prev })
|
||||
machineAuthorityHeld = func() bool { return whole }
|
||||
u := uiTestServer(t)
|
||||
return u, uiSignedIn(t, u)
|
||||
}
|
||||
|
||||
func TestSettingsSaysWhatThisComputerIsInPlainWords(t *testing.T) {
|
||||
u, cookie := settingsWorld(t, true)
|
||||
body := uiGet(t, u.handler(), "/setup", cookie).Body.String()
|
||||
for _, want := range []string{"Your own computer: one Host at most", "for the whole machine",
|
||||
"Connected Hosts", "member.host.privatae.ai", "Disconnect", "Set the owner name"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("Settings does not say %q", want)
|
||||
}
|
||||
}
|
||||
for _, gone := range []string{"<dt>Mode</dt>", "<dt>Anchor</dt>"} {
|
||||
if strings.Contains(body, gone) {
|
||||
t.Errorf("Settings still says %q", gone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheOwnerNameIsSetHereWithTheCode(t *testing.T) {
|
||||
u, cookie := settingsWorld(t, true)
|
||||
h := u.handler()
|
||||
w := uiPost(t, h, "/setup/owner/name", url.Values{"owner": {"Sam"}, "code": {codeFor(t, hostTestSecret)}}, cookie)
|
||||
if !strings.Contains(w.Body.String(), "code refused") {
|
||||
t.Errorf("a wrong code: %s", firstLines(w.Body.String()))
|
||||
}
|
||||
if o := currentOwnerClaim(); o != nil {
|
||||
t.Fatalf("a name was set with a wrong code: %q", o.Owner)
|
||||
}
|
||||
w = uiPost(t, h, "/setup/owner/name", url.Values{"owner": {"Sam"}, "code": {codeFor(t, machineTestSecret)}}, cookie)
|
||||
if o := currentOwnerClaim(); o == nil || o.Owner != "Sam" {
|
||||
t.Fatalf("the owner's code did not set the name: %s", firstLines(w.Body.String()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectingAHostTakesATickNotACode(t *testing.T) {
|
||||
u, cookie := settingsWorld(t, true)
|
||||
h := u.handler()
|
||||
if w := uiPost(t, h, "/setup/host/disconnect", url.Values{"host": {"member.host.privatae.ai"}}, cookie); !strings.Contains(w.Body.String(), "Tick the box") {
|
||||
t.Errorf("no tick: %s", firstLines(w.Body.String()))
|
||||
}
|
||||
if cfgs, _ := loadPairings(); len(cfgs) != 1 {
|
||||
t.Fatal("disconnected without the tick")
|
||||
}
|
||||
uiPost(t, h, "/setup/host/disconnect", url.Values{"host": {"member.host.privatae.ai"}, "confirm": {"1"}}, cookie)
|
||||
if cfgs, _ := loadPairings(); len(cfgs) != 0 {
|
||||
t.Fatal("the Host is still connected")
|
||||
}
|
||||
if items := collectActivity(time.Now().Add(-time.Hour)); len(items) == 0 || !strings.Contains(items[0].What, "Host removed member.host.privatae.ai") {
|
||||
t.Errorf("Activity does not show the disconnect: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleasingAHostFromQuarantineTakesTheCode(t *testing.T) {
|
||||
u, cookie := settingsWorld(t, true)
|
||||
f := quarantineLoad()
|
||||
if f.Pairing == nil {
|
||||
f.Pairing = map[string]*quarantineEntry{}
|
||||
}
|
||||
f.Pairing["member.host.privatae.ai"] = &quarantineEntry{Until: time.Now().Add(time.Hour).UTC().Format(time.RFC3339)}
|
||||
if err := quarantineSave(f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := u.handler()
|
||||
if body := uiGet(t, h, "/setup", cookie).Body.String(); !strings.Contains(body, "Release from quarantine") {
|
||||
t.Fatal("a quarantined Host offers no release")
|
||||
}
|
||||
uiPost(t, h, "/setup/host/release", url.Values{"host": {"member.host.privatae.ai"}}, cookie)
|
||||
if quarantinedUntil("member.host.privatae.ai", time.Now()).IsZero() {
|
||||
t.Fatal("released with no code")
|
||||
}
|
||||
uiPost(t, h, "/setup/host/release", url.Values{"host": {"member.host.privatae.ai"}, "code": {codeFor(t, machineTestSecret)}}, cookie)
|
||||
if !quarantinedUntil("member.host.privatae.ai", time.Now()).IsZero() {
|
||||
t.Fatal("the owner's code did not release it")
|
||||
}
|
||||
}
|
||||
|
||||
// In the person's own account the page does not offer acts it cannot do.
|
||||
func TestAPerAccountBastionOffersNoAdministratorActs(t *testing.T) {
|
||||
u, cookie := settingsWorld(t, false)
|
||||
body := uiGet(t, u.handler(), "/setup", cookie).Body.String()
|
||||
if strings.Contains(body, `hx-post="/setup/host/disconnect"`) || strings.Contains(body, "Set the owner name") {
|
||||
t.Error("a per-account bastion offered an administrator's act")
|
||||
}
|
||||
if !strings.Contains(body, "agents running as you can stop it") {
|
||||
t.Error("the page does not say what a per-account install costs")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package main
|
||||
|
||||
// uninstall.go — `privatae-bastion uninstall [--delete-data]`.
|
||||
//
|
||||
// Removes what the installers put in place: the service that starts the
|
||||
// bastion (the systemd unit, the launchd daemon, the Windows service, or the
|
||||
// account's own user unit, LaunchAgent or logon task) and the program. The
|
||||
// bastion's state stays unless --delete-data is given: the pairings and keys,
|
||||
// and the audit log, which is the record of what every Host and agent did on
|
||||
// this machine and is the one thing a person may need after the bastion is
|
||||
// gone. --delete-data takes a typed confirmation at a terminal.
|
||||
//
|
||||
// Installed for the whole machine, uninstalling needs the machine's
|
||||
// administrator, as stopping the service does. The steps are decided in
|
||||
// uninstallSteps, untagged, so the suite checks every platform's on any.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type uninstallStep struct {
|
||||
Run []string // a command to run; its failure is reported and the rest go on
|
||||
Remove string // a file to remove; one already gone is fine
|
||||
Call string // "system-service": uninstallSystemService (macOS, Windows)
|
||||
Say string // printed as it is done
|
||||
}
|
||||
|
||||
// uninstallSteps is what uninstalling removes on goos. whole: the bastion is
|
||||
// installed for the whole machine.
|
||||
func uninstallSteps(goos string, whole bool, home string, uid int) []uninstallStep {
|
||||
var s []uninstallStep
|
||||
switch goos {
|
||||
case "linux":
|
||||
if whole {
|
||||
s = append(s,
|
||||
uninstallStep{Run: []string{"systemctl", "disable", "--now", "privatae-bastion"}, Say: "stopped the system service privatae-bastion"},
|
||||
uninstallStep{Remove: linuxSystemUnitPath},
|
||||
uninstallStep{Run: []string{"systemctl", "daemon-reload"}},
|
||||
uninstallStep{Remove: "/usr/local/bin/privatae-bastion", Say: "removed /usr/local/bin/privatae-bastion"})
|
||||
} else {
|
||||
unit := filepath.Join(home, ".config", "systemd", "user", "privatae-bastion.service")
|
||||
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
|
||||
unit = filepath.Join(x, "systemd", "user", "privatae-bastion.service")
|
||||
}
|
||||
s = append(s,
|
||||
uninstallStep{Run: []string{"systemctl", "--user", "disable", "--now", "privatae-bastion"}, Say: "stopped your own service privatae-bastion"},
|
||||
uninstallStep{Remove: unit},
|
||||
uninstallStep{Run: []string{"systemctl", "--user", "daemon-reload"}},
|
||||
uninstallStep{Remove: filepath.Join(home, ".local", "bin", "privatae-bastion")})
|
||||
}
|
||||
case "darwin":
|
||||
if whole {
|
||||
s = append(s,
|
||||
uninstallStep{Call: "system-service"},
|
||||
uninstallStep{Remove: "/Library/Application Support/privatae/bin/privatae-bastion", Say: "removed the program"},
|
||||
uninstallStep{Remove: "/usr/local/bin/privatae-bastion"})
|
||||
} else {
|
||||
s = append(s,
|
||||
uninstallStep{Run: []string{"/bin/launchctl", "bootout", "gui/" + strconv.Itoa(uid) + "/ai.privatae.bastion.user"}, Say: "stopped your own LaunchAgent"},
|
||||
uninstallStep{Remove: filepath.Join(home, "Library", "LaunchAgents", "ai.privatae.bastion.user.plist")},
|
||||
uninstallStep{Remove: filepath.Join(home, ".local", "bin", "privatae-bastion")})
|
||||
}
|
||||
case "windows":
|
||||
if whole {
|
||||
s = append(s, uninstallStep{Call: "system-service"})
|
||||
} else {
|
||||
s = append(s,
|
||||
uninstallStep{Run: []string{"schtasks", "/End", "/TN", "PrivataeBastion"}},
|
||||
uninstallStep{Run: []string{"schtasks", "/Delete", "/TN", "PrivataeBastion", "/F"}, Say: "removed your sign-in task PrivataeBastion"})
|
||||
}
|
||||
}
|
||||
// The program is removed only from the installers' own places, never from
|
||||
// wherever else someone keeps a copy. On Windows a running program cannot
|
||||
// be removed; cmdUninstall says where it is.
|
||||
return s
|
||||
}
|
||||
|
||||
// uninstallWhole reports whether this machine's bastion is installed for the
|
||||
// whole machine.
|
||||
func uninstallWhole() bool {
|
||||
if runtime.GOOS == "linux" {
|
||||
return linuxSystemUnitPresent()
|
||||
}
|
||||
return systemService.installed
|
||||
}
|
||||
|
||||
// uninstallHomeRefusal: on Linux, root's state is found through root's HOME
|
||||
// (the unit runs with HOME=/root), and a sudo that keeps the caller's HOME (no
|
||||
// -H) or XDG_CONFIG_HOME (sudo -E, env_keep) would name the person's own state
|
||||
// as the machine's, and with --delete-data delete it. dataDir is the folder
|
||||
// this process would name. "" when fine.
|
||||
func uninstallHomeRefusal(goos string, root bool, dataDir, rootHome string) string {
|
||||
if goos != "linux" || !root || rootHome == "" ||
|
||||
filepath.Clean(dataDir) == filepath.Join(rootHome, ".config", "privatae-bastion") {
|
||||
return ""
|
||||
}
|
||||
return "run it as: sudo -H privatae-bastion uninstall, with no XDG_CONFIG_HOME kept. This sudo kept your HOME or XDG_CONFIG_HOME, " +
|
||||
"so the state it would name (" + dataDir + ") is your account's, not the machine's — nothing was removed"
|
||||
}
|
||||
|
||||
// uninstallRootStateRefusal is uninstallHomeRefusal for this process.
|
||||
func uninstallRootStateRefusal() string {
|
||||
if !machineAuthorityHeld() {
|
||||
return ""
|
||||
}
|
||||
rootHome := ""
|
||||
if u, err := user.LookupId("0"); err == nil {
|
||||
rootHome = u.HomeDir
|
||||
}
|
||||
dataDir, _ := configDirPath()
|
||||
return uninstallHomeRefusal(runtime.GOOS, true, dataDir, rootHome)
|
||||
}
|
||||
|
||||
func cmdUninstall(deleteData bool) error {
|
||||
if why := uninstallRootStateRefusal(); why != "" {
|
||||
return errors.New(why)
|
||||
}
|
||||
// From an account that does not administer the machine, uninstall
|
||||
// removes that account's own bastion and leaves the machine's alone.
|
||||
machine := uninstallWhole()
|
||||
whole := machine && machineAuthorityHeld()
|
||||
if machine && !whole && !accountInstallPresent() {
|
||||
return requireMachineAuthority("uninstall")
|
||||
}
|
||||
dataDir, err := configDirPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deleteData && !uninstallConfirm(dataDir) {
|
||||
return errors.New("nothing was removed")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
self, _ := resolvedSelf()
|
||||
cfgs, _ := loadPairings()
|
||||
hadHosts := len(cfgs) > 0
|
||||
failed := 0
|
||||
for _, st := range uninstallSteps(runtime.GOOS, whole, home, os.Getuid()) {
|
||||
switch {
|
||||
case st.Call == "system-service":
|
||||
if err := uninstallSystemService(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " %v\n", err)
|
||||
failed++
|
||||
}
|
||||
case len(st.Run) > 0:
|
||||
cmd := exec.Command(st.Run[0], st.Run[1:]...)
|
||||
hideConsole(cmd)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil && st.Say != "" {
|
||||
fmt.Fprintf(os.Stderr, " %s: %v %s\n", strings.Join(st.Run, " "), err, strings.TrimSpace(string(out)))
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
case st.Remove != "":
|
||||
if err := os.Remove(st.Remove); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
fmt.Fprintf(os.Stderr, " could not remove %s: %v\n", st.Remove, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
}
|
||||
if st.Say != "" {
|
||||
fmt.Println(" " + st.Say)
|
||||
}
|
||||
}
|
||||
if deleteData {
|
||||
if err := os.RemoveAll(dataDir); err != nil {
|
||||
return fmt.Errorf("the bastion is removed, but its state could not be deleted: %w", err)
|
||||
}
|
||||
fmt.Printf("Deleted %s: its pairings, keys and audit log.\n", dataDir)
|
||||
} else {
|
||||
fmt.Printf("Kept %s: its pairings, keys and the audit log of what Hosts and agents did here.\n"+
|
||||
" Delete it with: privatae-bastion uninstall --delete-data, or remove the folder.\n", dataDir)
|
||||
}
|
||||
if runtime.GOOS == "windows" && self != "" {
|
||||
fmt.Printf("The program is still at %s; Windows cannot remove a running program. Delete it now this has finished.\n", self)
|
||||
}
|
||||
if failed > 0 {
|
||||
return fmt.Errorf("%d step(s) did not finish (above); the rest was removed", failed)
|
||||
}
|
||||
if machine && !whole {
|
||||
fmt.Println("Your account's own bastion is removed. The machine's stays; removing it needs: sudo -H privatae-bastion uninstall")
|
||||
return nil
|
||||
}
|
||||
if hadHosts {
|
||||
fmt.Println("The bastion is uninstalled. The Hosts it was connected to see it go quiet.")
|
||||
} else {
|
||||
fmt.Println("The bastion is uninstalled.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// uninstallConfirm asks for the word "delete" at a terminal before the state
|
||||
// goes. Not at a terminal, the answer is no.
|
||||
func uninstallConfirm(dir string) bool {
|
||||
if !stdinIsConsole() {
|
||||
fmt.Fprintln(os.Stderr, "--delete-data deletes the audit log too, so it is confirmed at a terminal; this one's input is a file or a pipe.")
|
||||
return false
|
||||
}
|
||||
fmt.Printf("This deletes %s: the pairings, keys and the audit log of what Hosts and agents did here.\n"+
|
||||
"Type delete to go on: ", dir)
|
||||
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
return strings.TrimSpace(line) == "delete"
|
||||
}
|
||||
|
||||
// accountInstallPresent: this account has a bastion of its own installed (its
|
||||
// user unit, LaunchAgent or program in ~/.local/bin).
|
||||
func accountInstallPresent() bool {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, st := range uninstallSteps(runtime.GOOS, false, home, os.Getuid()) {
|
||||
if st.Remove != "" {
|
||||
if _, err := os.Stat(st.Remove); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// What uninstalling removes, per platform and install: the thing that starts
|
||||
// the bastion and the program in the installers' own places. Never the state
|
||||
// (that is --delete-data's, in cmdUninstall), and never a path outside what
|
||||
// the installers write.
|
||||
func TestUninstallRemovesWhatTheInstallersPutInPlace(t *testing.T) {
|
||||
const home = "/home/sam"
|
||||
cases := []struct {
|
||||
goos string
|
||||
whole bool
|
||||
want []string
|
||||
}{
|
||||
{"linux", true, []string{"systemctl disable --now privatae-bastion", "rm " + linuxSystemUnitPath, "systemctl daemon-reload", "rm /usr/local/bin/privatae-bastion"}},
|
||||
{"linux", false, []string{"systemctl --user disable --now privatae-bastion", "rm " + home + "/.config/systemd/user/privatae-bastion.service", "rm " + home + "/.local/bin/privatae-bastion"}},
|
||||
{"darwin", true, []string{"call system-service", "rm /Library/Application Support/privatae/bin/privatae-bastion", "rm /usr/local/bin/privatae-bastion"}},
|
||||
{"darwin", false, []string{"/bin/launchctl bootout gui/501/ai.privatae.bastion.user", "rm " + home + "/Library/LaunchAgents/ai.privatae.bastion.user.plist"}},
|
||||
{"windows", true, []string{"call system-service"}},
|
||||
{"windows", false, []string{"schtasks /Delete /TN PrivataeBastion /F"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
var got []string
|
||||
for _, st := range uninstallSteps(c.goos, c.whole, home, 501) {
|
||||
switch {
|
||||
case st.Call != "":
|
||||
got = append(got, "call "+st.Call)
|
||||
case len(st.Run) > 0:
|
||||
got = append(got, strings.Join(st.Run, " "))
|
||||
default:
|
||||
got = append(got, "rm "+st.Remove)
|
||||
}
|
||||
}
|
||||
all := strings.Join(got, "\n")
|
||||
for _, w := range c.want {
|
||||
if !strings.Contains(all, w) {
|
||||
t.Errorf("%s whole=%v: no %q in\n%s", c.goos, c.whole, w, all)
|
||||
}
|
||||
}
|
||||
if strings.Contains(all, "privatae-bastion/") && !strings.Contains(all, "privatae/bin/privatae-bastion") {
|
||||
t.Errorf("%s whole=%v: a step reaches into the state directory:\n%s", c.goos, c.whole, all)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On Linux a sudo without -H keeps the person's HOME, and the state uninstall
|
||||
// names (and --delete-data deletes) would be theirs, not the machine's.
|
||||
func TestUninstallAsRootRefusesTheCallersHome(t *testing.T) {
|
||||
for _, dir := range []string{"/home/sam/.config/privatae-bastion", ""} {
|
||||
if why := uninstallHomeRefusal("linux", true, dir, "/root"); !strings.Contains(why, "sudo -H") {
|
||||
t.Errorf("root naming %q was not refused: %q", dir, why)
|
||||
}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
goos string
|
||||
root bool
|
||||
home, root2 string
|
||||
}{{"linux", true, "/root/.config/privatae-bastion", "/root"}, {"linux", true, "/root/.config/privatae-bastion/", "/root/"},
|
||||
{"linux", false, "/home/sam/.config/privatae-bastion", "/root"}, {"darwin", true, "/Users/sam/Library/Application Support/privatae-bastion", "/var/root"}} {
|
||||
if why := uninstallHomeRefusal(c.goos, c.root, c.home, c.root2); why != "" {
|
||||
t.Errorf("%+v refused: %q", c, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What uninstall names, and --delete-data deletes, is the config folder,
|
||||
// which reads XDG_CONFIG_HOME before HOME. A sudo that keeps the person's
|
||||
// XDG_CONFIG_HOME (sudo -E, or env_keep) names their state even with
|
||||
// HOME set to root's.
|
||||
func TestUninstallAsRootRefusesTheCallersConfigFolder(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("root's state is found through its HOME on Linux only")
|
||||
}
|
||||
u, err := user.LookupId("0")
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
prev := machineAuthorityHeld
|
||||
t.Cleanup(func() { machineAuthorityHeld = prev })
|
||||
machineAuthorityHeld = func() bool { return true }
|
||||
t.Setenv("HOME", u.HomeDir)
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
if why := uninstallRootStateRefusal(); !strings.Contains(why, "sudo -H") {
|
||||
t.Errorf("root with the caller's XDG_CONFIG_HOME was not refused: %q", why)
|
||||
}
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
if why := uninstallRootStateRefusal(); why != "" {
|
||||
t.Errorf("root with its own HOME was refused: %q", why)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user