Files
rcourtman c501376843 Preserve canonical Patrol planning and outcome continuity
Return persisted planning acceptance or refusal inside the investigation turn.
Keep model judgment separate from action authority and preserve accepted action
identity across provider failures. Enforce actor/request idempotency atomically
and retain complete approval and independent verification context.

Preserve unknown disk evidence, stream whitespace and historical resolution
timestamps. Keep conversation scrolling inside its own panel. Record real-model,
disposable-lab and browser qualification with explicit population limits.

Refs #1782
2026-09-07 17:24:25 +01:00

196 lines
8.2 KiB
Go

package agentcapabilities
import (
"strings"
"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})
assertClass(PatrolAssessFindingToolName, map[string]interface{}{"verdict": "present"},
InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationPulseState})
assertClass(PatrolActionCapabilitiesToolName, map[string]interface{}{"resource_id": "vm:42"},
InvocationClass{Kind: ToolCallKindRead, Mutation: MutationNone})
assertClass(PatrolProposeActionToolName, map[string]interface{}{"resource_id": "vm:42"},
InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationPulseState})
assertClass(PatrolProposeObserverToolName, map[string]interface{}{"objective_id": "objective-1"},
InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationPulseState})
}
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["updates"] = InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationInfrastructure}
second, _ := InvocationDescriptorFor(PulseDockerToolName)
if got := second.Cases["updates"]; got.Mutation != MutationNone {
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)
}
}
func TestInvocationBlockedResultCoversAllMutationTargets(t *testing.T) {
// The blocked-invocation result is the shared refusal for every
// profile-denied mutation, not just infrastructure: pulse-state
// blocks (e.g. Patrol detection's finding-tool allowlist) use it too.
for _, target := range []MutationTarget{MutationPulseState, MutationInfrastructure} {
result := NewInvocationBlockedToolResult("pulse_alerts", InvocationClass{Kind: ToolCallKindWrite, Mutation: target})
text := ToolResultText(result)
if text == "" {
t.Fatal("blocked result must carry text")
}
for _, want := range []string{"Invocation blocked", "pulse_alerts", string(target), "propose a typed action"} {
if !strings.Contains(text, want) {
t.Fatalf("blocked result for %s missing %q: %s", target, want, text)
}
}
}
}
func TestRestrictedExposureVocabulary(t *testing.T) {
if !ToolHasRestrictedExposure(PatrolProposeActionToolName) {
t.Fatal("patrol_propose_action must be exposure-restricted")
}
for _, name := range []string{PulseQueryToolName, PulseDockerToolName, PulseReadToolName} {
if ToolHasRestrictedExposure(name) {
t.Fatalf("%s must not be exposure-restricted", name)
}
}
// Restricted exposure implies raw provider overrides are discarded;
// the projected form is the only exposable one.
projected := RedactToolCallArgumentsForExposure(PatrolProposeActionToolName, map[string]interface{}{
"params": map[string]interface{}{"token": "secret"},
})
if projected["params"] != RedactedProposalParamsMarker {
t.Fatalf("projector must redact params, got %#v", projected["params"])
}
}
func TestLegacyAssistantAliasesUseClosedInvocationClassification(t *testing.T) {
tests := map[string]MutationTarget{
LegacyAssistantFetchURLToolName: MutationNone,
LegacyAssistantRunCommandToolName: MutationInfrastructure,
LegacyAssistantSetResourceURLToolName: MutationPulseState,
ResolveFindingCapabilityName: MutationPulseState,
DismissFindingCapabilityName: MutationPulseState,
"unknown_alias": MutationInfrastructure,
}
for name, want := range tests {
if got := ClassifyLegacyAssistantInvocation(name); got.Mutation != want {
t.Fatalf("%s mutation = %q, want %q", name, got.Mutation, want)
}
}
}