1e201550b3
Grow executus/tools into a real generic tool library: - Register(reg): the always-available, zero-config tools — think, now (UTC unless a CurrentTimeProvider is wired), cite (inert unless a CitationStorage is wired). All nil-safe; a light host calls Register and is useful. - RegisterMeta(reg, MetaDeps): the LLM-backed meta tools — classify, extract_entities, summarize — over the llmmeta helper. Budget defaults to the shipped in-memory per-run cap; Files optional; caps default. - Seams moved (interface/type-only, no host coupling): research_providers.go (CurrentTimeProvider/CitationStorage/SearchBudget/PageExtractor/PDFFetcher/…) and file_storage.go (FileStorage + FileDomainMeta). Plus the in-memory budget default (research_defaults.go) and scope_validate.go. calculate deferred (drags github.com/Krognol/go-wolfram + a module-path replace — not worth it in the lean core for one tool). Core go.sum still free of gorm/redis/discordgo/sqlite/wolfram. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
114 lines
4.5 KiB
Go
114 lines
4.5 KiB
Go
// scope_validate.go centralises the storage-scope authorisation check
|
|
// shared by every v4 KV and file tool. It enforces:
|
|
//
|
|
// - "skill" — always allowed (the skill's shared, cross-caller area).
|
|
// - "user:<callerID>" — allowed if it matches inv.CallerID (or admin).
|
|
// - "user:<other>" — allowed only for admin callers.
|
|
// - "run:<runID>" — allowed if it matches inv.RunID (or admin).
|
|
// - "run:<other>" — allowed only for admin callers.
|
|
// - "root_run:<id>" — allowed if it matches inv.RootRunID (or admin):
|
|
// the dispatch tree's SHARED scratchpad, readable
|
|
// and writable by every run under one root
|
|
// (parallel sibling workers coordinate here).
|
|
// - any other shape — rejected with a descriptive error.
|
|
//
|
|
// Why a single helper (vs inline checks in each tool): the parsing rules
|
|
// must match exactly across kv_get/set/list/delete and file_save/get/
|
|
// list/delete. Centralising them means one place to fix when the
|
|
// vocabulary evolves and one place for the test matrix.
|
|
//
|
|
// Why the isAdmin parameter: the v4 Invocation does NOT carry an
|
|
// admin flag — production tools always pass isAdmin=false. The
|
|
// parameter exists for tests (which exercise the admin paths) and for a
|
|
// future Invocation extension that adds an admin signal without
|
|
// breaking this helper's signature.
|
|
package tools
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
|
)
|
|
|
|
// ValidateScope rejects scope strings the caller is not authorised to
|
|
// access. See file-level doc for the exact ruleset.
|
|
//
|
|
// Why isAdmin is parameterised: tests pass true to verify admin paths;
|
|
// production tools currently always pass false because Invocation
|
|
// doesn't carry admin status. The gate is "you can access your own
|
|
// scope only" until a future extension threads an admin signal through
|
|
// the executor.
|
|
func ValidateScope(inv tool.Invocation, scope string, isAdmin bool) error {
|
|
if scope == "skill" {
|
|
return nil
|
|
}
|
|
if rest, ok := strings.CutPrefix(scope, "user:"); ok {
|
|
if rest == "" {
|
|
return fmt.Errorf("scope: empty user id after 'user:'")
|
|
}
|
|
if rest == inv.CallerID {
|
|
return nil
|
|
}
|
|
if isAdmin {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("scope %q: cannot access another user's storage", scope)
|
|
}
|
|
if rest, ok := strings.CutPrefix(scope, "root_run:"); ok {
|
|
if rest == "" {
|
|
return fmt.Errorf("scope: empty run id after 'root_run:'")
|
|
}
|
|
// The dispatch tree's shared scratchpad. Every run in one tree
|
|
// carries the same RootRunID (stamped by both executors from the
|
|
// dispatchguard chain), so siblings spawned in parallel — even
|
|
// ephemeral workers with distinct agent IDs — validate against
|
|
// the same scope string. Storage-side, root_run scopes live in
|
|
// the shared RootRunKVPartition; this check is the isolation
|
|
// boundary between trees.
|
|
if rest == inv.RootRunID && inv.RootRunID != "" {
|
|
return nil
|
|
}
|
|
if isAdmin {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("scope %q: cannot access another dispatch tree's storage", scope)
|
|
}
|
|
if rest, ok := strings.CutPrefix(scope, "run:"); ok {
|
|
if rest == "" {
|
|
return fmt.Errorf("scope: empty run id after 'run:'")
|
|
}
|
|
if rest == inv.RunID {
|
|
return nil
|
|
}
|
|
// V10: when this run is a reply continuation, the agent may
|
|
// access the PARENT run's scope. The parent's run-scope KV is
|
|
// the natural carrier for "ask user a question, save state,
|
|
// resume on reply" — without this access, every continuation
|
|
// would have to re-derive state from parent_output alone.
|
|
// Note: the parent's run-scope is subject to the v4
|
|
// auto-purge (24h after parent finished). Long-delayed replies
|
|
// will see an empty scope.
|
|
if inv.Continuation != nil && rest == inv.Continuation.ParentRunID {
|
|
return nil
|
|
}
|
|
// V14: when this run is invoked via skill_invoke /
|
|
// skill_invoke_parallel from a parent skill, the agent may
|
|
// access the PARENT run's scope. This is the natural carrier
|
|
// for the "scout fans out, parent reads consolidated state"
|
|
// pattern that deepresearch uses — research-scout writes its
|
|
// touched-URL list under run:<parent_run_id> and the parent
|
|
// reads it back during the investigate phase. Without this
|
|
// access, every parent/child handoff would have to be
|
|
// serialised through tool-result strings.
|
|
if inv.ParentRunID != "" && rest == inv.ParentRunID {
|
|
return nil
|
|
}
|
|
if isAdmin {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("scope %q: cannot access another run's storage", scope)
|
|
}
|
|
return fmt.Errorf("scope %q: unknown shape; expected 'skill', 'user:<id>', 'run:<id>', or 'root_run:<id>'", scope)
|
|
}
|