mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
feat(permissions): add agent channel permission matrix
This commit is contained in:
1 parent
c029e4f6bf
commit
536ab4ac6c
17 files changed
+766
-67
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -212,6 +212,7 @@ func isAdminMethod(method string) bool {
|
||||
protocol.MethodConfigSchema,
|
||||
protocol.MethodConfigDefaults,
|
||||
protocol.MethodConfigPermissionsList,
|
||||
protocol.MethodConfigPermissionsCheck,
|
||||
protocol.MethodConfigPermissionsGrant,
|
||||
protocol.MethodConfigPermissionsRevoke,
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,7 @@ const (
|
||||
// Config permissions
|
||||
const (
|
||||
MethodConfigPermissionsList = "config.permissions.list"
|
||||
MethodConfigPermissionsCheck = "config.permissions.check"
|
||||
MethodConfigPermissionsGrant = "config.permissions.grant"
|
||||
MethodConfigPermissionsRevoke = "config.permissions.revoke"
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user