feat: security/ YAML files + shell/security/ loader (issue 0024b)
Crea los tres archivos YAML de configuración de seguridad centralizada en security/ (user-groups.yaml, agent-groups.yaml, permissions.yaml) y el loader impuro shell/security/loader.go que los lee y construye un security.SecurityPolicy puro. - security/user-groups.yaml: grupos de usuarios (admins, everyone) - security/agent-groups.yaml: grupos de agentes (assistants, all) - security/permissions.yaml: políticas de permisos por grupo de agentes - shell/security/loader.go: Load(dir) → SecurityPolicy; usa structs YAML intermedios para mantener pkg/security/ libre de gopkg.in/yaml.v3 - shell/security/loader_test.go: 6 tests cubren los casos del issue (dir inexistente, vacío, 3 YAMLs, solo uno, malformado, wildcards) El código se mergea con feature flag centralized-security-groups = false (loader creado, todavía no wired al launcher — eso es 0024c). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
# Grupos de agentes del sistema
|
||||||
|
# Agents: lista de agent IDs (del campo agent.id en config.yaml), o "*" para todos
|
||||||
|
groups:
|
||||||
|
assistants:
|
||||||
|
agents:
|
||||||
|
- assistant-bot
|
||||||
|
- asistente-2
|
||||||
|
all:
|
||||||
|
agents: ["*"]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Políticas de permisos: para cada grupo de agentes, qué acciones tiene cada grupo de usuarios
|
||||||
|
# Actions: "*" = todo, "ask" = chat libre, "command:<name>" = comandos, "tool:<name>" = tools
|
||||||
|
policies:
|
||||||
|
- agent_group: all
|
||||||
|
permissions:
|
||||||
|
- user_group: admins
|
||||||
|
actions: ["*"]
|
||||||
|
- user_group: everyone
|
||||||
|
actions: ["ask"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Grupos de usuarios del sistema
|
||||||
|
# Members: lista de Matrix user IDs, o "*" para todos los usuarios
|
||||||
|
groups:
|
||||||
|
admins:
|
||||||
|
members: [] # rellenar con los administradores reales
|
||||||
|
everyone:
|
||||||
|
members: ["*"]
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Package security provides the impure loader for security policy YAML files.
|
||||||
|
// It reads security/ directory files and returns a pure security.SecurityPolicy.
|
||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"github.com/enmanuel/agents/pkg/security"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- YAML intermediate types (private, only for parsing) ---
|
||||||
|
|
||||||
|
type yamlUserGroups struct {
|
||||||
|
Groups map[string]struct {
|
||||||
|
Members []string `yaml:"members"`
|
||||||
|
} `yaml:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type yamlAgentGroups struct {
|
||||||
|
Groups map[string]struct {
|
||||||
|
Agents []string `yaml:"agents"`
|
||||||
|
} `yaml:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type yamlPermissions struct {
|
||||||
|
Policies []struct {
|
||||||
|
AgentGroup string `yaml:"agent_group"`
|
||||||
|
Permissions []struct {
|
||||||
|
UserGroup string `yaml:"user_group"`
|
||||||
|
Actions []string `yaml:"actions"`
|
||||||
|
} `yaml:"permissions"`
|
||||||
|
} `yaml:"policies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads the security YAML files from dir and returns a SecurityPolicy.
|
||||||
|
// If dir does not exist or is empty, returns an empty policy without error.
|
||||||
|
// If an individual file is missing, that section is left empty.
|
||||||
|
// If a YAML file is malformed, returns an error naming the file.
|
||||||
|
func Load(dir string) (security.SecurityPolicy, error) {
|
||||||
|
if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) {
|
||||||
|
return security.SecurityPolicy{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
userGroups, err := loadUserGroups(filepath.Join(dir, "user-groups.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
return security.SecurityPolicy{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
agentGroups, err := loadAgentGroups(filepath.Join(dir, "agent-groups.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
return security.SecurityPolicy{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
policies, err := loadPermissions(filepath.Join(dir, "permissions.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
return security.SecurityPolicy{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return security.SecurityPolicy{
|
||||||
|
UserGroups: userGroups,
|
||||||
|
AgentGroups: agentGroups,
|
||||||
|
Policies: policies,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadUserGroups(path string) ([]security.UserGroup, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("security: reading %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw yamlUserGroups
|
||||||
|
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("security: parsing %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := make([]security.UserGroup, 0, len(raw.Groups))
|
||||||
|
for name, g := range raw.Groups {
|
||||||
|
groups = append(groups, security.UserGroup{
|
||||||
|
Name: name,
|
||||||
|
Members: g.Members,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return groups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAgentGroups(path string) ([]security.AgentGroup, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("security: reading %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw yamlAgentGroups
|
||||||
|
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("security: parsing %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := make([]security.AgentGroup, 0, len(raw.Groups))
|
||||||
|
for name, g := range raw.Groups {
|
||||||
|
groups = append(groups, security.AgentGroup{
|
||||||
|
Name: name,
|
||||||
|
Agents: g.Agents,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return groups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPermissions(path string) ([]security.AgentPolicy, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("security: reading %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw yamlPermissions
|
||||||
|
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("security: parsing %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
policies := make([]security.AgentPolicy, 0, len(raw.Policies))
|
||||||
|
for _, p := range raw.Policies {
|
||||||
|
perms := make([]security.Permission, 0, len(p.Permissions))
|
||||||
|
for _, perm := range p.Permissions {
|
||||||
|
perms = append(perms, security.Permission{
|
||||||
|
UserGroup: perm.UserGroup,
|
||||||
|
Actions: perm.Actions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
policies = append(policies, security.AgentPolicy{
|
||||||
|
AgentGroup: p.AgentGroup,
|
||||||
|
Permissions: perms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return policies, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package security_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
shellsecurity "github.com/enmanuel/agents/shell/security"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeFile is a helper that creates a file in dir with the given content.
|
||||||
|
func writeFile(t *testing.T, dir, name, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("writeFile %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test 3.1: directorio inexistente → policy vacía, sin error ---
|
||||||
|
|
||||||
|
func TestLoad_NonExistentDir(t *testing.T) {
|
||||||
|
policy, err := shellsecurity.Load("/tmp/does-not-exist-security-xyz")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
if len(policy.UserGroups) != 0 || len(policy.AgentGroups) != 0 || len(policy.Policies) != 0 {
|
||||||
|
t.Errorf("expected empty policy, got: %+v", policy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test 3.2: directorio vacío (sin YAML) → policy vacía, sin error ---
|
||||||
|
|
||||||
|
func TestLoad_EmptyDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
policy, err := shellsecurity.Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
if len(policy.UserGroups) != 0 || len(policy.AgentGroups) != 0 || len(policy.Policies) != 0 {
|
||||||
|
t.Errorf("expected empty policy, got: %+v", policy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test 3.3: los 3 YAML válidos → policy con todos los campos ---
|
||||||
|
|
||||||
|
func TestLoad_AllFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
writeFile(t, dir, "user-groups.yaml", `
|
||||||
|
groups:
|
||||||
|
admins:
|
||||||
|
members: ["@admin:example.com"]
|
||||||
|
everyone:
|
||||||
|
members: ["*"]
|
||||||
|
`)
|
||||||
|
writeFile(t, dir, "agent-groups.yaml", `
|
||||||
|
groups:
|
||||||
|
assistants:
|
||||||
|
agents:
|
||||||
|
- assistant-bot
|
||||||
|
all:
|
||||||
|
agents: ["*"]
|
||||||
|
`)
|
||||||
|
writeFile(t, dir, "permissions.yaml", `
|
||||||
|
policies:
|
||||||
|
- agent_group: all
|
||||||
|
permissions:
|
||||||
|
- user_group: admins
|
||||||
|
actions: ["*"]
|
||||||
|
- user_group: everyone
|
||||||
|
actions: ["ask"]
|
||||||
|
`)
|
||||||
|
|
||||||
|
policy, err := shellsecurity.Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(policy.UserGroups) != 2 {
|
||||||
|
t.Errorf("expected 2 user groups, got %d", len(policy.UserGroups))
|
||||||
|
}
|
||||||
|
if len(policy.AgentGroups) != 2 {
|
||||||
|
t.Errorf("expected 2 agent groups, got %d", len(policy.AgentGroups))
|
||||||
|
}
|
||||||
|
if len(policy.Policies) != 1 {
|
||||||
|
t.Errorf("expected 1 policy, got %d", len(policy.Policies))
|
||||||
|
}
|
||||||
|
if len(policy.Policies[0].Permissions) != 2 {
|
||||||
|
t.Errorf("expected 2 permissions, got %d", len(policy.Policies[0].Permissions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test 3.4: solo user-groups.yaml → user groups poblados, resto vacío ---
|
||||||
|
|
||||||
|
func TestLoad_OnlyUserGroups(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
writeFile(t, dir, "user-groups.yaml", `
|
||||||
|
groups:
|
||||||
|
admins:
|
||||||
|
members: ["@admin:example.com"]
|
||||||
|
`)
|
||||||
|
|
||||||
|
policy, err := shellsecurity.Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(policy.UserGroups) != 1 {
|
||||||
|
t.Errorf("expected 1 user group, got %d", len(policy.UserGroups))
|
||||||
|
}
|
||||||
|
if policy.UserGroups[0].Name != "admins" {
|
||||||
|
t.Errorf("expected group name 'admins', got %q", policy.UserGroups[0].Name)
|
||||||
|
}
|
||||||
|
if len(policy.AgentGroups) != 0 {
|
||||||
|
t.Errorf("expected no agent groups, got %d", len(policy.AgentGroups))
|
||||||
|
}
|
||||||
|
if len(policy.Policies) != 0 {
|
||||||
|
t.Errorf("expected no policies, got %d", len(policy.Policies))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test 3.5: YAML malformado → error con nombre de archivo en el mensaje ---
|
||||||
|
|
||||||
|
func TestLoad_MalformedYAML(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
writeFile(t, dir, "user-groups.yaml", `this: is: not: valid: yaml: [`)
|
||||||
|
|
||||||
|
_, err := shellsecurity.Load(dir)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for malformed YAML, got nil")
|
||||||
|
}
|
||||||
|
if got := err.Error(); len(got) == 0 {
|
||||||
|
t.Fatal("error message is empty")
|
||||||
|
}
|
||||||
|
// Must mention the filename
|
||||||
|
if !containsString(err.Error(), "user-groups.yaml") {
|
||||||
|
t.Errorf("error message should contain filename, got: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test 3.6: "*" como string literal en members y agents ---
|
||||||
|
|
||||||
|
func TestLoad_WildcardStrings(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
writeFile(t, dir, "user-groups.yaml", `
|
||||||
|
groups:
|
||||||
|
everyone:
|
||||||
|
members: ["*"]
|
||||||
|
`)
|
||||||
|
writeFile(t, dir, "agent-groups.yaml", `
|
||||||
|
groups:
|
||||||
|
all:
|
||||||
|
agents: ["*"]
|
||||||
|
`)
|
||||||
|
|
||||||
|
policy, err := shellsecurity.Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(policy.UserGroups) != 1 {
|
||||||
|
t.Fatalf("expected 1 user group, got %d", len(policy.UserGroups))
|
||||||
|
}
|
||||||
|
if len(policy.UserGroups[0].Members) != 1 || policy.UserGroups[0].Members[0] != "*" {
|
||||||
|
t.Errorf("expected members=[\"*\"], got %v", policy.UserGroups[0].Members)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(policy.AgentGroups) != 1 {
|
||||||
|
t.Fatalf("expected 1 agent group, got %d", len(policy.AgentGroups))
|
||||||
|
}
|
||||||
|
if len(policy.AgentGroups[0].Agents) != 1 || policy.AgentGroups[0].Agents[0] != "*" {
|
||||||
|
t.Errorf("expected agents=[\"*\"], got %v", policy.AgentGroups[0].Agents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(s, sub string) bool {
|
||||||
|
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsSubstr(s, sub))
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsSubstr(s, sub string) bool {
|
||||||
|
for i := 0; i <= len(s)-len(sub); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user