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 }