bastion 0.53.2
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s

This commit is contained in:
Privatae
2026-10-20 04:42:14 +00:00
commit e4c9b69b46
471 changed files with 159794 additions and 0 deletions
+770
View File
@@ -0,0 +1,770 @@
package main
// sanction.go — LOCAL SANCTION: the user, at their own machine, saying
// "this one is mine".
//
// NO BUILD TAG, on purpose, exactly like agents_parse.go: every judgement in
// here has to be exercised by the one `go test` people actually run, and the
// predicate below is the seam a policy engine will be built on.
//
// ── WHY THIS EXISTS ─────────────────────────────────────────────────────────
//
// This is the harder half of the pitch, and it comes second in the build
// order. The pitch is to identify the good so the bad can be terminated, and
// until this there was no way for a user to say THIS ONE IS MINE. Without
// that, a policy engine's first act is to kill the user's own editor.
// Detection is largely built — four kinds (agents.go:177), origin
// (origin.go), typed evidence (origin.go:53) — and containment is real
// (contain.go). What has been missing is the only input that makes any of it
// safe to act on.
//
// ── TWO MECHANISMS, AND THEY CAN NEVER BE ONE ───────────────────────────────
//
// THE HOST'S TAG. `DelegationToken` (agents.go:203) is read out of the
// process's OWN ENVIRONMENT — `procEnvToken` reads /proc/<pid>/environ for
// PRIVATAE_AGENT_TOKEN (agents_linux.go:149), `ps -E` on darwin
// (agents_darwin.go:192), the PEB on Windows (agents_windows.go:498). An
// environment variable is fixed at exec time by whoever called exec. It can
// mark an agent the HOST LAUNCHED. It can never mark a process that is already
// running, which is every process the user is looking at when they open this
// page.
//
// LOCAL SANCTION is the other half and it cannot use that mechanism at all.
// Nothing here mints a tag, writes to another process, or re-execs anything.
// It records, on this machine, that a person said a program is theirs — and a
// record is the only form this can take, because the thing being marked is
// already running.
//
// ── WHAT IDENTITY A SANCTION IS RECORDED AGAINST ────────────────────────────
//
// agentSanction gets one agentObs (agents.go:166-213) and nothing else, so the
// identity has to be built out of its fields. Taken one at a time:
//
// PID (agents.go:167) Worthless. It changes on every exec, and contain.go's
// own header records the shape that makes it worse than
// useless: "An agent under a supervisor comes straight
// back with a new pid." A sanction keyed on a pid is
// void by the next restart.
// PPID The same fact, one level up.
// Signature (agents.go:180) The TABLE ENTRY that matched (agents.go:34-59) —
// the PRODUCT, not the instance. Stable, and derived
// from the basename of argv[0] (agents_parse.go:47-49),
// so on its own it means "every claude-code on this box
// is mine" and anything that calls itself `claude` is a
// claude-code.
// Cmdline (agents.go:181) Stable for one exact invocation and different the
// moment an argument changes. `claude` and
// `claude --resume abc` would be two separate marks.
// That is the shape of control people stop using. Its
// first token — argv[0] — keyed this record before
// the exe identity; see THE FORGERY THIS CLOSES.
// Exe (agents.go:182-201) The KERNEL's record of which FILE the process is
// running. The ONLY field in an observation that the
// process did not choose for itself. EMPTY when the
// platform could not read it, and never back-filled.
// User (agents.go:179) Coarse alone; load-bearing as a component, because a
// mark must not cross OS accounts on a shared machine.
// Kind (agents.go:178) A class, not an identity; load-bearing as a
// component, because "the Claude desktop APP is mine"
// must not quietly also vouch for a claude-code AGENT.
// Origin, LLMEndpoints, All vary run to run. An identity built on them would
// Evidence need re-marking whenever the agent talked to a
// different endpoint.
//
// CHOSEN: the four-field tuple (Kind, Signature, Exe, User), fingerprinted.
// The signature stays in the tuple because for an agent that is a SCRIPT the
// kernel's executable is the interpreter, not the agent (see WHAT THIS STILL
// CONCEDES): without the signature, marking one node-hosted agent would vouch
// for every node process the plane observes for that user.
//
// Matched EXACTLY, never normalised. A launcher that spells the same path
// differently (Windows case, a symlink vs its target on macOS) produces a
// different identity and needs its own mark. That is a false NEGATIVE — it shows
// as unsanctioned — and unsanctioned is the safe direction for a wrong answer
// here.
//
// AN OBSERVATION WITH NO KERNEL PATH CANNOT BE MARKED AT ALL. Not "falls back to
// argv[0]", not "matches on the rest of the tuple": addSanction refuses to write
// one and agentSanction answers sanctionWhyNoExe. A fallback would mean the
// forgery below still worked, and would work precisely where this box is most
// blind — a process it cannot read is not a process it may vouch for. The cost
// is real and lands unevenly, so it is named in the platform table below rather
// than discovered later.
//
// ── WHAT EACH PLATFORM ACTUALLY SUPPLIES ────────────────────────────────────
//
// Linux /proc/<pid>/exe (agents_linux.go, procExe). A link the KERNEL
// maintains from the vnode, resolved: a `#!/bin/bash` script reported
// /usr/bin/bash here while its own argv[0] said /bin/bash. Strongest
// of the three. Readable for any process of the same user, and for
// every process when the bastion runs as root.
// macOS The exec path the kernel saved at exec time, read from
// KERN_PROCARGS2 (agents_darwin.go, procExe; decoded by
// parseProcargs2ExePath). NOT `ps -o comm=`, which three comments in
// this tree called the kernel's path and which is argv[0]: Apple's ps
// maps comm to just_command (adv_cmds ps/keyword.c:101), and
// getproclline (ps/print.c:115) skips the saved exec_path to reach
// argv[0]. The saved path is the string PASSED to execve, kept before
// namei resolves it (xnu bsd/kern/kern_exec.c exec_save_path), so it
// is the path the kernel opened but NOT re-derived from the vnode: a
// symlink stays the symlink. Same-user or root only.
// Windows QueryFullProcessImageName with PROCESS_QUERY_LIMITED_INFORMATION
// (agents_windows.go, winImagePath). NOT Toolhelp32's ExeFile
// (origin_windows.go:50), which is a BASENAME with no directory — so
// "claude.exe" in %LOCALAPPDATA% and "claude.exe" in %TEMP% were one
// string. QUERY_LIMITED is less than the PEB reads need, so a process
// whose memory is closed to the bastion can still be marked.
// other Empty, always (agents_other.go). That build reports it cannot watch
// itself, and now also cannot vouch for anything — which is the honest
// pair of statements for a box with no enumerator.
//
// ── WHAT THIS STILL CONCEDES ────────────────────────────────────────────────
//
// The forgery is NOT eliminated. Its price changes, and the price is the claim:
//
// BEFORE choose a string. One exec with a chosen argv[0] — free, needs no
// access to anything, leaves no trace on disk.
// AFTER control a FILE at the sanctioned path. Write access to that exact
// path (or to its directory), which for `~/.local/bin/claude` is the
// user's own account and for `/usr/local/bin/...` is root.
//
// So an attacker who can already write where the user's agent lives inherits
// the mark, and nothing here pretends otherwise. What it costs them is a
// durable artifact on disk in a place a person can look, instead of a flag on
// one exec.
//
// THREE MORE THINGS IT DOES NOT FIX, each of which has to be said out loud:
//
// - AN AGENT THAT IS A SCRIPT PINS ITS INTERPRETER, NOT ITSELF. On every
// platform the kernel's executable for a `#!` script is the interpreter —
// measured: /usr/bin/bash for a shell script here; macOS stores the shebang
// line's interpreter explicitly (xnu kern_exec.c, exec_save_path on
// ip_interp_buffer). For that class the tuple is (agent, <signature>,
// /usr/bin/node, user), and the only part telling one node-hosted agent
// from another is the SIGNATURE, which is derived from argv
// (agents_parse.go:43-68). Interpreter-hosted agents therefore keep an
// argv-shaped component: the forgery narrows from "any argv[0]" to "that
// argv[0] AND the same interpreter path", which is a smaller set and not a
// hard one to enter.
// - AN EMPTY EXE IS UNMARKABLE, AND A POLICY ENGINE MUST NOT READ THAT AS
// UNWANTED. sanctionWhyNoExe exists so the two are different answers.
// - THE PATH IS NOT THE FILE. Nothing here checks that the bytes at that path
// are the bytes the user vouched for; see THE NEXT RUNG.
//
// Therefore, unchanged in size: **LOCAL SANCTION IS A CONVENIENCE AND VISIBILITY
// CONTROL ON THE USER'S OWN MACHINE. IT IS NOT AN AUTHENTICATION BOUNDARY, AND
// NOTHING DOWNSTREAM MAY TREAT IT AS ONE.** README.md sets the same bar for the
// plane underneath it — the observe plane is "soft and evadable by design:
// visibility and early-warning, never the security wall". This is the same kind
// of thing and the same size of claim.
//
// What it buys, which is not nothing: the UNSANCTIONED SET becomes small enough
// for a person to read, a policy engine gains a way to not kill the user's
// editor on its first run, and the cheapest evasion now needs write access
// somewhere a person can audit.
//
// ── THE FORGERY THIS CLOSES ─────────────────────────────────────────────────
//
// Measured on this box, and it is one line:
//
// $ bash -c 'exec -a /home/user/.local/bin/claude sleep 25'
// /proc/<pid>/cmdline -> /home/user/.local/bin/claude 25
// /proc/<pid>/exe -> /usr/lib/cargo/bin/coreutils/sleep
//
// Every platform enumerator reads the process's own command line
// (agents_linux.go:41, agents_darwin.go:88, agents_windows.go:374), so while
// argv[0] was in the tuple that process matched a mark made for the real
// claude EXACTLY, without ever touching that file. The test that must never go
// green again is TestAForgedArgv0FromADifferentExecutableIsNotSanctioned.
//
// ── THE NEXT RUNG, NAMED AND NOT BUILT ──────────────────────────────────────
//
// Verify the executable itself rather than its path: Authenticode on Windows
// (WinVerifyTrust), `codesign -v` / the SecStaticCode API on macOS, and on
// Linux no standard equivalent at all — there is no system-wide signature
// scheme for ELF binaries, so it would have to be a hash the user pins.
//
// The tradeoff is the reason it is not here: A CONTENT HASH PINS A VERSION.
// Every legitimate update rewrites the binary, so every update un-sanctions the
// agent, and the user re-marks their editor on a release cadence they do not
// control. That is the same "control they stop using" failure the STORAGE
// section is written around. A signature CHAIN (publisher, not bytes) survives
// updates and is the only version of this worth building — which is why it is
// Windows and macOS first, and why Linux would need something else entirely.
//
// ── THE HOST TAG: THIS BOX CANNOT CHECK IT ──────────────────────────────────
//
// The tag is minted on the HOST as `"del_" + 18 url-safe random bytes`, and
// which tags are live lives in the host's delegations table. The
// reconciliation is explicitly the host's, and its own schema states the rule:
// the bastion observes the tag from the agent's env and reports it; the host
// reconciles, and an observed agent carrying a valid (non-revoked, non-expired)
// tag for its server counts as requested. Nothing in this repo holds a copy of that table; the
// only `del_` in the tree is test fixtures and one comment
// (agents_parse.go:703).
//
// So what this box can check is SHAPE, and the reason string says exactly that
// rather than implying more. A tag whose shape is wrong is NOT sanctioned: a
// real host tag always has the shape, so no real agent is refused by it, and a
// string that is not a host tag is not evidence the host launched anything.
//
// A shape check costs an attacker 24 random characters. It is not a control. It
// is here so the sentence shown to the user can be precise about which of the
// two mechanisms answered.
//
// ── RECORDS WRITTEN BEFORE THIS (v1), AND WHY THEY ARE NOT HONOURED ─────────
//
// A sanctions.json written by the argv[0] version may already be on a machine.
// Every one of those records says "the thing that CALLS ITSELF this is mine",
// which is the sentence this change exists to stop believing. Carrying them
// forward would preserve exactly the forgery being fixed, so:
//
// NOT MATCHED A v1 record has no Exe. sanctionRecord.stale() reports that,
// matching skips it, and the fingerprint domain moved to
// "privatae-sanction-v2" as well — so a v1 key cannot even be
// produced by a new observation. Two independent gates, because
// one of them being subtly wrong is how this kind of thing
// survives a migration.
// NOT DELETED It is the user's record of a decision they made. Deleting it
// silently would be this program throwing away their state and
// telling them nothing. It is kept, shown in its own section of
// the page with what it named and WHY it no longer counts, and
// the only button on it removes it.
// RE-MADE, NOT There is no "upgrade" path that could be honest: the v1 record
// UPGRADED holds a string the program chose, and nothing on this machine
// can turn that into the file the kernel loaded for a process
// that may not even be running. The user marks the live program
// again, which is one click on a page they are already on.
//
// The legacy value is retained on the record (sanctionRecord.LegacyArgv0) for
// ONE purpose: so the page can say what the old mark named. It is never an
// input to matching.
//
// ── STORAGE ─────────────────────────────────────────────────────────────────
//
// sanctions.json, beside events.json and the pairing state, in the config dir
// configDir() creates at 0700 (config.go:137-142). Written through atomicWrite
// (ctx.go:133-160 — temp file, chmod 0600, fsync, rename), so a reader never
// sees a half-written blob and a crash mid-write leaves the previous registry
// rather than an empty one.
//
// IT SURVIVES A RESTART, and the restart that matters is this binary's own
// self-update: maybeSelfUpdate replaces the executable and calls os.Exit(0)
// (update.go:127). events.go:35-45 records the same reasoning for the same
// directory. A sanction the user must re-apply after every update is a control
// they stop using, and the moment they stop, the unsanctioned set stops meaning
// anything.
//
// READS CREATE NOTHING. sanctionsPath is built on configDirPath()
// (config.go:129), which resolves the path without creating it — NOT configDir()
// (config.go:137), which is MkdirAll-on-read. agentSanction is a predicate a
// policy engine will call in a loop over every observation, and a predicate that
// creates a directory as a side effect of being asked a question is the exact
// defect ui_approvals_flow_test.go:53-84 exists to stop, which has already been
// written into this codebase twice. The WRITE path creates the directory, via
// atomicWrite's own MkdirAll(0700) at ctx.go:135.
//
// NO IN-MEMORY CACHE. The registry is read from disk on every call. The cheaper
// alternative — a package-level list loaded once, the shape events.go:81-90 uses
// — was rejected on two counts that outweigh the saving, and the saving was
// measured rather than assumed. On this box (go test -bench, EPYC 4345P):
// loadSanctions over a 5-record registry is 4.8µs; scanAgents(), which the
// CALLER must run to produce a single agentObs at all, is 24.2ms. The cache
// would be saving one part in five thousand. What it costs is worse than that:
// package state every test has to reset (events.go:47-57 records the accident
// that shape already caused in this package), and a copy that goes stale the
// moment anything outside this process writes the file.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"os/user"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// ── the reasons ─────────────────────────────────────────────────────────────
// The `why` a predicate returns is ALWAYS one of these constants. They are the
// stable half of the seam: a policy engine COMPARES (why == sanctionWhyHostTag),
// it never parses the sentence. They are written as sentences because the local
// UI renders them verbatim, and a reason a person cannot read is a reason that
// gets ignored.
//
// The two positive reasons are kept apart because "the host launched it" and
// "you marked it" are different facts and a later policy may want to treat them
// differently — one is an external authority this box cannot check, the other is
// a local record with an audit trail. sanctionWhyBoth exists so that a process
// carrying both does not silently lose one of them: collapsing to either would
// make the other unrecoverable by the caller.
const (
sanctionWhyHostTag = "The host launched it: this process carries a host-issued " +
"delegation tag. This machine cannot check whether that tag is still valid — " +
"only your host can."
sanctionWhyLocal = "You marked it: someone at this machine recorded this program " +
"as yours."
sanctionWhyBoth = "The host launched it — it carries a host-issued delegation tag, " +
"which only your host can check — and someone at this machine also marked it " +
"as yours."
sanctionWhyNone = "Nobody has vouched for this. It carries no host delegation tag, " +
"and nothing at this machine has marked it as yours."
sanctionWhyBadTag = "Nobody has vouched for this. It sets PRIVATAE_AGENT_TOKEN to " +
"something that is not shaped like a tag your host mints, and nothing at this " +
"machine has marked it as yours."
// sanctionWhyUnreadable is the one reason that comes back with
// sanctioned=TRUE without anything having vouched for anything. See
// agentSanction for why that is the safe direction, and why a caller that
// RENDERS the verdict has to test for this constant.
sanctionWhyUnreadable = "Unknown. This machine cannot read its own record of what " +
"you marked as yours, so it cannot say whether this was vouched for. Nothing " +
"here may be treated as unvouched until that is fixed."
// sanctionWhyNoExe (added with the exe identity) is NOT
// "nobody vouched for this". It is "this machine could not establish what
// this is", and the two must stay apart for the same reason
// sanctionWhyUnreadable is its own constant: a caller that terminates
// things is entitled to treat "I could not identify it" more carefully than
// "nothing has vouched for it", and it can only do that if the predicate
// says which one happened. It also tells a UI not to offer a mark button
// that could only fail.
sanctionWhyNoExe = "Nobody has vouched for this, and nothing here can. This machine " +
"could not read which file the process is actually running — the one thing " +
"about it that cannot be renamed — so there is nothing to mark as yours. Its " +
"command line is only the name it chose for itself."
)
// ── the host tag's shape ────────────────────────────────────────────────────
// hostTagPrefix and hostTagMinBody are the SHAPE of a tag the host mints:
// `"del_" + 18 url-safe random bytes`, which is 24 base64url characters after
// the prefix.
//
// The check is a FLOOR (16) rather than an equality (24) deliberately. A host
// that changes the entropy or the mint would otherwise make every tagged agent
// on every machine in the fleet read as unsanctioned overnight, and the box
// would be wrong in the direction that gets someone's work killed. 16 is still
// far past anything a person types into a shell by hand, which is the only
// thing this check actually catches.
const (
hostTagPrefix = "del_"
hostTagMinBody = 16
)
// looksLikeHostTag reports whether a delegation tag has the shape the host
// mints. It is NOT validation and nothing here pretends otherwise — see the
// header. The alphabet is base64url, which is what token_urlsafe produces.
func looksLikeHostTag(tok string) bool {
if !strings.HasPrefix(tok, hostTagPrefix) {
return false
}
body := tok[len(hostTagPrefix):]
if len(body) < hostTagMinBody {
return false
}
for _, r := range body {
switch {
case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
default:
return false
}
}
return true
}
// ── the identity ────────────────────────────────────────────────────────────
// sanctionIdentity is the tuple a local sanction is recorded against. The
// header argues each field; the short version is that this is the most specific
// thing in an agentObs that survives a restart.
type sanctionIdentity struct {
Kind string `json:"kind"`
Signature string `json:"signature"`
// Exe is the KERNEL's executable path for the process (agents.go, procExe
// per platform) — the file it is running, not the name it gave itself.
// EMPTY means this is not an identity: nothing may be marked against it and
// nothing may match it. The field it replaced was `argv0`, which is why the
// fingerprint's domain moved to v2.
Exe string `json:"exe"`
User string `json:"user"`
}
// sanctionIdentityOf derives the identity of one observation.
//
// a.Exe is taken VERBATIM. It is deliberately not defaulted, not cleaned up and
// not filled in from a.Cmdline when it is empty: every one of those is the same
// bug wearing a different hat, because the command line is the attacker-chosen
// value this record stopped keying on.
func sanctionIdentityOf(a agentObs) sanctionIdentity {
return sanctionIdentity{
Kind: a.Kind,
Signature: a.Signature,
Exe: a.Exe,
User: a.User,
}
}
// usable reports whether this identity can be marked or matched at all. An
// identity with no kernel path names nothing a later observation could be
// checked against — two different programs the box could not read would share
// it — so it is refused at both ends (addSanction, agentSanction).
func (id sanctionIdentity) usable() bool { return id.Exe != "" }
// fingerprint is the stable id of an identity: the value the UI posts and the
// key the registry is stored under.
//
// A hash rather than the tuple itself, for three reasons that are all about the
// form rather than about secrecy (the record stores the tuple in clear beside
// it): it is fixed-length, it is safe as a map key and a form value with no
// escaping, and a domain prefix means changing the fields cannot silently
// collide with an older record.
//
// THE DOMAIN IS v2 BECAUSE THE THIRD FIELD CHANGED MEANING. v1 hashed argv[0];
// this hashes the kernel's path. Bumping it makes it arithmetically impossible
// for a new observation to land on a v1 key — so the "old marks must not
// silently match" rule holds even if the Exe check below were removed by
// accident. That is the whole reason the prefix was there in the first place.
func (id sanctionIdentity) fingerprint() string {
sum := sha256.Sum256([]byte("privatae-sanction-v2\x00" + id.Kind + "\x00" +
id.Signature + "\x00" + id.Exe + "\x00" + id.User))
return hex.EncodeToString(sum[:16])
}
// describe is the identity in the words the page and the record use. Kept here
// so the page and the audit line can never drift apart.
//
// "run from" rather than "called" on purpose: the value is now the file, and
// the sentence a person reads should say which of the two it is.
func (id sanctionIdentity) describe() string {
what := id.Signature
if what == "" {
what = "an unnamed program"
}
if id.Exe != "" {
what += " run from " + id.Exe
}
if id.User != "" {
what += ", as " + id.User
}
return what
}
// ── the record ──────────────────────────────────────────────────────────────
// sanctionBy names the MECHANISM that made a mark, so a record written by a
// later surface (a CLI verb, another page) is distinguishable from this one
// without anybody having to date it.
const sanctionByLocalUI = "local-ui"
// sanctionRecord is one mark. It holds three separate things on purpose:
//
// the identity what a future observation is matched against
// Cmdline WHAT WAS SANCTIONED, as it looked at the moment of the
// click. Never matched on; it is what lets someone reading
// this record a month later see what the person was actually
// looking at, which the identity alone does not show.
// By / ByUser / At BY WHOM, HOW and WHEN.
type sanctionRecord struct {
ID string `json:"id"`
sanctionIdentity
Cmdline string `json:"cmdline,omitempty"`
At time.Time `json:"at"`
By string `json:"by"`
// ByUser is the OS account this program was running as when it wrote the
// record. It is NOT proof a human did anything — the bastion is commonly
// root — and it is empty rather than guessed when it cannot be read.
ByUser string `json:"by_user,omitempty"`
// LegacyArgv0 decodes the `argv0` field of a v1 record — the identity this
// registry used before the exe identity. NEVER WRITTEN by this code and never an
// input to matching; it exists so the page can tell the user what an expired
// mark named, instead of showing them a record with an empty description and
// asking them to trust it was theirs. See the header, RECORDS WRITTEN
// BEFORE THIS.
LegacyArgv0 string `json:"argv0,omitempty"`
}
// stale reports a record that can no longer vouch for anything: a v1 mark, kept
// for the user to see and remove, keyed on a string the program chose for
// itself. The test is the absence of a kernel path rather than the presence of
// the legacy field, because the property that matters is "there is nothing here
// to check against", which is also true of a hand-edited or truncated record.
func (r sanctionRecord) stale() bool { return !r.usable() }
// describeMark is what the page calls a record. It is NOT the promoted
// describe(): a stale record's path lived in a field the identity no longer
// has, and printing it as "run from …" would state the exact thing this change
// stopped believing. Named rather than shadowing so no call site picks the
// wrong one by accident.
func (r sanctionRecord) describeMark() string {
if !r.stale() {
return r.sanctionIdentity.describe()
}
what := r.Signature
if what == "" {
what = "an unnamed program"
}
if r.LegacyArgv0 != "" {
what += " that called itself " + r.LegacyArgv0
}
if r.User != "" {
what += ", as " + r.User
}
return what
}
// sanctionNow is the clock seam, exactly as events.go:89 and
// directive_refusal.go:35 do it: a test pins the timestamp rather than sleeping.
var sanctionNow = time.Now
// sanctionMu serialises read-modify-write. Two POSTs can land at once — the
// page is a browser and a browser will happily send both.
var sanctionMu sync.Mutex
// sanctionsPath is the registry's home. configDirPath(), NOT configDir(): see
// the header. Resolving a path must not create a directory.
func sanctionsPath() (string, error) {
d, err := configDirPath()
if err != nil {
return "", err
}
return filepath.Join(d, "sanctions.json"), nil
}
// loadSanctions reads the registry, keyed by fingerprint.
//
// A MISSING FILE IS AN EMPTY REGISTRY AND NOT AN ERROR — that is the ordinary
// state of a machine where nobody has marked anything yet.
//
// AN UNREADABLE OR CORRUPT FILE IS AN ERROR, and this is where it deliberately
// parts company with events.go:128-143, which swallows the same failure. There,
// losing the file loses history. Here, treating an unreadable registry as empty
// would make every marked agent on the machine read as UNSANCTIONED, and the
// thing downstream of this predicate terminates processes. "I could not read it"
// and "there is nothing in it" have to stay different answers.
func loadSanctions() (map[string]sanctionRecord, error) {
p, err := sanctionsPath()
if err != nil {
return nil, err
}
b, err := os.ReadFile(p)
if err != nil {
if os.IsNotExist(err) {
return map[string]sanctionRecord{}, nil
}
return nil, err
}
var out map[string]sanctionRecord
if err := json.Unmarshal(b, &out); err != nil {
return nil, fmt.Errorf("%s: %w", p, err)
}
if out == nil {
out = map[string]sanctionRecord{}
}
return out, nil
}
// saveSanctionsLocked writes the whole registry. Caller holds sanctionMu.
func saveSanctionsLocked(m map[string]sanctionRecord) error {
p, err := sanctionsPath()
if err != nil {
return err
}
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return atomicWrite(p, b)
}
// sanctionRecords is the registry as a list, newest mark first — the order a
// person reads "what have I vouched for" in.
func sanctionRecords() ([]sanctionRecord, error) {
m, err := loadSanctions()
if err != nil {
return nil, err
}
out := make([]sanctionRecord, 0, len(m))
for _, r := range m {
out = append(out, r)
}
sort.Slice(out, func(i, j int) bool {
if out[i].At.Equal(out[j].At) {
return out[i].ID < out[j].ID // a stable page, not a shuffling one
}
return out[i].At.After(out[j].At)
})
return out, nil
}
// addSanction records one observation as the user's.
//
// IDEMPOTENT, and it keeps the ORIGINAL time: marking something already marked
// returns the existing record untouched. The first mark is the audit fact; a
// second click is not a new decision and must not overwrite when it was made.
//
// IT REFUSES AN OBSERVATION WITH NO KERNEL PATH, with an error the caller shows
// the user. This is the FIRST of the three gates on that rule — refuse the
// write, refuse the match, and a v2 fingerprint domain a v1 key cannot reach —
// and it is the one that keeps the registry itself clean: a record with no Exe
// is a record nothing could ever check, so writing one would only make the
// registry lie about how much has been vouched for.
func addSanction(a agentObs, by string) (sanctionRecord, error) {
id := sanctionIdentityOf(a)
if !id.usable() {
return sanctionRecord{}, errors.New("this machine could not read which file " +
"that process is running, so there is nothing here that can be marked as " +
"yours. A command line is a name a program chooses for itself")
}
sanctionMu.Lock()
defer sanctionMu.Unlock()
m, err := loadSanctions()
if err != nil {
return sanctionRecord{}, err
}
fp := id.fingerprint()
if existing, ok := m[fp]; ok {
return existing, nil
}
rec := sanctionRecord{
ID: fp,
sanctionIdentity: id,
Cmdline: a.Cmdline,
At: sanctionNow().UTC(),
By: by,
ByUser: currentOSUser(),
}
m[fp] = rec
if err := saveSanctionsLocked(m); err != nil {
return sanctionRecord{}, err
}
return rec, nil
}
// dropSanction removes one mark by id and says whether it was there.
//
// IT TAKES AN ID AND NOTHING ELSE, so a mark can be withdrawn for a program that
// is NO LONGER RUNNING. Requiring a live observation to un-mark would leave every
// record for a program the user has since uninstalled permanently on the machine
// with no way to reach it — which is the accumulation this page exists to prevent.
func dropSanction(id string) (bool, error) {
sanctionMu.Lock()
defer sanctionMu.Unlock()
m, err := loadSanctions()
if err != nil {
return false, err
}
if _, ok := m[id]; !ok {
return false, nil
}
delete(m, id)
return true, saveSanctionsLocked(m)
}
// currentOSUser is the account this program is running as, or "" when it cannot
// be read. Never guessed: an empty by_user is an honest "this machine could not
// say", and a made-up one would be worse than no field at all in a record whose
// job is to be auditable.
func currentOSUser() string {
u, err := user.Current()
if err != nil || u == nil {
return ""
}
return u.Username
}
// ── the seam ────────────────────────────────────────────────────────────────
// agentSanction reports whether this observation is one the user has
// marked as theirs, and why.
//
// THE CONTRACT, because a policy engine is being built on this:
//
// - `why` is ALWAYS one of the sanctionWhy* constants above. Compare it;
// never parse it. It is populated for a false verdict too — "nothing has
// vouched for this" is the answer the whole product claim is about, and a
// caller that has to infer it from an empty string will infer wrong.
// - BOTH mechanisms are accounted for, and a process carrying both comes back
// as sanctionWhyBoth rather than losing one of them.
// - The host tag is reported as PRESENT AND WELL-SHAPED, which is all this box
// can establish (see the header). A caller that needs to know a tag is live
// must ask the host.
// - This function creates nothing, writes nothing, and terminates nothing.
//
// AN OBSERVATION WITH NO KERNEL PATH (a.Exe == "") CAN CARRY A HOST TAG BUT CAN
// NEVER CARRY A LOCAL MARK, and the order below says so: the tag is an
// environment variable the host set at exec, which has nothing to do with
// whether this box can read the process's executable, so it is answered first
// and unchanged. Only the LOCAL half depends on the path.
//
// PRECEDENCE for the unsanctioned answers, stated because it is a choice:
// sanctionWhyNoExe outranks sanctionWhyBadTag. A malformed tag is a fact about
// a value this machine READ; no kernel path is a statement that it could not
// identify the program at all, and the second is the more honest headline when
// both are true. It also carries the more useful instruction for a caller —
// "do not offer to mark this, and do not read 'not marked' as 'not wanted'".
//
// WHEN THE REGISTRY CANNOT BE READ it returns (true, sanctionWhyUnreadable), and
// that is the one case where a true verdict does not mean anything vouched for
// anything. The direction is chosen from the asymmetry the whole design rests
// on: a missed rogue agent is a bad day, while a false positive that kills the
// user's own long-running job is the thing they tell other people about. A
// corrupt registry that read as "nothing is
// sanctioned" would hand a policy engine a machine where every process looks
// unvouched-for, which is the one input that turns this whole feature into the
// failure it was built to prevent.
//
// The cost of that choice is that an unreadable registry hides everything, and
// it is worth naming rather than hiding: the file is 0600 in the directory that
// already holds this machine's pairing credentials and TOTP seeds, so anything
// able to corrupt it can already do considerably worse than conceal a process.
// A caller that RENDERS this verdict must test for sanctionWhyUnreadable and say
// so, rather than drawing a tick — ui_approvals_flow_test.go:113-151 is the same
// lesson on the same kind of page: a read that FAILED is not a machine with
// nothing on it.
func agentSanction(a agentObs) (sanctioned bool, why string) {
tag := a.DelegationToken
hostTagged := tag != "" && looksLikeHostTag(tag)
id := sanctionIdentityOf(a)
if !id.usable() {
// No local mark can exist for this — addSanction refuses to write one —
// so the registry has no answer to give and is not even read. That also
// means an unreadable registry changes nothing here: the verdict would
// be the same with a perfect one.
if hostTagged {
return true, sanctionWhyHostTag
}
return false, sanctionWhyNoExe
}
m, err := loadSanctions()
if err != nil {
return true, sanctionWhyUnreadable
}
// The SECOND gate on a v1 record (the first is the v2 fingerprint domain):
// a record that cannot say which file it vouched for does not vouch, even
// if its key were somehow reached. Defence in depth against a hand-edited
// registry, and the property is checked directly rather than inferred.
rec, found := m[id.fingerprint()]
marked := found && !rec.stale()
switch {
case hostTagged && marked:
return true, sanctionWhyBoth
case hostTagged:
return true, sanctionWhyHostTag
case marked:
return true, sanctionWhyLocal
case tag != "":
// A tag is set and it is not shaped like one the host mints. Reported
// as its own reason because it is the one unsanctioned state with
// something a person can act on in it.
return false, sanctionWhyBadTag
}
return false, sanctionWhyNone
}