689 lines
29 KiB
Go
689 lines
29 KiB
Go
package main
|
|
|
|
// The dry-run policy (policy.go): the rule, the census it writes, and the one
|
|
// property the whole unit exists to have — that it cannot stop anything.
|
|
//
|
|
// NOT tagged !windows, for the same reason sanction_test.go is not: this rule
|
|
// decides whether a Windows user's own editor would survive an automatic policy,
|
|
// so it has to be proven on that build too.
|
|
//
|
|
// Every test below was MUTATION-CHECKED — the code it covers was broken, the
|
|
// test confirmed red, and the code was restored. The mutation is named in each
|
|
// comment so the next person can re-run it rather than take this on trust.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// policyTestBox isolates the config dir and stubs the observe plane.
|
|
//
|
|
// It uses sanctionTestBox (sanction_test.go:30), which CREATES NOTHING and
|
|
// fails if the config dir already exists: whether asking this rule a question
|
|
// creates a directory is one of the claims below, and a fixture that supplies
|
|
// the thing under test can only ever pass (ui_approvals_flow_test.go:9-15).
|
|
func policyTestBox(t *testing.T, seen ...agentObs) string {
|
|
t.Helper()
|
|
cfg := sanctionTestBox(t)
|
|
prev := observedAgents
|
|
t.Cleanup(func() {
|
|
observedAgents = prev
|
|
policyNow = time.Now
|
|
recordPolicyCensusError(nil)
|
|
})
|
|
observedAgents = func() ([]agentObs, error) { return seen, nil }
|
|
recordPolicyCensusError(nil)
|
|
return cfg
|
|
}
|
|
|
|
// policyCandidateObs is an observation that meets every term of the rule: an
|
|
// agent, talking to a model at a distinctive address, started by the system,
|
|
// with nothing vouching for it.
|
|
//
|
|
// Exe IS REQUIRED and was added at integration. These fixtures were written
|
|
// before the change that moved a mark off the forgeable argv[0] onto the
|
|
// kernel's executable path, so they carried none — and agentSanction answers
|
|
// sanctionWhyNoExe for an observation with no path, which the policy scores
|
|
// cannot-judge rather than would-stop. Every verdict test here was passing for
|
|
// the wrong reason: the fixture was unidentifiable, and under the old six-reason
|
|
// seam that happened to land on the same answer as "nothing vouched for it".
|
|
// A rule test needs a process the machine can actually identify.
|
|
func policyCandidateObs(sig, cmd string, pid int) agentObs {
|
|
return agentObs{
|
|
PID: pid, Kind: kindAgent, Signature: sig, Cmdline: cmd, User: "user",
|
|
Exe: "/usr/local/bin/" + sig,
|
|
Origin: originService, LLMEndpoints: []string{"api.anthropic.com"},
|
|
}
|
|
}
|
|
|
|
// reasonFor pulls one term's answer out of a verdict.
|
|
func reasonFor(t *testing.T, v policyVerdict, term string) policyReason {
|
|
t.Helper()
|
|
for _, r := range v.Reasons {
|
|
if r.Term == term {
|
|
return r
|
|
}
|
|
}
|
|
t.Fatalf("the verdict carries no %q term: %+v", term, v.Reasons)
|
|
return policyReason{}
|
|
}
|
|
|
|
// ── the trap: a registry this machine could not read ────────────────────────
|
|
|
|
// agentSanction returns (TRUE, sanctionWhyUnreadable) when the registry will not
|
|
// load (sanction.go:539-541), and that true is not a vouch — it is an absence of
|
|
// knowledge. A policy that reads it as "fine" goes blind in silence, which is
|
|
// the failure this whole feature exists to prevent. The constant has to be
|
|
// tested BY NAME, before the bool.
|
|
//
|
|
// MUTATION: delete the `why == sanctionWhyUnreadable` case from
|
|
// policySanctionTerm, leaving `case sanctioned: leave`.
|
|
// RESULT: red — "a corrupt registry read as: leave-alone". Every candidate on
|
|
// the machine silently stopped being one, and the page would have said so.
|
|
func TestARegistryThisMachineCannotReadIsNeverAClearance(t *testing.T) {
|
|
cfg := policyTestBox(t)
|
|
if err := os.MkdirAll(cfg, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(cfg, "sanctions.json"),
|
|
[]byte("{not json at all"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
v := policyEvaluate(policyCandidateObs("claude-code", "/usr/local/bin/claude", 4242))
|
|
if v.Decision != policyCannotJudge {
|
|
t.Fatalf("a corrupt registry read as: %s. It must be %s — the rule is missing a "+
|
|
"term it needs, which is a different fact from having nothing to judge.",
|
|
v.Decision, policyCannotJudge)
|
|
}
|
|
r := reasonFor(t, v, policyTermSanction)
|
|
if r.Points != policyPointsUnknown {
|
|
t.Errorf("the sanction term points %q, want %q", r.Points, policyPointsUnknown)
|
|
}
|
|
if r.Detail != sanctionWhyUnreadable {
|
|
t.Errorf("the term does not carry the reason the seam gave: %q", r.Detail)
|
|
}
|
|
if r.Tier != tierAmbiguous {
|
|
t.Errorf("an unreadable record is tiered %q — it is not knowledge", r.Tier)
|
|
}
|
|
}
|
|
|
|
// The same corrupt registry, on an app. The kind gate is the outermost term for
|
|
// exactly this: contain.go:84-86 refuses to terminate an app however the host
|
|
// asks, so nothing this rule cannot read may move one off leave-alone.
|
|
//
|
|
// MUTATION: return policyPointsStop for kindApp in policyKindTerm.
|
|
// RESULT: red — the Claude desktop app became a candidate, which is the nine
|
|
// kill buttons agents.go:170-176 was written about.
|
|
func TestAnAppIsNeverACandidateWhateverElseIsUnreadable(t *testing.T) {
|
|
cfg := policyTestBox(t)
|
|
app := policyCandidateObs("claude-desktop", "/Applications/Claude.app/Contents/MacOS/claude", 51)
|
|
app.Kind = kindApp
|
|
|
|
if v := policyEvaluate(app); v.Decision != policyLeaveAlone {
|
|
t.Fatalf("an app meeting every other term = %s, want %s", v.Decision, policyLeaveAlone)
|
|
}
|
|
// And with nothing else readable either.
|
|
if err := os.MkdirAll(cfg, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(cfg, "sanctions.json"), []byte("{"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app.Origin = originUnknown
|
|
v := policyEvaluate(app)
|
|
if v.Decision != policyLeaveAlone {
|
|
t.Fatalf("an app on a machine that could read neither its marks nor its origins = %s, "+
|
|
"want %s — this rule must not even be able to propose an app", v.Decision, policyLeaveAlone)
|
|
}
|
|
if len(v.Reasons) != 1 || v.Reasons[0].Term != policyTermKind {
|
|
t.Errorf("the app's verdict should rest on the kind term alone: %+v", v.Reasons)
|
|
}
|
|
}
|
|
|
|
// ── automation: inventory until a model joins it ────────────────────────────
|
|
|
|
// An automation drives the machine with no model of its own (agents.go:61-65)
|
|
// and is never an alert. This rule needs no term for it, because the plane
|
|
// itself relabels one `agent` the moment STRONG model traffic joins it
|
|
// (agents_parse.go:421-426) — so the promotion is driven here through
|
|
// mergeConnections rather than asserted, and the rule is run on both sides of it.
|
|
//
|
|
// MUTATION: point kindAutomation at policyPointsStop in policyKindTerm.
|
|
// RESULT: red — "the kind term points stop". The first version of this test
|
|
// asserted only that a chromedriver with no endpoints is left alone, which the
|
|
// TRAFFIC term already guaranteed, so the mutation ran green: it was proving
|
|
// something other than what it claimed. It now asserts on the kind term
|
|
// directly, and hands the gate the shape it must refuse even if something
|
|
// upstream produces one.
|
|
func TestAnAutomationIsACandidateOnlyOnceModelTrafficPromotesIt(t *testing.T) {
|
|
policyTestBox(t)
|
|
driver := agentObs{PID: 77, Kind: kindAutomation, Signature: "chromedriver",
|
|
Cmdline: "/usr/bin/chromedriver --port=9515", User: "user",
|
|
Exe: "/usr/bin/chromedriver", Origin: originService}
|
|
|
|
if r := policyKindTerm(driver); r.Points != policyPointsLeave {
|
|
t.Fatalf("the kind term points %q for an automation, want %q (%q)",
|
|
r.Points, policyPointsLeave, r.Detail)
|
|
}
|
|
if v := policyEvaluate(driver); v.Decision != policyLeaveAlone {
|
|
t.Fatalf("an automation with no model behind it = %s, want %s", v.Decision, policyLeaveAlone)
|
|
}
|
|
// The gate holds even when every other term points at stopping. That shape
|
|
// cannot come out of the live plane — mergeConnections would have promoted
|
|
// it — which is the point: the kind gate is what this rule relies on, not
|
|
// what the plane happens to hand it.
|
|
loud := driver
|
|
loud.LLMEndpoints = []string{"api.anthropic.com"}
|
|
v := policyEvaluate(loud)
|
|
if v.Decision != policyLeaveAlone {
|
|
t.Fatalf("an automation meeting every other term = %s, want %s", v.Decision, policyLeaveAlone)
|
|
}
|
|
if len(v.Reasons) != 1 || v.Reasons[0].Term != policyTermKind {
|
|
t.Errorf("the automation's verdict should rest on the kind term alone: %+v", v.Reasons)
|
|
}
|
|
|
|
// The plane's own promotion: a strong local endpoint on the same pid.
|
|
byPid := map[int]agentObs{77: driver}
|
|
mergeConnections(byPid,
|
|
[]connObs{{PID: 77, RemoteIP: "127.0.0.1", RemotePort: 11434}},
|
|
map[string]string{}, map[int]string{11434: "ollama"}, map[int]string{},
|
|
// mergeConnections took three loose strings until a later change gave
|
|
// it procDetail, so the kernel's executable path could travel with the
|
|
// user and cmdline instead of being squeezed into a fourth return. A
|
|
// driver promoted by model traffic carries no exe here on purpose: the
|
|
// policy must treat "no path" as UNIDENTIFIED, never as unwanted.
|
|
func(int) procDetail { return procDetail{User: "user", Cmdline: driver.Cmdline} },
|
|
func(int) int { return 1 })
|
|
promoted := byPid[77]
|
|
if promoted.Kind != kindAgent {
|
|
t.Fatalf("fixture is broken: mergeConnections left the kind %q — the claim this "+
|
|
"rule rests on is that model traffic promotes an automation", promoted.Kind)
|
|
}
|
|
if v := policyEvaluate(promoted); v.Decision != policyWouldStop {
|
|
t.Fatalf("the promoted automation = %s, want %s: %+v", v.Decision, policyWouldStop, v.Reasons)
|
|
}
|
|
}
|
|
|
|
// ── origin: an allow-list, because "not interactive" swept up two others ────
|
|
|
|
// The original sketch of the rule was "whose origin is not `interactive`".
|
|
// That deny-list includes `remote` — origin.go:19, "an ssh /
|
|
// mosh / screen-sharing ancestor: a person, elsewhere" — and killing an agent
|
|
// somebody started over ssh is the same false positive as killing one they
|
|
// started at this keyboard.
|
|
//
|
|
// MUTATION: move originRemote into the policyPointsStop arm of policyOriginTerm.
|
|
// RESULT: red — "an agent started over ssh = would-stop".
|
|
func TestAPersonAtAKeyboardIsNeverACandidateWhereverTheKeyboardIs(t *testing.T) {
|
|
policyTestBox(t)
|
|
for _, c := range []struct {
|
|
origin string
|
|
want string
|
|
}{
|
|
{originInteractive, policyLeaveAlone},
|
|
{originRemote, policyLeaveAlone},
|
|
{originScheduler, policyWouldStop},
|
|
{originService, policyWouldStop},
|
|
{originAgent, policyWouldStop},
|
|
} {
|
|
a := policyCandidateObs("aider", "/usr/local/bin/aider", 4243)
|
|
a.Origin = c.origin
|
|
if v := policyEvaluate(a); v.Decision != c.want {
|
|
t.Errorf("origin %s = %s, want %s (%q)", c.origin, v.Decision, c.want,
|
|
reasonFor(t, v, policyTermOrigin).Detail)
|
|
}
|
|
}
|
|
}
|
|
|
|
// origin.go:25 defines `unknown` as "the box could not read enough to say. Never
|
|
// guessed." A deny-list read that absence as grounds to stop something.
|
|
//
|
|
// MUTATION: make the originUnknown branch return policyPointsLeave.
|
|
// RESULT: red — a process whose lineage could not be read was quietly cleared,
|
|
// and the page counted it among the ones the rule leaves alone.
|
|
func TestAnOriginThisMachineCouldNotReadIsNotAClearanceEither(t *testing.T) {
|
|
policyTestBox(t)
|
|
for _, origin := range []string{originUnknown, "", "something-new"} {
|
|
a := policyCandidateObs("goose", "/usr/local/bin/goose", 4244)
|
|
a.Origin = origin
|
|
v := policyEvaluate(a)
|
|
if v.Decision != policyCannotJudge {
|
|
t.Errorf("origin %q = %s, want %s", origin, v.Decision, policyCannotJudge)
|
|
}
|
|
if r := reasonFor(t, v, policyTermOrigin); r.Tier != tierAmbiguous {
|
|
t.Errorf("origin %q is tiered %q — an answer this machine does not have "+
|
|
"is never knowledge", origin, r.Tier)
|
|
}
|
|
}
|
|
// And an unknown origin is NOT decisive on its own: something that is not
|
|
// talking to a model is left alone with confidence, whatever its lineage.
|
|
quiet := policyCandidateObs("goose", "/usr/local/bin/goose", 4245)
|
|
quiet.Origin, quiet.LLMEndpoints = originUnknown, nil
|
|
if v := policyEvaluate(quiet); v.Decision != policyLeaveAlone {
|
|
t.Errorf("an unreadable origin on something with no model traffic = %s, want %s — "+
|
|
"a term that says no is decisive and the missing one could not have changed it",
|
|
v.Decision, policyLeaveAlone)
|
|
}
|
|
}
|
|
|
|
// ── model traffic: the second signal, and what counts as one ────────────────
|
|
|
|
// An ambiguous port is 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, or every developer with
|
|
// something on :8080 becomes a candidate.
|
|
//
|
|
// MUTATION: in policyTrafficTerm, take the first endpoint as strong regardless
|
|
// of its tier (drop the `e.Tier != tierAmbiguous` guard).
|
|
// RESULT: red — an agent whose only traffic was to :8080 became a candidate.
|
|
func TestAnAmbiguousPortCorroboratesAndNeverPromotes(t *testing.T) {
|
|
policyTestBox(t)
|
|
for _, c := range []struct {
|
|
eps []string
|
|
want string
|
|
}{
|
|
{[]string{"api.anthropic.com"}, policyWouldStop},
|
|
{[]string{"local:11434 (ollama)"}, policyWouldStop},
|
|
{[]string{"local:8080 (llama.cpp/localai?)"}, policyLeaveAlone},
|
|
{[]string{"local:8080 (llama.cpp/localai?)", "local:11434 (ollama)"}, policyWouldStop},
|
|
{nil, policyLeaveAlone},
|
|
} {
|
|
a := policyCandidateObs("cline", "/usr/local/bin/cline", 4246)
|
|
a.LLMEndpoints = c.eps
|
|
v := policyEvaluate(a)
|
|
if v.Decision != c.want {
|
|
t.Errorf("endpoints %v = %s, want %s (%q)", c.eps, v.Decision, c.want,
|
|
reasonFor(t, v, policyTermTraffic).Detail)
|
|
}
|
|
}
|
|
// No traffic is an ANSWER — the connection table was read — so it is a
|
|
// decisive leave-alone rather than a shrug, tiered ambiguous because one
|
|
// scan is a snapshot.
|
|
quiet := policyCandidateObs("cline", "/usr/local/bin/cline", 4247)
|
|
quiet.LLMEndpoints = nil
|
|
r := reasonFor(t, policyEvaluate(quiet), policyTermTraffic)
|
|
if r.Points != policyPointsLeave || r.Tier != tierAmbiguous {
|
|
t.Errorf("no traffic gave points %q tier %q, want %q / %q",
|
|
r.Points, r.Tier, policyPointsLeave, tierAmbiguous)
|
|
}
|
|
}
|
|
|
|
// A tag is one of TWO mechanisms and this rule never reads it directly — it
|
|
// compares the reason constant agentSanction returns (sanction.go:196-207), so
|
|
// a local mark counts exactly as much as a host tag does.
|
|
//
|
|
// MUTATION: in policySanctionTerm, ignore `sanctioned` and point at stop
|
|
// whenever the reason is not sanctionWhyLocal.
|
|
// RESULT: red — a host-tagged agent stayed a candidate.
|
|
func TestEitherMechanismTakesSomethingOutOfTheCandidateSet(t *testing.T) {
|
|
policyTestBox(t)
|
|
tagged := policyCandidateObs("claude-code", "/usr/local/bin/claude", 4248)
|
|
tagged.DelegationToken = "del_4jk-Lm9QzR_tUv2wXy7Bc1" // the shape the host mints
|
|
if v := policyEvaluate(tagged); v.Decision != policyLeaveAlone {
|
|
t.Errorf("a host-tagged agent = %s, want %s", v.Decision, policyLeaveAlone)
|
|
}
|
|
|
|
marked := policyCandidateObs("aider", "/usr/local/bin/aider", 4249)
|
|
if v := policyEvaluate(marked); v.Decision != policyWouldStop {
|
|
t.Fatalf("fixture is broken: %s before anything was marked", v.Decision)
|
|
}
|
|
if _, err := addSanction(marked, sanctionByLocalUI); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v := policyEvaluate(marked); v.Decision != policyLeaveAlone {
|
|
t.Errorf("a locally marked agent = %s, want %s", v.Decision, policyLeaveAlone)
|
|
}
|
|
// A tag that is not shaped like one the host mints is not a vouch — setting
|
|
// one environment variable would otherwise be the whole evasion.
|
|
bad := policyCandidateObs("goose", "/usr/local/bin/goose", 4250)
|
|
bad.DelegationToken = "yes"
|
|
if v := policyEvaluate(bad); v.Decision != policyWouldStop {
|
|
t.Errorf("PRIVATAE_AGENT_TOKEN=yes = %s, want %s", v.Decision, policyWouldStop)
|
|
}
|
|
}
|
|
|
|
// ── 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 feeds a feature that kills things.
|
|
//
|
|
// MUTATION: drop the three trailing terms from policyEvaluate (keep the kind
|
|
// term and decide from it alone).
|
|
// RESULT: red — "the verdict carries no model-traffic term".
|
|
func TestEveryVerdictCarriesEveryTermItWasDecidedOn(t *testing.T) {
|
|
policyTestBox(t)
|
|
v := policyEvaluate(policyCandidateObs("claude-code", "/usr/local/bin/claude", 4242))
|
|
|
|
want := []string{policyTermKind, policyTermTraffic, policyTermOrigin, policyTermSanction}
|
|
if len(v.Reasons) != len(want) {
|
|
t.Fatalf("a candidate's verdict rests on %d terms, want %d: %+v",
|
|
len(v.Reasons), len(want), v.Reasons)
|
|
}
|
|
for i, term := range want {
|
|
r := v.Reasons[i]
|
|
if r.Term != term {
|
|
t.Errorf("term %d is %q, want %q", i, r.Term, term)
|
|
}
|
|
if r.Detail == "" {
|
|
t.Errorf("%s carries no sentence — a reason a person cannot read is a "+
|
|
"reason that gets ignored", term)
|
|
}
|
|
switch r.Tier {
|
|
case tierVerified, tierKnown, tierAmbiguous:
|
|
default:
|
|
t.Errorf("%s is tiered %q, which is not one of the plane's three", term, r.Tier)
|
|
}
|
|
if r.Points != policyPointsStop {
|
|
t.Errorf("%s points %q on an observation that meets every term", term, r.Points)
|
|
}
|
|
}
|
|
// The identity a mark is recorded against travels with the verdict, so the
|
|
// census and the sanction page can name the same thing (sanction.go:274-292).
|
|
if v.ID != sanctionIdentityOf(policyCandidateObs("claude-code", "/usr/local/bin/claude", 9999)).fingerprint() {
|
|
t.Error("the verdict's id is not the sanction fingerprint, so it does not survive a restart")
|
|
}
|
|
}
|
|
|
|
// ── the census ──────────────────────────────────────────────────────────────
|
|
|
|
// What would have been stopped has to be nameable a week later, which means on
|
|
// disk, keyed by an identity that survives a restart at a new pid.
|
|
//
|
|
// MUTATION: remove the `if v.Decision != policyWouldStop { continue }` guard
|
|
// from policyRecord.
|
|
// RESULT: red — the desktop app the rule leaves alone was written into a list
|
|
// headed "what this would have stopped".
|
|
func TestTheCensusNamesOnlyWhatWouldHaveBeenStopped(t *testing.T) {
|
|
cfg := policyTestBox(t)
|
|
candidate := policyCandidateObs("claude-code", "/usr/local/bin/claude --resume abc", 4242)
|
|
app := policyCandidateObs("claude-desktop", "/Applications/Claude.app/Contents/MacOS/claude", 51)
|
|
app.Kind = kindApp
|
|
observedAgents = func() ([]agentObs, error) { return []agentObs{candidate, app}, nil }
|
|
|
|
policyCensusTick()
|
|
|
|
// Read back from the bytes, the way a fresh process would.
|
|
b, err := os.ReadFile(filepath.Join(cfg, "policy-dryrun.json"))
|
|
if err != nil {
|
|
t.Fatalf("the census is not on disk: %v — it would not survive a restart", err)
|
|
}
|
|
var l policyLedger
|
|
if err := json.Unmarshal(b, &l); err != nil {
|
|
t.Fatalf("the census on disk does not parse: %v", err)
|
|
}
|
|
if len(l.Candidates) != 1 {
|
|
t.Fatalf("the census holds %d entries, want 1: %+v", len(l.Candidates), l.Candidates)
|
|
}
|
|
c, ok := l.Candidates[sanctionIdentityOf(candidate).fingerprint()]
|
|
if !ok {
|
|
t.Fatalf("the census does not hold the candidate under its sanction fingerprint: %+v", l.Candidates)
|
|
}
|
|
// This was argv[0] once; the census now names the KERNEL's path,
|
|
// which is the whole point — a census that names what a process CALLED
|
|
// itself records the forgery rather than the file.
|
|
if c.Signature != "claude-code" || c.Exe != "/usr/local/bin/claude-code" || c.User != "user" {
|
|
t.Errorf("the census cannot name what it would have stopped: %+v", c.sanctionIdentity)
|
|
}
|
|
if c.LastCmdline != candidate.Cmdline || c.LastPID != 4242 || c.LastOrigin != originService {
|
|
t.Errorf("the census does not carry enough to recognise it: %+v", c)
|
|
}
|
|
if c.FirstSeen.IsZero() || c.LastSeen.IsZero() || l.Since.IsZero() || l.LastScan.IsZero() {
|
|
t.Errorf("the census carries no clock: %+v", l)
|
|
}
|
|
}
|
|
|
|
// A machine that has taken this measurement and found nothing is not the same
|
|
// machine as one that has never taken it. agents.go:594-598 records the same
|
|
// trap for the second-bastion scan: an answer of [] from a scan that stopped
|
|
// reads as "clean" for ever with nothing to say otherwise.
|
|
//
|
|
// MUTATION: initialise `changed` to false in policyRecord, so a scan with
|
|
// nothing to record writes nothing.
|
|
// RESULT: red — no file, and the page could not tell a clean machine from one
|
|
// that had never looked.
|
|
func TestTheCensusRecordsThatItLookedEvenWithNothingToRecord(t *testing.T) {
|
|
cfg := policyTestBox(t, agentObs{PID: 51, Kind: kindApp, Signature: "claude-desktop",
|
|
Exe: "/Applications/Claude.app/Contents/MacOS/Claude",
|
|
Cmdline: "/Applications/Claude.app/Contents/MacOS/claude", User: "user",
|
|
Origin: originInteractive})
|
|
|
|
policyCensusTick()
|
|
|
|
l, err := loadPolicyLedger()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if l.Since.IsZero() {
|
|
t.Fatalf("a scan that found nothing left no record that it ran: %s",
|
|
filepath.Join(cfg, "policy-dryrun.json"))
|
|
}
|
|
if len(l.Candidates) != 0 {
|
|
t.Errorf("the census invented %d candidates", len(l.Candidates))
|
|
}
|
|
if msg, _ := policyCensusError(); msg != "" {
|
|
t.Errorf("the tick recorded an error: %s", msg)
|
|
}
|
|
}
|
|
|
|
// The first sighting is the audit fact; the last one is how long this would have
|
|
// gone on for. Between flushes the clock is deliberately not rewritten — the
|
|
// cost of that is a last_seen accurate to policyLedgerFlush, which the page says
|
|
// out loud rather than implying a precision the file does not have.
|
|
//
|
|
// MUTATION: set c.FirstSeen = now unconditionally in policyRecord.
|
|
// RESULT: red — "the first sighting moved", and a candidate that had been one
|
|
// for three days looked like it had appeared this minute.
|
|
func TestTheCensusKeepsTheFirstSightingAndMovesTheLast(t *testing.T) {
|
|
policyTestBox(t, policyCandidateObs("aider", "/usr/local/bin/aider", 4243))
|
|
first := time.Date(2026, 9, 13, 10, 0, 0, 0, time.UTC)
|
|
policyNow = func() time.Time { return first }
|
|
policyCensusTick()
|
|
|
|
// Inside the flush window: the file is not rewritten.
|
|
policyNow = func() time.Time { return first.Add(policyLedgerFlush / 2) }
|
|
policyCensusTick()
|
|
l, err := loadPolicyLedger()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !l.LastScan.Equal(first) {
|
|
t.Errorf("the census rewrote itself inside the flush window (last_scan %s)", l.LastScan)
|
|
}
|
|
|
|
// Past it: the clock moves, the first sighting does not.
|
|
later := first.Add(policyLedgerFlush + time.Minute)
|
|
policyNow = func() time.Time { return later }
|
|
policyCensusTick()
|
|
l, err = loadPolicyLedger()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := l.Candidates[sanctionIdentityOf(policyCandidateObs("aider", "/usr/local/bin/aider", 0)).fingerprint()]
|
|
if !c.FirstSeen.Equal(first) {
|
|
t.Errorf("the first sighting moved to %s, want %s", c.FirstSeen, first)
|
|
}
|
|
if !c.LastSeen.Equal(later) {
|
|
t.Errorf("the last sighting is %s, want %s", c.LastSeen, later)
|
|
}
|
|
if !l.Since.Equal(first) {
|
|
t.Errorf("the census window start moved to %s", l.Since)
|
|
}
|
|
}
|
|
|
|
// The file is bounded. Anything that launches itself from a fresh temp path each
|
|
// run would otherwise grow it without limit, on a disk the user did not agree to
|
|
// spend — and a census that quietly became a lie about its own completeness is
|
|
// the thing this page cannot be.
|
|
//
|
|
// MUTATION: remove the policyTrim call from policyRecord.
|
|
// RESULT: red — the ledger kept growing past its cap and Dropped stayed 0.
|
|
func TestTheCensusIsBoundedAndDropsTheOldestFirst(t *testing.T) {
|
|
cfg := policyTestBox(t, policyCandidateObs("claude-code", "/usr/local/bin/claude", 4242))
|
|
base := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
// A ledger already at its cap, oldest first.
|
|
l := policyLedger{Since: base, LastScan: base, Candidates: map[string]policyCandidate{}}
|
|
for i := range policyLedgerMax {
|
|
id := "filler-" + strconv.Itoa(i)
|
|
l.Candidates[id] = policyCandidate{ID: id, FirstSeen: base,
|
|
LastSeen: base.Add(time.Duration(i) * time.Minute)}
|
|
}
|
|
oldest := ""
|
|
for id, c := range l.Candidates {
|
|
if oldest == "" || c.LastSeen.Before(l.Candidates[oldest].LastSeen) {
|
|
oldest = id
|
|
}
|
|
}
|
|
if err := os.MkdirAll(cfg, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, _ := json.MarshalIndent(l, "", " ")
|
|
if err := os.WriteFile(filepath.Join(cfg, "policy-dryrun.json"), b, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
policyNow = func() time.Time { return base.Add(24 * time.Hour) }
|
|
policyCensusTick()
|
|
|
|
got, err := loadPolicyLedger()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got.Candidates) != policyLedgerMax {
|
|
t.Fatalf("the census holds %d entries, want its cap of %d", len(got.Candidates), policyLedgerMax)
|
|
}
|
|
if _, still := got.Candidates[oldest]; still {
|
|
t.Errorf("the oldest entry survived the trim")
|
|
}
|
|
if got.Dropped != 1 {
|
|
t.Errorf("dropped = %d, want 1 — a census that trims silently is a census that "+
|
|
"lies about its own completeness", got.Dropped)
|
|
}
|
|
if _, ok := got.Candidates[sanctionIdentityOf(policyCandidateObs("claude-code", "/usr/local/bin/claude", 0)).fingerprint()]; !ok {
|
|
t.Error("the new candidate was trimmed instead of the old ones")
|
|
}
|
|
}
|
|
|
|
// Asking what would be stopped must CREATE NOTHING. This rule is called in a
|
|
// loop over every observation, and ui_approvals_flow_test.go:53-84 exists because a
|
|
// page that asked a question created a directory — twice. sanction.go:160-167
|
|
// holds the same line at the seam this reads.
|
|
//
|
|
// MUTATION: build policyLedgerPath on configDir() instead of configDirPath().
|
|
// RESULT: red — reading the page created the config directory.
|
|
func TestAskingWhatWouldBeStoppedCreatesNothing(t *testing.T) {
|
|
cfg := policyTestBox(t, policyCandidateObs("claude-code", "/usr/local/bin/claude", 4242))
|
|
|
|
if vs, err := policyVerdicts(); err != nil || len(vs) != 1 {
|
|
t.Fatalf("fixture is broken: %d verdicts", len(vs))
|
|
}
|
|
if _, err := loadPolicyLedger(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = collectPolicyView()
|
|
|
|
if _, err := os.Stat(cfg); !os.IsNotExist(err) {
|
|
t.Fatalf("asking what would be stopped created %s. configDirPath() "+
|
|
"(config.go:129) resolves a path; configDir() (config.go:137) is "+
|
|
"MkdirAll-on-read, and nothing on a read path may be the second one.", cfg)
|
|
}
|
|
}
|
|
|
|
// ── the point of the unit: it cannot stop anything ──────────────────────────
|
|
|
|
// Acting is step 4, and shipping it before step 3 is how this product would
|
|
// acquire a reputation for killing people's work. The dry run earns the claim
|
|
// only if it genuinely cannot act, so this
|
|
// checks both halves: the source of the unit never NAMES the one side effect in
|
|
// contain.go, and driving everything it has with that side effect stubbed kills
|
|
// nothing.
|
|
//
|
|
// MUTATION: add `killProcess(1)` to policyCensusTick.
|
|
// RESULT: red twice — "policy.go:… names killProcess" and "this unit terminated
|
|
// [1]".
|
|
func TestNothingInThisUnitCanReachContainment(t *testing.T) {
|
|
// ── the source half ──
|
|
files := []string{"policy.go", "uipolicy.go"}
|
|
// Named rather than globbed, and asserted to exist, so a rename cannot make
|
|
// this check pass vacuously (buildtags_test.go:65-67 makes the same point).
|
|
forbidden := map[string]string{
|
|
"killProcess": "the one side effect in contain.go:41",
|
|
"maybeContain": "the host-directed containment path, contain.go:56",
|
|
}
|
|
forbiddenSel := map[string]string{
|
|
"Kill": "a process signal",
|
|
"Signal": "a process signal",
|
|
}
|
|
forbiddenImport := map[string]string{
|
|
`"os/exec"`: "starting a program",
|
|
`"syscall"`: "signalling one",
|
|
}
|
|
for _, name := range files {
|
|
if _, err := os.Stat(name); err != nil {
|
|
t.Fatalf("%s is not here (%v) — this check would pass vacuously", name, err)
|
|
}
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, name, nil, 0)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
for _, imp := range f.Imports {
|
|
if why, bad := forbiddenImport[imp.Path.Value]; bad {
|
|
t.Errorf("%s imports %s (%s). This unit reports what a rule WOULD stop; "+
|
|
"it may not be able to stop anything.", name, imp.Path.Value, why)
|
|
}
|
|
}
|
|
ast.Inspect(f, func(n ast.Node) bool {
|
|
switch node := n.(type) {
|
|
case *ast.SelectorExpr:
|
|
if why, bad := forbiddenSel[node.Sel.Name]; bad {
|
|
t.Errorf("%s:%d reaches .%s (%s)", name,
|
|
fset.Position(node.Pos()).Line, node.Sel.Name, why)
|
|
}
|
|
case *ast.Ident:
|
|
if why, bad := forbidden[node.Name]; bad {
|
|
t.Errorf("%s:%d names %s — %s. Step 3 is a rehearsal and step 4 is a "+
|
|
"separate decision.",
|
|
name, fset.Position(node.Pos()).Line, node.Name, why)
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
// ── the behavioural half ──
|
|
policyTestBox(t, policyCandidateObs("claude-code", "/usr/local/bin/claude", 4242))
|
|
prevKill := killProcess
|
|
t.Cleanup(func() { killProcess = prevKill })
|
|
var killed []int
|
|
killProcess = func(pid int) error { killed = append(killed, pid); return nil }
|
|
|
|
u := uiTestServer(t)
|
|
h := u.handler()
|
|
cookie := uiSignedIn(t, u)
|
|
policyCensusTick()
|
|
for _, path := range []string{"/policy", "/fragments/policy"} {
|
|
r := uiReq(http.MethodGet, path)
|
|
r.Header.Set("Cookie", cookie)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("%s = %d: %s", path, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
if len(killed) != 0 {
|
|
t.Fatalf("this unit terminated %v — a dry run that can act is not a dry run", killed)
|
|
}
|
|
}
|