diff --git a/security/agent-groups.yaml b/security/agent-groups.yaml new file mode 100644 index 0000000..8287cc1 --- /dev/null +++ b/security/agent-groups.yaml @@ -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: ["*"] diff --git a/security/permissions.yaml b/security/permissions.yaml new file mode 100644 index 0000000..99650fb --- /dev/null +++ b/security/permissions.yaml @@ -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:" = comandos, "tool:" = tools +policies: + - agent_group: all + permissions: + - user_group: admins + actions: ["*"] + - user_group: everyone + actions: ["ask"] diff --git a/security/user-groups.yaml b/security/user-groups.yaml new file mode 100644 index 0000000..1a8ff46 --- /dev/null +++ b/security/user-groups.yaml @@ -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: ["*"] diff --git a/shell/security/loader.go b/shell/security/loader.go new file mode 100644 index 0000000..e204638 --- /dev/null +++ b/shell/security/loader.go @@ -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 +} diff --git a/shell/security/loader_test.go b/shell/security/loader_test.go new file mode 100644 index 0000000..fa6f747 --- /dev/null +++ b/shell/security/loader_test.go @@ -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 +}