Files
pulse/internal/agentcapabilities/invocation.go
T
rcourtman c501376843 Preserve canonical Patrol planning and outcome continuity
Return persisted planning acceptance or refusal inside the investigation turn.
Keep model judgment separate from action authority and preserve accepted action
identity across provider failures. Enforce actor/request idempotency atomically
and retain complete approval and independent verification context.

Preserve unknown disk evidence, stream whitespace and historical resolution
timestamps. Keep conversation scrolling inside its own panel. Record real-model,
disposable-lab and browser qualification with explicit population limits.

Refs #1782
2026-09-07 17:24:25 +01:00

327 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(ToolCallKindWrite, MutationPulseState),
PatrolActionCapabilitiesToolName: staticClass(ToolCallKindRead, MutationNone),
PatrolReportFindingToolName: staticClass(ToolCallKindWrite, MutationPulseState),
PatrolResolveFindingToolName: staticClass(ToolCallKindWrite, MutationPulseState),
PatrolProposeObserverToolName: 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
}