mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
97a30b0600
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.
153 lines
6.2 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|