Pulse/internal/agentcapabilities/invocation.go
2026-07-15 10:32:16 +01:00

325 lines
13 KiB
Go

package agentcapabilities
import (
"fmt"
"sort"
"strings"
)
// MutationTarget names what an individual tool invocation can change.
// Workflow kind (read/write/resolve) drives FSM transitions; mutation
// target drives safety policy: control-level gating and request-scoped
// mutation-deny policies key on it, never on workflow kind alone.
type MutationTarget string
const (
// MutationNone: the invocation changes nothing durable.
MutationNone MutationTarget = "none"
// MutationPulseState: the invocation changes Pulse's own records
// (findings, alerts, knowledge) but no customer infrastructure.
MutationPulseState MutationTarget = "pulse_state"
// MutationInfrastructure: the invocation can change customer
// infrastructure. Blocked at read-only control level and under
// deny-infrastructure-mutations request policy, before any handler.
MutationInfrastructure MutationTarget = "infrastructure"
)
// InvocationClass is the classification of one concrete tool invocation.
type InvocationClass struct {
Kind ToolCallKind
Mutation MutationTarget
}
// Valid reports whether the class uses the closed kind and mutation
// vocabularies. Descriptor validation rejects anything else, so an
// unclassifiable or typo'd class can never register.
func (c InvocationClass) Valid() bool {
switch c.Kind {
case ToolCallKindResolve, ToolCallKindRead, ToolCallKindWrite, ToolCallKindUserInput:
default:
return false
}
switch c.Mutation {
case MutationNone, MutationPulseState, MutationInfrastructure:
default:
return false
}
return true
}
// FailClosedInvocationClass is what missing, malformed, or unknown
// invocations classify as: a newly introduced or fabricated subaction can
// never bypass governed-mutation checks by being unclassified.
func FailClosedInvocationClass() InvocationClass {
return InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationInfrastructure}
}
// InvocationDescriptor is the registry-owned classification contract for
// one tool. A tool is either static (every invocation has one class) or
// discriminator-based (the named argument selects the subaction, and Cases
// must exactly cover the schema enum for that argument; registration
// asserts the coverage).
type InvocationDescriptor struct {
// Discriminator is the argument key whose value selects the
// subaction. Empty for static tools.
Discriminator string
// Static is the classification for every invocation of a static tool.
Static *InvocationClass
// Cases maps each declared discriminator enum value to its class.
Cases map[string]InvocationClass
}
// Classify resolves the invocation class for a concrete argument map.
// Missing, malformed, or unknown discriminator values fail closed.
func (d InvocationDescriptor) Classify(args map[string]interface{}) InvocationClass {
if d.Static != nil {
return *d.Static
}
if d.Discriminator == "" || len(d.Cases) == 0 {
return FailClosedInvocationClass()
}
raw, ok := args[d.Discriminator]
if !ok {
return FailClosedInvocationClass()
}
value, ok := raw.(string)
if !ok {
return FailClosedInvocationClass()
}
class, ok := d.Cases[strings.ToLower(strings.TrimSpace(value))]
if !ok {
return FailClosedInvocationClass()
}
return class
}
// Validate checks the descriptor's own shape and, for discriminator-based
// descriptors, that its cases exactly cover the given schema enum values.
// Registration fails on missing or extra cases so the classification
// contract can never drift from the offered schema.
func (d InvocationDescriptor) Validate(toolName string, enumValues []string) error {
if d.Static != nil {
if d.Discriminator != "" || len(d.Cases) != 0 {
return fmt.Errorf("tool %q invocation descriptor must be static or discriminator-based, not both", toolName)
}
if !d.Static.Valid() {
return fmt.Errorf("tool %q static invocation class uses an unknown kind or mutation target", toolName)
}
return nil
}
if d.Discriminator == "" {
return fmt.Errorf("tool %q invocation descriptor declares neither static class nor discriminator", toolName)
}
if len(enumValues) == 0 {
return fmt.Errorf("tool %q discriminator %q has no schema enum to cover", toolName, d.Discriminator)
}
want := map[string]bool{}
for _, v := range enumValues {
want[strings.ToLower(strings.TrimSpace(v))] = true
}
var missing, extra []string
for v := range want {
if _, ok := d.Cases[v]; !ok {
missing = append(missing, v)
}
}
for v, class := range d.Cases {
if !want[v] {
extra = append(extra, v)
}
if !class.Valid() {
return fmt.Errorf("tool %q invocation case %q uses an unknown kind or mutation target", toolName, v)
}
}
sort.Strings(missing)
sort.Strings(extra)
if len(missing) > 0 || len(extra) > 0 {
return fmt.Errorf("tool %q invocation descriptor does not exactly cover schema enum for %q (missing=%v extra=%v)", toolName, d.Discriminator, missing, extra)
}
return nil
}
func staticClass(kind ToolCallKind, mutation MutationTarget) InvocationDescriptor {
class := InvocationClass{Kind: kind, Mutation: mutation}
return InvocationDescriptor{Static: &class}
}
// registryInvocationDescriptors is the canonical classification table for
// every Pulse registry tool. Workflow kinds intentionally match the
// historical shared classifier so FSM transitions do not change; mutation
// targets are the safety-policy layer on top.
//
// Kubernetes's discriminator is `type`, not `action` - the historical
// hard-coded classifier read `action` and therefore classified every
// Kubernetes invocation (including scale/restart/delete_pod/exec) as read.
var registryInvocationDescriptors = map[string]InvocationDescriptor{
PulseQueryToolName: staticClass(ToolCallKindResolve, MutationNone),
PulseMetricsToolName: staticClass(ToolCallKindRead, MutationNone),
PulseStorageToolName: staticClass(ToolCallKindRead, MutationNone),
PulsePMGToolName: staticClass(ToolCallKindRead, MutationNone),
PulseSummarizeToolName: staticClass(ToolCallKindRead, MutationNone),
// pulse_read's exec subaction dispatches only structurally read-only
// commands: the handler's execution-intent classifier rejects
// WriteOrUnknown commands before dispatch.
PulseReadToolName: staticClass(ToolCallKindRead, MutationNone),
PulseControlToolName: staticClass(ToolCallKindWrite, MutationInfrastructure),
// pulse_file_edit is write-only: file inspection routes through
// pulse_read, so this tool never advertises a read subaction.
PulseFileEditToolName: staticClass(ToolCallKindWrite, MutationInfrastructure),
PulseDiscoveryToolName: {
Discriminator: "action",
Cases: map[string]InvocationClass{
"get": {Kind: ToolCallKindResolve, Mutation: MutationNone},
"list": {Kind: ToolCallKindResolve, Mutation: MutationNone},
// run collects evidence into the discovery cache only; it
// does not mutate customer infrastructure.
"run": {Kind: ToolCallKindResolve, Mutation: MutationNone},
},
},
PulseAlertsToolName: {
Discriminator: "action",
Cases: map[string]InvocationClass{
"list": {Kind: ToolCallKindRead, Mutation: MutationNone},
"findings": {Kind: ToolCallKindRead, Mutation: MutationNone},
"resolved": {Kind: ToolCallKindRead, Mutation: MutationNone},
"resolve": {Kind: ToolCallKindWrite, Mutation: MutationPulseState},
"dismiss": {Kind: ToolCallKindWrite, Mutation: MutationPulseState},
},
},
PulseKubernetesToolName: {
Discriminator: "type",
Cases: map[string]InvocationClass{
"clusters": {Kind: ToolCallKindRead, Mutation: MutationNone},
"nodes": {Kind: ToolCallKindRead, Mutation: MutationNone},
"pods": {Kind: ToolCallKindRead, Mutation: MutationNone},
"deployments": {Kind: ToolCallKindRead, Mutation: MutationNone},
"logs": {Kind: ToolCallKindRead, Mutation: MutationNone},
},
},
PulseDockerToolName: {
Discriminator: "action",
Cases: map[string]InvocationClass{
"updates": {Kind: ToolCallKindRead, Mutation: MutationNone},
"services": {Kind: ToolCallKindRead, Mutation: MutationNone},
"tasks": {Kind: ToolCallKindRead, Mutation: MutationNone},
"swarm": {Kind: ToolCallKindRead, Mutation: MutationNone},
// check_updates queues a read-only scan command on the
// agent; it changes nothing on the container estate, so it
// is read for workflow purposes too (write would drive the
// FSM into verification for a non-mutating refresh).
"check_updates": {Kind: ToolCallKindRead, Mutation: MutationNone},
},
},
PulseKnowledgeToolName: {
Discriminator: "action",
Cases: map[string]InvocationClass{
"recall": {Kind: ToolCallKindRead, Mutation: MutationNone},
"incidents": {Kind: ToolCallKindRead, Mutation: MutationNone},
"correlate": {Kind: ToolCallKindRead, Mutation: MutationNone},
"remember": {Kind: ToolCallKindWrite, Mutation: MutationPulseState},
},
},
PatrolGetFindingsToolName: staticClass(ToolCallKindRead, MutationNone),
// Assessments are accepted finding-lifecycle writes. A present verdict
// refreshes the finding, resolved closes it behind the existing verifier,
// and uncertain is persisted on the run while keeping the finding active.
PatrolAssessFindingToolName: staticClass(ToolCallKindWrite, MutationPulseState),
// patrol_propose_action is side-effect-free capture (mutation-none)
// and read-kind so a concluding proposal never drives the FSM into
// write verification. It is additionally profile-gated: the registry
// policy rejects it outside the Patrol investigation profile.
PatrolProposeActionToolName: staticClass(ToolCallKindRead, MutationNone),
PatrolActionCapabilitiesToolName: staticClass(ToolCallKindRead, MutationNone),
PatrolReportFindingToolName: staticClass(ToolCallKindWrite, MutationPulseState),
PatrolResolveFindingToolName: staticClass(ToolCallKindWrite, MutationPulseState),
}
// Clone returns a deep copy of the descriptor so callers can never
// mutate the canonical table through shared case maps or the static
// class pointer.
func (d InvocationDescriptor) Clone() InvocationDescriptor {
clone := InvocationDescriptor{Discriminator: d.Discriminator}
if d.Static != nil {
static := *d.Static
clone.Static = &static
}
if d.Cases != nil {
clone.Cases = make(map[string]InvocationClass, len(d.Cases))
for value, class := range d.Cases {
clone.Cases[value] = class
}
}
return clone
}
// InvocationDescriptorFor returns a deep copy of the canonical invocation
// descriptor for a registry tool name.
func InvocationDescriptorFor(toolName string) (InvocationDescriptor, bool) {
d, ok := registryInvocationDescriptors[strings.TrimSpace(toolName)]
if !ok {
return InvocationDescriptor{}, false
}
return d.Clone(), true
}
// ClassifyRegisteredInvocation classifies a concrete invocation of a
// registry tool. Unknown tool names fail closed: a tool without a
// descriptor cannot be assumed safe.
func ClassifyRegisteredInvocation(toolName string, args map[string]interface{}) InvocationClass {
descriptor, ok := InvocationDescriptorFor(toolName)
if !ok {
return FailClosedInvocationClass()
}
return descriptor.Classify(args)
}
// ClassifyLegacyAssistantInvocation classifies the compatibility aliases used
// by /api/ai/execute. Keeping this mapping here lets its provider projection
// and runtime boundary consume the same closed mutation vocabulary as the
// registry-backed Assistant. Unknown aliases fail closed.
func ClassifyLegacyAssistantInvocation(toolName string) InvocationClass {
switch strings.TrimSpace(toolName) {
case LegacyAssistantFetchURLToolName:
return InvocationClass{Kind: ToolCallKindRead, Mutation: MutationNone}
case LegacyAssistantRunCommandToolName:
return InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationInfrastructure}
case LegacyAssistantSetResourceURLToolName, ResolveFindingCapabilityName, DismissFindingCapabilityName:
return InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationPulseState}
default:
return FailClosedInvocationClass()
}
}
// RedactedProposalParamsMarker replaces proposal parameter values in every
// durable or user-visible exposure of a patrol_propose_action call.
const RedactedProposalParamsMarker = "[redacted-proposal-params]"
// ToolHasRestrictedExposure reports whether a tool's raw arguments are
// exposure-restricted: durable and user-visible surfaces must use only
// the projected form and must never substitute provider-streamed raw
// argument text.
func ToolHasRestrictedExposure(toolName string) bool {
return strings.TrimSpace(toolName) == PatrolProposeActionToolName
}
// RedactToolCallArgumentsForExposure is the canonical exposure projector
// for tool-call arguments: everything durable or user-visible (chat
// transcripts, tool_start/tool_progress/tool_end stream events) must route
// its arguments through here. Proposal parameter values exist only
// transiently for provider continuation and validation; the action audit
// is their canonical durable home. Returns the original map unchanged for
// tools without exposure restrictions.
func RedactToolCallArgumentsForExposure(toolName string, args map[string]interface{}) map[string]interface{} {
if strings.TrimSpace(toolName) != PatrolProposeActionToolName || args == nil {
return args
}
if _, ok := args["params"]; !ok {
return args
}
redacted := make(map[string]interface{}, len(args))
for key, value := range args {
redacted[key] = value
}
redacted["params"] = RedactedProposalParamsMarker
return redacted
}