2 Commits

Author SHA1 Message Date
Privatae 77b7853ae1 bastion 0.54.1
ci / personal-data (push) Successful in 6s
ci / fuzz (push) Successful in 2m49s
ci / test (push) Successful in 3m7s
2026-10-21 23:39:13 +00:00
Privatae d62da8adb4 bastion 0.54.0
ci / personal-data (push) Successful in 4s
ci / fuzz (push) Successful in 2m44s
ci / test (push) Successful in 2m11s
2026-10-21 22:29:10 +00:00
83 changed files with 8357 additions and 1405 deletions
+6
View File
@@ -83,6 +83,12 @@ jobs:
# maintainers' key, which a CI job never holds, so here it is skipped and
# the run says so; the entity-id, provenance, email and phone checks run.
run: python3 scripts/test_no_personal_data.py
- name: every test the claims table cites exists
# docs/claims.md names, for each security claim, the test that fails
# if the claim stops being true. A renamed or deleted test would leave
# the claim looking covered; this fails the run instead. Reads files
# and greps; runs nothing from the tree.
run: bash deploy/check-claims.sh
- name: no Host source file names in the tree
# The member repository publishes this tree as a release was built,
# and its release step refuses a path into the Host's Python
+42
View File
@@ -23,6 +23,48 @@ From 0.39.1 on, a heading marks a version *not published* or *held* once
that is settled. The newest entries may not be published yet; the manifest
says which version is. Older entries are not marked.
## 0.54.1
- **No change to what the bastion does.** The README is now a short front
page: install, quick start, local and networked modes, a summary of the
security model, how this repository is published, and how to build and
verify a release. The reference text moved into one page per topic under
`docs/`, listed in `docs/README.md`, with only its headings and links
changed. The README and CONTRIBUTING.md now say that issues and pull
requests are welcome on GitHub.
- **The claims table is its own file, `docs/claims.md`,** and
`deploy/check-claims.sh` reads it there. Two rows that cited two tests each
now cite one test per row, so the check passes again.
## 0.54.0
- **Every audit row also goes to the system log.** The rows written to
`mgmt_audit.jsonl`, `pairing_audit.jsonl` and each pairing's `audit.jsonl`
(terminals, root commands, store ops, op runs) are also written to the
machine's system log, so a log collector can keep them where no host can
write: syslog facility `auth`, tag `privatae-bastion-audit` (the journal on
systemd) on Linux and macOS, and the Application event log, source
`PrivataeBastionAudit`, on Windows, registered when the system service is
installed and removed with it. Each line is the file's row with `audit`
(the file) and `pairing` added, and a root command's text left out (its
digest and length stay). The lines go through a bounded queue, so a slow or
stuck system log never holds up the audited op; a full queue drops lines and
the next line carries `system_log_dropped`. The file stays the record.
- **A server is networked and anchored by default.** `install.sh` run as root
now installs the networked bastion (systemd on Linux, launchd on macOS);
`--local` keeps a root install local, a reinstall over a machine already
paired in local mode keeps it local unless `--networked` is passed, and run
as your own user it is local as before. On a networked machine with no anchor, the first `pair` run with
machine authority anchors it to that host. Before, such a machine refused
every op until `claim-anchor` was run by hand; now it is anchored at once:
every terminal and every write waits for the person's approval, and any
later host for the anchor's. `pair` then says to
enrol the second factor. `claim-anchor` with no token on a machine already
anchored to that host now succeeds and changes nothing (an installer that
anchors after pairing finds the work done); naming another host is still
refused without `--rotate`. The Windows installer is unchanged: `-Networked`
as before.
## 0.53.2
- **No change to what the bastion does.** Two code comments named source files
+10 -4
View File
@@ -7,10 +7,16 @@ their approval on that machine.
## What you can do today
Sign-in on this forge is not reachable from the internet, so you cannot file
an issue or open a pull request here. You can read, clone, build and verify a
release, and fork under the GPL. Report a bug or a security problem by email
(see [SECURITY.md](SECURITY.md)).
Issues and pull requests are welcome on GitHub, at
[github.com/Privatae/bastion](https://github.com/Privatae/bastion).
Development happens in a private repository, and the public repository
receives the source of each published release as one snapshot commit, so an
accepted change is carried into the development repository and arrives with
the next release. Sign-in on the git.privatae.ai forge is not reachable from
the internet, so issues and pull requests cannot be opened there. You can
also read, clone, build and verify a release, and fork under the GPL. Report
a security problem by email, not in a public issue (see
[SECURITY.md](SECURITY.md)).
## Checks a change has to pass
+204 -1313
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -22,7 +22,8 @@ notes if you wish.
machine do more than the grant and the local approval allow: reading files
outside a granted store, deleting where only archiving is allowed, running a
program that is not in the fixed table, bypassing the second factor, opening
a listener that is not documented in the README's security model.
a listener that is not documented in the security model
([docs/security-model.md](docs/security-model.md)).
## What is out of scope
+433
View File
@@ -0,0 +1,433 @@
package main
import (
"bytes"
"errors"
"os/exec"
"strconv"
"strings"
)
// WHO RUNS AN AGENT, NOT ONLY WHICH ACCOUNT.
//
// The process scan names the account an agent runs as (procUser on every
// platform, winTokenUser on Windows) and says nothing else about it. The
// question an assessment asks is about the account: is it an administrator, is
// it a person's own account or one that exists for a service, and is that
// person logged in right now. "An agent runs as a person, holds administrator
// rights, and nobody is at the keyboard" is a different finding from "an agent
// runs under a service account with no rights", and the account name alone
// cannot tell them apart.
//
// Three facts, each with an honest third value:
//
// owner_class domain_user, local_user, service, system, or unknown
// owner_admin yes, no, or unknown
// owner_interactive yes, no, or unknown
//
// UNKNOWN IS NEVER FOLDED INTO NO. A box that could not read the group list
// has not learned that the account is unprivileged, and saying "no" there is
// the confident-wrong answer this plane refuses everywhere else
// (baseline_identity.go draws the same line for sudo rules it could not read).
//
// Every decision is in this file, with no build tag, so the Linux test run
// exercises the macOS and Windows rules too. The platform files
// (agents_<goos>.go) only read: /proc and /etc on Linux, `id`, `dscl` and
// `who` on macOS, the process token and the session list on Windows.
//
// These ride on agentObs, so they travel only where `agents` does: inside the
// heartbeat's `observing` gate (agents.go).
const (
ownerClassDomain = "domain_user"
ownerClassLocal = "local_user"
ownerClassService = "service"
ownerClassSystem = "system"
ownerUnknown = "unknown"
ownerYes = "yes"
ownerNo = "no"
)
// ownerFacts is the three answers for one account.
type ownerFacts struct {
Class string
Admin string
Interactive string
}
// unknownOwner is the answer when nothing could be read: a process whose
// owner this bastion may not inspect, or a build with no reader.
var unknownOwner = ownerFacts{Class: ownerUnknown, Admin: ownerUnknown, Interactive: ownerUnknown}
func yesNo(b bool) string {
if b {
return ownerYes
}
return ownerNo
}
// ── Linux and macOS ─────────────────────────────────────────────────────────
// unixOwner is what a Linux or macOS reader collected about one account.
// Every field that can be unreadable carries its own "known" flag.
type unixOwner struct {
UID int // -1 when the uid could not be read
Name string
// LocalRecord: "yes" when the account is in this machine's own account
// database (/etc/passwd; the macOS local directory node), "no" when it is
// not, "unknown" when that could not be read.
LocalRecord string
// DirectorySource: this machine resolves accounts from a directory
// (nsswitch names sss, ldap, winbind or nis; a macOS mobile account was
// copied from one). Only then does "not in the local database" mean a
// domain account rather than a uid with no account at all.
DirectorySource bool
// SystemUIDMax is the highest uid the platform hands to system accounts.
SystemUIDMax int
Groups []string // group names the account or process holds
GroupsKnown bool
AdminGroups map[string]bool
// SudoApplies: on this platform a sudo rule can grant administration to
// any user or group (Linux), so "in no admin group" is only a "no" once
// the rules were read as well. macOS leaves it false and decides on the
// admin group, which is what its stock sudoers grants.
// SudoNamed / SudoKnown: a sudo rule names the account directly or
// through a group (sudoPrincipals), and whether the rules were readable.
SudoApplies bool
SudoNamed bool
SudoKnown bool
// Sessions is the set of account names with a login session now
// (logind, utmp, `who`); SessionsKnown is false when none could be read.
Sessions map[string]bool
SessionsKnown bool
}
// linuxSystemUIDMax is shadow-utils' default SYS_UID_MAX: accounts below
// UID_MIN (1000 by default) are system accounts. login.defs can move both;
// linuxUIDLimits reads it.
const linuxSystemUIDMax = 999
// darwinSystemUIDMax: macOS creates people's accounts from uid 501 up, and
// keeps the ids below for its own daemons (_www, _spotlight and the rest).
const darwinSystemUIDMax = 500
func classifyUnixOwner(o unixOwner) ownerFacts {
f := ownerFacts{Class: ownerUnknown, Admin: ownerUnknown, Interactive: ownerUnknown}
switch {
case o.UID == 0:
f.Class = ownerClassSystem
case o.UID == -1:
// Unread uid: the class cannot be told from a name alone. Other
// negative ids are real: macOS's directory gives nobody -2.
case o.UID == 65534 || o.UID <= o.SystemUIDMax:
// nobody, and every uid in the system range, is an account that exists
// for a program rather than a person.
f.Class = ownerClassService
case strings.ContainsAny(o.Name, `@\`):
// user@realm (SSSD) and DOMAIN\user (winbind) are directory names.
f.Class = ownerClassDomain
case o.LocalRecord == ownerYes:
f.Class = ownerClassLocal
case o.LocalRecord == ownerNo && o.DirectorySource:
f.Class = ownerClassDomain
}
switch {
case o.UID == 0:
f.Admin = ownerYes
case o.GroupsKnown && hasAnyGroup(o.Groups, o.AdminGroups):
f.Admin = ownerYes
case o.SudoKnown && o.SudoNamed:
f.Admin = ownerYes
case o.GroupsKnown && (o.SudoKnown || !o.SudoApplies):
f.Admin = ownerNo
}
if o.SessionsKnown && o.Name != "" {
f.Interactive = yesNo(o.Sessions[o.Name])
}
return f
}
func hasAnyGroup(groups []string, want map[string]bool) bool {
for _, g := range groups {
if want[g] {
return true
}
}
return false
}
// linuxAdminGroups: the groups a stock distribution's sudoers grants
// administration to (sudo on Debian and Ubuntu, wheel on Fedora, RHEL and
// Arch, admin on older Ubuntu).
var linuxAdminGroups = map[string]bool{"sudo": true, "wheel": true, "admin": true}
// darwinAdminGroups: macOS administrators are members of `admin`.
var darwinAdminGroups = map[string]bool{"admin": true}
// parseStatusIDs reads the real and effective uid and the supplementary gid
// list out of /proc/<pid>/status. ok is false when there is no Uid line.
func parseStatusIDs(status string) (realUID, effUID int, gids []int, ok bool) {
realUID, effUID = -1, -1
for _, line := range strings.Split(status, "\n") {
switch {
case strings.HasPrefix(line, "Uid:"):
f := strings.Fields(line)
if len(f) >= 3 {
r, err1 := strconv.Atoi(f[1])
e, err2 := strconv.Atoi(f[2])
if err1 == nil && err2 == nil {
realUID, effUID, ok = r, e, true
}
}
case strings.HasPrefix(line, "Groups:"):
for _, g := range strings.Fields(strings.TrimPrefix(line, "Groups:")) {
if n, err := strconv.Atoi(g); err == nil {
gids = append(gids, n)
}
}
}
}
return realUID, effUID, gids, ok
}
// parseGroupNames maps gid -> name from an /etc/group body.
func parseGroupNames(body string) map[int]string {
out := map[int]string{}
for _, line := range strings.Split(body, "\n") {
p := strings.Split(strings.TrimSpace(line), ":")
if len(p) < 3 || strings.HasPrefix(p[0], "#") {
continue
}
if n, err := strconv.Atoi(p[2]); err == nil {
out[n] = p[0]
}
}
return out
}
// parsePasswdUIDs is the set of uids /etc/passwd itself lists.
func parsePasswdUIDs(body string) map[int]bool {
out := map[int]bool{}
for _, line := range strings.Split(body, "\n") {
p := strings.Split(strings.TrimSpace(line), ":")
if len(p) < 3 || strings.HasPrefix(p[0], "#") {
continue
}
if n, err := strconv.Atoi(p[2]); err == nil {
out[n] = true
}
}
return out
}
// parseLoginDefsSysMax returns the highest system uid login.defs declares:
// SYS_UID_MAX when present, else UID_MIN-1, else the shadow-utils default.
func parseLoginDefsSysMax(body string) int {
sysMax, uidMin := -1, -1
for _, line := range strings.Split(body, "\n") {
f := strings.Fields(strings.TrimSpace(line))
if len(f) < 2 || strings.HasPrefix(f[0], "#") {
continue
}
n, err := strconv.Atoi(f[1])
if err != nil {
continue
}
switch f[0] {
case "SYS_UID_MAX":
sysMax = n
case "UID_MIN":
uidMin = n
}
}
switch {
case sysMax >= 0:
return sysMax
case uidMin > 0:
return uidMin - 1
}
return linuxSystemUIDMax
}
// nsswitchHasDirectory reports whether the passwd line of nsswitch.conf names
// a directory source, so an account missing from /etc/passwd is resolved from
// one rather than simply absent.
func nsswitchHasDirectory(body string) bool {
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "passwd:") {
continue
}
for _, src := range strings.Fields(strings.TrimPrefix(line, "passwd:")) {
switch src {
case "sss", "ldap", "winbind", "nis", "nisplus":
return true
}
}
}
return false
}
// parseLogindUserState reads systemd-logind's per-user state file
// (/run/systemd/users/<uid>). active and online mean the user has a session
// now; lingering means services run for them with no session.
func parseLogindUserState(body string) (interactive bool, ok bool) {
for _, line := range strings.Split(body, "\n") {
if v, found := strings.CutPrefix(strings.TrimSpace(line), "STATE="); found {
switch v {
case "active", "online":
return true, true
case "lingering", "closing", "offline":
return false, true
}
return false, false
}
}
return false, false
}
// utmp record layout on glibc Linux (bits/utmp.h): 384 bytes, ut_type a
// 16-bit value at offset 0, ut_user 32 bytes at offset 44. USER_PROCESS (7)
// is a login session.
const (
utmpRecordSize = 384
utmpUserOffset = 44
utmpUserSize = 32
utmpUserProcess = 7
)
// parseUtmpUsers is the set of account names with a USER_PROCESS record.
func parseUtmpUsers(b []byte) map[string]bool {
out := map[string]bool{}
for off := 0; off+utmpRecordSize <= len(b); off += utmpRecordSize {
rec := b[off : off+utmpRecordSize]
if int(rec[0])|int(rec[1])<<8 != utmpUserProcess {
continue
}
name := rec[utmpUserOffset : utmpUserOffset+utmpUserSize]
if i := bytes.IndexByte(name, 0); i >= 0 {
name = name[:i]
}
if len(name) > 0 {
out[string(name)] = true
}
}
return out
}
// parseWhoUsers is the set of account names `who` lists (macOS): the first
// field of every line.
func parseWhoUsers(out string) map[string]bool {
users := map[string]bool{}
for _, line := range strings.Split(out, "\n") {
if f := strings.Fields(line); len(f) > 0 {
users[f[0]] = true
}
}
return users
}
// parseIDGroups reads `id -Gn <user>`: group names separated by spaces.
func parseIDGroups(out string) []string { return strings.Fields(out) }
// dsclRecordMissing: dscl ran to the end and exited non-zero, which is how it
// says the local node has no such record. A dscl the timeout killed has an
// exit code of -1 and has said nothing about the account.
func dsclRecordMissing(err error) bool {
var ee *exec.ExitError
return errors.As(err, &ee) && ee.ExitCode() > 0
}
// parseDsclUser reads `dscl . -read /Users/<name> UniqueID OriginalNodeName`.
// A mobile account (cached from a directory) carries OriginalNodeName.
func parseDsclUser(out string) (uid int, mobile bool, ok bool) {
uid = -1
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if v, found := strings.CutPrefix(line, "UniqueID:"); found {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
uid, ok = n, true
}
}
if strings.HasPrefix(line, "OriginalNodeName:") {
mobile = true
}
}
return uid, mobile, ok
}
// ── Windows ─────────────────────────────────────────────────────────────────
// Well-known SIDs, from Microsoft's tables:
// https://learn.microsoft.com/en-us/windows/win32/secauthz/well-known-sids and
// https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/understand-security-identifiers
const (
sidLocalSystem = "S-1-5-18"
sidLocalService = "S-1-5-19"
sidNetworkService = "S-1-5-20"
sidAdministrators = "S-1-5-32-544"
sidInteractive = "S-1-5-4"
sidRemoteInteractive = "S-1-5-14"
sidLocalAccount = "S-1-5-113"
sidNTServicePrefix = "S-1-5-80-"
sidVirtualMachinePfx = "S-1-5-83-"
)
// winOwner is what the Windows reader collected from one process token.
type winOwner struct {
SID string
Domain string // the account's domain as LookupAccount names it
Computer string // this machine's NetBIOS name
// Groups are the token's group SIDs, deny-only ones included: an
// administrator running without elevation holds Administrators as a
// deny-only group, and is still an administrator account.
Groups map[string]bool
GroupsKnown bool
// SessionSIDs: accounts with a Remote Desktop Services session now
// (WTSEnumerateSessions + WTSQueryUserToken, which needs LocalSystem).
SessionSIDs map[string]bool
SessionsKnown bool
}
func classifyWinOwner(o winOwner) ownerFacts {
f := ownerFacts{Class: ownerUnknown, Admin: ownerUnknown, Interactive: ownerUnknown}
switch {
case o.SID == "":
case o.SID == sidLocalSystem:
f.Class = ownerClassSystem
case o.SID == sidLocalService || o.SID == sidNetworkService ||
strings.HasPrefix(o.SID, sidNTServicePrefix) || strings.HasPrefix(o.SID, sidVirtualMachinePfx):
f.Class = ownerClassService
case o.GroupsKnown && o.Groups[sidLocalAccount]:
f.Class = ownerClassLocal
case o.Domain == "":
case strings.EqualFold(o.Domain, o.Computer):
f.Class = ownerClassLocal
case strings.HasPrefix(o.SID, "S-1-5-21-") || strings.EqualFold(o.Domain, "AzureAD"):
// A machine or domain account whose domain is not this computer:
// an Active Directory or Entra ID account.
f.Class = ownerClassDomain
}
switch {
case o.SID == sidLocalSystem:
// "System is a hidden member of Administrators" (the second page above).
f.Admin = ownerYes
case o.GroupsKnown:
f.Admin = yesNo(o.Groups[sidAdministrators])
}
switch {
case o.SessionsKnown && o.SID != "":
f.Interactive = yesNo(o.SessionSIDs[o.SID])
case o.GroupsKnown && (o.Groups[sidInteractive] || o.Groups[sidRemoteInteractive]):
// No session list (the bastion is not LocalSystem), but this very
// process was started by an interactive logon, so the account has
// one. The converse proves nothing: a scheduled task's token is not
// interactive while its owner may well be logged in.
f.Interactive = ownerYes
}
return f
}
+83
View File
@@ -0,0 +1,83 @@
//go:build linux
package main
import (
"os"
"path/filepath"
"testing"
)
// The Linux reader end to end over an invented machine: a /proc with two
// processes and an /etc and /run that say who they belong to. Nothing here is
// read from the box the test runs on.
func TestLinuxOwnerFactsReaderOverAFixtureMachine(t *testing.T) {
root := t.TempDir()
write := func(rel, body string) {
t.Helper()
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
write("etc/passwd", "root:x:0:0::/root:/bin/sh\npat:x:1001:1001::/home/pat:/bin/bash\n"+
"kim:x:1002:1002::/home/kim:/bin/bash\nsvcdemo:x:120:120::/nonexistent:/usr/sbin/nologin\n")
write("etc/group", "root:x:0:\nsudo:x:27:pat\npat:x:1001:\nkim:x:1002:\n")
write("etc/sudoers", "root ALL=(ALL:ALL) ALL\n%sudo ALL=(ALL:ALL) ALL\n")
write("etc/nsswitch.conf", "passwd: files\n")
write("run/systemd/users/1001", "UID=1001\nSTATE=active\n")
write("proc/400/status", "Name:\tdemo\nUid:\t1001\t1001\t1001\t1001\nGroups:\t27 1001\n")
write("proc/401/status", "Name:\tdemo\nUid:\t1002\t1002\t1002\t1002\nGroups:\t1002\n")
write("proc/402/status", "Name:\tdemo\nUid:\t120\t120\t120\t120\nGroups:\t\n")
write("proc/403/status", "Name:\tdemo\nUid:\t1002\t0\t1002\t1002\nGroups:\t1002\n")
oldID, oldProc := identityRoot, procRoot
identityRoot, procRoot = root, filepath.Join(root, "proc")
t.Cleanup(func() { identityRoot, procRoot = oldID, oldProc })
read := ownerFactsReader()
for _, c := range []struct {
pid int
user string
want ownerFacts
}{
{400, "pat", ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
{401, "kim", ownerFacts{ownerClassLocal, ownerNo, ownerNo}},
{402, "svcdemo", ownerFacts{ownerClassService, ownerNo, ownerNo}},
// Effective root under kim's account: administrator, class unchanged.
{403, "kim", ownerFacts{ownerClassLocal, ownerYes, ownerNo}},
// A pid with no status file: everything unknown, nothing guessed.
{404, "ghost", unknownOwner},
} {
if got := read(c.pid, c.user); got != c.want {
t.Errorf("pid %d (%s): got %+v, want %+v", c.pid, c.user, got, c.want)
}
}
}
// Every observation scanAgents returns carries the owner facts: the wiring,
// not only the classifier. The one process is this test itself, so the facts
// come from the real /proc and are only checked for being filled in.
func TestScanAgentsCarriesOwnerFacts(t *testing.T) {
prev := scanProcessesFn
t.Cleanup(func() { scanProcessesFn = prev })
self := os.Getpid()
scanProcessesFn = func() (map[int]agentObs, error) {
return map[int]agentObs{self: {PID: self, User: procUser(self), Signature: "claude-code",
Cmdline: "claude", raw: "claude"}}, nil
}
got, err := scanAgents()
if err != nil || len(got) == 0 {
t.Fatalf("scan: %d observations, %v", len(got), err)
}
// The network signal may add this box's own model clients; every
// observation, however it was caught, must carry the facts.
for _, a := range got {
if a.OwnerClass == "" || a.OwnerAdmin == "" || a.OwnerInteractive == "" {
t.Fatalf("owner facts missing from pid %d", a.PID)
}
}
}
+209
View File
@@ -0,0 +1,209 @@
package main
import (
"encoding/binary"
"testing"
)
// Every value below is invented. No account, group or SID here belongs to a
// real machine.
func TestClassifyUnixOwner(t *testing.T) {
linux := func(o unixOwner) unixOwner {
o.SystemUIDMax = linuxSystemUIDMax
o.AdminGroups = linuxAdminGroups
o.SudoApplies = true
return o
}
cases := []struct {
name string
in unixOwner
want ownerFacts
}{
{"root is the system and an administrator",
linux(unixOwner{UID: 0, Name: "root", LocalRecord: ownerYes, GroupsKnown: true, SudoKnown: true,
Sessions: map[string]bool{}, SessionsKnown: true}),
ownerFacts{ownerClassSystem, ownerYes, ownerNo}},
{"a daemon account in the system range",
linux(unixOwner{UID: 115, Name: "svc-demo", LocalRecord: ownerYes, GroupsKnown: true, SudoKnown: true,
Sessions: map[string]bool{}, SessionsKnown: true}),
ownerFacts{ownerClassService, ownerNo, ownerNo}},
{"nobody is a service account",
linux(unixOwner{UID: 65534, Name: "nobody", LocalRecord: ownerYes}),
ownerFacts{ownerClassService, ownerUnknown, ownerUnknown}},
{"a person in the sudo group, logged in",
linux(unixOwner{UID: 1001, Name: "pat", LocalRecord: ownerYes, Groups: []string{"pat", "sudo"},
GroupsKnown: true, Sessions: map[string]bool{"pat": true}, SessionsKnown: true}),
ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
{"a person a sudo rule names directly",
linux(unixOwner{UID: 1002, Name: "lee", LocalRecord: ownerYes, Groups: []string{"lee"},
GroupsKnown: true, SudoNamed: true, SudoKnown: true}),
ownerFacts{ownerClassLocal, ownerYes, ownerUnknown}},
{"no admin group and the sudo rules read: no",
linux(unixOwner{UID: 1003, Name: "kim", LocalRecord: ownerYes, Groups: []string{"kim"},
GroupsKnown: true, SudoKnown: true, Sessions: map[string]bool{"pat": true}, SessionsKnown: true}),
ownerFacts{ownerClassLocal, ownerNo, ownerNo}},
{"no admin group but the sudo rules unreadable: unknown, never no",
linux(unixOwner{UID: 1003, Name: "kim", LocalRecord: ownerYes, Groups: []string{"kim"}, GroupsKnown: true}),
ownerFacts{ownerClassLocal, ownerUnknown, ownerUnknown}},
{"groups unreadable: unknown, never no",
linux(unixOwner{UID: 1004, Name: "ash", LocalRecord: ownerYes, SudoKnown: true}),
ownerFacts{ownerClassLocal, ownerUnknown, ownerUnknown}},
{"not in passwd on a machine that resolves from a directory",
linux(unixOwner{UID: 501234, Name: "501234", LocalRecord: ownerNo, DirectorySource: true}),
ownerFacts{ownerClassDomain, ownerUnknown, ownerUnknown}},
{"not in passwd and no directory: a uid with no account, not a domain user",
linux(unixOwner{UID: 501234, Name: "501234", LocalRecord: ownerNo}),
ownerFacts{ownerUnknown, ownerUnknown, ownerUnknown}},
{"a realm-qualified name is a directory account",
linux(unixOwner{UID: 70001, Name: "pat@corp.example", LocalRecord: ownerUnknown}),
ownerFacts{ownerClassDomain, ownerUnknown, ownerUnknown}},
{"uid unread",
linux(unixOwner{UID: -1, Name: ""}),
ownerFacts{ownerUnknown, ownerUnknown, ownerUnknown}},
{"macOS admin group member",
unixOwner{UID: 501, Name: "sam", LocalRecord: ownerYes, Groups: []string{"staff", "admin"},
GroupsKnown: true, SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups,
Sessions: map[string]bool{"sam": true}, SessionsKnown: true},
ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
{"macOS standard user: the admin group decides",
unixOwner{UID: 502, Name: "jo", LocalRecord: ownerYes, Groups: []string{"staff"},
GroupsKnown: true, SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups},
ownerFacts{ownerClassLocal, ownerNo, ownerUnknown}},
{"macOS daemon",
unixOwner{UID: 88, Name: "_demo", LocalRecord: ownerYes, SystemUIDMax: darwinSystemUIDMax,
AdminGroups: darwinAdminGroups},
ownerFacts{ownerClassService, ownerUnknown, ownerUnknown}},
{"macOS mobile account",
unixOwner{UID: 1204567, Name: "rio", LocalRecord: ownerNo, DirectorySource: true,
SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups},
ownerFacts{ownerClassDomain, ownerUnknown, ownerUnknown}},
}
for _, c := range cases {
if got := classifyUnixOwner(c.in); got != c.want {
t.Errorf("%s: got %+v, want %+v", c.name, got, c.want)
}
}
}
func TestClassifyWinOwner(t *testing.T) {
const box = "DESK-DEMO"
user := "S-1-5-21-1111111111-2222222222-3333333333-1001"
cases := []struct {
name string
in winOwner
want ownerFacts
}{
{"LocalSystem",
winOwner{SID: sidLocalSystem, Domain: "NT AUTHORITY", Computer: box},
ownerFacts{ownerClassSystem, ownerYes, ownerUnknown}},
{"NetworkService",
winOwner{SID: sidNetworkService, Domain: "NT AUTHORITY", Computer: box,
Groups: map[string]bool{}, GroupsKnown: true},
ownerFacts{ownerClassService, ownerNo, ownerUnknown}},
{"a virtual service account",
winOwner{SID: "S-1-5-80-1-2-3-4-5", Domain: "NT SERVICE", Computer: box},
ownerFacts{ownerClassService, ownerUnknown, ownerUnknown}},
{"local administrator, unelevated, at the desktop",
winOwner{SID: user, Domain: box, Computer: box,
Groups: map[string]bool{sidAdministrators: true, sidInteractive: true},
GroupsKnown: true, SessionSIDs: map[string]bool{user: true}, SessionsKnown: true},
ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
{"domain user, not an administrator, no session now",
winOwner{SID: user, Domain: "CORPDEMO", Computer: box,
Groups: map[string]bool{"S-1-5-3": true}, GroupsKnown: true,
SessionSIDs: map[string]bool{}, SessionsKnown: true},
ownerFacts{ownerClassDomain, ownerNo, ownerNo}},
{"the local-account group decides even with no domain name",
winOwner{SID: user, Groups: map[string]bool{sidLocalAccount: true}, GroupsKnown: true},
ownerFacts{ownerClassLocal, ownerNo, ownerUnknown}},
{"no session list, but this process is an interactive logon",
winOwner{SID: user, Domain: box, Computer: box,
Groups: map[string]bool{sidRemoteInteractive: true, sidInteractive: true}, GroupsKnown: true},
ownerFacts{ownerClassLocal, ownerNo, ownerYes}},
{"no session list and a batch token: unknown, never no",
winOwner{SID: user, Domain: box, Computer: box,
Groups: map[string]bool{"S-1-5-3": true}, GroupsKnown: true},
ownerFacts{ownerClassLocal, ownerNo, ownerUnknown}},
{"token unreadable",
winOwner{},
ownerFacts{ownerUnknown, ownerUnknown, ownerUnknown}},
}
for _, c := range cases {
if got := classifyWinOwner(c.in); got != c.want {
t.Errorf("%s: got %+v, want %+v", c.name, got, c.want)
}
}
}
func TestOwnerFileParsers(t *testing.T) {
status := "Name:\tdemo\nUid:\t1001\t0\t1001\t1001\nGid:\t1001\t1001\t1001\t1001\nGroups:\t27 1001 \n"
r, e, gids, ok := parseStatusIDs(status)
if !ok || r != 1001 || e != 0 || len(gids) != 2 || gids[0] != 27 {
t.Fatalf("status: %d %d %v %v", r, e, gids, ok)
}
if _, _, _, ok := parseStatusIDs("Name:\tdemo\n"); ok {
t.Fatal("a status with no Uid line must not read as uid 0")
}
names := parseGroupNames("root:x:0:\nsudo:x:27:pat\n# comment\nbad\n")
if names[27] != "sudo" || names[0] != "root" || len(names) != 2 {
t.Fatalf("groups: %v", names)
}
uids := parsePasswdUIDs("root:x:0:0::/root:/bin/sh\npat:x:1001:1001::/home/pat:/bin/bash\n")
if !uids[1001] || !uids[0] || uids[5] {
t.Fatalf("passwd: %v", uids)
}
for body, want := range map[string]int{
"": linuxSystemUIDMax,
"UID_MIN 500\n": 499,
"UID_MIN 1000\nSYS_UID_MAX 899\n": 899,
"# SYS_UID_MAX 10\n": linuxSystemUIDMax,
} {
if got := parseLoginDefsSysMax(body); got != want {
t.Errorf("login.defs %q: %d, want %d", body, got, want)
}
}
if !nsswitchHasDirectory("group: files\npasswd: files sss systemd\n") ||
nsswitchHasDirectory("passwd: files systemd\n") {
t.Fatal("nsswitch directory detection")
}
for body, want := range map[string][2]bool{
"UID=1001\nSTATE=active\nSESSIONS=3\n": {true, true},
"STATE=lingering\n": {false, true},
"STATE=something-new\n": {false, false},
"UID=1001\n": {false, false},
} {
got, ok := parseLogindUserState(body)
if got != want[0] || ok != want[1] {
t.Errorf("logind %q: %v %v", body, got, ok)
}
}
if u := parseWhoUsers("sam console Oct 20 09:00\njo ttys001 Oct 20 09:05\n"); !u["sam"] || !u["jo"] || len(u) != 2 {
t.Fatalf("who: %v", u)
}
uid, mobile, ok := parseDsclUser("OriginalNodeName:\n /Active Directory/DEMO\nUniqueID: 1204567\n")
if !ok || uid != 1204567 || !mobile {
t.Fatalf("dscl: %d %v %v", uid, mobile, ok)
}
if g := parseIDGroups("staff admin everyone\n"); len(g) != 3 || g[1] != "admin" {
t.Fatalf("id: %v", g)
}
}
func TestParseUtmpUsersReadsOnlyLoginSessions(t *testing.T) {
rec := func(typ uint16, user string) []byte {
b := make([]byte, utmpRecordSize)
binary.LittleEndian.PutUint16(b, typ)
copy(b[utmpUserOffset:], user)
return b
}
var blob []byte
blob = append(blob, rec(utmpUserProcess, "pat")...)
blob = append(blob, rec(8, "gone")...) // DEAD_PROCESS: a session that ended
blob = append(blob, rec(utmpUserProcess, "lee")...)
blob = append(blob, 1, 2, 3) // a torn tail is ignored, not misread
got := parseUtmpUsers(blob)
if !got["pat"] || !got["lee"] || got["gone"] || len(got) != 2 {
t.Fatalf("utmp: %v", got)
}
}
+158
View File
@@ -214,6 +214,14 @@ type agentObs struct {
// (baseline.go). Empty where the platform does not supply one: darwin and
// windows never populate procFacts.Unit, so those fall back to the origin.
Unit string `json:"unit,omitempty"`
// The account behind User (agentowner.go): domain_user, local_user,
// service, system or unknown; whether it administers this machine; and
// whether it has a login session now. yes, no or unknown each — unknown
// is what an unreadable token or group list says, never no. Empty only on
// an observation made outside scanAgents.
OwnerClass string `json:"owner_class,omitempty"`
OwnerAdmin string `json:"owner_admin,omitempty"`
OwnerInteractive string `json:"owner_interactive,omitempty"`
Evidence []evidence `json:"evidence,omitempty"`
raw string // the UNTRUNCATED command line, for classification only; never reported
// automation: the signature came from the automation tables, so the
@@ -304,6 +312,7 @@ func scanAgents() ([]agentObs, error) {
}
extra := originExtras()
facts := procFactsReader()
owners := ownerFactsReader()
out := make([]agentObs, 0, len(byPid))
for pid, a := range byPid {
if faIsOwnChild(pid) {
@@ -317,6 +326,11 @@ func scanAgents() ([]agentObs, error) {
if f, ok := facts(pid); ok {
a.Unit = f.Unit
}
// Who the owning account is, beside who it is called. Read here
// rather than in each platform's walk so a process the network
// signal alone caught (mergeConnections) gets the same answer.
o := owners(pid, a.User)
a.OwnerClass, a.OwnerAdmin, a.OwnerInteractive = o.Class, o.Admin, o.Interactive
a.Evidence = append([]evidence{signatureEvidence(a)}, endpointEvidence(a.LLMEndpoints)...)
if a.DelegationToken != "" {
a.Evidence = append(a.Evidence, evidence{Signal: "delegation-tag", Tier: tierKnown, Value: "present"})
@@ -327,6 +341,133 @@ func scanAgents() ([]agentObs, error) {
return out, nil
}
// scanMCPProcessesFn is the seam a test uses to hand the correlation a
// process list; production never reassigns it.
var scanMCPProcessesFn = scanMCPProcesses
// mcpServersReport reads the declared tool servers and correlates them with
// the process list. It lives here, not in mcpconfig.go, because reading the
// process list runs `ps` on macOS: the config reader itself starts nothing,
// and the claims test holds it to that.
func mcpServersReport() ([]mcpServerObs, mcpConfigScan) {
declared, scan, read := cachedMCPConfigs()
if !scan.Supported {
scan.ProcessScan = "unsupported"
return []mcpServerObs{}, scan
}
procs, err := scanMCPProcessesFn(mcpWantedBases(declared))
switch {
case err == nil:
scan.ProcessScan = "ok"
case agentScanSupported:
scan.ProcessScan = "failed"
default:
scan.ProcessScan = "unsupported"
}
if !read {
// No read of the files has finished yet (timed_out says so). With
// nothing known to be declared, every tool-server-looking process
// would read as running_not_declared, which nobody has checked.
return []mcpServerObs{}, scan
}
out := correlateMCP(declared, procs, err == nil)
if out == nil {
out = []mcpServerObs{}
}
if len(out) >= mcpMaxServers {
scan.Truncated = true
}
return out, scan
}
// mcpCache holds the last complete read of the config files. The heartbeat
// is every 60 s and the read is up to a thousand files across every account;
// like the scheduler inventory (schedCacheFor) it is redone every ten
// minutes, while the process correlation above runs on every beat. One read
// runs at a time, and the beats wait for it at most mcpReadTimeout IN TOTAL,
// counted from when it started: a home on a network mount that stopped
// answering holds that one goroutine, delays one beat by at most that much,
// never the beats after it, and never starts a second read.
var mcpCache struct {
sync.Mutex
at time.Time
have bool
declared []mcpServerObs
scan mcpConfigScan
inflight chan struct{}
started time.Time // when the read in flight began
gen int
}
const mcpCacheFor = 10 * time.Minute
// mcpReadTimeout is how long one beat waits for a read in progress.
var mcpReadTimeout = 20 * time.Second
// cachedMCPConfigs is the last complete read. read is false when no read has
// finished yet, so the list says nothing about what is declared.
func cachedMCPConfigs() (declared []mcpServerObs, scan mcpConfigScan, read bool) {
mcpCache.Lock()
if mcpCache.have && time.Since(mcpCache.at) < mcpCacheFor {
d, s := mcpCache.declared, mcpCache.scan
mcpCache.Unlock()
return d, s, true
}
done := mcpCache.inflight
if done == nil {
done = make(chan struct{})
mcpCache.inflight, mcpCache.started = done, time.Now()
gen := mcpCache.gen
go func() {
d, s := readMCPConfigs()
mcpCache.Lock()
if mcpCache.gen == gen {
mcpCache.declared, mcpCache.scan, mcpCache.at, mcpCache.have = d, s, time.Now(), true
mcpCache.inflight = nil
}
mcpCache.Unlock()
close(done)
}()
}
wait := mcpReadTimeout - time.Since(mcpCache.started)
mcpCache.Unlock()
timedOut := false
if wait <= 0 {
// An earlier beat already waited the whole time for this read.
select {
case <-done:
default:
timedOut = true
}
} else {
t := time.NewTimer(wait)
select {
case <-done:
case <-t.C:
timedOut = true
}
t.Stop()
}
mcpCache.Lock()
defer mcpCache.Unlock()
if !mcpCache.have {
// Nothing read yet: say so, rather than "no servers".
return nil, mcpConfigScan{Supported: true, TimedOut: true}, false
}
s := mcpCache.scan
s.TimedOut = timedOut
return mcpCache.declared, s, true
}
// resetMCPConfigCache forgets the last read; a read still running is
// ignored when it finishes. Tests use it to start clean.
func resetMCPConfigCache() {
mcpCache.Lock()
defer mcpCache.Unlock()
mcpCache.gen++
mcpCache.have, mcpCache.declared, mcpCache.scan, mcpCache.inflight = false, nil, mcpConfigScan{}, nil
}
var (
llmIPCache map[string]string
llmIPCacheAt time.Time
@@ -554,11 +695,21 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t
// done for nothing, on the user's own hardware.
var schedEntries []schedEntry
var schedScan schedScanReport
// Declared tool servers (mcpconfig.go): the agent clients' own config
// files, correlated with the process list. Same gate and same
// quiet-vs-blind rule as the schedulers: read only for a pairing entitled
// to observe, and the capability travels only from a build that can.
var mcpServers []mcpServerObs
var mcpScan mcpConfigScan
if observing {
schedEntries, schedScan = scanSchedulers()
if schedScan.Supported {
caps = append(caps, "scheduler-observe")
}
mcpServers, mcpScan = mcpServersReport()
if mcpScan.Supported {
caps = append(caps, "tool-server-observe")
}
}
// Context preparation (contextprep.go): the tools on this box that
// turn a user's bytes into their content — a PDF's text, an image's OCR.
@@ -674,6 +825,13 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t
// that read 0 crontabs is visibly different from one that read 40.
body["schedulers"] = schedEntries
body["scheduler_scan"] = schedScan
// The tool servers each agent client declares, and which are running
// (or running with no declaration). Paths, server names and command
// lines, so inside this gate with the agent list; env values and
// header values never leave mcpconfig.go, only their names and
// whether one looked like a secret.
body["tool_servers"] = mcpServers
body["tool_server_scan"] = mcpScan
// Where this machine keeps its own record — the findings journal and
// the signed accounting (machinestore.go, baselinesign.go). The host
// drafts a machine store at this root for the user to approve, once;
+73
View File
@@ -101,6 +101,79 @@ func scanProcesses() (map[int]agentObs, error) {
return out, nil
}
// ownerFactsReader answers the three owner facts agentowner.go decides on.
// macOS keeps accounts in Open Directory, not /etc/passwd, so it asks the
// system's own tools, each a fixed argv under runTimed's timeout: `dscl .`
// (the LOCAL node only) for the uid and whether the account is a mobile copy
// of a directory account, `id -Gn` for its groups (nested groups resolved by
// the system), and `who` once per scan for who is logged in. Answers are kept
// per account for the scan, so ten agents of one person cost three commands.
func ownerFactsReader() func(pid int, user string) ownerFacts {
var sessions map[string]bool
sessionsOK, sessionsRead := false, false
cache := map[string]ownerFacts{}
return func(pid int, user string) ownerFacts {
if user == "" || strings.ContainsAny(user, "/ \t\n") {
return unknownOwner
}
if f, ok := cache[user]; ok {
return f
}
if !sessionsRead {
sessionsRead = true
if out, err := runTimed("who"); err == nil {
sessions, sessionsOK = parseWhoUsers(string(out)), true
}
}
o := unixOwner{UID: -1, Name: user, LocalRecord: ownerUnknown,
SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups,
Sessions: sessions, SessionsKnown: sessionsOK}
out, err := runTimed("dscl", ".", "-read", "/Users/"+user, "UniqueID", "OriginalNodeName")
if err == nil {
if uid, mobile, ok := parseDsclUser(string(out)); ok {
o.UID = uid
o.LocalRecord = ownerYes
if mobile {
o.LocalRecord, o.DirectorySource = ownerNo, true
}
}
} else if dsclRecordMissing(err) {
// dscl ran and has no such record in the local node: the account
// comes from a directory. The uid then has to come from id.
o.LocalRecord, o.DirectorySource = ownerNo, true
if out, err := runTimed("id", "-u", "--", user); err == nil {
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil {
o.UID = n
}
}
}
if out, err := runTimed("id", "-Gn", "--", user); err == nil {
o.Groups, o.GroupsKnown = parseIDGroups(string(out)), true
}
f := classifyUnixOwner(o)
cache[user] = f
return f
}
}
// scanMCPProcesses is the process list mcpconfig.go correlates declared tool
// servers against: one `ps` sweep, every process with its parent and
// arguments. An error is a sweep that did not happen.
func scanMCPProcesses(want map[string]bool) ([]mcpProc, error) {
_, argvs, parents, err := psSweep()
if err != nil {
return nil, err
}
self := os.Getpid()
out := make([]mcpProc, 0, len(argvs))
for pid, argv := range argvs {
if pid != self {
out = append(out, mcpProc{PID: pid, PPID: parents[pid], Argv: argv})
}
}
return out, nil
}
// procParent answers from the sweep scanProcesses just took.
func procParent(pid int) int {
psCache.Lock()
+132
View File
@@ -64,6 +64,35 @@ func scanProcesses() (map[int]agentObs, error) {
return out, nil
}
// scanMCPProcesses is the process list mcpconfig.go correlates declared tool
// servers against: every process, with its parent and command line, from one
// /proc walk. The want set only matters on Windows, where reading a command
// line costs a handle per process. An error is a walk that did not happen,
// and then no running state is guessed.
func scanMCPProcesses(want map[string]bool) ([]mcpProc, error) {
entries, err := os.ReadDir(procRoot)
if err != nil {
return nil, fmt.Errorf("read %s: %w", procRoot, err)
}
self := os.Getpid()
var out []mcpProc
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil || pid == self || !e.IsDir() {
continue
}
p := mcpProc{PID: pid}
if raw, err := os.ReadFile(procRoot + "/" + e.Name() + "/stat"); err == nil {
p.PPID = parseProcStatPPID(string(raw))
}
if raw, err := os.ReadFile(procRoot + "/" + e.Name() + "/cmdline"); err == nil {
p.Argv = splitNul(raw)
}
out = append(out, p)
}
return out, nil
}
// procParent reads one process's parent pid from /proc/<pid>/stat.
func procParent(pid int) int {
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
@@ -126,6 +155,109 @@ func procUser(pid int) string {
return ""
}
// ownerFactsReader answers, per process, the three facts about its owner that
// agentowner.go decides on. The machine-wide tables (/etc/passwd, /etc/group,
// login.defs, nsswitch.conf, the sudo rules, who is logged in) are read once
// per scan, on first use, and every process looks itself up in them.
//
// The uid and groups come from /proc/<pid>/status, so they are what the
// PROCESS holds, the same source procUser names it from.
func ownerFactsReader() func(pid int, user string) ownerFacts {
var (
loaded bool
groupNames map[int]string
groupsOK bool
passwd map[int]bool
passwdOK bool
sysMax int
directory bool
sudoers map[string]bool
sudoKnown bool
sessions map[string]bool
sessionsOK bool
logindUsers = identityPath("/run/systemd/users")
)
load := func() {
loaded = true
if b, err := os.ReadFile(identityPath("/etc/group")); err == nil {
groupNames, groupsOK = parseGroupNames(string(b)), true
}
if b, err := os.ReadFile(identityPath("/etc/passwd")); err == nil {
passwd, passwdOK = parsePasswdUIDs(string(b)), true
}
sysMax = linuxSystemUIDMax
if b, err := os.ReadFile(identityPath("/etc/login.defs")); err == nil {
sysMax = parseLoginDefsSysMax(string(b))
}
if b, err := os.ReadFile(identityPath("/etc/nsswitch.conf")); err == nil {
directory = nsswitchHasDirectory(string(b))
}
var partial string
sudoers, partial = sudoPrincipals()
sudoKnown = partial == ""
// Who has a session: logind's own record when it runs, else utmp.
// Neither readable leaves the answer unknown rather than "nobody".
if _, err := os.Stat(logindUsers); err != nil {
if b, err := os.ReadFile(identityPath("/var/run/utmp")); err == nil {
sessions, sessionsOK = parseUtmpUsers(b), true
}
}
}
return func(pid int, user string) ownerFacts {
if !loaded {
load()
}
raw, err := os.ReadFile(procRoot + "/" + strconv.Itoa(pid) + "/status")
if err != nil {
return unknownOwner
}
realUID, effUID, gids, ok := parseStatusIDs(string(raw))
if !ok {
return unknownOwner
}
o := unixOwner{UID: realUID, Name: user, LocalRecord: ownerUnknown, DirectorySource: directory,
SystemUIDMax: sysMax, AdminGroups: linuxAdminGroups, SudoApplies: true,
SudoNamed: sudoers[user], SudoKnown: sudoKnown}
if passwdOK {
o.LocalRecord = yesNo(passwd[realUID])
}
if groupsOK {
o.GroupsKnown = true
for _, g := range gids {
if n, ok := groupNames[g]; ok {
o.Groups = append(o.Groups, n)
}
}
}
switch {
case sessionsOK:
o.Sessions, o.SessionsKnown = sessions, true
default:
b, err := os.ReadFile(logindUsers + "/" + strconv.Itoa(realUID))
switch {
case err == nil:
if live, ok := parseLogindUserState(string(b)); ok {
o.Sessions, o.SessionsKnown = map[string]bool{user: live}, true
}
case os.IsNotExist(err):
// logind keeps a file for every user it tracks; none means
// this account has no session and no lingering services.
if _, derr := os.Stat(logindUsers); derr == nil {
o.Sessions, o.SessionsKnown = map[string]bool{}, true
}
}
}
f := classifyUnixOwner(o)
if effUID == 0 {
// A process running with effective uid 0 holds root whatever
// account started it: that is the administrator answer that
// matters, while the class stays the account's own.
f.Admin = ownerYes
}
return f
}
}
func procCmdline(pid int) string {
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline")
if err != nil {
+8
View File
@@ -34,3 +34,11 @@ func procEnvToken(pid int) string { return "" }
func procExe(pid int) string { return "" }
func procParent(pid int) int { return 0 }
func processParents() map[int]int { return map[int]int{} }
// ownerFactsReader: no reader here, so every owner fact is unknown.
func ownerFactsReader() func(pid int, user string) ownerFacts {
return func(int, string) ownerFacts { return unknownOwner }
}
// scanMCPProcesses: no enumerator, so running state is never measured here.
func scanMCPProcesses(map[string]bool) ([]mcpProc, error) { return nil, errNoEnumerator }
+169 -5
View File
@@ -306,6 +306,7 @@ func processParents() map[int]int {
// scanProcesses: an error is a snapshot that did not happen (Toolhelp can
// fail transiently), never an empty box; the heartbeat withholds `agents`.
func scanProcesses() (map[int]agentObs, error) {
winBeginSIDScan()
out := map[int]agentObs{}
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
@@ -390,6 +391,50 @@ func scanProcesses() (map[int]agentObs, error) {
return out, nil
}
// scanMCPProcesses is the process list mcpconfig.go correlates declared tool
// servers against. Every process comes from one Toolhelp snapshot with its
// parent; the command line, which costs a handle and a memory read, is read
// only for a program in want (interpreters, package runners, and every
// program a declared server names) or whose own name carries the word mcp.
// The rest keep their parent link with no command line, which is all the
// correlation needs from them. An error is a snapshot that did not happen.
func scanMCPProcesses(want map[string]bool) ([]mcpProc, error) {
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return nil, fmt.Errorf("process snapshot: %w", err)
}
defer windows.CloseHandle(snap)
var e windows.ProcessEntry32
e.Size = uint32(unsafe.Sizeof(e))
self := os.Getpid()
var out []mcpProc
for err = windows.Process32First(snap, &e); err == nil; err = windows.Process32Next(snap, &e) {
pid := int(e.ProcessID)
if pid <= 0 || pid == self {
continue
}
p := mcpProc{PID: pid, PPID: int(e.ParentProcessID)}
exe := windows.UTF16ToString(e.ExeFile[:])
if base := binBase(exe); want[base] || mcpWord(base) {
cmdline := ""
if h, herr := winOpenForRead(pid); herr == nil {
cmdline = winCmdline(h)
windows.CloseHandle(h)
}
if argv, derr := windows.DecomposeCommandLine(cmdline); derr == nil && len(argv) > 0 {
p.Argv = argv
} else {
p.Argv = []string{exe}
}
}
out = append(out, p)
}
if !errors.Is(err, windows.ERROR_NO_MORE_FILES) {
return nil, fmt.Errorf("process walk: %w", err)
}
return out, nil
}
// ── connections: GetExtendedTcpTable ────────────────────────────────────────
var (
@@ -463,14 +508,133 @@ func winTokenUser(h windows.Handle) string {
if err != nil || tu.User.Sid == nil {
return ""
}
account, domain, _, err := tu.User.Sid.LookupAccount("")
return winAccountLabel(tu.User.Sid.String(), winScanSIDNames())
}
// winSIDScan is the current scan's account-name cache (sidnames.go), shared
// by winTokenUser and ownerFactsReader so a SID is looked up once per scan
// and the scan's lookups stay within sidLookupBudget. scanProcesses starts a
// new one; the readers that run after it in scanAgents use that one.
var winSIDScan struct {
sync.Mutex
names *sidNames
}
func winBeginSIDScan() *sidNames {
n := newSIDNames(winLookupSID, sidLookupBudget, sidLookupSlots)
winSIDScan.Lock()
winSIDScan.names = n
winSIDScan.Unlock()
return n
}
func winScanSIDNames() *sidNames {
winSIDScan.Lock()
n := winSIDScan.names
winSIDScan.Unlock()
if n == nil {
return winBeginSIDScan()
}
return n
}
// winLookupSID is LookupAccountSid on this machine for one SID string.
func winLookupSID(s string) (string, string, error) {
a, err := winLookupSIDAccount(s)
return a.Account, a.Domain, err
}
// winLookupSIDAccount is LookupAccountSid with the SID's type. Unbounded:
// callers go through sidNames or winAccounts.
func winLookupSIDAccount(s string) (sidAccount, error) {
sid, err := windows.StringToSid(s)
if err != nil {
return tu.User.Sid.String()
return sidAccount{}, err
}
if domain != "" {
return domain + `\` + account
account, domain, typ, err := sid.LookupAccount("")
return sidAccount{Account: account, Domain: domain, Type: typ}, err
}
return account
// winAccounts is the process-wide account cache for lookups made outside a
// scan: the approvals pipe and the folder agent's drop (sidnames.go).
var winAccounts = newSIDDirectory(winLookupSIDAccount)
// ownerFactsReader answers the three owner facts agentowner.go decides on,
// from each process's own token: its user SID and that SID's domain, and its
// group SIDs (deny-only ones included, which is how an administrator running
// without elevation still reads as an administrator account). The session
// list is read once per scan and needs LocalSystem, which the system service
// is; elsewhere it is left unknown and the token's own logon type answers
// what it can (classifyWinOwner). The domain comes from the scan's shared
// account-name cache (winScanSIDNames), so a SID winTokenUser already looked
// up is not asked again, and an unreachable domain controller leaves the
// class unknown instead of holding the scan.
func ownerFactsReader() func(pid int, user string) ownerFacts {
computer, _ := windows.ComputerName()
names := winScanSIDNames()
var sessions map[string]bool
sessionsOK, sessionsRead := false, false
return func(pid int, _ string) ownerFacts {
if !sessionsRead {
sessionsRead = true
sessions, sessionsOK = winSessionSIDs()
}
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
return unknownOwner
}
defer windows.CloseHandle(h)
var tok windows.Token
if err := windows.OpenProcessToken(h, windows.TOKEN_QUERY, &tok); err != nil {
return unknownOwner
}
defer tok.Close()
o := winOwner{Computer: computer, SessionSIDs: sessions, SessionsKnown: sessionsOK}
if tu, err := tok.GetTokenUser(); err == nil && tu.User.Sid != nil {
o.SID = tu.User.Sid.String()
o.Domain = winAccountDomain(o.SID, names)
}
if tg, err := tok.GetTokenGroups(); err == nil {
o.Groups, o.GroupsKnown = map[string]bool{}, true
for _, g := range tg.AllGroups() {
if g.Sid != nil {
o.Groups[g.Sid.String()] = true
}
}
}
return classifyWinOwner(o)
}
}
// winSessionSIDs is the set of user SIDs with an active or disconnected
// Remote Desktop Services session (the console is one). ok is false when the
// list could not be read: WTSQueryUserToken needs LocalSystem's privilege.
func winSessionSIDs() (map[string]bool, bool) {
var info *windows.WTS_SESSION_INFO
var count uint32
if err := windows.WTSEnumerateSessions(0, 0, 1, &info, &count); err != nil {
return nil, false
}
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(info)))
list := unsafe.Slice(info, count)
out := map[string]bool{}
for _, s := range list {
if s.State != windows.WTSActive && s.State != windows.WTSDisconnected {
continue
}
var tok windows.Token
if err := windows.WTSQueryUserToken(s.SessionID, &tok); err != nil {
if errors.Is(err, windows.ERROR_PRIVILEGE_NOT_HELD) || errors.Is(err, windows.ERROR_ACCESS_DENIED) {
return nil, false
}
continue // a session with no user (the services session, a login screen)
}
if tu, err := tok.GetTokenUser(); err == nil && tu.User.Sid != nil {
out[tu.User.Sid.String()] = true
}
tok.Close()
}
return out, true
}
func procUser(pid int) string {
+25
View File
@@ -978,6 +978,20 @@ func cmdRepinAnchor() error {
return nil
}
// anchorAlreadyHolds: the anchor names an active pairing here, and host
// (claim-anchor --host, "" for none) names that same pairing.
func anchorAlreadyHolds(cfgs []*Config, prev *anchorClaim, host string) bool {
c := pairingFor(cfgs, prev.AnchorHost)
if c == nil || !c.active() {
return false
}
if host == "" {
return true
}
named, err := pickPairing(cfgs, host)
return err == nil && named.HostName == c.HostName
}
// pickPairing resolves --host against this box's pairings. It accepts the full
// host_name the box reports, or just the handle in front of it, because the
// name the user is looking at depends on which surface they read it from —
@@ -1021,6 +1035,17 @@ func cmdClaimAnchor(token string, rotate bool, owner, host string) error {
return err
}
prev := currentAnchor()
// Already anchored to the host this asks for (no token, no other host
// named), and that host's pairing is active: done, not an error. `pair`
// anchors a networked box to its first host by itself, and an installer
// that runs claim-anchor after pairing must find that a success. An anchor
// whose pairing is pending or revoked is not done, and is refused below as
// before. --host takes the short handle as well as the full name
// (pickPairing), so the same host named either way is the same request.
if prev != nil && !rotate && token == "" && anchorAlreadyHolds(cfgs, prev, host) {
fmt.Printf("already anchored to %s (owner %q)\n", prev.AnchorHost, prev.Owner)
return nil
}
if prev != nil && !rotate {
return fmt.Errorf("this box is already anchored to %s — `claim-anchor --rotate <token>` moves the anchor (needs the current anchor's consent, or %d days of silence from it)", prev.AnchorHost, int(anchorDeadAfter.Hours()/24))
}
+47 -3
View File
@@ -658,14 +658,58 @@ func TestClaimAnchorWithNoToken(t *testing.T) {
}
})
t.Run("already anchored — still refused without --rotate", func(t *testing.T) {
t.Run("already anchored to that host — success, and nothing changes", func(t *testing.T) {
// `pair` anchors a networked box by itself, and an installer that runs
// claim-anchor afterwards must find that done, not an error.
freshBox(t, true)
writePairing(t, tMember)
if err := writeAnchor("Acme", tMember); err != nil {
t.Fatal(err)
}
if err := cmdClaimAnchor("", false, "", ""); err == nil || !contains(err.Error(), "already anchored") {
t.Fatalf("dropping the token must not let a second claim through: %v", err)
if err := cmdClaimAnchor("", false, "", ""); err != nil {
t.Fatalf("claim-anchor on a box already anchored to its host must succeed: %v", err)
}
if err := cmdClaimAnchor("", false, "", tMember); err != nil {
t.Fatalf("naming the anchor host itself must succeed: %v", err)
}
if err := cmdClaimAnchor("", false, "", handleOf(tMember)); err != nil {
t.Fatalf("naming the anchor host by its handle (as --host accepts) must succeed: %v", err)
}
if a := currentAnchor(); a == nil || a.AnchorHost != tMember || a.Owner != "Acme" {
t.Fatalf("the anchor must be untouched: %+v", a)
}
})
t.Run("anchored to a host whose pairing is pending or revoked — not reported done", func(t *testing.T) {
// A success here would tell an installer the anchor holds while its
// host has no active lanes on this box.
for _, st := range []string{pairingPending, pairingRevoked} {
freshBox(t, true)
writePairingState(t, tMember, st)
if err := writeAnchor("Acme", tMember); err != nil {
t.Fatal(err)
}
if err := cmdClaimAnchor("", false, "", ""); err == nil {
t.Fatalf("anchor pairing %s: claim-anchor must not report the anchor done", st)
}
if got := loadState(t, tMember); got != st {
t.Fatalf("anchor pairing %s: state changed to %q", st, got)
}
}
})
t.Run("already anchored — another host is still refused without --rotate", func(t *testing.T) {
freshBox(t, true)
writePairing(t, tMember)
writePairing(t, tAnchor)
if err := writeAnchor("Acme", tMember); err != nil {
t.Fatal(err)
}
if err := cmdClaimAnchor("", false, "", tAnchor); err == nil || !contains(err.Error(), "already anchored") {
t.Fatalf("naming a different host must not move the anchor without --rotate: %v", err)
}
if a := currentAnchor(); a == nil || a.AnchorHost != tMember {
t.Fatalf("the anchor must be untouched: %+v", a)
}
})
+32
View File
@@ -138,6 +138,30 @@ type pipeClient struct {
// "" when nobody is.
SessionUserSID string
SessionActive bool // WTSActive: the session someone is in front of
// NameTimedOut: the account directory did not answer for UserSID within
// pipeSIDLane's budget, so UserName is empty and nobody can say whose
// account approved. pipeApprove refuses; list and deny go on, by SID.
NameTimedOut bool
}
// pipeSIDLane bounds the client's account-name lookup (sidnames.go). The tray
// waits 2*pipeIOTimeout for its answer, and the name is looked up before the
// request is read, so the lookup gets well under that: a directory that does
// not answer costs one connection 2 s, and holds at most one goroutine for
// the pipe however many connections come.
var pipeSIDLane = sidLane{slots: make(chan struct{}, 1), budget: 2 * time.Second}
// pipeClientName fills c.UserName from the account directory, or sets
// c.NameTimedOut when the directory did not answer in time. A SID with no
// account leaves UserName empty: the log shows the SID, as before.
func pipeClientName(c *pipeClient, dir *sidDirectory, lane sidLane) {
acct, outcome := dir.resolve(c.UserSID, lane)
switch outcome {
case sidResolved:
c.UserName = sidAccountLabel(acct)
case sidTimedOut:
c.NameTimedOut = true
}
}
// purposeDigest is the sha256 of a purpose as the tray carries it back.
@@ -330,6 +354,14 @@ func pipeTarget(req pipeRequest) (dir string, r stepupRequest, err error) {
var pipeApproveMu sync.Mutex
func pipeApprove(req pipeRequest, c pipeClient, now time.Time) pipeResponse {
// FAILS CLOSED ON A SLOW DIRECTORY. The rules above decide by SID, but an
// approval's audit line names the account that gave it, and when the
// directory could not say whose account this is, nothing is approved.
// Before the throttle and the code, so this counts as no wrong code.
if c.NameTimedOut {
return pipeResponse{Error: "not approved: this machine's account directory did not answer for " + c.UserSID +
" in time; try again in a minute, or approve from an Administrator terminal"}
}
pipeApproveMu.Lock()
defer pipeApproveMu.Unlock()
code := strings.TrimSpace(req.Code)
+3 -6
View File
@@ -311,12 +311,9 @@ func pipeClientOf(h windows.Handle) (pipeClient, error) {
return c, fmt.Errorf("cannot read the connecting process's account (pid %d): %w", c.PID, err)
}
c.UserSID = u.User.Sid.String()
if name, dom, _, err := u.User.Sid.LookupAccount(""); err == nil {
c.UserName = name
if dom != "" {
c.UserName = dom + `\` + name
}
}
// Bounded and cached (sidnames.go): a directory that does not answer
// leaves NameTimedOut set, and pipeApprove refuses.
pipeClientName(&c, winAccounts, pipeSIDLane)
var sess, n uint32
if err := windows.GetTokenInformation(tok, windows.TokenSessionId, (*byte)(unsafe.Pointer(&sess)), uint32(unsafe.Sizeof(sess)), &n); err != nil {
return c, fmt.Errorf("cannot read the connecting process's session (pid %d): %w", c.PID, err)
+179
View File
@@ -0,0 +1,179 @@
package main
// Audit rows to the system log, as well as to their files.
//
// Every audit file on this box (mgmt_audit.jsonl, pairing_audit.jsonl, and a
// pairing's audit.jsonl for terminals, root commands, store ops and op runs)
// lives here, on this machine. The host can fetch them over the tunnel but
// never edit them (mgmt_audit.go handleAuditFetch). That still leaves the host
// as the only central view of a fleet, and the host is the agent the audit is
// about. So each row is also written to the machine's own system log, where
// whatever already collects that log (journald or syslog forwarding, a log
// shipper, a SIEM) takes it to a store the host cannot write:
//
// Linux, macOS syslog, facility auth, tag privatae-bastion-audit
// (journald on a systemd machine: SYSLOG_IDENTIFIER=privatae-bastion-audit)
// Windows the Application event log, source PrivataeBastionAudit, event id 1
//
// The system-log line is the file's row with two fields added in front of it:
// `audit`, which file the row went to, and `pairing`, the pairing it belongs to
// ("" for a box-level file). The file stays the record: a system log that is
// missing or refuses a write costs that copy and nothing else, and is never a
// reason to refuse or slow the op being audited.
//
// A root command's text is the one thing the file has that the system-log line
// does not: it is whatever the host typed, a password on a command line
// included, and the system log is readable by more accounts than the 0600 file
// and forwarded off the machine by design. The line keeps command_sha256 and
// command_bytes, so a collector can still match it to the file and the
// transcript and see if either was changed.
//
// The write itself never runs on the audited op's goroutine. The syslog socket
// is a blocking write: a journald or syslog daemon that is stuck or slow would
// otherwise hold every audited op, and every other audit writer behind the
// lock, for as long as it stays stuck. Lines go into a bounded queue that one
// goroutine drains; a full queue drops the line and counts it, and the next
// line queued after the gap carries the count (auditDroppedField).
import (
"bytes"
"encoding/json"
"path/filepath"
"sync"
"sync/atomic"
"time"
)
const (
auditSyslogTag = "privatae-bastion-audit"
auditEventSource = "PrivataeBastionAudit"
auditEventID = 1
auditFilePairing = "pairing_audit.jsonl"
auditFilePairingD = "audit.jsonl"
)
// auditSink takes one line for the system log. A variable so a test can watch
// the lines; the default queues it for the platform's writer and never blocks.
var auditSink = auditEnqueue
const (
// auditQueueMax lines wait for the system log; beyond that they are
// dropped and counted. A burst of audited ops far larger than any lane
// produces fits; a system log stuck for good holds no more than this.
auditQueueMax = 4096
// auditDroppedField is added to the first line queued after a drop: how
// many lines the system log lost just before it, so the gap shows where.
auditDroppedField = "system_log_dropped"
// auditFlushOnExit bounds how long a finished command waits for its rows
// to reach a slow system log.
auditFlushOnExit = 2 * time.Second
)
// auditQueue is the bounded queue in front of a system-log writer, drained by
// one goroutine so lines keep their order.
type auditQueue struct {
ch chan []byte
write func([]byte)
once sync.Once
dropped atomic.Int64
pending atomic.Int64 // queued or being written
}
func newAuditQueue(n int, write func([]byte)) *auditQueue {
return &auditQueue{ch: make(chan []byte, n), write: write}
}
// The box's one queue, in front of systemAuditWrite (auditlog_*.go).
var auditQ = newAuditQueue(auditQueueMax, func(l []byte) { systemAuditWrite(l) })
func auditEnqueue(line []byte) { auditQ.enqueue(line) }
// auditFlush waits, at most d, for queued lines to reach the system log. A
// command that audits and then exits (pair, claim-anchor, set-mode) calls it
// on the way out (main.go), so its rows are not lost with the process; a
// stuck system log costs that wait and nothing more.
func auditFlush(d time.Duration) { auditQ.flush(d) }
// enqueue hands one line to the drain goroutine, or drops it and counts it
// when the queue is full. It never waits on the writer.
func (q *auditQueue) enqueue(line []byte) {
q.once.Do(func() { go q.drain() })
n := q.dropped.Swap(0)
if n > 0 {
line = auditWithDropped(line, n)
}
q.pending.Add(1)
select {
case q.ch <- line:
default:
q.pending.Add(-1)
q.dropped.Add(n + 1)
}
}
func (q *auditQueue) drain() {
for line := range q.ch {
q.write(line)
q.pending.Add(-1)
}
}
func (q *auditQueue) flush(d time.Duration) {
deadline := time.Now().Add(d)
for q.pending.Load() > 0 && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
}
// auditWithDropped adds the drop count to a line (every line here is the
// JSON object auditToSystemLog made).
func auditWithDropped(line []byte, n int64) []byte {
fields := map[string]any{}
dec := json.NewDecoder(bytes.NewReader(line))
dec.UseNumber()
if err := dec.Decode(&fields); err != nil {
return line
}
fields[auditDroppedField] = n
out, err := json.Marshal(fields)
if err != nil {
return line
}
return out
}
// auditToSystemLog sends one audit row, as it was written to its file, to the
// system log, with the file's name and the pairing in front.
func auditToSystemLog(file, pairing string, rec []byte) {
fields := map[string]any{}
dec := json.NewDecoder(bytes.NewReader(rec))
dec.UseNumber() // numbers go out exactly as the file has them
if err := dec.Decode(&fields); err != nil {
return
}
if cmd, ok := fields["command"].(string); ok {
// The text stays in the file; see the top of this file.
delete(fields, "command")
if _, ok := fields["command_bytes"]; !ok {
fields["command_bytes"] = len(cmd)
}
}
fields["audit"] = file
fields["pairing"] = pairing
line, err := json.Marshal(fields)
if err != nil {
return
}
auditSink(line)
}
// auditPairingOfDir names the pairing of a per-pairing audit file from its
// directory: the last path element, which is the pairing's host name
// (Config.dcnDir: dcn/<host_name>), except for a legacy pairing that kept the
// old shared directory, where it is that directory's name.
func auditPairingOfDir(dir string) string {
if dir == "" {
return ""
}
return filepath.Base(filepath.Clean(dir))
}
+40
View File
@@ -0,0 +1,40 @@
//go:build !windows
package main
import (
"log/syslog"
"sync"
)
var (
auditSyslogMu sync.Mutex
auditSyslog *syslog.Writer
)
// systemAuditWrite sends one line to syslog (journald on a systemd machine),
// facility auth, at info. Called only from auditQ's drain (auditlog.go), never on
// an audited op's own goroutine: the write blocks while the daemon is stuck. The connection is opened on first use and dropped
// after a failed write, so the next row tries again; a machine with no syslog
// socket costs the copy, never the row in the file.
func systemAuditWrite(line []byte) {
auditSyslogMu.Lock()
defer auditSyslogMu.Unlock()
if auditSyslog == nil {
w, err := syslog.New(syslog.LOG_AUTH|syslog.LOG_INFO, auditSyslogTag)
if err != nil {
return
}
auditSyslog = w
}
if err := auditSyslog.Info(string(line)); err != nil {
_ = auditSyslog.Close()
auditSyslog = nil
}
}
// registerAuditEventSource has nothing to register outside Windows: syslog
// takes any tag.
func registerAuditEventSource() error { return nil }
func removeAuditEventSource() {}
+95
View File
@@ -0,0 +1,95 @@
package main
import (
"encoding/json"
"reflect"
"strings"
"sync"
"testing"
"time"
)
// The sink the binary ships with, read before auditlog_test.go's init swaps
// it out (package variables are set before any init runs).
var auditSinkShipped = auditSink
// TestAuditSinkIsTheQueue: the shipped sink is the queue, never the
// platform's writer called on the audited op's own goroutine.
func TestAuditSinkIsTheQueue(t *testing.T) {
if reflect.ValueOf(auditSinkShipped).Pointer() != reflect.ValueOf(auditEnqueue).Pointer() {
t.Fatal("the system-log sink must be auditEnqueue: a direct write blocks the audited op while syslog is stuck")
}
}
// TestAuditQueueNeverWaitsOnAStuckWriter: with a writer that never returns,
// far more rows than the queue holds are all taken at once; the ones past the
// bound are dropped and counted, and the count rides on the next line that
// gets into the queue.
func TestAuditQueueNeverWaitsOnAStuckWriter(t *testing.T) {
release := make(chan struct{})
var mu sync.Mutex
var got []string
q := newAuditQueue(8, func(l []byte) {
<-release
mu.Lock()
got = append(got, string(l))
mu.Unlock()
})
done := make(chan struct{})
go func() {
for i := 0; i < 1000; i++ {
q.enqueue([]byte(`{"n":1}`))
}
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("audit rows waited on a stuck system log")
}
close(release)
q.flush(5 * time.Second)
// Nothing lost without a count: every one of the 1001 rows is either a
// line written or counted on a line written after the gap.
q.enqueue([]byte(`{"n":2}`))
q.flush(5 * time.Second)
mu.Lock()
defer mu.Unlock()
var counted float64
for _, l := range got {
m := map[string]any{}
if err := json.Unmarshal([]byte(l), &m); err != nil {
t.Fatal(err)
}
if n, ok := m[auditDroppedField].(float64); ok {
counted += n
}
}
if len(got) > 10 || counted == 0 || float64(len(got))+counted != 1001 {
t.Fatalf("lines written %d (at most 10), dropped and counted %v: want 1001 in all", len(got), counted)
}
}
// TestAuditSystemLogOmitsTheRootCommandText: a root command's text stays in
// the 0600 file; the system-log line carries its digest and length.
func TestAuditSystemLogOmitsTheRootCommandText(t *testing.T) {
var line string
prev := auditSink
auditSink = func(l []byte) { line = string(l) }
t.Cleanup(func() { auditSink = prev })
auditToSystemLog(auditFilePairingD, "p", []byte(`{"event":"start","command":"PGPASSWORD=hunter2 psql","command_sha256":"ab"}`))
m := map[string]any{}
if err := json.Unmarshal([]byte(line), &m); err != nil {
t.Fatal(err)
}
if strings.Contains(line, "hunter2") || m["command"] != nil {
t.Fatalf("the command text reached the system log: %s", line)
}
if m["command_sha256"] != "ab" || m["command_bytes"] != float64(len("PGPASSWORD=hunter2 psql")) {
t.Fatalf("the line must keep the digest and the length: %s", line)
}
auditToSystemLog(auditFilePairingD, "p", []byte(`{"command":"x","command_bytes":70000}`))
if !strings.Contains(line, `"command_bytes":70000`) {
t.Fatalf("a row's own command_bytes (the full length) is kept: %s", line)
}
}
+69
View File
@@ -0,0 +1,69 @@
//go:build !windows
package main
import (
"log/syslog"
"net"
"os"
"path/filepath"
"testing"
"time"
)
// TestAuditRowsDoNotWaitOnAStuckSyslogSocket: a real syslog writer on a
// datagram socket nobody reads blocks once the socket's queue is full, as
// /dev/log does while journald is stuck. Audit rows behind the queue all
// return at once; the same writer called inline (as 0.54.0 first did) does not.
func TestAuditRowsDoNotWaitOnAStuckSyslogSocket(t *testing.T) {
dir, err := os.MkdirTemp("", "al")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
sock := filepath.Join(dir, "s")
l, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: sock, Net: "unixgram"})
if err != nil {
t.Fatal(err)
}
// Closing the reading end is what lets the stuck writes below return
// (refused) so their goroutines end; the writer itself is never closed,
// since its Close would wait behind a write that is stuck.
t.Cleanup(func() { l.Close() })
w, err := syslog.Dial("unixgram", sock, syslog.LOG_AUTH|syslog.LOG_INFO, auditSyslogTag)
if err != nil {
t.Fatal(err)
}
write := func(line []byte) { _ = w.Info(string(line)) }
// The inline writer does block: without this the test proves nothing.
inline := make(chan struct{})
go func() {
for i := 0; i < 5000; i++ {
write([]byte(`{"op":"x"}`))
}
close(inline)
}()
select {
case <-inline:
t.Skip("this kernel never blocked a datagram writer; nothing to bound")
case <-time.After(2 * time.Second):
}
q := newAuditQueue(64, write)
done := make(chan struct{})
go func() {
for i := 0; i < 5000; i++ {
q.enqueue([]byte(`{"op":"y"}`))
}
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("audit rows waited on a stuck syslog socket")
}
if q.dropped.Load() == 0 {
t.Fatal("rows past the bound must be counted as dropped")
}
}
+154
View File
@@ -0,0 +1,154 @@
package main
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// The test binary never writes to the real system log: the suite's audit
// rows would otherwise land in the journal of whatever machine runs it.
func init() { auditSink = func([]byte) {} }
// auditSinkCapture swaps the system-log sink for one that keeps the lines.
func auditSinkCapture(t *testing.T) func() []map[string]any {
t.Helper()
var mu sync.Mutex
var lines [][]byte
prev := auditSink
auditSink = func(line []byte) {
mu.Lock()
defer mu.Unlock()
lines = append(lines, append([]byte(nil), line...))
}
t.Cleanup(func() { auditSink = prev })
return func() []map[string]any {
mu.Lock()
defer mu.Unlock()
var out []map[string]any
for _, l := range lines {
m := map[string]any{}
if err := json.Unmarshal(l, &m); err != nil {
t.Fatalf("system-log line is not JSON: %s", l)
}
out = append(out, m)
}
return out
}
}
func auditFileRows(t *testing.T, path string) []map[string]any {
t.Helper()
fh, err := os.Open(path)
if err != nil {
t.Fatalf("audit file %s: %v", path, err)
}
defer fh.Close()
var out []map[string]any
sc := bufio.NewScanner(fh)
for sc.Scan() {
m := map[string]any{}
if err := json.Unmarshal(sc.Bytes(), &m); err != nil {
t.Fatalf("audit row is not JSON: %s", sc.Text())
}
out = append(out, m)
}
return out
}
// auditSameRow: the system-log line is the file's row, every field equal,
// with `audit` and `pairing` added.
func auditSameRow(t *testing.T, what string, file, logged map[string]any, wantFile, wantPairing string) {
t.Helper()
if logged["audit"] != wantFile || logged["pairing"] != wantPairing {
t.Fatalf("%s: system-log line names audit=%v pairing=%v, want %q %q", what, logged["audit"], logged["pairing"], wantFile, wantPairing)
}
for k, v := range file {
if lv, ok := logged[k]; !ok || !jsonEq(lv, v) {
t.Fatalf("%s: field %q is %v in the file and %v in the system log", what, k, v, logged[k])
}
}
if len(logged) != len(file)+2 {
t.Fatalf("%s: system-log line has %d fields, the file row %d (+2 expected)", what, len(logged), len(file))
}
}
func jsonEq(a, b any) bool {
ja, _ := json.Marshal(a)
jb, _ := json.Marshal(b)
return string(ja) == string(jb)
}
// TestAuditRowsReachTheSystemLog: every audit writer on this box sends the row
// it writes to its file to the system log as well, unchanged, naming the file
// and the pairing: the management lane (mgmt_audit.jsonl), pairing changes
// (pairing_audit.jsonl), and a pairing's audit.jsonl for store ops and op runs,
// terminals and root commands.
func TestAuditRowsReachTheSystemLog(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
got := auditSinkCapture(t)
cfgDir, err := configDir()
if err != nil {
t.Fatal(err)
}
pairDir := filepath.Join(t.TempDir(), "acme.host.example")
if err := os.MkdirAll(pairDir, 0o700); err != nil {
t.Fatal(err)
}
mgmtAudit("acme.host.example", "uptime", "observe", "allowed", "", 0, []string{"a"}, "")
pairingAudit("pairing_add", "other.host.example", "ok")
dcnAudit(pairDir, "dcn_read", 7, "/srv/notes.txt", "allowed")
sessionAudit(pairDir, "session", "s-1", "ops", "start", 0, "opened")
rootExecAudit(pairDir, map[string]any{"session_id": "s-2", "event": "stop", "exit": 0})
lines := got()
if len(lines) != 5 {
t.Fatalf("got %d system-log lines, want 5: %v", len(lines), lines)
}
mgmt := auditFileRows(t, filepath.Join(cfgDir, mgmtAuditFile))
pair := auditFileRows(t, filepath.Join(cfgDir, auditFilePairing))
per := auditFileRows(t, filepath.Join(pairDir, auditFilePairingD))
if len(mgmt) != 1 || len(pair) != 1 || len(per) != 3 {
t.Fatalf("file rows: mgmt %d, pairing %d, per-pairing %d; want 1, 1, 3", len(mgmt), len(pair), len(per))
}
auditSameRow(t, "management lane", mgmt[0], lines[0], mgmtAuditFile, "acme.host.example")
auditSameRow(t, "pairing change", pair[0], lines[1], auditFilePairing, "")
auditSameRow(t, "store op", per[0], lines[2], auditFilePairingD, "acme.host.example")
auditSameRow(t, "terminal", per[1], lines[3], auditFilePairingD, "acme.host.example")
auditSameRow(t, "root command", per[2], lines[4], auditFilePairingD, "acme.host.example")
}
// TestAuditFileRowStandsWithoutASystemLog: a system log that takes nothing
// costs the copy only; the row is still written to its file.
func TestAuditFileRowStandsWithoutASystemLog(t *testing.T) {
prev := auditSink
auditSink = func([]byte) {}
t.Cleanup(func() { auditSink = prev })
pairDir := filepath.Join(t.TempDir(), "acme.host.example")
if err := os.MkdirAll(pairDir, 0o700); err != nil {
t.Fatal(err)
}
dcnAudit(pairDir, "dcn_read", 1, "/x", "allowed")
if rows := auditFileRows(t, filepath.Join(pairDir, auditFilePairingD)); len(rows) != 1 {
t.Fatalf("file rows = %d, want 1", len(rows))
}
}
// TestAuditSystemLogKeepsNumbersExact: a large id goes to the system log as
// the digits the file has, not as a float.
func TestAuditSystemLogKeepsNumbersExact(t *testing.T) {
var line string
prev := auditSink
auditSink = func(l []byte) { line = string(l) }
t.Cleanup(func() { auditSink = prev })
auditToSystemLog(auditFilePairingD, "p", []byte(`{"store_id":9007199254740993}`))
if !json.Valid([]byte(line)) || !strings.Contains(line, `"store_id":9007199254740993`) {
t.Fatalf("system-log line %s does not carry the id exactly", line)
}
}
+54
View File
@@ -0,0 +1,54 @@
//go:build windows
package main
import (
"strings"
"sync"
"golang.org/x/sys/windows/svc/eventlog"
)
var (
auditEventMu sync.Mutex
auditEventLog *eventlog.Log
)
// systemAuditWrite sends one line to the Application event log under the
// source PrivataeBastionAudit, event id 1, at information level. The source
// is registered when the system service is installed
// (registerAuditEventSource); before that, or if the log refuses, the copy is
// lost and the row in the file is not. Called only from auditQ's drain
// (auditlog.go): the event log service can be slow to answer.
func systemAuditWrite(line []byte) {
auditEventMu.Lock()
defer auditEventMu.Unlock()
if auditEventLog == nil {
l, err := eventlog.Open(auditEventSource)
if err != nil {
return
}
auditEventLog = l
}
if err := auditEventLog.Info(auditEventID, string(line)); err != nil {
_ = auditEventLog.Close()
auditEventLog = nil
}
}
// registerAuditEventSource registers PrivataeBastionAudit as an Application
// event log source, so the rows show with their own source name. Already
// registered is success.
func registerAuditEventSource() error {
err := eventlog.InstallAsEventCreate(auditEventSource, eventlog.Info|eventlog.Warning|eventlog.Error)
if err != nil && strings.Contains(err.Error(), "already exists") {
return nil
}
return err
}
// removeAuditEventSource unregisters the source when the service is removed.
// Events already written stay in the log.
func removeAuditEventSource() {
_ = eventlog.Remove(auditEventSource)
}
+404 -19
View File
@@ -11,6 +11,7 @@ package main
// runs) is driven in claims_unix_test.go and beside each lane's own tests.
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
@@ -49,6 +50,18 @@ func parseSources(t *testing.T) *srcPkg {
if err != nil {
t.Fatalf("parse %s: %v", n, err)
}
p.addFile(n, f)
}
if len(p.files) < 50 {
t.Fatalf("parsed only %d source files — is the test running in the package directory?", len(p.files))
}
return p
}
// addFile registers one parsed file and its functions. A test that proves a
// check can fail adds a synthetic file through here, so it is indexed exactly
// as a real one would be.
func (p *srcPkg) addFile(n string, f *ast.File) {
p.files[n] = f
for _, d := range f.Decls {
fd, ok := d.(*ast.FuncDecl)
@@ -67,10 +80,22 @@ func parseSources(t *testing.T) *srcPkg {
}
}
}
if len(p.files) < 50 {
t.Fatalf("parsed only %d source files — is the test running in the package directory?", len(p.files))
// funcKeysIn is the p.funcs key of every function declared in file n.
func (p *srcPkg) funcKeysIn(n string) []string {
var out []string
for _, d := range p.files[n].Decls {
fd, ok := d.(*ast.FuncDecl)
if !ok {
continue
}
return p
key := funcKey(fd)
if p.funcs[key] != fd {
key = key + "@" + n
}
out = append(out, key)
}
return out
}
// funcKey is "name" for a function and "Type.name" for a method.
@@ -488,10 +513,30 @@ func TestListenAddressesAreOnlyEverAllocatedOnLoopback(t *testing.T) {
}
}
// No MCP endpoint. Every HTTP route the binary registers, on any listener,
// is in this table; none of them, and no op name on the management lane,
// names MCP; no string in the code addresses an /mcp path or speaks JSON-RPC,
// the protocol MCP is carried on; and no dependency is an MCP library.
// No MCP endpoint: the bastion never speaks the protocol. Every HTTP route
// the binary registers, on any listener, is in this table; none of them, and
// no op name on the management lane, names MCP; no string in the code is a
// URL or route with an /mcp path, or speaks JSON-RPC, the protocol MCP is
// carried on; and no dependency is an MCP library.
//
// WHAT CHANGED, AND WHY THE GUARANTEE DID NOT. This test used to refuse any
// string containing "/mcp" at all. The connection-config reader
// (mcpconfig.go) has to name the files agent clients declare their tool
// servers in — .cursor/mcp.json, claude_desktop_config.json, an mcpServers
// key — and READING A FILE SPEAKS NO PROTOCOL. So the check now targets the
// protocol instead of the word (mcpReaderViolations):
//
// - no literal anywhere is a URL or route with an mcp path segment;
// - the config basenames and keys may appear in mcpconfig.go and nowhere
// else, so a second file that starts reading (or writing) them is a
// change a reviewer sees here;
// - nothing declared in a config-reader file (mcpconfig*.go) imports, or
// can reach through this package's call graph, net.Dial or net.Listen,
// net/http, os/exec, TLS dialling or the websocket client. The reader is
// handed its process list by agents.go; it starts and dials nothing.
//
// TestMCPReaderCheckCatchesADialAndAStrayBasename proves each of those can
// fail.
func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
p := parseSources(t)
// A route served by more than one surface names each file, "a.go|b.go":
@@ -547,14 +592,10 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
}
}
for _, v := range mcpReaderViolations(p) {
t.Error(v)
}
for name, f := range p.files {
for _, s := range stringLits(f) {
l := strings.ToLower(s)
if strings.Contains(l, "/mcp") || strings.Contains(l, "jsonrpc") ||
strings.Contains(l, "modelcontextprotocol") || strings.Contains(l, "tools/call") {
t.Errorf("%s carries %q: nothing in the bastion addresses an MCP endpoint or speaks JSON-RPC", name, s)
}
}
for _, imp := range f.Imports {
if strings.Contains(strings.ToLower(imp.Path.Value), "mcp") {
t.Errorf("%s imports %s", name, imp.Path.Value)
@@ -575,6 +616,346 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
}
}
// mcpConfigNames are the file basenames and keys agent clients declare tool
// servers under. Only mcpconfig.go may name them. settings.json and
// config.toml are left out on purpose: many programs use those names (the
// coder lane reads the NVIDIA runtime's config.toml), so they say nothing
// about tool servers; the keys beside them do.
var mcpConfigNames = []string{"claude_desktop_config", "mcp.json", "mcp_config.json", ".claude.json",
"mcpservers", "context_servers", "mcp_servers",
// The clients' own directories, which name a settings.json or
// config.toml as surely as the basename would.
".cursor", ".codex", ".zed", ".codeium", ".vscode"}
// mcpURLOrRoute reports whether a string literal is a URL or route that
// addresses an mcp path. Outside a URL, every "/mcp" counts (/mcp, /mcp/x,
// /mcp-proxy) except a client's config FILE name: /mcp.json and
// /mcp_config.json.
func mcpURLOrRoute(l string) bool {
l = strings.ToLower(l)
if i := strings.Index(l, "://"); i >= 0 {
rest := l[i+3:]
if j := strings.IndexByte(rest, '/'); j >= 0 {
return strings.Contains(rest[j:], "/mcp")
}
return false
}
for i := 0; ; {
j := strings.Index(l[i:], "/mcp")
if j < 0 {
return false
}
rest := l[i+j+len("/mcp"):]
if !strings.HasPrefix(rest, ".json") && !strings.HasPrefix(rest, "_config.json") {
return true
}
i += j + len("/mcp")
}
}
// mcpReaderSink: a package-level function or type that opens a connection,
// resolves a name, or starts a process — named as a call or as a value.
func mcpReaderSink(recv, sel string) bool {
switch {
case recv == "net" && (strings.HasPrefix(sel, "Dial") || strings.HasPrefix(sel, "Listen") ||
strings.HasPrefix(sel, "Lookup") || strings.HasPrefix(sel, "Resolve") || sel == "Dialer" || sel == "Resolver"):
return true
case strings.Contains(recv, "net.Dialer") || strings.Contains(recv, "net.Resolver"):
return true
case recv == "http" || recv == "exec" || recv == "websocket" || recv == "rpc" || recv == "jsonrpc":
return true
case recv == "tls" && strings.HasPrefix(sel, "Dial"):
return true
case (recv == "os" || recv == "syscall" || recv == "unix" || recv == "windows") &&
(sel == "StartProcess" || sel == "ForkExec" || sel == "Exec" || strings.HasPrefix(sel, "CreateProcess") ||
strings.HasPrefix(sel, "ShellExecute") || sel == "WinExec"):
return true
case (recv == "syscall" || recv == "unix" || recv == "windows") && (sel == "Socket" || sel == "Connect" || sel == "Bind" ||
sel == "Listen" || sel == "Sendto" || sel == "Sendmsg" || sel == "SendmsgN" || strings.HasPrefix(sel, "WSA") ||
sel == "ConnectEx" || sel == "GetAddrInfoW" || sel == "GetHostByName" || strings.HasPrefix(sel, "DnsQuery")):
return true
case (recv == "syscall" || recv == "windows") && (strings.HasPrefix(sel, "LoadLibrary") || sel == "LoadDLL" ||
sel == "MustLoadDLL" || sel == "NewLazyDLL" || sel == "NewLazySystemDLL"):
// A DLL loaded by name reaches any function in it, past every check here.
return true
case recv == "registry" && sel == "OpenRemoteKey":
return true
}
return false
}
// mcpReaderMethodSink: a METHOD call that connects, resolves or starts,
// whatever the receiver is called — d.Dial on a net.Dialer variable,
// c.Get on an http.Client, cmd.Run on an exec.Cmd. Over-approximate on
// purpose: it only applies to code the reader can reach.
var mcpReaderMethodSink = map[string]bool{"Dial": true, "DialContext": true, "DialTCP": true, "DialUDP": true,
"DialUnix": true, "DialIP": true, "Listen": true, "ListenPacket": true, "LookupHost": true, "LookupIP": true,
"LookupIPAddr": true, "LookupAddr": true, "LookupNetIP": true, "Do": true, "Get": true, "Post": true,
"PostForm": true, "Head": true, "RoundTrip": true, "Start": true, "Run": true, "Output": true,
"CombinedOutput": true, "Connect": true}
// mcpReaderAllowedImports is every package a config-reader file may import:
// parsing, strings, and opening files. Anything else is a change a reviewer
// has to see here first. x/sys/windows and its registry package are the
// Windows open (CreateFile and the handle's own type, attributes and owner)
// and the profile list it checks owners against; each can also start a
// process, open a socket, load a DLL or open a remote registry, and those
// calls are sinks in mcpReaderSink like their unix equivalents.
var mcpReaderAllowedImports = map[string]bool{"bytes": true, "encoding/json": true, "errors": true, "io": true,
"io/fs": true, "net": true, "net/url": true, "os": true, "path/filepath": true, "regexp": true,
"runtime": true, "sort": true, "strconv": true, "strings": true, "syscall": true, "unicode": true,
"unicode/utf8": true, "golang.org/x/sys/unix": true, "golang.org/x/sys/windows": true,
"golang.org/x/sys/windows/registry": true}
// mcpValueSink: a sink named as a VALUE rather than called (f := net.Dial).
// The http, exec and websocket packages also export constants and types
// (http.StatusOK, websocket.BinaryMessage), which name no connection; only
// their functions count here.
func mcpValueSink(recv, sel string) bool {
switch recv {
case "http", "exec", "websocket", "rpc", "jsonrpc":
switch sel {
case "Get", "Post", "Head", "PostForm", "Command", "CommandContext", "Dial", "DialContext",
"DialHTTP", "DefaultClient", "DefaultDialer", "DefaultTransport", "NewClient":
return true
}
return false
}
return mcpReaderSink(recv, sel)
}
// mcpSinksIn lists the network and process calls inside one declaration.
func mcpSinksIn(n ast.Node) []string {
var out []string
called := map[*ast.SelectorExpr]bool{}
ast.Inspect(n, func(x ast.Node) bool {
if c, ok := x.(*ast.CallExpr); ok {
if sel, ok := c.Fun.(*ast.SelectorExpr); ok {
called[sel] = true
}
}
return true
})
ast.Inspect(n, func(x ast.Node) bool {
switch v := x.(type) {
case *ast.SelectorExpr:
recv := exprText(v.X)
if (called[v] && mcpReaderSink(recv, v.Sel.Name)) || (!called[v] && mcpValueSink(recv, v.Sel.Name)) {
out = append(out, recv+"."+v.Sel.Name)
}
case *ast.CallExpr:
if sel, ok := v.Fun.(*ast.SelectorExpr); ok && mcpReaderMethodSink[sel.Sel.Name] {
out = append(out, exprText(sel.X)+"."+sel.Sel.Name+"()")
}
}
return true
})
return out
}
// mcpReaderViolations is every way the package breaks the guarantee above.
func mcpReaderViolations(p *srcPkg) []string {
var out []string
names := make([]string, 0, len(p.files))
for n := range p.files {
names = append(names, n)
}
sort.Strings(names)
var readerFiles []string
for _, name := range names {
if strings.HasPrefix(name, "mcpconfig") {
readerFiles = append(readerFiles, name)
}
for _, s := range stringLits(p.files[name]) {
l := strings.ToLower(s)
if mcpURLOrRoute(s) || strings.Contains(l, "jsonrpc") || strings.Contains(l, "tools/call") {
out = append(out, fmt.Sprintf("%s carries %q: nothing in the bastion addresses an MCP endpoint or speaks JSON-RPC", name, s))
}
// The protocol's namespace. The reader may name the npm scope
// its reference servers are published under, to recognise one
// running; nothing else may name it at all.
if strings.Contains(l, "modelcontextprotocol") && !(name == "mcpconfig.go" && l == "@modelcontextprotocol/") {
out = append(out, fmt.Sprintf("%s carries %q: only the config reader may name the reference servers' npm scope", name, s))
}
if name == "mcpconfig.go" {
continue
}
for _, b := range mcpConfigNames {
if strings.Contains(l, b) {
out = append(out, fmt.Sprintf("%s names %q (%s): only mcpconfig.go reads agent clients' tool-server config", name, s, b))
}
}
}
}
if len(readerFiles) == 0 {
return append(out, "no mcpconfig*.go file: the reader moved, so this check is no longer checking it")
}
var roots []string
for _, name := range readerFiles {
for _, imp := range p.files[name].Imports {
path, _ := strconv.Unquote(imp.Path.Value)
if !mcpReaderAllowedImports[path] {
out = append(out, fmt.Sprintf("%s imports %s: the config reader reads files and nothing else", name, path))
}
}
roots = append(roots, p.funcKeysIn(name)...)
}
// Package-level variables are nodes too ("var:name"): a function held in
// one (var f = net.Dial, var g = func() { ... }) is reached by whoever
// reads the variable, and a reader file's own variables are roots, since
// their initialisers run with the package.
g := p.callGraph()
vars := map[string][]ast.Expr{}
for _, name := range names {
for _, d := range p.files[name].Decls {
gd, ok := d.(*ast.GenDecl)
if !ok || gd.Tok != token.VAR {
continue
}
for _, sp := range gd.Specs {
vs := sp.(*ast.ValueSpec)
for _, id := range vs.Names {
key := "var:" + id.Name
vars[key] = append(vars[key], vs.Values...)
if strings.HasPrefix(name, "mcpconfig") {
roots = append(roots, key)
}
}
}
}
}
refs := func(n ast.Node) []string {
var out []string
ast.Inspect(n, func(x ast.Node) bool {
if id, ok := x.(*ast.Ident); ok {
if _, isVar := vars["var:"+id.Name]; isVar {
out = append(out, "var:"+id.Name)
}
if _, isFn := p.funcs[id.Name]; isFn {
out = append(out, id.Name)
}
}
return true
})
return out
}
sinks := map[string][]string{}
for key, fd := range p.funcs {
g[key] = append(g[key], refs(fd)...)
if s := mcpSinksIn(fd); len(s) > 0 {
sinks[key] = s
}
}
for key, vals := range vars {
for _, v := range vals {
g[key] = append(g[key], refs(v)...)
sinks[key] = append(sinks[key], mcpSinksIn(v)...)
}
}
seen := reach(g, roots, nil)
for fn, path := range seen {
if what := sinks[fn]; len(what) > 0 {
out = append(out, fmt.Sprintf("the config reader can reach %s, which reaches a network or process call (%s): %s",
fn, strings.Join(what, ", "), path))
}
}
sort.Strings(out)
return out
}
// THE CHECK ABOVE CAN FAIL. A reader that starts a process, imports a
// network package, reaches a dialler through another function, or a second
// file that names a client's config file or an /mcp URL: each one is added
// as a synthetic source and must be reported. The real tree must report
// nothing, and the scan must have found sinks and roots to compare, so a
// broken extraction cannot pass by finding nothing.
func TestMCPReaderCheckCatchesADialAndAStrayBasename(t *testing.T) {
clean := parseSources(t)
if v := mcpReaderViolations(clean); len(v) != 0 {
t.Fatalf("the real tree must be clean: %v", v)
}
if len(clean.funcKeysIn("mcpconfig.go")) < 10 {
t.Fatal("found almost no functions in mcpconfig.go; the root extraction is broken")
}
add := func(p *srcPkg, name, src string) {
t.Helper()
f, err := parser.ParseFile(p.fset, name, src, parser.SkipObjectResolution)
if err != nil {
t.Fatal(err)
}
p.addFile(name, f)
}
cases := []struct {
name, want string
files map[string]string
}{
{"the reader execs", "can reach mcpSneakRun", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakRun() { _ = exec.Command(\"x\") }\n"}},
{"the reader dials through a helper elsewhere", "can reach sneakDialHelper", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakCall() { sneakDialHelper() }\n",
"sneak_helper.go": "package main\nfunc sneakDialHelper() { net.Dial(\"tcp\", \"x\") }\n"}},
{"the reader imports net/http", "imports net/http", map[string]string{
"mcpconfig_sneak.go": "package main\nimport _ \"net/http\"\n"}},
{"another file names a config file", "only mcpconfig.go reads", map[string]string{
"sneak_other.go": "package main\nvar sneakPath = \".cursor/mcp.json\"\n"}},
{"another file names the servers key", "only mcpconfig.go reads", map[string]string{
"sneak_other.go": "package main\nvar sneakKey = \"mcpServers\"\n"}},
{"a URL with an mcp path", "addresses an MCP endpoint", map[string]string{
"sneak_other.go": "package main\nvar sneakURL = \"https://h.example.com/v1/mcp\"\n"}},
{"a route", "addresses an MCP endpoint", map[string]string{
"sneak_other.go": "package main\nvar sneakRoute = \"/mcp\"\n"}},
{"a route with a suffix", "addresses an MCP endpoint", map[string]string{
"sneak_other.go": "package main\nvar sneakRoute = \"/mcp-proxy\"\n"}},
{"the reader dials through a Dialer variable", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakD() { var d net.Dialer; d.Dial(\"tcp\", \"x\") }\n"}},
{"the reader dials through a function value", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakF() { f := net.Dial; f(\"tcp\", \"x\") }\n"}},
{"the reader dials from a package-level function literal", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nvar mcpSneakV = func() { net.Dial(\"tcp\", \"x\") }\n"}},
{"the reader opens a raw socket", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakS() { syscall.Socket(2, 1, 0) }\n"}},
{"the reader resolves a name", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakL() { net.LookupHost(\"x\") }\n"}},
{"the reader imports a websocket client not on a deny list", "imports golang.org/x/net/websocket", map[string]string{
"mcpconfig_sneak.go": "package main\nimport ws \"golang.org/x/net/websocket\"\nfunc mcpSneakW() { ws.Dial(\"x\", \"\", \"y\") }\n"}},
{"the reader calls a dialler held in another file's variable", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakH() { sneakDialer(\"tcp\", \"x\") }\n",
"sneak_helper.go": "package main\nvar sneakDialer = net.Dial\n"}},
{"another file names a client's config directory", "only mcpconfig.go reads", map[string]string{
"sneak_other.go": "package main\nvar sneakDir = \".codex/\"\n"}},
{"the Windows reader starts a process", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nfunc mcpSneakCP() { windows.CreateProcess(nil, nil, nil, nil, false, 0, nil, nil, nil, nil) }\n"}},
{"the Windows reader opens a socket", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nfunc mcpSneakWS() { windows.WSASocket(2, 1, 0, nil, 0, 0) }\n"}},
{"the Windows reader loads a DLL by name", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nvar mcpSneakDLL = windows.NewLazySystemDLL(\"ws2_32.dll\")\n"}},
{"the Windows reader opens a remote registry", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows/registry\"\nfunc mcpSneakRR() { registry.OpenRemoteKey(\"h\", registry.LOCAL_MACHINE) }\n"}},
}
for _, c := range cases {
p := parseSources(t)
for n, src := range c.files {
add(p, n, src)
}
found := false
for _, v := range mcpReaderViolations(p) {
if strings.Contains(v, c.want) {
found = true
}
}
if !found {
t.Errorf("%s: not reported (want %q in %v)", c.name, c.want, mcpReaderViolations(p))
}
}
// A config file name is not a URL path, and a file in the reader may
// name one.
for _, ok := range []string{".cursor/mcp.json", "mcp_config.json", ".codeium/windsurf/mcp_config.json", "managed-mcp.json"} {
if mcpURLOrRoute(ok) {
t.Errorf("%q read as an MCP URL or route", ok)
}
}
}
// callGraph maps each function to what it calls or refers to, by name.
// A method call through a value is resolved to every method of that name in
// the package, so the graph over-approximates: a path it cannot rule out is
@@ -582,6 +963,14 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
// followed; none of the handlers below does that.
func (p *srcPkg) callGraph() map[string][]string {
g := map[string][]string{}
// The per-file duplicates ("name@file"), by name, built once: looking
// them up by prefix for every identifier made this graph cost seconds.
dups := map[string][]string{}
for k := range p.funcs {
if i := strings.IndexByte(k, '@'); i >= 0 {
dups[k[:i]] = append(dups[k[:i]], k)
}
}
for key, fd := range p.funcs {
if fd.Body == nil {
continue
@@ -593,11 +982,7 @@ func (p *srcPkg) callGraph() map[string][]string {
if _, ok := p.funcs[v.Name]; ok {
out = append(out, v.Name)
}
for k := range p.funcs {
if strings.HasPrefix(k, v.Name+"@") {
out = append(out, k)
}
}
out = append(out, dups[v.Name]...)
case *ast.CallExpr:
if sel, ok := v.Fun.(*ast.SelectorExpr); ok {
out = append(out, p.methods[sel.Sel.Name]...)
+10
View File
@@ -0,0 +1,10 @@
//go:build !linux && !darwin && !windows
package main
// The release builds linux, darwin and windows (deploy/build.sh). Any other
// unix (freebsd, openbsd) still builds, with the local context socket unable
// to tell who is on the other end: contextPeerUser then fails, and
// contextSockServe answers every connection with that reason and forwards
// nothing. An ask that cannot be attributed is refused, never let through.
func contextPeerUID(uintptr) (uint32, error) { return 0, contextSockUnsupported }
+1
View File
@@ -189,6 +189,7 @@ func dcnAudit(dir, op string, storeID int64, path, decision string) {
"ts": time.Now().UTC().Format(time.RFC3339), "op": op,
"store_id": storeID, "path": path, "decision": decision,
})
auditToSystemLog(auditFilePairingD, auditPairingOfDir(dir), rec)
fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"),
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
+5 -7
View File
@@ -98,15 +98,13 @@ func faResolveDropOwner(root, stateDir string) (*faOwner, error) {
if err != nil || sid == nil {
return nil, fmt.Errorf("cannot read the folder's owner: %v", err)
}
name, domain, typ, err := sid.LookupAccount("")
// Bounded and cached (sidnames.go); no answer in time refuses the start.
acct, err := faWinDropOwnerAccount(sid.String(), winAccounts, faDropSIDLane)
if err != nil {
return nil, fmt.Errorf("refusing to run the agent: the granted folder's owner (%s) has no account on this machine", sid.String())
return nil, err
}
full := name
if domain != "" {
full = domain + `\` + name
}
if why := faWinOwnerRefusal(sid.String(), typ, full); why != "" {
name, full := acct.Account, sidAccountLabel(acct)
if why := faWinOwnerRefusal(sid.String(), acct.Type, full); why != "" {
return nil, errors.New(why)
}
home := faWinProfileDir(sid.String())
+26
View File
@@ -5,12 +5,38 @@ package main
// that gather their inputs are in the tagged file.
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"unicode/utf16"
)
// faDropSIDLane bounds the lookup of the granted folder's owner (sidnames.go).
// The start runs on the store's own goroutine and nothing waits on it, so the
// budget is generous enough for a slow directory that does answer. It is a
// bound all the same: the store is marked as ensuring for as long as the start
// runs, and every reconcile skips it while it is, so a lookup that never
// returned would hold the store as "installing" for good. Timed out, the store
// is failed with the reason and the reconcile tries again
// (faReconcileEvery), by when a late answer is in the cache.
var faDropSIDLane = sidLane{slots: make(chan struct{}, 1), budget: 10 * time.Second}
// faWinDropOwnerAccount is the folder owner's account from the directory. It
// fails closed: no account, or no answer in time, and no agent is started.
func faWinDropOwnerAccount(sid string, dir *sidDirectory, lane sidLane) (sidAccount, error) {
acct, outcome := dir.resolve(sid, lane)
switch outcome {
case sidTimedOut:
return sidAccount{}, fmt.Errorf("refusing to run the agent for now: this machine's account directory did not answer "+
"for the granted folder's owner (%s) within %v; it is tried again at the next check", sid, lane.budget)
case sidUnmapped:
return sidAccount{}, fmt.Errorf("refusing to run the agent: the granted folder's owner (%s) has no account on this machine", sid)
}
return acct, nil
}
// winSidTypeUser is SidTypeUser: the SID names a person's account rather than
// a group, an alias or a well-known identity.
const winSidTypeUser = 1
+104
View File
@@ -0,0 +1,104 @@
package main
import (
"go/ast"
"go/build"
"go/parser"
"go/token"
"os"
"sort"
"strings"
"testing"
)
// EVERY PLATFORM FUNCTION EXISTS ON EVERY TARGET THAT BUILDS THIS PACKAGE.
//
// A function with one file per platform (contextPeerUID in contextsock_linux.go
// and contextsock_darwin.go) and a caller in a file every unix builds
// (contextsock_unix.go) leaves any other unix with an undefined name, and
// nothing in CI cross-builds one: `GOOS=freebsd go build` failed that way.
// The release targets are linux, darwin and windows (deploy/build.sh);
// freebsd and openbsd are not released, but they build, with what has no
// implementation there refusing (contextsock_other.go and the other
// *_other.go files).
//
// This asks the toolchain which files each target compiles (go/build's
// MatchFile) and checks that every package function called from those files
// is declared in them. It is not a type check, and it does not compile; it
// catches the missing-platform-file mistake, which is the one that happened,
// on the platform the tests already run on.
func TestEveryCalledPlatformFunctionIsDeclaredOnEveryTarget(t *testing.T) {
entries, err := os.ReadDir(".")
if err != nil {
t.Fatal(err)
}
type parsed struct {
name string
file *ast.File
}
var files []parsed
declaredAnywhere := map[string]bool{}
fset := token.NewFileSet()
for _, e := range entries {
n := e.Name()
if e.IsDir() || !strings.HasSuffix(n, ".go") || strings.HasSuffix(n, "_test.go") {
continue
}
f, err := parser.ParseFile(fset, n, nil, parser.SkipObjectResolution)
if err != nil {
t.Fatalf("%s: %v", n, err)
}
files = append(files, parsed{n, f})
for _, d := range f.Decls {
if fd, ok := d.(*ast.FuncDecl); ok && fd.Recv == nil {
declaredAnywhere[fd.Name.Name] = true
}
}
}
if len(files) < 50 {
t.Fatalf("parsed only %d files; is the test running in the package directory?", len(files))
}
for _, goos := range []string{"linux", "darwin", "windows", "freebsd", "openbsd"} {
ctx := build.Default
ctx.GOOS = goos
ctx.CgoEnabled = false
declared := map[string]bool{}
var matched []parsed
for _, p := range files {
ok, err := ctx.MatchFile(".", p.name)
if err != nil {
t.Fatalf("%s: %v", p.name, err)
}
if !ok {
continue
}
matched = append(matched, p)
for _, d := range p.file.Decls {
if fd, ok := d.(*ast.FuncDecl); ok && fd.Recv == nil {
declared[fd.Name.Name] = true
}
}
}
missing := map[string]string{}
for _, p := range matched {
ast.Inspect(p.file, func(x ast.Node) bool {
c, ok := x.(*ast.CallExpr)
if !ok {
return true
}
if id, ok := c.Fun.(*ast.Ident); ok && declaredAnywhere[id.Name] && !declared[id.Name] {
missing[id.Name] = p.name
}
return true
})
}
var out []string
for fn, where := range missing {
out = append(out, fn+" (called in "+where+")")
}
sort.Strings(out)
if len(out) > 0 {
t.Errorf("GOOS=%s: called but not declared for this target: %s", goos, strings.Join(out, ", "))
}
}
}
+76
View File
@@ -0,0 +1,76 @@
//go:build !windows
package main
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"testing"
)
// installShFunc returns one shell function's text from deploy/install.sh.
func installShFunc(t *testing.T, name string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
if err != nil {
t.Fatal(err)
}
m := regexp.MustCompile(`(?ms)^` + regexp.QuoteMeta(name) + `\(\) \{\n.*?^\}\n`).Find(raw)
if m == nil {
t.Fatalf("install.sh defines no %s()", name)
}
return string(m)
}
// TestInstallShKeepsAPairedLocalMachineLocal: a root reinstall defaults to
// networked, except on a machine already paired in local mode, which keeps
// its mode (paired_local). Switched, its pairing would sit on an unanchored
// networked machine, which refuses every op until claim-anchor.
func TestInstallShKeepsAPairedLocalMachineLocal(t *testing.T) {
fn := installShFunc(t, "paired_local")
run := func(dirs ...string) bool {
t.Helper()
args := append([]string{"-c", fn + `paired_local "$@"`, "sh"}, dirs...)
err := exec.Command("sh", args...).Run()
if _, ok := err.(*exec.ExitError); err != nil && !ok {
t.Fatal(err)
}
return err == nil
}
mk := func(t *testing.T, files map[string]string) string {
t.Helper()
d := t.TempDir()
for name, body := range files {
p := filepath.Join(d, name)
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
return d
}
missing := filepath.Join(t.TempDir(), "none")
if !run(mk(t, map[string]string{"pairings/a.host.example.json": "{}"}), missing) {
t.Fatal("a paired machine with no mode file is local and must stay local")
}
if !run(mk(t, map[string]string{"pairings/a.json": "{}", "mode.json": `{"mode":"local"}`})) {
t.Fatal("a paired machine in local mode must stay local")
}
if !run(mk(t, map[string]string{"config.json": "{}"})) {
t.Fatal("a pre-0.10 single pairing (config.json) is a pairing")
}
if !run(missing, mk(t, map[string]string{"pairings/a.json": "{}"})) {
t.Fatal("a pairing in the second folder counts")
}
if run(mk(t, map[string]string{"pairings/a.json": "{}", "mode.json": "{\n \"mode\": \"networked\"\n}"})) {
t.Fatal("a networked machine is not kept local")
}
if run(mk(t, map[string]string{"pairings/.keep": ""}), missing) {
t.Fatal("a machine with no pairing gets the networked default")
}
}
+4
View File
@@ -229,6 +229,7 @@ func main() {
// Started by the Windows Service Control Manager: the service runs `run`
// itself and never reaches the argument parsing below.
if maybeRunAsWindowsService() {
auditFlush(auditFlushOnExit)
return
}
if len(os.Args) < 2 {
@@ -618,6 +619,9 @@ func main() {
usage()
os.Exit(2)
}
// 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)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
File diff suppressed because it is too large Load Diff
+223
View File
@@ -0,0 +1,223 @@
package main
import (
"encoding/json"
"fmt"
"os/exec"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
)
// Every value below is invented. Each case is a way a real config carries a
// credential on the command line; the record that leaves the machine must
// carry none of them, in any field.
func TestMCPRecordNeverCarriesASecretValue(t *testing.T) {
cases := []struct {
name string
e mcpEntry
secrets []string
}{
{"a database URL with a password", mcpEntry{command: "npx", args: []string{"-y", "@modelcontextprotocol/server-postgres", "postgresql://demo:PwDemo7731@localhost/demo"}}, []string{"PwDemo7731"}},
{"a key in a URL query, and an Authorization header argument", mcpEntry{command: "npx", args: []string{"mcp-remote", "https://mcp.example.com/sse?api_key=QkeyDemo1234", "--header", "Authorization: Bearer HdrDemo45678"}}, []string{"QkeyDemo1234", "HdrDemo45678"}},
{"a header given as --header=Name:value", mcpEntry{command: "npx", args: []string{"mcp-remote", "--header=X-Api-Key:HkeyDemo9999"}}, []string{"HkeyDemo9999"}},
{"a short password after a secret flag", mcpEntry{command: "demo-srv", args: []string{"--password", "shrt1"}}, []string{"shrt1"}},
{"a password that starts with a dollar sign", mcpEntry{command: "demo-srv", args: []string{"--password", "$DollarPw99x"}}, []string{"DollarPw99x"}},
{"a short value in docker -e NAME=value", mcpEntry{command: "docker", args: []string{"run", "-e", "API_KEY=ab12", "demo/img"}}, []string{"ab12"}},
{"NAME=value inside --env=", mcpEntry{command: "docker", args: []string{"run", "--env=API_TOKEN=TokDemo5678", "demo/img"}}, []string{"TokDemo5678"}},
{"the whole command line in command", mcpEntry{command: "demo-srv --token CmdTokDemo42"}, []string{"CmdTokDemo42"}},
{"a git URL with a password", mcpEntry{command: "uvx", args: []string{"--from", "git+https://demo:GitPwDemo31@git.example.com/o/r", "demo"}}, []string{"GitPwDemo31"}},
{"a git URL with a token as the user", mcpEntry{command: "npx", args: []string{"-y", "git+https://GitUserTok55@git.example.com/o/r"}}, []string{"GitUserTok55"}},
{"a token in a URL path", mcpEntry{command: "npx", args: []string{"mcp-remote", "https://hooks.example.com/api/s/PathTokDemo8812/sse"}}, []string{"PathTokDemo8812"}},
}
for _, c := range cases {
b, _ := json.Marshal(mcpAnalyze(c.e))
for _, s := range c.secrets {
if strings.Contains(string(b), s) {
t.Errorf("%s: the record carries %q: %s", c.name, s, b)
}
}
}
// A running server no file declares goes through the same redaction.
out := correlateMCP(nil, []mcpProc{{PID: 7, PPID: 1, Argv: []string{"node", "/opt/demo/mcp-server.js",
"postgresql://demo:ShadowPw77x@db.example.com/x"}}}, true)
b, _ := json.Marshal(out)
if strings.Contains(string(b), "ShadowPw77x") {
t.Errorf("an undeclared server's record carries its password: %s", b)
}
// What is still kept: a reference to where the secret comes from, and
// the URL's host.
o := mcpAnalyze(mcpEntry{command: "demo-srv", args: []string{"--token", "${DEMO_TOKEN}", "https://mcp.example.com/sse"}})
if !strings.Contains(o.Command, "${DEMO_TOKEN}") || !strings.Contains(o.Command, "https://mcp.example.com") {
t.Errorf("redaction took more than the secret: %q", o.Command)
}
}
// One record, and the list, stay bounded however large the file's names are
// and however many processes look like servers.
func TestMCPRecordAndListStayBounded(t *testing.T) {
env := map[string]string{}
for i := 0; i < 5000; i++ {
env[fmt.Sprintf("DEMO_VAR_%04d_%s", i, strings.Repeat("x", 200))] = ""
}
o := mcpAnalyze(mcpEntry{name: strings.Repeat("n", 100000), command: "npx",
args: []string{"-y", strings.Repeat("p", 100000)}, env: env})
if b, _ := json.Marshal(o); len(b) > 8192 {
t.Errorf("one stdio record is %d bytes", len(b))
}
o = mcpAnalyze(mcpEntry{name: "r", url: "https://" + strings.Repeat("a", 100000) + ".example.com/x"})
if b, _ := json.Marshal(o); len(b) > 8192 {
t.Errorf("one remote record is %d bytes", len(b))
}
var procs []mcpProc
for i := 0; i < 3000; i++ {
procs = append(procs, mcpProc{PID: 10 + i, PPID: 1, Argv: []string{"demo-mcp-server"}})
}
if out := correlateMCP(nil, procs, true); len(out) > mcpMaxServers {
t.Errorf("%d records from undeclared servers; the list is capped at %d", len(out), mcpMaxServers)
}
}
// A crafted config.toml nested thousands deep is refused, not walked on the
// stack; a realistic depth still parses.
func TestParseTOMLSubsetRefusesDeepNesting(t *testing.T) {
for name, src := range map[string]string{
"arrays": "a = " + strings.Repeat("[", 5000) + strings.Repeat("]", 5000) + "\n",
"inline tables": "a = " + strings.Repeat("{b = ", 5000) + "1" + strings.Repeat("}", 5000) + "\n",
} {
if _, err := parseTOMLSubset(src); err == nil {
t.Errorf("%s nested 5000 deep parsed", name)
}
}
if _, err := parseTOMLSubset("[mcp_servers.x]\nenv = { A = { B = [[1, 2], [3]] } }\n"); err != nil {
t.Errorf("a realistic depth: %v", err)
}
}
// macOS's nobody is uid -2 in its directory; it is a service account, not
// an unread uid.
func TestClassifyUnixOwnerNegativeDirectoryUID(t *testing.T) {
f := classifyUnixOwner(unixOwner{UID: -2, Name: "nobody", SystemUIDMax: darwinSystemUIDMax, LocalRecord: ownerYes})
if f.Class != ownerClassService {
t.Errorf("uid -2: %+v", f)
}
if f := classifyUnixOwner(unixOwner{UID: -1, Name: "demo"}); f.Class != ownerUnknown {
t.Errorf("an unread uid: %+v", f)
}
}
// dscl that was killed by the timeout has not said the record is missing:
// only a real non-zero exit does.
func TestDsclRecordMissingOnlyOnARealExit(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("needs sh")
}
_, killed := exec.Command("sh", "-c", "kill -9 $$").Output()
_, missing := exec.Command("sh", "-c", "exit 56").Output()
if dsclRecordMissing(killed) {
t.Error("a killed dscl read as no local record")
}
if !dsclRecordMissing(missing) {
t.Error("dscl exiting 56 did not read as no local record")
}
if dsclRecordMissing(nil) {
t.Error("success read as no local record")
}
}
// A read that stops answering (a home on a network mount) holds neither the
// beat nor a second reader, and a finished read is reused for the cache
// window instead of redone on every 60-second beat.
func TestMCPServersReportNeverWaitsOnAStuckRead(t *testing.T) {
oldH, oldS, oldP, oldT := mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout
block := make(chan struct{})
t.Cleanup(func() {
close(block)
mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout = oldH, oldS, oldP, oldT
resetMCPConfigCache()
})
resetMCPConfigCache()
var calls int32
mcpHomesFn = func() ([]mcpHome, bool) {
atomic.AddInt32(&calls, 1)
<-block
return nil, true
}
mcpSystemFn = func() []mcpSource { return nil }
scanMCPProcessesFn = func(map[string]bool) ([]mcpProc, error) { return nil, nil }
mcpReadTimeout = 100 * time.Millisecond
for beat := 0; beat < 3; beat++ {
done := make(chan mcpConfigScan, 1)
go func() { _, s := mcpServersReport(); done <- s }()
select {
case s := <-done:
if !s.TimedOut || !s.Supported {
t.Fatalf("beat %d: a read that has not finished must say so: %+v", beat, s)
}
case <-time.After(5 * time.Second):
t.Fatalf("beat %d waited on a stuck read", beat)
}
}
if n := atomic.LoadInt32(&calls); n != 1 {
t.Fatalf("%d reads started while one was stuck", n)
}
resetMCPConfigCache()
atomic.StoreInt32(&calls, 0)
home := t.TempDir()
mcpHomesFn = func() ([]mcpHome, bool) {
atomic.AddInt32(&calls, 1)
return []mcpHome{{Dir: home, ConfigDir: home}}, true
}
for beat := 0; beat < 3; beat++ {
if _, s := mcpServersReport(); s.TimedOut {
t.Fatalf("beat %d timed out: %+v", beat, s)
}
}
if n := atomic.LoadInt32(&calls); n != 1 {
t.Fatalf("the files were read %d times in one cache window", n)
}
}
// A read stuck on a home that stopped answering delays ONE beat by at most
// mcpReadTimeout; the beats after it do not wait on the same read again. And
// until a read has finished, nothing is known to be declared, so no running
// process may be reported as running_not_declared.
func TestMCPStuckReadDelaysOneBeatAndClaimsNoShadows(t *testing.T) {
oldH, oldS, oldP, oldT := mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout
block := make(chan struct{})
t.Cleanup(func() {
close(block)
mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout = oldH, oldS, oldP, oldT
resetMCPConfigCache()
})
resetMCPConfigCache()
mcpHomesFn = func() ([]mcpHome, bool) { <-block; return nil, true }
mcpSystemFn = func() []mcpSource { return nil }
scanMCPProcessesFn = func(map[string]bool) ([]mcpProc, error) {
return []mcpProc{{PID: 4242, PPID: 1, Argv: []string{"demo-mcp-server", "--stdio"}}}, nil
}
mcpReadTimeout = 400 * time.Millisecond
start := time.Now()
out, s := mcpServersReport()
if took := time.Since(start); took < mcpReadTimeout/2 {
t.Fatalf("the first beat did not wait for the read at all (%v)", took)
}
if !s.TimedOut || len(out) != 0 {
t.Fatalf("before any read finished: timed_out %v, servers %+v", s.TimedOut, out)
}
for beat := 1; beat < 4; beat++ {
start := time.Now()
out, s := mcpServersReport()
if took := time.Since(start); took > mcpReadTimeout/2 {
t.Fatalf("beat %d waited %v on a read an earlier beat already waited out", beat, took)
}
if !s.TimedOut || len(out) != 0 {
t.Fatalf("beat %d: timed_out %v, servers %+v", beat, s.TimedOut, out)
}
}
}
+32
View File
@@ -0,0 +1,32 @@
//go:build darwin
package main
import (
"os"
"path/filepath"
)
// The accounts whose agent clients mcpconfig.go reads on macOS: the service's
// own home and every directory under /Users except Shared. The per-user
// configuration directory is ~/Library/Application Support.
func mcpHomes() ([]mcpHome, bool) {
var homes []string
if h, err := os.UserHomeDir(); err == nil && h != "" {
homes = append(homes, h)
}
if entries, err := os.ReadDir("/Users"); err == nil {
for _, e := range entries {
if e.IsDir() && e.Name() != "Shared" && e.Name()[0] != '.' {
homes = append(homes, filepath.Join("/Users", e.Name()))
}
}
}
var out []mcpHome
for _, h := range dedupeStrings(homes) {
out = append(out, mcpHome{Dir: h, ConfigDir: filepath.Join(h, "Library", "Application Support")})
}
return out, true
}
func mcpSystemFiles() []mcpSource { return mcpSystemSources("darwin", "") }
+23
View File
@@ -0,0 +1,23 @@
//go:build linux
package main
import (
"path/filepath"
)
// The accounts whose agent clients mcpconfig.go reads on Linux: the same
// homes the app inventory reads (userHomes: the service's own home and every
// directory under /home). A home this process may not enter yields files it
// counts as unreadable, never as empty. The per-user configuration directory
// is the XDG default, ~/.config; another account's XDG_CONFIG_HOME lives in
// its own environment, which a system service does not see.
func mcpHomes() ([]mcpHome, bool) {
var out []mcpHome
for _, h := range userHomes() {
out = append(out, mcpHome{Dir: h, ConfigDir: filepath.Join(h, ".config")})
}
return out, true
}
func mcpSystemFiles() []mcpSource { return mcpSystemSources("linux", "") }
+38
View File
@@ -0,0 +1,38 @@
//go:build linux
package main
import (
"os"
"path/filepath"
"syscall"
"testing"
"time"
)
// A FIFO left where a client keeps its config must not hold the read, and
// with it the heartbeat: it is counted unreadable and the scan moves on.
func TestReadMCPConfigsDoesNotBlockOnAFIFO(t *testing.T) {
home := t.TempDir()
if err := os.MkdirAll(filepath.Join(home, ".cursor"), 0o755); err != nil {
t.Fatal(err)
}
if err := syscall.Mkfifo(filepath.Join(home, ".cursor", "mcp.json"), 0o600); err != nil {
t.Skipf("cannot make a FIFO here: %v", err)
}
oldH, oldS := mcpHomesFn, mcpSystemFn
t.Cleanup(func() { mcpHomesFn, mcpSystemFn = oldH, oldS })
mcpHomesFn = func() ([]mcpHome, bool) { return []mcpHome{{Dir: home, ConfigDir: home}}, true }
mcpSystemFn = func() []mcpSource { return nil }
done := make(chan mcpConfigScan, 1)
go func() { _, scan := readMCPConfigs(); done <- scan }()
select {
case scan := <-done:
if scan.Unreadable != 1 {
t.Fatalf("the FIFO was not counted: %+v", scan)
}
case <-time.After(5 * time.Second):
t.Fatal("reading the config files blocked on a FIFO")
}
}
+42
View File
@@ -0,0 +1,42 @@
//go:build darwin
package main
import (
"os"
"path/filepath"
"golang.org/x/sys/unix"
)
// mcpOpenRegular opens path for reading only if what it names, after any
// symlinks, is a regular file, without ever opening anything else. macOS has
// no O_PATH, so the links are resolved first, the resolved path is required
// to be a regular file, and it is then opened with O_NOFOLLOW_ANY: a link
// put in its place since the look makes the open fail rather than lead
// somewhere new, and a device can only sit in devfs, which the look already
// refused. The opened file must be the one looked at.
func mcpOpenRegular(path string) (*os.File, string, error) {
resolved, err := filepath.EvalSymlinks(path)
if err != nil {
return nil, "", err
}
lst, err := os.Lstat(resolved)
if err != nil {
return nil, "", err
}
if !lst.Mode().IsRegular() {
return nil, "", errMCPNotRegular
}
fd, err := unix.Open(resolved, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOCTTY|unix.O_NONBLOCK|unix.O_NOFOLLOW_ANY, 0)
if err != nil {
return nil, "", &os.PathError{Op: "open", Path: path, Err: err}
}
f := os.NewFile(uintptr(fd), path)
fi, err := f.Stat()
if err != nil || !fi.Mode().IsRegular() || !os.SameFile(fi, lst) {
f.Close()
return nil, "", errMCPNotRegular
}
return f, mcpFileOwner(fi), nil
}
+42
View File
@@ -0,0 +1,42 @@
//go:build linux
package main
import (
"os"
"strconv"
"golang.org/x/sys/unix"
)
// mcpOpenRegular opens path for reading only if what it names, after any
// symlinks, is a regular file, without ever opening anything else. The first
// open is O_PATH: it resolves the path and pins the inode but does not open
// the file (no driver runs, a FIFO does not wait). Only once fstat says
// regular is that same inode reopened for reading through /proc/self/fd, so
// swapping the path between the look and the read reaches nothing new.
func mcpOpenRegular(path string) (*os.File, string, error) {
pfd, err := unix.Open(path, unix.O_PATH|unix.O_CLOEXEC, 0)
if err != nil {
return nil, "", &os.PathError{Op: "open", Path: path, Err: err}
}
defer unix.Close(pfd)
var st unix.Stat_t
if err := unix.Fstat(pfd, &st); err != nil {
return nil, "", &os.PathError{Op: "fstat", Path: path, Err: err}
}
if st.Mode&unix.S_IFMT != unix.S_IFREG {
return nil, "", errMCPNotRegular
}
fd, err := unix.Open("/proc/self/fd/"+strconv.Itoa(pfd), unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOCTTY|unix.O_NONBLOCK, 0)
if err != nil {
return nil, "", &os.PathError{Op: "reopen", Path: path, Err: err}
}
f := os.NewFile(uintptr(fd), path)
fi, err := f.Stat()
if err != nil || !fi.Mode().IsRegular() {
f.Close()
return nil, "", errMCPNotRegular
}
return f, mcpFileOwner(fi), nil
}
+136
View File
@@ -0,0 +1,136 @@
//go:build linux
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"golang.org/x/sys/unix"
)
// A config path that is, or links to, something other than a regular file
// is never OPENED for reading. Opening is not harmless: a device's driver
// runs on open (a watchdog arms, a serial line toggles DTR), so a symlink at
// ~/.cursor/mcp.json pointing at one would let any account make this root
// service act on hardware. inotify reports every real open of the target;
// the reader's look at what the path is must not be one.
func TestMCPReadFileNeverOpensANonRegularTarget(t *testing.T) {
dir := t.TempDir()
fifo := filepath.Join(dir, "target")
if err := unix.Mkfifo(fifo, 0o600); err != nil {
t.Skipf("cannot make a FIFO here: %v", err)
}
link := filepath.Join(dir, "mcp.json")
if err := os.Symlink(fifo, link); err != nil {
t.Fatal(err)
}
ino, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC)
if err != nil {
t.Skipf("no inotify: %v", err)
}
defer unix.Close(ino)
if _, err := unix.InotifyAddWatch(ino, fifo, unix.IN_OPEN); err != nil {
t.Skipf("cannot watch: %v", err)
}
var scan mcpConfigScan
for _, p := range []string{link, fifo} {
if b, ok := mcpReadFile(p, &scan); ok || b != nil {
t.Fatalf("%s read as a config file", p)
}
}
buf := make([]byte, 4096)
if n, _ := unix.Read(ino, buf); n > 0 {
t.Fatalf("the FIFO was opened %d time(s) by the reader", n/unix.SizeofInotifyEvent)
}
if scan.Unreadable != 2 {
t.Fatalf("scan: %+v", scan)
}
// A regular file through a symlink is still read, as the client would.
real := filepath.Join(dir, "real.json")
if err := os.WriteFile(real, []byte(`{"mcpServers":{}}`), 0o600); err != nil {
t.Fatal(err)
}
link2 := filepath.Join(dir, "link2.json")
if err := os.Symlink(real, link2); err != nil {
t.Fatal(err)
}
if _, ok := mcpReadFile(link2, &scan); !ok {
t.Fatal("a symlink to a regular file was not read")
}
}
// A file in one account's home, or in a project that account listed, is read
// only when that account or root owns it. Otherwise one account could point
// its own config path, or its list of projects, at another account's files
// and have this service read them on its behalf.
func TestReadMCPConfigsSkipsAFileAnotherAccountOwns(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("needs root to make files owned by other uids")
}
const owner, other = 42101, 42102
root := t.TempDir()
if err := os.Chmod(root, 0o755); err != nil {
t.Fatal(err)
}
home := filepath.Join(root, "home", "demo")
victim := filepath.Join(root, "victim")
write := func(p, body string, uid int) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chown(p, uid, uid); err != nil {
t.Fatal(err)
}
}
write(filepath.Join(victim, "secret.json"), `{"mcpServers": {"victim-file": {"command": "demo-v"}}}`, other)
write(filepath.Join(victim, ".mcp.json"), `{"mcpServers": {"victim-project": {"command": "demo-w"}}}`, other)
write(filepath.Join(root, "rootowned.json"), `{"mcpServers": {"root-file": {"command": "demo-r"}}}`, 0)
projJSON, _ := json.Marshal(victim)
write(filepath.Join(home, ".claude.json"), `{"mcpServers": {"own": {"command": "demo-o"}}, "projects": {`+string(projJSON)+`: {}}}`, owner)
if err := os.MkdirAll(filepath.Join(home, ".cursor"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(victim, "secret.json"), filepath.Join(home, ".cursor", "mcp.json")); err != nil {
t.Fatal(err)
}
// A dotfile manager's link to a root-owned file is the account's own.
if err := os.MkdirAll(filepath.Join(home, ".codeium", "windsurf"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(root, "rootowned.json"), filepath.Join(home, ".codeium", "windsurf", "mcp_config.json")); err != nil {
t.Fatal(err)
}
if err := os.Chown(home, owner, owner); err != nil {
t.Fatal(err)
}
oldH, oldS, oldOS := mcpHomesFn, mcpSystemFn, mcpGOOS
t.Cleanup(func() { mcpHomesFn, mcpSystemFn, mcpGOOS = oldH, oldS, oldOS })
mcpGOOS = "linux"
mcpHomesFn = func() ([]mcpHome, bool) {
return []mcpHome{{Dir: home, ConfigDir: filepath.Join(home, ".config")}}, true
}
mcpSystemFn = func() []mcpSource { return nil }
got, scan := readMCPConfigs()
names := map[string]bool{}
for _, o := range got {
names[o.Name] = true
}
if names["victim-file"] || names["victim-project"] {
t.Errorf("read another account's file on this account's behalf: %v", names)
}
if !names["own"] || !names["root-file"] {
t.Errorf("the account's own and root-owned files must still be read: %v", names)
}
if scan.ForeignOwner != 2 {
t.Errorf("scan: %+v", scan)
}
}
+33
View File
@@ -0,0 +1,33 @@
//go:build !linux && !darwin && !windows
package main
import "os"
// No account list on this platform (mcpconfig_other.go), so the reader never
// gets here; this keeps the package building. A path is still opened only
// when it was a regular file, and the opened file must be the one looked at.
func mcpOpenRegular(path string) (*os.File, string, error) {
st, err := os.Stat(path)
if err != nil {
return nil, "", err
}
if !st.Mode().IsRegular() {
return nil, "", errMCPNotRegular
}
f, err := os.Open(path)
if err != nil {
return nil, "", err
}
fi, err := f.Stat()
if err != nil || !fi.Mode().IsRegular() || !os.SameFile(fi, st) {
f.Close()
return nil, "", errMCPNotRegular
}
return f, mcpOwnerUnknown, nil
}
var mcpAdminOwners = map[string]bool{}
// mcpPathOwner: no owner model here, so no owner check.
func mcpPathOwner(string) string { return mcpNoOwnerCheck }
+122
View File
@@ -0,0 +1,122 @@
//go:build windows
package main
import (
"os"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
// The decisions are in mcpconfig_winopen.go; this file makes the calls.
// The values mcpconfig_winopen.go spells out must be Windows' own: each line
// fails to compile if one differs.
var (
_ = [1]struct{}{}[mcpWinFileTypeDisk^windows.FILE_TYPE_DISK]
_ = [1]struct{}{}[mcpWinAttrDirectory^windows.FILE_ATTRIBUTE_DIRECTORY]
_ = [1]struct{}{}[mcpWinAttrDevice^windows.FILE_ATTRIBUTE_DEVICE]
_ = [1]struct{}{}[mcpWinAttrReparse^windows.FILE_ATTRIBUTE_REPARSE_POINT]
)
var mcpAdminOwners = mcpWinAdminOwners
// mcpOpenRegular opens path with CreateFile and keeps it only when the
// handle is a regular file on disk (mcpWinOpenWith). It shares read, write
// and delete with everyone else: the agent client that owns the file must be
// able to save it, including by replacing it, while this reads it.
func mcpOpenRegular(path string) (*os.File, string, error) {
return mcpWinOpenWith(mcpWinSystemOps, path)
}
var mcpWinSystemOps = mcpWinOps{
openPath: mcpWinCreateFile,
handleInfo: mcpWinHandleFacts,
closeHandle: func(h uintptr) { windows.CloseHandle(windows.Handle(h)) },
asFile: func(h uintptr, path string) *os.File { return os.NewFile(h, path) },
}
func mcpWinCreateFile(path string) (uintptr, error) {
p, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, &os.PathError{Op: "open", Path: path, Err: err}
}
h, err := windows.CreateFile(p, windows.GENERIC_READ,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil,
windows.OPEN_EXISTING, windows.FILE_FLAG_OPEN_REPARSE_POINT, 0)
if err != nil {
return 0, &os.PathError{Op: "open", Path: path, Err: err}
}
return uintptr(h), nil
}
// mcpWinHandleFacts reads the type, the attributes and the owner from the
// handle. GENERIC_READ carries READ_CONTROL, which GetSecurityInfo needs for
// the owner; an owner that cannot be read is left "" and the file is then
// read only where no owner check applies.
func mcpWinHandleFacts(h uintptr) (mcpWinHandleInfo, error) {
wh := windows.Handle(h)
var i mcpWinHandleInfo
t, err := windows.GetFileType(wh)
if err != nil {
return i, err
}
i.FileType = t
if t != windows.FILE_TYPE_DISK {
return i, nil // refused by mcpWinRegular; nothing more to ask it
}
var bi windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(wh, &bi); err != nil {
return i, err
}
i.Attributes = bi.FileAttributes
if sd, err := windows.GetSecurityInfo(wh, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION); err == nil {
if o, _, err := sd.Owner(); err == nil && o != nil {
i.Owner = o.String()
}
}
return i, nil
}
// mcpPathOwner is the SID whose profile dir is, from the ProfileList the
// User Profile Service keeps
// (HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\<SID>,
// value ProfileImagePath). A directory it does not name, or names twice,
// gives mcpOwnerUnknown, so only SYSTEM's and the Administrators group's
// files are read there.
func mcpPathOwner(dir string) string {
return mcpWinProfileSID(dir, mcpWinProfiles())
}
func mcpWinProfiles() map[string]string {
out := map[string]string{}
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
`SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList`, registry.ENUMERATE_SUB_KEYS)
if err != nil {
return out
}
defer k.Close()
sids, err := k.ReadSubKeyNames(-1)
if err != nil {
return out
}
for _, sid := range sids {
sk, err := registry.OpenKey(k, sid, registry.QUERY_VALUE)
if err != nil {
continue
}
p, typ, err := sk.GetStringValue("ProfileImagePath")
sk.Close()
if err != nil {
continue
}
if typ == registry.EXPAND_SZ {
if x, err := registry.ExpandString(p); err == nil {
p = x
}
}
out[sid] = p
}
return out
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux && !darwin && !windows
package main
// No account list on this platform: the scan says unsupported rather than
// reporting a machine with no tool servers.
func mcpHomes() ([]mcpHome, bool) { return nil, false }
func mcpSystemFiles() []mcpSource { return nil }
+36
View File
@@ -0,0 +1,36 @@
//go:build linux || darwin
package main
import (
"os"
"strconv"
"syscall"
)
// mcpAdminOwners: root owns files in any account's home without that being
// another account's file.
var mcpAdminOwners = map[string]bool{"0": true}
// mcpFileOwner is the uid that owns an opened file, as mcpOwnerAllowed
// compares it.
func mcpFileOwner(fi os.FileInfo) string {
if st, ok := fi.Sys().(*syscall.Stat_t); ok {
return strconv.FormatUint(uint64(st.Uid), 10)
}
return mcpOwnerUnknown // matches no account and not root: the file is skipped
}
// mcpPathOwner is the uid that owns an account's home: the account whose
// files are read there. A home that cannot be looked at gives root, so only
// root-owned files would be read in it.
func mcpPathOwner(dir string) string {
st, err := os.Stat(dir)
if err != nil {
return "0"
}
if s, ok := st.Sys().(*syscall.Stat_t); ok {
return strconv.FormatUint(uint64(s.Uid), 10)
}
return "0"
}
+461
View File
@@ -0,0 +1,461 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// Every fixture here is invented: server names, packages, hosts, paths and
// the secret-shaped values, which are built from repeated letters so no real
// credential can be in this file.
var (
fakeAnthropicKey = "sk-ant-api03-" + strings.Repeat("Q", 40)
fakeGitHubToken = "ghp_" + strings.Repeat("Z", 36)
fakeLiteral = "plain-" + strings.Repeat("k", 12)
)
func entriesByName(t *testing.T, format, body string) map[string]mcpEntry {
t.Helper()
es, _, err := mcpParse(format, []byte(body))
if err != nil {
t.Fatalf("%s: %v", format, err)
}
out := map[string]mcpEntry{}
for _, e := range es {
out[e.name] = e
}
return out
}
func TestMCPParseClaudeDesktopShape(t *testing.T) {
es := entriesByName(t, mcpFormatServers, `{"mcpServers": {
"files": {"command": "npx", "args": ["-y", "@demo-scope/server-files@1.4.2", "/srv/demo"]},
"search": {"command": "uvx", "args": ["demo-search"], "env": {"DEMO_API_KEY": "`+fakeLiteral+`"}}
}, "otherSetting": true}`)
if len(es) != 2 || es["files"].command != "npx" || es["search"].env["DEMO_API_KEY"] == "" {
t.Fatalf("entries: %+v", es)
}
}
func TestMCPParseClaudeCodeUserAndProjects(t *testing.T) {
body := `{"numStartups": 3, "mcpServers": {"u1": {"type": "stdio", "command": "demo-tool"}},
"projects": {
"/home/demo/proj-a": {"mcpServers": {"p1": {"type": "http", "url": "https://tools.example.com/x"}}},
"/home/demo/proj-b": {"allowedTools": []}
}}`
es, projects, err := mcpParse(mcpFormatClaude, []byte(body))
if err != nil {
t.Fatal(err)
}
if len(es) != 2 || es[0].name != "u1" || es[0].projectScoped || es[1].name != "p1" || !es[1].projectScoped {
t.Fatalf("entries: %+v", es)
}
want := []string{filepath.FromSlash("/home/demo/proj-a"), filepath.FromSlash("/home/demo/proj-b")}
if !reflect.DeepEqual(projects, want) {
t.Fatalf("projects: %v", projects)
}
}
func TestMCPParseVSCodeJSONC(t *testing.T) {
es := entriesByName(t, mcpFormatVSCode, `{
// a comment the file is allowed to carry
"inputs": [{"id": "tok", "type": "promptString", "password": true}],
"servers": {
"remote": {"type": "sse", "url": "https://tools.example.com/sse", "headers": {"Authorization": "Bearer ${input:tok}"},},
/* a block comment, "with a quote" */
"local": {"command": "node", "args": ["/opt/demo/server.js"],},
},
}`)
if es["remote"].typ != "sse" || es["local"].command != "node" || len(es) != 2 {
t.Fatalf("entries: %+v", es)
}
// A // inside a string is data, not a comment.
es = entriesByName(t, mcpFormatVSCode, `{"servers": {"u": {"url": "https://h.example.com//x"}}}`)
if es["u"].url != "https://h.example.com//x" {
t.Fatalf("a URL lost its slashes: %q", es["u"].url)
}
}
func TestMCPParseZedBothCommandForms(t *testing.T) {
es := entriesByName(t, mcpFormatZed, `{
"theme": "One Dark", // Zed's settings carry everything else too
"context_servers": {
"flat": {"source": "custom", "command": "demo-mcp-server", "args": ["--stdio"], "env": {"DEMO_TOKEN": "`+fakeLiteral+`"}},
"nested": {"command": {"path": "/usr/local/bin/demo", "args": ["serve"], "env": {"LOG_LEVEL": "info"}}},
"remote": {"url": "https://zed-tools.example.com/mcp", "headers": {"X-Api-Key": "`+fakeLiteral+`"}}
}
}`)
if es["flat"].command != "demo-mcp-server" || es["nested"].command != "/usr/local/bin/demo" ||
!reflect.DeepEqual(es["nested"].args, []string{"serve"}) || es["nested"].env["LOG_LEVEL"] != "info" ||
es["remote"].headers["X-Api-Key"] == "" {
t.Fatalf("entries: %+v", es)
}
}
func TestMCPParseWindsurfServerURL(t *testing.T) {
es := entriesByName(t, mcpFormatServers, `{"mcpServers": {"w": {"serverUrl": "https://ws-tools.example.com/mcp"}}}`)
if es["w"].url != "https://ws-tools.example.com/mcp" {
t.Fatalf("serverUrl not read: %+v", es)
}
}
func TestMCPParseCodexTOML(t *testing.T) {
body := `# invented Codex config
model = "demo-model"
[mcp_servers.docs]
command = "npx"
args = [
"-y", # a comment inside an array
"demo-docs-server@2.0.1",
]
env = { "DEMO_KEY" = "` + fakeLiteral + `", PLAIN = 'x' }
env_vars = ["HOME_HINT", { name = "FORWARDED", source = "local" }]
startup_timeout_sec = 20
[mcp_servers."remote tools"]
url = "https://codex-tools.example.com/mcp"
bearer_token_env_var = "DEMO_BEARER"
enabled = false
[mcp_servers.docs.tools.search]
approval_mode = "approve"
[[profiles_list]]
command = "not a server"
`
es := entriesByName(t, mcpFormatCodex, body)
d := es["docs"]
if d.command != "npx" || !reflect.DeepEqual(d.args, []string{"-y", "demo-docs-server@2.0.1"}) ||
d.env["DEMO_KEY"] == "" || d.env["PLAIN"] != "x" ||
!reflect.DeepEqual(d.envNames, []string{"HOME_HINT", "FORWARDED"}) {
t.Fatalf("docs: %+v", d)
}
r := es["remote tools"]
if r.url == "" || !r.authHint || !r.disabled || !reflect.DeepEqual(r.envNames, []string{"DEMO_BEARER"}) {
t.Fatalf("remote: %+v", r)
}
if len(es) != 2 {
t.Fatalf("an [[array of tables]] leaked in, or a server was lost: %v", es)
}
if _, err := parseTOMLSubset("[mcp_servers.x]\ncommand = \"unterminated\n"); err == nil {
t.Fatal("a broken line must fail the file, not half-read it")
}
if v, _ := parseTOMLSubset(`k = "a\tb\u00e9\"q"`); v["k"] != "a\tbé\"q" {
t.Fatalf("escapes: %q", v["k"])
}
}
// THE VALUE NEVER LEAVES. A secret in env, a header, an argument or the URL
// sets one bool; the marshalled record carries the variable's NAME and
// nothing of the value.
func TestMCPAnalyzeKeepsNamesAndDropsValues(t *testing.T) {
cases := []struct {
name string
e mcpEntry
}{
{"env, known prefix", mcpEntry{name: "a", command: "npx", args: []string{"demo"},
env: map[string]string{"ANTHROPIC_API_KEY": fakeAnthropicKey}}},
{"env, known prefix under a plain name", mcpEntry{name: "a2", command: "demo",
env: map[string]string{"DEMO_SETTING": fakeAnthropicKey}}},
{"env, literal under a secret name", mcpEntry{name: "b", command: "demo",
env: map[string]string{"SERVICE_TOKEN": fakeLiteral}}},
{"header", mcpEntry{name: "c", url: "https://h.example.com/mcp",
headers: map[string]string{"Authorization": "Bearer " + fakeLiteral}}},
{"argument after a flag", mcpEntry{name: "d", command: "demo", args: []string{"--api-key", fakeLiteral}}},
{"argument with =", mcpEntry{name: "e", command: "demo", args: []string{"--token=" + fakeLiteral}}},
{"bare token argument", mcpEntry{name: "f", command: "demo", args: []string{fakeGitHubToken}}},
{"docker -e NAME=value", mcpEntry{name: "g", command: "docker",
args: []string{"run", "-i", "--rm", "-e", "GITHUB_TOKEN=" + fakeLiteral, "ghcr.io/demo/server"}}},
{"URL query", mcpEntry{name: "h", url: "https://h.example.com/mcp?api_key=" + fakeLiteral}},
{"URL password", mcpEntry{name: "i", url: "https://u:" + fakeLiteral + "@h.example.com/mcp"}},
}
for _, c := range cases {
o := mcpAnalyze(c.e)
if !o.SecretInPlaintext {
t.Errorf("%s: the secret was not seen", c.name)
}
b, _ := json.Marshal(o)
for _, v := range []string{fakeAnthropicKey, fakeLiteral, fakeGitHubToken} {
if strings.Contains(string(b), v) {
t.Errorf("%s: a value left in the record: %s", c.name, b)
}
}
}
o := mcpAnalyze(mcpEntry{name: "a", command: "npx", args: []string{"demo"},
env: map[string]string{"B_KEY": "${env:B_KEY}", "A_URL": "https://h.example.com", "LOG": "debug",
"KEY_FILE": "/etc/demo/key.pem"}, envNames: []string{"FORWARDED", "LOG"}})
if o.SecretInPlaintext {
t.Fatal("a reference, a URL, a path and a short value are not secrets in plain text")
}
if !reflect.DeepEqual(o.EnvNames, []string{"A_URL", "B_KEY", "FORWARDED", "KEY_FILE", "LOG"}) {
t.Fatalf("env names: %v", o.EnvNames)
}
if b, _ := json.Marshal(o); strings.Contains(string(b), "debug") || strings.Contains(string(b), "key.pem") {
t.Fatalf("an env value travelled: %s", b)
}
}
func TestMCPPackageAndPinned(t *testing.T) {
cases := []struct {
cmd string
args []string
source, name, pinned string
}{
{"npx", []string{"-y", "@demo-scope/server-files@1.4.2", "/srv"}, "npx", "@demo-scope/server-files", "yes"},
{"npx", []string{"-y", "@demo-scope/server-files"}, "npx", "@demo-scope/server-files", "no"},
{"npx", []string{"demo-server@latest"}, "npx", "demo-server", "no"},
{"npx", []string{"demo-server@^1.2.0"}, "npx", "demo-server", "no"},
{"npx", []string{"--package", "demo-pkg@3.0.0", "demo-bin"}, "npx", "demo-pkg", "yes"},
{"C:\\Program Files\\nodejs\\npx.cmd", []string{"-y", "demo-win@0.1.0"}, "npx", "demo-win", "yes"},
{"pnpm", []string{"dlx", "demo-dlx@1.0.0"}, "npx", "demo-dlx", "yes"},
{"npx", []string{"github:demo/repo"}, "npx", "github:demo/repo", "unknown"},
{"uvx", []string{"demo-py==0.3.1"}, "uvx", "demo-py", "yes"},
{"uvx", []string{"demo-py@0.3.1"}, "uvx", "demo-py", "yes"},
{"uvx", []string{"--from", "demo-py>=0.3", "demo-cli"}, "uvx", "demo-py", "no"},
{"uvx", []string{"demo-py"}, "uvx", "demo-py", "no"},
{"uv", []string{"tool", "run", "demo-py==1.0"}, "uvx", "demo-py", "yes"},
{"pipx", []string{"run", "demo-pipx==2.1"}, "pip", "demo-pipx", "yes"},
{"docker", []string{"run", "-i", "--rm", "-e", "X", "ghcr.io/demo/srv:1.2"}, "docker", "ghcr.io/demo/srv", "no"},
{"docker", []string{"run", "--rm", "localhost:5000/demo/srv@sha256:" + strings.Repeat("a", 64)}, "docker", "localhost:5000/demo/srv", "yes"},
{"docker", []string{"run", "-i", "demo/srv"}, "docker", "demo/srv", "no"},
{"node", []string{"/opt/demo/server.js"}, "unknown", "", "unknown"},
{"/usr/local/bin/demo-mcp-server", []string{"stdio"}, "binary", "", "unknown"},
}
for _, c := range cases {
s, n, p := mcpPackage(c.cmd, c.args)
if s != c.source || n != c.name || p != c.pinned {
t.Errorf("%s %v: got %s %q %s, want %s %q %s", c.cmd, c.args, s, n, p, c.source, c.name, c.pinned)
}
}
}
func TestMCPTransportLocalityAuth(t *testing.T) {
cases := []struct {
e mcpEntry
transport, locality, auth, urlHst string
}{
{mcpEntry{command: "demo"}, "stdio", "local_process", "not_applicable", ""},
{mcpEntry{typ: "streamable-http", url: "https://tools.example.com/mcp"}, "http", "remote", "unknown", "tools.example.com"},
{mcpEntry{url: "https://tools.example.com/sse"}, "sse", "remote", "unknown", "tools.example.com"},
{mcpEntry{url: "wss://tools.example.com/x"}, "websocket", "remote", "unknown", "tools.example.com"},
{mcpEntry{typ: "ws", url: "ws://127.0.0.1:9000"}, "websocket", "loopback", "unknown", "127.0.0.1"},
{mcpEntry{url: "http://localhost:3333/mcp"}, "http", "loopback", "unknown", "localhost"},
{mcpEntry{url: "http://10.20.30.40:80/mcp"}, "http", "lan", "unknown", "10.20.30.40"},
{mcpEntry{url: "http://toolbox.internal/mcp"}, "http", "lan", "unknown", "toolbox.internal"},
{mcpEntry{url: "http://toolbox/mcp"}, "http", "lan", "unknown", "toolbox"},
{mcpEntry{url: "https://t.example.com/mcp", headers: map[string]string{"Authorization": "Bearer ${env:T}"}}, "http", "remote", "yes", "t.example.com"},
{mcpEntry{url: "https://t.example.com/mcp", authHint: true}, "http", "remote", "yes", "t.example.com"},
{mcpEntry{typ: "carrier-pigeon", url: "https://t.example.com"}, "unknown", "remote", "unknown", "t.example.com"},
}
for _, c := range cases {
o := mcpAnalyze(c.e)
if o.Transport != c.transport || o.Locality != c.locality || o.AuthConfigured != c.auth || o.URLHost != c.urlHst {
t.Errorf("%+v: got %s %s %s %q", c.e, o.Transport, o.Locality, o.AuthConfigured, o.URLHost)
}
if c.transport == "stdio" && o.BindClass != "not_applicable" || c.transport != "stdio" && o.BindClass != "unknown" {
t.Errorf("%+v: bind %s", c.e, o.BindClass)
}
}
// The command is the cmdline handling's: joined, and cut at 200.
long := mcpAnalyze(mcpEntry{command: "demo", args: []string{strings.Repeat("a", 300)}})
if len(long.Command) != 200 {
t.Fatalf("command not truncated like a cmdline: %d", len(long.Command))
}
}
func TestCorrelateMCPStates(t *testing.T) {
declared := []mcpServerObs{
mcpAnalyze(mcpEntry{name: "files", command: "npx", args: []string{"-y", "@demo-scope/server-files@1.4.2"}}),
mcpAnalyze(mcpEntry{name: "idle", command: "uvx", args: []string{"demo-idle"}}),
mcpAnalyze(mcpEntry{name: "bin", command: "/opt/demo/bin/demo-tool", args: []string{"serve"}}),
mcpAnalyze(mcpEntry{name: "saas", url: "https://tools.example.com/mcp"}),
mcpAnalyze(mcpEntry{name: "local-url", url: "http://127.0.0.1:7000/mcp"}),
}
procs := []mcpProc{
{PID: 10, PPID: 1, Argv: []string{"demo-client"}},
{PID: 20, PPID: 10, Argv: []string{"npm", "exec", "-y", "@demo-scope/server-files@1.4.2"}},
// npx's child: no package name in its own line, inherits the match.
{PID: 21, PPID: 20, Argv: []string{"node", "/home/demo/.npm/_npx/abc/node_modules/.bin/mcp-server-files"}},
{PID: 30, PPID: 10, Argv: []string{"/opt/demo/bin/demo-tool", "serve"}},
// Shadow: a server nobody declared, and its own child (collapsed).
{PID: 40, PPID: 1, Argv: []string{"node", "/opt/x/node_modules/@modelcontextprotocol/server-memory/dist/index.js"}},
{PID: 41, PPID: 40, Argv: []string{"node", "/opt/x/mcp-worker.js"}},
{PID: 50, PPID: 1, Argv: []string{"/usr/bin/github-mcp-server", "stdio", "--token=" + fakeGitHubToken}},
// Not servers: an agent run with an MCP flag, and a grep.
{PID: 60, PPID: 1, Argv: []string{"claude", "--mcp-config", "/tmp/mcp.json"}},
{PID: 61, PPID: 1, Argv: []string{"grep", "-r", "mcp-server", "/srv"}},
{PID: 70, PPID: 1}, // a process whose command line was not read
}
got := correlateMCP(declared, procs, true)
state := map[string]mcpServerObs{}
var shadows []mcpServerObs
for _, o := range got {
if o.RunningState == mcpStateShadow {
shadows = append(shadows, o)
continue
}
state[o.Name] = o
}
if s := state["files"]; s.RunningState != mcpStateRunning || !reflect.DeepEqual(s.PIDs, []int{20, 21}) {
t.Errorf("files: %s %v", s.RunningState, s.PIDs)
}
if s := state["idle"]; s.RunningState != mcpStateIdle {
t.Errorf("idle: %s", s.RunningState)
}
if s := state["bin"]; s.RunningState != mcpStateRunning || !reflect.DeepEqual(s.PIDs, []int{30}) {
t.Errorf("bin: %s %v", s.RunningState, s.PIDs)
}
if s := state["saas"]; s.RunningState != mcpStateNotLocal {
t.Errorf("saas: %s", s.RunningState)
}
if s := state["local-url"]; s.RunningState != mcpStateUnknown {
t.Errorf("local-url: %s", s.RunningState)
}
if len(shadows) != 2 || !reflect.DeepEqual(shadows[0].PIDs, []int{40}) || !reflect.DeepEqual(shadows[1].PIDs, []int{50}) {
t.Fatalf("shadow servers: %+v", shadows)
}
if !shadows[1].SecretInPlaintext || strings.Contains(shadows[1].Command, fakeGitHubToken) {
t.Fatalf("a running server's secret argument travelled: %+v", shadows[1])
}
// No process list: nothing is guessed, and no shadow is invented.
for _, o := range correlateMCP(declared, nil, false) {
if o.RunningState == mcpStateShadow || (o.Transport == "stdio" && o.RunningState != mcpStateUnknown) {
t.Errorf("with no process list %s read %s", o.Name, o.RunningState)
}
}
}
// The whole reader over invented homes: the per-OS path table, the projects
// Claude Code lists, the system file, and an unreadable file that must be
// counted rather than read as empty.
func TestReadMCPConfigsOverFixtureHomes(t *testing.T) {
root := t.TempDir()
home := filepath.Join(root, "home", "demo")
proj := filepath.Join(root, "work", "proj")
write := func(p, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
h := mcpHome{Dir: home, ConfigDir: filepath.Join(home, ".config")}
projJSON, _ := json.Marshal(proj)
write(filepath.Join(home, ".claude.json"), `{"mcpServers": {"u": {"command": "demo-u"}},
"projects": {`+string(projJSON)+`: {}}}`)
write(filepath.Join(home, ".cursor", "mcp.json"), `{"mcpServers": {"c": {"url": "https://c.example.com/mcp"}}}`)
write(filepath.Join(home, ".codex", "config.toml"), "[mcp_servers.x]\ncommand = \"demo-x\"\n")
write(filepath.Join(proj, ".mcp.json"), `{"mcpServers": {"p": {"command": "demo-p"}}}`)
write(filepath.Join(proj, ".vscode", "mcp.json"), `{"servers": {"v": {"command": "demo-v"},},}`)
write(filepath.Join(home, ".config", "zed", "settings.json"), `{"context_servers": {"z": {"command": "demo-z"}`) // broken
// A directory where a file should be: present, and unreadable as a file.
if err := os.MkdirAll(filepath.Join(home, ".codeium", "windsurf", "mcp_config.json"), 0o755); err != nil {
t.Fatal(err)
}
sys := filepath.Join(root, "etc", "managed-mcp.json")
write(sys, `{"mcpServers": {"s": {"command": "demo-s"}}}`)
oldH, oldS, oldOS := mcpHomesFn, mcpSystemFn, mcpGOOS
t.Cleanup(func() { mcpHomesFn, mcpSystemFn, mcpGOOS = oldH, oldS, oldOS })
mcpGOOS = "linux"
mcpHomesFn = func() ([]mcpHome, bool) { return []mcpHome{h}, true }
mcpSystemFn = func() []mcpSource { return []mcpSource{{mcpClientCode, mcpScopeSystem, sys, mcpFormatServers}} }
got, scan := readMCPConfigs()
where := map[string]string{}
for _, o := range got {
where[o.Name] = o.Client + "/" + o.Scope
}
want := map[string]string{"s": "claude-code/system", "u": "claude-code/user", "c": "cursor/user",
"x": "codex/user", "p": "claude-code/project", "v": "vscode/project"}
if !reflect.DeepEqual(where, want) {
t.Fatalf("servers: %v", where)
}
if scan.Accounts != 1 || scan.Projects != 1 || scan.Unparsed != 1 || scan.Unreadable != 1 || scan.FilesRead != 7 {
t.Fatalf("scan: %+v", scan)
}
mcpHomesFn = func() ([]mcpHome, bool) { return nil, false }
if got, scan := readMCPConfigs(); got != nil || scan.Supported {
t.Fatal("an unsupported platform must say so, not report an empty machine")
}
}
// The path table names each client's file on each platform.
func TestMCPConfigSourcesPerPlatform(t *testing.T) {
find := func(srcs []mcpSource, client, suffix string) bool {
for _, s := range srcs {
if s.Client == client && strings.HasSuffix(filepath.ToSlash(s.Path), suffix) {
return true
}
}
return false
}
mac := mcpConfigSources("darwin", mcpHome{Dir: "/Users/demo", ConfigDir: "/Users/demo/Library/Application Support"})
win := mcpConfigSources("windows", mcpHome{Dir: `C:\Users\demo`, ConfigDir: `C:\Users\demo\AppData\Roaming`})
lin := mcpConfigSources("linux", mcpHome{Dir: "/home/demo", ConfigDir: "/home/demo/.config"})
for _, c := range []struct {
srcs []mcpSource
client, suffix string
}{
{mac, mcpClientDesktop, "Library/Application Support/Claude/claude_desktop_config.json"},
{win, mcpClientDesktop, "Roaming/Claude/claude_desktop_config.json"},
{lin, mcpClientCode, "/home/demo/.claude.json"},
{lin, mcpClientCursor, ".cursor/mcp.json"},
{mac, mcpClientVSCode, "Application Support/Code/User/mcp.json"},
{lin, mcpClientWindsurf, ".config/devin/mcp_config.json"},
{lin, mcpClientWindsurf, ".codeium/windsurf/mcp_config.json"},
{lin, mcpClientCodex, ".codex/config.toml"},
{mac, mcpClientZed, "/Users/demo/.config/zed/settings.json"},
{win, mcpClientZed, "Roaming/Zed/settings.json"},
} {
if !find(c.srcs, c.client, filepath.ToSlash(c.suffix)) {
t.Errorf("%s: no source ending %s", c.client, c.suffix)
}
}
if s := mcpSystemSources("windows", `C:\Program Files`); len(s) != 1 || !strings.HasSuffix(filepath.ToSlash(s[0].Path), "ClaudeCode/managed-mcp.json") {
t.Errorf("windows system sources: %v", s)
}
}
// The heartbeat's report: a process walk that failed says so, and every
// local server then reads unknown rather than "not running".
func TestMCPServersReportWhenTheProcessWalkFails(t *testing.T) {
home := t.TempDir()
if err := os.WriteFile(filepath.Join(home, ".claude.json"),
[]byte(`{"mcpServers": {"u": {"command": "demo-u"}}}`), 0o600); err != nil {
t.Fatal(err)
}
oldH, oldS, oldP := mcpHomesFn, mcpSystemFn, scanMCPProcessesFn
t.Cleanup(func() { mcpHomesFn, mcpSystemFn, scanMCPProcessesFn = oldH, oldS, oldP })
resetMCPConfigCache()
t.Cleanup(resetMCPConfigCache)
mcpHomesFn = func() ([]mcpHome, bool) { return []mcpHome{{Dir: home, ConfigDir: home}}, true }
mcpSystemFn = func() []mcpSource { return nil }
scanMCPProcessesFn = func(map[string]bool) ([]mcpProc, error) { return nil, os.ErrPermission }
got, scan := mcpServersReport()
if len(got) != 1 || got[0].RunningState != mcpStateUnknown || scan.ProcessScan == "ok" {
t.Fatalf("failed walk: %+v %+v", got, scan)
}
scanMCPProcessesFn = func(want map[string]bool) ([]mcpProc, error) {
if !want["demo-u"] {
t.Error("the declared program was not in the set whose command lines are read")
}
return []mcpProc{{PID: 9, PPID: 1, Argv: []string{"/usr/bin/demo-u"}}}, nil
}
got, scan = mcpServersReport()
if len(got) != 1 || got[0].RunningState != mcpStateRunning || scan.ProcessScan != "ok" {
t.Fatalf("walk: %+v %+v", got, scan)
}
}
+48
View File
@@ -0,0 +1,48 @@
//go:build windows
package main
import (
"os"
"path/filepath"
"strings"
)
// The accounts whose agent clients mcpconfig.go reads on Windows: every
// profile directory beside the Public one (normally C:\Users\*), except the
// templates and shared profiles Windows keeps there. %USERPROFILE% is not
// used to find them: under LocalSystem it is the system profile, not a
// person's. The per-user configuration directory is AppData\Roaming, which
// is what %APPDATA% names for that account.
func mcpHomes() ([]mcpHome, bool) {
root := ""
if pub := os.Getenv("PUBLIC"); pub != "" {
root = filepath.Dir(pub)
} else if drive := os.Getenv("SystemDrive"); drive != "" {
root = drive + `\Users`
}
var homes []string
if h, err := os.UserHomeDir(); err == nil && h != "" {
homes = append(homes, h)
}
if root != "" {
if entries, err := os.ReadDir(root); err == nil {
for _, e := range entries {
switch strings.ToLower(e.Name()) {
case "public", "default", "default user", "all users", "defaultapppool":
continue
}
if e.IsDir() {
homes = append(homes, filepath.Join(root, e.Name()))
}
}
}
}
var out []mcpHome
for _, h := range dedupeStrings(homes) {
out = append(out, mcpHome{Dir: h, ConfigDir: filepath.Join(h, "AppData", "Roaming")})
}
return out, true
}
func mcpSystemFiles() []mcpSource { return mcpSystemSources("windows", os.Getenv("ProgramFiles")) }
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"os"
"sort"
"strings"
)
// THE WINDOWS OPEN, DECIDED WHERE A LINUX TEST CAN SEE IT.
//
// mcpconfig_open_windows.go opens a config path with CreateFile and asks the
// HANDLE what it holds; every decision about the answer is here, with no
// build tag, so the Linux test run checks the Windows rules with fake handles
// (mcpconfig_winopen_test.go). Nothing here calls Windows.
//
// The open does not follow a reparse point at the last component
// (FILE_FLAG_OPEN_REPARSE_POINT): a symbolic link, junction or other
// reparse point at a config path is opened as itself, seen as a reparse
// point, and refused. What is checked is the handle that will be read, so
// there is no gap between a look and an open for a swap to fall into. A
// directory, a device, a pipe or anything else that is not a file on disk is
// refused the same way. A reparse point in a directory ABOVE the file is
// followed, as for any program opening the path; the file it leads to must
// then be owned by the account whose profile it is read for, or by SYSTEM or
// the Administrators group (mcpOwnerAllowed), so a link into another
// account's profile reads nothing of it.
// The Windows values the decision reads, from the SDK headers (winbase.h,
// winnt.h) as golang.org/x/sys/windows carries them: FILE_TYPE_DISK,
// FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_DEVICE, FILE_ATTRIBUTE_REPARSE_POINT.
const (
mcpWinFileTypeDisk = 0x0001
mcpWinAttrDirectory = 0x0010
mcpWinAttrDevice = 0x0040
mcpWinAttrReparse = 0x0400
)
// mcpWinAdminOwners: the owners a file in any account's profile may have
// besides that account. SYSTEM (S-1-5-18), and the Administrators group
// (S-1-5-32-544), which is the default owner of what an elevated
// administrator creates.
var mcpWinAdminOwners = map[string]bool{"S-1-5-18": true, "S-1-5-32-544": true}
// mcpWinHandleInfo is what the open read from the handle it holds.
type mcpWinHandleInfo struct {
FileType uint32 // GetFileType
Attributes uint32 // GetFileInformationByHandle's FileAttributes
Owner string // the owner SID from GetSecurityInfo; "" when unread
}
// mcpWinRegular: the handle is a file on disk that is not a directory, a
// device or a reparse point.
func mcpWinRegular(i mcpWinHandleInfo) bool {
return i.FileType == mcpWinFileTypeDisk &&
i.Attributes&(mcpWinAttrDirectory|mcpWinAttrDevice|mcpWinAttrReparse) == 0
}
// mcpWinOps are the Windows calls the open makes, as values so a test can
// stand fakes in for them. The field names are not method names anywhere in
// the package: the claims test resolves a call through a value by name.
type mcpWinOps struct {
openPath func(path string) (uintptr, error)
handleInfo func(h uintptr) (mcpWinHandleInfo, error)
closeHandle func(h uintptr)
asFile func(h uintptr, path string) *os.File
}
// mcpWinOpenWith opens path, checks the handle, and hands back the file and
// its owner SID. A handle that is refused is closed before return and never
// becomes a file.
func mcpWinOpenWith(ops mcpWinOps, path string) (*os.File, string, error) {
h, err := ops.openPath(path)
if err != nil {
return nil, "", err
}
info, err := ops.handleInfo(h)
if err != nil {
ops.closeHandle(h)
return nil, "", &os.PathError{Op: "query", Path: path, Err: err}
}
if !mcpWinRegular(info) {
ops.closeHandle(h)
return nil, "", errMCPNotRegular
}
owner := info.Owner
if owner == "" {
owner = mcpOwnerUnknown
}
return ops.asFile(h, path), owner, nil
}
// mcpWinProfileSID is the SID whose profile directory is dir, from the
// ProfileList (SID to ProfileImagePath, already expanded). mcpOwnerUnknown
// when no SID or more than one names it; only an administrator's or
// SYSTEM's files are then read there.
func mcpWinProfileSID(dir string, profiles map[string]string) string {
norm := func(p string) string {
return strings.ToLower(strings.TrimRight(strings.ReplaceAll(p, "/", `\`), `\`))
}
want := norm(dir)
var hits []string
for sid, p := range profiles {
if strings.HasPrefix(sid, "S-1-") && !strings.Contains(sid, ".") && norm(p) == want {
hits = append(hits, sid)
}
}
sort.Strings(hits)
if len(hits) != 1 {
return mcpOwnerUnknown
}
return hits[0]
}
+179
View File
@@ -0,0 +1,179 @@
package main
import (
"errors"
"os"
"path/filepath"
"testing"
)
// The Windows open's decisions, with fake handles: Windows itself never runs
// here, and nothing in CI runs on Windows, so this is what checks the rules.
// The SIDs are invented.
const (
testWinSIDOwner = "S-1-5-21-1000000001-1000000002-1000000003-1201"
testWinSIDOther = "S-1-5-21-1000000001-1000000002-1000000003-1202"
)
type fakeWinHandles struct {
info mcpWinHandleInfo
infoErr error
openErr error
openCount int
closed []uintptr
files []uintptr
backing string // a real file the "handle" reads from
}
func (f *fakeWinHandles) ops(t *testing.T) mcpWinOps {
return mcpWinOps{
openPath: func(string) (uintptr, error) {
if f.openErr != nil {
return 0, f.openErr
}
f.openCount++
return uintptr(100 + f.openCount), nil
},
handleInfo: func(uintptr) (mcpWinHandleInfo, error) { return f.info, f.infoErr },
closeHandle: func(h uintptr) { f.closed = append(f.closed, h) },
asFile: func(h uintptr, path string) *os.File {
f.files = append(f.files, h)
r, err := os.Open(f.backing)
if err != nil {
t.Fatal(err)
}
return r
},
}
}
func TestMCPWinOpenRefusesWhatIsNotARegularFileOnDisk(t *testing.T) {
backing := filepath.Join(t.TempDir(), "mcp.json")
if err := os.WriteFile(backing, []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
refused := map[string]mcpWinHandleInfo{
"symbolic link or junction (reparse point)": {FileType: mcpWinFileTypeDisk, Attributes: mcpWinAttrReparse | 0x20, Owner: testWinSIDOwner},
"directory": {FileType: mcpWinFileTypeDisk, Attributes: mcpWinAttrDirectory, Owner: testWinSIDOwner},
"device attribute": {FileType: mcpWinFileTypeDisk, Attributes: mcpWinAttrDevice, Owner: testWinSIDOwner},
"character device": {FileType: 0x0002, Owner: testWinSIDOwner},
"pipe": {FileType: 0x0003, Owner: testWinSIDOwner},
"unknown type": {FileType: 0x0000, Owner: testWinSIDOwner},
}
for name, info := range refused {
fk := &fakeWinHandles{info: info, backing: backing}
f, _, err := mcpWinOpenWith(fk.ops(t), `C:\Users\demo\.cursor\mcp.json`)
if f != nil || !errors.Is(err, errMCPNotRegular) {
t.Errorf("%s: opened (err %v)", name, err)
}
if len(fk.closed) != 1 || len(fk.files) != 0 {
t.Errorf("%s: handle not closed, or made into a file: closed %v files %v", name, fk.closed, fk.files)
}
}
fk := &fakeWinHandles{infoErr: errors.New("query failed"), backing: backing}
if f, _, err := mcpWinOpenWith(fk.ops(t), `C:\x`); f != nil || err == nil || len(fk.closed) != 1 {
t.Errorf("a handle that cannot be queried must be closed and refused: %v %v", err, fk.closed)
}
fk = &fakeWinHandles{openErr: &os.PathError{Op: "open", Path: `C:\x`, Err: os.ErrNotExist}, backing: backing}
if _, _, err := mcpWinOpenWith(fk.ops(t), `C:\x`); !errors.Is(err, os.ErrNotExist) {
t.Errorf("a missing file must stay a missing file (not counted unreadable): %v", err)
}
// A plain file (FILE_ATTRIBUTE_ARCHIVE) is opened, and its owner comes back.
fk = &fakeWinHandles{info: mcpWinHandleInfo{FileType: mcpWinFileTypeDisk, Attributes: 0x20, Owner: testWinSIDOwner}, backing: backing}
f, owner, err := mcpWinOpenWith(fk.ops(t), `C:\x`)
if err != nil || f == nil || owner != testWinSIDOwner || len(fk.closed) != 0 {
t.Fatalf("a regular file was refused: %v %q %v", err, owner, fk.closed)
}
f.Close()
// An owner that could not be read is never an owner that matches.
fk = &fakeWinHandles{info: mcpWinHandleInfo{FileType: mcpWinFileTypeDisk, Attributes: 0x80}, backing: backing}
f, owner, err = mcpWinOpenWith(fk.ops(t), `C:\x`)
if err != nil || owner != mcpOwnerUnknown {
t.Fatalf("unread owner: %v %q", err, owner)
}
f.Close()
if mcpOwnerAllowed(owner, mcpOwnerUnknown, mcpWinAdminOwners) {
t.Fatal("an unread owner matched an unknown profile owner")
}
}
func TestMCPWinOwnerRules(t *testing.T) {
for _, c := range []struct {
file, want string
ok bool
}{
{testWinSIDOwner, testWinSIDOwner, true},
{"S-1-5-18", testWinSIDOwner, true}, // SYSTEM
{"S-1-5-32-544", testWinSIDOwner, true}, // Administrators
{testWinSIDOther, testWinSIDOwner, false},
{"S-1-1-0", testWinSIDOwner, false}, // Everyone
{"", testWinSIDOwner, false},
{mcpOwnerUnknown, testWinSIDOwner, false},
{testWinSIDOther, mcpOwnerUnknown, false},
{"S-1-5-18", mcpOwnerUnknown, true},
{testWinSIDOther, mcpNoOwnerCheck, true},
} {
if got := mcpOwnerAllowed(c.file, c.want, mcpWinAdminOwners); got != c.ok {
t.Errorf("file owner %q read for %q: allowed %v, want %v", c.file, c.want, got, c.ok)
}
}
}
func TestMCPWinProfileSID(t *testing.T) {
profiles := map[string]string{
"S-1-5-18": `C:\Windows\system32\config\systemprofile`,
testWinSIDOwner: `C:\Users\demo`,
testWinSIDOwner + ".bak": `C:\Users\demo`,
testWinSIDOther: `C:\Users\other`,
"S-1-5-21-1000000001-1000000002-9-1": `C:\Users\twice`,
"S-1-5-21-1000000001-1000000002-9-2": `C:\Users\twice`,
}
for dir, want := range map[string]string{
`C:\Users\demo`: testWinSIDOwner,
`c:\users\DEMO\`: testWinSIDOwner,
`C:/Users/demo`: testWinSIDOwner,
`C:\Users\other`: testWinSIDOther,
`C:\Users\twice`: mcpOwnerUnknown,
`C:\Users\nobody`: mcpOwnerUnknown,
} {
if got := mcpWinProfileSID(dir, profiles); got != want {
t.Errorf("%s: %q, want %q", dir, got, want)
}
}
}
// End to end through mcpReadFileAs with the Windows open's rules: a file in a
// profile that another account owns is not read and is counted, one SYSTEM
// owns is read.
func TestMCPReadFileAsWindowsOwnerRules(t *testing.T) {
backing := filepath.Join(t.TempDir(), "mcp.json")
if err := os.WriteFile(backing, []byte(`{"mcpServers":{}}`), 0o600); err != nil {
t.Fatal(err)
}
oldOpen, oldAdmins := mcpOpenRegularFn, mcpAdminOwners
t.Cleanup(func() { mcpOpenRegularFn, mcpAdminOwners = oldOpen, oldAdmins })
mcpAdminOwners = mcpWinAdminOwners
for _, c := range []struct {
owner string
read bool
foreign int
}{
{testWinSIDOwner, true, 0},
{"S-1-5-18", true, 0},
{testWinSIDOther, false, 1},
{"", false, 1},
} {
fk := &fakeWinHandles{info: mcpWinHandleInfo{FileType: mcpWinFileTypeDisk, Attributes: 0x20, Owner: c.owner}, backing: backing}
mcpOpenRegularFn = func(p string) (*os.File, string, error) { return mcpWinOpenWith(fk.ops(t), p) }
var scan mcpConfigScan
_, ok := mcpReadFileAs(`C:\Users\demo\.cursor\mcp.json`, testWinSIDOwner, &scan)
if ok != c.read || scan.ForeignOwner != c.foreign {
t.Errorf("owner %q: read %v foreign %d, want %v %d", c.owner, ok, scan.ForeignOwner, c.read, c.foreign)
}
}
}
+1
View File
@@ -148,6 +148,7 @@ func mgmtAudit(self, op, family, verdict, why string, exit int, args []string, a
line["approval"] = approval
}
rec, _ := json.Marshal(line)
auditToSystemLog(mgmtAuditFile, self, rec)
mgmtAuditMu.Lock()
defer mgmtAuditMu.Unlock()
+14 -2
View File
@@ -818,6 +818,12 @@ func TestTheObservePlanesPayloadIsGatedOnObserve(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
t.Setenv("OLLAMA_ADDR", "127.0.0.1:1")
// The tool-server reader looks in an empty home, not this machine's.
oldHomes, oldSys := mcpHomesFn, mcpSystemFn
t.Cleanup(func() { mcpHomesFn, mcpSystemFn = oldHomes, oldSys })
empty := t.TempDir()
mcpHomesFn = func() ([]mcpHome, bool) { return []mcpHome{{Dir: empty, ConfigDir: empty}}, true }
mcpSystemFn = func() []mcpSource { return nil }
beat := func(self string) map[string]any {
t.Helper()
@@ -846,13 +852,16 @@ func TestTheObservePlanesPayloadIsGatedOnObserve(t *testing.T) {
// The store root names the operator's OS account; it is path material
// like the rest and travels only with `observe`. The holes map travels
// with it: absent must mean "not entitled", and only that.
"machine_record_root", "machine_record_dropped"} {
"machine_record_root", "machine_record_dropped",
// The tool servers agent clients declare name files, servers and
// command lines: observe material like the agent list.
"tool_servers", "tool_server_scan"} {
if _, present := m[k]; present {
t.Fatalf("%q travelled to a pairing that cannot observe — that is the machine's "+
"process list going to a host approved for one model", k)
}
}
if hasCap(m, "agent-observe") || hasCap(m, "scheduler-observe") {
if hasCap(m, "agent-observe") || hasCap(m, "scheduler-observe") || hasCap(m, "tool-server-observe") {
t.Fatalf("the observe capabilities were advertised to a pairing outside the family: %v",
m["capabilities"])
}
@@ -869,6 +878,9 @@ func TestTheObservePlanesPayloadIsGatedOnObserve(t *testing.T) {
if _, present := m["schedulers"]; !present {
t.Fatal("a pairing granted observe must still get the scheduler inventory")
}
if _, present := m["tool_servers"]; !present || !hasCap(m, "tool-server-observe") {
t.Fatal("a pairing granted observe must get the declared tool servers and their capability")
}
// The anchor holds every family, and an unanchored box gates nothing — the
// two cases that must not change, which between them cover nearly every install.
+38
View File
@@ -210,6 +210,31 @@ func cmdPair(token, redeemURL, hostCAFile string, replace bool) error {
if err != nil {
return err
}
// A networked machine is a server. Left unanchored it refuses every op to
// every host (capsAllowIn: unanchoredNetworkedRefusal) until someone runs
// claim-anchor, a step people missed. So the first pairing on a networked
// box with no anchor anchors it, as `claim-anchor` with no token would, and
// from then on every terminal and write waits for the person's approval and
// every later pairing for this host's. Only with machine authority, which
// claim-anchor needs anyway.
// The pairing itself succeeded, so a failed anchor is reported, not
// returned: `pair` failing here would read as "could not pair" to a caller
// (npx cseries exits on it) when the pairing exists. claim-anchor, which
// such a caller runs next, reports the anchor's own failure.
anchored, err := anchorFirstNetworkedPairing(cfg, all)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
}
if anchored {
if fresh, lerr := loadPairings(); lerr == nil {
if c := pairingFor(fresh, cfg.HostName); c != nil {
cfg, all = c, fresh
}
}
fmt.Printf("\nThis machine is networked and anchored to %s: every terminal and every write\n", cfg.HostName)
fmt.Println("waits for your approval, and any later host waits for this one to approve it.")
fmt.Printf("Enrol your second factor now, so you can approve here:\n %s\n\n", stepupEnrollHint())
}
if cfg.tcpTransport() && cfg.active() {
fmt.Printf("Paired with %s over TCP.\n\n", cfg.HostName)
fmt.Printf(" lanes: %s (mutual TLS, the Host's CA pinned, name %s)\n coder runs: none over TCP\n", cfg.LaneAddr, tcpServerName)
@@ -242,6 +267,19 @@ func cmdPair(token, redeemURL, hostCAFile string, replace bool) error {
return nil
}
// anchorFirstNetworkedPairing anchors a networked machine that has no anchor
// to the pairing just made, when it is the machine's only one and the caller
// holds machine authority (see cmdPair). Reports whether it anchored.
func anchorFirstNetworkedPairing(cfg *Config, all []*Config) (bool, error) {
if boxMode() != modeNetworked || currentAnchor() != nil || len(all) != 1 || !machineAuthorityHeld() {
return false, nil
}
if err := cmdClaimAnchor("", false, "", cfg.HostName); err != nil {
return false, fmt.Errorf("paired with %s, but anchoring this networked machine to it failed: %w\n anchor it with: %s", cfg.HostName, err, anchorCommandHint())
}
return true, nil
}
// redeemURLBase is the platform's enrollment ingress. A variable so a test can
// serve a redeem response on loopback; nothing outside tests assigns it.
var redeemURLBase = "https://api.privatae.ai"
+116
View File
@@ -0,0 +1,116 @@
//go:build !windows
package main
import (
"path/filepath"
"testing"
)
// TestPairAnchorsANetworkedMachineToItsFirstHost: on a networked machine with
// no anchor, the first pairing anchors it (as claim-anchor with no token
// would), so a server is not left refusing everything until claim-anchor is
// run by hand. A local machine,
// an already anchored one, a second pairing, or a caller without machine
// authority are left as they were.
func TestPairAnchorsANetworkedMachineToItsFirstHost(t *testing.T) {
held := true
old := machineAuthorityHeld
machineAuthorityHeld = func() bool { return held }
t.Cleanup(func() { machineAuthorityHeld = old })
box := func(t *testing.T, networked bool) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
if networked {
if err := setBoxMode(modeNetworked); err != nil {
t.Fatal(err)
}
}
}
only := func(t *testing.T, host string) (*Config, []*Config) {
t.Helper()
all, err := loadPairings()
if err != nil {
t.Fatal(err)
}
return pairingFor(all, host), all
}
t.Run("networked, no anchor, first pairing: anchored and active", func(t *testing.T) {
held = true
box(t, true)
writePairingState(t, tMember, pairingPending)
cfg, all := only(t, tMember)
anchored, err := anchorFirstNetworkedPairing(cfg, all)
if err != nil || !anchored {
t.Fatalf("the first pairing on a networked box must anchor it: anchored=%v err=%v", anchored, err)
}
if a := currentAnchor(); a == nil || a.AnchorHost != tMember {
t.Fatalf("anchor = %+v, want %s", a, tMember)
}
if got := loadState(t, tMember); got != pairingActive {
t.Fatalf("the anchor never waits on itself: state %q", got)
}
// The installer's claim-anchor after it finds the work done.
if err := cmdClaimAnchor("", false, "", ""); err != nil {
t.Fatalf("claim-anchor after pair must succeed: %v", err)
}
})
t.Run("local machine: not anchored", func(t *testing.T) {
held = true
box(t, false)
writePairing(t, tMember)
cfg, all := only(t, tMember)
if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil {
t.Fatalf("a local machine must not be anchored: %v %v", anchored, err)
}
if currentAnchor() != nil {
t.Fatal("a local machine has no anchor")
}
})
t.Run("already anchored: a new pairing stays pending", func(t *testing.T) {
held = true
box(t, true)
writePairing(t, tAnchor)
if err := writeAnchor("Acme", tAnchor); err != nil {
t.Fatal(err)
}
writePairingState(t, tMember, pairingPending)
cfg, all := only(t, tMember)
if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil {
t.Fatalf("an anchored machine must not be re-anchored: %v %v", anchored, err)
}
if a := currentAnchor(); a == nil || a.AnchorHost != tAnchor {
t.Fatalf("anchor moved: %+v", a)
}
if got := loadState(t, tMember); got != pairingPending {
t.Fatalf("a later host waits for the anchor's approval: state %q", got)
}
})
t.Run("a second pairing on an unanchored machine: left for claim-anchor --host", func(t *testing.T) {
held = true
box(t, true)
writePairing(t, tAnchor)
writePairingState(t, tMember, pairingPending)
cfg, all := only(t, tMember)
if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil {
t.Fatalf("with two pairings the person picks the anchor: %v %v", anchored, err)
}
})
t.Run("no machine authority: not anchored", func(t *testing.T) {
held = true
box(t, true) // set-mode networked itself needs machine authority
writePairingState(t, tMember, pairingPending)
held = false
cfg, all := only(t, tMember)
if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil {
t.Fatalf("without machine authority nothing is anchored: %v %v", anchored, err)
}
})
}
+1
View File
@@ -42,6 +42,7 @@ func pairingAudit(op, subject, decision string) {
"ts": time.Now().UTC().Format(time.RFC3339), "op": op,
"subject": subject, "decision": decision,
})
auditToSystemLog(auditFilePairing, "", rec)
fh, err := os.OpenFile(filepath.Join(d, "pairing_audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return
+11
View File
@@ -138,3 +138,14 @@ func restartHintFor(goos string) string {
}
return "Restart the service so the change takes: systemctl restart privatae-bastion"
}
// stepupEnrollHint is the stepup-enroll line to print after a networked
// machine is anchored: approvals on it need a second factor or a device.
func stepupEnrollHint() string { return stepupEnrollHintFor(runtime.GOOS) }
func stepupEnrollHintFor(goos string) string {
if goos == "windows" {
return "privatae-bastion stepup-enroll (from this Administrator terminal)"
}
return "sudo -H privatae-bastion stepup-enroll"
}
+1
View File
@@ -479,6 +479,7 @@ func rootExecAudit(dir string, rec map[string]any) {
rec["ts"] = time.Now().UTC().Format(time.RFC3339)
rec["op"] = sessionOpRootExec
raw, _ := json.Marshal(rec)
auditToSystemLog(auditFilePairingD, auditPairingOfDir(dir), raw)
fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return
+1
View File
@@ -254,6 +254,7 @@ func sessionAudit(dir, op, sessionID, user, event string, exit int, detail strin
"ts": time.Now().UTC().Format(time.RFC3339), "op": op, "session_id": sessionID,
"user": user, "event": event, "exit": exit, "decision": detail,
})
auditToSystemLog(auditFilePairingD, auditPairingOfDir(dir), rec)
fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return
+230
View File
@@ -0,0 +1,230 @@
package main
// Account lookups made outside a scan (sidnames.go): the approvals pipe and the
// folder agent's drop. Fake lookups stand in for LookupAccountSid, so these run
// on every platform. Every SID and name here is invented.
import (
"errors"
"strings"
"sync/atomic"
"testing"
"time"
)
const testSIDDana = "S-1-5-21-1000000001-1000000002-1000000003-1201"
// hangingLookup blocks until release is closed, then answers with acct.
func hangingLookup(calls *int32, release <-chan struct{}, acct sidAccount) func(string) (sidAccount, error) {
return func(string) (sidAccount, error) {
atomic.AddInt32(calls, 1)
<-release
return acct, nil
}
}
// A directory that does not answer: every caller is told "timed out" within
// its budget, only one lookup is ever started on the lane, and the answer that
// comes late fills the cache for the next caller.
func TestSIDDirectoryUnreachableTimesOutWithinBudget(t *testing.T) {
release := make(chan struct{})
var calls int32
dir := newSIDDirectory(hangingLookup(&calls, release,
sidAccount{Account: "dana", Domain: "EXAMPLECORP", Type: winSidTypeUser}))
lane := sidLane{slots: make(chan struct{}, 1), budget: 100 * time.Millisecond}
for i := 0; i < 5; i++ {
start := time.Now()
if _, out := dir.resolve(testSIDDana, lane); out != sidTimedOut {
t.Fatalf("call %d: outcome %v while the directory does not answer", i, out)
}
if took := time.Since(start); took > lane.budget+500*time.Millisecond {
t.Fatalf("call %d waited %v, budget %v", i, took, lane.budget)
}
}
if n := atomic.LoadInt32(&calls); n != 1 {
t.Fatalf("%d lookups started while the first was still blocked", n)
}
close(release)
deadline := time.Now().Add(2 * time.Second)
for {
a, out := dir.resolve(testSIDDana, lane)
if out == sidResolved {
if sidAccountLabel(a) != `EXAMPLECORP\dana` {
t.Fatalf("late answer = %+v", a)
}
break
}
if time.Now().After(deadline) {
t.Fatal("the late answer never reached the cache")
}
time.Sleep(10 * time.Millisecond)
}
if n := atomic.LoadInt32(&calls); n != 1 {
t.Fatalf("the late answer was asked again: %d lookups", n)
}
}
// Answers are kept for sidDirectoryTTL; a failure is asked again every time.
func TestSIDDirectoryCachesAnswersNotFailures(t *testing.T) {
var calls int32
dir := newSIDDirectory(func(sid string) (sidAccount, error) {
atomic.AddInt32(&calls, 1)
if sid == testSIDDana {
return sidAccount{Account: "dana", Domain: "EXAMPLECORP", Type: winSidTypeUser}, nil
}
return sidAccount{}, errors.New("none mapped")
})
now := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)
dir.now = func() time.Time { return now }
lane := sidLane{slots: make(chan struct{}, 1), budget: time.Second}
for i := 0; i < 3; i++ {
if _, out := dir.resolve(testSIDDana, lane); out != sidResolved {
t.Fatalf("outcome %v", out)
}
}
if n := atomic.LoadInt32(&calls); n != 1 {
t.Fatalf("%d lookups for one account within the TTL", n)
}
now = now.Add(sidDirectoryTTL)
dir.resolve(testSIDDana, lane)
if n := atomic.LoadInt32(&calls); n != 2 {
t.Fatalf("an expired answer was not asked again: %d lookups", n)
}
for i := 0; i < 2; i++ {
if _, out := dir.resolve("S-1-5-21-9-9-9-9", lane); out != sidUnmapped {
t.Fatalf("unmapped SID outcome %v", out)
}
}
if n := atomic.LoadInt32(&calls); n != 4 {
t.Fatalf("a failure was cached: %d lookups", n)
}
}
// A lookup hung on one lane does not hold another lane.
func TestSIDDirectoryLanesAreIndependent(t *testing.T) {
release := make(chan struct{})
defer close(release)
dir := newSIDDirectory(func(sid string) (sidAccount, error) {
if sid == testSIDDana {
<-release
}
return sidAccount{Account: "erin", Type: winSidTypeUser}, nil
})
stuck := sidLane{slots: make(chan struct{}, 1), budget: 50 * time.Millisecond}
other := sidLane{slots: make(chan struct{}, 1), budget: time.Second}
if _, out := dir.resolve(testSIDDana, stuck); out != sidTimedOut {
t.Fatalf("stuck lane outcome %v", out)
}
if _, out := dir.resolve("S-1-5-21-1000000001-1000000002-1000000003-1202", other); out != sidResolved {
t.Fatalf("the other lane was held by the stuck one: %v", out)
}
}
// THE APPROVALS PIPE FAILS CLOSED. A client whose account the directory could
// not name in time is never approved, even with a right code; it is not
// counted as a wrong code; list still answers. Once the directory answers,
// the same code approves.
func TestPipeApproveRefusedWhenTheAccountLookupTimedOut(t *testing.T) {
dir, idA, purposeA, _, _ := pipeBox(t)
now := time.Now()
release := make(chan struct{})
var calls int32
accounts := newSIDDirectory(hangingLookup(&calls, release,
sidAccount{Account: "pat", Domain: "EXAMPLE", Type: winSidTypeUser}))
lane := sidLane{slots: make(chan struct{}, 1), budget: 100 * time.Millisecond}
c := thePerson
c.UserName = ""
start := time.Now()
pipeClientName(&c, accounts, lane)
if took := time.Since(start); took > lane.budget+500*time.Millisecond {
t.Fatalf("the client's name lookup took %v, budget %v", took, lane.budget)
}
if !c.NameTimedOut || c.UserName != "" {
t.Fatalf("a lookup that did not answer must be marked: %+v", c)
}
code := pipeCodeNow()
approve := pipeRequest{Op: "approve", Host: pipeTestHost, ID: idA, Purpose: purposeDigest(purposeA), Code: code}
if resp := pipeServe(approve, c, now); resp.OK || !strings.Contains(resp.Error, "did not answer") {
t.Fatalf("an approve on a timed-out lookup must be refused: %+v", resp)
}
if r := stateOf(t, dir, idA); r.State != "pending" {
t.Fatalf("a timed-out lookup changed the request: %s", r.State)
}
if f := pipeThrottleLoad(dir); f.Failures != 0 {
t.Fatalf("the refusal was counted as a wrong code: %d", f.Failures)
}
if resp := pipeServe(pipeRequest{Op: "list"}, c, now); !resp.OK {
t.Fatalf("list must still answer by SID: %+v", resp)
}
close(release)
c2 := thePerson
c2.UserName = ""
deadline := time.Now().Add(2 * time.Second)
for {
c2.NameTimedOut = false
pipeClientName(&c2, accounts, lane)
if !c2.NameTimedOut {
break
}
if time.Now().After(deadline) {
t.Fatal("the directory's late answer never reached the pipe")
}
time.Sleep(10 * time.Millisecond)
}
if c2.UserName != `EXAMPLE\pat` {
t.Fatalf("name = %q", c2.UserName)
}
if resp := pipeServe(approve, c2, now); !resp.OK {
t.Fatalf("with the account named, the code approves: %+v", resp)
}
}
// An account the directory says does not exist is still served by SID, as
// before: that is an answer, not a timeout.
func TestPipeClientNameUnmappedIsNotATimeout(t *testing.T) {
accounts := newSIDDirectory(func(string) (sidAccount, error) { return sidAccount{}, errors.New("none mapped") })
c := thePerson
c.UserName = ""
pipeClientName(&c, accounts, sidLane{slots: make(chan struct{}, 1), budget: time.Second})
if c.NameTimedOut || c.UserName != "" {
t.Fatalf("unmapped: %+v", c)
}
}
// THE DROP FAILS CLOSED. The folder owner's lookup is bounded, and a
// directory that does not answer starts no agent; no account starts none.
func TestFaWinDropOwnerAccountFailsClosed(t *testing.T) {
release := make(chan struct{})
defer close(release)
var calls int32
slow := newSIDDirectory(hangingLookup(&calls, release, sidAccount{Account: "dana", Type: winSidTypeUser}))
lane := sidLane{slots: make(chan struct{}, 1), budget: 100 * time.Millisecond}
start := time.Now()
if _, err := faWinDropOwnerAccount(testSIDDana, slow, lane); err == nil || !strings.Contains(err.Error(), "did not answer") {
t.Fatalf("a directory that does not answer must refuse the start: %v", err)
}
if took := time.Since(start); took > lane.budget+500*time.Millisecond {
t.Fatalf("the drop waited %v, budget %v", took, lane.budget)
}
none := newSIDDirectory(func(string) (sidAccount, error) { return sidAccount{}, errors.New("none mapped") })
if _, err := faWinDropOwnerAccount(testSIDDana, none, sidLane{slots: make(chan struct{}, 1), budget: time.Second}); err == nil ||
!strings.Contains(err.Error(), "has no account") {
t.Fatalf("no account must refuse the start: %v", err)
}
ok := newSIDDirectory(func(string) (sidAccount, error) {
return sidAccount{Account: "dana", Domain: "EXAMPLECORP", Type: winSidTypeUser}, nil
})
a, err := faWinDropOwnerAccount(testSIDDana, ok, sidLane{slots: make(chan struct{}, 1), budget: time.Second})
if err != nil || a.Type != winSidTypeUser || sidAccountLabel(a) != `EXAMPLECORP\dana` {
t.Fatalf("answered: %+v %v", a, err)
}
}
+267
View File
@@ -0,0 +1,267 @@
package main
import (
"sync"
"time"
)
// ACCOUNT NAMES FOR WINDOWS SIDS, ASKED ONCE PER SCAN AND NEVER FOR LONG.
//
// The Windows process scan names each agent's account (winTokenUser) and
// classifies it (ownerFactsReader), and both turn a SID into an account and a
// domain with LookupAccountSid. On a domain-joined machine that call can go
// to a domain controller, and when the controller cannot be reached it waits
// for the network to give up. Asked per process, with no cache, a scan of a
// few dozen processes could then hold the heartbeat for minutes.
//
// So a scan holds one sidNames: each SID is asked at most once, both callers
// share the answer, and the scan waits on lookups for at most a fixed budget
// in total. Once a lookup has used up the budget, every SID not already
// answered is unresolved for the rest of the scan: the account shows as its
// SID string and the owner's class as unknown, which is what the classifier
// already says when the domain could not be read.
//
// LookupAccountSid cannot be cancelled. A lookup the scan stopped waiting
// for finishes in its own goroutine and is then dropped. At most one lookup
// runs at a time across the process (the slots channel), so a controller
// that stays unreachable for many scans holds one blocked thread, not one per
// scan.
//
// The type has no build tag so the Linux test run checks it with a fake
// lookup; agents_windows.go supplies the real one.
// sidLookupBudget is the most one scan waits on account lookups in total. It
// is a bound on the harm, not a measured lookup time: a scan asks once per
// distinct account, and a domain controller that does not answer costs a
// scan this much of its 60-second beat and no more.
var sidLookupBudget = 2 * time.Second
// sidLookupSlots: one lookup in flight at a time, process-wide.
var sidLookupSlots = make(chan struct{}, 1)
// sidName is one SID's answer. OK is false when the lookup failed, timed
// out, or was never started because the budget was gone.
type sidName struct {
Account, Domain string
OK bool
}
type sidNames struct {
mu sync.Mutex
lookup func(sid string) (account, domain string, err error)
slots chan struct{}
left time.Duration
names map[string]sidName
}
func newSIDNames(lookup func(string) (string, string, error), budget time.Duration, slots chan struct{}) *sidNames {
return &sidNames{lookup: lookup, slots: slots, left: budget, names: map[string]sidName{}}
}
// resolve answers one SID, from the scan's cache when it has been asked.
func (c *sidNames) resolve(sid string) sidName {
c.mu.Lock()
defer c.mu.Unlock()
if n, ok := c.names[sid]; ok {
return n
}
n := c.ask(sid)
c.names[sid] = n
return n
}
// ask runs one lookup within what is left of the budget. Called with mu held.
func (c *sidNames) ask(sid string) sidName {
if c.left <= 0 {
return sidName{}
}
start := time.Now()
var n sidName
finished := boundedSIDCall(c.slots, c.left, func() {
a, d, err := c.lookup(sid)
n = sidName{Account: a, Domain: d, OK: err == nil}
})
if !finished {
c.left = 0
return sidName{}
}
c.left -= time.Since(start)
return n
}
// boundedSIDCall runs call on its own goroutine while holding one of slots,
// and waits for it at most budget in all, the wait for a free slot included.
// It reports whether call finished in time; only then may the caller read
// what call wrote. A call it stopped waiting for keeps its slot until it
// returns, so a lane whose lookup never returns has one blocked goroutine,
// and every later caller on that lane waits its budget and is told no.
func boundedSIDCall(slots chan struct{}, budget time.Duration, call func()) bool {
if budget <= 0 {
return false
}
timer := time.NewTimer(budget)
defer timer.Stop()
select {
case slots <- struct{}{}:
case <-timer.C:
return false // an earlier lookup on this lane still holds the slot
}
done := make(chan struct{})
go func() {
defer func() { <-slots }()
call()
close(done)
}()
select {
case <-done:
return true
case <-timer.C:
return false
}
}
// ACCOUNT LOOKUPS OUTSIDE A SCAN: the approvals pipe and the folder agent's drop.
//
// Both resolve one SID when something happens (a tray connects, a store's
// agent is started), not once per scan, so they cannot share a scan's budget.
// They share one process-wide cache instead (sidDirectory): an answer is kept
// for sidDirectoryTTL, so the tray's polling asks the directory once per
// account per ten minutes, and a lookup that answers after its caller stopped
// waiting still fills the cache for the next caller. Only an answer is
// cached; a failure or a timeout is asked again next time.
//
// Each caller has its own lane (slot and budget), so a lookup hung on one
// lane never blocks the other or the scan's: at most one goroutine per lane is
// ever held by a directory that does not answer.
//
// What a timeout means is the caller's decision, and both fail closed: the
// pipe refuses an approve (pipeApprove), the drop refuses to start the agent
// (faWinDropOwnerAccount).
// sidAccount is one SID's answer: the account, its domain and its SID type
// (SID_NAME_USE).
type sidAccount struct {
Account, Domain string
Type uint32
}
// sidOutcome is how a lookup outside a scan ended.
type sidOutcome int
const (
sidResolved sidOutcome = iota // the directory answered with an account
sidUnmapped // the directory answered: no such account
sidTimedOut // the directory did not answer within the lane's budget
)
// sidLane is one caller's single-flight slot and per-lookup budget.
type sidLane struct {
slots chan struct{}
budget time.Duration
}
const (
sidDirectoryTTL = 10 * time.Minute
sidDirectoryMax = 256 // accounts kept; a machine has far fewer people
)
type sidCached struct {
acct sidAccount
at time.Time
}
type sidDirectory struct {
mu sync.Mutex
lookup func(sid string) (sidAccount, error)
now func() time.Time
names map[string]sidCached
}
func newSIDDirectory(lookup func(string) (sidAccount, error)) *sidDirectory {
return &sidDirectory{lookup: lookup, now: time.Now, names: map[string]sidCached{}}
}
// resolve answers sid from the cache, or asks the directory on lane.
func (d *sidDirectory) resolve(sid string, lane sidLane) (sidAccount, sidOutcome) {
if a, ok := d.cached(sid); ok {
return a, sidResolved
}
var (
acct sidAccount
err error
)
finished := boundedSIDCall(lane.slots, lane.budget, func() {
// Asked again with the slot held: the lookup this caller queued
// behind may have just answered for the same SID.
if a, ok := d.cached(sid); ok {
acct = a
return
}
acct, err = d.lookup(sid)
if err == nil {
d.store(sid, acct) // also when the caller has stopped waiting
}
})
switch {
case !finished:
return sidAccount{}, sidTimedOut
case err != nil:
return sidAccount{}, sidUnmapped
}
return acct, sidResolved
}
func (d *sidDirectory) cached(sid string) (sidAccount, bool) {
d.mu.Lock()
defer d.mu.Unlock()
if c, ok := d.names[sid]; ok && d.now().Sub(c.at) < sidDirectoryTTL {
return c.acct, true
}
return sidAccount{}, false
}
func (d *sidDirectory) store(sid string, acct sidAccount) {
d.mu.Lock()
defer d.mu.Unlock()
if len(d.names) >= sidDirectoryMax {
for k, c := range d.names {
if d.now().Sub(c.at) >= sidDirectoryTTL {
delete(d.names, k)
}
}
if len(d.names) >= sidDirectoryMax {
d.names = map[string]sidCached{}
}
}
d.names[sid] = sidCached{acct: acct, at: d.now()}
}
// sidAccountLabel is DOMAIN\account, or the bare account with no domain.
func sidAccountLabel(a sidAccount) string {
if a.Domain != "" {
return a.Domain + `\` + a.Account
}
return a.Account
}
// winAccountLabel is how the scan names a token's account: DOMAIN\account,
// or the SID itself when it could not be resolved.
func winAccountLabel(sid string, names *sidNames) string {
n := names.resolve(sid)
switch {
case !n.OK:
return sid
case n.Domain != "":
return n.Domain + `\` + n.Account
}
return n.Account
}
// winAccountDomain is the account's domain for the owner classifier, "" when
// the SID could not be resolved (classifyWinOwner then says unknown).
func winAccountDomain(sid string, names *sidNames) string {
if n := names.resolve(sid); n.OK {
return n.Domain
}
return ""
}
+103
View File
@@ -0,0 +1,103 @@
package main
import (
"errors"
"fmt"
"sync/atomic"
"testing"
"time"
)
// Invented SIDs: the domain part is made up and names no real domain.
const (
testSIDAlice = "S-1-5-21-1000000001-1000000002-1000000003-1101"
testSIDBob = "S-1-5-21-1000000001-1000000002-1000000003-1102"
)
// One scan asks each SID once, and the account label (winTokenUser) and the
// owner's domain (ownerFactsReader) share the answer.
func TestSIDNamesAsksEachSIDOncePerScan(t *testing.T) {
var calls int32
lookup := func(sid string) (string, string, error) {
atomic.AddInt32(&calls, 1)
switch sid {
case testSIDAlice:
return "alice", "EXAMPLECORP", nil
case testSIDBob:
return "bob", "EXAMPLECORP", nil
}
return "", "", errors.New("none mapped")
}
names := newSIDNames(lookup, time.Second, make(chan struct{}, 1))
for i := 0; i < 40; i++ {
if got := winAccountLabel(testSIDAlice, names); got != `EXAMPLECORP\alice` {
t.Fatalf("label = %q", got)
}
if got := winAccountDomain(testSIDAlice, names); got != "EXAMPLECORP" {
t.Fatalf("domain = %q", got)
}
winAccountLabel(testSIDBob, names)
}
if got := winAccountLabel("S-1-5-21-9-9-9-9", names); got != "S-1-5-21-9-9-9-9" {
t.Fatalf("an unmapped SID must be shown as itself, got %q", got)
}
winAccountDomain("S-1-5-21-9-9-9-9", names)
if n := atomic.LoadInt32(&calls); n != 3 {
t.Fatalf("%d lookups for 3 distinct SIDs in one scan", n)
}
}
// A domain controller that does not answer: the first lookup hangs. The scan
// must stop waiting at its budget, every later SID must come back unresolved
// at once (no second lookup started), the owner's class must read unknown,
// and the next scan must not stack a second blocked lookup behind the first.
func TestSIDNamesUnreachableDirectoryDegradesWithinBudget(t *testing.T) {
release := make(chan struct{})
defer close(release)
var calls int32
lookup := func(sid string) (string, string, error) {
atomic.AddInt32(&calls, 1)
<-release
return "", "", errors.New("released")
}
slots := make(chan struct{}, 1)
const budget = 150 * time.Millisecond
for scan := 0; scan < 3; scan++ {
names := newSIDNames(lookup, budget, slots)
start := time.Now()
for i := 0; i < 30; i++ {
sid := fmt.Sprintf("S-1-5-21-1000000001-1000000002-1000000003-%d", 2000+i)
if got := winAccountLabel(sid, names); got != sid {
t.Fatalf("scan %d: unresolved SID shown as %q", scan, got)
}
o := classifyWinOwner(winOwner{SID: sid, Domain: winAccountDomain(sid, names),
Computer: "DESKTOP-TEST", Groups: map[string]bool{}, GroupsKnown: true})
if o.Class != ownerUnknown {
t.Fatalf("scan %d: class %q for an account whose domain could not be read", scan, o.Class)
}
}
if took := time.Since(start); took > budget+time.Second {
t.Fatalf("scan %d spent %v on lookups, budget %v", scan, took, budget)
}
}
if n := atomic.LoadInt32(&calls); n != 1 {
t.Fatalf("%d lookups started while the first was still blocked", n)
}
}
// A slow but answering lookup is used, and only what it took counts against
// the budget.
func TestSIDNamesSlowLookupWithinBudgetIsUsed(t *testing.T) {
lookup := func(sid string) (string, string, error) {
time.Sleep(20 * time.Millisecond)
return "carol", "EXAMPLECORP", nil
}
names := newSIDNames(lookup, 2*time.Second, make(chan struct{}, 1))
for i := 0; i < 5; i++ {
sid := fmt.Sprintf("S-1-5-21-1000000001-1000000002-1000000003-%d", 3000+i)
if d := winAccountDomain(sid, names); d != "EXAMPLECORP" {
t.Fatalf("lookup %d within budget came back %q", i, d)
}
}
}
+8
View File
@@ -249,6 +249,13 @@ func installSystemService() error {
return fmt.Errorf("the %s service is registered but did not start: %w", winServiceName, err)
}
fmt.Printf("System service installed: %s, running %s as LocalSystem.\n", winServiceName, self)
// Audit rows go to the Application event log under their own source as
// well as to their files (auditlog.go). A failure costs that copy only.
if err := registerAuditEventSource(); err != nil {
fmt.Printf(" Audit rows will not reach the event log (%v); they are still written to their files.\n", err)
} else {
fmt.Printf(" Every audit row is also written to the Application event log, source %s.\n", auditEventSource)
}
fmt.Println(" It starts at boot, before anyone signs in, and restarts five seconds after any failure.")
// The person's tray, at every sign-in (tray.go trayFor). A failure here
// leaves the service serving; approvals still work from this terminal.
@@ -294,6 +301,7 @@ func uninstallSystemService() error {
return fmt.Errorf("cannot remove the %s service: %w", winServiceName, err)
}
removeTrayLogonEntry()
removeAuditEventSource()
fmt.Printf("System service removed: %s is no longer registered.\n", winServiceName)
fmt.Printf(" The bastion's state under %s is left in place.\n", windowsSystemAppData(os.Getenv("SystemRoot")))
return nil
+1 -1
View File
@@ -1 +1 @@
0.53.2
0.54.1
+11 -10
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
# Check that every test the README's security table cites exists.
# Check that every test the security claims table cites exists.
#
# deploy/check-claims.sh [repo root] (default: the repository this sits in)
#
# The README's "How each claim is tested" table names, for each security claim,
# a test in client-go as `file_test.go`: `TestOrFuzzName`. A row is only worth
# The "How each claim is tested" table in docs/claims.md names, for each
# security claim, a test in client-go as `file_test.go`: `TestOrFuzzName`. A row is only worth
# something while that function exists: a renamed or deleted test would leave
# the claim looking covered when nothing checks it. This finds every such
# citation in the table and confirms `func <name>(` is defined in the cited
@@ -22,17 +22,18 @@ set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
ROOT="${1:-$HERE}"
cd "$ROOT"
[ -f README.md ] && [ -d client-go ] || { echo "not a repository root: $ROOT" >&2; exit 2; }
[ -f docs/claims.md ] && [ -d client-go ] || { echo "not a repository root (no docs/claims.md or client-go): $ROOT" >&2; exit 2; }
python3 - <<'PY'
import re, sys
text = open("README.md", encoding="utf-8").read()
start = text.find("### How each claim is tested")
if start < 0:
print("the README has no 'How each claim is tested' section", file=sys.stderr)
text = open("docs/claims.md", encoding="utf-8").read()
head = re.search(r"^#{1,6} How each claim is tested[ \t]*$", text, re.M)
if head is None:
print("docs/claims.md has no 'How each claim is tested' heading", file=sys.stderr)
sys.exit(1)
end = text.find("\n## ", start)
table = text[start:end if end >= 0 else len(text)]
# The table runs from that heading to the next heading, or to the end of the file.
nxt = re.search(r"^#{1,6} ", text[head.end():], re.M)
table = text[head.end():head.end() + nxt.start() if nxt else len(text)]
cite = re.compile(r"`([\w./-]+_test\.go)`: `(\w+)`")
rows, unreadable = [], []
+70 -14
View File
@@ -1,9 +1,10 @@
#!/bin/sh
# privatae-bastion installer (macOS / Linux). Installs the binary AND — on Linux as root —
# the always-on systemd service, and with --networked on macOS a launchd daemon
# running as root, so onboarding a node is two commands:
# privatae-bastion installer (macOS / Linux). Run as root (a server), it installs the
# NETWORKED bastion as a system service (systemd on Linux, launchd on macOS), and the
# first host paired anchors it, so onboarding a server is two commands:
# curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh
# sudo -H privatae-bastion pair <token>
# Run as your own user (a desktop), it installs the LOCAL bastion for that user.
#
set -e
# dl.privatae.ai, not privatae.ai/bastion: the download host is served from each
@@ -20,17 +21,57 @@ arch=$(uname -m); case "$arch" in x86_64|amd64) arch=amd64;; aarch64|arm64) arch
if [ "$(id -u)" = 0 ]; then bindir=/usr/local/bin; root=1; else bindir="${HOME}/.local/bin"; root=0; mkdir -p "$bindir"; fi
bin="$bindir/privatae-bastion"
# --networked: install the NETWORKED bastion — a root-owned
# service on a machine several people share, anchored to one host that approves
# every further pairing. Everything else is the LOCAL bastion: one host, and on a
# home machine it runs as the user. Usage: curl ... | sudo sh -s -- --networked
# As root the default is the NETWORKED bastion: a root-owned service, anchored to
# the host it is first paired with, so every terminal and every write waits for the
# person's approval and every later host for the anchor's. A server left in local
# mode would serve its host with no approval at all. --local keeps a root install
# local (one host, the pairing's caps are the authorization); run as your own user
# it is always local. --networked is accepted and is the root default.
networked=0
local=0
for a in "$@"; do
case "$a" in
--networked) networked=1 ;;
--local) local=1 ;;
*) echo "unknown option: $a" >&2; exit 2 ;;
esac
done
if [ "$networked" = 1 ] && [ "$local" = 1 ]; then
echo "ERROR: --networked and --local contradict each other; pass one" >&2; exit 2
fi
asked_networked=$networked
# paired_local DIR...: 0 when one of these state folders holds a pairing and
# its mode is not networked (no mode.json reads as local, mode.go boxMode).
# The service's own folder goes first.
paired_local() {
for d in "$@"; do
[ -d "$d" ] || continue
if [ -f "$d/mode.json" ] && grep -q '"networked"' "$d/mode.json"; then return 1; fi
[ -f "$d/config.json" ] && return 0
for f in "$d"/pairings/*.json; do [ -f "$f" ] && return 0; done
done
return 1
}
if [ "$root" = 1 ] && [ "$local" != 1 ]; then
case "$(uname -s)" in
Linux) if command -v systemctl >/dev/null 2>&1; then networked=1; fi ;;
Darwin) networked=1 ;;
esac
fi
# A reinstall (an upgrade) of a machine already paired in local mode keeps
# local mode unless --networked is passed. Switched by default, its pairing
# would sit on an unanchored networked machine, which refuses every op until
# claim-anchor is run, and `pair` would accept a second host.
kept_local=0
if [ "$networked" = 1 ] && [ "$asked_networked" != 1 ]; then
case "$(uname -s)" in
Darwin) state_dirs="/var/root/Library/Application Support/privatae-bastion|${HOME}/Library/Application Support/privatae-bastion" ;;
*) state_dirs="/root/.config/privatae-bastion|${XDG_CONFIG_HOME:-$HOME/.config}/privatae-bastion" ;;
esac
if paired_local "${state_dirs%%|*}" "${state_dirs#*|}"; then networked=0; kept_local=1; fi
fi
if [ "$networked" = 1 ] && [ "$root" != 1 ]; then
echo "ERROR: --networked needs root — a networked bastion is a root-owned service (run with sudo)" >&2; exit 1
fi
@@ -159,10 +200,19 @@ UNIT
echo "Service installed + enabled (it restarts until you pair, then connects automatically)."
echo ""
if [ "$networked" = 1 ]; then
echo "Networked bastion. Anchor it to YOUR host first (root, one step):"
echo "Networked bastion. Pair it with YOUR host; the first host paired becomes its anchor:"
echo " sudo -H privatae-bastion pair <token>"
echo "Then enrol your second factor, so you can approve here:"
echo " sudo -H privatae-bastion stepup-enroll"
echo "Every terminal and write waits for your approval, and every later host for your host's."
echo "(Already paired before this install? Anchor with: sudo -H privatae-bastion claim-anchor)"
elif [ "$kept_local" = 1 ]; then
echo "This machine is already paired as a LOCAL bastion, and reinstalling keeps it local."
echo "To make it a networked server, anchored to the host it serves:"
echo " sudo -H privatae-bastion set-mode networked"
echo " sudo -H privatae-bastion claim-anchor"
echo " (add --host <name> if this machine already serves several Hosts)"
echo "Every host paired after that waits, pending, until your host approves it."
echo " sudo -H privatae-bastion stepup-enroll"
echo " sudo systemctl restart privatae-bastion"
else
echo "Now pair: sudo -H privatae-bastion pair <token>"
fi
@@ -175,18 +225,24 @@ elif [ "$darwin_service" = 1 ]; then
"$bin" service install || die "could not register the system service"
echo ""
echo "Networked bastion, running as a system service (launchd, as root)."
echo "Anchor it to YOUR host first (root, one step):"
echo "Pair it with YOUR host; the first host paired becomes its anchor:"
# The full path, not the name on PATH: /usr/local/bin may belong to the
# signed-in user, who could point the link at their own program, and this
# line is run as root.
echo " sudo -H '$bin' claim-anchor"
echo " (add --host <name> if this machine already serves several Hosts)"
echo "Every host paired after that waits, pending, until your host approves it."
echo " sudo -H '$bin' pair <token>"
echo "Then enrol your second factor, so you can approve here:"
echo " sudo -H '$bin' stepup-enroll"
echo "Every terminal and write waits for your approval, and every later host for your host's."
echo "(Already paired before this install? Anchor with: sudo -H '$bin' claim-anchor)"
elif [ "$networked" = 1 ]; then
die "--networked needs a system service manager: systemd on Linux, launchd on macOS"
else
case ":$PATH:" in *":$bindir:"*) ;; *) echo "Add to PATH: export PATH=\"\$PATH:$bindir\"" ;; esac
echo ""
if [ "$kept_local" = 1 ]; then
echo "This machine is already paired as a LOCAL bastion, and reinstalling keeps it local."
echo "To make it a networked server instead, run the installer again with --networked."
fi
echo "Now pair: privatae-bastion pair <token>"
echo "Run it: privatae-bastion run (Linux non-root: 'loginctl enable-linger \$USER' + a --user unit for boot persistence)"
fi
+32
View File
@@ -0,0 +1,32 @@
# Bastion reference
The front page is the [README](../README.md). These pages hold the reference
text, one topic per file.
## What the bastion does
Everything below is in `client-go/` and compiled into the one binary.
- [lanes.md](lanes.md): the scoped WireGuard tunnel, the git lane, the worker lanes, and the session lane with terminals and root commands.
- [context.md](context.md): your files turned into text on this machine, and the DCN stores a host reads through signed grants.
- [models.md](models.md): the model store ops (`model:list`, `model:pull`, `model:drop`) and when the ollama forward is filtered.
- [approvals.md](approvals.md): approvals with a local second factor, writes on an anchored machine, and the approvals window.
- [ops.md](ops.md): programs a host wrote, run in a sandbox first, and action ops for ship steps.
- [ship-plans.md](ship-plans.md): one signed plan for a ship across several machines, its receipts and its undo.
- [service-slot.md](service-slot.md): a server a host built, running here with no network.
- [observe.md](observe.md): what is running on this machine, as read-only observe ops.
- [updates.md](updates.md): the heartbeat and the signed self-update.
- [memory-mirror.md](memory-mirror.md): a streaming replica of a host's database on your hardware.
- [backups.md](backups.md): this bastion's backup key, restore requests, backups kept here, and restoring a lost host.
- [audit.md](audit.md): the audit files on this machine and the same rows in its system log.
- [console.md](console.md): the local console.
- [host-on-this-machine.md](host-on-this-machine.md): pairing with a Host on the same machine over unix sockets, or in a VM over forwarded ports.
## Using and checking it
- [install.md](install.md): how the system service runs on Linux, macOS and Windows, and where release binaries and the signed manifest are served from.
- [commands.md](commands.md): every command and what it does.
- [security-model.md](security-model.md): the security model, every point in full.
- [claims.md](claims.md): for each security claim, the test that fails if it stops being true, or a note that there is none.
- [build.md](build.md): cross-compiling, running the tests, and running the fuzz targets.
- [layout.md](layout.md): the repository layout, and the names that keep the word freeagent.
+49
View File
@@ -0,0 +1,49 @@
# Approvals
## Approvals with a local second factor
`stepup-enroll` enrols an authenticator on this machine. From then on a
request to install a folder grant, apply a move plan, pull a model, or open a
root session shows up under `privatae-bastion approvals` in plain words, and
you `approve` or `deny` it here with a code the host never handles. Without an
enrolled factor, folder grants and move plans are gated by the signed grant
alone and the host verifies its own factor; model pulls and root sessions are
refused outright.
## Writes on an anchored machine wait for your approval
On a machine anchored to a host, every management op is either a read or a
write. Reads answer on the anchor host's word. A write (a model drop, a
terminal, a job, a self-update, stopping a process, running a local tool to
extract a document)
runs only on an approval that this machine verifies and spends: bound to that
op, this machine and a digest of exactly its arguments, single use. You give
it either with the second factor enrolled here or from your **approval
device**: `device-init` makes one machine of yours the device, and
`pin-device <key>` on each machine you govern accepts what it signs. The
device shows each ask in the machine's own words and signs only that; a root
terminal still needs the code at the machine itself. Six kinds of write
take no fresh approval: standing work under a place you set up at the machine
(the nightly backup fetch, the host's context store), a run under op terms you
already approved (see [Ops](ops.md)), a root command under the root-command grant
you gave at this machine with the code (see [Session lane](lanes.md#session-lane)), the anchor
host's signed record of who else
may pair here, taking something away (a revoke), and a self-update to a newer
version we signed (see [Heartbeat and self-update](updates.md)). A local, unanchored machine is unchanged: the pairing is
the authorization on your own computer.
The lane watches itself: five refused writes from one pairing within ten
minutes close that pairing's write lanes on this machine for ten minutes,
doubling on repeat. Reads and revokes are never held. `quarantine clear
<host>` lifts it.
## Approvals from the taskbar
On the machine that is your approval device (`device-init`), every ask a host
files for one of your machines raises a notification on Windows. Clicking it, or the
tray's approvals line, opens the approvals window: each ask in plain words,
with Approve and Deny, and "Approve all shown" when there are several. Approve
signs with this device's key exactly as `device-approve` does: one run, on the
machine the ask names, within the hour. "Approve all" signs only the asks the
window showed you, never one that arrived after. The same window lists the
requests waiting for a code on this machine itself. The window does not
redraw by itself while you read it: when something new arrives it says so and
waits for you to show it. Off Windows the window is a page of the local
console (`privatae-bastion ui`, then Approvals in the header).
+29
View File
@@ -0,0 +1,29 @@
# Audit: on this machine and in its system log
Every decision the bastion makes is written as one JSON line to an audit
file on this machine: what was asked of the management lane, allowed or
refused (`mgmt_audit.jsonl`), pairing changes (`pairing_audit.jsonl`), and
each pairing's terminals, root commands, store ops and op runs (that
pairing's `audit.jsonl`). A host can fetch these files over the tunnel but
cannot change or remove them.
Each line is also written to the machine's own system log, so whatever
already collects that log can take it to a store no host can write:
| System | Where | Name |
|---|---|---|
| Linux | syslog, facility `auth`; on systemd, the journal | `privatae-bastion-audit` (`journalctl -t privatae-bastion-audit`) |
| macOS | syslog | `privatae-bastion-audit` |
| Windows | Application event log, event id 1 | source `PrivataeBastionAudit`, registered when the system service is installed |
The system-log line is the file's line with two fields added: `audit`, which
file it went to, and `pairing`, the pairing it belongs to (empty for a
machine-wide file). One field is left out: a root command's text, which can
hold a password typed on a command line and stays in the file only. The line
keeps the command's `command_sha256` and `command_bytes`, so it can still be
matched to the file and the transcript.
The file stays the record. Lines go to the system log through a queue, so a
system log that is missing, slow or stuck never holds up the op being
audited; if the queue fills, lines are dropped, and the next line queued
carries `system_log_dropped`, how many were lost just before it.
+133
View File
@@ -0,0 +1,133 @@
# Backups
## Backups: this bastion's key, and a host's restore
Your host encrypts each of its backups before the backup leaves it, to the
host's recovery key (which you keep) and to every bastion key you registered
with it. The platform holds none of those keys. This bastion can hold one:
`privatae-bastion backup key init` makes it here and prints the three fields
to register on your host (kind `bastion`, a label, and the public key). The
key is a standard age key file, 0600, in the bastion's own state folder; only
its public half ever leaves this machine. `backup key init --replace` makes a
new one and keeps the old one beside it, because the backups made before
still need it.
Each backup also carries its own key, sealed in a small sidecar file next to
it to the same keys. When a host restores a backup, it asks this
bastion for that one backup's key (`backup_reseal`). Nothing is opened until
you approve the request here: `privatae-bastion backup requests` shows which
backup and which host, and `backup approve <id>` shows the pairing the
request came in on and its host number (this machine's own record, from
pairing), what the host says, and what the sidecar itself says, opened here
(the host number, when it was made, which backup). A sidecar of another
host's backup is refused, whatever the request calls itself: a host is given
only its own backups' keys. Restoring a lost host's backups onto the host
that replaces it is yours to do here, with `backup restore-plan`. Approving
(with the code from your authenticator, when one is enrolled for that host
here) checks the sidecar is the backup the host asked for, of a backup kept
in platform storage (`b/`) or staged there for a copy kept only here (`s/`),
and seals the same record to that host's own key alone. The
host then fetches and decrypts that backup itself. This bastion's key never
leaves this machine, and the host gets a key that opens that one backup and
no other. A host can hold at most 16 requests waiting here, or 512 KiB of them;
answering one frees its room, and a request lapses after a day. The daemon
and `backup approve`/`deny` lock the request file while they change it, and a
request file that cannot be read is set aside beside it and named by `backup
requests`, not overwritten.
When your host keeps a copy of its backups on this machine, they arrive
through `backup_fetch`, each with its sidecar, named `<stamp>.tar.zst.age` and
`<stamp>.key.age` (the stamp is the backup's UTC time, `YYYYMMDDTHHMMSSZ`).
Files named that way always land at the top of this host's backup folder,
whatever folder the host names. This bastion keeps a record, in its own state
folder, of every backup file it fetched: when it arrived, its size and its
SHA-256. After each one arrives, the folder is thinned among the files in that
record only, and only while each is still the file that arrived: the newest 7,
and the newest of each of the last 14 days, 8 weeks and 12 months, judged by
when each file arrived here, not by the name the host gave it. Files you put
there, files from before this version and anything else in the folder are
never removed. A file named `.keep-all` in the folder turns thinning off.
Each host's backup folder must be its own. A backup op is refused while the
folder is the same as another paired host's, sits inside it or contains it;
while it is or contains this bastion's configuration folder, backup keys or
backup records; or while it sits inside the configuration folder anywhere
but under its `backups` folder (the default, `backups/<host>`, is there). Set
a separate `backup_root` for each host to use one. Every host's backup folder, wherever it
is, is part of this machine's own record for ops: no op may name it as an
effect, and the sandbox hides it. One on a network mount that stops
answering is left unmasked for a run rather than stopping it, and no op may
name it all the same.
A host can list the backups held here for it (`backup_list`): each backup's
stamp, the sizes of its data file and sidecar, and their SHA-256 digests. A
listing never reads a file to hash it: a digest this bastion does not know yet
is left empty and read in the background, and appears on a later listing. A
file whose read does not return within two minutes (a network mount that
stopped answering) holds up no other folder: the background reading moves on,
reads nothing more from that folder until that read returns, and keeps its
digest if it does. The two minutes count from the last read of the file that
returned, not from the start of the file: a large backup on a slow disk whose
reads keep returning is read to the end however long it takes, and never
counts as stuck. To
restore a backup held only here, the host asks this bastion to send one of
those files to a short-lived upload address (`backup_push`): only a file named
like a backup, directly inside this host's backup folder or a folder you
shared with it, not through a link that leaves that folder; only the file
whose SHA-256 the host names, checked before anything is sent; only over https
to a public address that is not this machine's own, with the header the address was signed with (the
platform signs `If-None-Match: *`, so storage never writes over an object that
is there); that header with that value is the only one a host may hand over,
and any other header name or value is refused before a job starts, as is a
start with no headers at all (the platform always hands that one over). A push
whose file stops answering (a stat, the open or one read of it not returning
within two minutes, as on a network mount that died) fails with a sentence and
its upload is stopped, rather than staying pending. The read it was stuck in
cannot be stopped and keeps a thread and the open file until the mount
answers. Pushes and the background reading for digests keep one record of
their reads, so a folder holds at most one stuck push read and one stuck
digest read however often a push is started there, and the whole bastion at
most eight reads of both together: a start in a folder with a read of either
kind that has not returned is refused with a sentence, as is any start while
eight reads have not returned, until one does; the reading for digests skips
a folder whose push read has not returned, and reads nothing while eight are
in flight. A push that waits for its turn to read (behind a stuck read) is
not blamed for it: after two minutes of waiting it fails with a sentence
naming what it waited on, its folder's stuck read or the cap of eight. A job's status answers only the host that started it,
and no reply quotes the upload address's signature.
## Restoring a lost host
If a host is lost, a new one is made to replace it, and it restores from a
backup. The new host needs two things: that backup's key, and the backup file.
The key: the new host shows its own key under Settings > Backup > Restore.
Either this bastion reseals the backup's key to it — through a restore request
from the new host once this bastion is paired with it, when the new host has
the lost one's host number (a request for another host number's backup is
refused), or, for any host, here with
`privatae-bastion backup restore-plan --dir <folder> --stamp <stamp> --to
<the new host's key>` — or the owner does it in their browser with the lost
host's recovery key, which is also a recipient of every sidecar. Either key
works; the new host never receives either of them.
The file:
- from platform storage or your own S3 bucket, a new host finds the lost
host's backups only if it has the same host number as the lost one;
- from this machine: pair this bastion with the new host, then share the lost
host's folder with it, here, with
`privatae-bastion backup share --with <new host> --from <lost host>` (or
`--dir <folder>` for any folder). The new host then sees those backups in
its listing as `shared/<name>` and asks this bastion to send the one it
needs. It cannot write to that folder. `backup unshare` takes it back, and
unpairing or revoking the new host takes back every folder shared with it.
This bastion does not bring up the new host. Running one on hardware you own
needs infrastructure that is not part of this program, and not built yet:
- a DMZ cluster on your hardware: a Kubernetes cluster with the network
policy, admission and storage the host's pod expects;
- the operator that creates and runs a host's pod on that cluster;
- the host's container images, from a registry that cluster can pull from;
- the certificate authority that issues the host its identity, and the
services a host reaches when it starts, or replacements for them;
- a place the new host can fetch the backup from, as above.
+31
View File
@@ -0,0 +1,31 @@
# Build, test and fuzz
Cloning and building are in the [README](../README.md#build-from-source).
Cross-compile the way the release pipeline does:
```sh
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags "-X main.clientVersion=dev" -o privatae-bastion-darwin-arm64 .
```
Run the tests:
```sh
go test ./...
```
The suite covers all three platforms' parsing and judgement logic from a single
`go test` on any OS, because every platform-specific decision is kept in files
with no build tag and only the enumerators are tagged.
The walls a host's input has to get through also have fuzz targets
(`client-go/fuzz_*_test.go`): the path sandbox for store reads and move
plans, an op's declared effects and path params, service slot names, which
capability family lets a pairing run an op, and every signed record and
approval the bastion verifies. Each target states the property the wall
exists for. `go test` runs their seed inputs; to let the fuzzer search for an
input that breaks one, run each target for a bounded time:
```sh
deploy/fuzz.sh # 20 seconds per target; deploy/fuzz.sh 300 for longer
```
+242
View File
@@ -0,0 +1,242 @@
# How each claim is tested
The claims are the points of the [security model](security-model.md).
Each row names a test in `client-go/` that fails if the claim it sits under
stops being true. Where a claim, or part of one, has no such test, the row
says so. Those parts are enforced only by the code as written.
| Claim | Test (file: function) | What it asserts |
|---|---|---|
| The bastion dials out | `config_test.go`: `TestNextListenAddr` | Each pairing's control port is allocated on `127.0.0.1`, from 7878 up. |
| | `mirror_test.go`: `TestMirrorPeerAllowed` | The memory-mirror forwarder accepts only loopback and the replica's own subnet; a LAN address, the default container bridge or an unknown subnet is refused. |
| | `claims_static_test.go`: `TestEveryListenerThisPackageOpensIsInTheTable` | Every listener this package opens outside the tunnel is in a table with its number of call sites; the console, the context page, the mirror push bridge and the action-op probe bind loopback, and the mirror forwarder binds its gateway and loopback. The WireGuard device's own UDP socket is opened in the wireguard module, not this package, and is not in the table. |
| | `claims_static_test.go`: `TestListenAddressesAreOnlyEverAllocatedOnLoopback` | The control and git port addresses are set only by the loopback allocators, or kept from the pairing being replaced. |
| Inside the tunnel, only gated listeners | `anchor_test.go`: `TestInferenceLaneIsCapsGated` | A pairing without the `inference` family has its forward connections closed and the forwards left out of its heartbeat; granting the family opens them without a restart. |
| | `service_reach_test.go`: `TestReachGrantSyncAndTheForwardWall` | Installing a reach grant is refused without an approval, for another host, or for another machine. `serviceReachAllowed`, the reach check a slot port runs, then admits the granted host to the granted server only; another server, another host or a revoked grant is refused. The check is called directly, not through the slot listener. |
| | `claims_unix_test.go`: `TestEachInferenceForwardAdmitsOnlyThePairingThatHoldsInference` | Through each forward's accept loop on an in-memory tunnel: a pairing without `inference` never reaches the upstream, and granting the family lets the next connection through, with no restart. |
| | `claims_unix_test.go`: `TestTheServiceSlotAdmitsOnlyWithTheFamilyAndAReachGrant` | Through the slot listener: a connection is refused with a reach grant but no `services` family, and with the family but no reach grant for that server; with both it reaches the server's socket. |
| | `claims_static_test.go`: `TestOnlyTheGatedForwardsListenInsideTheTunnel` | Every listener on the tunnel's network stack is one of the four gated forwards, each at one call site; only they are handed the stack, and it is otherwise only dialled out of. |
| | not covered by a test | The reach check's own comparison of a stored grant's machine (`service_reach.go`, `serviceReachAllowed`). |
| Scoped tunnel | `claims_tunnel_test.go`: `TestTheTunnelReachesOnlyThePairedHost` | A WireGuard device configured the way a pairing configures it holds one peer, whose only allowed address is the host's /128, before and after an endpoint rotation. |
| | `claims_tunnel_test.go`: `TestATunnelAddressThatIsNotOneHostIsRefused` | A tunnel address that is a prefix, empty, zoned, unspecified, multicast or not an address is refused at tunnel start and at a rotation, and the device is left with no peer. |
| | `claims_static_test.go`: `TestARotationReachesTheDeviceOnlyThroughSwapPeer` | A rotation, a failover and a probe change the live peer through `swapPeer`, the function the two tests above drive: the variables they call it through are declared with it and never assigned or addressed outside tests. |
| | `claims_unix_test.go`: `TestAPairingResponseWithAWideTunnelAddressSavesNothing` | A pairing response that offers a prefix is refused and leaves no file in the config dir; one that offers a single address pairs. |
| | `claims_unix_test.go`: `TestRunPairingRefusesAWideTunnelAddress` | A pairing file that holds a prefix brings no tunnel up. |
| | `fuzz_tunnel_test.go`: `FuzzATunnelAddressIsOneHostOrRefused` | Whatever the input, the tunnel address check refuses it or returns exactly the one address it was given. |
| A Host on this machine gets the same walls | `local_host_linux_test.go`: `TestAUnixRedeemURLCannotPointAtATCPAddress` | Every network spelling of a redeem URL (a host and port, `localhost`, user info, another scheme, a relative, unclean or query-carrying path) is refused before the token is read; a TCP listener at the named address gets no connection and no file is written. |
| | `local_host_linux_test.go`: `TestAUnixPairingPinsTheHostsCA` | A `--replace` whose answer brings another CA is refused and changes nothing; an operator certificate the CA did not sign, a lane socket or runs directory outside the shared directory, or an answer that is not transport `unix` saves nothing; a platform `--replace` of the pairing is refused before the platform is called. |
| | `local_host_linux_test.go`: `TestTheUnixLaneRefusesAPeerWithoutThePinnedCA` | A lane server whose certificate another CA signed, or that names another host, is dialled and receives no request. |
| | `local_host_linux_test.go`: `TestTheUnixLaneCarriesTheBearerAndTheSameProtocol` | A pairing with no bearer does not start and dials nothing. With one, the heartbeat and the first frame of every worker carry it; the pools are the tunnel's (two parked harness workers) and a parked worker serves a harness job over the socket. |
| | `local_host_linux_test.go`: `TestTheCoderGrantRuleAppliesToAUnixPairing` | On an anchored machine a run-up over the unix lane without the standing coder grant is refused at the gate; with the approved grant it passes. |
| | `local_host_linux_test.go`: `TestARunSocketIsTheOwningPairingsOnly` | The owner's run is a 0660 socket in the owner's runs directory, given to the `privatae-host` group, and reaches the run's control socket; another pairing's directory never gets it, another pairing cannot open it, and once the run is not the pairing's a connection gets nothing and the socket goes. |
| | `local_host_linux_test.go`: `TestARunSocketGoesAtRunDownAndAtTheWatchdogsStop` | The socket is removed at run-down and at the watchdog's stop and does not come back; with no `privatae-host` group it is root's and the reason is logged. |
| | `local_host_linux_test.go`: `TestARunsDirectoryTheHostCanChangeGetsNoSocket` | A runs directory the Host owns, a group-writable one, one under a directory the Host owns, or one in a shared directory that is not sticky gets no socket, and a link swapped in for the socket between bind and chmod changes nothing; the design's layout (1770 shared directory, `runs/` made by the bastion) gets its socket. |
| | `local_host_linux_test.go`: `TestARunSocketOpensOnlyForALiveRunOfThePairing` | No socket is made for a run with no record, another pairing's run or a run the watchdog stopped; another pairing reusing the run_id gets its own. |
| | `local_host_linux_test.go`: `TestAUnixPairingsRunStillCannotReachThisMachine` | A run a unix pairing owns gets 403 from its egress proxy for loopback, this machine's public address and a name for it. |
| | `local_host_linux_test.go`: `TestTheOwnersImagePrefix` | Run-up takes only `<prefix>harness-<engine>@sha256:<digest>` under the owner's prefix, and pull credentials only for its registry. |
| | `local_host_linux_test.go`: `TestALocalImageRunsByItsIDWithoutAPull` | A local image runs by its ID with no pull only when the image with that ID carries a tag of the named repository. |
| No MCP endpoint | `claims_static_test.go`: `TestNoRouteOpOrDependencySpeaksMCP` | Every HTTP route on every listener is in a fixed table and none names MCP; no management op, import or module names MCP; no string in the code is a URL or route with an `/mcp` path, or names JSON-RPC. The agent clients' tool-server config files are named only in `mcpconfig.go`, which reads them, and nothing in the config reader imports or can reach a dialler, an HTTP client, a websocket or a process start. |
| | `claims_static_test.go`: `TestMCPReaderCheckCatchesADialAndAStrayBasename` | The check above reports a config reader that starts a process, imports `net/http` or reaches a dialler through another function, a Windows reader that starts a process, opens a socket, loads a DLL by name or opens a remote registry, and a second file that names a client's config file, the servers key, an `/mcp` URL or an `/mcp` route. |
| Grants are verified here | `anchor_test.go`: `TestPairingGrantSyncWalls` | A pairing grant signed by another key, for another machine, naming another anchor, or expired is refused, and the pairing stays pending. |
| | `fuzz_grants_test.go`: `FuzzAChangedSignedByteNeverVerifies` | For every kind of signed record, changing any byte of the record or its signature makes it fail to verify. |
| | `session_test.go`: `TestSessionPinsTheHostKeyWithoutAStoreGrant` | The first key offered is pinned; a different key later is refused and the pin is kept. |
| | `anchor_test.go`: `TestAnchorVerifyKeyPinsFromDCNAndRefusesRepin` | The anchor's verify key cannot be re-pinned to a different key. |
| | `session_test.go`: `TestSessionRefusedWithoutGrantIs126` | An op the grant both allows and denies, or a grant that denies `*`, is refused. |
| | `localdeny_test.go`: `TestTheLocalSwitchCanOnlyEverRemoveReach` | Switching a capability off on this machine never turns a refusal into an allow. |
| Fixed argv | `observe_ops_test.go`: `TestObserveArgsNeverReachArgv` | Shell metacharacters and flags sent as arguments to every observe op are refused or never reach a command line, and each op runs only its pinned argv. Linux only. |
| | `contextextract_test.go`: `TestCtxExtractRefusesAnUnknownTool` | Text extraction runs only tool names from its fixed table. |
| | `claims_unix_test.go`: `TestTheAllowlistedOpIgnoresTheCallersArguments` | `uptime`, sent through the management lane with hostile arguments, runs its program with none. |
| | `claims_static_test.go`: `TestTheAllowlistedOpRunsItsTableArgvOnly` | The allowlisted table is exactly `uptime` with no arguments, and the one command the lane starts is built from the table's argv alone. |
| | `claims_static_test.go`: `TestFileHandlersStartNoShell` | Nothing a context, store or backup handler can reach runs a shell; the one process any of them can start is the local search agent's Python, which `dcn_grant_sync` brings up. |
| Approval on the machine, once enrolled | `stepup_test.go`: `TestStepupVerifyBurnAndApprovals` | With no factor enrolled the gate passes; once enrolled, a code is used once, and an approval passes only for the exact terms it was issued for, and only once. |
| | `modelswap_test.go`: `TestAnApprovalIsSingleUse` | A spent approval does not fund a second model download. |
| | `modelswap_test.go`: `TestUnenrolledMachineCannotHaveAModelPulled` | With no factor enrolled, a model pull is refused. |
| | `session_test.go`: `TestSessionRootRefusedWithoutStepup` | A root session with no approval is refused and leaves no transcript. |
| | `session_test.go`: `TestSessionRootApprovalBindsToSession` | The purpose a root approval is bound to differs between two session ids on the same machine. Compared as strings, not through the terminal handler. |
| | not covered by a test | That a root approval's purpose also names the machine (`session.go`, `purposeForSessionRoot`). |
| On an anchored machine, a write needs an approval | `hostops_test.go`: `TestAnAnchoredBoxRefusesAWriteWithoutApprovalAndRunsItOnceWithOne` | With no factor and no device a write is refused; with a device, a write without an approval is refused, an approved one runs once, and a replay is refused. |
| | `hostops_test.go`: `TestEveryBrokenDeviceApprovalIsRefused` | A device approval for other arguments, with a changed signature, expired, for two uses or with no nonce is refused. |
| | `hostops_test.go`: `TestAConsoleApprovalRunsAWriteOnce` | An approval given with the factor enrolled here runs the write once. |
| | `hostops_test.go`: `TestEveryOpIsReadOrWrite` | Every op on the management lane is classified as a read or a write, and a new op counts as a write. |
| | `hostops_test.go`: `TestRepeatedRefusedWritesQuarantineThePairing` | After repeated refused writes the pairing's writes are held, even approved ones; reads and revokes still pass. |
| | `claims_update_forward_test.go`: `TestANewerSignedReleaseNeedsNoApproval` | A self-update to a newer version reaches the signed manifest with no approval; a member host's directive and the local switch still refuse it. |
| | `claims_update_forward_test.go`: `TestTheBoxTellsTheHostAForwardUpdateNeedsNoApproval` | The enforcing report says so, so the host does not ask anyone for a forward update; a build that cannot read its own version says it does not. |
| | `claims_update_forward_test.go`: `TestAnOlderOrUncomparableVersionStillNeedsAnApproval` | A self-update to an older version, the running version spelled differently, a suffixed version, or any version on a build that cannot compare its own is refused without an approval, and the host is told why; with one, a move to an older version proceeds. |
| Host-written programs run sandboxed first | `ops_test.go`: `TestOpThatWritesOutsideItsEffectsIsRefusedBeforeAnyoneIsAsked` | A dry run that writes outside its declared effects, links out of them, or writes to `/etc` is refused and lands nothing; inside the sandbox the network and `/proc` are closed. Skipped where the sandbox is unavailable. |
| | `ops_linux_test.go`: `TestOpDryRunShowsTheChangeAndApplyCommitsExactlyThat` | A dry run commits nothing; an apply must name the dry run's change list and carry an approval bound to that op, its parameters, the machine and that change list. Skipped where the sandbox is unavailable. |
| | `ops_test.go`: `TestOpTermsMutationsAreEachRefused` | Each altered field of signed op terms is refused. |
| | `ops_linux_test.go`: `TestOpSandboxShowsOnlyTheBaseAndWhatTheOpDeclared` | In a real sandbox, an op reads the file it declared and the system base; a file beside its own folder, a folder beside it and `/etc/shadow` read as empty; a declared path that has since become a link onto `/etc/ssh` refuses the run. Skipped where the sandbox cannot run. |
| | `ops_view_test.go`: `TestOpSensitivePathsAreRefusedAtUnderOrAboveAndThroughLinks` | A declared path at, under or above a credential store, as written or through a link, is refused; a malformed `sensitive_paths` refuses every op. |
| | `fuzz_paths_unix_test.go`: `FuzzAnOpEffectNeverCoversTheMachinesOwnRecord` | No accepted effect covers the bastion's own records or the system directories the sandbox masks. |
| An action op's program reaches only what it declared | `ops_action_linux_test.go`: `TestActionProgramReachesOnlyItsDestinations` | In a real unit, an apply reaches its declared destination by name; another port on that address, the declared port on another address, this machine's loopback and the internet are refused. A dry run reaches nothing, its declared destination included, and a name that resolves to the metadata service is refused. Skipped where action ops cannot run. |
| | `ops_action_linux_test.go`: `TestActionProgramSeesNothingItWasNotGiven` | In a real unit, the program runs as a fresh uid with no capabilities and no new privileges; `/root`, `/etc/shadow`, the docker and systemd sockets and an abstract socket outside it are out of reach; it reads its declared file and writes to `/tmp`, but not to the machine: not `/usr/local`, not a world-writable folder it declared as a read. Skipped where action ops cannot run. |
| | `ops_action_linux_test.go`: `TestActionCredentialIsHandedAndNothingIsLeft` | A sealed credential reaches the program and never its output; after the run no file holds it, the run's tmpfs is unmounted, its work folder and unit are gone, and no process of its uid is left, a detached one included. A dry run handed a credential is refused. Skipped where action ops cannot run. |
| | `ops_action_linux_test.go`: `TestActionBuilderSocketOnlyInApply` | The builder socket reaches the program in an apply through the socket's group, never in a dry run; a socket in root's group, or a machine with no builder, refuses an apply that builds. Skipped where action ops cannot run. |
| | `ops_action_linux_test.go`: `TestActionVerbCredentialNeverReachesTheRealProgram` | In a real unit, a credential a `tree.checkout` names never reaches the program, while the verb uses it and the program's own credential is handed and scrubbed from its output. Skipped where action ops cannot run. |
| An action op is never capped, and yields first | `ops_action_linux_test.go`: `TestActionMemoryIsNotCappedAndTheOpYieldsFirst` | In a real unit, a run holds 1500 MiB resident with nothing limiting it, and the kernel would kill it first (`oom_score_adj` 1000). When the machine's free memory falls to the floor, the op is stopped, with a refusal that names its peak. A machine already under the floor starts no op and spends no uid. Skipped where action ops cannot run or 10 GiB is not free. |
| | `ops_action_linux_test.go`: `TestActionYieldsWhenTheServicesWaitOnMemoryNotForItsOwnStalls` | Against a stand-in tree of pressures: neither the op's own slice waiting nor a pod group already waiting stops it; the services' slice rising to 12.5% during the run stops it within seconds; a group that stays at a level does not stop the next run; a group that spiked as the run began and fell back does not hide a later rise. Skipped where action ops cannot run. |
| | `ops_action_linux_test.go`: `TestActionProgramSeesNothingItWasNotGiven` | (also) The real unit's program sits in `privatae_ops.slice`, the slice the pressure reading leaves out. |
| | `ops_action_linux_test.go`: `TestTheMemoryWatchReportsOnlyAKillThatLanded` | A kill that finds no unit is not reported as the op stopped. |
| | `ops_action_linux_test.go`: `TestTheMemoryWatchKeepsTryingUntilTheKillLands` | With the machine short before the op's unit exists, the first kill finds nothing; the real unit that starts a second later is still stopped. Skipped where action ops cannot run. |
| | `ops_action_linux_test.go`: `TestMemoryFloorSitsAboveTheKubeletsEviction` | The floor reads the kubelet's eviction line from the k3s config, its drop-ins, the k3s unit's flags, the kubelet config k3s writes, and any kubelet config file or folder a flag names (one named and missing counts as unreadable): percentages, decimals, binary and decimal units, soft as well as hard, and a kubelet config file's form, never a comment. It sits 512 MiB above that line, or at 5% of memory, or at 1 GiB, whichever is largest. A value it cannot read raises the floor to 10% plus 512 MiB. |
| | `ops_action_linux_test.go`: `TestActionApplyOfAnotherChangeListRunsNothing` | An apply whose change list differs from the approved one is refused before the program starts. Skipped where action ops cannot run. |
| | `ops_action_test.go`: `TestActionManifestRefusesWhatIsNotDeclaredOrNotBounded` | An unknown verb or argument, a workload, container, unit or tree named by a parameter, an undeclared credential, verbs without root, a destination on loopback, link-local or a metadata service (also inside NAT64 or IPv4-mapped IPv6), the bastion's own unit, a tree on a credential store, a tree or container moved twice by one op, and more, are each refused when the op arrives. |
| | `ops_action_test.go`: `TestSealedCredentialsOpenOnlyForThisRunAndThisOp` | A credential sealed for another run, to another machine, tampered, expired, undeclared, missing or sent twice is refused. |
| Root verbs move only forward | `ops_verbs_linux_test.go`: `TestSetImageTouchesOnlyTheNamedContainerAndOnlyForward` | Against a stand-in kubectl: a digest with no receipt, a receipt from another repository, a container the workload lacks, a live image this machine never rolled (a receipt for it included), another image and a backward commit are each refused with nothing set; a forward roll sets exactly the named container and records it; an image already set still waits for its rollout, and fails when it does not finish. The ancestry answer is a stand-in here. |
| | `ops_verbs_linux_test.go`: `TestAncestryByFetchRefusesABackwardOrSidewaysCommit` | With real git, the ancestry check `k8s.set_image` makes accepts a forward commit or the same one, and refuses a backward, sideways or unknown commit. |
| | `ops_verbs_linux_test.go`: `TestTreeCheckoutMovesOnlyForward` | With real git: a tree moves forward to the exact commit, runs none of its own hooks, leaves untracked files and nested repositories alone, and refuses a backward or sideways commit, another origin, local changes and a config that could run a program. |
| | `ops_verbs_linux_test.go`: `TestTreeCheckoutRefusesATreeRootDoesNotControlAndKeepsIgnoredFiles` | A tree, `.git` or anything in it another user owns, or a git config anyone or another group can write, is refused before git runs; a move that would overwrite an ignored file the machine keeps is refused and the file is untouched. |
| | `ops_verbs_linux_test.go`: `TestTreeCheckoutNeverWritesIntoAFolderRootDoesNotControl` | A move that would write into a folder another user owns is refused and writes nothing; a move that leaves that folder alone goes ahead. |
| | `ops_action_test.go`: `TestReceiptsAreThisMachinesAndBindTheCommit` | A receipt this machine signed names the commit behind a digest and never makes it look rolled; the first receipt for a digest stands and a conflicting one is refused; a changed, relabelled or foreign receipt is refused. |
| A ship plan runs as your device signed it, and only there | `ops_plan_test.go`: `TestAMachineRefusesEveryPlanItShouldNot` | A machine refuses a plan whose line, params, commit, expiry or code flag was changed after signing; a line for another machine; a plan naming another key for this machine; a device it never pinned; a line whose input receipt is missing, foreign, from another plan or of a failed step; an expired plan; a line run twice; and a plan older than one it already ran. |
| | `ops_plan_test.go`: `TestTheDeviceSignsOnlyAPlanWhoseMachinesItPinned` | The device refuses a plan naming a machine it has not pinned, puts in its own pinned key for each machine whatever the pod sent, names every step in the words it shows, and signs each plan for a pipeline with a higher version. |
| | `ops_plan_test.go`: `TestARootPlanNeedsTheCodeOnTheDevice` | A plan with root work is refused without the code or with a wrong one, is signed with a right one and says so, and is never signed by "approve all shown". |
| | `ops_plan_test.go`: `TestARootLineNeedsTheCodeAndTheOpsOwnVerbs` | A root line from a plan that does not say the code was given is refused, whatever signed it; a line showing other verbs than its held op declares is refused. |
| | `ops_plan_test.go`: `TestAnUndoNeedsAFailedLaterLineOfThePlan` | An undo is refused for a step the plan does not let be undone, on an earlier step's failure or any success, on a later step whose apply (not check) failed, or for a step this machine never ran; it runs once. |
| | `ops_plan_linux_test.go`: `TestAnUndoLeavesWhatMovedSinceAlone` | An undo takes a workload back only from the state the step left it in; one that moved since is left alone. |
| | `ops_plan_linux_test.go`: `TestAnUndoRestartsTheUnitOnlyAfterPuttingTheStateBack` | An undo of a step that set an image and restarted a unit sets the image back first and restarts the unit after. |
| | `ops_plan_linux_test.go`: `TestAnUndoOfATreeNeverOverwritesAnIgnoredFile` | Moving a tree back is refused when it would overwrite a file the machine keeps and git ignores, each name matched as itself, never as a pattern or pathspec; the file is untouched. |
| | `ops_plan_linux_test.go`: `TestALineRunsOneAttemptAtATime` | In real units: a second attempt of a step is refused while the first runs, and once one passed none runs again. Skipped where action ops cannot run. |
| | `ops_plan_linux_test.go`: `TestAnUndoAfterARetryGoesBackToBeforeTheFirstAttempt` | In real units: a roll that set the image and failed, then passed on its retry, is undone by a failed check to the image live before the first attempt, and never runs again after its undo. Skipped where action ops cannot run. |
| | `ops_plan_linux_test.go`: `TestOnlyTheLatestAttemptsFailureUndoesTheLine` | In real units: an earlier attempt's failure undoes nothing once a later attempt passed, or while a later attempt is the latest; the latest attempt's failure undoes to before the first. Skipped where action ops cannot run. |
| | `ops_plan_linux_test.go`: `TestALaterLinesCheckUndoesOnlyOnItsLastAttempt` | In real units: a check that may still be retried undoes nothing; its last allowed attempt's failure undoes the roll. Skipped where action ops cannot run. |
| | `ops_plan_linux_test.go`: `TestAVerbThatCannotBeRecordedDoesNotRun` | In real units: a verb whose record for the undo cannot be written is not run, and the step fails. Skipped where action ops cannot run. |
| | `ops_plan_linux_test.go`: `TestAnAttemptTheBastionDiedInIsSettled` | An attempt whose bastion process is gone is settled at startup: as it ended, from its kept receipt, or as failed with a signed receipt saying so; an attempt of a live process is never settled. |
| | `ops_plan_linux_test.go`: `TestARetryLeavesATargetSomethingElseMovedAlone` | In real units: a retry of a roll whose workload another pipeline moved after the first attempt is refused, and the undo leaves the other change in place. Skipped where action ops cannot run. |
| | `ops_plan_linux_test.go`: `TestATargetMovedWhileTheProgramRunsIsLeftAlone` | In real units: a workload another pipeline rolls while the line's program runs is not rolled over, and the undo leaves the other change in place. Skipped where action ops cannot run. |
| | `ops_plan_linux_test.go`: `TestAVerbMovesOnlyFromTheStateItWasApprovedToMoveFrom` | A roll whose workload is in another state than the one its change list read and recorded sets nothing; in that state it rolls. |
| | `ops_plan_linux_test.go`: `TestATreeMovesOnlyFromTheCommitItWasApprovedToMoveFrom` | With real git: a tree at another commit than the one its change list read does not move; at that commit it does. |
| | `ops_plan_linux_test.go`: `TestTwoChangesToOneTargetNeverInterleave` | Two rolls of one workload at once: the second waits while the first is between its read and its write, then finds the workload moved and sets nothing. |
| | `ops_plan_linux_test.go`: `TestAChangeWaitsForOneInFlightOnTheSameTarget` | A tree move, a tree undo and an image undo each wait, without reading the target, while another change holds it, and run once it is done. |
| | `ops_verbs_linux_test.go`: `TestEveryCommandAVerbRunsHasADeadline` | Every command a verb runs has a deadline (a minute; a rollout or a unit's restart six, past their own five), so one that never answers cannot hold its target's lock; a command past it is stopped. |
| | `ops_plan_linux_test.go`: `TestAnUndoReadsItsTargetAgainBeforeItWrites` | An image or tree undo reads its target again right before its write; a roll or commit made after its first read is left in place. |
| | `ops_plan_linux_test.go`: `TestOnlyAProcessKnownToBeGoneIsSettled` | An attempt is settled only when its process is known to be gone or replaced under its pid; this process, and any when /proc cannot be read, are not. |
| | `ops_plan_linux_test.go`: `TestAnUndoMatchesTheReceiptToTheLinesRecord` | An undo is refused while an attempt runs, twice, on a later step's failure when this step did not pass, on the step's own failure once an attempt passed, and on an attempt older than the latest. Called directly. |
| | `ops_plan_linux_test.go`: `TestALinesRecordAndLedgerAgreeAndAreLetGo` | An attempt spent with no record of it is refused; a step's record and receipts are let go a week after the plan expires, not before. |
| | `ops_plan_test.go`: `TestLinesRunInOrderAndOnlyAsOftenAsThePlanAllows` | A line is refused until every line before it has a successful signed receipt; a line runs only as many times as the plan allows. |
| | `ops_plan_test.go`: `TestAPlanCarriesNothingThatCouldHideWhatThePersonReads` | The device refuses a plan whose input, params or verbs hold an escape, a line break, a direction mark, a key twice, an unknown verb or argument; its words carry no control character. |
| | `ops_plan_test.go`: `TestThePageAsksTheCodeForARootPlanAndNeverApprovesItWithAll` | The approvals page asks for the code on a root plan's card, leaves it out of "approve all shown" even when its id is posted, refuses it without the code and signs it with it. |
| | `ops_plan_linux_test.go`: `TestAShipPlanRunsEachLineOnceAndUndoesOnAFailedCheck` | In real units against a stand-in kubectl: a build's receipt carries its digest; the roll refuses to run before it and then rolls exactly that digest, adopting the live image as the first roll; a replay is refused; a failed check undoes the roll to the image live before; a plan whose roll names a digest none of its steps built rolls nothing. Skipped where action ops cannot run. |
| A server in the service slot has no network | `service_test.go`: `TestServiceUpRunsAStayingContainerWithNoPublishedPort` | The container starts with `--network none`, all capabilities dropped, no new privileges and no published port. |
| | `service_reach_test.go`: `TestReachGrantSyncAndTheForwardWall` | Installing a reach grant is refused without an approval, for another host, or for another machine. The reach check then admits the granted host to the granted server only, and a revoked grant admits nothing. Called directly, not through the slot listener. |
| | `claims_unix_test.go`: `TestTheServiceSlotAdmitsOnlyWithTheFamilyAndAReachGrant` | Through the slot listener: each connection is checked, and one without a reach grant for that server, or without the `services` family, never reaches the server's socket. |
| | not covered by a test | The reach check's own comparison of a stored grant's machine. |
| Egress for someone else reaches the public internet, never this machine | `coders_linux_test.go`: `TestCoderEgressRefusesEveryInternalClass` | A coder run's proxy refuses every internal class (loopback, RFC 1918, CGNAT, link-local and metadata, ULA, IPv4-mapped, IPv4-compatible, IPv4-translated, NAT64, 6to4, unspecified, 0/8, 198.18/15, 240/4, multicast, fec0::/10), by address and by a name with one such answer among public ones, and `localhost` names and names that do not resolve. |
| | `coders_linux_test.go`: `TestCoderEgressRefusesThisMachinesOwnPublicAddress` | A public address on this machine's interface is refused in every spelling (IPv4-mapped, NAT64, 6to4) and through a name; a neighbour on the same subnet passes; `allow_lan` is the one exception. |
| | `coders_linux_test.go`: `TestCoderEgressLocalAddressesAreReread` | An address the machine gains after start is refused; interfaces that cannot be read refuse. |
| | `coders_linux_test.go`: `TestCoderEgressDialsTheVettedAddressAndHonoursAllowLAN` | The proxy returns the vetted addresses, IPv4 first, never the name; a LAN host passes only when named in `allow_lan`, and an entry with a port only on that port. |
| | `coders_linux_test.go`: `TestCoderEgressProxyOverTheSocket` | Through the run's socket: loopback, LAN, metadata, private and this machine's public address get 403 with the reason, over CONNECT and plain HTTP; a public destination is spliced, and only its vetted address is dialled. |
| | `ownaddr_test.go`: `TestOwnDestinationAsksTheKernel` | With the kernel's answer supplied: an address a `local` route delivers here is this machine, also as the IPv4 address inside an IPv4-mapped, NAT64 or 6to4 address; a unicast route or no route is not; a failed lookup cannot decide; without a lookup the interface addresses still decide. |
| | `ownaddr_test.go`: `TestCoderEgressRefusesAnAddressTheKernelDeliversHere` | A coder run is refused an address the kernel delivers here, in every spelling, and refused anything when the lookup fails; `allow_lan` stays the exception. |
| | `ownaddr_netns_linux_test.go`: `TestAnyIPDestinationsAreRefusedInARealNamespace` | In a private network namespace with `local 192.0.2.0/24` and `local 2001:db8:a::/64` on `lo`, plus a broadcast route (198.51.101.255), anycast routes (198.51.102.0/24, 2001:db8:d::/64) and a multicast route: the real route lookup reads each as this machine, and a unicast route or no route as not; a 0.0.0.0 listener answers on both local ranges. A coder run's proxy and the anchored egress lane each refuse those addresses (local, broadcast and anycast), the IPv4-mapped and NAT64 forms of each IPv4 one, and a name resolving to each, with the lane's this-machine reason (a name the lane could not resolve does not count), and pass the unicast and no-route addresses and a name resolving to each, dialling the vetted address; each answers 403 end to end with a real dialer, the proxy over its socket and the anchored lane through its connection handler as the anchor host, to CONNECT by address and by name and to plain HTTP. The backup lanes' URL check refuses the same addresses, forms and names as this machine's or its network's and passes the unicast and no-route ones, and their dial wall refuses the local, broadcast and anycast addresses. Nothing reaches the listener. Skipped where a namespace cannot be made. |
| | `ownaddr_netns_linux_test.go`: `TestOnlyAUnicastRouteLeavesThisMachine` | The kernel's route type is read so that only unicast leaves this machine; local, broadcast, anycast, multicast, unspec, nat and xresolve are this machine. |
| | `hostops_test.go`: `TestAnchoredEgressRefusesLoopbackAndPrivateDestinations` | On an anchored machine the egress lane refuses loopback, `localhost`, private, ULA, link-local, CGNAT and metadata addresses, dials the vetted address, and a local machine keeps the lane it had. |
| | `ownaddr_test.go`: `TestAnchoredEgressRefusesThisMachinesOwnAddresses` | On an anchored machine the egress lane refuses an address on this machine's interface and one a `local` route delivers here, in every spelling, and refuses when the lookup fails; a neighbour passes; a local machine's lane is unchanged. |
| | `ownaddr_test.go`: `TestBackupLanesRefuseThisMachinesOwnAddresses` | The backup lanes' URL check refuses an address on this machine's interface and one a `local` route delivers here, in every spelling and through a name, and their dial wall refuses those addresses; a failed lookup refuses both; a neighbour on the subnet passes. With the kernel's answer supplied. |
| | `backupfetch_test.go`: `TestBackupWallJudgesTheIPv4InsideAndTheReservedRanges` | The backup lanes' classifier, address wall and URL check refuse 10.0.0.1 and 127.0.0.1 inside NAT64, 10.0.0.1 inside 6to4 and in the IPv4-compatible and IPv4-translated forms, the metadata address inside 6to4, a local-use NAT64 address, `fec0::1`, `198.18.0.0/15` (also inside an IPv4-mapped address), `240.0.0.0/4` (also inside NAT64) and `255.255.255.255`; public addresses in the NAT64, 6to4 and IPv4-mapped forms, and the addresses either side of `198.18.0.0/15`, pass the classifier. |
| | not covered by a test | A public address that reaches this machine only through a 1:1 NAT in front of it. Nothing refuses it. |
| Audit rows also reach the system log | `auditlog_test.go`: `TestAuditRowsReachTheSystemLog` | The management lane, pairing changes, store ops, terminals and root commands each send the row they write to their file to the system log too, unchanged, with `audit` and `pairing` added. |
| | `auditlog_test.go`: `TestAuditFileRowStandsWithoutASystemLog` | A system log that takes nothing costs only that copy; the row is still written to its file. |
| | `auditlog_syslog_test.go`: `TestAuditRowsDoNotWaitOnAStuckSyslogSocket` | A real syslog writer on a socket nobody reads blocks; 5000 rows queued in front of it all return at once and the excess is counted as dropped. |
| | `auditlog_queue_test.go`: `TestAuditSinkIsTheQueue` | The shipped sink is the queue, not the writer. |
| | `auditlog_queue_test.go`: `TestAuditQueueNeverWaitsOnAStuckWriter` | With a writer that never returns, 1000 rows are taken at once, and every row is either written or counted in `system_log_dropped` on a later line. |
| | `auditlog_queue_test.go`: `TestAuditSystemLogOmitsTheRootCommandText` | A root command's text is not in the system-log line; its digest and length are. |
| A networked machine is anchored to its first host | `pair_anchor_test.go`: `TestPairAnchorsANetworkedMachineToItsFirstHost` | On a networked machine with no anchor, the first pairing made with machine authority anchors it and is active; a local machine, an anchored one (a new host stays pending), a second pairing, and a caller without machine authority are left as they were. `claim-anchor` afterwards succeeds and changes nothing. Called directly, not through `pair`'s redeem. |
| | `anchor_test.go`: `TestClaimAnchorWithNoToken` | `claim-anchor` with no token on a machine anchored to that host succeeds and changes nothing, the host named in full, by handle or not at all; when the anchor's pairing is pending or revoked it is refused and the state is left as it was; another host is refused without `--rotate`. |
| A root reinstall keeps a paired local machine local | `install_sh_test.go`: `TestInstallShKeepsAPairedLocalMachineLocal` | `install.sh`'s check finds a pairing in a folder whose mode is local (or unset), in either folder it looks in; a networked folder, or one with no pairing, gets the networked default. The rest of the installer is not run by a test. |
| A terminal is checked here and logged | `session_test.go`: `TestSessionOpensWithoutAGrantOnALocalBox` | On a local machine a terminal with no grant and no approval opens and is audited. The same test calls `sessionPinOfferedKey` directly: the first key offered is pinned and a different one is refused. |
| | `session_test.go`: `TestSessionRefusedWithoutGrantIs126` | A grant that is sent is verified: a bad signature, an op not listed, or an op the grant denies is refused and audited. |
| | `fuzz_grants_test.go`: `FuzzEachOpIsAllowedOnlyByItsOwnFamily` | On an anchored machine the family check (`capsAllow`) lets a host other than the anchor run an op, the session ops included, only if its pairing grant names that op's family. Called directly, not through the terminal handler. |
| | `session_test.go`: `TestSessionOnAnAnchoredBoxTheApprovalIsTheWall` | On an anchored machine a terminal without an approval is refused, with or without a factor enrolled, is audited and leaves no transcript. |
| | `session_test.go`: `TestSessionRootRefusedWithoutStepup` | A root terminal with no approval is refused and leaves no transcript. |
| | `session_test.go`: `TestSessionRootApprovalBindsToSession` | The purpose a root approval is bound to differs between two session ids on the same machine. Compared as strings, not through the terminal handler. |
| | `session_test.go`: `TestSessionFramingResizeAndTranscript` | An opened session writes a transcript and start and stop lines to the audit log. |
| | `hostops_test.go`: `TestTheDeviceSignsOnlyWhatItDerivedItself` | The approval device refuses to take a root-terminal ask. |
| Root commands run only under a grant given here with the code | `rootexec_test.go`: `TestRootExecGrantCeremony` | The grant exists only after a code from the factor enrolled here checks out, once per code, with an expiry no longer than a year; past its expiry or after a revoke it refuses. |
| | `rootexec_test.go`: `TestRootExecRefusals` | Through the terminal handler: with no grant, an expired, revoked, other-host or other-machine grant, from a member pairing holding the `session` family, or with the bastion not root, the command is refused, never starts, leaves no transcript and is audited with its command. |
| | `rootexec_test.go`: `TestRootExecLocalSwitchRefuses` | With `session` switched off here, a granted command is refused. |
| | `rootexec_test.go`: `TestRootExecRoundTrip` | A granted command returns its stdout, stderr and exit status, and is written to a transcript and to the audit log at start and stop with the command. |
| | `rootexec_test.go`: `TestRootExecOutputCapIsStated` | An output over the cap keeps its first and last half, says how many bytes were cut, and is kept whole in the transcript. |
| | `rootexec_test.go`: `TestRootExecDeadlineKills` | A command past its deadline is killed and answered with 124 and timed_out. |
| | `rootexec_test.go`: `TestRootExecStopsWhenAWallFallsMidRun` | Through the handler: a command already running is killed within one recheck when its grant is revoked or expires, `session` is switched off here, or the pairing is quarantined, and answered with 126 and a J that says why; while every wall holds, the recheck leaves it alone. |
| | `rootexec_test.go`: `TestRootExecDeadlineKillsAChildThatIgnoresSIGTERM` | Through the handler with the real `/bin/sh`: at the deadline a child that ignores SIGTERM, holding the output open or not, is gone when the answer arrives, within the deadline and the kill grace. Every signal goes through a fence that admits only the process group the test started. |
| | `rootexec_test.go`: `TestRootExecBadCommandAndTranscriptFailureAreAudited` | A bad command, a bad session id, and a transcript that cannot be opened each leave an audit row with the command (or its size and sha256) and exit 1; none starts. |
| | `rootexec_watch_linux_test.go`: `TestRootExecWatchKernel` | In a private network namespace, through the handler with the real `/bin/sh`, nftables and a cgroup: a command's connections to outside addresses (IPv4 and IPv6, TCP and UDP) are reported in `untrusted_connections`; loopback, the machine's own address and listed platform ranges are not, though they stay in the audit row marked own; a grandchild's connections are caught; what a command reached before its deadline killed it is reported; no table or cgroup is left after any of them, and one left by a dead bastion is removed by the next sweep, with any process in it moved to its parent. |
| | `rootexec_watch_test.go`: `TestRootExecJCarriesOnlyOutsideConnections` | The J frame lists only the destinations outside the platform list (addresses, CIDRs and resolved names), the stop row lists every one and counts the outside ones, the transcript records them; with only loopback and own hosts the field is absent. |
| | `rootexec_watch_test.go`: `TestRootExecUnwatchedCountsAsOutside` | A command whose connections could not be watched is reported with one untrusted entry that says why. |
| | `rootexec_watch_test.go`: `TestRootExecOwnHostsList` | No list means only loopback is own; a broken list or a bad entry reads as empty; a name that does not resolve adds nothing; the host's list is capped and the rest counted. |
| | `claims_unix_test.go`: `TestAMemberWithoutTheSessionFamilyGetsNoTerminal` | Through the terminal handler, on an anchored machine: a host other than the anchor without the `session` family is refused and leaves no transcript; once the family is granted the same approval opens the terminal. |
| | `claims_unix_test.go`: `TestAnAnchoredTerminalApprovalOpensOneSessionOnce` | Through the handler: an anchored machine's terminal approval is refused for another session, opens its own, and is refused when used again. |
| | `claims_unix_test.go`: `TestARootTerminalNeedsTheBastionToRunAsRoot` | Through the handler: with a factor enrolled and an approval for that session, a bastion not running as root refuses a root terminal, and the same approval opens it once the bastion runs as root. |
| | `claims_unix_test.go`: `TestADeviceCannotApproveARootTerminal` | Through the handler: this machine refuses a device approval for a root terminal, with or without a factor enrolled. |
| | `claims_unix_test.go`: `TestARootApprovalOpensOneSessionOnce` | Through the handler: a root approval is refused for another session, opens its own, and is refused when used again. |
| | `claims_unix_test.go`: `TestTheSessionHandlerPinsTheOfferedKey` | Through the handler: the first key a frame offers is pinned, and a later frame offering another key is refused with the pin kept. |
| | not covered by a test | That a root approval's purpose names the machine (`session.go`, `purposeForSessionRoot`). |
| Root only where there is no one else to ask | `anchor_test.go`: `TestClaimAnchorWalls` | `claim-anchor` is refused without machine authority. Asserted only when the tests run without root. |
| | `owner_test.go`: `TestClaimOwner` | `claim-owner` is refused without root and writes nothing. Asserted only when the tests run without root. |
| | `claims_unix_test.go`: `TestMachineWideFactsNeedMachineAuthority` | `claim-owner`, `claim-anchor`, `claim-anchor --repin` and `set-mode networked` are refused without machine authority and write nothing; with it, each completes (`--repin` re-pins the anchor to the pairing that holds it). Both halves run whatever user the tests run as. |
| On macOS and Windows, a networked bastion is the system's service | `sysservice_test.go`: `TestLaunchdPlistRunsTheDaemonAsRootAtBootAndKeepsItAlive` | The launchd job is well-formed and runs the program as root at boot, with root's home, and launchd restarts it whenever it exits. |
| | `sysservice_test.go`: `TestBinaryChainMustBeRootOnly` | On macOS the service is refused for a program a non-root user owns, that a group or everyone can write, that is a symlink, or that sits under such a directory. Run against a stand-in for the disk, not on a Mac. |
| | `sysservice_test.go`: `TestWindowsServiceBinaryMustLiveUnderProgramFiles` | On Windows the service is refused for a program outside Program Files, including the per-user install folder. |
| | `sysservice_test.go`: `TestServiceGuardRefusesAUserspaceBastionBesideTheService` | With the service installed, `pair`, `run` and `status` from an account without machine authority are refused; an administrator, and a machine with no service, are not. |
| | `sysservice_test.go`: `TestServiceModeOpensNothingOnTheDesktop` | As a Windows service, the tray, the blocking dialog and the detached relaunch after an update each return before doing anything. Read from the source, not run. |
| | `runlock_unix_test.go`: `TestARunBesideTheMacServiceIsRefusedBeforeItStarts` | With the macOS daemon installed, a second `run` is refused while the first holds the lock in the state folder, and may run once it is gone; with no daemon nothing is locked. Run against the real lock on Linux; the macOS wiring (`runguard_darwin.go`) is not run off a Mac. |
| | not covered by a test | That launchd and the Windows service manager actually restart the bastion after it exits (`sysservice_darwin.go`, `sysservice_windows.go`); neither runs off its own platform. |
| | `freeagent_drop_winrules_test.go`: `TestFaWinOwnerRefusalRunsOnlyAsAPerson` | On a Windows service a folder's agent is refused when the folder's owner is SYSTEM, a service account, a BUILTIN group such as Administrators, the built-in Administrator or Guest, or not a person's account; a local, domain or Entra ID user is accepted. |
| | `freeagent_drop_winrules_test.go`: `TestFaAgentDirStaysOutOfTheFolderWhereTheHandOverIsAPermission` | Where the hand-over is a permission entry (Windows), the agent's directory is never made inside the person's folder. |
| | not covered by a test | That the Windows agent is started under the owner's session token, and that its state leaf's permission is set only while the leaf is empty (`freeagent_drop_windows.go`); neither runs off Windows. |
| Observation changes nothing on the machine | `observe_endpoints_test.go`: `TestObserveEndpointAllowedIsTheInverseOfObserveNet` | The monitoring ops may reach only private addresses, exactly the ones `observe:net` refuses. |
| | `observe_endpoints_test.go`: `TestObserveDialControlRefusesPublicAtConnectTime` | A public address is refused at the socket, so a DNS answer cannot move a query outside. |
| | `observe_endpoints_test.go`: `TestObserveNeverFollowsARedirect` | A redirect from a monitoring endpoint is reported as a failure, and its target is never fetched. |
| | `claims_static_test.go`: `TestObserveOpsWriteNothing` | Nothing an observe or computer op can reach writes, removes, renames, changes permissions on, mounts or signals anything, apart from the bastion's own audit line and its journal of observe results; the programs those ops run are a fixed list of reads. |
| | `claims_static_test.go`: `TestNoInputInjection` | No input-injection program is run, no Windows input API is loaded, and no script the computer ops run asks for a keystroke, a click or an accessibility action. |
| A backup's key goes to a host only when you approve it here | `backupreseal_test.go`: `TestBackupResealWaitsForThePersonThenDeliversToTheHostOnly` | Through the management lane: a request is filed pending and hands out nothing; another pairing cannot read it; after approval here the host receives the record sealed to its key only; a refusal hands over nothing; a decided request cannot be decided again. |
| | `backupreseal_test.go`: `TestResealOpensAnAgeWrittenSidecarAndSealsToTheHostAlone` | A sidecar written by the age command is resealed byte for byte to the host's key alone (not to the key that opened it, not to another host), and the age command opens the result. |
| | `backupreseal_test.go`: `TestResealRefusesWhatIsNotTheBackupItClaims` | A sidecar of another kind or version, naming another backup, with a malformed stamp or key, sealed to someone else, or oversized, is refused. |
| | `backupreseal_bind_test.go`: `TestResealRefusesAnotherHostsBackupWhateverTheHostSays` | A host holding another host's key file, calling itself that host, is refused when the person approves, and nothing is sealed to it; the other host asking for its own backup is served. |
| | `backupreseal_bind_test.go`: `TestResealDecideRefusesWhatIsNotThePairingsOwn` | The refusal is in the act itself, not only the preview: a request is bound to the pairing and host number that filed it. |
| | `backupreseal_test.go`: `TestResealTakesBothPlacesABackupIsKept` | A key file naming `b/<stamp>` or `s/<stamp>` opens and is resealed to the host's key, written by this code and, where the age command is installed, by it. |
| | `backupreseal_test.go`: `TestResealRefusesAnObjectOutsideBothPlaces` | A key file naming any other folder, a path that climbs or nests, or another backup's name is refused. |
| | `backuppush_test.go`: `TestBackupPushStartRefuses` | `backup_push` refuses, before any job starts, a path outside the backup folder, a subfolder, a path through a linked folder, a link, a file not named like a backup, a folder not shared with the host, plain http, and this machine's or its network's addresses. |
| | `backuppush_test.go`: `TestBackupPushSendsNothingWhenTheFileIsNotTheOneNamed` | A file whose SHA-256 is not the one the host named fails the job before a byte is sent. |
| | `backuppush_headers_test.go`: `TestBackupPushSendsTheSignedHeaders` | The headers an upload address was signed with go on the PUT: a storage that refuses a PUT without `If-None-Match: *` accepts it, and refuses a second one with 412, reported as such. |
| | `backuppush_headers_test.go`: `TestBackupPushStartTakesTheHeaders` | Through the management lane, `If-None-Match: *` (as sent, or in lower case) is the only header a host may hand over: any other name (an unexpected header, Host, Content-Length, Transfer-Encoding, Connection), any other value for it, or a split header line is refused before a job starts, and so is a start with no headers. |
| | `backuppush_bound_test.go`: `TestAPushFromAFolderThatStopsAnsweringFails` | A push whose file read never returns, while checking the digest or while sending, fails within the bound with a sentence instead of staying pending. |
| | `backuppush_stuck_test.go`: `TestRepeatedPushesFromADeadFolderHoldOneRead` | Pushes started again and again on a folder whose read never returns leave one blocked read, one open descriptor and no more goroutines; each start after the first stuck read is refused with a sentence. |
| | `backuppush_stuck_test.go`: `TestAPushFolderIsReadAgainWhenItsReadReturns` | Once a stuck read returns, its folder is read again: the next push there reads its file to the end. |
| | `backuppush_stuck_test.go`: `TestStuckPushReadsAreCappedPerBastion` | Across folders the blocked reads never pass the cap, and a start while the cap is held is refused, even from a folder that answers; once the reads return, a push starts. |
| | `backuppush_stuck_test.go`: `TestAPushWaitingForASlotIsNotBlamedOnItsOwnFolder` | A push from a folder that answers, waiting for a slot while other folders' stuck reads hold the cap, fails with the sentence about the cap, not one saying its own folder stopped answering. |
| | `backupreads_test.go`: `TestAPushIsNotStartedInAFolderWhoseHashingReadIsStuck` | A folder whose read for a digest has not returned refuses a push with a sentence and starts no push read there; once the read returns its digest is kept and a push starts. |
| | `backupreads_test.go`: `TestTheHashingDoesNotReadAFolderWhosePushReadIsStuck` | A folder whose push read has not returned is not read for digests, while other folders are; once it returns the folder is read again. |
| | `backupreads_test.go`: `TestStuckHashingAndPushReadsShareTheCap` | A stuck digest read and a stuck push read count toward one cap: at the cap, a folder that answers gets neither a push nor a digest read until one of them returns. |
| | `backuphash_progress_test.go`: `TestASlowHashThatKeepsAnsweringRefusesNoPushFromItsFolder` | A backup read for its digest far longer than the bound, its reads returning all along, does not make its folder stuck: a push of another backup there starts and reads its file while the digest is still being read. |
| | `backuphash_progress_test.go`: `TestAHashWithNoAnswerForTheBoundStillMarksItsFolderStuck` | A read for a digest that answers for a while and then goes without an answer for the bound makes its folder stuck: a push there is refused with the sentence about the digest, the read counts toward the cap, and once it answers its digest is kept and a push starts. |
| | `backuppush_test.go`: `TestBackupPushNeverFollowsARedirect` | An upload address that redirects is reported as a failure, and the redirect's target is never reached. |
| | `backupretention_test.go`: `TestRetentionIsNotSteeredByTheNamesAHostGives` | Files a host names into past days, all arriving today, do not push out the real backups of those days. |
| | `backupretention_test.go`: `TestRetentionRemovesOnlyWhatThisBastionFetched` | Thinning removes no file this bastion did not record fetching (a file you put there, one replaced since it arrived), no file not named like a backup, no link and nothing a link points to, and nothing when `.keep-all` is present. |
| | `backupreseal_test.go`: `TestBackupApproveNeedsTheEnrolledFactor` | With a second factor enrolled, approving without its code or with a wrong one opens nothing and leaves the request pending. |
| | `backuplist_test.go`: `TestBackupListShowsOnlyFoldersSharedWithThisHost` | A folder shared with one host is listed for that host only, and not once taken back. |
| | `backuphash_bound_test.go`: `TestAHashReadThatNeverReturnsHoldsUpNoOtherFolder` | A file whose read for its digest never returns holds up the digests of other folders only for the bound; nothing more is read from its folder while it is stuck; when it returns its digest is kept and the folder is read again. |
| | `backupshared_bound_test.go`: `TestASharedFolderThatDoesNotAnswerHoldsUpNothing` | A shared folder that hangs when resolved or read is left out of `backup_list` (named under `unavailable`) within the bound, and `backup_push` from it is refused within the bound; the folders that answer are served. |
| | `backupshared_bound_test.go`: `TestASharedFolderThatNowReachesTheBastionsStateIsLeftOut` | A shared folder replaced by a link to the bastion's configuration folder is not listed and not sent from. |
| | `backuproot_test.go`: `TestTheBackupFolderIsCreatedOnFirstBackupOp` | `backup_list` creates no folder; the first backup op that writes does. |
| | `backuproot_test.go`: `TestBackupRootsAreEachHostsOwn` | A backup op is refused while the host's backup folder is the same as, inside, or contains another paired host's, also through a link. |
| | `backuproot_test.go`: `TestBackupRootNeverOverlapsTheBastionsOwnState` | A backup folder that is or contains the bastion's configuration folder, keys or backup records, or sits in the configuration folder outside `backups/`, is refused, and none of them can be shared; the default folder is accepted. |
| No delete op on shared stores | `dcn_plan_test.go`: `TestDcnPlanCheckApplyVerify` | A move plan that escapes the store, targets an existing path, or touches an excluded file is refused whole; the op list carries the plan op and no `dcn_delete` or `dcn_remove`. |
| | `fuzz_paths_unix_test.go`: `FuzzMovePlanStaysInsideTheRoot` | Any move the plan check passes stays inside the store, targets a path that does not exist yet, and avoids excluded paths. |
| Verified releases | `release_sign_test.go`: `TestATamperedManifestIsRefused` | A manifest changed after signing is refused. |
| | `release_sign_test.go`: `TestAManifestSignedByAnotherKeyIsRefused` | A manifest signed by a key the build does not pin is refused. |
| | `release_sign_test.go`: `TestAnUnsignedManifestIsNotRead` | A manifest with no signature reaches neither the updater nor the local page. |
| | `freeagent_managed_test.go`: `TestFaEnsurePyzVerifiesHash` | An agent download whose SHA-256 differs from the manifest is refused and the installed agent's bytes are unchanged; afterwards, with the manifest unreachable, the installed path is still served. |
| | `release_sign_agent_test.go`: `TestAnOlderSignedAgentManifestDoesNotDowngrade` | An older agent version is neither downloaded nor installed. |
| | `claims_update_test.go`: `TestADigestMismatchNeverSwapsTheBinary` | A self-update whose download does not match the manifest's SHA-256 (another build's, empty, truncated) leaves the running binary byte-identical with nothing moved aside. |
| | `claims_update_forward_test.go`: `TestTheForwardPathInstallsNothingButTheSignedTarget` | With no approval, a signed manifest for a version other than the target, and an unsigned manifest for the target, are never downloaded from; a signed manifest naming the target is. |
| | not covered by a test | Where the release key is kept; the installer's SHA-256 check. |
| No secrets cached for git | `claims_git_test.go`: `TestTheGitHelperFetchesATokenPerAuthAndStoresNothing` | Each auth asks the daemon for a fresh token; store and erase do nothing; nothing is written to the bastion's config. |
| | `claims_git_test.go`: `TestNoHelperOnTheMachineStoresTheForgeToken` | A real git reading the forge through the proxy, with git's `store` helper configured before `git-setup`'s lines, after them, or in the repository, authenticates with the host's token and writes it nowhere. |
| | `claims_git_test.go`: `TestGitSetupResetsTheHelperChainBeforeItsOwnHelper` | `git-setup` prints the reset of the forge's helper chain right before its own helper, for one host and for several. |
| | `claims_git_test.go`: `TestWithoutTheAuthtypeCapabilityOnlyTheResetProtects` | For a git without the authtype capability the helper answers the username and password, and only a helper configured before the reset is kept out. |
`deploy/check-claims.sh` checks that every test this table cites exists in
`client-go`; run it after renaming or removing a test.
+52
View File
@@ -0,0 +1,52 @@
# Command reference
| Command | What it does |
|---|---|
| `pair <token>` | Enrol this machine with a host. Each pair adds a host; re-pairing an existing host is refused. |
| `pair -` | The same, with the token as the first line of standard input, so it is not in the process list or your shell history. Only `-` reads standard input: `pair` with no token is the usage error. Every form of `pair` takes `-` in place of `<token>`. |
| `pair --replace <token>` | Re-pair an existing host: rotates its credentials, keeps its port, context store and DCN state. |
| `pair [--replace] --redeem-url unix:///<socket> <token>` | Pair with a Host on this machine over its enroll socket. Its CA is pinned for life. See [A Host on this machine](host-on-this-machine.md). |
| `pair [--replace] --redeem-url https://<host>:<port>/<path> --host-ca <pem> <token>` | Pair with a Host in a VM on this machine over its enroll port, trusting that CA alone. Its CA is pinned for life. See [A Host in a VM on this machine](host-on-this-machine.md#a-host-in-a-vm-on-this-machine). |
| `run` | Bring up the tunnel and lanes for every paired host. |
| `status` | Show every pairing and its tunnel status. |
| `ui` | Open the local console in your browser. |
| `add-folder [--host <host>] <path> [name]` | Add a folder to your network. `--host` is required when more than one host is paired. |
| `context` | Choose what your files become on your network. Runs as you. |
| `stepup-enroll [--host <host>] [--rotate]` | Enrol (or rotate) the second factor on this machine. |
| `approvals [--host <host>]` | List what your host is asking to do here, in plain words. |
| `approve [--host <host>] <id>` / `deny …` | Answer one request with a code from the authenticator enrolled here. |
| `approvals drop [--host <host>] <id>` | Take back an approval this machine issued and has not used yet. |
| `grants drop <store id> [--host <host>]` | Remove a folder grant held on this machine. The host is told. |
| `device-init` | Make this machine your approval device. |
| `device-approvals` | What your machines are asking you to approve, in their words. |
| `device-approve <id>\|all [--code <code>]` / `device-deny <id>` | Approve or refuse from the device. `all` signs only the asks the last listing showed, and never a ship plan that runs root work: that takes `--code`. |
| `pin-device <key>` / `unpin-device <key>` | On a machine you govern: accept (or stop accepting) approvals signed by your device. Root required. Prints the `pin-machine` line to run on the device. |
| `pin-machine <host> <key>` / `unpin-machine <host>` | On your device: pin (or drop) a machine's key, as that machine printed it, so the device can sign ship plans that include it. |
| `ops [--host <host>]` | The ops your host wrote that this machine holds, and the standing op grants. |
| `ops drop <grant> [--host <host>]` | Take back a standing op grant. The host is told. |
| `coders set [--max N] [--mem MB] [--cpus F] [--engines a,b\|all] [--allow-lan host[:port],...] [--image-prefix <registry/path/>\|default]` | Size this machine for coding runs your hosts place here: how many at once, memory and CPUs per run, which engines, LAN hosts a run may reach, and where run images may come from (`<prefix>harness-<engine>@sha256:...`; default `git.privatae.ai/privatae-harness/`). Does not switch them on. |
| `coders on` / `coders off` | Allow coding runs here (Linux, bastion running as root), filling unset sizes with defaults; or stop new ones. Off by default. Each run is a container with no network of its own; it reaches the public internet through this bastion and never this machine or its LAN. |
| `coders drop [--host <host>]` | Take back the standing coder grant of every host, or one, and stop their runs. |
| `coders status [--json]` | The coder settings, grants and runs on this machine. |
| `quarantine clear <host>` | Reopen a host's write lanes that this machine closed after repeated refusals. |
| `git-setup` | Print the `git config` lines that route `git.privatae.ai` through this bastion. |
| `git-credential` | Git credential helper. Fetches your forge token from your host on every auth. |
| `unpair <host>` | Tear down one pairing. Context store and DCN state are left in place. |
| `set-mode <local\|networked>` | Switch modes. Root required. |
| `claim-anchor [--owner <name>] [--host <host>] [<token>]` | Networked mode: make a host the anchor. No token when the host is already paired here. Also `--rotate <token>`, `--repin`. Root required. |
| `claim-owner <name>` | State who owns this machine. Root required. Optional; grants nothing. |
| `observe` | Print the agent, automation and scheduler snapshot the next check-in would carry. |
| `observe:baseline [--force]` | Record what is normal on this machine. Administrator only. |
| `observe:baseline:show` | Print the baseline this machine holds. |
| `observe:delta [--json]` | What is running that the baseline does not hold. Administrator only. |
| `backup key init [--replace] [--label <name>]` | Make this bastion's backup key and print the fields to register on your host. `--replace` keeps the old key. |
| `backup key show` | The backup key this bastion holds, and the older ones it keeps. |
| `backup requests [--host <host>]` | Restore requests your host sent this machine. |
| `backup approve [--host <host>] <id>` / `backup deny …` | Answer one: approving seals that one backup's key to the requesting host. |
| `backup reseal --sidecar <file> --to <host key>` | Seal one backup's key to a host's own key, offline. |
| `backup restore-plan --dir <folder> [--stamp <stamp> --to <host key>]` | List the backups in a folder; reseal one for the host that replaces a lost one. |
| `backup share --with <host> (--from <host> \| --dir <folder>)` | Let a paired host list and ask for the backups in another host's folder (or any folder). |
| `backup shares --with <host>` / `backup unshare --with <host> <name>` | The folders shared with a host; take one back. |
| `version` | Print the version. |
Run `privatae-bastion` with no arguments for the built-in help.
+8
View File
@@ -0,0 +1,8 @@
# Local console
`privatae-bastion ui` opens a page served by the running daemon on `127.0.0.1`
and reachable from nowhere else: pairings, the anchor, containment state, marks
on automated programs, the dry-run policy, and pending approvals. On Windows
a tray icon shows a one-line state and offers four actions: open what is
waiting for your approval, open the console, open your context page, and stop
the bastion.
+19
View File
@@ -0,0 +1,19 @@
# Your files: context and DCN stores
## Context: your files, turned into text here
`add-folder` hands a folder to your network. `privatae-bastion context` opens a
browser page, running as **you** (not as the daemon), that shows exactly the
files you can already open and asks the host before anything is added. The
bastion can also turn bytes into content on your own hardware before anything
leaves: OCR for scanned documents, speech-to-text, office-document extraction
and video frames. Anything that turns your bytes into their content runs on
your machine, never on the platform.
## Distributed Context Network (DCN) stores
Signature-verified store grants for folders you share. The host's verify key is
pinned on first sync (trust on first use) and a later key change is refused.
Every read is sandboxed to the granted path, walks no symlinks out of it, and is
audited on this machine. The vocabulary is reads plus **one write lane**: a
signed move plan that you confirmed on this machine, which archives files and
never deletes them. Dropping a grant removes the grant, not your files.
**No delete op exists.**
+87
View File
@@ -0,0 +1,87 @@
# A Host on this machine
A Host with no platform (a customer's own server with the Host on it) pairs
with a bastion on the same machine, so its threads can build and test code in
sealed coder runs. Nothing about the walls changes: mutual TLS and the bearer
on every lane, a CA this bastion pins, grants the Host signs and this bastion
verifies on every operation, an anchored machine. What changes is who issues
the certificates (the Host's own CA) and how the two talk: unix sockets in a
directory both reach, `/run/privatae-host/` (root, group `privatae-host`,
1770: the Host runs without privileges and makes its two sockets there, and the
sticky bit keeps it from moving what root made; `runs/` inside it is 0750 root,
so only this bastion makes run sockets), instead of WireGuard through the relay.
| socket | served by | what |
|---|---|---|
| `/run/privatae-host/enroll.sock` | the Host | `pair` redeems its token here (`POST /bastion/enroll/redeem`, the platform redeem's body) |
| `/run/privatae-host/lane.sock` | the Host | every lane, as over the tunnel to `:8443` |
| `/run/privatae-host/runs/<run_id>.sock` | this bastion | one coder run's control plane, for the owning pairing only |
The commands an install script runs on such a machine, as root:
```sh
# 1. The bastion as the machine's service, in networked mode.
curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh -s -- --networked
# 2. Pair with the Host over its enroll socket (the token comes from the Host).
sudo -H privatae-bastion pair --redeem-url unix:///run/privatae-host/enroll.sock <token>
# 3. Make the Host the anchor: from now on every coder run-up needs the
# standing coder grant the Host signs.
sudo -H privatae-bastion claim-anchor --owner <name>
# 4. A way to approve at this machine: it is its own approval device.
privatae-bastion device-init # prints the pin-device line
sudo -H privatae-bastion pin-device <key it printed>
# 5. Coder runs of the workspace engine, from images built on this machine.
sudo -H privatae-bastion coders set --engines workspace --image-prefix localhost/privatae-harness/
sudo -H privatae-bastion coders on
sudo systemctl restart privatae-bastion
```
**The one thing the owner does by hand:** approve the Host's coder grant, once,
on this machine's console (`privatae-bastion ui`, then Approvals). Until then
every run-up is refused at the gate.
A `unix://` redeem URL names an absolute socket path and nothing else: a
host, a port or a relative path is refused before the token is read. The
Host's answer names its CA, the lane socket and the runs directory, and both
must be inside the directory of the enroll socket you named. The CA is pinned
as the platform's is: it is the only root the lanes trust, the Host's lane
certificate must carry the pairing's name, and a `pair --replace` that brings a
different CA is refused.
The image is built on the machine and tagged
`<prefix>harness-workspace:<tag>`; the Host names it
`<prefix>harness-workspace@<image ID>`, the ID from
`docker image inspect --format '{{.Id}}'`. Run-up takes an image that is
present that way without a pull or any registry credential. Docker's classic
image store keeps no repo digest for an image built or loaded locally, so
there the run starts from the image ID, and only when the image with that ID
carries a tag of the named repository.
## A Host in a VM on this machine
A Host with no platform that runs in a VM on this machine shares no kernel and
no directory with it. Its installer forwards two ports of the VM to this
machine's loopback and hands over a pairing token with the Host's CA:
```sh
privatae-bastion pair --redeem-url https://127.0.0.1:8810/v1/local/redeem --host-ca host-ca.pem <token>
privatae-bastion run
```
| port | what |
|---|---|
| `8810` | `pair` redeems its token here (`POST /v1/local/redeem`, the platform redeem's body) |
| `8811` | every lane, as over the tunnel to `:8443` |
The redeem and every lane go over TLS that trusts the `--host-ca` alone (no
system roots) and checks the name `host.cseries.internal` on the Host's
certificate; the lanes present this bastion's operator certificate, which the
Host knows it by. The Host's answer must carry that same CA and name a lane on
the host the redeem URL names. The CA is pinned for the pairing's life, as for
a unix pairing. `--host-ca` goes with an `https://` redeem URL only, and both
are checked before the token is read.
Such a pairing has no WireGuard forwards (inference, PII, service slots) and
no coder runs: a run's control plane is a tunnel port or a socket in a unix
pairing's runs directory, and a TCP pairing has neither, so its run-up is
refused before any container starts.
+33
View File
@@ -0,0 +1,33 @@
# Install: the service and the release files
The short version, server and desktop, is in the [README](../README.md#install).
A networked install runs as the operating system's own service, whoever is
signed in, and only the machine's administrator can stop it:
- **Linux:** the `systemd` unit `privatae-bastion.service`, as root.
- **macOS:** a `launchd` daemon (`ai.privatae.bastion`) running as root. The
program goes in `/Library/Application Support/privatae/bin`, which only root
can change, with a link on `PATH`.
- **Windows:** the service `PrivataeBastion`, running as LocalSystem. The
program goes in `Program Files\Privatae`, which only administrators can
change. The installer needs an elevated terminal, and removes the logon task
an earlier install registered. The agent that serves a shared folder runs
as the folder's owner, under their own token, while they are signed in to
the machine; it never runs as LocalSystem, and the bastion writes nothing
into the folder.
On macOS and Windows, `privatae-bastion service install|uninstall|status` does
the same by hand. An administrator's commands (`sudo -H …` on macOS, an
Administrator terminal on Windows) act on the service's state. From an account
that does not administer the machine, `pair`, `run` and `status` are refused,
so that account does not end up with a second, empty bastion of its own.
Release binaries are served from `https://dl.privatae.ai`. The release manifest
(`version.json`, read by the installers at `https://dl.privatae.ai/version.json`
and by the self-updater at `https://privatae.ai/bastion/version.json`) lists the
current version, the commit it was built from, and the URL plus SHA-256 of
every binary. Beside it, `version.json.<first 16 hex of its sha256>.sig` is an
ed25519 signature over a statement naming the release, its version and the
manifest's sha256, made on the key holder's own computer with
`deploy/release-sign`.
+89
View File
@@ -0,0 +1,89 @@
# Tunnel and lanes
## Tunnel
A userspace WireGuard tunnel (gVisor netstack) scoped so this machine can reach
**only its own host**. No TUN device is created and `run` does not need root.
The tunnel is dialled from this machine. Inside it, the bastion accepts on
exactly three addresses, all under the `inference` capability family: a
forward to a local ollama (port 11434), a forward to an OpenAI-compatible
server when one is configured (port 18080), and a forward to a local PII
sterilizer when one is configured. Nothing else listens.
## Git lane
A loopback HTTP proxy plus a `git-credential` helper, so your local `git` reaches
`git.privatae.ai` through your host. The forge answers platform addresses only,
so this lane is the way in. `privatae-bastion git-setup` prints the three
config lines: the proxy, a reset of the credential-helper chain for that URL
(so no other helper caches your token on this machine), and the helper itself.
## Worker lanes
Outbound-only reverse relays to the host: egress, management, harness, and
session. The management lane runs a **fixed-argv allowlist**. Ops that touch
your files are dedicated Go handlers with no shell. Of those file handlers,
the one that runs a program is text extraction: it invokes a local tool named
in a fixed table
(`pdftotext`, `ffprobe`, and the like) from a fixed argv template, and the
caller supplies only the tool's name and the document.
## Session lane
An interactive terminal that runs on this machine and streams to the host over
the same reverse WebSocket the other lanes use. No inbound port, no SSH server.
It is your own act in your Terminal app, and needs no standing grant from the
host (0.39.0): on a machine anchored to a host, `session:open` takes your
approval from your device or this console, bound to that one session; on a
local machine the pairing's caps are the authorization. A grant the host still
presents is verified as before, and a deny in it refuses. `session:open` runs
as an unprivileged user when the daemon is root. `session:root` runs as root
only when the daemon is root **and** you approved that specific session here
with the enrolled second factor. Every session is logged locally (output only) and recorded in the
pairing's audit file. Not available on Windows.
**Root commands (0.49.0).** The anchor host can also run one command at a
time as root, with no terminal and no approval per command, once you have
granted it here:
```sh
sudo -H privatae-bastion root-exec grant --host <anchor host> --for 30d # asks for the code enrolled here
privatae-bastion root-exec [--host <host>] # what is granted, until when
privatae-bastion root-exec revoke [--host <host>] # take it back
```
The grant names one host (the anchor; a member pairing never gets it) and
this machine, ends at the time you chose, and is written only by that command
with the code; nothing the host sends can create, widen or extend it. Each
command runs as `/bin/sh -c` in its own process group, with a deadline (10
minutes unless the host asks for less or more, never more than 8 hours) and
with stdout and stderr returned up to a size cap (256 KiB each unless the host
asks, at most 4 MiB); a cut output keeps its start and its end and says how
many bytes were dropped. At the deadline the whole group gets SIGTERM, then
SIGKILL 2 seconds later, so a child that ignores SIGTERM goes too. The
bastion must run as root, the `session` family switched off here stops it,
and a quarantined pairing runs nothing. These are checked again every 2
seconds while a command runs: a revoke, the grant's expiry, the switch or a
quarantine stops a command already running, and its answer says why. Every
command is written whole, with its output (up to 64 MiB), to the context
store's `sessions/<id>.log`, and recorded at start and stop in the audit file
with its sha256, exit and byte counts; a refused, malformed or failed one gets
an audit row too. Not available on Windows.
The bastion also records where each root command connects. On Linux the
command starts in a cgroup of its own, and an nftables table the bastion
creates for that command records every connection the command and its
children open; the table only records (it never allows or drops anything)
and is removed when the command ends, or at the next start if the bastion
died mid-command. Destinations outside the platform's own hosts are returned
to the host as `untrusted_connections`, and the host then treats that output
as untrusted and ends root for the turn. List the platform's own hosts in
`platform_hosts.json` in the bastion's config folder:
```json
{"hosts": ["2001:db8::/32", "203.0.113.0/24", "git.example.org"]}
```
With no list, every connection that leaves this machine counts as outside;
loopback never does. A command whose connections could not be watched is
reported as if it had connected out. What it cannot see: connections another
program makes on the command's behalf, such as dockerd pulling an image for
`docker build`, or a service `systemctl` starts. A root command can also leave
its cgroup or delete the table on purpose; a missing table reads as "not
watched", but a command that leaves its cgroup is not detected.
+67
View File
@@ -0,0 +1,67 @@
# Repository layout
```
client-go/ the bastion: one Go package, one binary
main.go CLI entry point and built-in help
run.go daemon: tunnel, lanes, heartbeat, control ports
pair.go pairing and key generation
pair_unix.go pairing with a Host on this machine over its enroll socket
coder_socket.go a coder run's socket for a Host on this machine
anchor.go networked-mode anchor
mgmt*.go management lane, allowlist and audit
auditlog*.go every audit row also written to the system log
writes.go which management ops are writes (and need an approval)
device*.go the approval device
quarantine.go closing a pairing's write lanes after repeated refusals
ops*.go host-written ops: terms, sandbox, dry run and apply
service*.go service slot: servers with no network, reach grants, mirrors
session*.go session lane (interactive terminal)
dcn*.go Distributed Context Network store handlers
context*.go folder sharing and on-machine text extraction
modelswap.go model store
inferencefilter.go filter on the ollama forward (scoped pairings, local models switch, withheld grants)
mirror.go memory mirror: the host's database replica on your disk
stepup*.go second-factor enrolment and approvals
agents*.go agent observe
freeagent*.go the bastion agent: install, run and supervise it (see below)
release_sign.go the release keys and the manifest signature check
computer*.go computer observe
observe*.go host observe, baseline and delta
ui*.go local console
uiassets/ console HTML and CSS
deploy/
VERSION current release version
install.sh macOS / Linux installer
install.ps1 Windows installer
build.sh reproducible cross-compile, shared by publish and verify
publish.sh release pipeline: build, sign the manifest, publish, verify
release-sign/ the signing tool: runs where the release key is, never here
release-verify.sh check a manifest's signature against the pinned keys
verify-release.sh rebuild a published release and compare its digests
fuzz.sh run every fuzz target for a bounded time
check-claims.sh check that every test the security table cites exists
docs/ reference pages, listed in docs/README.md
LICENSE GNU GPL v3
```
The bastion agent (repository `bastion-agent`) was first called freeagent,
and some names keep that word on purpose. They are stable names, not
leftovers, and are not renamed:
- the Go files and identifiers above (`freeagent*.go`, `fa*`);
- the agent's state directory, `/var/lib/privatae/freeagent` on a root
install, where each installed agent keeps its key, vault and logs;
- the heartbeat fields `freeagent_ok` and `freeagent_stores`, and the
`freeagent` capability, which the platform reads;
- the `FREEAGENT_*` environment variables the bastion sets for the agent
process, and the `freeagent: 1` schema field of the agent's manifest.
Renaming the directory, the fields or the variables would break an installed
machine or the platform's reader for no change in behaviour, and renaming the
code would only make its history harder to follow. The public addresses were renamed:
from 0.42.0 the bastion reads only `privatae.ai/bastion-agent/version.json`
and the `bastion-agent-<version>.pyz` it names. The legacy
`privatae.ai/freeagent/` address has been retired and no longer answers.
Bastions from 0.20.0 to 0.41.x only fell back to it, so they read the new
address. A bastion older than 0.20.0 read only the old one: it finds the
address gone and keeps the agent it has installed.
+12
View File
@@ -0,0 +1,12 @@
# Memory mirror
When your host asks for it, the bastion keeps a streaming replica of the
host's database on hardware you own: a PostgreSQL standby running the same
image the host runs, fed through a forwarder in this process that carries the
replication stream over the tunnel. The host decides whether a mirror exists
and when a copy is void; this machine holds the copy. The forwarder listens
on loopback and on the replica's private container-network gateway address,
never on all interfaces, and every accepted connection is source-checked
against the replica's subnet. The data on your disk is ordinary PostgreSQL
files, unencrypted: it is your copy, readable with your own tools, and
full-disk encryption on your machine is the protection.
+23
View File
@@ -0,0 +1,23 @@
# Model store
The host may manage which models live on this machine through the
`model:list`, `model:pull` and `model:drop` ops, under their own capability
family (`models`, separate from `inference`). Using a loaded model and deciding
what gets downloaded onto your disk are different permissions. A `model:pull`
needs your approval **on this machine**, is bound to that exact model, role and
byte ceiling, is good for one use, and is stopped if the download turns out
larger than approved. A machine with no second factor enrolled refuses
`model:pull` outright. `model:drop` refuses any model that this pairing did not
pull, so a model you fetched for your own work cannot be removed through that
op. A pull is reported available only after a behavioural probe, because a
completed download proves only that bytes arrived.
Note the boundary: those rules govern the `model:*` ops. The host's direct
traffic to the ollama forward inside the tunnel is filtered (store-changing
requests such as `/api/pull` and `/api/delete` refused, model names checked)
only when the pairing is **scoped to named models**, when you have switched
the `models` capability off in the local console, or when a networked
bastion's grant withholds model management. Otherwise, on a local bastion
with an unscoped pairing, that forward is a raw byte-for-byte splice to your
ollama, and the pairing itself is the authorization. Scope the pairing on
your host, or switch `models` off here, if you want the filter.
+70
View File
@@ -0,0 +1,70 @@
# Observe: what is running on this machine
Read-only, and reported so the host can show **why** rather than a verdict.
- **Agent observe.** Watches for AI-agent activity two ways and correlates by
process: a process signal (known agent binaries, interpreter-wrapped ones by
command line) and a network signal (connections to inference endpoints, both
cloud APIs and local model servers). Each observation carries an **origin**
(`interactive`, `remote`, `scheduler`, `service`, `agent`) and a typed
evidence list with a confidence tier. Tools that drive the machine with no
model of their own are inventoried as `automation` and promoted to `agent`
only when model traffic joins them. Each also says who owns the account it
runs as: `owner_class` (`domain_user`, `local_user`, `service`, `system`),
`owner_admin` and `owner_interactive` (each `yes`, `no` or `unknown`; a
group list or token that could not be read is `unknown`, never `no`).
- **Declared tool servers.** The files where Claude Desktop, Claude Code,
Cursor, VS Code, Windsurf, Codex and Zed declare their MCP servers, per
account and for the projects Claude Code lists, read and never written or
connected to. Per server: client, scope, path, name, transport, whether it is
a local process or a URL (and the URL's host), the launch command truncated
like an agent's, the package and whether it is pinned, the **names** of the
environment variables it gets, whether a value looked like a secret (the
value is dropped where it is read), whether a remote server has
authentication configured, and whether it is running, declared but not
running, or running with no declaration. A command line keeps a URL's
scheme and host only, and every flag, header or `NAME=value` that names a
secret is replaced. A file is opened only once it is known to be a regular
file (on Windows: a reparse point, directory or device at the path is
refused, checked on the open handle), and in an account's home or project
only when that account or root owns it (on Windows: the account whose
profile it is, SYSTEM or the Administrators group; `files_foreign_owner`
counts the rest). The files are read at most every ten minutes, and a read
that does not finish holds the beats for at most 20 seconds in total
(`timed_out` says it did not finish; until one read has finished, no server
is reported).
- **Scheduler inventory.** Crontabs, `systemd` timers, `launchd` jobs and
Windows scheduled tasks whose command would run an agent or automation.
- **Baseline and delta.** `observe:baseline` records what is normal on this
machine: every automated program, its user, and a digest of the file it runs
from. `observe:delta` reports what the baseline does not hold, with ordinary
noise filtered out. Administrator only.
- **Computer observe.** The machine map the host plans from instead of a
screenshot: installed applications with their scripting doors, the windows
open now, and one app's accessibility tree as exact text. Read-only. No input
injection lives in the bastion.
- **Host observe** (Linux). Sockets, firewall, failed units and timers, disk,
kernel log, and a read-only Kubernetes view where the control-plane installer
has placed a `view`-bound kubeconfig. Every argv is fixed. Off Linux each of
these answers `unsupported`. The one exception is `observe:net`, which is
pure Go and works on every platform: given a `host[:port]` it makes an
outbound probe **from this machine** (TCP connect, TLS expiry and chain,
HTTP status).
- **Monitoring observe** (`observe:alerts`, `observe:metrics`, `observe:logs`).
The pull half of the same family: read-only HTTP against an Alertmanager,
Prometheus and Loki running on the box's own network, named once at install
in a local config file. Only private addresses are accepted — the reverse of
`observe:net`'s public-only rule, since these read the box's own
neighbourhood. `observe:alerts` reports the active Alertmanager set;
`observe:metrics` and `observe:logs` each take one name from a fixed list
and the box owns the query — no PromQL or LogQL ever crosses the wire from
the host. Alert counts also ride the heartbeat, refreshed on their own
interval, and are absent — not zero — whenever no Alertmanager is
configured.
The observe ops that send traffic are `observe:net`, `observe:k8s` (a
read-only query of a cluster API) and the three monitoring ops above (private
addresses only, refused at the socket if a redirect or a DNS answer would move
the destination after validation). A build with no enumerator for its
platform **withholds** the capability, so the host can tell a machine that is
quiet apart from one that is blind.
+106
View File
@@ -0,0 +1,106 @@
# Ops and action ops
## Ops: programs your host wrote, run in a sandbox first
A host can ship an op, a small program (`op.yaml` plus `run`) it wrote and
tested, identified by the sha256 of its contents. It runs here only under
terms the host signed and you approved: once, with these parameters, or as a
standing grant with parameter bounds, an expiry and a use count (never as
root). Every run is a dry run first, in a throwaway sandbox with no network,
an overlay over this machine's disks, and this bastion's own records masked;
it reports what the op would create, change and remove. An op sees only the
system's programs and libraries, a few `/etc` files that hold no secret, and
the paths its manifest declares (its effects and its `reads`). Every other
file and folder is covered with an empty one. A link is left in place, so an
op can read where a link points, but not what is there. The op can also see
the names of the folders it was denied, in its own mount table. A declared
path that reaches a credential store is refused, as written and as it
resolves, and checked again at every run. The stores are `/root`, `/etc/ssh`,
`/etc/shadow`, `/etc/wireguard`, `/etc/systemd`, docker's and k3s's keys and
more, plus any path you list in this machine's `sensitive_paths`. Covers are
put on paths, so a bind mount or a hard link that you make yourself, putting
a credential file inside a declared path, is seen through it. List such a
path in `sensitive_paths`. Anything outside the
op's declared file effects refuses the run. Applying is the same run again,
then a check in a fresh sandbox. A one-time apply is bound to the change list
the dry run showed you, and lands nothing else. A run under a standing grant
is bound to the grant's parameter bounds and the op's declared effects
instead: you approved the grant, not each run's change list. Linux only, and file effects only; other platforms refuse every op
and say so. `privatae-bastion ops` lists what is held, and `ops drop <grant>`
takes a standing grant back.
## Action ops: ship steps, run the same way
A ship step acts rather than edits: it builds and pushes an image, moves a
source tree to a new commit, restarts a service, rolls a workload. An op with
`kind: action` does this in two parts, and approving it works the same way:
a dry run first, then an apply bound to the change list the dry run showed.
- **The op's program runs unprivileged.** Each run gets a uid no earlier run
had, in its own systemd transient unit, with no capabilities and no new
privileges. This machine's files
are read-only to it: it writes only to a private work folder at `/tmp` and
its own throwaway `/run` and `/var/tmp`. It sees only what a files op sees
(above).
- **Its network, in an apply,** reaches only the `destinations` it declares
(`host:port`):
- the unit's address allowlist holds it to the addresses those names
resolved to when the run began;
- Landlock holds its TCP connections to the declared ports, and blocks
abstract unix sockets and signals outside the sandbox;
- UDP is held to the declared addresses but not to their ports, and a port
declared for one address is open on every declared address;
- a destination on this machine, a link-local address, or a cloud's
metadata service is refused, as written and as resolved.
**A dry run has no network at all,** because nobody approved it.
- **Memory: no ceiling, and the op goes first.** A run may use whatever
memory the machine has to spare. It is the first thing stopped when the
machine runs short:
- the kernel, if it must kill, kills the op before anything else;
- the bastion stops the op when free memory falls to a floor. The floor is
the largest of 1 GiB, 5% of memory, and, on a Kubernetes node, the
kubelet's own eviction line (hard or soft) plus 512 MiB, so the op goes
before any pod is evicted. A kubelet setting it cannot read raises the
floor, never lowers it;
- the bastion also stops the op when the machine's services, pods or users
start waiting on memory: 7 points more of the time than the least they
had during the run. The op's own waits don't count, and neither does a group that was
already waiting, such as a pod at its own limit;
- a machine already under the floor starts no op;
- a run counts as stopped only if the kill landed.
The refusal says what happened and how much the op had used.
- **Credentials:** it is handed the `credentials` it declares, sealed by the
host to this machine's encryption key and bound to that run, in its
environment only. A credential a verb names goes to the verb, never to the
program. They are scrubbed from its output and gone when the run ends. A
dry run is handed none.
- **The builder:** an op that declares `builder` reaches this machine's
builder socket in an apply, never in a dry run, and never through a
privileged group.
- **Root work is done by this bastion, as named verbs** the manifest declares
with their arguments written in:
- `tree.checkout` moves an existing git tree forward to an exact commit from
its own origin. It runs only in a tree root owns, whose `.git` no one
else can write, and never writes into a folder another user controls. It
never runs the tree's hooks, never overwrites an ignored file the machine
keeps, and refuses a tree with local changes or a git config that could
run a program.
- `unit.reload` and `unit.restart` act on one systemd service, never the
bastion's own.
- `k8s.set_image` sets one container's image to a digest, only forward:
- the digest needs a receipt this machine signed for an image one of its
op runs reported, built from the repository the manifest names;
- its commit must descend from the one behind the live image;
- the live image's commit counts only if this machine rolled it. An
image it did not roll is refused, and the refusal says so.
- `logs.check` returns a verdict and one line, read after the apply.
Only a commit and a digest may come from the run's parameters. The program
cannot ask for a verb. An op with verbs is root, so it is never a standing
grant.
What a program pushed becomes a receipt, signed by this machine. The
heartbeat says whether this machine can run action ops (`action_ops`) and
carries its encryption key, signed by the machine's own key. A run that
this bastion's restart cut short leaves nothing: the next start unmounts
its files and stops its unit.
+160
View File
@@ -0,0 +1,160 @@
# Security model
- **The bastion dials out.** Every lane is a reverse connection from this
machine to the host. No port is opened on an externally reachable interface;
the control ports and console bind loopback only, and the optional
memory-mirror forwarder binds loopback plus the replica container's private
gateway address, with every accept source-checked.
- **Inside the tunnel, only gated listeners.** The host can connect to up to
three inference forwards (local ollama, an OpenAI-compatible server, a PII
sterilizer), each a proxy to a service you run, on loopback by default and
elsewhere only if you point it there by environment variable; and to one
port per server in the service slot (18100 to 18199), joined to that
server's socket; and to one port per coder run (18200 to 18299), joined to
that run's control socket. Every connection is checked when it is accepted: a forward
needs the pairing to hold the `inference` capability family, a service
slot port needs the `services` family and a reach grant for that server
naming that host, and a coder run port needs the `harness` family, the run
to be that host's and within its deadline, and the connection to come from
that host's own tunnel address. Nothing else in the tunnel accepts a connection.
- **A Host on this machine gets the same walls.** Its lanes go over its lane
socket with mutual TLS against the CA pinned at pairing, the pairing's name
checked and the bearer on every lane; a pairing with no bearer opens none.
A coder run it owns is a socket in its own runs directory (0660, group
`privatae-host`, else root), checked at every accept as a run port is, and
removed at run-down and at the watchdog's stop. The runs directory must be
one only root can change (root's, writable by nobody else, under
directories that are root's and not writable by others unless sticky), or
no socket is made in it. On an anchored machine its run-up needs the
standing coder grant like any pairing's.
- **Scoped tunnel.** The WireGuard peer's only allowed address is the paired
host's single tunnel address, so the tunnel can reach nothing else. A
pairing that offers anything wider is refused, and a pairing file that
holds one brings no tunnel up.
- **No MCP endpoint.** Nothing on this machine drives the host.
- **Grants are verified here.** What the host may do arrives signed. The verify
key is pinned on first use, and a deny always beats an allow.
- **Fixed argv.** Every management op runs a fixed command line. File ops are
dedicated Go handlers with no shell. Text extraction runs a named local tool
from a fixed template.
- **Approval on the machine, once enrolled.** With a second factor enrolled
here, folder grants, move plans, model pulls and root sessions each need a
code from this machine, bound to that exact request, single use. Without
one, grants and move plans rest on the signed grant alone, and model pulls
and root sessions are refused.
- **On an anchored machine, a write needs an approval verified and spent
here.** It comes from the second factor enrolled here or from your pinned
approval device, is bound to the op and a digest of its arguments, and is
used once; with neither enrolled, those writes are refused. The exceptions
take no fresh approval: standing work under a place you set up at the
machine (backup fetch and backup push, the host's context store), a backup
restore request (approved on its own, below), runs under op terms you
approved earlier, a root command under the grant you gave here with the
code, the anchor host's signed pairing records, revokes, and a
self-update to a newer version we signed. Repeated refused writes close a pairing's
write lanes for a while.
- **Host-written programs run sandboxed first.** An op runs only under signed
terms you approved, as a dry run in a sandbox with no network, and never
changes anything outside its declared file effects. A one-time apply lands
only the change list you saw; a run under a standing grant is held to the
grant's bounds.
- **A server in the service slot has no network.** The host's tunnel is its
only way in, and it cannot reach this machine, your network or the
internet.
- **Egress for someone else reaches the public internet, never this
machine.** A coder run's proxy, always, and the egress lane on an anchored
machine refuse loopback, link-local (the cloud metadata address included),
private, CGNAT and reserved addresses, and any address this machine takes
as its own: one on its interfaces (a public one included), and on Linux any
address the kernel's routing table delivers here (a `local`, broadcast,
anycast or multicast route, such as an AnyIP range on `lo`), asked of the
kernel per connection. The IPv4 address inside an IPv4-mapped, NAT64 or
6to4 address is checked too, every address a name resolves to is checked,
and the address checked is the one dialled. When the machine cannot tell,
it refuses. A coder run reaches a LAN host only when you named it in
`coders set --allow-lan`; the anchored egress lane has no such exception.
The anchored lane's refusal of this machine's own non-private addresses is
new in 0.50.0; before it, that lane refused only the internal ranges. A
local (unanchored) machine's egress lane is unchanged and vets no
destination. The backup lanes (`backup_fetch` and `backup_push`, anchored
or not) apply the same check of this machine's own addresses, to every
address the URL's name resolves to, to each redirect a fetch follows and to
the address dialled; this too is new in 0.50.0, and before it they refused
only loopback, private, link-local, multicast, unspecified, CGNAT and ULA
addresses. Also since 0.50.0 they classify an address as a coder run's
proxy does: the IPv4 address inside a NAT64 `64:ff9b::/96` or 6to4
address is judged, and IPv4-compatible and IPv4-translated addresses, the
rest of `64:ff9b::/32`, site-local `fec0::/10`, `0.0.0.0/8`,
`198.18.0.0/15` and `240.0.0.0/4` are refused. Not covered: a public
address that reaches this machine only through a 1:1 NAT in front of it,
which is on no interface and in no local route. The bastion does not learn
that address.
- **A terminal is checked here and logged.** On a local (unanchored) machine,
a terminal opens when the host asks, with no approval. No signed grant is
needed, but one that is sent is verified, and a deny in it refuses. On an
anchored machine, a host other than the anchor needs the `session` family
in its pairing grant, and each terminal needs an approval from your pinned
approval device or the second factor enrolled here, bound to that one
session and used once. A root terminal, on either kind of machine, needs
the bastion to run as root, a second factor enrolled here, and a code
entered on this machine for that session; an approval device cannot approve
it. Each session, and each refusal of a well-formed request, is written to
the audit log on this machine when the pairing has a grant store; a
malformed request is refused with no audit line.
- **Root commands run only under a grant given here with the code.** Only
the anchor host can hold it. It names this machine, ends when you said, is
revoked from this console, and needs the bastion to run as root. A revoke
or an expiry also stops a command already running, within 2 seconds. Every
command and its output is written to a transcript here and to the audit
log; refusals, malformed commands and setup failures are audited too.
- **Root only where there is no one else to ask.** Claiming ownership or the
anchor requires root because those are machine-wide facts.
- **On macOS and Windows, a networked bastion is the system's service.** It
runs as root under launchd, or as LocalSystem under the Windows service
manager, starts at boot and is restarted when it stops, so the signed-in
user cannot stop it. It is registered only from a program that only root or
an administrator can replace. Beside it, `pair`, `run` and `status` from an
account that does not administer the machine are refused, so this binary
will not start a second bastion in that account. An older or modified copy
is not bound by that refusal; what keeps the service in place is that the
signed-in user does not administer the machine.
- **Observation changes nothing on the machine.** Observe ops read and report.
No input injection lives in the bastion. The observe ops that send traffic
are `observe:net`, an outbound probe to a host you name; `observe:k8s`, a
read-only query of a cluster API; and the three monitoring ops
(`observe:alerts`, `observe:metrics`, `observe:logs`), read-only HTTP
against an Alertmanager/Prometheus/Loki on the box's own network —
private addresses only, re-checked at the socket so neither a redirect nor
a DNS answer can walk a query out of that neighbourhood.
- **A backup's key goes to a host only when you approve it here.** A host's
request to restore files a request and gets nothing until you approve it
on this machine; the key sealed to the host then opens that one backup and
no other, and this bastion's key never leaves this machine. Thinning the backup
folder removes only backup files this bastion recorded fetching, still as
they arrived, judged by arrival here. A host sends from this machine only
backup files in its own folder or a folder you shared with it.
- **No delete op on shared stores.** The one write lane is a move plan you
approved, and it archives rather than deletes.
- **Verified releases.** The self-updater checks the release manifest's
signature against the release keys compiled into it, then checks the
binary's SHA-256 against that manifest before anything is swapped. The
agent download is checked the same way, and an installed agent is never
replaced with an older one. The release key never reaches the machine that
builds and publishes: each release waits until the key holder signs it on
their own computer. The installer checks the SHA-256; see
[SECURITY.md](../SECURITY.md) for what that does and does not prove.
- **No secrets cached for git.** The credential helper fetches a token from
the host on every auth and stores nothing itself, and `git-setup` resets
the forge's helper chain before adding it. With git 2.46 or later, git's
own store and cache helpers keep nothing, wherever they are configured; a
helper that records everything it is given still receives the token. A git older than 2.46 gets the username
and password, and for it only helpers configured before `git-setup`'s
lines are kept out.
## How each claim is tested
[claims.md](claims.md) names, for each point above, the test that fails if
it stops being true, and says where there is none.
To report a security issue, please do not open a public issue. See
[SECURITY.md](../SECURITY.md).
+12
View File
@@ -0,0 +1,12 @@
# Service slot: a server your host built, with no network
A host can leave a server running here, such as a private git server: an
image started with every capability dropped, no new privileges, one data
directory, and **no network at all**. It serves on a unix socket, and the
host's tunnel is the only way in; the bastion checks on every connection that
the socket belongs to the server's own user. Reaching a server needs a reach
grant you approved, naming that host and that server. If the server should
mirror public repositories, this machine does the fetching over its own
connection (public internet only, https, no redirects) and pushes into the
server. It is its own capability family, `services`. Linux only, and only
where a container runtime is installed.
+71
View File
@@ -0,0 +1,71 @@
# Ship plans: one approval for a whole ship
A ship is several action ops on several machines, in order: build and push
on one, move a tree and restart on another, roll a workload on a third. Your
device signs **one plan** listing every step, and each machine runs only its
own lines of it.
**What your device signs:**
- the pipeline, a version that only goes up, the commit being shipped, a
nonce, and an expiry of at most four hours;
- each step: its machine, its op and that op's sha256, its params, the verbs
it will run, the params it takes from an earlier step's receipt, and whether
it may be undone;
- the key of every machine in it. The device fills these in from its own pins
(`pin-machine`), never from your host. When you pin your device on a
machine, the machine prints the exact `pin-machine` line to run on the
device.
**Before signing, the device:**
- refuses a plan naming a machine it has not pinned;
- asks for your code when a plan runs root work, checks it against the
factor enrolled on the device's own pairing (never one the ask names), and
says in the plan that it did;
- keeps such a plan out of "approve all shown";
- shows every field as plain text: a plan whose params, inputs or verbs hold a
control character, a line break or a Unicode direction mark is refused, so
nothing in it can redraw or hide what you read.
**Each machine, before it runs a line, checks that:**
- the plan is signed by a device pinned there;
- it has not expired, and it is not older than a plan it already ran for that
pipeline;
- it names this machine's own key for this machine;
- the line is for this machine, and has run fewer times than the plan
allows (once, unless you approved retries). Its attempts run one at a
time: the next only after the last one failed, never after one passed, and
never after the step was undone. The machine keeps this record itself, and
an attempt the bastion died in counts as failed. A tree or workload
something else moved since the line's changes were read — while its
program ran, or between two attempts — is left alone: the verb refuses
it, so an undo can never take that other change back. Changes this
bastion makes to one tree or workload, forward or undo, take turns;
a change from outside is caught one command before the write;
- every line before it succeeded, shown by their signed receipts, so a check
never runs before the step it checks;
- a root line comes with your code;
- the op it holds declares exactly the verbs the line showed you.
**Values that don't exist when you approve,** like the digest a build
produces, come from receipts. Each line ends in a receipt signed with the key
your device pinned for that machine, and the machine keeps it for your host
to read back (`op_plan_status`). A roll takes its digest only from a
successful earlier step of the same plan, at the plan's commit: the image the
plan's own build step reported, never one from another run. That rests on the
build op reporting truly, and that op is one you approved in the plan. The first roll on a machine with no
record of its live image says so on the device, and takes that image as its
starting point.
**Undo.** A step marked undoable is taken back once, to the state that
machine recorded before the step's first attempt. The machine records each
change before it makes it, so a retry, or a change the bastion died in the
middle of, is taken back too. An undo takes one of two signed receipts:
- a later step of the same plan failed its check (its apply ran, its verify
or log check did not pass) on the last attempt the plan allows it, so no
retry of it can still pass;
- the step's own latest attempt failed, and none of its attempts passed.
The tree or image goes back first; a unit the step restarted is restarted
after, on what was put back. A tree or workload that moved since is left
alone, and a file the machine keeps is never overwritten. Once undone, the
step never runs again.
+14
View File
@@ -0,0 +1,14 @@
# Heartbeat and self-update
Each check-in carries the machine's capabilities, posture, agent scan,
scheduler inventory and declared tool servers (the last three only to a
pairing granted `observe`). The host can direct endpoint rotation and a self-update.
The update is fetched from the release manifest; the manifest's signature is
checked against the release keys compiled into this binary and the binary's
digest against the manifest before it is swapped. On an anchored machine a
move to a newer version needs no approval from you: our signature on the
release is the approval, and the update goes ahead only when the signed
manifest names exactly the version your host asked for. A move to an older
version, or to one this build cannot compare with its own, waits for your
approval, bound to that version. Switching `directives` off in the local
console refuses both.