diff --git a/docs/04-gateway-protocol.md b/docs/04-gateway-protocol.md index 6df4ecc4..61f764bb 100644 --- a/docs/04-gateway-protocol.md +++ b/docs/04-gateway-protocol.md @@ -112,7 +112,7 @@ flowchart LR |------|--------------------| | viewer | `agents.list`, `config.get`, `sessions.list`, `sessions.preview`, `health`, `status`, `providers.models`, `skills.list`, `skills.get`, `channels.list`, `channels.status`, `cron.list`, `cron.status`, `cron.runs`, `usage.get`, `usage.summary` | | operator | All viewer methods plus: `chat.send`, `chat.abort`, `chat.history`, `chat.inject`, `sessions.delete`, `sessions.reset`, `sessions.patch`, `cron.create`, `cron.update`, `cron.delete`, `cron.toggle`, `cron.run`, `skills.update`, `send`, `exec.approval.list`, `exec.approval.approve`, `exec.approval.deny`, `device.pair.request`, `device.pair.list` | -| admin | All operator methods plus: `config.apply`, `config.patch`, `agents.create`, `agents.update`, `agents.delete`, `agents.files.*`, `teams.*`, `channels.toggle`, `device.pair.approve`, `device.pair.revoke` | +| admin | All operator methods plus: `config.apply`, `config.patch`, `config.permissions.*`, `agents.create`, `agents.update`, `agents.delete`, `agents.files.*`, `teams.*`, `channels.toggle`, `device.pair.approve`, `device.pair.revoke` | --- @@ -194,6 +194,10 @@ flowchart TD | `config.apply` | Replace entire configuration | | `config.patch` | Partial configuration update | | `config.schema` | Get configuration JSON schema | +| `config.permissions.list` | List agent config permission rules | +| `config.permissions.check` | Preview effective permission for an agent, scope, config type, and user | +| `config.permissions.grant` | Add or update an agent config permission rule | +| `config.permissions.revoke` | Remove an agent config permission rule | ### Skills diff --git a/docs/23-ai-agent-permission-matrix.md b/docs/23-ai-agent-permission-matrix.md new file mode 100644 index 00000000..c3ef1d92 --- /dev/null +++ b/docs/23-ai-agent-permission-matrix.md @@ -0,0 +1,71 @@ +# AI Agent Permission Matrix + +This matrix documents the effective authorization layers for agent actions across channels, groups, and workspaces. + +## Permission Layers + +| Layer | Scope | Enforced By | Notes | +|-------|-------|-------------|-------| +| Tenant RBAC | Dashboard, HTTP, WebSocket RPC | `internal/permissions` | Viewer/operator/admin/owner. Admin methods include `config.permissions.*`. | +| Agent ownership/share | Agent visibility and management | `store.AgentStore.CanAccess` | Controls which agents a dashboard user can manage. | +| Channel membership | Platform delivery | Channel adapter | Platform can still reject outbound delivery after GoClaw allows it. | +| Agent config permissions | Agent config mutations from chat | `agent_config_permissions` | Matches by `agent_id`, `scope`, `config_type`, `user_id`, including wildcard rows. | +| Workspace file boundary | Filesystem access | tool sandbox/boundary checks | Prevents path escape and unsupported writes. | +| Context file boundary | Agent identity/context files | `ContextFileInterceptor` | Routes protected files to store and requires group writer permission in group contexts. | + +## Agent Config Permission Rows + +| Field | Examples | Meaning | +|-------|----------|---------| +| `scope` | `agent`, `group:*`, `group:zalo:123`, `group:telegram:-100`, `*` | Where the grant applies. | +| `config_type` | `file_writer`, `heartbeat`, `cron`, `context_files`, `*` | What action family the grant covers. | +| `user_id` | `123456`, `zalo-user-id`, `*` | Who the grant covers. `*` grants every member in the selected scope. | +| `permission` | `allow`, `deny` | Effective decision. Deny can override broader allow. | + +Effective precedence: + +1. Individual deny. +2. Individual allow. +3. Scope/user wildcard deny. +4. Scope/user wildcard allow. +5. Default deny. + +## Channel Matrix + +| Channel Context | Read Agent Output | Send Reply | Write Workspace File | Write Protected Context File | Grant All Members | +|-----------------|-------------------|------------|----------------------|------------------------------|-------------------| +| Dashboard | RBAC controlled | N/A | Admin/operator path, then workspace boundary | Admin path, then context interceptor | Use Permissions tab | +| Direct message | Agent/session access | Channel adapter | Allowed by workspace boundary | Allowed by agent/context rules | Usually not needed | +| Telegram group | Group scope + sender ID | Channel adapter | Requires `file_writer` when group-gated | Requires `context_files` or `file_writer` and real sender | `scope=group:telegram:`, `user_id=*` | +| Zalo group | Group scope + sender ID | Channel adapter, group thread metadata | Requires `file_writer` when group-gated | Requires `context_files` or `file_writer` and real sender | `scope=group:zalo:`, `user_id=*` | +| Discord guild/channel | Guild scope + sender ID | Channel adapter | Requires `file_writer` when guild-gated | Requires `context_files` or `file_writer` and real sender | `scope=guild:` or matching group scope, `user_id=*` | +| Scheduled/proactive run | System sender | Channel adapter | Deny for group-gated file writes unless elevated context | Deny for protected group context writes | Configure explicit rules or run from dashboard/admin context | + +## Zalo Context Write Rule + +Zalo group failures commonly happen when an agent writes `SOUL.md`, `IDENTITY.md`, `AGENTS.md`, `USER.md`, `USER_PREDEFINED.md`, or `CAPABILITIES.md` from a group session but the acting sender is missing. Protected context writes now use the group permission gate: + +- `sender_id` must be a real platform user, not empty or synthetic. +- `user_id` must identify the group scope, for example `group:zalo:`. +- The sender must match a `context_files` allow or legacy `file_writer` allow, including wildcard rows such as `user_id="*"`. +- Missing tenant context or permission-store errors fail closed. + +## UX Contract + +The Permissions tab should expose a full matrix editor: + +| Control | Behavior | +|---------|----------| +| User/contact picker | Accepts explicit user IDs and contact search results. | +| All members button | Sets `user_id="*"` for the current rule. | +| Config type selector | Supports `file_writer`, `heartbeat`, `cron`, `context_files`, and `*`. | +| Scope selector | Supports known groups, `group:*`, `agent`, and `*`. | +| Check access | Calls `config.permissions.check` and shows the effective allow/deny decision before or after saving. | + +## Security Notes + +- Wildcard `user_id="*"` should be easy to grant but visually explicit because it expands access to every member in scope. +- Synthetic senders remain denied for group file/context writes. This avoids system turns inheriting permissions from no real user. +- Permission-store errors fail closed for group mutation boundaries. +- Backend validation rejects unknown config types and permissions before writing rules. +- Platform send permissions are still separate from GoClaw permissions; a channel adapter may reject delivery even when GoClaw allows the agent action. diff --git a/docs/project-changelog.md b/docs/project-changelog.md index 1b65c93d..f1e3040e 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -4,6 +4,28 @@ Significant changes, features, and fixes in reverse chronological order. --- +## 2026-05-17 + +### Agent Permissions: channel and workspace matrix + +**Features** + +- Added `config.permissions.check` so the UI can preview the effective allow/deny decision for an agent, scope, config type, and user. +- Added Permissions UI support for `userId="*"` to grant all members in a selected group scope. +- Documented the cross-channel agent permission matrix, including Zalo group context writes and workspace/context file boundaries. + +**Security** + +- Protected group context file writes now require a real sender with `context_files` or legacy `file_writer` permission. +- Group file/context/cron permission-store errors now fail closed instead of silently allowing mutation. +- Backend config permission RPCs validate config types and permission values before storing rules. + +**Tests** + +- Added focused store and context interceptor coverage for permission preview and protected group context writes. + +--- + <<<<<<< HEAD ## v3.11.3 — 2026-04-26 diff --git a/internal/gateway/methods/config_permissions.go b/internal/gateway/methods/config_permissions.go index 4e3044e8..9b16c749 100644 --- a/internal/gateway/methods/config_permissions.go +++ b/internal/gateway/methods/config_permissions.go @@ -38,6 +38,7 @@ func (m *ConfigPermissionsMethods) SetMemberResolver(r channels.MemberResolver) func (m *ConfigPermissionsMethods) Register(router *gateway.MethodRouter) { router.Register(protocol.MethodConfigPermissionsList, m.handleList) + router.Register(protocol.MethodConfigPermissionsCheck, m.handleCheck) router.Register(protocol.MethodConfigPermissionsGrant, m.handleGrant) router.Register(protocol.MethodConfigPermissionsRevoke, m.handleRevoke) } @@ -55,6 +56,10 @@ func (m *ConfigPermissionsMethods) handleList(ctx context.Context, client *gatew client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "agentId"))) return } + if params.ConfigType != "" && !store.ValidConfigType(params.ConfigType) { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid configType")) + return + } agentUUID, err := resolveAgentUUIDCached(ctx, m.agentRouter, m.agentStore, params.AgentID) if err != nil { @@ -71,6 +76,38 @@ func (m *ConfigPermissionsMethods) handleList(ctx context.Context, client *gatew client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"permissions": perms})) } +func (m *ConfigPermissionsMethods) handleCheck(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + locale := store.LocaleFromContext(ctx) + var params struct { + AgentID string `json:"agentId"` + Scope string `json:"scope"` + ConfigType string `json:"configType"` + UserID string `json:"userId"` + } + if req.Params != nil { + json.Unmarshal(req.Params, ¶ms) + } + + if errMsg := validateConfigPermissionParams(locale, params.AgentID, params.Scope, params.ConfigType, params.UserID, "allow", false); errMsg != "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, errMsg)) + return + } + + agentUUID, err := resolveAgentUUIDCached(ctx, m.agentRouter, m.agentStore, params.AgentID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid agentId")) + return + } + + decision, err := store.CheckConfigPermissionDecision(ctx, m.permStore, agentUUID, params.Scope, params.ConfigType, params.UserID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, configPermInternalErr("check", err))) + return + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"decision": decision})) +} + func (m *ConfigPermissionsMethods) handleGrant(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { locale := store.LocaleFromContext(ctx) var params struct { @@ -86,21 +123,8 @@ func (m *ConfigPermissionsMethods) handleGrant(ctx context.Context, client *gate json.Unmarshal(req.Params, ¶ms) } - switch { - case params.AgentID == "": - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "agentId"))) - return - case params.Scope == "": - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "scope"))) - return - case params.ConfigType == "": - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "configType"))) - return - case params.UserID == "": - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "userId"))) - return - case params.Permission == "": - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "permission"))) + if errMsg := validateConfigPermissionParams(locale, params.AgentID, params.Scope, params.ConfigType, params.UserID, params.Permission, true); errMsg != "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, errMsg)) return } @@ -158,18 +182,8 @@ func (m *ConfigPermissionsMethods) handleRevoke(ctx context.Context, client *gat json.Unmarshal(req.Params, ¶ms) } - switch { - case params.AgentID == "": - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "agentId"))) - return - case params.Scope == "": - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "scope"))) - return - case params.ConfigType == "": - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "configType"))) - return - case params.UserID == "": - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "userId"))) + if errMsg := validateConfigPermissionParams(locale, params.AgentID, params.Scope, params.ConfigType, params.UserID, "allow", false); errMsg != "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, errMsg)) return } @@ -191,3 +205,25 @@ func configPermInternalErr(action string, err error) string { slog.Error("config.permissions RPC error", "action", action, "error", err) return "internal error" } + +func validateConfigPermissionParams(locale, agentID, scope, configType, userID, permission string, validatePermission bool) string { + switch { + case agentID == "": + return i18n.T(locale, i18n.MsgRequired, "agentId") + case scope == "": + return i18n.T(locale, i18n.MsgRequired, "scope") + case configType == "": + return i18n.T(locale, i18n.MsgRequired, "configType") + case userID == "": + return i18n.T(locale, i18n.MsgRequired, "userId") + case !store.ValidConfigScope(scope): + return "invalid scope" + case !store.ValidConfigType(configType): + return "invalid configType" + case validatePermission && permission == "": + return i18n.T(locale, i18n.MsgRequired, "permission") + case validatePermission && !store.ValidConfigPermission(permission): + return "invalid permission" + } + return "" +} diff --git a/internal/permissions/policy.go b/internal/permissions/policy.go index 5348fc08..f5be0921 100644 --- a/internal/permissions/policy.go +++ b/internal/permissions/policy.go @@ -212,6 +212,7 @@ func isAdminMethod(method string) bool { protocol.MethodConfigSchema, protocol.MethodConfigDefaults, protocol.MethodConfigPermissionsList, + protocol.MethodConfigPermissionsCheck, protocol.MethodConfigPermissionsGrant, protocol.MethodConfigPermissionsRevoke, diff --git a/internal/permissions/policy_test.go b/internal/permissions/policy_test.go index 03d84592..9b4d1700 100644 --- a/internal/permissions/policy_test.go +++ b/internal/permissions/policy_test.go @@ -97,6 +97,7 @@ func TestCanAccess_AdminMethods(t *testing.T) { pe := NewPolicyEngine(nil) adminMethods := []string{ protocol.MethodConfigApply, + protocol.MethodConfigPermissionsCheck, protocol.MethodAgentsCreate, protocol.MethodAgentsDelete, protocol.MethodAPIKeysCreate, diff --git a/internal/store/config_permission_store.go b/internal/store/config_permission_store.go index 56f5ccbb..09fa3da3 100644 --- a/internal/store/config_permission_store.go +++ b/internal/store/config_permission_store.go @@ -12,16 +12,18 @@ import ( // Config type constants for agent_config_permissions.config_type column. const ( - ConfigTypeFileWriter = "file_writer" // Group file write access - ConfigTypeHeartbeat = "heartbeat" // Heartbeat config access - ConfigTypeCron = "cron" // Cron job management access + ConfigTypeFileWriter = "file_writer" // Group file write access + ConfigTypeHeartbeat = "heartbeat" // Heartbeat config access + ConfigTypeCron = "cron" // Cron job management access + ConfigTypeContextFiles = "context_files" // Context file write access + ConfigTypeWildcard = "*" // Any config type ) // ConfigPermission represents an allow/deny rule for agent configuration. type ConfigPermission struct { ID uuid.UUID `json:"id" db:"id"` AgentID uuid.UUID `json:"agentId" db:"agent_id"` - Scope string `json:"scope" db:"scope"` // "agent" | "group:telegram:-100456" | "group:*" | "*" + Scope string `json:"scope" db:"scope"` // "agent" | "group:telegram:-100456" | "group:*" | "*" ConfigType string `json:"configType" db:"config_type"` // "heartbeat" | "cron" | "context_files" | "file_writer" | "*" UserID string `json:"userId" db:"user_id"` Permission string `json:"permission" db:"permission"` // "allow" | "deny" @@ -31,6 +33,70 @@ type ConfigPermission struct { UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` } +// ConfigPermissionDecision is a compact, UI-safe explanation of an effective +// permission check. +type ConfigPermissionDecision struct { + Allowed bool `json:"allowed"` + AgentID string `json:"agentId"` + Scope string `json:"scope"` + ConfigType string `json:"configType"` + UserID string `json:"userId"` + Reason string `json:"reason"` +} + +// ValidConfigPermission reports whether permission is an accepted value. +func ValidConfigPermission(permission string) bool { + return permission == "allow" || permission == "deny" +} + +// ValidConfigType reports whether configType is supported by the generic +// agent_config_permissions evaluator. +func ValidConfigType(configType string) bool { + switch configType { + case ConfigTypeFileWriter, ConfigTypeHeartbeat, ConfigTypeCron, ConfigTypeContextFiles, ConfigTypeWildcard: + return true + default: + return false + } +} + +// ValidConfigScope reports whether scope is understood by the generic +// agent_config_permissions evaluator and current UI matrix. +func ValidConfigScope(scope string) bool { + return scope == "agent" || + scope == "*" || + scope == "group:*" || + strings.HasPrefix(scope, "group:") || + strings.HasPrefix(scope, "guild:") +} + +// CheckConfigPermissionDecision wraps CheckPermission with a stable response +// shape that the UI can render before and after granting a rule. +func CheckConfigPermissionDecision(ctx context.Context, permStore ConfigPermissionStore, agentID uuid.UUID, scope, configType, userID string) (ConfigPermissionDecision, error) { + decision := ConfigPermissionDecision{ + AgentID: agentID.String(), + Scope: scope, + ConfigType: configType, + UserID: userID, + } + if permStore == nil { + decision.Reason = "permission store unavailable" + return decision, nil + } + allowed, err := permStore.CheckPermission(ctx, agentID, scope, configType, userID) + if err != nil { + decision.Reason = "permission check failed" + return decision, err + } + decision.Allowed = allowed + if allowed { + decision.Reason = "matched an allow rule" + } else { + decision.Reason = "no matching allow rule or a deny rule has precedence" + } + return decision, nil +} + // ConfigPermissionStore manages agent configuration permissions with wildcard scope matching. type ConfigPermissionStore interface { // CheckPermission checks if a user has permission for a given config action. @@ -52,7 +118,7 @@ type ConfigPermissionStore interface { // - empty SenderID → DENY (system turn lost the real user — security gap if allowed) // - synthetic SenderID → DENY (subagent:, notification:, teammate:, system:, ticker:, session_send_tool) // - real numeric SenderID → DB lookup; deny if no grant -// - DB errors → fail-open (preserve availability over strictness) +// - missing tenant / DB errors → DENY (permission boundary must fail closed) // // Outside group/guild context (DM, HTTP, cron-direct): always allow — no per-user // writer gate applies. @@ -72,6 +138,9 @@ func CheckFileWriterPermission(ctx context.Context, permStore ConfigPermissionSt if agentID == uuid.Nil { return nil // no agent context } + if TenantIDFromContext(ctx) == uuid.Nil { + return fmt.Errorf("permission denied: tenant context is required for group file writes") + } // RBAC bypass: admin / operator / owner roles are pre-authenticated by // the tenant RBAC system (dashboard users, tenant admins). File-writer // grants exist to gate random group members; authenticated admins @@ -86,7 +155,7 @@ func CheckFileWriterPermission(ctx context.Context, permStore ConfigPermissionSt numericID := strings.SplitN(senderID, "|", 2)[0] allowed, err := permStore.CheckPermission(ctx, agentID, userID, ConfigTypeFileWriter, numericID) if err != nil { - return nil // fail-open on DB error only (availability) + return fmt.Errorf("permission denied: file writer permission check failed: %w", err) } if !allowed { return fmt.Errorf("permission denied: only file writers can modify files in this group. Use /addwriter to get write access") @@ -94,6 +163,50 @@ func CheckFileWriterPermission(ctx context.Context, permStore ConfigPermissionSt return nil } +// CheckContextFilePermission returns an error if a protected context file write +// in group/guild context does not have context_files or file_writer access. +func CheckContextFilePermission(ctx context.Context, permStore ConfigPermissionStore) error { + if permStore == nil { + return nil + } + userID := UserIDFromContext(ctx) + if !strings.HasPrefix(userID, "group:") && !strings.HasPrefix(userID, "guild:") { + return nil + } + agentID := AgentIDFromContext(ctx) + if agentID == uuid.Nil { + return nil + } + if TenantIDFromContext(ctx) == uuid.Nil { + return fmt.Errorf("permission denied: tenant context is required for group context file writes") + } + if isAdminRole(ctx) { + return nil + } + senderID := SenderIDFromContext(ctx) + if senderID == "" || isSyntheticSender(senderID) { + return fmt.Errorf("permission denied: system context cannot write files in group chats. If this is a legitimate user action, ensure the acting sender is preserved through the tool chain") + } + numericID := strings.SplitN(senderID, "|", 2)[0] + + allowed, err := permStore.CheckPermission(ctx, agentID, userID, ConfigTypeContextFiles, numericID) + if err != nil { + return fmt.Errorf("permission denied: context file permission check failed: %w", err) + } + if allowed { + return nil + } + + allowed, err = permStore.CheckPermission(ctx, agentID, userID, ConfigTypeFileWriter, numericID) + if err != nil { + return fmt.Errorf("permission denied: file writer permission check failed: %w", err) + } + if !allowed { + return fmt.Errorf("permission denied: only users with context_files or file_writer permission can modify context files in this group") + } + return nil +} + // isAdminRole reports whether ctx carries an elevated RBAC role // (admin / operator / owner) that should bypass per-user file-writer // grants. Tenant-authenticated identities pre-pass RBAC at the gateway @@ -134,6 +247,9 @@ func CheckCronPermission(ctx context.Context, permStore ConfigPermissionStore) e if agentID == uuid.Nil { return nil // no agent context } + if TenantIDFromContext(ctx) == uuid.Nil { + return fmt.Errorf("permission denied: tenant context is required for group cron permissions") + } if isAdminRole(ctx) { return nil // RBAC bypass (admin/operator/owner) } @@ -146,7 +262,7 @@ func CheckCronPermission(ctx context.Context, permStore ConfigPermissionStore) e // Check cron-specific permission first. allowed, err := permStore.CheckPermission(ctx, agentID, userID, ConfigTypeCron, numericID) if err != nil { - return nil // fail-open + return fmt.Errorf("permission denied: cron permission check failed: %w", err) } if allowed { return nil @@ -154,7 +270,7 @@ func CheckCronPermission(ctx context.Context, permStore ConfigPermissionStore) e // Fall back to file_writer (implies full mutation access). allowed, err = permStore.CheckPermission(ctx, agentID, userID, ConfigTypeFileWriter, numericID) if err != nil { - return nil // fail-open + return fmt.Errorf("permission denied: file writer permission check failed: %w", err) } if !allowed { return fmt.Errorf("permission denied: only users with cron or file_writer permission can manage cron jobs in group chats") diff --git a/internal/store/config_permission_store_test.go b/internal/store/config_permission_store_test.go new file mode 100644 index 00000000..a84a068d --- /dev/null +++ b/internal/store/config_permission_store_test.go @@ -0,0 +1,123 @@ +package store + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" +) + +type decisionConfigPermStore struct { + allowed bool + err error + gotScope string + gotType string + gotUserID string + gotAgentID uuid.UUID +} + +func (s *decisionConfigPermStore) CheckPermission(_ context.Context, agentID uuid.UUID, scope, configType, userID string) (bool, error) { + s.gotAgentID = agentID + s.gotScope = scope + s.gotType = configType + s.gotUserID = userID + return s.allowed, s.err +} + +func (s *decisionConfigPermStore) Grant(context.Context, *ConfigPermission) error { return nil } +func (s *decisionConfigPermStore) Revoke(context.Context, uuid.UUID, string, string, string) error { + return nil +} +func (s *decisionConfigPermStore) List(context.Context, uuid.UUID, string, string) ([]ConfigPermission, error) { + return nil, nil +} +func (s *decisionConfigPermStore) ListFileWriters(context.Context, uuid.UUID, string) ([]ConfigPermission, error) { + return nil, nil +} + +func TestValidConfigType(t *testing.T) { + for _, configType := range []string{ + ConfigTypeFileWriter, + ConfigTypeHeartbeat, + ConfigTypeCron, + ConfigTypeContextFiles, + ConfigTypeWildcard, + } { + if !ValidConfigType(configType) { + t.Fatalf("expected %q to be valid", configType) + } + } + if ValidConfigType("workspace") { + t.Fatal("unexpected valid config type") + } +} + +func TestValidConfigScope(t *testing.T) { + for _, scope := range []string{ + "agent", + "*", + "group:*", + "group:zalo:123", + "group:telegram:-100", + "guild:discord:456", + } { + if !ValidConfigScope(scope) { + t.Fatalf("expected %q to be valid", scope) + } + } + for _, scope := range []string{"", "dm:zalo:123", "workspace", "topic:telegram:1"} { + if ValidConfigScope(scope) { + t.Fatalf("expected %q to be invalid", scope) + } + } +} + +func TestCheckConfigPermissionDecision(t *testing.T) { + agentID := uuid.New() + permStore := &decisionConfigPermStore{allowed: true} + + decision, err := CheckConfigPermissionDecision( + context.Background(), + permStore, + agentID, + "group:zalo:123", + ConfigTypeContextFiles, + "*", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !decision.Allowed { + t.Fatal("expected decision to allow") + } + if decision.Reason == "" { + t.Fatal("expected reason") + } + if permStore.gotAgentID != agentID || permStore.gotScope != "group:zalo:123" || permStore.gotType != ConfigTypeContextFiles || permStore.gotUserID != "*" { + t.Fatalf("unexpected check args: %#v", permStore) + } +} + +func TestCheckConfigPermissionDecisionReturnsStableDeniedShapeOnStoreError(t *testing.T) { + agentID := uuid.New() + permStore := &decisionConfigPermStore{err: errors.New("db down")} + + decision, err := CheckConfigPermissionDecision( + context.Background(), + permStore, + agentID, + "group:zalo:123", + ConfigTypeFileWriter, + "user-1", + ) + if err == nil { + t.Fatal("expected error") + } + if decision.Allowed { + t.Fatal("store errors must not render as allowed") + } + if decision.Reason != "permission check failed" { + t.Fatalf("unexpected reason: %q", decision.Reason) + } +} diff --git a/internal/tools/context_file_interceptor.go b/internal/tools/context_file_interceptor.go index e107d4cb..e8c4463c 100644 --- a/internal/tools/context_file_interceptor.go +++ b/internal/tools/context_file_interceptor.go @@ -23,7 +23,7 @@ var protectedFileSet = map[string]bool{ bootstrap.AgentsFile: true, bootstrap.UserFile: true, bootstrap.UserPredefinedFile: true, - bootstrap.CapabilitiesFile: true, + bootstrap.CapabilitiesFile: true, } // contextFileSet is the set of filenames routed to the DB store. @@ -34,9 +34,9 @@ var contextFileSet = map[string]bool{ bootstrap.IdentityFile: true, bootstrap.UserFile: true, bootstrap.UserPredefinedFile: true, - bootstrap.BootstrapFile: true, // first-run file (deleted after completion) - bootstrap.HeartbeatFile: true, // agent-level heartbeat checklist - bootstrap.CapabilitiesFile: true, // domain expertise (evolvable when self_evolve=true) + bootstrap.BootstrapFile: true, // first-run file (deleted after completion) + bootstrap.HeartbeatFile: true, // agent-level heartbeat checklist + bootstrap.CapabilitiesFile: true, // domain expertise (evolvable when self_evolve=true) } // isContextFile checks if a path refers to a workspace-root context file. @@ -75,12 +75,12 @@ const defaultContextCacheTTL = 5 * time.Minute // Keeps SOUL.md, IDENTITY.md etc. in Postgres. // Routes based on agent type: "open" → all per-user, "predefined" → only USER.md per-user. type ContextFileInterceptor struct { - agentStore store.AgentStore - workspace string // workspace root for matching absolute paths - agentCache cache.Cache[[]store.AgentContextFileData] // agent-level files, keyed by agentID.String() - userCache cache.Cache[[]store.AgentContextFileData] // user-level files, keyed by "agentID:userID" - ttl time.Duration - permStore store.ConfigPermissionStore // nil = no group write restriction + agentStore store.AgentStore + workspace string // workspace root for matching absolute paths + agentCache cache.Cache[[]store.AgentContextFileData] // agent-level files, keyed by agentID.String() + userCache cache.Cache[[]store.AgentContextFileData] // user-level files, keyed by "agentID:userID" + ttl time.Duration + permStore store.ConfigPermissionStore // nil = no group write restriction } // NewContextFileInterceptor creates an interceptor backed by the given agent store. @@ -121,7 +121,7 @@ func (b *ContextFileInterceptor) ReadFile(ctx context.Context, path string) (str return "", false, nil // no agent context } - userID := store.UserIDFromContext(ctx) + userID := store.ContextUserID(ctx) agentType := store.AgentTypeFromContext(ctx) // Open agent: ALL files per-user → fallback to agent-level @@ -204,31 +204,22 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st return false, nil // no agent context } - userID := store.UserIDFromContext(ctx) + scopeUserID := store.UserIDFromContext(ctx) + userID := store.ContextUserID(ctx) agentType := store.AgentTypeFromContext(ctx) // Permission check: protected files in group context require allowlist membership. // Exception: during bootstrap onboarding (BOOTSTRAP.md still exists for this user), // USER.md writes are allowed so the bot can complete the first-run ritual. - if (strings.HasPrefix(userID, "group:") || strings.HasPrefix(userID, "guild:")) && protectedFileSet[fileName] { + if (strings.HasPrefix(scopeUserID, "group:") || strings.HasPrefix(scopeUserID, "guild:")) && protectedFileSet[fileName] { skipCheck := false - if fileName == bootstrap.UserFile && b.hasBootstrapFile(ctx, agentID, userID) { + if fileName == bootstrap.UserFile && b.hasBootstrapFile(ctx, agentID, scopeUserID) { skipCheck = true // onboarding in progress — allow USER.md write } if !skipCheck { - senderID := store.SenderIDFromContext(ctx) - if senderID != "" && b.permStore != nil { - numericID := strings.SplitN(senderID, "|", 2)[0] - allowed, err := b.permStore.CheckPermission(ctx, agentID, userID, store.ConfigTypeFileWriter, numericID) - if err != nil { - slog.Warn("security.group_file_writer_check_failed", - "error", err, "sender", numericID, "file", fileName, "group", userID) - // fail open: allow write if check fails - } else if !allowed { - return true, fmt.Errorf("permission denied: you are not authorized to modify %s in this group. Ask a group file writer to add you with /addwriter", fileName) - } + if err := store.CheckContextFilePermission(ctx, b.permStore); err != nil { + return true, fmt.Errorf("permission denied: you are not authorized to modify %s in this group. %w", fileName, err) } - // senderID empty or no permStore = system context (cron, subagent) → fail open } } @@ -297,6 +288,9 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st // Used by the agent loop to dynamically resolve context files for system prompt. // Uses the same agentCache/userCache as ReadFile — invalidated on WriteFile and pubsub events. func (b *ContextFileInterceptor) LoadContextFiles(ctx context.Context, agentID uuid.UUID, userID, agentType string) []bootstrap.ContextFile { + if store.IsSharedContext(ctx) { + userID = "" + } // Open agent: all files from user_context_files if agentType == store.AgentTypeOpen && userID != "" { files := b.cachedUserFiles(ctx, agentID, userID) diff --git a/internal/tools/context_file_interceptor_test.go b/internal/tools/context_file_interceptor_test.go index 9cc165d2..58899529 100644 --- a/internal/tools/context_file_interceptor_test.go +++ b/internal/tools/context_file_interceptor_test.go @@ -2,6 +2,7 @@ package tools import ( "context" + "errors" "strings" "sync/atomic" "testing" @@ -23,6 +24,32 @@ type stubAgentStore struct { setUserCallN atomic.Int32 } +type stubConfigPermissionStore struct { + allow bool + allowedTypes map[string]bool + err error +} + +func (s stubConfigPermissionStore) CheckPermission(_ context.Context, _ uuid.UUID, _ string, configType, _ string) (bool, error) { + if s.err != nil { + return false, s.err + } + if s.allowedTypes != nil { + return s.allowedTypes[configType], nil + } + return s.allow, nil +} +func (s stubConfigPermissionStore) Grant(context.Context, *store.ConfigPermission) error { return nil } +func (s stubConfigPermissionStore) Revoke(context.Context, uuid.UUID, string, string, string) error { + return nil +} +func (s stubConfigPermissionStore) List(context.Context, uuid.UUID, string, string) ([]store.ConfigPermission, error) { + return nil, nil +} +func (s stubConfigPermissionStore) ListFileWriters(context.Context, uuid.UUID, string) ([]store.ConfigPermission, error) { + return nil, nil +} + func (s *stubAgentStore) GetAgentContextFiles(_ context.Context, _ uuid.UUID) ([]store.AgentContextFileData, error) { s.agentCallsN.Add(1) return s.agentFiles, nil @@ -66,7 +93,7 @@ func (s *stubAgentStore) GetByIDs(_ context.Context, _ []uuid.UUID) ([]store.Age return nil, nil } func (s *stubAgentStore) GetDefault(_ context.Context) (*store.AgentData, error) { return nil, nil } -func (s *stubAgentStore) ResetStuckSummoning(_ context.Context) (int64, error) { return 0, nil } +func (s *stubAgentStore) ResetStuckSummoning(_ context.Context) (int64, error) { return 0, nil } func (s *stubAgentStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error { return nil } func (s *stubAgentStore) Delete(_ context.Context, _ uuid.UUID) error { return nil } func (s *stubAgentStore) List(_ context.Context, _ string) ([]store.AgentData, error) { @@ -104,6 +131,7 @@ func (s *stubAgentStore) EnsureUserProfile(_ context.Context, _ uuid.UUID, _ str func (s *stubAgentStore) PropagateContextFile(_ context.Context, _ uuid.UUID, _ string) (int, error) { return 0, nil } + // ---- Tests ---- // TestInterceptor_CacheHit verifies that a second read does NOT call GetAgentContextFiles again. @@ -348,6 +376,149 @@ func TestInterceptor_BlocksCapabilitiesWithoutSelfEvolve(t *testing.T) { } } +func TestInterceptor_BlocksProtectedGroupContextWriteWithoutSender(t *testing.T) { + agentID := uuid.New() + tenantID := uuid.New() + as := &stubAgentStore{} + intc := NewContextFileInterceptor(as, "/workspace", + cache.NewInMemoryCache[[]store.AgentContextFileData](), + cache.NewInMemoryCache[[]store.AgentContextFileData](), + ) + intc.SetConfigPermStore(stubConfigPermissionStore{allow: true}) + + ctx := store.WithAgentID(context.Background(), agentID) + ctx = store.WithTenantID(ctx, tenantID) + ctx = store.WithAgentType(ctx, store.AgentTypeOpen) + ctx = store.WithUserID(ctx, "group:zalo:123") + + handled, err := intc.WriteFile(ctx, "SOUL.md", "new soul") + if !handled { + t.Fatal("expected SOUL.md to be handled") + } + if err == nil { + t.Fatal("expected protected group context write to require a real sender") + } + if !strings.Contains(err.Error(), "system context cannot write files") { + t.Fatalf("unexpected error: %v", err) + } + if n := as.setUserCallN.Load(); n != 0 { + t.Fatalf("denied write should not touch user context store, got %d writes", n) + } +} + +func TestInterceptor_AllowsProtectedGroupContextWriteForGrantedSender(t *testing.T) { + agentID := uuid.New() + tenantID := uuid.New() + as := &stubAgentStore{} + intc := NewContextFileInterceptor(as, "/workspace", + cache.NewInMemoryCache[[]store.AgentContextFileData](), + cache.NewInMemoryCache[[]store.AgentContextFileData](), + ) + intc.SetConfigPermStore(stubConfigPermissionStore{ + allowedTypes: map[string]bool{store.ConfigTypeContextFiles: true}, + }) + + ctx := store.WithAgentID(context.Background(), agentID) + ctx = store.WithTenantID(ctx, tenantID) + ctx = store.WithAgentType(ctx, store.AgentTypeOpen) + ctx = store.WithUserID(ctx, "group:zalo:123") + ctx = store.WithSenderID(ctx, "456") + + handled, err := intc.WriteFile(ctx, "SOUL.md", "new soul") + if err != nil { + t.Fatalf("expected granted sender to write protected group context file, got: %v", err) + } + if !handled { + t.Fatal("expected SOUL.md to be handled") + } + if n := as.setUserCallN.Load(); n != 1 { + t.Fatalf("expected one user context write, got %d", n) + } +} + +func TestInterceptor_AllowsProtectedGroupContextWriteForLegacyFileWriter(t *testing.T) { + agentID := uuid.New() + tenantID := uuid.New() + as := &stubAgentStore{} + intc := NewContextFileInterceptor(as, "/workspace", + cache.NewInMemoryCache[[]store.AgentContextFileData](), + cache.NewInMemoryCache[[]store.AgentContextFileData](), + ) + intc.SetConfigPermStore(stubConfigPermissionStore{ + allowedTypes: map[string]bool{store.ConfigTypeFileWriter: true}, + }) + + ctx := store.WithAgentID(context.Background(), agentID) + ctx = store.WithTenantID(ctx, tenantID) + ctx = store.WithAgentType(ctx, store.AgentTypeOpen) + ctx = store.WithUserID(ctx, "group:zalo:123") + ctx = store.WithSenderID(ctx, "456") + + handled, err := intc.WriteFile(ctx, "SOUL.md", "new soul") + if err != nil { + t.Fatalf("expected legacy file_writer to write protected group context file, got: %v", err) + } + if !handled { + t.Fatal("expected SOUL.md to be handled") + } +} + +func TestInterceptor_BlocksProtectedGroupContextWriteWithoutTenant(t *testing.T) { + agentID := uuid.New() + as := &stubAgentStore{} + intc := NewContextFileInterceptor(as, "/workspace", + cache.NewInMemoryCache[[]store.AgentContextFileData](), + cache.NewInMemoryCache[[]store.AgentContextFileData](), + ) + intc.SetConfigPermStore(stubConfigPermissionStore{ + allowedTypes: map[string]bool{store.ConfigTypeContextFiles: true}, + }) + + ctx := store.WithAgentID(context.Background(), agentID) + ctx = store.WithAgentType(ctx, store.AgentTypeOpen) + ctx = store.WithUserID(ctx, "group:zalo:123") + ctx = store.WithSenderID(ctx, "456") + + handled, err := intc.WriteFile(ctx, "SOUL.md", "new soul") + if !handled { + t.Fatal("expected SOUL.md to be handled") + } + if err == nil { + t.Fatal("expected missing tenant context to fail closed") + } + if !strings.Contains(err.Error(), "tenant context is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInterceptor_BlocksProtectedGroupContextWriteOnPermissionStoreError(t *testing.T) { + agentID := uuid.New() + tenantID := uuid.New() + as := &stubAgentStore{} + intc := NewContextFileInterceptor(as, "/workspace", + cache.NewInMemoryCache[[]store.AgentContextFileData](), + cache.NewInMemoryCache[[]store.AgentContextFileData](), + ) + intc.SetConfigPermStore(stubConfigPermissionStore{err: errors.New("db down")}) + + ctx := store.WithAgentID(context.Background(), agentID) + ctx = store.WithTenantID(ctx, tenantID) + ctx = store.WithAgentType(ctx, store.AgentTypeOpen) + ctx = store.WithUserID(ctx, "group:zalo:123") + ctx = store.WithSenderID(ctx, "456") + + handled, err := intc.WriteFile(ctx, "SOUL.md", "new soul") + if !handled { + t.Fatal("expected SOUL.md to be handled") + } + if err == nil { + t.Fatal("expected permission store errors to fail closed") + } + if !strings.Contains(err.Error(), "permission check failed") { + t.Fatalf("unexpected error: %v", err) + } +} + // TestInterceptor_AllowsCapabilitiesRead verifies that a predefined agent // with self_evolve=true can read CAPABILITIES.md (needed before updating). func TestInterceptor_AllowsCapabilitiesRead(t *testing.T) { @@ -401,3 +572,63 @@ func TestInterceptor_BlocksCapabilitiesReadWithoutSelfEvolve(t *testing.T) { t.Errorf("expected context-loaded error, got: %v", err) } } + +func TestInterceptor_SharedContextReadsAgentLevelForOpenAgent(t *testing.T) { + agentID := uuid.New() + as := &stubAgentStore{ + agentFiles: []store.AgentContextFileData{ + {AgentID: agentID, FileName: "USER.md", Content: "shared profile"}, + }, + userFiles: []store.UserContextFileData{ + {AgentID: agentID, UserID: "user-1", FileName: "USER.md", Content: "private profile"}, + }, + } + intc := NewContextFileInterceptor(as, "/workspace", + cache.NewInMemoryCache[[]store.AgentContextFileData](), + cache.NewInMemoryCache[[]store.AgentContextFileData](), + ) + + ctx := store.WithAgentID(context.Background(), agentID) + ctx = store.WithAgentType(ctx, store.AgentTypeOpen) + ctx = store.WithUserID(ctx, "user-1") + ctx = store.WithSharedContext(ctx) + + content, handled, err := intc.ReadFile(ctx, "USER.md") + if err != nil { + t.Fatalf("shared context read returned error: %v", err) + } + if !handled { + t.Fatal("expected USER.md to be handled") + } + if content != "shared profile" { + t.Fatalf("expected shared agent-level context, got %q", content) + } +} + +func TestInterceptor_SharedContextWritesAgentLevelForOpenAgent(t *testing.T) { + agentID := uuid.New() + as := &stubAgentStore{} + intc := NewContextFileInterceptor(as, "/workspace", + cache.NewInMemoryCache[[]store.AgentContextFileData](), + cache.NewInMemoryCache[[]store.AgentContextFileData](), + ) + + ctx := store.WithAgentID(context.Background(), agentID) + ctx = store.WithAgentType(ctx, store.AgentTypeOpen) + ctx = store.WithUserID(ctx, "user-1") + ctx = store.WithSharedContext(ctx) + + handled, err := intc.WriteFile(ctx, "USER.md", "shared profile") + if err != nil { + t.Fatalf("shared context write returned error: %v", err) + } + if !handled { + t.Fatal("expected USER.md to be handled") + } + if n := as.setAgentCallN.Load(); n != 1 { + t.Fatalf("expected SetAgentContextFile once, got %d", n) + } + if n := as.setUserCallN.Load(); n != 0 { + t.Fatalf("expected no SetUserContextFile calls, got %d", n) + } +} diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index 15080995..d6191801 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -101,6 +101,7 @@ const ( // Config permissions const ( MethodConfigPermissionsList = "config.permissions.list" + MethodConfigPermissionsCheck = "config.permissions.check" MethodConfigPermissionsGrant = "config.permissions.grant" MethodConfigPermissionsRevoke = "config.permissions.revoke" ) diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index 1e04fd17..106518e2 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -168,6 +168,7 @@ export const Methods = { // Config permissions CONFIG_PERMISSIONS_LIST: "config.permissions.list", + CONFIG_PERMISSIONS_CHECK: "config.permissions.check", CONFIG_PERMISSIONS_GRANT: "config.permissions.grant", CONFIG_PERMISSIONS_REVOKE: "config.permissions.revoke", diff --git a/ui/web/src/i18n/locales/en/agents.json b/ui/web/src/i18n/locales/en/agents.json index f30678fe..5d306e81 100644 --- a/ui/web/src/i18n/locales/en/agents.json +++ b/ui/web/src/i18n/locales/en/agents.json @@ -997,6 +997,11 @@ "title": "Permissions", "description": "Control who can modify agent config and files. Owner always has full access.", "addRule": "Add Rule", + "allMembers": "All members", + "allMembersTitle": "Grant this rule to every member in the selected scope by using userId=\"*\".", + "checkAccess": "Check access", + "allowed": "Allowed", + "denied": "Denied", "fileWriters": "File Writers", "configPerms": "Config Permissions", "noRules": "No permission rules. Owner has implicit full access.", diff --git a/ui/web/src/i18n/locales/vi/agents.json b/ui/web/src/i18n/locales/vi/agents.json index ea8d07f7..5adb34b0 100644 --- a/ui/web/src/i18n/locales/vi/agents.json +++ b/ui/web/src/i18n/locales/vi/agents.json @@ -982,6 +982,11 @@ "title": "Quyền hạn", "description": "Quản lý ai được phép thay đổi cấu hình agent và file. Chủ sở hữu luôn có quyền đầy đủ.", "addRule": "Thêm quy tắc", + "allMembers": "Tat ca members", + "allMembersTitle": "Grant rule nay cho tat ca members trong scope dang chon bang userId=\"*\".", + "checkAccess": "Kiem tra quyen", + "allowed": "Duoc phep", + "denied": "Bi chan", "fileWriters": "Người viết file", "configPerms": "Quyền cấu hình", "noRules": "Chưa có quy tắc. Chủ sở hữu mặc định có đầy đủ quyền.", diff --git a/ui/web/src/i18n/locales/zh/agents.json b/ui/web/src/i18n/locales/zh/agents.json index ac99513e..4dfb4e53 100644 --- a/ui/web/src/i18n/locales/zh/agents.json +++ b/ui/web/src/i18n/locales/zh/agents.json @@ -982,6 +982,11 @@ "title": "权限管理", "description": "控制谁可以修改代理配置和文件。所有者始终拥有完全访问权限。", "addRule": "添加规则", + "allMembers": "All members", + "allMembersTitle": "Grant this rule to every member in the selected scope by using userId=\"*\".", + "checkAccess": "Check access", + "allowed": "Allowed", + "denied": "Denied", "fileWriters": "文件编辑者", "configPerms": "配置权限", "noRules": "暂无权限规则。所有者默认拥有完全访问权限。", diff --git a/ui/web/src/pages/agents/agent-detail/agent-permissions-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-permissions-tab.tsx index ee2aef49..579b8a4d 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-permissions-tab.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-permissions-tab.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo, useCallback } from "react"; -import { Plus, Trash2, Loader2, Shield, FolderOpen, RefreshCw } from "lucide-react"; +import { Plus, Trash2, Loader2, Shield, FolderOpen, RefreshCw, Users, CheckCircle2, XCircle } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -7,7 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; -import { useConfigPermissions, type ConfigPermission } from "../hooks/use-config-permissions"; +import { useConfigPermissions, type ConfigPermission, type ConfigPermissionDecision } from "../hooks/use-config-permissions"; import { UserPickerCombobox } from "@/components/shared/user-picker-combobox"; import { useContactResolver } from "@/hooks/use-contact-resolver"; import { formatUserLabel } from "@/lib/format-user-label"; @@ -56,13 +56,15 @@ export function AgentPermissionsTab({ agentId }: AgentPermissionsTabProps) { const { t } = useTranslation("agents"); const ws = useWs(); const http = useHttp(); - const { permissions, loading, load, grant, revoke } = useConfigPermissions(agentId); + const { permissions, loading, load, grant, revoke, check } = useConfigPermissions(agentId); const [userId, setUserId] = useState(""); const [configType, setConfigType] = useState("file_writer"); const [scope, setScope] = useState("group:*"); const [permission, setPermission] = useState("allow"); const [adding, setAdding] = useState(false); + const [checking, setChecking] = useState(false); + const [decision, setDecision] = useState(); const [targets, setTargets] = useState([]); // Fetch delivery targets (groups/topics) from channel_contacts @@ -107,6 +109,26 @@ export function AgentPermissionsTab({ agentId }: AgentPermissionsTabProps) { useEffect(() => { load(); }, [load]); + const handleCheck = useCallback(async () => { + const trimmed = userId.trim(); + if (!trimmed || !scope || !configType) { + setDecision(undefined); + return; + } + setChecking(true); + try { + setDecision(await check(scope, configType, trimmed)); + } catch { + setDecision(undefined); + } finally { + setChecking(false); + } + }, [check, scope, configType, userId]); + + useEffect(() => { + setDecision(undefined); + }, [scope, configType, userId]); + const handleAdd = async () => { const trimmed = userId.trim(); if (!trimmed) return; @@ -130,6 +152,7 @@ export function AgentPermissionsTab({ agentId }: AgentPermissionsTabProps) { } catch { /* best-effort — backend still auto-enriches via getChatMember */ } await grant(scope, configType, trimmed, permission, metadata); setUserId(""); + setDecision(undefined); setAdding(false); }; @@ -194,6 +217,17 @@ export function AgentPermissionsTab({ agentId }: AgentPermissionsTabProps) { placeholder={t("permissions.userIdPlaceholder")} className="flex-1 min-w-[160px]" /> +