mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 13:11:30 -10:00
* perf(tools): add explicit tool effect metadata Introduce fail-closed EffectClass and ToolCapabilities on every built-in tool so a later concurrent read-only batch PR can decide safety from explicit metadata rather than tool names. Plugin and MCP tools stay EffectUnknown. No concurrent execution is enabled. * fix(tools): address review feedback on effect metadata Harden capability normalization and resource keys before PR6 consumes them: - Force ThreadSafe=false for every non-ReadOnly effect at read time - Reject duplicate builtin names in ValidateBuiltinCatalog - Coerce numeric session ids and accept "session" for process keys - Strip path/query before userinfo in endpoint host parsing - Wire apply_patch keys from unified-diff paths + cwd - Reclassify bash as WorkspaceWrite (one-shot shell mutator) - Expand regression tests for keys, normalization, and gates * fix(tools): keep multiFileResourceKeys used by apply_patch keys Satisfies unused lint after switching apply_patch to applyPatchResourceKeys. * fix(tools): resolve CodeRabbit residual capability/key issues - Join apply_patch diff paths under cwd for conflict keys - Validate constructor-declared capabilities before normalization - Unify session/process ID key extraction helper --------- Co-authored-by: KRATOS <[email protected]>
173 lines
6.0 KiB
Go
173 lines
6.0 KiB
Go
package tools
|
|
|
|
import "strings"
|
|
|
|
// EffectClass classifies a tool's side effects for concurrency safety.
|
|
// Concurrent execution (a later PR) must only consider tools that are both
|
|
// ThreadSafe and EffectReadOnly; this package only declares metadata.
|
|
// ThreadSafe is force-cleared for every non-ReadOnly effect at read time
|
|
// (see normalizeCapabilities).
|
|
type EffectClass int
|
|
|
|
const (
|
|
// EffectUnknown is the fail-closed default for unclassified tools.
|
|
// Never concurrency-eligible; always serialized.
|
|
EffectUnknown EffectClass = iota
|
|
// EffectReadOnly reads local or remote state without mutating it.
|
|
// The only effect class that may set ThreadSafe=true after audit.
|
|
EffectReadOnly
|
|
// EffectProcessRead observes a retained process, PTY, or terminal.
|
|
EffectProcessRead
|
|
// EffectWorkspaceWrite mutates the local repository or workspace.
|
|
// Includes one-shot shell commands that can rewrite the workspace.
|
|
EffectWorkspaceWrite
|
|
// EffectExternalWrite mutates state outside the local workspace.
|
|
EffectExternalWrite
|
|
// EffectInteractive uses browser, PTY/terminal, prompts, or other
|
|
// session-bound interaction where ordering matters.
|
|
EffectInteractive
|
|
)
|
|
|
|
// String returns a stable diagnostic name for the effect class.
|
|
func (e EffectClass) String() string {
|
|
switch e {
|
|
case EffectUnknown:
|
|
return "unknown"
|
|
case EffectReadOnly:
|
|
return "read_only"
|
|
case EffectProcessRead:
|
|
return "process_read"
|
|
case EffectWorkspaceWrite:
|
|
return "workspace_write"
|
|
case EffectExternalWrite:
|
|
return "external_write"
|
|
case EffectInteractive:
|
|
return "interactive"
|
|
default:
|
|
return "invalid"
|
|
}
|
|
}
|
|
|
|
// Valid reports whether e is one of the defined effect classes.
|
|
func (e EffectClass) Valid() bool {
|
|
return e >= EffectUnknown && e <= EffectInteractive
|
|
}
|
|
|
|
// ToolCapabilities is the concurrency-safety contract for a tool.
|
|
// ResourceKeys, when set, returns deterministic conflict keys for a call's
|
|
// arguments. Returning no keys does NOT make a tool parallel-safe.
|
|
type ToolCapabilities struct {
|
|
Effect EffectClass
|
|
ThreadSafe bool
|
|
ResourceKeys func(args map[string]any) []string
|
|
}
|
|
|
|
// CapabilityProvider is the optional interface tools implement to declare
|
|
// effect metadata. Tools that do not implement it are treated as EffectUnknown
|
|
// and not thread-safe (fail-closed).
|
|
type CapabilityProvider interface {
|
|
Capabilities() ToolCapabilities
|
|
}
|
|
|
|
// UnknownCapabilities is the fail-closed default for tools without metadata.
|
|
func UnknownCapabilities() ToolCapabilities {
|
|
return ToolCapabilities{
|
|
Effect: EffectUnknown,
|
|
ThreadSafe: false,
|
|
}
|
|
}
|
|
|
|
// normalizeCapabilities is the single read path for capability metadata.
|
|
// ThreadSafe is cleared unless Effect is a valid EffectReadOnly — mutators,
|
|
// interactive tools, process readers, unknown, and invalid effects must never
|
|
// appear concurrency-eligible to a future batch planner that only checks
|
|
// ThreadSafe && Effect.
|
|
func normalizeCapabilities(caps ToolCapabilities) ToolCapabilities {
|
|
if !caps.Effect.Valid() || caps.Effect != EffectReadOnly {
|
|
caps.ThreadSafe = false
|
|
}
|
|
return caps
|
|
}
|
|
|
|
// CapabilitiesOf returns a tool's declared capabilities, or the fail-closed
|
|
// unknown default when the tool does not implement CapabilityProvider.
|
|
func CapabilitiesOf(tool Tool) ToolCapabilities {
|
|
if tool == nil {
|
|
return UnknownCapabilities()
|
|
}
|
|
if provider, ok := tool.(CapabilityProvider); ok {
|
|
return normalizeCapabilities(provider.Capabilities())
|
|
}
|
|
return UnknownCapabilities()
|
|
}
|
|
|
|
// (baseTool).Capabilities exposes the capabilities field set at construction,
|
|
// normalized so ThreadSafe cannot surface on non-ReadOnly effects.
|
|
// Tools that embed baseTool inherit this method.
|
|
func (tool baseTool) Capabilities() ToolCapabilities {
|
|
return normalizeCapabilities(tool.capabilities)
|
|
}
|
|
|
|
// declaredCapabilities returns the constructor-set metadata without runtime
|
|
// normalization. Catalog validation uses this so a mis-declared
|
|
// ThreadSafe=true on a mutator is still reported even though CapabilitiesOf
|
|
// clears it for callers.
|
|
func (tool baseTool) declaredCapabilities() ToolCapabilities {
|
|
return tool.capabilities
|
|
}
|
|
|
|
// declaredCapabilityProvider is implemented by tools that can report their
|
|
// raw constructor metadata (baseTool embedders).
|
|
type declaredCapabilityProvider interface {
|
|
declaredCapabilities() ToolCapabilities
|
|
}
|
|
|
|
// Registry.Capabilities looks up a registered tool's capabilities by name.
|
|
// Unknown tools return the fail-closed default (not an error) so callers can
|
|
// treat missing tools as non-concurrent without panicking.
|
|
func (registry *Registry) Capabilities(name string) ToolCapabilities {
|
|
if registry == nil {
|
|
return UnknownCapabilities()
|
|
}
|
|
tool, ok := registry.Get(name)
|
|
if !ok {
|
|
return UnknownCapabilities()
|
|
}
|
|
return CapabilitiesOf(tool)
|
|
}
|
|
|
|
// ValidateCapabilities checks a single capability record for consistency.
|
|
// It returns a non-empty slice of human-readable problems when invalid.
|
|
// Note: CapabilitiesOf already normalizes ThreadSafe away for non-ReadOnly
|
|
// effects; validation still rejects the raw combination so catalog tests
|
|
// catch mis-declared constructors.
|
|
func ValidateCapabilities(name string, caps ToolCapabilities) []string {
|
|
var problems []string
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
name = "<unnamed>"
|
|
}
|
|
if !caps.Effect.Valid() {
|
|
problems = append(problems, name+": invalid effect value")
|
|
}
|
|
if caps.Effect == EffectUnknown && caps.ThreadSafe {
|
|
problems = append(problems, name+": ThreadSafe=true is forbidden with EffectUnknown")
|
|
}
|
|
// Mutating / interactive / process-read effects are never concurrency-eligible.
|
|
switch caps.Effect {
|
|
case EffectWorkspaceWrite, EffectExternalWrite, EffectInteractive, EffectProcessRead:
|
|
if caps.ThreadSafe {
|
|
problems = append(problems, name+": ThreadSafe=true is not permitted for effect "+caps.Effect.String())
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// ValidateToolCapabilities validates CapabilitiesOf(tool) for a registered tool.
|
|
func ValidateToolCapabilities(tool Tool) []string {
|
|
if tool == nil {
|
|
return []string{"<nil tool>"}
|
|
}
|
|
return ValidateCapabilities(tool.Name(), CapabilitiesOf(tool))
|
|
}
|