mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3518bfc64e | ||
|
|
fee94fc949 | ||
|
|
29e47d879d | ||
|
|
a3a19b26b6 |
No files matched your search
@@ -34,7 +34,8 @@ func NewScopedApplyPatchTool(workspaceRoot string, scope PathScope) Tool {
|
||||
Required: []string{"patch"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: promptSafety(SideEffectWrite, "Applies patch hunks that can create, edit, or delete files."),
|
||||
safety: promptSafety(SideEffectWrite, "Applies patch hunks that can create, edit, or delete files."),
|
||||
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false, ResourceKeys: applyPatchResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
|
||||
@@ -88,6 +88,8 @@ func NewAskUserTool() *askUserTool {
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Asks the user clarifying questions; gathers input only."),
|
||||
// EffectInteractive: blocks on user input; ordering matters.
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ func NewScopedBashTool(workspaceRoot string, scope PathScope) Tool {
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: promptSafety(SideEffectShell, "Shell commands can read, write, or execute programs."),
|
||||
// One-shot shell can rewrite the workspace (and more). Serialized
|
||||
// as WorkspaceWrite so external-mutator audits include bash; not
|
||||
// session-bound Interactive (that is reserved for PTY/terminal tools).
|
||||
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false, ResourceKeys: directoryResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package tools
|
||||
|
||||
import "github.com/Gitlawb/zero/internal/localcontrol"
|
||||
|
||||
// BuiltinCatalog returns every built-in tool that Zero can expose to the model
|
||||
// from this package (core + control + optional local-control helpers).
|
||||
//
|
||||
// It is the source of truth for metadata coverage tests: every tool returned
|
||||
// here must declare an explicit Effect other than EffectUnknown.
|
||||
//
|
||||
// Not included (fail-closed EffectUnknown by design):
|
||||
// - MCP tools (internal/mcp registryTool)
|
||||
// - Plugin tools (internal/plugins pluginTool)
|
||||
// - Specialist tools registered outside this package (Task, Generate, …)
|
||||
//
|
||||
// workspaceRoot is used only to construct path-scoped tools; it need not exist.
|
||||
func BuiltinCatalog(workspaceRoot string) []Tool {
|
||||
if workspaceRoot == "" {
|
||||
workspaceRoot = "."
|
||||
}
|
||||
var all []Tool
|
||||
all = append(all, CoreReadOnlyTools(workspaceRoot)...)
|
||||
all = append(all, CoreWriteTools(workspaceRoot)...)
|
||||
all = append(all, CoreShellTools(workspaceRoot)...)
|
||||
// Network tools: always include web_fetch; always include web_search for
|
||||
// classification even when no backend is configured at runtime (registration
|
||||
// remains conditional in CoreNetworkTools).
|
||||
all = append(all, NewWebFetchTool(), NewWebSearchTool())
|
||||
all = append(all, NewEscalateModelTool())
|
||||
|
||||
// tool_search needs a live registry of the tools above.
|
||||
reg := NewRegistry()
|
||||
for _, tool := range all {
|
||||
reg.Register(tool)
|
||||
}
|
||||
search := NewToolSearchTool(reg)
|
||||
reg.Register(search)
|
||||
all = append(all, search)
|
||||
|
||||
// Local-control tools are feature-gated at registration time but always
|
||||
// exist as built-in implementations with fixed classifications.
|
||||
all = append(all, NewLocalBrowserTools(localcontrol.BrowserOptions{})...)
|
||||
all = append(all, NewLocalDesktopTools(localcontrol.DesktopOptions{})...)
|
||||
all = append(all, NewLocalTerminalTools(localcontrol.TerminalOptions{})...)
|
||||
all = append(all, NewLocalControlArtifactTools(LocalControlArtifactOptions{})...)
|
||||
return all
|
||||
}
|
||||
|
||||
// ValidateBuiltinCatalog returns diagnostics for every catalog tool that is
|
||||
// missing or inconsistently classified. Empty means full coverage.
|
||||
//
|
||||
// Duplicate builtin names are always rejected: ResourceKeys is a function and
|
||||
// cannot be compared, so partial metadata comparison would miss conflict-scope
|
||||
// drift between two same-name tools.
|
||||
//
|
||||
// Validation uses constructor-declared capabilities (before ThreadSafe
|
||||
// normalization) so a mutator wired with ThreadSafe=true is still reported.
|
||||
// Runtime callers continue to use CapabilitiesOf, which normalizes.
|
||||
func ValidateBuiltinCatalog(workspaceRoot string) []string {
|
||||
var problems []string
|
||||
seen := map[string]struct{}{}
|
||||
for _, tool := range BuiltinCatalog(workspaceRoot) {
|
||||
name := tool.Name()
|
||||
if _, ok := seen[name]; ok {
|
||||
problems = append(problems, name+": duplicate builtin name in catalog")
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
|
||||
// Runtime view: must not remain Unknown after normalization.
|
||||
runtimeCaps := CapabilitiesOf(tool)
|
||||
if runtimeCaps.Effect == EffectUnknown {
|
||||
problems = append(problems, name+": built-in tool remains EffectUnknown (metadata required)")
|
||||
}
|
||||
|
||||
// Declaration view: catch invalid ThreadSafe on mutators that
|
||||
// CapabilitiesOf would silently clear.
|
||||
declared := declaredCapabilitiesOf(tool)
|
||||
problems = append(problems, ValidateCapabilities(name, declared)...)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// declaredCapabilitiesOf returns constructor metadata when available; otherwise
|
||||
// falls back to CapabilitiesOf (already normalized).
|
||||
func declaredCapabilitiesOf(tool Tool) ToolCapabilities {
|
||||
if tool == nil {
|
||||
return UnknownCapabilities()
|
||||
}
|
||||
if d, ok := tool.(declaredCapabilityProvider); ok {
|
||||
return d.declaredCapabilities()
|
||||
}
|
||||
return CapabilitiesOf(tool)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// plainTool implements Tool without CapabilityProvider — fail-closed default.
|
||||
type plainTool struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (t plainTool) Name() string { return t.name }
|
||||
func (t plainTool) Description() string { return "plain" }
|
||||
func (t plainTool) Parameters() Schema { return Schema{Type: "object"} }
|
||||
func (t plainTool) Safety() Safety { return readOnlySafety("plain") }
|
||||
func (t plainTool) Run(context.Context, map[string]any) Result {
|
||||
return okResult("ok")
|
||||
}
|
||||
|
||||
func TestCapabilitiesOfDefaultsUnknown(t *testing.T) {
|
||||
caps := CapabilitiesOf(plainTool{name: "mystery"})
|
||||
if caps.Effect != EffectUnknown {
|
||||
t.Fatalf("Effect = %v, want Unknown", caps.Effect)
|
||||
}
|
||||
if caps.ThreadSafe {
|
||||
t.Fatal("ThreadSafe must be false for unclassified tools")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilitiesOfNilTool(t *testing.T) {
|
||||
caps := CapabilitiesOf(nil)
|
||||
if caps.Effect != EffectUnknown || caps.ThreadSafe {
|
||||
t.Fatalf("nil tool = %+v, want fail-closed unknown", caps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownNotThreadSafeEvenIfProviderLies(t *testing.T) {
|
||||
tool := baseTool{
|
||||
name: "liar",
|
||||
capabilities: ToolCapabilities{
|
||||
Effect: EffectUnknown,
|
||||
ThreadSafe: true, // invalid — Capabilities() must clear it
|
||||
},
|
||||
}
|
||||
caps := tool.Capabilities()
|
||||
if caps.ThreadSafe {
|
||||
t.Fatal("Capabilities() must force ThreadSafe=false for EffectUnknown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeClearsThreadSafeOnMutators(t *testing.T) {
|
||||
tool := baseTool{
|
||||
name: "writer",
|
||||
capabilities: ToolCapabilities{
|
||||
Effect: EffectWorkspaceWrite,
|
||||
ThreadSafe: true, // must be cleared on read
|
||||
},
|
||||
}
|
||||
if tool.Capabilities().ThreadSafe {
|
||||
t.Fatal("WorkspaceWrite must never surface ThreadSafe=true via Capabilities()")
|
||||
}
|
||||
// Wrap with Run so CapabilitiesOf's CapabilityProvider path is exercised.
|
||||
wrapped := deferredCapableTool{baseTool: tool}
|
||||
if CapabilitiesOf(wrapped).ThreadSafe {
|
||||
t.Fatal("CapabilitiesOf must normalize mutator ThreadSafe away")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsThreadSafeUnknown(t *testing.T) {
|
||||
problems := ValidateCapabilities("x", ToolCapabilities{Effect: EffectUnknown, ThreadSafe: true})
|
||||
if len(problems) == 0 {
|
||||
t.Fatal("expected validation problems for ThreadSafe+Unknown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsThreadSafeMutators(t *testing.T) {
|
||||
for _, effect := range []EffectClass{EffectWorkspaceWrite, EffectExternalWrite, EffectInteractive, EffectProcessRead} {
|
||||
problems := ValidateCapabilities("m", ToolCapabilities{Effect: effect, ThreadSafe: true})
|
||||
if len(problems) == 0 {
|
||||
t.Fatalf("effect %s: expected ThreadSafe rejection", effect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryBuiltinHasExplicitMetadata(t *testing.T) {
|
||||
problems := ValidateBuiltinCatalog(t.TempDir())
|
||||
if len(problems) > 0 {
|
||||
t.Fatalf("builtin metadata coverage failed:\n %s", strings.Join(problems, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinCatalogNoUnknown(t *testing.T) {
|
||||
counts := map[EffectClass]int{}
|
||||
for _, tool := range BuiltinCatalog(t.TempDir()) {
|
||||
caps := CapabilitiesOf(tool)
|
||||
if caps.Effect == EffectUnknown {
|
||||
t.Errorf("tool %q is EffectUnknown", tool.Name())
|
||||
}
|
||||
counts[caps.Effect]++
|
||||
}
|
||||
if counts[EffectUnknown] != 0 {
|
||||
t.Fatalf("Unknown built-ins = %d, want 0", counts[EffectUnknown])
|
||||
}
|
||||
t.Logf("classification counts: %+v", counts)
|
||||
}
|
||||
|
||||
func TestRegistryCapabilitiesLookup(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
reg.Register(NewScopedReadFileTool(t.TempDir(), nil))
|
||||
caps := reg.Capabilities("read_file")
|
||||
if caps.Effect != EffectReadOnly {
|
||||
t.Fatalf("read_file effect = %v, want ReadOnly", caps.Effect)
|
||||
}
|
||||
// Missing tool → fail-closed.
|
||||
missing := reg.Capabilities("does_not_exist")
|
||||
if missing.Effect != EffectUnknown || missing.ThreadSafe {
|
||||
t.Fatalf("missing tool = %+v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResourceKeysNormalized(t *testing.T) {
|
||||
keys := fileResourceKeys(map[string]any{"path": `./src/../src/main.go`})
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("keys = %v", keys)
|
||||
}
|
||||
want := ResourceKeyFile + NormalizeResourcePath("./src/../src/main.go")
|
||||
if keys[0] != want {
|
||||
t.Fatalf("key = %q, want %q", keys[0], want)
|
||||
}
|
||||
// Missing args → nil, no panic.
|
||||
if fileResourceKeys(nil) != nil {
|
||||
t.Fatal("nil args should yield nil keys")
|
||||
}
|
||||
if fileResourceKeys(map[string]any{}) != nil {
|
||||
t.Fatal("empty args should yield nil keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePathNormalizePlatform(t *testing.T) {
|
||||
// Unix-style clean
|
||||
got := NormalizeResourcePath("a//b/../c")
|
||||
if got != "a/c" && got != filepath.ToSlash(filepath.Clean("a//b/../c")) {
|
||||
// After ToSlash clean should be a/c
|
||||
if NormalizeResourcePath("a//b/../c") != "a/c" {
|
||||
t.Fatalf("unix clean = %q", got)
|
||||
}
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
// Case-insensitive
|
||||
if NormalizeResourcePath(`C:\Foo\Bar`) != NormalizeResourcePath(`c:\foo\bar`) {
|
||||
t.Fatalf("windows case fold failed: %q vs %q",
|
||||
NormalizeResourcePath(`C:\Foo\Bar`), NormalizeResourcePath(`c:\foo\bar`))
|
||||
}
|
||||
}
|
||||
// Empty / URL-shaped
|
||||
if NormalizeResourcePath("") != "" {
|
||||
t.Fatal("empty path")
|
||||
}
|
||||
if NormalizeResourcePath("https://user:pass@host/path") != "" {
|
||||
t.Fatal("URL must not become a file resource path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointResourceKeysStripSecrets(t *testing.T) {
|
||||
keys := endpointResourceKeys(map[string]any{
|
||||
"url": "https://user:[email protected]:443/v1?token=abc",
|
||||
})
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("keys = %v", keys)
|
||||
}
|
||||
if strings.Contains(keys[0], "secret") || strings.Contains(keys[0], "token") || strings.Contains(keys[0], "user") {
|
||||
t.Fatalf("endpoint key leaked secret material: %q", keys[0])
|
||||
}
|
||||
if !strings.Contains(keys[0], "api.example.com") {
|
||||
t.Fatalf("endpoint key missing host: %q", keys[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointResourceKeysAtInQueryDoesNotStealHost(t *testing.T) {
|
||||
// Path/query must be stripped BEFORE userinfo so ?x=@bad cannot become the host.
|
||||
keys := endpointResourceKeys(map[string]any{
|
||||
"url": "https://api.example.com/v1?token=@secret",
|
||||
})
|
||||
if len(keys) != 1 || keys[0] != ResourceKeyEndpoint+"api.example.com" {
|
||||
t.Fatalf("keys = %v, want endpoint:api.example.com", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessResourceKeysNumericSessionID(t *testing.T) {
|
||||
// write_stdin declares session_id as integer; JSON decodes as float64.
|
||||
keys := processResourceKeys(map[string]any{"session_id": float64(42)})
|
||||
if len(keys) != 1 || keys[0] != ResourceKeyProcess+"42" {
|
||||
t.Fatalf("keys = %v, want process:42", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessResourceKeysSessionArg(t *testing.T) {
|
||||
// terminal_session requires "session", not session_id.
|
||||
keys := processResourceKeys(map[string]any{"session": "term-7"})
|
||||
if len(keys) != 1 || keys[0] != ResourceKeyProcess+"term-7" {
|
||||
t.Fatalf("keys = %v, want process:term-7", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchResourceKeysFromDiffAndCwd(t *testing.T) {
|
||||
patch := "--- a/old.go\n+++ b/new.go\n@@ -1 +1 @@\n-x\n+y\n"
|
||||
keys := applyPatchResourceKeys(map[string]any{
|
||||
"patch": patch,
|
||||
"cwd": "pkg",
|
||||
})
|
||||
// Diff paths must be joined under cwd so the same basename under different
|
||||
// cwds does not collide for the future conflict planner.
|
||||
want := map[string]bool{
|
||||
ResourceKeyDirectory + "pkg": true,
|
||||
ResourceKeyFile + "pkg/old.go": true,
|
||||
ResourceKeyFile + "pkg/new.go": true,
|
||||
}
|
||||
for _, k := range keys {
|
||||
if !want[k] {
|
||||
t.Fatalf("unexpected key %q in %v", k, keys)
|
||||
}
|
||||
delete(want, k)
|
||||
}
|
||||
if len(want) != 0 {
|
||||
t.Fatalf("missing keys %v from %v", want, keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredMutatorThreadSafeIsReportedByCatalogValidation(t *testing.T) {
|
||||
// Runtime CapabilitiesOf clears ThreadSafe on mutators; catalog validation
|
||||
// must still see the raw declaration so miswired constructors fail the gate.
|
||||
tool := deferredCapableTool{baseTool: baseTool{
|
||||
name: "bad_writer",
|
||||
capabilities: ToolCapabilities{
|
||||
Effect: EffectWorkspaceWrite,
|
||||
ThreadSafe: true,
|
||||
},
|
||||
}}
|
||||
declared := declaredCapabilitiesOf(tool)
|
||||
if !declared.ThreadSafe || declared.Effect != EffectWorkspaceWrite {
|
||||
t.Fatalf("declared = %+v, want raw WorkspaceWrite+ThreadSafe", declared)
|
||||
}
|
||||
problems := ValidateCapabilities(tool.Name(), declared)
|
||||
if len(problems) == 0 {
|
||||
t.Fatal("expected ValidateCapabilities to reject raw WorkspaceWrite+ThreadSafe")
|
||||
}
|
||||
// Runtime view stays safe.
|
||||
if CapabilitiesOf(tool).ThreadSafe {
|
||||
t.Fatal("runtime CapabilitiesOf must still clear ThreadSafe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchResourceKeysFallbackWorkspace(t *testing.T) {
|
||||
keys := applyPatchResourceKeys(map[string]any{"patch": "not a real diff"})
|
||||
if len(keys) != 1 || keys[0] != ResourceKeyWorkspace+"root" {
|
||||
t.Fatalf("keys = %v, want workspace:root fallback", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceWriteClassifications(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, tool := range []Tool{
|
||||
NewScopedWriteFileTool(root, nil),
|
||||
NewScopedEditFileTool(root, nil),
|
||||
NewScopedApplyPatchTool(root, nil),
|
||||
NewScopedBashTool(root, nil),
|
||||
} {
|
||||
if got := CapabilitiesOf(tool).Effect; got != EffectWorkspaceWrite {
|
||||
t.Errorf("%s effect = %v, want WorkspaceWrite", tool.Name(), got)
|
||||
}
|
||||
if CapabilitiesOf(tool).ThreadSafe {
|
||||
t.Errorf("%s must not be ThreadSafe", tool.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInteractiveClassifications(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"ask_user", "exec_command", "write_stdin", "update_plan", "escalate_model", "request_permissions", "browser_open", "terminal_session", "desktop_action"} {
|
||||
var found Tool
|
||||
for _, tool := range BuiltinCatalog(root) {
|
||||
if tool.Name() == name {
|
||||
found = tool
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("tool %q not in catalog", name)
|
||||
}
|
||||
if got := CapabilitiesOf(found).Effect; got != EffectInteractive {
|
||||
t.Errorf("%s effect = %v, want Interactive", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyClassifications(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"read_file", "read_minified_file", "list_directory", "glob", "grep", "lsp_navigate", "skill", "web_fetch", "web_search", "tool_search"} {
|
||||
var found Tool
|
||||
for _, tool := range BuiltinCatalog(root) {
|
||||
if tool.Name() == name {
|
||||
found = tool
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("tool %q not in catalog", name)
|
||||
}
|
||||
caps := CapabilitiesOf(found)
|
||||
if caps.Effect != EffectReadOnly {
|
||||
t.Errorf("%s effect = %v, want ReadOnly", name, caps.Effect)
|
||||
}
|
||||
// Current catalog intentionally keeps read tools non-thread-safe
|
||||
// until each is audited for shared mutable state (FileTracker, etc.).
|
||||
if caps.ThreadSafe {
|
||||
t.Errorf("%s ThreadSafe=true without explicit audit", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBuiltinCatalogRejectsUnknownRaw(t *testing.T) {
|
||||
// Prove the gate is not a rubber stamp: raw Unknown+ThreadSafe is rejected.
|
||||
problems := ValidateCapabilities("ghost", ToolCapabilities{Effect: EffectUnknown, ThreadSafe: true})
|
||||
if len(problems) == 0 {
|
||||
t.Fatal("expected ValidateCapabilities to reject Unknown+ThreadSafe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureArtifactIsWorkspaceWrite(t *testing.T) {
|
||||
for _, tool := range NewLocalControlArtifactTools(LocalControlArtifactOptions{}) {
|
||||
if CapabilitiesOf(tool).Effect != EffectWorkspaceWrite {
|
||||
t.Fatalf("%s effect = %v, want WorkspaceWrite", tool.Name(), CapabilitiesOf(tool).Effect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilitiesLookupRace(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
for _, tool := range BuiltinCatalog(t.TempDir()) {
|
||||
reg.Register(tool)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 32; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for _, tool := range reg.All() {
|
||||
_ = reg.Capabilities(tool.Name())
|
||||
_ = CapabilitiesOf(tool)
|
||||
caps := CapabilitiesOf(tool)
|
||||
if caps.ResourceKeys != nil {
|
||||
_ = caps.ResourceKeys(map[string]any{"path": "a/b.go", "url": "https://example.com"})
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestEffectStringAndValid(t *testing.T) {
|
||||
for _, e := range []EffectClass{EffectUnknown, EffectReadOnly, EffectProcessRead, EffectWorkspaceWrite, EffectExternalWrite, EffectInteractive} {
|
||||
if !e.Valid() {
|
||||
t.Fatalf("%v should be valid", e)
|
||||
}
|
||||
if e.String() == "invalid" || e.String() == "" {
|
||||
t.Fatalf("String for %d", e)
|
||||
}
|
||||
}
|
||||
if EffectClass(99).Valid() {
|
||||
t.Fatal("invalid class reported valid")
|
||||
}
|
||||
}
|
||||
|
||||
// deferredCapableTool models a deferred-eligible built-in that still carries
|
||||
// explicit capabilities (MCP tools do not implement CapabilityProvider and
|
||||
// stay Unknown by design).
|
||||
type deferredCapableTool struct {
|
||||
baseTool
|
||||
}
|
||||
|
||||
func (t deferredCapableTool) Deferred() bool { return true }
|
||||
|
||||
func (t deferredCapableTool) Run(context.Context, map[string]any) Result {
|
||||
return okResult("ok")
|
||||
}
|
||||
|
||||
func TestDeferredToolPreservesCapabilities(t *testing.T) {
|
||||
tool := deferredCapableTool{baseTool: baseTool{
|
||||
name: "deferred_reader",
|
||||
capabilities: ToolCapabilities{
|
||||
Effect: EffectReadOnly,
|
||||
ThreadSafe: false,
|
||||
ResourceKeys: fileResourceKeys,
|
||||
},
|
||||
}}
|
||||
if !IsDeferred(tool) {
|
||||
t.Fatal("expected deferred")
|
||||
}
|
||||
reg := NewRegistry()
|
||||
reg.Register(tool)
|
||||
got, ok := reg.Get("deferred_reader")
|
||||
if !ok {
|
||||
t.Fatal("missing registration")
|
||||
}
|
||||
caps := CapabilitiesOf(got)
|
||||
if caps.Effect != EffectReadOnly {
|
||||
t.Fatalf("effect = %v after deferred register", caps.Effect)
|
||||
}
|
||||
keys := caps.ResourceKeys(map[string]any{"path": "pkg/a.go"})
|
||||
if len(keys) != 1 || keys[0] != ResourceKeyFile+NormalizeResourcePath("pkg/a.go") {
|
||||
t.Fatalf("resource keys = %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginAndMCPStyleDefaultUnknown(t *testing.T) {
|
||||
// Plugin and MCP adapters do not implement CapabilityProvider; they must
|
||||
// remain fail-closed (serialized) until they opt into a trusted contract.
|
||||
for _, name := range []string{"mcp_github_create_issue", "plugin_custom_tool"} {
|
||||
caps := CapabilitiesOf(plainTool{name: name})
|
||||
if caps.Effect != EffectUnknown || caps.ThreadSafe {
|
||||
t.Fatalf("%s = %+v, want Unknown/not-thread-safe", name, caps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemasUnchangedForCoreTools(t *testing.T) {
|
||||
// Metadata must not alter tool names or parameter shapes.
|
||||
root := t.TempDir()
|
||||
read := NewScopedReadFileTool(root, nil)
|
||||
if read.Name() != "read_file" {
|
||||
t.Fatalf("name = %q", read.Name())
|
||||
}
|
||||
params := read.Parameters()
|
||||
if params.Type != "object" {
|
||||
t.Fatalf("params type = %q", params.Type)
|
||||
}
|
||||
if _, ok := params.Properties["path"]; !ok {
|
||||
t.Fatal("path property missing")
|
||||
}
|
||||
// Safety path unchanged.
|
||||
if read.Safety().SideEffect != SideEffectRead {
|
||||
t.Fatalf("safety side effect changed: %v", read.Safety().SideEffect)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,8 @@ func NewScopedEditFileTool(workspaceRoot string, scope PathScope) Tool {
|
||||
Required: []string{"path", "old_string", "new_string"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: promptSafety(SideEffectWrite, "Edits files in place."),
|
||||
safety: promptSafety(SideEffectWrite, "Edits files in place."),
|
||||
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false, ResourceKeys: fileResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
|
||||
@@ -57,6 +57,8 @@ func newEscalateModelTool(registry modelregistry.Registry) escalateModelTool {
|
||||
Reason: "Requests a mid-run switch to a stronger model; the agent loop performs the switch.",
|
||||
AdvertiseInAuto: true,
|
||||
},
|
||||
// Requests mid-run model switch; session-bound.
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
|
||||
},
|
||||
registry: registry,
|
||||
}
|
||||
|
||||
@@ -465,6 +465,8 @@ func NewScopedExecCommandTool(workspaceRoot string, scope PathScope, manager *ex
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: promptSafety(SideEffectShell, "Shell commands can read, write, or execute programs."),
|
||||
// PTY/shell session — never concurrent.
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: processResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
@@ -717,6 +719,8 @@ func NewWriteStdinTool(manager *execSessionManager) Tool {
|
||||
Reason: "Sending stdin can drive an existing shell process beyond the original command; empty polling and Ctrl-C interrupts are allowed automatically.",
|
||||
AdvertiseInAuto: true,
|
||||
},
|
||||
// Writes to a retained process stdin — process interaction.
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: processResourceKeys},
|
||||
},
|
||||
manager: manager,
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@ func NewScopedGlobTool(workspaceRoot string, scope PathScope) Tool {
|
||||
Required: []string{"pattern"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Finds matching paths without reading contents or modifying files."),
|
||||
safety: readOnlySafety("Finds matching paths without reading contents or modifying files."),
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: workspaceResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
|
||||
@@ -53,7 +53,8 @@ func NewScopedGrepTool(workspaceRoot string, scope PathScope) Tool {
|
||||
Required: []string{"pattern"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Searches file paths and matching lines without modifying files."),
|
||||
safety: readOnlySafety("Searches file paths and matching lines without modifying files."),
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: workspaceResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
|
||||
@@ -33,7 +33,8 @@ func NewScopedListDirectoryTool(workspaceRoot string, scope PathScope) Tool {
|
||||
},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Lists directory entries without modifying files."),
|
||||
safety: readOnlySafety("Lists directory entries without modifying files."),
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: directoryResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
|
||||
@@ -53,7 +53,8 @@ func newBrowserInstallTool(options localcontrol.BrowserOptions) Tool {
|
||||
},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Downloads browser runtime files for local browser automation."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Downloads browser runtime files for local browser automation."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options),
|
||||
}
|
||||
@@ -98,7 +99,8 @@ func newBrowserLaunchToolWithLauncher(options localcontrol.BrowserOptions, launc
|
||||
Required: []string{"app"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Launches a supported local Chromium/Electron app with DevTools enabled and attaches local browser automation."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Launches a supported local Chromium/Electron app with DevTools enabled and attaches local browser automation."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options),
|
||||
launcher: launcher,
|
||||
@@ -172,7 +174,8 @@ func newBrowserConnectTool(options localcontrol.BrowserOptions) Tool {
|
||||
Required: []string{"target"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Attaches to a local browser or Electron app exposed over the Chrome DevTools Protocol."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Attaches to a local browser or Electron app exposed over the Chrome DevTools Protocol."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options),
|
||||
}
|
||||
@@ -211,7 +214,8 @@ func newBrowserOpenTool(options localcontrol.BrowserOptions) Tool {
|
||||
Required: []string{"url"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectNetwork, "Opens a URL through the local browser automation helper. For already-running Chrome/Electron apps, use browser_connect instead. If first use reports a missing browser runtime, run browser_install once."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectNetwork, "Opens a URL through the local browser automation helper. For already-running Chrome/Electron apps, use browser_connect instead. If first use reports a missing browser runtime, run browser_install once."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options),
|
||||
}
|
||||
@@ -253,7 +257,8 @@ func newBrowserSnapshotTool(options localcontrol.BrowserOptions) Tool {
|
||||
},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Reads the current local browser automation session state."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Reads the current local browser automation session state."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options),
|
||||
}
|
||||
@@ -292,7 +297,8 @@ func newBrowserClickTool(options localcontrol.BrowserOptions) Tool {
|
||||
Required: []string{"ref"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Clicks an element in the local browser automation session."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Clicks an element in the local browser automation session."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options),
|
||||
}
|
||||
@@ -332,7 +338,8 @@ func newBrowserTypeTool(options localcontrol.BrowserOptions) Tool {
|
||||
Required: []string{"ref", "text"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Types text into an element in the local browser automation session."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Types text into an element in the local browser automation session."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options),
|
||||
}
|
||||
@@ -371,7 +378,8 @@ func newBrowserPressTool(options localcontrol.BrowserOptions) Tool {
|
||||
Required: []string{"key"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Presses a key in the local browser automation session."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Presses a key in the local browser automation session."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options),
|
||||
}
|
||||
@@ -456,7 +464,8 @@ func newBrowserActionTool(options localcontrol.BrowserOptions) Tool {
|
||||
Required: []string{"command"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Interacts with the local browser automation session."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Interacts with the local browser automation session."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options),
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ func newCaptureArtifactTool(options LocalControlArtifactOptions) Tool {
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(enabled, SideEffectLocalControl, "Captures local browser, desktop, or terminal state into the configured artifact directory."),
|
||||
// Captures write artifacts under the configured directory.
|
||||
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false},
|
||||
},
|
||||
browser: localcontrol.NewBrowser(options.Browser),
|
||||
desktop: localcontrol.NewDesktop(options.Desktop),
|
||||
|
||||
@@ -35,7 +35,8 @@ func newDesktopWindowsTool(options localcontrol.DesktopOptions) Tool {
|
||||
},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Reads native desktop window metadata."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Reads native desktop window metadata."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
|
||||
},
|
||||
desktop: localcontrol.NewDesktop(options),
|
||||
}
|
||||
@@ -81,7 +82,8 @@ func newDesktopSnapshotTool(options localcontrol.DesktopOptions) Tool {
|
||||
Required: []string{"pid", "window_id"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Reads a native desktop window state."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Reads a native desktop window state."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
|
||||
},
|
||||
desktop: localcontrol.NewDesktop(options),
|
||||
}
|
||||
@@ -125,7 +127,8 @@ func newDesktopActionTool(options localcontrol.DesktopOptions) Tool {
|
||||
Required: []string{"command", "input"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Interacts with native desktop windows."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Interacts with native desktop windows."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
|
||||
},
|
||||
desktop: localcontrol.NewDesktop(options),
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ func newTerminalSessionTool(options localcontrol.TerminalOptions) Tool {
|
||||
Required: []string{"action", "session"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalTerminal, "Launches or interacts with a local virtual terminal session."),
|
||||
safety: localControlSafety(options.Enabled, SideEffectLocalTerminal, "Launches or interacts with a local virtual terminal session."),
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: processResourceKeys},
|
||||
},
|
||||
terminal: localcontrol.NewTerminal(options),
|
||||
}
|
||||
|
||||
@@ -58,7 +58,8 @@ func NewScopedLSPNavigateTool(workspaceRoot string, scope PathScope) Tool {
|
||||
Required: []string{"op", "path"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Queries the language server for code navigation; reads files, modifies nothing."),
|
||||
safety: readOnlySafety("Queries the language server for code navigation; reads files, modifies nothing."),
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: fileResourceKeys},
|
||||
},
|
||||
workspaceRoot: root,
|
||||
scope: scope,
|
||||
|
||||
@@ -39,6 +39,8 @@ func NewScopedReadFileTool(workspaceRoot string, scope PathScope) Tool {
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Reads file contents without modifying files."),
|
||||
// ThreadSafe=false: may update FileTracker session state on read.
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: fileResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
|
||||
@@ -33,7 +33,8 @@ func NewScopedReadMinifiedFileTool(workspaceRoot string, scope PathScope) Tool {
|
||||
Required: []string{"path"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Reads a minified view of file contents without modifying files."),
|
||||
safety: readOnlySafety("Reads a minified view of file contents without modifying files."),
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: fileResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
|
||||
@@ -34,6 +34,8 @@ func NewRequestPermissionsTool() Tool {
|
||||
Permission: PermissionAllow,
|
||||
Reason: "Requests a user permission decision but does not read, write, run commands, or use the network.",
|
||||
},
|
||||
// Interactive permission escalation.
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Resource-key prefixes used for future conflict detection. Keys are pure
|
||||
// metadata in this PR — no locks or scheduling are applied.
|
||||
//
|
||||
// Unused prefixes (repository/pty/browser) are reserved for follow-up
|
||||
// classifiers so PR6 conflict keys stay vocabulary-stable.
|
||||
const (
|
||||
ResourceKeyFile = "file:"
|
||||
ResourceKeyDirectory = "directory:"
|
||||
ResourceKeyRepository = "repository:" // reserved
|
||||
ResourceKeyProcess = "process:"
|
||||
ResourceKeyPTY = "pty:" // reserved
|
||||
ResourceKeyBrowser = "browser:" // reserved
|
||||
ResourceKeyEndpoint = "endpoint:"
|
||||
ResourceKeySession = "session:"
|
||||
ResourceKeyWorkspace = "workspace:"
|
||||
)
|
||||
|
||||
// NormalizeResourcePath produces a deterministic path token for resource keys.
|
||||
// It never touches the filesystem (no EvalSymlinks / Stat) so it cannot cause
|
||||
// side effects or panic on missing paths. Empty input returns "".
|
||||
//
|
||||
// Rules:
|
||||
// - Trim space
|
||||
// - filepath.Clean
|
||||
// - Convert separators to '/'
|
||||
// - On Windows, lower-case for case-insensitive comparison
|
||||
// - Strip a leading "./"
|
||||
// - Never include credentials or query fragments (paths only)
|
||||
func NormalizeResourcePath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
// Reject URL-shaped values — those use endpoint: keys.
|
||||
if strings.Contains(path, "://") {
|
||||
return ""
|
||||
}
|
||||
cleaned := filepath.Clean(path)
|
||||
if cleaned == "." {
|
||||
return "."
|
||||
}
|
||||
normalized := filepath.ToSlash(cleaned)
|
||||
normalized = strings.TrimPrefix(normalized, "./")
|
||||
if runtime.GOOS == "windows" {
|
||||
normalized = strings.ToLower(normalized)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// fileResourceKeys extracts a file: resource key from common path argument names.
|
||||
// Missing or empty path returns nil (not an error); never panics.
|
||||
func fileResourceKeys(args map[string]any) []string {
|
||||
path := firstStringArg(args, "path", "file", "file_path", "filepath", "filename", "target")
|
||||
normalized := NormalizeResourcePath(path)
|
||||
if normalized == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{ResourceKeyFile + normalized}
|
||||
}
|
||||
|
||||
// directoryResourceKeys extracts a directory: key from path/directory/cwd args.
|
||||
func directoryResourceKeys(args map[string]any) []string {
|
||||
path := firstStringArg(args, "path", "directory", "dir", "cwd", "workdir")
|
||||
normalized := NormalizeResourcePath(path)
|
||||
if normalized == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{ResourceKeyDirectory + normalized}
|
||||
}
|
||||
|
||||
// endpointResourceKeys extracts an endpoint: key from url/endpoint args.
|
||||
// Host only is kept when a full URL is provided (no secrets/query/userinfo).
|
||||
func endpointResourceKeys(args map[string]any) []string {
|
||||
raw := firstStringArg(args, "url", "endpoint", "uri")
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
host := resourceHost(raw)
|
||||
if host == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{ResourceKeyEndpoint + host}
|
||||
}
|
||||
|
||||
// sessionResourceKeys extracts a session: key from session / session_id args.
|
||||
// Numeric session ids (JSON numbers) are coerced to strings.
|
||||
func sessionResourceKeys(args map[string]any) []string {
|
||||
return scopedIDResourceKeys(ResourceKeySession, args, "session", "session_id", "sessionId")
|
||||
}
|
||||
|
||||
// processResourceKeys extracts process: keys for retained process / terminal
|
||||
// tools. Accepts session, session_id, pid, and numeric JSON values so
|
||||
// write_stdin (integer session_id) and terminal_session (string session)
|
||||
// both produce keys.
|
||||
func processResourceKeys(args map[string]any) []string {
|
||||
return scopedIDResourceKeys(ResourceKeyProcess, args,
|
||||
"session", "session_id", "sessionId", "process_id", "pid", "id")
|
||||
}
|
||||
|
||||
// scopedIDResourceKeys is the shared extract/trim/bound/prefix path for
|
||||
// session- and process-scoped resource keys.
|
||||
func scopedIDResourceKeys(prefix string, args map[string]any, keys ...string) []string {
|
||||
id := strings.TrimSpace(firstStringArg(args, keys...))
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
// Never put free-form content in keys — IDs only, bounded length.
|
||||
if len(id) > 128 {
|
||||
id = id[:128]
|
||||
}
|
||||
return []string{prefix + id}
|
||||
}
|
||||
|
||||
// workspaceResourceKeys returns a single workspace: root marker when the call
|
||||
// affects the whole workspace (e.g. glob without a path root).
|
||||
func workspaceResourceKeys(_ map[string]any) []string {
|
||||
return []string{ResourceKeyWorkspace + "root"}
|
||||
}
|
||||
|
||||
// multiFileResourceKeys collects path and paths[] arguments into file: keys.
|
||||
func multiFileResourceKeys(args map[string]any) []string {
|
||||
var keys []string
|
||||
keys = append(keys, fileResourceKeys(args)...)
|
||||
if raw, ok := args["paths"]; ok {
|
||||
switch typed := raw.(type) {
|
||||
case []string:
|
||||
for _, p := range typed {
|
||||
if n := NormalizeResourcePath(p); n != "" {
|
||||
keys = append(keys, ResourceKeyFile+n)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if s, ok := item.(string); ok {
|
||||
if n := NormalizeResourcePath(s); n != "" {
|
||||
keys = append(keys, ResourceKeyFile+n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return uniqueKeys(keys)
|
||||
}
|
||||
|
||||
// applyPatchResourceKeys derives conflict keys for apply_patch: the cwd as a
|
||||
// directory key, optional path/paths args, plus any +++/--- file paths
|
||||
// parseable from the unified diff. Relative diff paths are joined under cwd
|
||||
// so the same file under different cwd values does not collide (e.g. cwd=pkg
|
||||
// + +++ b/new.go → file:pkg/new.go). Falls back to workspace:root when nothing
|
||||
// is available so a patch call is never "keyless" for the future conflict planner.
|
||||
func applyPatchResourceKeys(args map[string]any) []string {
|
||||
var keys []string
|
||||
keys = append(keys, directoryResourceKeys(args)...)
|
||||
keys = append(keys, multiFileResourceKeys(args)...)
|
||||
cwd := firstStringArg(args, "cwd", "workdir")
|
||||
patch := firstStringArg(args, "patch", "diff")
|
||||
for _, p := range pathsFromUnifiedDiff(patch) {
|
||||
joined := joinUnderResourceCwd(cwd, p)
|
||||
if n := NormalizeResourcePath(joined); n != "" {
|
||||
keys = append(keys, ResourceKeyFile+n)
|
||||
}
|
||||
}
|
||||
keys = uniqueKeys(keys)
|
||||
if len(keys) == 0 {
|
||||
return workspaceResourceKeys(args)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// joinUnderResourceCwd joins a relative path under cwd for resource keys only.
|
||||
// Absolute paths are left unchanged. Empty cwd or "." leaves rel as-is.
|
||||
// Never touches the filesystem.
|
||||
func joinUnderResourceCwd(cwd, rel string) string {
|
||||
rel = strings.TrimSpace(rel)
|
||||
if rel == "" {
|
||||
return ""
|
||||
}
|
||||
// Absolute (Unix or Windows) paths are already workspace-rooted or outside;
|
||||
// do not re-prefix.
|
||||
if filepath.IsAbs(rel) {
|
||||
return rel
|
||||
}
|
||||
cwd = strings.TrimSpace(cwd)
|
||||
if cwd == "" || cwd == "." {
|
||||
return rel
|
||||
}
|
||||
return filepath.ToSlash(filepath.Join(cwd, rel))
|
||||
}
|
||||
|
||||
// pathsFromUnifiedDiff extracts a/ and b/ file paths from unified-diff headers.
|
||||
// Malformed input returns nil; never panics.
|
||||
func pathsFromUnifiedDiff(patch string) []string {
|
||||
if strings.TrimSpace(patch) == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, line := range strings.Split(patch, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "+++ ") || strings.HasPrefix(line, "--- "):
|
||||
rest := strings.TrimSpace(line[4:])
|
||||
// Drop trailing tab timestamp if present.
|
||||
if i := strings.IndexByte(rest, '\t'); i >= 0 {
|
||||
rest = rest[:i]
|
||||
}
|
||||
if rest == "/dev/null" || rest == "" {
|
||||
continue
|
||||
}
|
||||
// Strip a/ or b/ prefix common in git diffs.
|
||||
if len(rest) > 2 && (rest[0] == 'a' || rest[0] == 'b') && rest[1] == '/' {
|
||||
rest = rest[2:]
|
||||
}
|
||||
out = append(out, rest)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// firstStringArg returns the first present argument as a string. Numeric JSON
|
||||
// values (float64/int from encoding/json) are coerced so tools that declare
|
||||
// integer session ids still yield resource keys.
|
||||
func firstStringArg(args map[string]any, keys ...string) string {
|
||||
if args == nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range keys {
|
||||
value, ok := args[key]
|
||||
if !ok || value == nil {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case float64:
|
||||
// JSON numbers decode as float64. Prefer integer form when exact.
|
||||
if typed == float64(int64(typed)) {
|
||||
return fmt.Sprintf("%d", int64(typed))
|
||||
}
|
||||
return fmt.Sprintf("%v", typed)
|
||||
case float32:
|
||||
return fmt.Sprintf("%v", typed)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", typed)
|
||||
case int64:
|
||||
return fmt.Sprintf("%d", typed)
|
||||
case int32:
|
||||
return fmt.Sprintf("%d", typed)
|
||||
case jsonNumber:
|
||||
return typed.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// jsonNumber matches encoding/json.Number without importing encoding/json here
|
||||
// for a one-line type assert (tests may inject it).
|
||||
type jsonNumber interface {
|
||||
String() string
|
||||
}
|
||||
|
||||
// resourceHost returns a lower-cased host for endpoint keys, stripping
|
||||
// userinfo, path, query, and fragment. Returns "" when not parseable safely.
|
||||
//
|
||||
// Order matters: path/query/fragment are removed BEFORE userinfo so a query
|
||||
// value containing "@" cannot steal the authority (e.g.
|
||||
// https://api.example.com/v1?token=@secret → endpoint:api.example.com).
|
||||
// host:port is preserved so two services on different ports do not collide.
|
||||
func resourceHost(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
// Strip scheme.
|
||||
if idx := strings.Index(raw, "://"); idx >= 0 {
|
||||
raw = raw[idx+3:]
|
||||
}
|
||||
// Isolate authority: strip path/query/fragment first.
|
||||
if slash := strings.IndexAny(raw, "/?#"); slash >= 0 {
|
||||
raw = raw[:slash]
|
||||
}
|
||||
// Strip userinfo from the authority only.
|
||||
if at := strings.LastIndex(raw, "@"); at >= 0 {
|
||||
raw = raw[at+1:]
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || strings.Contains(raw, "@") {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(raw)
|
||||
}
|
||||
|
||||
func uniqueKeys(keys []string) []string {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(keys))
|
||||
out := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[k]; ok {
|
||||
continue
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -48,7 +48,8 @@ func NewSkillTool(dir string) *skillTool {
|
||||
// unknown keys (AdditionalProperties:false).
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Reads a local skill file; gathers reusable instructions only."),
|
||||
safety: readOnlySafety("Reads a local skill file; gathers reusable instructions only."),
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ func NewToolSearchTool(registry *Registry) Tool {
|
||||
Reason: "Lists and loads already-registered tool schemas; performs no side effects.",
|
||||
AdvertiseInAuto: true,
|
||||
},
|
||||
// Reads registry schemas only; no workspace mutation.
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false},
|
||||
},
|
||||
registry: registry,
|
||||
}
|
||||
|
||||
@@ -143,10 +143,11 @@ type PrePermissionRejecter interface {
|
||||
}
|
||||
|
||||
type baseTool struct {
|
||||
name string
|
||||
description string
|
||||
parameters Schema
|
||||
safety Safety
|
||||
name string
|
||||
description string
|
||||
parameters Schema
|
||||
safety Safety
|
||||
capabilities ToolCapabilities // zero value = EffectUnknown, not thread-safe
|
||||
}
|
||||
|
||||
func (tool baseTool) Name() string {
|
||||
|
||||
@@ -63,6 +63,8 @@ func NewUpdatePlanTool() *updatePlanTool {
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Updates in-memory planning state only."),
|
||||
// Session plan state — ordering matters across turns.
|
||||
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,8 @@ func newWebFetchToolWithClientAndResolver(client *http.Client, resolver webFetch
|
||||
Reason: "Fetches remote URL content over the network.",
|
||||
AdvertiseInAuto: true,
|
||||
},
|
||||
// Network read; ThreadSafe=false until client proven concurrent-safe.
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: endpointResourceKeys},
|
||||
},
|
||||
client: client,
|
||||
resolver: resolver,
|
||||
|
||||
@@ -90,6 +90,8 @@ func newWebSearchToolWithBackend(backend searchBackend) Tool {
|
||||
Reason: "Sends model-provided search query text to the configured web search backend.",
|
||||
AdvertiseInAuto: true,
|
||||
},
|
||||
// Search backends may share non-thread-safe clients.
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false},
|
||||
},
|
||||
backend: backend,
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@ func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool {
|
||||
Required: []string{"path", "content"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: promptSafety(SideEffectWrite, "Creates or overwrites files."),
|
||||
safety: promptSafety(SideEffectWrite, "Creates or overwrites files."),
|
||||
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false, ResourceKeys: fileResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
|
||||
Reference in new issue
Block a user