P3: meta + primitive tool group (think/now/cite + classify/extract/summarize)
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>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user