mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-23 07:53:36 +00:00
325 lines
13 KiB
Go
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
|
|
}
|