bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
package main
|
||||
|
||||
// The seam between sanction.go and policy.go, pinned from the CONSUMER's side.
|
||||
//
|
||||
// This exists because of a defect that neither unit could have caught. The
|
||||
// policy engine was written against six `sanctionWhy*` constants. A later
|
||||
// change — moving a mark off the forgeable argv[0] onto the kernel's executable
|
||||
// path — added a seventh, `sanctionWhyNoExe`, which returns FALSE because
|
||||
// nothing has vouched for the process. The signature never changed, so nothing
|
||||
// broke, and the new constant fell into the policy's `default` branch and
|
||||
// scored a PROPOSED KILL for every observation whose executable path this box
|
||||
// cannot read.
|
||||
//
|
||||
// The lesson is the test: a predicate whose RETURN VALUE is an open set needs
|
||||
// its consumer to enumerate that set, or the next addition is a silent
|
||||
// behaviour change in a file nobody edited.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every constant the seam can return, and what the policy must do with it.
|
||||
// A new one added to sanction.go without a line here fails the test below.
|
||||
var seamExpectations = map[string]string{
|
||||
sanctionWhyHostTag: policyPointsLeave,
|
||||
sanctionWhyLocal: policyPointsLeave,
|
||||
sanctionWhyBoth: policyPointsLeave,
|
||||
sanctionWhyNone: policyPointsStop,
|
||||
sanctionWhyBadTag: policyPointsStop,
|
||||
sanctionWhyUnreadable: policyPointsUnknown,
|
||||
sanctionWhyNoExe: policyPointsUnknown,
|
||||
}
|
||||
|
||||
// COULD-NOT-READ IS NOT UNWANTED. These two are the only reasons the box gives
|
||||
// for "I was unable to establish this", and a dry run that proposes stopping
|
||||
// what it could not identify is worse than one that says nothing: it teaches
|
||||
// the reader that the candidate list is noise.
|
||||
func TestWhatCouldNotBeReadIsNeverProposedForStopping(t *testing.T) {
|
||||
for _, why := range []string{sanctionWhyUnreadable, sanctionWhyNoExe} {
|
||||
got, ok := seamExpectations[why]
|
||||
if !ok {
|
||||
t.Fatalf("%q is missing from seamExpectations", why)
|
||||
}
|
||||
if got != policyPointsUnknown {
|
||||
t.Errorf("%q scores %v, want policyPointsUnknown — it means UNIDENTIFIED, "+
|
||||
"not unwanted (sanction.go:150)", why, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The consumer must account for EVERY constant the producer can return. This is
|
||||
// the check that would have caught the defect: it fails when sanction.go grows
|
||||
// a reason that policy.go has never been told about.
|
||||
func TestThePolicyAccountsForEverySanctionReason(t *testing.T) {
|
||||
// Derived from the source rather than restated, so a new constant cannot be
|
||||
// added without this noticing.
|
||||
declared := sanctionReasonConstants(t)
|
||||
if len(declared) < 7 {
|
||||
t.Fatalf("only found %d sanctionWhy* constants in sanction.go — the scan has "+
|
||||
"stopped finding them, which makes this check a green light that proves "+
|
||||
"nothing: %v", len(declared), declared)
|
||||
}
|
||||
for _, why := range declared {
|
||||
if _, ok := seamExpectations[why]; !ok {
|
||||
t.Errorf("sanction.go can return a reason the policy has never been told "+
|
||||
"about. Add it to seamExpectations AND to policySanctionTerm, and decide "+
|
||||
"deliberately whether it means stop, leave, or could-not-read — the "+
|
||||
"default branch scores STOP, which is the wrong answer for anything that "+
|
||||
"means 'unreadable'. Reason: %q", why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And the mapping must be what policySanctionTerm actually does, not what this
|
||||
// file wishes it did.
|
||||
func TestTheSeamTableMatchesTheRunningCode(t *testing.T) {
|
||||
for why, want := range seamExpectations {
|
||||
got := policyPointsForReason(t, why)
|
||||
if got != want {
|
||||
t.Errorf("policySanctionTerm scores %q as %v, the seam table says %v",
|
||||
why, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
// sanctionReasonConstants reads the constant names out of sanction.go rather
|
||||
// than listing them, for the same reason mgmtOpVocabulary is walked rather than
|
||||
// restated: a list kept by hand drifts from the thing it describes.
|
||||
func sanctionReasonConstants(t *testing.T) []string {
|
||||
t.Helper()
|
||||
src := readSourceFile(t, "sanction.go")
|
||||
var out []string
|
||||
for _, line := range strings.Split(src, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "sanctionWhy") || !strings.Contains(line, "=") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(strings.SplitN(line, "=", 2)[0])
|
||||
v, ok := sanctionReasonValue(name)
|
||||
if ok {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sanctionReasonValue(name string) (string, bool) {
|
||||
switch name {
|
||||
case "sanctionWhyHostTag":
|
||||
return sanctionWhyHostTag, true
|
||||
case "sanctionWhyLocal":
|
||||
return sanctionWhyLocal, true
|
||||
case "sanctionWhyBoth":
|
||||
return sanctionWhyBoth, true
|
||||
case "sanctionWhyNone":
|
||||
return sanctionWhyNone, true
|
||||
case "sanctionWhyBadTag":
|
||||
return sanctionWhyBadTag, true
|
||||
case "sanctionWhyUnreadable":
|
||||
return sanctionWhyUnreadable, true
|
||||
case "sanctionWhyNoExe":
|
||||
return sanctionWhyNoExe, true
|
||||
}
|
||||
// A constant this switch does not know is exactly the drift the caller is
|
||||
// looking for; report the NAME so the failure names the new thing.
|
||||
return name, true
|
||||
}
|
||||
|
||||
// readSourceFile reads a file from the package directory. The tests run with
|
||||
// the package as their working directory, the same assumption console_test.go
|
||||
// and tray_test.go already make.
|
||||
func readSourceFile(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// policyPointsForReason drives the REAL policySanctionTerm by constructing an
|
||||
// observation that produces the wanted reason, rather than calling an internal
|
||||
// mapping — the table must match the running code, not a parallel copy of it.
|
||||
func policyPointsForReason(t *testing.T, why string) string {
|
||||
t.Helper()
|
||||
sanctionTestBox(t)
|
||||
a := agentObs{Kind: kindAgent, Signature: "claude-code", User: "user",
|
||||
Exe: "/usr/local/bin/claude", Cmdline: "claude"}
|
||||
switch why {
|
||||
case sanctionWhyHostTag:
|
||||
a.DelegationToken = "del_" + strings.Repeat("a", 24)
|
||||
case sanctionWhyBadTag:
|
||||
a.DelegationToken = "yes"
|
||||
case sanctionWhyNoExe:
|
||||
a.Exe = ""
|
||||
case sanctionWhyLocal, sanctionWhyBoth:
|
||||
if why == sanctionWhyBoth {
|
||||
a.DelegationToken = "del_" + strings.Repeat("a", 24)
|
||||
}
|
||||
if _, err := addSanction(a, "local-ui"); err != nil {
|
||||
t.Fatalf("addSanction: %v", err)
|
||||
}
|
||||
case sanctionWhyUnreadable:
|
||||
// A registry that exists and will not parse. Not a missing file —
|
||||
// missing is "nothing marked", which is a legitimate answer.
|
||||
p, err := sanctionsPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, got := agentSanction(a)
|
||||
if got != why {
|
||||
t.Skipf("could not construct %q (got %q) — the fixture needs updating, "+
|
||||
"not the assertion", why, got)
|
||||
}
|
||||
return policySanctionTerm(a).Points
|
||||
}
|
||||
Reference in New Issue
Block a user