mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
3073a50614
Four classifier ratchets from review of 67c2534c0:
The classification vocabulary is closed: descriptor validation rejects
any class outside the known workflow kinds and mutation targets (an
empty Mutation no longer registers), and InvocationPolicy.Allows
independently denies unknown mutation targets outright, so a class
that somehow bypassed validation still cannot execute. Descriptor
lookups and registration store deep copies, so callers can never
mutate the canonical table through shared case maps or static class
pointers. Registration rejects descriptor overrides for canonical tool
names; overrides exist only for genuinely non-canonical extension and
test names.
Projected governance now derives its action mode from mutation
targets, not workflow kinds, and recomputes it even when no enum value
was filtered; a projection whose remaining invocations mutate nothing
downgrades to scope-only approval metadata (registered scope-only
summaries are preserved). Docker check_updates reclassifies from
{write,none} to {read,none}: it queues a read-only scan, and the write
kind was driving the FSM into verification and making the read-only
Docker projection report mixed. Discovery consequently projects as
mode=read in governance manifests, which is the honest mutation-derived
mode. The pulse_file_edit governance summary no longer claims to read
files. The contract prose also names pulse_read's execution-intent
classifier as mandatory second-stage enforcement for exec, not merely
defense in depth: the static read/none descriptor cannot prove
arbitrary command text safe.
Proofs: open-vocabulary rejection, unknown-target policy denial,
descriptor copy isolation, canonical-override rejection, and the
read-only Docker read/scope-only projection.
134 lines
5.3 KiB
Go
134 lines
5.3 KiB
Go
package agentcapabilities
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
func TestInvocationDescriptorClassifyFailsClosed(t *testing.T) {
|
|
descriptor, ok := InvocationDescriptorFor(PulseKubernetesToolName)
|
|
if !ok {
|
|
t.Fatal("kubernetes descriptor missing")
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
args map[string]interface{}
|
|
}{
|
|
{name: "missing discriminator", args: nil},
|
|
{name: "malformed discriminator", args: map[string]interface{}{"type": 42}},
|
|
{name: "unknown value", args: map[string]interface{}{"type": "drain_node"}},
|
|
{name: "wrong discriminator key", args: map[string]interface{}{"action": "pods"}},
|
|
}
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
class := descriptor.Classify(tt.args)
|
|
if class != FailClosedInvocationClass() {
|
|
t.Fatalf("Classify(%#v) = %#v, want fail-closed write/infrastructure", tt.args, class)
|
|
}
|
|
})
|
|
}
|
|
|
|
if class := ClassifyRegisteredInvocation("no_such_tool", nil); class != FailClosedInvocationClass() {
|
|
t.Fatalf("unknown tool classified %#v, want fail-closed", class)
|
|
}
|
|
}
|
|
|
|
func TestInvocationDescriptorValidateRequiresExactEnumCoverage(t *testing.T) {
|
|
descriptor := InvocationDescriptor{
|
|
Discriminator: "action",
|
|
Cases: map[string]InvocationClass{
|
|
"list": {Kind: ToolCallKindRead, Mutation: MutationNone},
|
|
"restart": {Kind: ToolCallKindWrite, Mutation: MutationInfrastructure},
|
|
},
|
|
}
|
|
if err := descriptor.Validate("demo", []string{"list", "restart"}); err != nil {
|
|
t.Fatalf("exact coverage should validate: %v", err)
|
|
}
|
|
if err := descriptor.Validate("demo", []string{"list", "restart", "delete"}); err == nil {
|
|
t.Fatal("missing case must fail validation")
|
|
}
|
|
if err := descriptor.Validate("demo", []string{"list"}); err == nil {
|
|
t.Fatal("extra case must fail validation")
|
|
}
|
|
if err := descriptor.Validate("demo", nil); err == nil {
|
|
t.Fatal("discriminator without schema enum must fail validation")
|
|
}
|
|
|
|
static := InvocationDescriptor{Static: &InvocationClass{Kind: ToolCallKindRead, Mutation: MutationNone}}
|
|
if err := static.Validate("demo", nil); err != nil {
|
|
t.Fatalf("static descriptor should validate without enum: %v", err)
|
|
}
|
|
both := static
|
|
both.Discriminator = "action"
|
|
if err := both.Validate("demo", nil); err == nil {
|
|
t.Fatal("static plus discriminator must fail validation")
|
|
}
|
|
neither := InvocationDescriptor{}
|
|
if err := neither.Validate("demo", nil); err == nil {
|
|
t.Fatal("empty descriptor must fail validation")
|
|
}
|
|
}
|
|
|
|
func TestCanonicalDescriptorsPinSafetyCriticalClassifications(t *testing.T) {
|
|
assertClass := func(tool string, args map[string]interface{}, want InvocationClass) {
|
|
t.Helper()
|
|
if got := ClassifyRegisteredInvocation(tool, args); got != want {
|
|
t.Fatalf("%s %v = %#v, want %#v", tool, args, got, want)
|
|
}
|
|
}
|
|
assertClass(PulseKubernetesToolName, map[string]interface{}{"type": "scale"},
|
|
InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationInfrastructure})
|
|
assertClass(PulseDockerToolName, map[string]interface{}{"action": "update"},
|
|
InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationInfrastructure})
|
|
assertClass(PulseDockerToolName, map[string]interface{}{"action": "check_updates"},
|
|
InvocationClass{Kind: ToolCallKindRead, Mutation: MutationNone})
|
|
assertClass(PulseAlertsToolName, map[string]interface{}{"action": "resolve"},
|
|
InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationPulseState})
|
|
assertClass(PulseControlToolName, nil,
|
|
InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationInfrastructure})
|
|
assertClass(PulseFileEditToolName, map[string]interface{}{"action": "write"},
|
|
InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationInfrastructure})
|
|
assertClass(PulseReadToolName, map[string]interface{}{"action": "exec"},
|
|
InvocationClass{Kind: ToolCallKindRead, Mutation: MutationNone})
|
|
}
|
|
|
|
func TestInvocationClassValidationRejectsOpenVocabulary(t *testing.T) {
|
|
missingMutation := InvocationDescriptor{Static: &InvocationClass{Kind: ToolCallKindWrite}}
|
|
if err := missingMutation.Validate("demo", nil); err == nil {
|
|
t.Fatal("static class without a mutation target must fail validation")
|
|
}
|
|
unknownKind := InvocationDescriptor{Static: &InvocationClass{Kind: ToolCallKind(99), Mutation: MutationNone}}
|
|
if err := unknownKind.Validate("demo", nil); err == nil {
|
|
t.Fatal("static class with an unknown kind must fail validation")
|
|
}
|
|
badCase := InvocationDescriptor{
|
|
Discriminator: "action",
|
|
Cases: map[string]InvocationClass{
|
|
"list": {Kind: ToolCallKindRead, Mutation: MutationTarget("estate")},
|
|
},
|
|
}
|
|
if err := badCase.Validate("demo", []string{"list"}); err == nil {
|
|
t.Fatal("case with an unknown mutation target must fail validation")
|
|
}
|
|
}
|
|
|
|
func TestInvocationDescriptorForReturnsIsolatedCopies(t *testing.T) {
|
|
first, ok := InvocationDescriptorFor(PulseDockerToolName)
|
|
if !ok {
|
|
t.Fatal("docker descriptor missing")
|
|
}
|
|
first.Cases["update"] = InvocationClass{Kind: ToolCallKindRead, Mutation: MutationNone}
|
|
|
|
second, _ := InvocationDescriptorFor(PulseDockerToolName)
|
|
if got := second.Cases["update"]; got.Mutation != MutationInfrastructure {
|
|
t.Fatalf("mutating a returned descriptor leaked into the canonical table: %#v", got)
|
|
}
|
|
|
|
static, _ := InvocationDescriptorFor(PulseControlToolName)
|
|
static.Static.Mutation = MutationNone
|
|
refetched, _ := InvocationDescriptorFor(PulseControlToolName)
|
|
if refetched.Static.Mutation != MutationInfrastructure {
|
|
t.Fatalf("mutating a returned static class leaked into the canonical table: %#v", refetched.Static)
|
|
}
|
|
}
|