// 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:" — allowed if it matches inv.CallerID (or admin). // - "user:" — allowed only for admin callers. // - "run:" — allowed if it matches inv.RunID (or admin). // - "run:" — allowed only for admin callers. // - "root_run:" — 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: 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:', 'run:', or 'root_run:'", scope) }