Files
pulse/internal/agentcapabilities/invocation_test.go
T
rcourtman 97a30b0600 Introduce the Patrol execution profiles: detection and investigation
Execution posture is now profile-owned through the core-only,
never-serialized tools.ExecutionProfile. Both Patrol profiles are
non-interactive, deny infrastructure mutations, and clear any inherited
autonomous mode. Detection restricts pulse-state mutations to an
explicit allowlist of the finding lifecycle tools - a blanket
pulse-state allowance would also permit alert dismissal and knowledge
writes - while investigation denies all pulse-state mutations, keeping
it structurally read-only. InvocationPolicy gains the allowlist and
Allows() is now tool-name-aware.

Chat turns build ONE effective request executor (control level,
autonomy, profile, resolved context) BEFORE provider projection and
clone that executor per provider attempt, reversing the previous
project-from-base-then-clone order so the offered schema and the
runtime boundary always agree. Scheduled Patrol (ExecutePatrolStream)
now runs under the detection profile instead of autonomous mode -
closing its direct view of Docker/Kubernetes mutation subactions - and
ListAvailableTools projects through the identical path.
toolsForExecutionMode's mode booleans are replaced by the profile on
the executor itself.

Non-interactive profiles independently hide pulse_question from the
manifest AND runtime-block it before the interactive-call-set special
case: a fabricated question call returns a non-interactive error
without emitting a waiting event, and sibling tool calls from the same
provider turn keep processing. Approval waits never block for
non-interactive profiles (they queue), and the tool-only-turn wrap-up
guardrail is interactive-profile-owned instead of keyed on autonomy.
The system prompt describes detection and investigation modes directly
rather than claiming controlled or autonomous execution; the
investigation prompt directs the model toward typed action proposals.

Proofs: detection allowlist enforcement (alerts resolve and knowledge
remember blocked, finding tools allowed, projection agrees),
investigation structural read-only (patrol mutation tools dropped from
the manifest and blocked at runtime), profile clone isolation, profile
prompt modes, and question-tool hiding across profiles.
2026-07-10 15:53:38 +01:00

153 lines
6.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})
}
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)
}
}
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)
}
}
}
}