mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-05-05 01:02:37 +02:00
Release prep: 54 engines, self-hosted signatures, i18n, dashboard updates
This commit is contained in:
parent
694e32be26
commit
41cbfd6e0a
178 changed files with 36008 additions and 399 deletions
117
internal/domain/identity/agent.go
Normal file
117
internal/domain/identity/agent.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// Package identity implements Non-Human Identity (NHI) for AI agents (SDD-003).
|
||||
//
|
||||
// Each agent has a unique AgentIdentity with capabilities (tool permissions),
|
||||
// constraints, and a delegation chain showing trust ancestry.
|
||||
package identity
|
||||
|
||||
import "time"
|
||||
|
||||
// AgentType classifies the autonomy level of an agent.
|
||||
type AgentType string
|
||||
|
||||
const (
|
||||
AgentAutonomous AgentType = "AUTONOMOUS" // Self-directed, no human in loop
|
||||
AgentSupervised AgentType = "SUPERVISED" // Human-in-the-loop for critical decisions
|
||||
AgentExternal AgentType = "EXTERNAL" // Third-party agent, minimal trust
|
||||
)
|
||||
|
||||
// Permission represents an operation type for tool access control.
|
||||
type Permission string
|
||||
|
||||
const (
|
||||
PermRead Permission = "READ"
|
||||
PermWrite Permission = "WRITE"
|
||||
PermExecute Permission = "EXECUTE"
|
||||
PermSend Permission = "SEND"
|
||||
)
|
||||
|
||||
// AgentIdentity represents a Non-Human Identity (NHI) for an AI agent.
|
||||
type AgentIdentity struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
AgentName string `json:"agent_name"`
|
||||
AgentType AgentType `json:"agent_type"`
|
||||
CreatedBy string `json:"created_by"` // Human principal who deployed
|
||||
DelegationChain []DelegationLink `json:"delegation_chain"` // Trust ancestry chain
|
||||
Capabilities []ToolPermission `json:"capabilities"` // Per-tool allowlists
|
||||
Constraints AgentConstraints `json:"constraints"` // Operational limits
|
||||
Tags map[string]string `json:"tags,omitempty"` // Arbitrary metadata
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
// DelegationLink records one step in the trust delegation chain.
|
||||
type DelegationLink struct {
|
||||
DelegatorID string `json:"delegator_id"` // Who delegated
|
||||
DelegatorType string `json:"delegator_type"` // "human" | "agent"
|
||||
Scope string `json:"scope"` // What was delegated
|
||||
GrantedAt time.Time `json:"granted_at"`
|
||||
}
|
||||
|
||||
// ToolPermission defines what an agent is allowed to do with a specific tool.
|
||||
type ToolPermission struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
Permissions []Permission `json:"permissions"`
|
||||
}
|
||||
|
||||
// AgentConstraints defines operational limits for an agent.
|
||||
type AgentConstraints struct {
|
||||
MaxTokensPerTurn int `json:"max_tokens_per_turn,omitempty"`
|
||||
MaxToolCallsPerTurn int `json:"max_tool_calls_per_turn,omitempty"`
|
||||
PIDetectionLevel string `json:"pi_detection_level"` // "strict" | "standard" | "relaxed"
|
||||
AllowExternalComms bool `json:"allow_external_comms"`
|
||||
}
|
||||
|
||||
// HasPermission checks if the agent has a specific permission for a specific tool.
|
||||
// Returns false for unknown tools (fail-safe closed — SDD-003 M3).
|
||||
func (a *AgentIdentity) HasPermission(toolName string, perm Permission) bool {
|
||||
for _, cap := range a.Capabilities {
|
||||
if cap.ToolName == toolName {
|
||||
for _, p := range cap.Permissions {
|
||||
if p == perm {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false // Tool known but permission not granted
|
||||
}
|
||||
}
|
||||
return false // Unknown tool → DENY (fail-safe closed)
|
||||
}
|
||||
|
||||
// HasTool returns true if the agent has ANY permission for the specified tool.
|
||||
func (a *AgentIdentity) HasTool(toolName string) bool {
|
||||
for _, cap := range a.Capabilities {
|
||||
if cap.ToolName == toolName {
|
||||
return len(cap.Permissions) > 0
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ToolNames returns the list of all tools this agent has access to.
|
||||
func (a *AgentIdentity) ToolNames() []string {
|
||||
names := make([]string, 0, len(a.Capabilities))
|
||||
for _, cap := range a.Capabilities {
|
||||
names = append(names, cap.ToolName)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Validate checks required fields.
|
||||
func (a *AgentIdentity) Validate() error {
|
||||
if a.AgentID == "" {
|
||||
return ErrMissingAgentID
|
||||
}
|
||||
if a.AgentName == "" {
|
||||
return ErrMissingAgentName
|
||||
}
|
||||
if a.CreatedBy == "" {
|
||||
return ErrMissingCreatedBy
|
||||
}
|
||||
switch a.AgentType {
|
||||
case AgentAutonomous, AgentSupervised, AgentExternal:
|
||||
// valid
|
||||
default:
|
||||
return ErrInvalidAgentType
|
||||
}
|
||||
return nil
|
||||
}
|
||||
72
internal/domain/identity/capability.go
Normal file
72
internal/domain/identity/capability.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package identity
|
||||
|
||||
// CapabilityDecision represents the result of a capability check.
|
||||
type CapabilityDecision struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
AgentID string `json:"agent_id"`
|
||||
ToolName string `json:"tool_name"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// CapabilityChecker verifies agent permissions against the identity store.
|
||||
// Integrates with DIP Oracle — called before tool execution.
|
||||
type CapabilityChecker struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// NewCapabilityChecker creates a capability checker backed by the identity store.
|
||||
func NewCapabilityChecker(store *Store) *CapabilityChecker {
|
||||
return &CapabilityChecker{store: store}
|
||||
}
|
||||
|
||||
// Check verifies that the agent has the required permission for the tool.
|
||||
// Returns DENY for: unknown agent, unknown tool, missing permission (fail-safe closed).
|
||||
func (c *CapabilityChecker) Check(agentID, toolName string, perm Permission) CapabilityDecision {
|
||||
agent, err := c.store.Get(agentID)
|
||||
if err != nil {
|
||||
return CapabilityDecision{
|
||||
Allowed: false,
|
||||
AgentID: agentID,
|
||||
ToolName: toolName,
|
||||
Reason: "agent_not_found",
|
||||
}
|
||||
}
|
||||
|
||||
if !agent.HasPermission(toolName, perm) {
|
||||
// Determine specific denial reason
|
||||
reason := "unknown_tool_for_agent"
|
||||
if agent.HasTool(toolName) {
|
||||
reason = "insufficient_permissions"
|
||||
}
|
||||
return CapabilityDecision{
|
||||
Allowed: false,
|
||||
AgentID: agentID,
|
||||
ToolName: toolName,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
// Update last seen timestamp
|
||||
_ = c.store.UpdateLastSeen(agentID)
|
||||
|
||||
return CapabilityDecision{
|
||||
Allowed: true,
|
||||
AgentID: agentID,
|
||||
ToolName: toolName,
|
||||
Reason: "allowed",
|
||||
}
|
||||
}
|
||||
|
||||
// CheckExternal verifies capability for an EXTERNAL agent type.
|
||||
// External agents have additional restrictions: no EXECUTE permission ever.
|
||||
func (c *CapabilityChecker) CheckExternal(agentID, toolName string, perm Permission) CapabilityDecision {
|
||||
if perm == PermExecute {
|
||||
return CapabilityDecision{
|
||||
Allowed: false,
|
||||
AgentID: agentID,
|
||||
ToolName: toolName,
|
||||
Reason: "external_agents_cannot_execute",
|
||||
}
|
||||
}
|
||||
return c.Check(agentID, toolName, perm)
|
||||
}
|
||||
13
internal/domain/identity/errors.go
Normal file
13
internal/domain/identity/errors.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package identity
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors for identity operations.
|
||||
var (
|
||||
ErrMissingAgentID = errors.New("identity: agent_id is required")
|
||||
ErrMissingAgentName = errors.New("identity: agent_name is required")
|
||||
ErrMissingCreatedBy = errors.New("identity: created_by is required")
|
||||
ErrInvalidAgentType = errors.New("identity: invalid agent_type (valid: AUTONOMOUS, SUPERVISED, EXTERNAL)")
|
||||
ErrAgentNotFound = errors.New("identity: agent not found")
|
||||
ErrAgentExists = errors.New("identity: agent already exists")
|
||||
)
|
||||
395
internal/domain/identity/identity_test.go
Normal file
395
internal/domain/identity/identity_test.go
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
package identity
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// === Agent Identity Tests ===
|
||||
|
||||
func TestAgentIdentityValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
agent AgentIdentity
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
"valid autonomous",
|
||||
AgentIdentity{AgentID: "a1", AgentName: "Test", CreatedBy: "admin", AgentType: AgentAutonomous},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"valid supervised",
|
||||
AgentIdentity{AgentID: "a2", AgentName: "Test", CreatedBy: "admin", AgentType: AgentSupervised},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"valid external",
|
||||
AgentIdentity{AgentID: "a3", AgentName: "Test", CreatedBy: "admin", AgentType: AgentExternal},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"missing agent_id",
|
||||
AgentIdentity{AgentName: "Test", CreatedBy: "admin", AgentType: AgentAutonomous},
|
||||
ErrMissingAgentID,
|
||||
},
|
||||
{
|
||||
"missing agent_name",
|
||||
AgentIdentity{AgentID: "a1", CreatedBy: "admin", AgentType: AgentAutonomous},
|
||||
ErrMissingAgentName,
|
||||
},
|
||||
{
|
||||
"missing created_by",
|
||||
AgentIdentity{AgentID: "a1", AgentName: "Test", AgentType: AgentAutonomous},
|
||||
ErrMissingCreatedBy,
|
||||
},
|
||||
{
|
||||
"invalid agent_type",
|
||||
AgentIdentity{AgentID: "a1", AgentName: "Test", CreatedBy: "admin", AgentType: "INVALID"},
|
||||
ErrInvalidAgentType,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.agent.Validate()
|
||||
if err != tt.wantErr {
|
||||
t.Errorf("Validate() = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPermissionFailSafeClosed(t *testing.T) {
|
||||
agent := AgentIdentity{
|
||||
Capabilities: []ToolPermission{
|
||||
{ToolName: "web_search", Permissions: []Permission{PermRead}},
|
||||
{ToolName: "memory_store", Permissions: []Permission{PermRead, PermWrite}},
|
||||
},
|
||||
}
|
||||
|
||||
// Allowed
|
||||
if !agent.HasPermission("web_search", PermRead) {
|
||||
t.Error("should allow READ on web_search")
|
||||
}
|
||||
if !agent.HasPermission("memory_store", PermWrite) {
|
||||
t.Error("should allow WRITE on memory_store")
|
||||
}
|
||||
|
||||
// Deny: wrong permission on known tool
|
||||
if agent.HasPermission("web_search", PermWrite) {
|
||||
t.Error("should deny WRITE on web_search (insufficient_permissions)")
|
||||
}
|
||||
|
||||
// Deny: unknown tool (fail-safe closed — SDD-003 M3)
|
||||
if agent.HasPermission("unknown_tool", PermRead) {
|
||||
t.Error("should deny READ on unknown_tool (fail-safe closed)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasTool(t *testing.T) {
|
||||
agent := AgentIdentity{
|
||||
Capabilities: []ToolPermission{
|
||||
{ToolName: "web_search", Permissions: []Permission{PermRead}},
|
||||
},
|
||||
}
|
||||
if !agent.HasTool("web_search") {
|
||||
t.Error("should have web_search")
|
||||
}
|
||||
if agent.HasTool("unknown") {
|
||||
t.Error("should not have unknown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolNames(t *testing.T) {
|
||||
agent := AgentIdentity{
|
||||
Capabilities: []ToolPermission{
|
||||
{ToolName: "a", Permissions: []Permission{PermRead}},
|
||||
{ToolName: "b", Permissions: []Permission{PermWrite}},
|
||||
},
|
||||
}
|
||||
names := agent.ToolNames()
|
||||
if len(names) != 2 {
|
||||
t.Fatalf("expected 2 tool names, got %d", len(names))
|
||||
}
|
||||
}
|
||||
|
||||
// === Store Tests ===
|
||||
|
||||
func TestStoreRegisterAndGet(t *testing.T) {
|
||||
s := NewStore()
|
||||
agent := &AgentIdentity{
|
||||
AgentID: "agent-01",
|
||||
AgentName: "Task Manager",
|
||||
CreatedBy: "admin@xn--80akacl3adqr.xn--p1acf",
|
||||
AgentType: AgentSupervised,
|
||||
}
|
||||
if err := s.Register(agent); err != nil {
|
||||
t.Fatalf("Register failed: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.Get("agent-01")
|
||||
if err != nil {
|
||||
t.Fatalf("Get failed: %v", err)
|
||||
}
|
||||
if got.AgentName != "Task Manager" {
|
||||
t.Errorf("got name %q, want %q", got.AgentName, "Task Manager")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreNotFound(t *testing.T) {
|
||||
s := NewStore()
|
||||
_, err := s.Get("nonexistent")
|
||||
if err != ErrAgentNotFound {
|
||||
t.Errorf("expected ErrAgentNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreDuplicateReject(t *testing.T) {
|
||||
s := NewStore()
|
||||
agent := &AgentIdentity{
|
||||
AgentID: "dup-01", AgentName: "A", CreatedBy: "admin", AgentType: AgentAutonomous,
|
||||
}
|
||||
_ = s.Register(agent)
|
||||
err := s.Register(agent)
|
||||
if err != ErrAgentExists {
|
||||
t.Errorf("expected ErrAgentExists, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRemove(t *testing.T) {
|
||||
s := NewStore()
|
||||
_ = s.Register(&AgentIdentity{
|
||||
AgentID: "rm-01", AgentName: "A", CreatedBy: "admin", AgentType: AgentAutonomous,
|
||||
})
|
||||
if err := s.Remove("rm-01"); err != nil {
|
||||
t.Fatalf("Remove failed: %v", err)
|
||||
}
|
||||
if s.Count() != 0 {
|
||||
t.Error("expected 0 agents after removal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreList(t *testing.T) {
|
||||
s := NewStore()
|
||||
_ = s.Register(&AgentIdentity{AgentID: "l1", AgentName: "A", CreatedBy: "admin", AgentType: AgentAutonomous})
|
||||
_ = s.Register(&AgentIdentity{AgentID: "l2", AgentName: "B", CreatedBy: "admin", AgentType: AgentSupervised})
|
||||
if len(s.List()) != 2 {
|
||||
t.Errorf("expected 2 agents, got %d", len(s.List()))
|
||||
}
|
||||
}
|
||||
|
||||
// === Capability Check Tests ===
|
||||
|
||||
func TestCapabilityAllowed(t *testing.T) {
|
||||
s := NewStore()
|
||||
_ = s.Register(&AgentIdentity{
|
||||
AgentID: "cap-01", AgentName: "A", CreatedBy: "admin", AgentType: AgentAutonomous,
|
||||
Capabilities: []ToolPermission{
|
||||
{ToolName: "web_search", Permissions: []Permission{PermRead}},
|
||||
},
|
||||
})
|
||||
checker := NewCapabilityChecker(s)
|
||||
d := checker.Check("cap-01", "web_search", PermRead)
|
||||
if !d.Allowed {
|
||||
t.Errorf("expected allowed, got denied: %s", d.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityDeniedUnknownAgent(t *testing.T) {
|
||||
s := NewStore()
|
||||
checker := NewCapabilityChecker(s)
|
||||
d := checker.Check("ghost", "web_search", PermRead)
|
||||
if d.Allowed {
|
||||
t.Error("should deny unknown agent")
|
||||
}
|
||||
if d.Reason != "agent_not_found" {
|
||||
t.Errorf("expected reason agent_not_found, got %s", d.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityDeniedUnknownTool(t *testing.T) {
|
||||
s := NewStore()
|
||||
_ = s.Register(&AgentIdentity{
|
||||
AgentID: "cap-02", AgentName: "A", CreatedBy: "admin", AgentType: AgentAutonomous,
|
||||
Capabilities: []ToolPermission{
|
||||
{ToolName: "web_search", Permissions: []Permission{PermRead}},
|
||||
},
|
||||
})
|
||||
checker := NewCapabilityChecker(s)
|
||||
d := checker.Check("cap-02", "unknown_tool", PermRead)
|
||||
if d.Allowed {
|
||||
t.Error("should deny unknown tool (fail-safe closed)")
|
||||
}
|
||||
if d.Reason != "unknown_tool_for_agent" {
|
||||
t.Errorf("expected reason unknown_tool_for_agent, got %s", d.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityDeniedInsufficientPerms(t *testing.T) {
|
||||
s := NewStore()
|
||||
_ = s.Register(&AgentIdentity{
|
||||
AgentID: "cap-03", AgentName: "A", CreatedBy: "admin", AgentType: AgentAutonomous,
|
||||
Capabilities: []ToolPermission{
|
||||
{ToolName: "web_search", Permissions: []Permission{PermRead}},
|
||||
},
|
||||
})
|
||||
checker := NewCapabilityChecker(s)
|
||||
d := checker.Check("cap-03", "web_search", PermWrite)
|
||||
if d.Allowed {
|
||||
t.Error("should deny WRITE on READ-only tool")
|
||||
}
|
||||
if d.Reason != "insufficient_permissions" {
|
||||
t.Errorf("expected reason insufficient_permissions, got %s", d.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalAgentCannotExecute(t *testing.T) {
|
||||
s := NewStore()
|
||||
_ = s.Register(&AgentIdentity{
|
||||
AgentID: "ext-01", AgentName: "External", CreatedBy: "admin", AgentType: AgentExternal,
|
||||
Capabilities: []ToolPermission{
|
||||
{ToolName: "web_search", Permissions: []Permission{PermRead, PermExecute}},
|
||||
},
|
||||
})
|
||||
checker := NewCapabilityChecker(s)
|
||||
d := checker.CheckExternal("ext-01", "web_search", PermExecute)
|
||||
if d.Allowed {
|
||||
t.Error("external agents should never get EXECUTE permission")
|
||||
}
|
||||
}
|
||||
|
||||
// === Namespaced Memory Tests ===
|
||||
|
||||
func TestNamespacedMemoryIsolation(t *testing.T) {
|
||||
m := NewNamespacedMemory()
|
||||
|
||||
// Agent A stores a value
|
||||
m.Store("agent-a", "secret", "classified-data")
|
||||
|
||||
// Agent A can read it
|
||||
val, ok := m.Get("agent-a", "secret")
|
||||
if !ok || val.(string) != "classified-data" {
|
||||
t.Error("agent-a should be able to read its own data")
|
||||
}
|
||||
|
||||
// Agent B CANNOT read Agent A's data
|
||||
_, ok = m.Get("agent-b", "secret")
|
||||
if ok {
|
||||
t.Error("agent-b should NOT be able to read agent-a's data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespacedMemoryKeys(t *testing.T) {
|
||||
m := NewNamespacedMemory()
|
||||
m.Store("agent-a", "key1", "v1")
|
||||
m.Store("agent-a", "key2", "v2")
|
||||
m.Store("agent-b", "key3", "v3")
|
||||
|
||||
keysA := m.Keys("agent-a")
|
||||
if len(keysA) != 2 {
|
||||
t.Errorf("agent-a should have 2 keys, got %d", len(keysA))
|
||||
}
|
||||
|
||||
keysB := m.Keys("agent-b")
|
||||
if len(keysB) != 1 {
|
||||
t.Errorf("agent-b should have 1 key, got %d", len(keysB))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespacedMemoryCount(t *testing.T) {
|
||||
m := NewNamespacedMemory()
|
||||
m.Store("a", "k1", "v1")
|
||||
m.Store("a", "k2", "v2")
|
||||
m.Store("b", "k1", "v1")
|
||||
|
||||
if m.Count("a") != 2 {
|
||||
t.Errorf("agent a should have 2 entries, got %d", m.Count("a"))
|
||||
}
|
||||
if m.Count("b") != 1 {
|
||||
t.Errorf("agent b should have 1 entry, got %d", m.Count("b"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespacedMemoryDelete(t *testing.T) {
|
||||
m := NewNamespacedMemory()
|
||||
m.Store("a", "key", "val")
|
||||
m.Delete("a", "key")
|
||||
_, ok := m.Get("a", "key")
|
||||
if ok {
|
||||
t.Error("key should be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// === Context Pinning Tests ===
|
||||
|
||||
func TestSecurityEventsPinned(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "hello", TokenCount: 100},
|
||||
{Role: "security", Content: "injection detected", TokenCount: 50, IsPinned: true, EventType: "injection_detected"},
|
||||
{Role: "user", Content: "more chat", TokenCount: 100},
|
||||
{Role: "security", Content: "permission denied", TokenCount: 50, IsPinned: true, EventType: "permission_denied"},
|
||||
{Role: "user", Content: "latest chat", TokenCount: 100},
|
||||
}
|
||||
|
||||
// Total = 400 tokens, budget = 200
|
||||
trimmed := TrimContext(messages, 200)
|
||||
|
||||
// Both security events MUST survive
|
||||
secCount := 0
|
||||
for _, m := range trimmed {
|
||||
if m.IsPinned {
|
||||
secCount++
|
||||
}
|
||||
}
|
||||
if secCount != 2 {
|
||||
t.Errorf("expected 2 pinned security events to survive, got %d", secCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonSecurityEventsTrimmed(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "old msg 1", TokenCount: 100},
|
||||
{Role: "user", Content: "old msg 2", TokenCount: 100},
|
||||
{Role: "user", Content: "old msg 3", TokenCount: 100},
|
||||
{Role: "security", Content: "pinned event", TokenCount: 50, IsPinned: true},
|
||||
{Role: "user", Content: "newest msg", TokenCount: 100},
|
||||
}
|
||||
|
||||
// Total = 450, budget = 200
|
||||
// Pinned = 50, remaining budget = 150 → keep newest msg (100), not enough for old msgs
|
||||
trimmed := TrimContext(messages, 200)
|
||||
|
||||
totalTokens := 0
|
||||
for _, m := range trimmed {
|
||||
totalTokens += m.TokenCount
|
||||
}
|
||||
if totalTokens > 200 {
|
||||
t.Errorf("trimmed context exceeds budget: %d > 200", totalTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinnedByEventType(t *testing.T) {
|
||||
if !IsPinnedEvent("injection_detected") {
|
||||
t.Error("injection_detected should be pinned")
|
||||
}
|
||||
if !IsPinnedEvent("credential_access_blocked") {
|
||||
t.Error("credential_access_blocked should be pinned")
|
||||
}
|
||||
if !IsPinnedEvent("genai_credential_access") {
|
||||
t.Error("genai_credential_access should be pinned")
|
||||
}
|
||||
if IsPinnedEvent("normal_chat") {
|
||||
t.Error("normal_chat should NOT be pinned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimContextWithinBudget(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "hello", TokenCount: 50},
|
||||
{Role: "assistant", Content: "hi", TokenCount: 50},
|
||||
}
|
||||
// Within budget — no trimming
|
||||
trimmed := TrimContext(messages, 1000)
|
||||
if len(trimmed) != 2 {
|
||||
t.Errorf("expected 2 messages (within budget), got %d", len(trimmed))
|
||||
}
|
||||
}
|
||||
79
internal/domain/identity/memory.go
Normal file
79
internal/domain/identity/memory.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package identity
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// NamespacedMemory wraps any key-value store with agent-level namespace isolation.
|
||||
// Agent A cannot read/write/query Agent B's memory (SDD-003 M4).
|
||||
type NamespacedMemory struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]interface{} // "agentID::key" → value
|
||||
}
|
||||
|
||||
// NewNamespacedMemory creates a new namespaced memory store.
|
||||
func NewNamespacedMemory() *NamespacedMemory {
|
||||
return &NamespacedMemory{
|
||||
entries: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// namespacedKey creates the internal key: "agentID::userKey".
|
||||
func namespacedKey(agentID, key string) string {
|
||||
return fmt.Sprintf("%s::%s", agentID, key)
|
||||
}
|
||||
|
||||
// Store stores a value within the agent's namespace.
|
||||
func (n *NamespacedMemory) Store(agentID, key string, value interface{}) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.entries[namespacedKey(agentID, key)] = value
|
||||
}
|
||||
|
||||
// Get retrieves a value from the agent's own namespace.
|
||||
// Returns nil, false if the key doesn't exist.
|
||||
func (n *NamespacedMemory) Get(agentID, key string) (interface{}, bool) {
|
||||
n.mu.RLock()
|
||||
defer n.mu.RUnlock()
|
||||
val, ok := n.entries[namespacedKey(agentID, key)]
|
||||
return val, ok
|
||||
}
|
||||
|
||||
// Delete removes a value from the agent's own namespace.
|
||||
func (n *NamespacedMemory) Delete(agentID, key string) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
delete(n.entries, namespacedKey(agentID, key))
|
||||
}
|
||||
|
||||
// Keys returns all keys within the agent's namespace (without the namespace prefix).
|
||||
func (n *NamespacedMemory) Keys(agentID string) []string {
|
||||
n.mu.RLock()
|
||||
defer n.mu.RUnlock()
|
||||
|
||||
prefix := agentID + "::"
|
||||
var keys []string
|
||||
for k := range n.entries {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
keys = append(keys, k[len(prefix):])
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// Count returns the number of entries in the agent's namespace.
|
||||
func (n *NamespacedMemory) Count(agentID string) int {
|
||||
n.mu.RLock()
|
||||
defer n.mu.RUnlock()
|
||||
|
||||
prefix := agentID + "::"
|
||||
count := 0
|
||||
for k := range n.entries {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
109
internal/domain/identity/pinning.go
Normal file
109
internal/domain/identity/pinning.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package identity
|
||||
|
||||
// Context-aware trimming with security event pinning (SDD-003 M5).
|
||||
//
|
||||
// Security events are pinned in context and exempt from trimming
|
||||
// when the context window overflows. This prevents attackers from
|
||||
// waiting for security events to be evicted.
|
||||
|
||||
// Message represents a context window message.
|
||||
type Message struct {
|
||||
Role string `json:"role"` // "user", "assistant", "system", "security"
|
||||
Content string `json:"content"`
|
||||
TokenCount int `json:"token_count"`
|
||||
IsPinned bool `json:"is_pinned"` // Security events are pinned
|
||||
EventType string `json:"event_type,omitempty"` // For security messages
|
||||
}
|
||||
|
||||
// PinnedEventTypes are security events that MUST NOT be trimmed from context.
|
||||
var PinnedEventTypes = map[string]bool{
|
||||
"permission_denied": true,
|
||||
"injection_detected": true,
|
||||
"circuit_breaker_open": true,
|
||||
"credential_access_blocked": true,
|
||||
"exfiltration_attempt": true,
|
||||
"ssrf_blocked": true,
|
||||
"genai_credential_access": true,
|
||||
"genai_persistence": true,
|
||||
}
|
||||
|
||||
// IsPinnedEvent returns true if the event type should be pinned (never trimmed).
|
||||
func IsPinnedEvent(eventType string) bool {
|
||||
return PinnedEventTypes[eventType]
|
||||
}
|
||||
|
||||
// TrimContext trims context messages to fit within maxTokens,
|
||||
// preserving all pinned security events.
|
||||
//
|
||||
// Algorithm:
|
||||
// 1. Separate pinned and unpinned messages
|
||||
// 2. Calculate token budget remaining after pinned messages
|
||||
// 3. Trim unpinned messages (oldest first) to fit budget
|
||||
// 4. Merge: pinned messages in original positions + surviving unpinned
|
||||
func TrimContext(messages []Message, maxTokens int) []Message {
|
||||
if len(messages) == 0 {
|
||||
return messages
|
||||
}
|
||||
|
||||
// Calculate total tokens
|
||||
totalTokens := 0
|
||||
for _, m := range messages {
|
||||
totalTokens += m.TokenCount
|
||||
}
|
||||
|
||||
// If within budget, return as-is
|
||||
if totalTokens <= maxTokens {
|
||||
return messages
|
||||
}
|
||||
|
||||
// Separate pinned and unpinned, preserving original indices
|
||||
type indexedMsg struct {
|
||||
idx int
|
||||
msg Message
|
||||
}
|
||||
var pinned, unpinned []indexedMsg
|
||||
pinnedTokens := 0
|
||||
|
||||
for i, m := range messages {
|
||||
if m.IsPinned || IsPinnedEvent(m.EventType) {
|
||||
pinned = append(pinned, indexedMsg{i, m})
|
||||
pinnedTokens += m.TokenCount
|
||||
} else {
|
||||
unpinned = append(unpinned, indexedMsg{i, m})
|
||||
}
|
||||
}
|
||||
|
||||
// Budget for unpinned messages
|
||||
remainingBudget := maxTokens - pinnedTokens
|
||||
if remainingBudget < 0 {
|
||||
remainingBudget = 0
|
||||
}
|
||||
|
||||
// Trim unpinned from the beginning (oldest first)
|
||||
var survivingUnpinned []indexedMsg
|
||||
usedTokens := 0
|
||||
// Keep messages from the END (newest) that fit
|
||||
for i := len(unpinned) - 1; i >= 0; i-- {
|
||||
if usedTokens + unpinned[i].msg.TokenCount <= remainingBudget {
|
||||
survivingUnpinned = append([]indexedMsg{unpinned[i]}, survivingUnpinned...)
|
||||
usedTokens += unpinned[i].msg.TokenCount
|
||||
}
|
||||
}
|
||||
|
||||
// Merge by original index order
|
||||
all := append(pinned, survivingUnpinned...)
|
||||
// Sort by original index
|
||||
for i := 0; i < len(all); i++ {
|
||||
for j := i + 1; j < len(all); j++ {
|
||||
if all[j].idx < all[i].idx {
|
||||
all[i], all[j] = all[j], all[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]Message, len(all))
|
||||
for i, im := range all {
|
||||
result[i] = im.msg
|
||||
}
|
||||
return result
|
||||
}
|
||||
99
internal/domain/identity/store.go
Normal file
99
internal/domain/identity/store.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package identity
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Store manages AgentIdentity CRUD operations.
|
||||
// Thread-safe for concurrent access from multiple goroutines.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
agents map[string]*AgentIdentity // agent_id → identity
|
||||
}
|
||||
|
||||
// NewStore creates a new in-memory identity store.
|
||||
func NewStore() *Store {
|
||||
return &Store{
|
||||
agents: make(map[string]*AgentIdentity),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a new agent identity to the store.
|
||||
// Returns ErrAgentExists if the agent_id is already registered.
|
||||
func (s *Store) Register(agent *AgentIdentity) error {
|
||||
if err := agent.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, exists := s.agents[agent.AgentID]; exists {
|
||||
return ErrAgentExists
|
||||
}
|
||||
|
||||
if agent.CreatedAt.IsZero() {
|
||||
agent.CreatedAt = time.Now()
|
||||
}
|
||||
agent.LastSeenAt = time.Now()
|
||||
s.agents[agent.AgentID] = agent
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves an agent identity by ID.
|
||||
// Returns ErrAgentNotFound if the agent doesn't exist.
|
||||
func (s *Store) Get(agentID string) (*AgentIdentity, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
agent, ok := s.agents[agentID]
|
||||
if !ok {
|
||||
return nil, ErrAgentNotFound
|
||||
}
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
// UpdateLastSeen updates the last_seen_at timestamp for an agent.
|
||||
func (s *Store) UpdateLastSeen(agentID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
agent, ok := s.agents[agentID]
|
||||
if !ok {
|
||||
return ErrAgentNotFound
|
||||
}
|
||||
agent.LastSeenAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes an agent identity from the store.
|
||||
func (s *Store) Remove(agentID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.agents[agentID]; !ok {
|
||||
return ErrAgentNotFound
|
||||
}
|
||||
delete(s.agents, agentID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all registered agent identities.
|
||||
func (s *Store) List() []*AgentIdentity {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make([]*AgentIdentity, 0, len(s.agents))
|
||||
for _, agent := range s.agents {
|
||||
result = append(result, agent)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Count returns the number of registered agents.
|
||||
func (s *Store) Count() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.agents)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue