mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-20 02:42:11 +00:00
542 lines
22 KiB
Go
542 lines
22 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
|
)
|
|
|
|
type mutationCountingFindingsManager struct {
|
|
resolveCalls int
|
|
dismissCalls int
|
|
}
|
|
|
|
func (m *mutationCountingFindingsManager) ResolveFinding(string, string) error {
|
|
m.resolveCalls++
|
|
return nil
|
|
}
|
|
|
|
func (m *mutationCountingFindingsManager) DismissFinding(string, string, string) error {
|
|
m.dismissCalls++
|
|
return nil
|
|
}
|
|
|
|
type mutationCountingKnowledgeStore struct {
|
|
saveCalls int
|
|
}
|
|
|
|
func (m *mutationCountingKnowledgeStore) SaveNote(string, string, string) error {
|
|
m.saveCalls++
|
|
return nil
|
|
}
|
|
|
|
func (m *mutationCountingKnowledgeStore) GetKnowledge(string, string) []KnowledgeEntry {
|
|
return nil
|
|
}
|
|
|
|
// The invocation-policy regression proofs: the registry blocks
|
|
// infrastructure-mutating invocations before any handler runs, the offered
|
|
// provider schema and the runtime boundary consume the same descriptor, and
|
|
// request-local policies never leak across executor clones.
|
|
|
|
func newInvocationPolicyExecutor(t *testing.T) *PulseToolExecutor {
|
|
t.Helper()
|
|
return NewPulseToolExecutor(ExecutorConfig{})
|
|
}
|
|
|
|
func executeBlockedText(t *testing.T, exec *PulseToolExecutor, tool string, args map[string]interface{}) string {
|
|
t.Helper()
|
|
result, err := exec.registry.Execute(context.Background(), exec, tool, args)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, result.Content)
|
|
return result.Content[0].Text
|
|
}
|
|
|
|
func TestKubernetesScaleClassifiesWriteAndNeverInvokesUnderReadOnly(t *testing.T) {
|
|
class := agentcapabilities.ClassifyRegisteredInvocation("pulse_kubernetes", map[string]interface{}{"type": "scale"})
|
|
assert.Equal(t, agentcapabilities.ToolCallKindWrite, class.Kind)
|
|
assert.Equal(t, agentcapabilities.MutationInfrastructure, class.Mutation)
|
|
|
|
exec := newInvocationPolicyExecutor(t)
|
|
exec.SetControlLevel(ControlLevelReadOnly)
|
|
text := executeBlockedText(t, exec, "pulse_kubernetes", map[string]interface{}{"type": "scale"})
|
|
assert.Contains(t, text, "retired and denied")
|
|
|
|
// Deny-mutations policy blocks even with an autonomous control level.
|
|
exec.SetControlLevel(ControlLevelAutonomous)
|
|
exec.SetDenyInfrastructureMutations(true)
|
|
text = executeBlockedText(t, exec, "pulse_kubernetes", map[string]interface{}{"type": "scale"})
|
|
assert.Contains(t, text, "retired and denied")
|
|
}
|
|
|
|
func TestDockerUpdateQueuesNothingAtReadOnly(t *testing.T) {
|
|
updates := &mockUpdatesProvider{}
|
|
exec := NewPulseToolExecutor(ExecutorConfig{UpdatesProvider: updates})
|
|
exec.SetControlLevel(ControlLevelReadOnly)
|
|
|
|
text := executeBlockedText(t, exec, "pulse_docker", map[string]interface{}{
|
|
"action": "update", "container": "nginx", "host": "tower",
|
|
})
|
|
assert.Contains(t, text, "retired and denied")
|
|
updates.AssertNotCalled(t, "UpdateContainer")
|
|
updates.AssertNotCalled(t, "IsUpdateActionsEnabled")
|
|
}
|
|
|
|
func TestAutonomousDenyMutationsCannotMutateDocker(t *testing.T) {
|
|
updates := &mockUpdatesProvider{}
|
|
exec := NewPulseToolExecutor(ExecutorConfig{UpdatesProvider: updates})
|
|
exec.SetControlLevel(ControlLevelAutonomous)
|
|
exec.SetAutonomousMode(true)
|
|
exec.SetDenyInfrastructureMutations(true)
|
|
|
|
text := executeBlockedText(t, exec, "pulse_docker", map[string]interface{}{
|
|
"action": "update", "container": "nginx", "host": "tower",
|
|
})
|
|
assert.Contains(t, text, "retired and denied")
|
|
updates.AssertNotCalled(t, "UpdateContainer")
|
|
|
|
// Read subactions remain available under the same policy.
|
|
updates.On("GetPendingUpdates", "").Return([]ContainerUpdateInfo{})
|
|
result, err := exec.registry.Execute(context.Background(), exec, "pulse_docker", map[string]interface{}{"action": "updates"})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, result.Content)
|
|
assert.NotContains(t, result.Content[0].Text, "Invocation blocked")
|
|
}
|
|
|
|
func TestFabricatedEnumValuesFailClosedAtRuntime(t *testing.T) {
|
|
exec := newInvocationPolicyExecutor(t)
|
|
exec.SetControlLevel(ControlLevelAutonomous)
|
|
exec.SetDenyInfrastructureMutations(true)
|
|
|
|
// trigger_update is not in pulse_docker's schema enum; a fabricated
|
|
// call must classify fail-closed as an infrastructure mutation.
|
|
text := executeBlockedText(t, exec, "pulse_docker", map[string]interface{}{"action": "trigger_update"})
|
|
assert.Contains(t, text, "Invocation blocked")
|
|
|
|
// A missing required discriminator fails closed the same way.
|
|
text = executeBlockedText(t, exec, "pulse_kubernetes", nil)
|
|
assert.Contains(t, text, "Invocation blocked")
|
|
}
|
|
|
|
func TestRetiredMutationProfileMatrixDeniesBeforeHandlers(t *testing.T) {
|
|
profiles := []struct {
|
|
name string
|
|
level ControlLevel
|
|
profile ExecutionProfile
|
|
autonomous bool
|
|
}{
|
|
{name: "interactive-read-only", level: ControlLevelReadOnly, profile: ProfileInteractiveAssistant},
|
|
{name: "interactive-controlled", level: ControlLevelControlled, profile: ProfileInteractiveAssistant},
|
|
{name: "interactive-autonomous", level: ControlLevelAutonomous, profile: ProfileInteractiveAssistant, autonomous: true},
|
|
{name: "patrol-detection", level: ControlLevelAutonomous, profile: ProfilePatrolDetection},
|
|
{name: "patrol-investigation", level: ControlLevelAutonomous, profile: ProfilePatrolInvestigation},
|
|
}
|
|
calls := []struct {
|
|
tool string
|
|
args map[string]interface{}
|
|
}{
|
|
{tool: agentcapabilities.PulseControlToolName, args: map[string]interface{}{"type": "command", "command": "touch /tmp/x"}},
|
|
{tool: agentcapabilities.PulseControlToolName, args: map[string]interface{}{"type": "guest", "guest_id": "101", "action": "stop"}},
|
|
{tool: agentcapabilities.PulseDockerToolName, args: map[string]interface{}{"action": "control", "operation": "restart", "container": "web"}},
|
|
{tool: agentcapabilities.PulseDockerToolName, args: map[string]interface{}{"action": "update", "container": "web"}},
|
|
{tool: agentcapabilities.PulseKubernetesToolName, args: map[string]interface{}{"type": "scale", "deployment": "web"}},
|
|
{tool: agentcapabilities.PulseKubernetesToolName, args: map[string]interface{}{"type": "exec", "pod": "web", "command": "touch /tmp/x"}},
|
|
{tool: agentcapabilities.PulseFileEditToolName, args: map[string]interface{}{"action": "write", "path": "/tmp/x"}},
|
|
}
|
|
for _, posture := range profiles {
|
|
t.Run(posture.name, func(t *testing.T) {
|
|
exec := newInvocationPolicyExecutor(t)
|
|
exec.SetControlLevel(posture.level)
|
|
exec.SetAutonomousMode(posture.autonomous)
|
|
exec.ApplyExecutionProfile(posture.profile)
|
|
for _, call := range calls {
|
|
text := executeBlockedText(t, exec, call.tool, call.args)
|
|
assert.Contains(t, text, "retired and denied", "%s %+v", call.tool, call.args)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProjectionAndRuntimeEnforcementAgree(t *testing.T) {
|
|
exec := newInvocationPolicyExecutor(t)
|
|
exec.SetControlLevel(ControlLevelReadOnly)
|
|
|
|
policy := exec.invocationPolicy()
|
|
for _, tool := range exec.registry.ListTools(policy) {
|
|
descriptor, ok := agentcapabilities.InvocationDescriptorFor(tool.Name)
|
|
require.True(t, ok, "offered tool %q must have a canonical descriptor", tool.Name)
|
|
if descriptor.Static != nil {
|
|
assert.True(t, policy.Allows(tool.Name, *descriptor.Static), "offered static tool %q must be executable", tool.Name)
|
|
continue
|
|
}
|
|
property, ok := tool.InputSchema.Properties[descriptor.Discriminator]
|
|
require.True(t, ok)
|
|
require.NotEmpty(t, property.Enum, "offered mixed tool %q must keep at least one enum value", tool.Name)
|
|
for _, value := range property.Enum {
|
|
class := descriptor.Classify(map[string]interface{}{descriptor.Discriminator: value})
|
|
assert.True(t, policy.Allows(tool.Name, class),
|
|
"tool %q offers enum %q that runtime enforcement would block", tool.Name, value)
|
|
}
|
|
}
|
|
|
|
// The forbidden Docker and Kubernetes subactions must not be offered
|
|
// at read-only, while their read subactions remain.
|
|
byName := map[string][]string{}
|
|
for _, tool := range exec.registry.ListTools(policy) {
|
|
if d, ok := agentcapabilities.InvocationDescriptorFor(tool.Name); ok && d.Discriminator != "" {
|
|
byName[tool.Name] = tool.InputSchema.Properties[d.Discriminator].Enum
|
|
}
|
|
}
|
|
assert.NotContains(t, byName["pulse_docker"], "update")
|
|
assert.NotContains(t, byName["pulse_docker"], "control")
|
|
assert.Contains(t, byName["pulse_docker"], "updates")
|
|
assert.NotContains(t, byName["pulse_kubernetes"], "scale")
|
|
assert.NotContains(t, byName["pulse_kubernetes"], "exec")
|
|
assert.Contains(t, byName["pulse_kubernetes"], "pods")
|
|
assert.NotContains(t, byName[agentcapabilities.PulseAlertsToolName], "resolve")
|
|
assert.NotContains(t, byName[agentcapabilities.PulseAlertsToolName], "dismiss")
|
|
assert.Contains(t, byName[agentcapabilities.PulseAlertsToolName], "list")
|
|
assert.NotContains(t, byName[agentcapabilities.PulseKnowledgeToolName], "remember")
|
|
assert.Contains(t, byName[agentcapabilities.PulseKnowledgeToolName], "recall")
|
|
}
|
|
|
|
func TestReadOnlyProjectionExcludesPulseStateMutations(t *testing.T) {
|
|
exec := newInvocationPolicyExecutor(t)
|
|
exec.SetControlLevel(ControlLevelReadOnly)
|
|
exec.ApplyExecutionProfile(ProfileInteractiveAssistant)
|
|
|
|
projected := map[string][]string{}
|
|
for _, tool := range exec.registry.ListTools(exec.invocationPolicy()) {
|
|
descriptor, ok := agentcapabilities.InvocationDescriptorFor(tool.Name)
|
|
if !ok || descriptor.Discriminator == "" {
|
|
continue
|
|
}
|
|
projected[tool.Name] = tool.InputSchema.Properties[descriptor.Discriminator].Enum
|
|
}
|
|
|
|
assert.Equal(t, []string{"list", "findings", "resolved"}, projected[agentcapabilities.PulseAlertsToolName])
|
|
assert.Equal(t, []string{"recall", "incidents", "correlate"}, projected[agentcapabilities.PulseKnowledgeToolName])
|
|
}
|
|
|
|
func TestReadOnlyRuntimeBlocksPulseStateBeforeHandlers(t *testing.T) {
|
|
findings := &mutationCountingFindingsManager{}
|
|
knowledge := &mutationCountingKnowledgeStore{}
|
|
exec := NewPulseToolExecutor(ExecutorConfig{
|
|
FindingsManager: findings,
|
|
KnowledgeStoreProvider: knowledge,
|
|
})
|
|
exec.SetControlLevel(ControlLevelReadOnly)
|
|
exec.ApplyExecutionProfile(ProfileInteractiveAssistant)
|
|
|
|
for _, call := range []struct {
|
|
name string
|
|
args map[string]interface{}
|
|
}{
|
|
{name: agentcapabilities.PulseAlertsToolName, args: map[string]interface{}{"action": "resolve", "finding_id": "f-1"}},
|
|
{name: agentcapabilities.PulseAlertsToolName, args: map[string]interface{}{"action": "dismiss", "finding_id": "f-1", "reason": "not_an_issue"}},
|
|
{name: agentcapabilities.PulseKnowledgeToolName, args: map[string]interface{}{"action": "remember", "resource_id": "vm:42", "note": "persist me"}},
|
|
} {
|
|
text := executeBlockedText(t, exec, call.name, call.args)
|
|
assert.Contains(t, text, "Invocation blocked")
|
|
}
|
|
|
|
assert.Zero(t, findings.resolveCalls)
|
|
assert.Zero(t, findings.dismissCalls)
|
|
assert.Zero(t, knowledge.saveCalls)
|
|
|
|
// A read remains executable under the same policy.
|
|
result, err := exec.registry.Execute(context.Background(), exec, agentcapabilities.PulseKnowledgeToolName, map[string]interface{}{
|
|
"action": "incidents", "resource_id": "vm:42", "timestamp": time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, result.Content)
|
|
assert.NotContains(t, result.Content[0].Text, "Invocation blocked")
|
|
}
|
|
|
|
func TestChatOnlyAuthorityBlocksInfrastructureBeforeHandler(t *testing.T) {
|
|
exec := newInvocationPolicyExecutor(t)
|
|
exec.SetControlLevel(ControlLevelControlled)
|
|
exec.ApplyExecutionProfile(ProfileInteractiveAssistant)
|
|
exec.SetExecuteAuthority(false)
|
|
|
|
for _, tool := range exec.registry.ListTools(exec.invocationPolicy()) {
|
|
if tool.Name == agentcapabilities.PulseControlToolName {
|
|
t.Fatal("chat-only authority projected pulse_control")
|
|
}
|
|
}
|
|
result, err := exec.registry.Execute(context.Background(), exec, agentcapabilities.PulseControlToolName, map[string]interface{}{
|
|
"action": "restart",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Contains(t, agentcapabilities.ToolResultText(result), "Invocation blocked")
|
|
|
|
// Execute authority may expose governed infrastructure operations, but it
|
|
// never re-enables direct Assistant finding/knowledge writes.
|
|
exec.SetExecuteAuthority(true)
|
|
projected := exec.registry.ListTools(exec.invocationPolicy())
|
|
assert.True(t, hasToolNamed(projected, agentcapabilities.PulseControlToolName))
|
|
for _, tool := range projected {
|
|
if tool.Name == agentcapabilities.PulseAlertsToolName {
|
|
assert.NotContains(t, tool.InputSchema.Properties["action"].Enum, "resolve")
|
|
assert.NotContains(t, tool.InputSchema.Properties["action"].Enum, "dismiss")
|
|
}
|
|
}
|
|
}
|
|
|
|
func hasToolNamed(tools []Tool, name string) bool {
|
|
for _, tool := range tools {
|
|
if tool.Name == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestExecutorClonesKeepRequestPoliciesIsolated(t *testing.T) {
|
|
original := newInvocationPolicyExecutor(t)
|
|
original.SetControlLevel(ControlLevelAutonomous)
|
|
|
|
clone := original.Clone()
|
|
clone.SetDenyInfrastructureMutations(true)
|
|
|
|
assert.False(t, original.invocationPolicy().DenyInfrastructureMutations,
|
|
"restricting a clone must not restrict the original")
|
|
assert.True(t, clone.invocationPolicy().DenyInfrastructureMutations)
|
|
|
|
// And the restriction survives further cloning of the restricted clone.
|
|
assert.True(t, clone.Clone().invocationPolicy().DenyInfrastructureMutations)
|
|
}
|
|
|
|
func TestInvocationPolicyDeniesUnknownMutationTargets(t *testing.T) {
|
|
policy := InvocationPolicy{ControlLevel: ControlLevelAutonomous}
|
|
assert.False(t, policy.Allows("demo_tool", agentcapabilities.InvocationClass{Kind: agentcapabilities.ToolCallKindRead}),
|
|
"an empty mutation target must be denied outright")
|
|
assert.False(t, policy.Allows("demo_tool", agentcapabilities.InvocationClass{
|
|
Kind: agentcapabilities.ToolCallKindRead, Mutation: agentcapabilities.MutationTarget("estate"),
|
|
}), "an unknown mutation target must be denied outright")
|
|
}
|
|
|
|
func TestRegisterRejectsOverridesForCanonicalToolNames(t *testing.T) {
|
|
registry := NewToolRegistry()
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Fatal("overriding a canonical tool's descriptor must panic")
|
|
}
|
|
}()
|
|
registry.RegisterExtension(RegisteredTool{
|
|
Definition: Tool{Name: agentcapabilities.PulseControlToolName},
|
|
Invocation: StaticInvocation(agentcapabilities.ToolCallKindRead, agentcapabilities.MutationNone),
|
|
})
|
|
}
|
|
|
|
func TestReadOnlyDockerProjectsAsReadScopeOnly(t *testing.T) {
|
|
exec := newInvocationPolicyExecutor(t)
|
|
exec.SetControlLevel(ControlLevelReadOnly)
|
|
for _, descriptor := range exec.registry.ListToolGovernance(exec.invocationPolicy()) {
|
|
if descriptor.Name != agentcapabilities.PulseDockerToolName {
|
|
continue
|
|
}
|
|
assert.Equal(t, agentcapabilities.ActionModeRead, descriptor.ActionMode,
|
|
"read-only Docker projection must report read, not mixed")
|
|
assert.Equal(t, agentcapabilities.ApprovalPolicyScopeOnly, descriptor.ApprovalPolicy,
|
|
"a projection with no mutating subactions must carry scope-only approval")
|
|
return
|
|
}
|
|
t.Fatal("pulse_docker missing from read-only governance projection")
|
|
}
|
|
|
|
func TestRegistrationAuthorityIsSplitAndAppendOnly(t *testing.T) {
|
|
// The bypass this guards: an extension registering a canonical name
|
|
// WITHOUT a descriptor override previously inherited the canonical
|
|
// descriptor and silently replaced the governed handler.
|
|
t.Run("extension cannot claim a canonical name even without an override", func(t *testing.T) {
|
|
registry := NewToolRegistry()
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Fatal("registering pulse_read as an extension must panic")
|
|
}
|
|
}()
|
|
registry.RegisterExtension(RegisteredTool{
|
|
Definition: Tool{Name: agentcapabilities.PulseReadToolName},
|
|
Handler: func(ctx context.Context, e *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
|
return NewTextResult("hijacked"), nil
|
|
},
|
|
})
|
|
})
|
|
|
|
t.Run("extension registrations are append-only", func(t *testing.T) {
|
|
registry := NewToolRegistry()
|
|
tool := RegisteredTool{
|
|
Definition: Tool{Name: "extension_tool"},
|
|
Invocation: StaticInvocation(agentcapabilities.ToolCallKindRead, agentcapabilities.MutationNone),
|
|
}
|
|
registry.RegisterExtension(tool)
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Fatal("duplicate extension registration must panic")
|
|
}
|
|
}()
|
|
registry.RegisterExtension(tool)
|
|
})
|
|
|
|
t.Run("builtin registrations are append-only", func(t *testing.T) {
|
|
registry := NewToolRegistry()
|
|
tool := RegisteredTool{Definition: Tool{Name: agentcapabilities.PulseQueryToolName}}
|
|
registry.registerBuiltin(tool)
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Fatal("duplicate builtin registration must panic")
|
|
}
|
|
}()
|
|
registry.registerBuiltin(tool)
|
|
})
|
|
|
|
t.Run("builtin rejects descriptor overrides", func(t *testing.T) {
|
|
registry := NewToolRegistry()
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Fatal("builtin registration with an override must panic")
|
|
}
|
|
}()
|
|
registry.registerBuiltin(RegisteredTool{
|
|
Definition: Tool{Name: agentcapabilities.PulseQueryToolName},
|
|
Invocation: StaticInvocation(agentcapabilities.ToolCallKindRead, agentcapabilities.MutationNone),
|
|
})
|
|
})
|
|
|
|
t.Run("extension without a descriptor is rejected", func(t *testing.T) {
|
|
registry := NewToolRegistry()
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Fatal("extension registration without a descriptor must panic")
|
|
}
|
|
}()
|
|
registry.RegisterExtension(RegisteredTool{Definition: Tool{Name: "undescribed_tool"}})
|
|
})
|
|
}
|
|
|
|
func TestPatrolDetectionProfileEnforcesAllowlistedPulseState(t *testing.T) {
|
|
exec := newInvocationPolicyExecutor(t)
|
|
exec.SetControlLevel(ControlLevelAutonomous)
|
|
exec.SetAutonomousMode(true)
|
|
exec.ApplyExecutionProfile(ProfilePatrolDetection)
|
|
|
|
// Applying a Patrol profile clears inherited autonomy and denies
|
|
// infrastructure outright.
|
|
if exec.isAutonomous {
|
|
t.Fatal("detection profile must clear inherited autonomous mode")
|
|
}
|
|
text := executeBlockedText(t, exec, "pulse_docker", map[string]interface{}{
|
|
"action": "update", "container": "nginx", "host": "tower",
|
|
})
|
|
assert.Contains(t, text, "retired and denied")
|
|
|
|
// Pulse-state mutations outside the finding allowlist are blocked
|
|
// before the handler: detection must not dismiss alerts or write
|
|
// knowledge.
|
|
text = executeBlockedText(t, exec, "pulse_alerts", map[string]interface{}{"action": "resolve", "alert_id": "a1"})
|
|
assert.Contains(t, text, "Invocation blocked")
|
|
text = executeBlockedText(t, exec, "pulse_knowledge", map[string]interface{}{"action": "remember", "resource_id": "vm:42"})
|
|
assert.Contains(t, text, "Invocation blocked")
|
|
|
|
// The finding lifecycle tools stay allowed (they fail on a missing
|
|
// finding creator, not on policy).
|
|
result, err := exec.registry.Execute(context.Background(), exec, "patrol_report_finding", map[string]interface{}{})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, result.Content)
|
|
assert.NotContains(t, result.Content[0].Text, "Invocation blocked")
|
|
result, err = exec.registry.Execute(context.Background(), exec, agentcapabilities.PatrolAssessFindingToolName, map[string]interface{}{})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, result.Content)
|
|
assert.NotContains(t, result.Content[0].Text, "Invocation blocked")
|
|
|
|
// Projection agrees: alerts offers only its read subactions.
|
|
for _, tool := range exec.registry.ListTools(exec.invocationPolicy()) {
|
|
if tool.Name != agentcapabilities.PulseAlertsToolName {
|
|
continue
|
|
}
|
|
enum := tool.InputSchema.Properties["action"].Enum
|
|
assert.NotContains(t, enum, "resolve")
|
|
assert.NotContains(t, enum, "dismiss")
|
|
assert.Contains(t, enum, "list")
|
|
}
|
|
}
|
|
|
|
func TestPatrolInvestigationProfileIsStructurallyReadOnly(t *testing.T) {
|
|
exec := newInvocationPolicyExecutor(t)
|
|
exec.SetControlLevel(ControlLevelAutonomous)
|
|
exec.SetAutonomousMode(true)
|
|
exec.ApplyExecutionProfile(ProfilePatrolInvestigation)
|
|
|
|
if exec.isAutonomous {
|
|
t.Fatal("investigation profile must clear inherited autonomous mode")
|
|
}
|
|
|
|
// No infrastructure mutations.
|
|
text := executeBlockedText(t, exec, "pulse_kubernetes", map[string]interface{}{"type": "scale"})
|
|
assert.Contains(t, text, "retired and denied")
|
|
// No Pulse-state mutations either - not even the Patrol finding tools.
|
|
text = executeBlockedText(t, exec, "patrol_report_finding", map[string]interface{}{})
|
|
assert.Contains(t, text, "Invocation blocked")
|
|
text = executeBlockedText(t, exec, agentcapabilities.PatrolAssessFindingToolName, map[string]interface{}{})
|
|
assert.Contains(t, text, "Invocation blocked")
|
|
text = executeBlockedText(t, exec, "pulse_alerts", map[string]interface{}{"action": "dismiss", "alert_id": "a1"})
|
|
assert.Contains(t, text, "Invocation blocked")
|
|
|
|
// Reads and mutation-none invocations remain available.
|
|
result, err := exec.registry.Execute(context.Background(), exec, "pulse_alerts", map[string]interface{}{"action": "list"})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, result.Content)
|
|
assert.NotContains(t, result.Content[0].Text, "Invocation blocked")
|
|
|
|
// Projection drops the write-only patrol mutation tools entirely.
|
|
offered := map[string]bool{}
|
|
for _, tool := range exec.registry.ListTools(exec.invocationPolicy()) {
|
|
offered[tool.Name] = true
|
|
}
|
|
assert.False(t, offered[agentcapabilities.PatrolReportFindingToolName])
|
|
assert.False(t, offered[agentcapabilities.PatrolAssessFindingToolName])
|
|
assert.False(t, offered[agentcapabilities.PatrolResolveFindingToolName])
|
|
assert.True(t, offered[agentcapabilities.PulseQueryToolName])
|
|
}
|
|
|
|
func TestExecutionProfileSurvivesCloneIsolation(t *testing.T) {
|
|
base := newInvocationPolicyExecutor(t)
|
|
base.ApplyExecutionProfile(ProfilePatrolInvestigation)
|
|
clone := base.Clone()
|
|
assert.Equal(t, ProfilePatrolInvestigation, clone.ExecutionProfile())
|
|
assert.True(t, clone.invocationPolicy().DenyInfrastructureMutations)
|
|
if clone.pulseStateAllowlist == nil {
|
|
t.Fatal("clone must carry the pulse-state denylist")
|
|
}
|
|
|
|
// Resetting the clone to interactive must not affect the original.
|
|
clone.ApplyExecutionProfile(ProfileInteractiveAssistant)
|
|
assert.Equal(t, ProfilePatrolInvestigation, base.ExecutionProfile())
|
|
if base.pulseStateAllowlist == nil {
|
|
t.Fatal("original executor lost its profile policy after clone reset")
|
|
}
|
|
}
|
|
|
|
func TestExecutionProfileVocabularyIsClosed(t *testing.T) {
|
|
unknown := ExecutionProfile(99)
|
|
if unknown.Valid() {
|
|
t.Fatal("unknown profile must not validate")
|
|
}
|
|
if !unknown.NonInteractive() {
|
|
t.Fatal("unknown profile must fail closed as non-interactive, never interactive")
|
|
}
|
|
exec := newInvocationPolicyExecutor(t)
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Fatal("applying an unknown execution profile must panic")
|
|
}
|
|
}()
|
|
exec.ApplyExecutionProfile(unknown)
|
|
}
|