package main // policy.go — THE DRY-RUN POLICY: what this machine WOULD stop, with no // authority to stop anything. // // NO BUILD TAG, on purpose, exactly like agents_parse.go and sanction.go: every // judgement here decides whether somebody's work survives an automatic policy // later, so it has to be exercised by the one `go test` people actually run, // from any platform. // // ── WHY THIS EXISTS, AND WHY IT CANNOT ACT ────────────────────────────────── // // The conflict, stated plainly: the pitch says the product "identifies rogue // AI and automatically stops it", and the code does not — `maybeContain` runs // off the heartbeat response (contain.go:56, run.go:575), so the HOST decides // and this box carries it out. The work is ordered, and this unit is step 3 of // that order: // // 3. Dry-run policy. The rule engine, reporting what it WOULD contain, with // no authority to act. This is what earns the claim. // 4. Autonomous containment, opt-in, only after (3) has run long enough on // real machines to show its false-positive rate. // // So the one thing this file must never grow is a side effect. The reason is // the sentence the whole unit is built around: "A missed rogue agent is a // bad day. A false positive that kills the user's own long-running job is the // thing they tell other people about." policy_test.go parses this file and // uipolicy.go and fails if either one so much as NAMES killProcess (contain.go:41) // or maybeContain (contain.go:56). That test is the unit, more than the rule is. // // ── THE RULE, AND WHERE IT DEPARTS FROM THE SKETCH ────────────────────────── // // The original sketch: "an `agent`-kind process, with model traffic, carrying no // delegation tag, whose origin is not `interactive`". Each term below is // derived from what agentObs (agents.go:165-192) actually carries, and two of // them came out different from the sketch: // // SKETCH BUILT WHY // carrying no delegation tag → not SANCTIONED the tag is one of // TWO mechanisms; // reading the tag // direct would skip // the local mark and // re-implement the // shape check. // origin is not `interactive` → origin is one of scheduler, "not interactive" // service, agent includes `remote` // (a person over ssh) // and `unknown` (no // answer at all). // // The second correction is the load-bearing one. origin.go:19 defines `remote` // as "an ssh / mosh / screen-sharing ancestor: a person, elsewhere" — killing // an agent a person started over ssh is the same false positive as killing one // they started at the console, and a deny-list ("not interactive") quietly // swept it in. origin.go:25 defines `unknown` as "the box could not read enough // to say. Never guessed." — a deny-list read that absence of knowledge as // grounds to stop something. Both are fixed by making the term an ALLOW-LIST of // the origins where nobody is at a keyboard. // // ── EVERY TERM, AND WHY IT IS IN ──────────────────────────────────────────── // // KIND == agent (policyKindTerm). The outermost gate, and it returns before any // other term is even evaluated, so that nothing — not an unreadable registry, // not a missing origin — can move an app off "leave alone": // // app NEVER a candidate. contain.go:84-86 refuses to terminate one // however the host asks, and agents.go:170-176 records what it // cost to learn: one Windows box reported the Claude DESKTOP app // as nine "claude-code" agents (eight of them Chromium helpers) // and the host offered nine kill buttons. An alert that cries wolf // teaches the user to dismiss alerts, which is the one failure // this feature cannot survive. // automation NOT a candidate, and it needs no term of its own. An automation // is inventory — a browser driver, an input injector, an RPA // runtime with no model of its own (agents.go:61-65). The moment // STRONG model traffic joins it the plane relabels it `agent` // before this file ever sees it (agents_parse.go:421-426), so the // interesting case arrives already inside the term above. Adding // `automation` here would flag every chromedriver and every // playwright run on a developer's machine for nothing. // client NOT a candidate in v1, and this is a judgement call worth // stating rather than burying. A `client` is any process the // signature tables MISSED that holds a strong inference endpoint // (agents_parse.go:480-483) — on a real machine that is curl, a // notebook kernel, a python script, an IDE plugin. It also rests // on ONE signal, where the plane's whole design is two correlated // ones (the README, "Observe"). It is still evaluated, still // listed, and still carries its reasons, so the census shows what // a wider rule WOULD have caught before anybody widens it. // // MODEL TRAFFIC (policyTrafficTerm). The second, independent signal. A // signature is `known` tier at best because "a name is something a process // chooses" (origin.go:194-196); traffic to a model is the corroboration. The // term reads the endpoint list through endpointEvidence (origin.go:211-221) // rather than re-deciding what counts, so the tier rule lives in exactly one // place: a "?"-suffixed endpoint is an AMBIGUOUS port — one that collides with // ordinary web development (agents.go:146-151) — and the plane's standing rule // is that those annotate and never promote (agents.go:130-133). This rule obeys // it: an ambiguous endpoint alone is not enough to be a candidate. // // REJECTED: reading a.Evidence for a "model-endpoint" line. Evidence is // assembled by scanAgents (agents.go:269-273) and is absent on an observation // built anywhere else, so a policy keyed on it would silently answer "no // traffic" for every caller that is not the live plane. // // ORIGIN ∈ {scheduler, service, agent} (policyOriginTerm). "Nobody was at the // keyboard", stated positively. origin.go:14-15 is the reason the field exists // at all: the shape the headlines are about is "an agent that has been running // for months with nobody at the keyboard", and that needs no new table to see. // // NOT SANCTIONED (policySanctionTerm). The seam, agentSanction (sanction.go:535). // // REJECTED: reading a.DelegationToken here. It would re-implement // looksLikeHostTag (sanction.go:251), skip the local mark entirely — which is // the half a person can actually use (sanction.go:32-37) — and put a value the // page must never render (uisanction_test.go:331-351) into a second file. // This unit compares the REASON CONSTANT and never parses the sentence. // // REJECTED AS A TERM: User. It is already inside the identity a mark is // recorded against (sanction.go:274-292), so it is load-bearing here through // the sanction term. Adding it again — "only root", "only this user" — would be // a guess about which accounts matter on a machine this unit has never seen. // // ── THE ANSWER IS NOT A BOOLEAN ───────────────────────────────────────────── // // agents.go:183-188: the plane "never emits a bare verdict — it emits typed // evidence with tiers so the host shows WHY". A verdict nobody can audit is a // verdict nobody should trust, and this one is the input to a feature that // terminates processes. So every observation gets every term, with its tier and // a sentence, and THREE possible decisions rather than two: // // would-stop every term this machine could read points at stopping. // leave-alone a term this machine could read says no. Decisive. // cannot-judge no term says no, and a term the policy NEEDS could not be // read. This is not "nothing to judge" — it is "I cannot". // // cannot-judge is reserved for a term that could not be READ, never for one // that was read and came back empty. The two places the plane itself admits it // could not read are the only two that reach it: an origin of `unknown` // (origin.go:25, 190) and a sanction registry that would not load // (sanction.go:517-525). "No model traffic in this scan" is an ANSWER — the // connection table was read and this process held nothing — so it is a // decisive leave-alone, tiered ambiguous because a scan is a snapshot. // // ── THE ONE THAT WILL BE GOT WRONG: sanctionWhyUnreadable ─────────────────── // // agentSanction returns (TRUE, sanctionWhyUnreadable) when it cannot read the // registry, and sanction.go:517-534 argues that direction at length: a corrupt // registry that read as "nothing is marked" would hand a policy engine a // machine where every process looks unvouched-for. But that true is NOT a // vouch, it is an absence of knowledge, and a policy that treats it as "fine" // goes blind in silence — which is the failure this whole feature exists to // prevent. So policySanctionTerm tests the CONSTANT BY NAME, before it looks at // the bool, and returns "unknown" rather than "leave alone". A verdict built on // it is cannot-judge, and the page says so in those words. // // ── THE CENSUS: WHY THE VERDICT IS ALSO WRITTEN DOWN ──────────────────────── // // The false-positive rate is the thing step 4 waits on, so this unit has // to be able to answer, after a week on a real machine: what would this have // killed, and would that have been wrong? A page that renders the current scan // answers neither. So every census tick folds the would-stop set into a ledger // on disk, keyed by the SANCTION FINGERPRINT (sanction.go:302-306) — the // identity chosen precisely because it survives a restart at a new pid — and // the page then asks, of each past candidate, what became of it. One that a // person has SINCE MARKED AS THEIRS is a false positive this rule has been // measured making. That number is the metric, and it is nameable row by row. // // WRITING. A new candidate is written immediately; otherwise the ledger is // rewritten at most once every policyLedgerFlush, which also refreshes // last_scan. The first tick writes even with nothing to record, and both halves // of that matter for the same reason agents.go:594-598 gives about the // second-bastion scan: "A scan that stops completing keeps publishing its last // answer, and if that answer is [] the host would read 'clean' for ever with // nothing to say otherwise." Without the first write, a machine that has never // run the census is indistinguishable from one that ran it for a week and found // nothing; without last_scan, a census that died three days ago still reads as // an all-clear. // // READS CREATE NOTHING — policyLedgerPath is built on configDirPath() // (config.go:129), never configDir() (config.go:137), which is MkdirAll-on-read. // ui_approvals_flow_test.go:53-84 exists because a page that asked a question created // a directory, twice, and sanction.go:160-167 holds the same line for the same // reason. The WRITE path creates the directory, through atomicWrite's own // MkdirAll(0700) at ctx.go:135. import ( "context" "encoding/json" "fmt" "os" "path/filepath" "sort" "sync" "time" ) // ── the decisions ─────────────────────────────────────────────────────────── // The decision constants. A caller COMPARES these; it never parses them, the // same contract sanction.go:196-200 sets for its reasons. const ( policyWouldStop = "would-stop" policyLeaveAlone = "leave-alone" policyCannotJudge = "cannot-judge" ) // The terms, named so the page, the reasons and the tests share one vocabulary. const ( policyTermKind = "kind" policyTermTraffic = "model-traffic" policyTermOrigin = "origin" policyTermSanction = "sanction" ) // Which way a term points. Three values, not a bool, for the same reason the // decision has three: a term the machine could not read is a different fact // from a term that came back no. const ( policyPointsStop = "stop" policyPointsLeave = "leave" policyPointsUnknown = "unknown" ) // policyReason is one term's answer: which way it points, how well this machine // knows it, and a sentence a person can read. Shaped on `evidence` // (origin.go:53) and using the SAME tier constants, because the page renders // them beside the plane's own evidence and two tier vocabularies on one screen // would be two things to learn. type policyReason struct { Term string `json:"term"` Points string `json:"points"` Tier string `json:"tier"` Detail string `json:"detail"` } // policyVerdict is what this rule says about ONE observation. // // It embeds sanctionIdentity (sanction.go:274) rather than copying its fields: // that is the identity a mark is recorded against, so a verdict and a mark // cannot drift into describing different things, and describe() (sanction.go:310) // already writes the sentence the page and the ledger both need. type policyVerdict struct { ID string `json:"id"` sanctionIdentity PID int `json:"pid"` Origin string `json:"origin,omitempty"` Cmdline string `json:"cmdline,omitempty"` Endpoints []string `json:"endpoints,omitempty"` Decision string `json:"decision"` Reasons []policyReason `json:"reasons"` } // policyRuleLines is the rule in the words the page shows. It lives here, beside // the terms it describes, so that changing a term and forgetting the sentence is // one file's mistake rather than two. policy_test.go asserts every term constant // reaches the rendered page. func policyRuleLines() []policyReason { return []policyReason{ {Term: policyTermKind, Tier: tierKnown, Detail: "it is an agent — not an app you are running, not a browser driver " + "with no model behind it, and not an unknown process caught by one signal alone"}, {Term: policyTermTraffic, Tier: tierVerified, Detail: "it is talking to a model, at an address or a port distinctive enough " + "to stand on its own"}, {Term: policyTermOrigin, Tier: tierKnown, Detail: "it was started by a clock, by the system, or by another agent — " + "nobody was at the keyboard"}, {Term: policyTermSanction, Tier: tierKnown, Detail: "nothing has vouched for it: no host delegation tag, and nobody at " + "this machine has marked it as yours"}, } } // ── the rule ──────────────────────────────────────────────────────────────── // policyEvaluate is the rule, for one observation. // // THE KIND GATE RETURNS EARLY, and that is a structural choice rather than an // optimisation: it means no other term — not an unreadable registry, not a // missing origin — can move an app off leave-alone. contain.go:84-86 refuses to // terminate an app however the host asks; this rule cannot even propose one. func policyEvaluate(a agentObs) policyVerdict { id := sanctionIdentityOf(a) v := policyVerdict{ ID: id.fingerprint(), sanctionIdentity: id, PID: a.PID, Origin: a.Origin, Cmdline: a.Cmdline, Endpoints: a.LLMEndpoints, } kind := policyKindTerm(a) v.Reasons = []policyReason{kind} if kind.Points != policyPointsStop { v.Decision = policyLeaveAlone return v } v.Reasons = append(v.Reasons, policyTrafficTerm(a), policyOriginTerm(a), policySanctionTerm(a)) v.Decision = policyDecide(v.Reasons) return v } // policyDecide folds the terms into one answer. A term that says NO is decisive // — whatever else could not be read could not have changed the outcome, so the // answer is a confident leave-alone rather than a shrug. Only when every // readable term points at stopping does an unreadable one matter, and then it // matters completely. func policyDecide(rs []policyReason) string { unknown := false for _, r := range rs { switch r.Points { case policyPointsLeave: return policyLeaveAlone case policyPointsUnknown: unknown = true } } if unknown { return policyCannotJudge } return policyWouldStop } // policyKindTerm: WHAT the process is (agents.go:168-176). func policyKindTerm(a agentObs) policyReason { r := policyReason{Term: policyTermKind, Tier: tierKnown} switch a.Kind { case kindAgent: r.Points, r.Detail = policyPointsStop, "an agent: software that acts on its own." case kindApp: r.Points = policyPointsLeave r.Detail = "software you are running that happens to talk to a model. This machine " + "refuses to close an app however your host asks, so a rule that proposed one " + "would be proposing something that cannot happen." case kindAutomation: r.Points = policyPointsLeave r.Detail = "an automation — it drives the machine but has no model of its own. If a " + "model joins it, this machine relabels it an agent and it lands back in this rule." case kindClient: r.Points = policyPointsLeave r.Detail = "an unknown process caught by its network traffic alone. One signal is not " + "enough to propose stopping something; it is listed here so you can see what a " + "wider rule would have caught." default: // The plane gives every observation a kind (agents_parse.go:242-252), so // this is reachable only from a stub or from a kind added upstream that // this rule has not been taught. Not a candidate is the safe direction. r.Points, r.Tier = policyPointsLeave, tierAmbiguous r.Detail = "this machine did not classify it as an agent." } return r } // policyTrafficTerm: the second, independent signal. func policyTrafficTerm(a agentObs) policyReason { r := policyReason{Term: policyTermTraffic} // endpointEvidence (origin.go:211-221) is the tier rule itself: a // "?"-suffixed label is an ambiguous port. Calling it keeps that judgement // in one place instead of re-deciding it here. ev := endpointEvidence(a.LLMEndpoints) for _, e := range ev { if e.Tier != tierAmbiguous { r.Points, r.Tier = policyPointsStop, tierVerified r.Detail = "talking to " + joinMax(a.LLMEndpoints, 3) + "." return r } } r.Points, r.Tier = policyPointsLeave, tierAmbiguous if len(ev) > 0 { r.Detail = "the only model traffic seen (" + joinMax(a.LLMEndpoints, 3) + ") is on a " + "port that ordinary web development also uses, so it corroborates and never " + "promotes. If a model really does serve there, tell this machine (OLLAMA_ADDR " + "or PRIVATAE_LLM_ADDR) and the port becomes evidence." return r } r.Detail = "no connection to a model when this machine last looked. That is one scan, " + "not a guarantee." return r } // policyOriginTerm: how it was started (origin.go:8-30). An ALLOW-LIST — see // the header for why "not interactive" was the wrong shape. func policyOriginTerm(a agentObs) policyReason { r := policyReason{Term: policyTermOrigin, Tier: tierKnown} switch a.Origin { case originScheduler: r.Points, r.Detail = policyPointsStop, "it fired on a clock: cron, a timer, or a scheduled task." case originService: r.Points, r.Detail = policyPointsStop, "it runs as a service, with no session behind it." case originAgent: r.Points, r.Detail = policyPointsStop, "another agent on this machine started it." case originInteractive: r.Points = policyPointsLeave r.Detail = "it was started from a terminal or a desktop session on this machine — " + "a person is plausibly sitting there." case originRemote: r.Points = policyPointsLeave r.Detail = "it was started over ssh or a shared screen — a person, elsewhere. Stopping " + "it would take someone's work away exactly as stopping one at this keyboard would." case originUnknown, "": r.Points, r.Tier = policyPointsUnknown, tierAmbiguous r.Detail = "this machine could not establish how it was started. That is not a " + "clearance and it is not grounds to stop anything." default: r.Points, r.Tier = policyPointsUnknown, tierAmbiguous r.Detail = "this machine reported an origin this rule has not been taught (" + a.Origin + "), so it cannot say whether a person is behind it." } // Tiered `known` even for an answer the plane called `verified`. classifyOrigin // returns verified for a session or a scheduler parent and known for an // inventory match (origin.go:169, 182), and this unit cannot tell which // answered without re-walking the tree. Reporting the weaker of the two can // never overstate what is known. return r } // policySanctionTerm: the seam (sanction.go:535). // // THE CONSTANT IS TESTED BY NAME, BEFORE THE BOOL, and it has to be: an // unreadable registry comes back (true, sanctionWhyUnreadable), so a term // written as `if sanctioned` reads a machine that lost its own records as a // machine where everything is vouched for, and says nothing. See the header. func policySanctionTerm(a agentObs) policyReason { sanctioned, why := agentSanction(a) r := policyReason{Term: policyTermSanction, Tier: tierKnown, Detail: why} switch { // TWO CONSTANTS MEAN "COULD NOT READ", AND NEITHER MEANS "UNWANTED". // // sanctionWhyUnreadable was here from the start. sanctionWhyNoExe arrived // later, with the change that moved a mark off the forgeable argv[0] onto // the kernel's executable path — and it returns FALSE, because nothing has // vouched for the process. Left in the default branch it scored // policyPointsStop, so every observation whose executable path this box // cannot read became a PROPOSED KILL. That is not a rare case: it is every // process on a build with no enumerator, and on Windows every process whose // handle cannot be opened. sanction.go says in as many words that it means // UNIDENTIFIED, not unwanted (sanction.go:150). // // Caught at integration, because neither unit could see it. The policy was // written against six constants; the seventh was added afterwards, in another // change whose brief was not to alter the seam — and it did not alter it. // It ADDED to it, correctly, and the consumer had no way to know. A seam is // not safe just because its signature held. case why == sanctionWhyUnreadable, why == sanctionWhyNoExe: r.Points, r.Tier = policyPointsUnknown, tierAmbiguous case sanctioned: r.Points = policyPointsLeave default: r.Points = policyPointsStop } return r } // policyVerdicts is the rule over everything this machine can see right now. // // observedAgents (contain.go:34) rather than scanAgents directly, for the // reason uisanction.go:130-133 gives: it is the same var the thing that KILLS // consults, so the dry run and the real containment can never be answering // different questions — and it is the seam every test in this package stubs. // // The error is the walk failing: no verdicts, and the caller must not read // that as "nothing would have been stopped". The error wins over anything // that came back beside it (the list is not returned with it) — the same rule // the kill guard and the agents page hold. func policyVerdicts() ([]policyVerdict, error) { seen, err := observedAgents() if err != nil { return nil, err } out := make([]policyVerdict, 0, len(seen)) for _, a := range seen { out = append(out, policyEvaluate(a)) } return out, nil } // policyWouldStopCount is the headline number: how many of the observations in // this set the rule would stop, and how many it could not judge at all. func policyWouldStopCount(vs []policyVerdict) (stop, cannotJudge int) { for _, v := range vs { switch v.Decision { case policyWouldStop: stop++ case policyCannotJudge: cannotJudge++ } } return stop, cannotJudge } // ── the census ────────────────────────────────────────────────────────────── // policyCandidate is one program this rule would have stopped, and when. // // It embeds sanctionIdentity for the same reason policyVerdict does, and here // there is a second: the ledger outlives the process, so the only thing that can // name a row a week later is an identity built to survive a restart // (sanction.go:38-78). Everything prefixed Last is what the program looked like // the last time it was seen — carried so a person reviewing this can recognise // it, never matched on. type policyCandidate struct { ID string `json:"id"` sanctionIdentity FirstSeen time.Time `json:"first_seen"` LastSeen time.Time `json:"last_seen"` LastPID int `json:"last_pid,omitempty"` LastOrigin string `json:"last_origin,omitempty"` LastCmdline string `json:"last_cmdline,omitempty"` LastEndpoints []string `json:"last_endpoints,omitempty"` } // policyLedger is the census on disk. // // Since answers "how long has this been measured", LastScan answers "is it // still being measured", and the difference between them is the whole reason // both exist — see the header. type policyLedger struct { Since time.Time `json:"since"` LastScan time.Time `json:"last_scan"` Dropped int `json:"dropped,omitempty"` Candidates map[string]policyCandidate `json:"candidates"` } const ( // policyCensusEvery — WHY 60 SECONDS. It is the cadence this box ALREADY // samples this exact data at: every heartbeat calls scanAgents() // (agents.go:479) and the beat is a 60-second ticker (run.go:596). So the // census adds one more scan per minute to a box that already runs one per // minute per pairing — not a new cost class, and the same resolution the // host's own agent list has. The limit is honest and stated on the page: // anything that lives and dies inside a minute is invisible to a sampler, // exactly as it is invisible to the heartbeat. policyCensusEvery = 60 * time.Second // policyCensusFirst keeps the first scan clear of daemon startup, the same // reason update.go:251-257 gives for its own first check. policyCensusFirst = 20 * time.Second // policyLedgerFlush — how stale last_seen and last_scan are allowed to get. // A NEW candidate is written the moment it appears; this governs only the // clock. Five minutes is 288 writes a day of a file measured in kilobytes, // against a stopped-census window nobody would notice. The page states the // granularity rather than implying a precision the file does not have. policyLedgerFlush = 5 * time.Minute // policyLedgerMax bounds the file. A machine with more than this many // DISTINCT unvouched-for automated identities has a different problem than // this page can help with, and the alternative — an unbounded file fed by // anything that launches itself from a fresh temp path each run — is a disk // the user did not agree to spend. The oldest go first and the count of what // went is kept, so the page can say the census is trimming rather than // quietly becoming a lie. policyLedgerMax = 500 ) // policyNow is the clock seam, as sanction.go:353 and directive_refusal.go:35 // do it: a test pins the timestamp rather than sleeping. var policyNow = time.Now // policyMu serialises the ledger's read-modify-write. var policyMu sync.Mutex // The last census failure, kept so the PAGE can say the record is not being // written — the shape directive_refusal.go:30-36 uses, and for its reason: a // failure that only ever reached a console is a failure nobody standing at the // machine can see. A census that cannot write and says nothing would render as // "nothing would have been stopped", which is the most reassuring sentence this // program could print about a machine it has stopped watching. var ( policyErrMu sync.Mutex policyErr string policyErrAt time.Time ) func recordPolicyCensusError(err error) { policyErrMu.Lock() defer policyErrMu.Unlock() if err == nil { policyErr, policyErrAt = "", time.Time{} return } policyErr, policyErrAt = err.Error(), policyNow().UTC() } func policyCensusError() (string, time.Time) { policyErrMu.Lock() defer policyErrMu.Unlock() return policyErr, policyErrAt } // policyLedgerPath is the census's home. configDirPath(), NOT configDir(): // resolving a path must not create a directory. See the header. func policyLedgerPath() (string, error) { d, err := configDirPath() if err != nil { return "", err } return filepath.Join(d, "policy-dryrun.json"), nil } // loadPolicyLedger reads the census. // // A MISSING FILE IS AN EMPTY LEDGER AND NOT AN ERROR — but the caller can still // tell "never run" from "ran and found nothing", because a ledger that has ever // run has a Since. A CORRUPT FILE IS AN ERROR, the same line loadSanctions // draws at sanction.go:373-379: "I could not read it" and "there is nothing in // it" have to stay different answers, and here the second one is the all-clear. // // A corrupt file therefore STOPS the census until somebody removes it, because // policyRecord will not write over a read it could not make. That direction is // chosen rather than fallen into: this file is the record of what a rule would // have taken away from a person, and overwriting it because it did not parse // destroys the evidence instead of the problem. The page says so, where the // person is, rather than leaving it to a log. func loadPolicyLedger() (policyLedger, error) { l := policyLedger{Candidates: map[string]policyCandidate{}} p, err := policyLedgerPath() if err != nil { return l, err } b, err := os.ReadFile(p) if err != nil { if os.IsNotExist(err) { return l, nil } return l, err } if err := json.Unmarshal(b, &l); err != nil { return policyLedger{Candidates: map[string]policyCandidate{}}, err } if l.Candidates == nil { l.Candidates = map[string]policyCandidate{} } return l, nil } // savePolicyLedgerLocked writes the census. Caller holds policyMu. func savePolicyLedgerLocked(l policyLedger) error { p, err := policyLedgerPath() if err != nil { return err } b, err := json.MarshalIndent(l, "", " ") if err != nil { return err } return atomicWrite(p, b) // ctx.go:133-160 — temp file, 0600, fsync, rename } // policyTrim drops the oldest candidates once the ledger is over its cap, and // counts what went. Oldest by LAST seen rather than first: something that has // been a candidate for months and still is has more to say about this rule than // something last seen in March. func policyTrim(l *policyLedger) { if len(l.Candidates) <= policyLedgerMax { return } ids := make([]string, 0, len(l.Candidates)) for id := range l.Candidates { ids = append(ids, id) } sort.Slice(ids, func(i, j int) bool { a, b := l.Candidates[ids[i]], l.Candidates[ids[j]] if a.LastSeen.Equal(b.LastSeen) { return a.ID < b.ID // a stable trim, not a random one } return a.LastSeen.Before(b.LastSeen) }) for _, id := range ids[:len(ids)-policyLedgerMax] { delete(l.Candidates, id) l.Dropped++ } } // policyRecord folds one scan's would-stop set into the census. // // It records ONLY would-stop. A cannot-judge is not a candidate — the rule did // not reach a verdict — and writing one into a list headed "what this would // have stopped" would put a machine's blind spot in a column of accusations. // The page carries the cannot-judge count separately, where it reads as the // alarm it is. func policyRecord(vs []policyVerdict) error { now := policyNow().UTC() policyMu.Lock() defer policyMu.Unlock() l, err := loadPolicyLedger() if err != nil { return err } changed := l.Since.IsZero() // the first scan writes, even with nothing to say if changed { l.Since = now } for _, v := range vs { if v.Decision != policyWouldStop { continue } c, ok := l.Candidates[v.ID] if !ok { // A program this rule has never proposed stopping before. Written // immediately, not at the next flush: it is the event the census // exists to catch, and a crash in the next five minutes must not // lose it. c = policyCandidate{ID: v.ID, FirstSeen: now} changed = true } c.sanctionIdentity = v.sanctionIdentity c.LastSeen = now c.LastPID, c.LastOrigin, c.LastCmdline = v.PID, v.Origin, v.Cmdline c.LastEndpoints = v.Endpoints l.Candidates[v.ID] = c } if now.Sub(l.LastScan) >= policyLedgerFlush { changed = true } if !changed { // Nothing new, and the clock is fresh enough. The LastSeen bumps above // are dropped rather than kept in memory — a cached copy of a file // something else may write is the shape sanction.go:169-178 rejects, and // the cost is a last_seen accurate to policyLedgerFlush, which the page // says out loud. return nil } l.LastScan = now policyTrim(&l) return savePolicyLedgerLocked(l) } // policyCensusTick is one pass: evaluate, record, remember any failure. // // A failed walk is RECORDED as the census failure, not written as a clean pass: // policyRecord over no verdicts would have cleared the stored error and the // page would then read "nothing would have been stopped" about a machine the // census could not see. func policyCensusTick() { verdicts, err := policyVerdicts() if err != nil { recordPolicyCensusError(fmt.Errorf("the process walk failed, so this pass judged nothing: %w", err)) return } recordPolicyCensusError(policyRecord(verdicts)) } // runPolicyCensus keeps the census going for as long as the daemon runs. Shaped // on runUpdateAvailabilityPoll (update.go:265-280), started beside it from // startUIServer (ui.go). // // IT MUST NOT BE DRIVEN BY THE PAGE. Rendering is a read, and a read that writes // to disk is the defect ui_approvals_flow_test.go:9-15 was written about; worse, a // census that only accumulates while somebody has a browser open would answer // "nothing would have been stopped" for every machine nobody was watching, which // is every machine this measurement is for. func runPolicyCensus(ctx context.Context) { select { case <-ctx.Done(): return case <-time.After(policyCensusFirst): } for { policyCensusTick() select { case <-ctx.Done(): return case <-time.After(policyCensusEvery): } } }