feat(permissions): add agent channel permission matrix

This commit is contained in:
Duy Nguyen committed 2026-05-17 15:09:49 +07:00
1 parent c029e4f6bf
commit 536ab4ac6c
17 files changed
+766 -67

No files matched your search

+5 -1
View File
@@ -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
+71
View File
@@ -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:<chatId>`, `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:<chatId>`, `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:<id>` 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:<chatId>`.
- 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.
+22
View File
@@ -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
+63 -27
View File
@@ -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, &params)
}
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, &params)
}
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, &params)
}
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 ""
}
+1
View File
@@ -212,6 +212,7 @@ func isAdminMethod(method string) bool {
protocol.MethodConfigSchema,
protocol.MethodConfigDefaults,
protocol.MethodConfigPermissionsList,
protocol.MethodConfigPermissionsCheck,
protocol.MethodConfigPermissionsGrant,
protocol.MethodConfigPermissionsRevoke,
+1
View File
@@ -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,
+124 -8
View File
@@ -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")
@@ -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)
}
}
+20 -26
View File
@@ -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)
+232 -1
View File
@@ -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)
}
}
+1
View File
@@ -101,6 +101,7 @@ const (
// Config permissions
const (
MethodConfigPermissionsList = "config.permissions.list"
MethodConfigPermissionsCheck = "config.permissions.check"
MethodConfigPermissionsGrant = "config.permissions.grant"
MethodConfigPermissionsRevoke = "config.permissions.revoke"
)
+1
View File
@@ -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",
+5
View File
@@ -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.",
+5
View File
@@ -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.",
+5
View File
@@ -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": "暂无权限规则。所有者默认拥有完全访问权限。",
@@ -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<ConfigPermissionDecision | undefined>();
const [targets, setTargets] = useState<DeliveryTarget[]>([]);
// 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]"
/>
<Button
type="button"
variant={userId === "*" ? "default" : "outline"}
size="sm"
className="h-9 shrink-0"
onClick={() => setUserId("*")}
title={t("permissions.allMembersTitle")}
>
<Users className="h-4 w-4" />
<span className="hidden sm:inline">{t("permissions.allMembers")}</span>
</Button>
<Select value={configType} onValueChange={setConfigType}>
<SelectTrigger className="w-[130px] text-base md:text-sm">
<SelectValue />
@@ -225,10 +259,38 @@ export function AgentPermissionsTab({ agentId }: AgentPermissionsTabProps) {
className="h-9 w-9 shrink-0"
onClick={handleAdd}
disabled={adding || !userId.trim()}
title={t("permissions.addRule")}
>
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={handleCheck}
disabled={checking || !userId.trim()}
>
{checking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Shield className="h-3.5 w-3.5" />}
{t("permissions.checkAccess")}
</Button>
{decision && (
<div
className={`flex min-w-0 items-center gap-1.5 rounded-md border px-2 py-1 text-xs ${
decision.allowed
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300"
: "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-300"
}`}
>
{decision.allowed ? <CheckCircle2 className="h-3.5 w-3.5 shrink-0" /> : <XCircle className="h-3.5 w-3.5 shrink-0" />}
<span className="truncate">
{decision.allowed ? t("permissions.allowed") : t("permissions.denied")} - {decision.reason}
</span>
</div>
)}
</div>
{currentDescKey && (
<p className="text-xs text-muted-foreground">{t(currentDescKey)}</p>
)}
@@ -16,6 +16,15 @@ export interface ConfigPermission {
updatedAt: string;
}
export interface ConfigPermissionDecision {
allowed: boolean;
agentId: string;
scope: string;
configType: string;
userId: string;
reason: string;
}
export function useConfigPermissions(agentId: string | undefined) {
const ws = useWs();
const [permissions, setPermissions] = useState<ConfigPermission[]>([]);
@@ -53,6 +62,18 @@ export function useConfigPermissions(agentId: string | undefined) {
[ws, agentId, load],
);
const check = useCallback(
async (scope: string, configType: string, userId: string) => {
if (!agentId || !scope || !configType || !userId) return undefined;
const res = await ws.call<{ decision: ConfigPermissionDecision }>(
Methods.CONFIG_PERMISSIONS_CHECK,
{ agentId, scope, configType, userId },
);
return res.decision;
},
[ws, agentId],
);
const revoke = useCallback(
async (scope: string, configType: string, userId: string) => {
if (!agentId) return;
@@ -69,5 +90,5 @@ export function useConfigPermissions(agentId: string | undefined) {
[ws, agentId, load],
);
return { permissions, loading, load, grant, revoke };
return { permissions, loading, load, grant, revoke, check };
}