Files
pulse/internal/agentcapabilities/tool_call_test.go
T
rcourtman 67c2534c08 Replace the hard-coded tool classifier with registry-owned invocation descriptors
Every registered Pulse tool now carries a canonical invocation
descriptor (internal/agentcapabilities/invocation.go): static or
discriminator-based, classifying each invocation with a workflow kind
plus a mutation target (none / pulse_state / infrastructure). Mixed
descriptors must exactly cover their schema enum and registration
panics otherwise, so an unclassifiable tool cannot exist. Missing,
malformed, unknown, or fabricated discriminator values classify
fail-closed as infrastructure writes.

Provider projection and runtime enforcement consume the same
descriptor under one InvocationPolicy (control level plus the
request-local, non-serializable deny_infrastructure_mutations
restriction, isolated across executor clones): ListTools and
ListToolGovernance remove forbidden enum values, drop empty tools, and
recompute the offered action mode, while ToolRegistry.Execute blocks
forbidden invocations before the handler runs. This closes the mixed
tool control-level bypass, most seriously Docker action:update, which
previously fell through to direct execution at read-only, and fixes
the Kubernetes misclassification: the retired switch read the action
argument while the schema discriminator is type, so type:scale
classified as read.

pulse_file_edit is now write-only (append/write); file inspection
routes through pulse_read action=file, whose exec path keeps its
structural read-only execution-intent enforcement. ClassifyToolCall
consults the descriptor table first and retains only genuinely
non-registry compatibility cases. The deny restriction is deliberately
separate from autonomous mode, which only suppresses interactive
questions and grants no mutation authority.

Proofs: descriptor validation and fail-closed classification unit
tests, plus the invocation-policy regression suite (scale classifies
write and never invokes at read-only or under deny; Docker update
queues nothing at read-only; autonomous plus deny cannot mutate;
fabricated enum values fail at runtime; filtered projection and
runtime enforcement agree; executor clones keep request policies
isolated). Contracts and registry ownership updated for the new
shared invocation descriptor boundary.

Slice 3a of the typed-lifecycle ratchet; the patrol_investigation
execution profile and patrol_propose_action tool build on this
substrate next.
2026-07-10 13:31:11 +01:00

145 lines
8.4 KiB
Go

package agentcapabilities
import "testing"
func TestToolCallKindString(t *testing.T) {
tests := []struct {
kind ToolCallKind
want string
}{
{ToolCallKindResolve, "resolve"},
{ToolCallKindRead, "read"},
{ToolCallKindWrite, "write"},
{ToolCallKindUserInput, "user_input"},
{ToolCallKind(99), "unknown"},
}
for _, tt := range tests {
if got := tt.kind.String(); got != tt.want {
t.Fatalf("ToolCallKind(%d).String() = %q, want %q", tt.kind, got, tt.want)
}
}
}
func TestPulseIntelligenceToolNameConstants(t *testing.T) {
tests := map[string]string{
"PulseQueryToolName": PulseQueryToolName,
"PulseDiscoveryToolName": PulseDiscoveryToolName,
"PulseMetricsToolName": PulseMetricsToolName,
"PulseStorageToolName": PulseStorageToolName,
"PulseDockerToolName": PulseDockerToolName,
"PulseKubernetesToolName": PulseKubernetesToolName,
"PulseAlertsToolName": PulseAlertsToolName,
"PulseReadToolName": PulseReadToolName,
"PulseControlToolName": PulseControlToolName,
"PulseFileEditToolName": PulseFileEditToolName,
"PulseKnowledgeToolName": PulseKnowledgeToolName,
"PulsePMGToolName": PulsePMGToolName,
"PulseSummarizeToolName": PulseSummarizeToolName,
"PulseRunCommandToolName": PulseRunCommandToolName,
"PulseControlGuestToolName": PulseControlGuestToolName,
"PulseControlDockerToolName": PulseControlDockerToolName,
"PulseSearchResourcesToolName": PulseSearchResourcesToolName,
"PulseGetResourceToolName": PulseGetResourceToolName,
"PulseGetTopologyToolName": PulseGetTopologyToolName,
"PulseListInfrastructureToolName": PulseListInfrastructureToolName,
"PulseGetConnectionHealthToolName": PulseGetConnectionHealthToolName,
"PulseGetDockerLogsToolName": PulseGetDockerLogsToolName,
"PulseGetPerformanceMetricsToolName": PulseGetPerformanceMetricsToolName,
"PulseGetTemperaturesToolName": PulseGetTemperaturesToolName,
"PulseGetBaselinesToolName": PulseGetBaselinesToolName,
"PulseGetPatternsToolName": PulseGetPatternsToolName,
"PatrolGetFindingsToolName": PatrolGetFindingsToolName,
"PatrolReportFindingToolName": PatrolReportFindingToolName,
"PatrolResolveFindingToolName": PatrolResolveFindingToolName,
}
want := map[string]string{
"PulseQueryToolName": "pulse_query",
"PulseDiscoveryToolName": "pulse_discovery",
"PulseMetricsToolName": "pulse_metrics",
"PulseStorageToolName": "pulse_storage",
"PulseDockerToolName": "pulse_docker",
"PulseKubernetesToolName": "pulse_kubernetes",
"PulseAlertsToolName": "pulse_alerts",
"PulseReadToolName": "pulse_read",
"PulseControlToolName": "pulse_control",
"PulseFileEditToolName": "pulse_file_edit",
"PulseKnowledgeToolName": "pulse_knowledge",
"PulsePMGToolName": "pulse_pmg",
"PulseSummarizeToolName": "pulse_summarize",
"PulseRunCommandToolName": "pulse_run_command",
"PulseControlGuestToolName": "pulse_control_guest",
"PulseControlDockerToolName": "pulse_control_docker",
"PulseSearchResourcesToolName": "pulse_search_resources",
"PulseGetResourceToolName": "pulse_get_resource",
"PulseGetTopologyToolName": "pulse_get_topology",
"PulseListInfrastructureToolName": "pulse_list_infrastructure",
"PulseGetConnectionHealthToolName": "pulse_get_connection_health",
"PulseGetDockerLogsToolName": "pulse_get_docker_logs",
"PulseGetPerformanceMetricsToolName": "pulse_get_performance_metrics",
"PulseGetTemperaturesToolName": "pulse_get_temperatures",
"PulseGetBaselinesToolName": "pulse_get_baselines",
"PulseGetPatternsToolName": "pulse_get_patterns",
"PatrolGetFindingsToolName": "patrol_get_findings",
"PatrolReportFindingToolName": "patrol_report_finding",
"PatrolResolveFindingToolName": "patrol_resolve_finding",
}
for name, got := range tests {
if got != want[name] {
t.Fatalf("%s = %q, want %q", name, got, want[name])
}
}
}
func TestClassifyToolCallUsesSharedSafetyClassification(t *testing.T) {
tests := []struct {
name string
toolName string
args map[string]interface{}
want ToolCallKind
}{
{name: "native question", toolName: PulseQuestionToolName, want: ToolCallKindUserInput},
{name: "query resolves", toolName: "pulse_query", want: ToolCallKindResolve},
{name: "discovery get resolves", toolName: "pulse_discovery", args: map[string]interface{}{"action": "get"}, want: ToolCallKindResolve},
{name: "discovery run resolves", toolName: "pulse_discovery", args: map[string]interface{}{"action": "run"}, want: ToolCallKindResolve},
{name: "discovery missing action fails closed", toolName: "pulse_discovery", want: ToolCallKindWrite},
{name: "metrics reads", toolName: "pulse_metrics", want: ToolCallKindRead},
{name: "summarize reads", toolName: PulseSummarizeToolName, want: ToolCallKindRead},
{name: "alert list reads", toolName: "pulse_alerts", args: map[string]interface{}{"action": "list"}, want: ToolCallKindRead},
{name: "alert resolve writes", toolName: "pulse_alerts", args: map[string]interface{}{"action": "resolve"}, want: ToolCallKindWrite},
{name: "read exec remains read", toolName: "pulse_read", args: map[string]interface{}{"action": "exec"}, want: ToolCallKindRead},
{name: "control writes", toolName: "pulse_control", args: map[string]interface{}{"type": "command"}, want: ToolCallKindWrite},
{name: "docker services reads", toolName: "pulse_docker", args: map[string]interface{}{"action": "services"}, want: ToolCallKindRead},
{name: "docker update writes", toolName: "pulse_docker", args: map[string]interface{}{"action": "update"}, want: ToolCallKindWrite},
// Kubernetes's real discriminator is `type`; the retired
// hard-coded classifier read `action` and therefore classified
// scale/restart/delete_pod/exec as read.
{name: "kubernetes pods reads", toolName: "pulse_kubernetes", args: map[string]interface{}{"type": "pods"}, want: ToolCallKindRead},
{name: "kubernetes scale writes", toolName: "pulse_kubernetes", args: map[string]interface{}{"type": "scale"}, want: ToolCallKindWrite},
{name: "kubernetes exec writes", toolName: "pulse_kubernetes", args: map[string]interface{}{"type": "exec"}, want: ToolCallKindWrite},
{name: "kubernetes wrong discriminator fails closed", toolName: "pulse_kubernetes", args: map[string]interface{}{"action": "pods"}, want: ToolCallKindWrite},
// pulse_file_edit is write-only; file reads route via pulse_read.
{name: "file read fails closed", toolName: "pulse_file_edit", args: map[string]interface{}{"action": "read"}, want: ToolCallKindWrite},
{name: "file append writes", toolName: "pulse_file_edit", args: map[string]interface{}{"action": "append"}, want: ToolCallKindWrite},
{name: "knowledge recall reads", toolName: "pulse_knowledge", args: map[string]interface{}{"action": "recall"}, want: ToolCallKindRead},
{name: "knowledge remember writes", toolName: "pulse_knowledge", args: map[string]interface{}{"action": "remember"}, want: ToolCallKindWrite},
{name: "legacy command writes", toolName: LegacyAssistantRunCommandToolName, want: ToolCallKindWrite},
{name: "legacy fetch url reads", toolName: LegacyAssistantFetchURLToolName, want: ToolCallKindRead},
{name: "legacy set url writes", toolName: LegacyAssistantSetResourceURLToolName, want: ToolCallKindWrite},
{name: "patrol findings read", toolName: "patrol_get_findings", want: ToolCallKindRead},
{name: "patrol report writes", toolName: "patrol_report_finding", want: ToolCallKindWrite},
{name: "unknown defaults write", toolName: "future_tool", want: ToolCallKindWrite},
{name: "generic action read", toolName: "future_tool", args: map[string]interface{}{"action": "inspect"}, want: ToolCallKindRead},
{name: "generic operation write", toolName: "future_tool", args: map[string]interface{}{"operation": "delete"}, want: ToolCallKindWrite},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ClassifyToolCall(tt.toolName, tt.args); got != tt.want {
t.Fatalf("ClassifyToolCall(%q, %#v) = %s, want %s", tt.toolName, tt.args, got, tt.want)
}
})
}
}