Files
isaacgao4396andisaacgao4396 169e0bafaf fix(tools): use_skill inlines content when read_file isn't granted (#1547)
* feat(store): add context helper for per-iteration tool allowlist

* feat(pipeline): surface resolved tool allowlist to tool dispatch via context

* fix(tools): use_skill inlines content when read_file isn't granted

Fixes #1477

---------

Co-authored-by: isaacgao4396 <gaoyuan4396@gmail.com>
2026-09-03 18:13:56 +07:00

521 lines
19 KiB
Go

package store
import (
"context"
"encoding/json"
"strings"
"github.com/google/uuid"
)
type contextKey string
const (
// UserIDKey is the context key for the external user ID (TEXT, free-form).
UserIDKey contextKey = "goclaw_user_id"
// AgentIDKey is the context key for the agent UUID.
AgentIDKey contextKey = "goclaw_agent_id"
// AgentTypeKey is the context key for the agent type ("open" or "predefined").
AgentTypeKey contextKey = "goclaw_agent_type"
// SenderIDKey is the original individual sender's ID (not group-scoped).
// In group chats, UserIDKey is group-scoped but SenderIDKey preserves
// the actual person who sent the message.
SenderIDKey contextKey = "goclaw_sender_id"
// SelfEvolveKey indicates whether a predefined agent can update its SOUL.md.
SelfEvolveKey contextKey = "goclaw_self_evolve"
// LocaleKey is the context key for the user's preferred locale (e.g. "en", "vi", "zh").
LocaleKey contextKey = "goclaw_locale"
// SharedMemoryKey indicates memory should be shared (no per-user scoping).
SharedMemoryKey contextKey = "goclaw_shared_memory"
// SharedKGKey indicates KG should be shared across all users of the agent (no per-user scoping).
SharedKGKey contextKey = "goclaw_shared_kg"
// SharedSessionsKey indicates sessions should be shared across all users (no per-group scoping).
SharedSessionsKey contextKey = "goclaw_shared_sessions"
// SharedContextKey indicates context files should be read/written at agent scope.
SharedContextKey contextKey = "goclaw_shared_context"
// ShellDenyGroupsKey holds per-agent shell deny group overrides.
ShellDenyGroupsKey contextKey = "goclaw_shell_deny_groups"
// AgentKeyKey is the context key for the agent key/name (string identifier, e.g. "default").
AgentKeyKey contextKey = "goclaw_agent_key"
// AgentContextWindowKey carries the calling agent's configured context window.
AgentContextWindowKey contextKey = "goclaw_agent_context_window"
// AgentMaxTokensKey carries the calling agent's configured output reserve.
AgentMaxTokensKey contextKey = "goclaw_agent_max_tokens"
// TenantIDKey is the context key for the tenant UUID.
TenantIDKey contextKey = "goclaw_tenant_id"
// CrossTenantKey indicates the caller has cross-tenant access (owner/system admin).
CrossTenantKey contextKey = "goclaw_cross_tenant"
// TenantSlugKey stores the tenant's URL-safe slug for filesystem paths.
TenantSlugKey contextKey = "goclaw_tenant_slug"
// RoleKey is the context key for the caller's permission role (e.g. "admin", "operator", "viewer").
RoleKey contextKey = "goclaw_role"
// AvailableToolNamesKey carries this iteration's policy-resolved tool allowlist
// (canonical registry names) so a tool can introspect its own sibling tools.
AvailableToolNamesKey contextKey = "goclaw_available_tool_names"
// CredentialUserIDKey holds the resolved tenant user identity for credential lookups.
// Falls back to UserIDFromContext if not set.
CredentialUserIDKey contextKey = "goclaw_credential_user_id"
// SenderNameKey is the display name from channel metadata (for bootstrap auto-contact).
SenderNameKey contextKey = "goclaw_sender_name"
// ChannelContextScopeKey carries the channel/group/user scope for runtime grants and credentials.
ChannelContextScopeKey contextKey = "goclaw_channel_context_scope"
// AgentAudioKey carries the immutable agent audio snapshot for TTS tool dispatch.
AgentAudioKey contextKey = "goclaw_agent_audio"
)
// AgentAudioSnapshot is an immutable snapshot of agent audio config carried through
// the tool-dispatch context. OtherConfig is a defensive byte copy taken at insertion
// time — callers MUST NOT mutate it after calling WithAgentAudio.
type AgentAudioSnapshot struct {
AgentID uuid.UUID
OtherConfig json.RawMessage // immutable byte copy — never mutate after insertion
}
// WithAgentAudio returns a new context with the given agent audio snapshot.
// The snapshot is stored as-is — the CALLER is responsible for making a defensive
// copy of OtherConfig before calling this function (use append([]byte(nil), src...)).
func WithAgentAudio(ctx context.Context, snap AgentAudioSnapshot) context.Context {
return context.WithValue(ctx, AgentAudioKey, snap)
}
// AgentAudioFromCtx extracts the agent audio snapshot from context.
// Returns ok=true only when the key is present AND AgentID != uuid.Nil.
func AgentAudioFromCtx(ctx context.Context) (AgentAudioSnapshot, bool) {
snap, ok := ctx.Value(AgentAudioKey).(AgentAudioSnapshot)
if !ok || snap.AgentID == uuid.Nil {
return AgentAudioSnapshot{}, false
}
return snap, true
}
// WithShellDenyGroups returns a new context with shell deny group overrides.
func WithShellDenyGroups(ctx context.Context, groups map[string]bool) context.Context {
return context.WithValue(ctx, ShellDenyGroupsKey, groups)
}
// ShellDenyGroupsFromContext returns shell deny group overrides from the context, or nil.
func ShellDenyGroupsFromContext(ctx context.Context) map[string]bool {
if v, _ := ctx.Value(ShellDenyGroupsKey).(map[string]bool); v != nil {
return v
}
if rc := RunContextFromCtx(ctx); rc != nil {
return rc.ShellDenyGroups
}
return nil
}
// WithUserID returns a new context with the given user ID.
func WithUserID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, UserIDKey, id)
}
// UserIDFromContext extracts the user ID from context. Returns "" if not set.
func UserIDFromContext(ctx context.Context) string {
if v, ok := ctx.Value(UserIDKey).(string); ok && v != "" {
return v
}
if rc := RunContextFromCtx(ctx); rc != nil {
return rc.UserID
}
return ""
}
// WithCredentialUserID returns a new context with the resolved tenant user identity for credential lookups.
func WithCredentialUserID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, CredentialUserIDKey, id)
}
// ExplicitCredentialUserIDFromContext returns only the explicitly injected credential identity.
// Unlike CredentialUserIDFromContext, it does not fall back to UserID.
func ExplicitCredentialUserIDFromContext(ctx context.Context) string {
if v, ok := ctx.Value(CredentialUserIDKey).(string); ok {
return v
}
return ""
}
// CredentialUserIDFromContext returns the resolved identity for credential lookups.
// Falls back to RunContext.CredentialUserID, then UserIDFromContext.
func CredentialUserIDFromContext(ctx context.Context) string {
if v := ExplicitCredentialUserIDFromContext(ctx); v != "" {
return v
}
if rc := RunContextFromCtx(ctx); rc != nil && rc.CredentialUserID != "" {
return rc.CredentialUserID
}
return UserIDFromContext(ctx)
}
// WithAgentContextWindow returns a context carrying the configured agent window.
func WithAgentContextWindow(ctx context.Context, window int) context.Context {
if window <= 0 {
return ctx
}
return context.WithValue(ctx, AgentContextWindowKey, window)
}
// AgentContextWindowFromContext returns the configured agent window, or zero.
func AgentContextWindowFromContext(ctx context.Context) int {
if v, ok := ctx.Value(AgentContextWindowKey).(int); ok && v > 0 {
return v
}
return 0
}
// WithAgentMaxTokens returns a context carrying the configured agent max_tokens.
func WithAgentMaxTokens(ctx context.Context, maxTokens int) context.Context {
if maxTokens <= 0 {
return ctx
}
return context.WithValue(ctx, AgentMaxTokensKey, maxTokens)
}
// AgentMaxTokensFromContext returns the configured agent max_tokens, or zero.
func AgentMaxTokensFromContext(ctx context.Context) int {
if v, ok := ctx.Value(AgentMaxTokensKey).(int); ok && v > 0 {
return v
}
return 0
}
// WithAgentID returns a new context with the given agent UUID.
func WithAgentID(ctx context.Context, id uuid.UUID) context.Context {
return context.WithValue(ctx, AgentIDKey, id)
}
// AgentIDFromContext extracts the agent UUID from context. Returns uuid.Nil if not set.
func AgentIDFromContext(ctx context.Context) uuid.UUID {
if v, ok := ctx.Value(AgentIDKey).(uuid.UUID); ok && v != uuid.Nil {
return v
}
if rc := RunContextFromCtx(ctx); rc != nil {
return rc.AgentID
}
return uuid.Nil
}
// WithAgentType returns a new context with the given agent type.
func WithAgentType(ctx context.Context, t string) context.Context {
return context.WithValue(ctx, AgentTypeKey, t)
}
// AgentTypeFromContext extracts the agent type from context. Returns "" if not set.
func AgentTypeFromContext(ctx context.Context) string {
if v, ok := ctx.Value(AgentTypeKey).(string); ok && v != "" {
return v
}
if rc := RunContextFromCtx(ctx); rc != nil {
return rc.AgentType
}
return ""
}
// WithAgentKey returns a new context with the agent key/name (string identifier).
func WithAgentKey(ctx context.Context, key string) context.Context {
return context.WithValue(ctx, AgentKeyKey, key)
}
// AgentKeyFromContext extracts the agent key from context. Returns "" if not set.
func AgentKeyFromContext(ctx context.Context) string {
if v, ok := ctx.Value(AgentKeyKey).(string); ok && v != "" {
return v
}
if rc := RunContextFromCtx(ctx); rc != nil {
return rc.AgentKey
}
return ""
}
// WithSenderID returns a new context with the original individual sender ID.
func WithSenderID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, SenderIDKey, id)
}
// WithSenderName returns a new context with the sender display name from channel metadata.
func WithSenderName(ctx context.Context, name string) context.Context {
return context.WithValue(ctx, SenderNameKey, name)
}
// SenderNameFromContext extracts the sender display name. Returns "" if not set.
func SenderNameFromContext(ctx context.Context) string {
v, _ := ctx.Value(SenderNameKey).(string)
return v
}
// SenderIDFromContext extracts the sender ID from context. Returns "" if not set.
func SenderIDFromContext(ctx context.Context) string {
if v, ok := ctx.Value(SenderIDKey).(string); ok && v != "" {
return v
}
if rc := RunContextFromCtx(ctx); rc != nil {
return rc.SenderID
}
return ""
}
// ActorIDFromContext returns the acting principal — the entity performing
// the action. The resolution is context-aware:
//
// - Group / guild scope (UserID has "group:" or "guild:" prefix):
// returns SenderID (the individual sender) when set, else falls back
// to UserID. SenderID is the only stable actor identity in a group
// because UserID is the shared group principal.
//
// - DM / HTTP / cron / anywhere UserID is a real user identifier:
// returns UserID. This preserves tenant-user merging — the gateway
// consumer rewrites UserID to the merged tenant identity (e.g.
// "viettx") for DMs via ContactCollector.ResolveTenantUserID, so
// ownership/audit records use the cross-channel stable identity
// rather than a channel-specific raw sender (e.g. "386246614").
// SenderID is used only as a fallback when UserID is empty.
//
// Use for:
// - permission checks (file writers, role gates)
// - audit trails (initiated_by, owner_id)
// - ownership fields (skill publisher, cron owner)
//
// DO NOT use for:
// - memory / KG / session scope (use UserIDFromContext / MemoryUserID / KGUserID)
// - file-system or per-scope isolation (scope = group principal on purpose)
func ActorIDFromContext(ctx context.Context) string {
uid := UserIDFromContext(ctx)
// Group/guild: UserID is the scope namespace, not an actor. Prefer SenderID.
if strings.HasPrefix(uid, "group:") || strings.HasPrefix(uid, "guild:") {
if sid := SenderIDFromContext(ctx); sid != "" {
return sid
}
return uid
}
// DM / HTTP / cron: UserID is (possibly merged) actor identity.
if uid != "" {
return uid
}
return SenderIDFromContext(ctx)
}
// WithSelfEvolve returns a new context with the self-evolve flag.
func WithSelfEvolve(ctx context.Context, v bool) context.Context {
return context.WithValue(ctx, SelfEvolveKey, v)
}
// SelfEvolveFromContext extracts the self-evolve flag from context. Returns false if not set.
func SelfEvolveFromContext(ctx context.Context) bool {
if v, ok := ctx.Value(SelfEvolveKey).(bool); ok {
return v
}
if rc := RunContextFromCtx(ctx); rc != nil {
return rc.SelfEvolve
}
return false
}
// WithSharedMemory returns a context flagged for shared memory (skip per-user scoping).
func WithSharedMemory(ctx context.Context) context.Context {
return context.WithValue(ctx, SharedMemoryKey, true)
}
// IsSharedMemory returns true if memory should be shared across users.
func IsSharedMemory(ctx context.Context) bool {
if v, ok := ctx.Value(SharedMemoryKey).(bool); ok {
return v
}
if rc := RunContextFromCtx(ctx); rc != nil {
return rc.SharedMemory
}
return false
}
// MemoryUserID returns the userID to use for memory operations.
// Returns "" (shared/global) when shared memory is active, otherwise the per-user ID.
func MemoryUserID(ctx context.Context) string {
if IsSharedMemory(ctx) {
return ""
}
return UserIDFromContext(ctx)
}
// WithSharedContext returns a context flagged for shared context files.
func WithSharedContext(ctx context.Context) context.Context {
return context.WithValue(ctx, SharedContextKey, true)
}
// IsSharedContext returns true if context files should use agent-level scope.
func IsSharedContext(ctx context.Context) bool {
if v, ok := ctx.Value(SharedContextKey).(bool); ok {
return v
}
if rc := RunContextFromCtx(ctx); rc != nil {
return rc.SharedContext
}
return false
}
// ContextUserID returns the userID to use for context-file operations.
// Shared workspace mode maps virtual context files to the agent-level store so
// read_file/write_file behavior matches the visible shared workspace path.
func ContextUserID(ctx context.Context) string {
if IsSharedContext(ctx) {
return ""
}
return UserIDFromContext(ctx)
}
// KGUserID returns the userID to use for knowledge graph operations.
// Returns "" (agent-level scope) when shared KG is active, otherwise the per-user ID.
func KGUserID(ctx context.Context) string {
if IsSharedKG(ctx) {
return ""
}
return UserIDFromContext(ctx)
}
// WithSharedKG returns a context flagged for shared knowledge graph (agent-level, no per-user scoping).
func WithSharedKG(ctx context.Context) context.Context {
return context.WithValue(ctx, SharedKGKey, true)
}
// IsSharedKG returns true if the knowledge graph should be shared across users.
func IsSharedKG(ctx context.Context) bool {
if v, ok := ctx.Value(SharedKGKey).(bool); ok {
return v
}
if rc := RunContextFromCtx(ctx); rc != nil {
return rc.SharedKG
}
return false
}
// WithSharedSessions returns a context flagged for shared sessions (skip per-group scoping).
func WithSharedSessions(ctx context.Context) context.Context {
return context.WithValue(ctx, SharedSessionsKey, true)
}
// IsSharedSessions returns true if sessions should be shared across users/groups.
func IsSharedSessions(ctx context.Context) bool {
if v, ok := ctx.Value(SharedSessionsKey).(bool); ok {
return v
}
if rc := RunContextFromCtx(ctx); rc != nil {
return rc.SharedSessions
}
return false
}
// WithLocale returns a new context with the given locale.
func WithLocale(ctx context.Context, locale string) context.Context {
return context.WithValue(ctx, LocaleKey, locale)
}
// LocaleFromContext extracts the locale from context. Returns "en" if not set.
func LocaleFromContext(ctx context.Context) string {
if v, ok := ctx.Value(LocaleKey).(string); ok && v != "" {
return v
}
return "en"
}
// WithTenantID returns a new context with the given tenant UUID.
func WithTenantID(ctx context.Context, id uuid.UUID) context.Context {
return context.WithValue(ctx, TenantIDKey, id)
}
// TenantIDFromContext extracts the tenant UUID from context.
// Returns uuid.Nil if not set (fail-closed — callers must check).
func TenantIDFromContext(ctx context.Context) uuid.UUID {
if v, ok := ctx.Value(TenantIDKey).(uuid.UUID); ok && v != uuid.Nil {
return v
}
if rc := RunContextFromCtx(ctx); rc != nil {
return rc.TenantID
}
return uuid.Nil
}
// WithCrossTenant returns a context flagged for cross-tenant access.
// Deprecated: Only used by skills store (is_system dual-visibility pattern).
// All other callers must use explicit tenant context or unscoped store methods.
func WithCrossTenant(ctx context.Context) context.Context {
return context.WithValue(ctx, CrossTenantKey, true)
}
// IsCrossTenant returns true if the caller has cross-tenant access.
// Deprecated: Only used by skills store and inline pg/*.go tenant checks.
// Permission guards should use IsOwnerRole(). SQL queries use tenantClauseN() (no bypass).
func IsCrossTenant(ctx context.Context) bool {
v, _ := ctx.Value(CrossTenantKey).(bool)
return v
}
// IsOwnerRole returns true if the caller has the "owner" role.
// Replaces IsCrossTenant for permission guards.
func IsOwnerRole(ctx context.Context) bool {
return RoleFromContext(ctx) == string(RoleOwner)
}
// IsMasterScope reports whether ctx should be treated as master-scope:
//
// (a) system owner role (IsOwnerRole bypass-all), or
// (b) tenant id is unset (uuid.Nil — legacy / system callers), or
// (c) tenant id equals MasterTenantID.
//
// Used by both WS config.* methods and HTTP admin routes that write to
// global (non-tenant-scoped) tables or execute server-wide side effects
// (shell, filesystem). Centralises the Phase 1 / Phase 0b hotfix rule
// so every layer shares one predicate — no drift.
func IsMasterScope(ctx context.Context) bool {
if IsOwnerRole(ctx) {
return true
}
tid := TenantIDFromContext(ctx)
return tid == uuid.Nil || tid == MasterTenantID
}
// RoleOwner is the owner role constant for context checks.
// Must match permissions.RoleOwner.
const RoleOwner = "owner"
// WithTenantSlug returns a new context with the given tenant slug.
func WithTenantSlug(ctx context.Context, slug string) context.Context {
return context.WithValue(ctx, TenantSlugKey, slug)
}
// TenantSlugFromContext extracts the tenant slug from context. Returns "" if not set.
func TenantSlugFromContext(ctx context.Context) string {
if v, ok := ctx.Value(TenantSlugKey).(string); ok {
return v
}
return ""
}
// WithRole returns a new context with the caller's permission role.
func WithRole(ctx context.Context, role string) context.Context {
return context.WithValue(ctx, RoleKey, role)
}
// RoleFromContext extracts the permission role from context. Returns "" if not set.
func RoleFromContext(ctx context.Context) string {
if v, ok := ctx.Value(RoleKey).(string); ok {
return v
}
return ""
}
// WithAvailableToolNames returns a new context carrying this iteration's
// policy-resolved tool allowlist, so a tool can check whether a sibling tool
// is available to the calling agent.
func WithAvailableToolNames(ctx context.Context, names map[string]bool) context.Context {
return context.WithValue(ctx, AvailableToolNamesKey, names)
}
// AvailableToolNamesFromContext extracts the tool allowlist from context.
// Returns nil when not set — callers MUST treat nil as "no restriction known"
// (every tool available), matching the same nil convention already used by
// RunState.Tool.AllowedTools (see ThinkStage.Execute): nil means either the
// agent has no tool policy configured, or the caller never populated this key
// at all (e.g. a code path outside the pipeline's per-iteration tool dispatch).
// Never treat nil as "no tools available".
func AvailableToolNamesFromContext(ctx context.Context) map[string]bool {
v, _ := ctx.Value(AvailableToolNamesKey).(map[string]bool)
return v
}