diff --git a/README.md b/README.md index c3411339..0d16215c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# FlowGuard (Go Port) +# MCP Gateway -A simplified Go port of FlowGuard - a proxy server for Model Context Protocol (MCP) servers. +A Go port of FlowGuard - a gateway for Model Context Protocol (MCP) servers. ## Features @@ -252,18 +252,65 @@ Supported JSON-RPC 2.0 methods: - `tools/call` - Call a tool with parameters - Any other MCP method (forwarded as-is) -## Architecture Simplifications +## Architecture -This Go port focuses on core MCP proxy functionality: +This Go port focuses on core MCP proxy functionality with optional security features: + +### Core Features (Enabled) - ✅ TOML and JSON stdin configuration - ✅ Stdio transport for backend servers - ✅ Docker container launching - ✅ Routed and unified modes - ✅ Basic request/response proxying -- ❌ DIFC enforcement (removed) -- ❌ Sub-agents (removed) -- ❌ Guards (removed) + +### DIFC Integration (Not Yet Enabled) + +FlowGuard includes a complete implementation of **Decentralized Information Flow Control (DIFC)** for information security, but it is **not yet enabled by default**. The DIFC system provides: + +- **Label-based Security**: Track information flow with secrecy and integrity labels +- **Reference Monitor**: Centralized policy enforcement for all MCP operations +- **Guard Framework**: Domain-specific resource labeling (e.g., GitHub repos, files) +- **Agent Tracking**: Per-agent taint tracking across requests +- **Fine-grained Control**: Collection filtering for partial access to resources + +#### DIFC Components (Implemented) + +``` +internal/difc/ +├── labels.go # Secrecy/integrity labels with flow semantics +├── resource.go # Resource labeling (coarse & fine-grained) +├── evaluator.go # DIFC policy evaluation & enforcement +├── agent.go # Per-agent label tracking (taint tracking) +└── capabilities.go # Global tag registry + +internal/guard/ +├── guard.go # Guard interface definition +├── noop.go # NoopGuard (default, allows all operations) +├── registry.go # Guard registration & lookup +└── context.go # Agent ID extraction utilities +``` + +#### How DIFC Works (When Enabled) + +1. **Resource Labeling**: Guards label resources based on domain knowledge (e.g., "repo:owner/name", "visibility:private") +2. **Agent Tracking**: Each agent has secrecy/integrity labels that accumulate through reads (taint tracking) +3. **Policy Enforcement**: Reference Monitor checks if operations violate label flow semantics: + - **Read**: Resource secrecy must flow to agent secrecy (resource ⊆ agent) + - **Write**: Agent integrity must flow to resource integrity (agent ⊆ resource) +4. **Fine-grained Filtering**: Collections (e.g., search results) automatically filtered to allowed items + +#### Enabling DIFC (Future) + +To enable DIFC enforcement, you'll need to: + +1. **Implement domain-specific guards** (e.g., GitHub, filesystem) +2. **Configure agent labels** in `config.toml` +3. **Register guards** in server initialization + +See [`docs/DIFC_INTEGRATION_PROPOSAL.md`](docs/DIFC_INTEGRATION_PROPOSAL.md) for full design details. + +**Current Status**: All DIFC infrastructure is implemented and tested, but only the `NoopGuard` is active (which returns empty labels, effectively disabling enforcement). Custom guards for specific backends (GitHub, filesystem, etc.) are not yet implemented. ## Development diff --git a/docs/DIFC_INTEGRATION_PROPOSAL.md b/docs/DIFC_INTEGRATION_PROPOSAL.md new file mode 100644 index 00000000..7a9397ad --- /dev/null +++ b/docs/DIFC_INTEGRATION_PROPOSAL.md @@ -0,0 +1,1347 @@ +# DIFC Integration Proposal for FlowGuard-Go + +## Overview + +This document proposes an approach to integrate Decentralized Information Flow Control (DIFC) checks and labeling into the Go implementation of FlowGuard, following the patterns established in the Rust implementation. + +## Core Concepts + +### DIFC Labels +- **Secrecy Labels**: Control information disclosure (who can read data) +- **Integrity Labels**: Control information trust (who can write/influence data) +- **Resources**: Represent external systems with their own label requirements + +### Guard Pattern +- Each MCP server has an associated **Guard** that understands its domain +- Guards **ONLY label** resources and response data - they do NOT make access control decisions +- The **Reference Monitor** (in the server) uses guard-provided labels to enforce DIFC policies +- Reference Monitor decides whether operations are allowed and filters response data +- Default to a **NoopGuard** for servers without custom guards + +## Architecture + +### 1. Package Structure + +``` +flowguard-go/ +├── internal/ +│ ├── difc/ # DIFC label system +│ │ ├── labels.go # Label types and operations +│ │ ├── resource.go # Resource representation +│ │ └── capabilities.go # Global capabilities +│ ├── guard/ # Guard framework +│ │ ├── guard.go # Guard interface and types +│ │ ├── noop.go # Default noop guard +│ │ ├── registry.go # Guard registration +│ │ └── context.go # DIFC context per request +│ ├── guards/ # Specific guard implementations +│ │ ├── github/ # GitHub MCP guard +│ │ │ ├── guard.go +│ │ │ ├── requests.go +│ │ │ └── policy.go +│ │ └── ... # Other guards +│ └── server/ +│ ├── unified.go # Integrate DIFC checks +│ └── routed.go # Integrate DIFC checks +``` + +### 2. Core Interfaces + +#### Guard Interface + +```go +package guard + +import ( + "context" + "github.com/githubnext/gh-aw-mcpg/internal/difc" + sdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// BackendCaller provides a way for guards to make read-only calls to the backend +// to gather information needed for labeling (e.g., fetching issue author) +type BackendCaller interface { + // CallTool makes a read-only call to the backend MCP server + // This is used by guards to gather metadata for labeling + CallTool(ctx context.Context, toolName string, args interface{}) (*sdk.CallToolResult, error) +} + +// Guard handles DIFC labeling for a specific MCP server +// Guards ONLY label resources - they do NOT make access control decisions +type Guard interface { + // Name returns the identifier for this guard (e.g., "github", "noop") + Name() string + + // LabelResource determines the resource being accessed and its labels + // This may call the backend (via BackendCaller) to gather metadata needed for labeling + // Returns: + // - resource: The labeled resource (simple or nested structure for fine-grained filtering) + // - operation: The type of operation (Read, Write, or ReadWrite) + LabelResource(ctx context.Context, req *sdk.CallToolRequest, backend BackendCaller, caps *difc.Capabilities) (*difc.LabeledResource, difc.OperationType, error) +} + +// OperationType indicates the nature of the resource access +type OperationType int + +const ( + OperationRead OperationType = iota + OperationWrite + OperationReadWrite +) +``` + +#### DIFC Label System + +```go +package difc + +import "sync" + +// Tag represents a single tag (e.g., "repo:owner/name", "agent:demo-agent") +type Tag string + +// Label represents a set of DIFC tags +type Label struct { + tags map[Tag]struct{} + mu sync.RWMutex +} + +// Resource represents an external system with label requirements (deprecated - use LabeledResource) +type Resource struct { + Description string // Human-readable description of what this resource represents + Secrecy SecrecyLabel + Integrity IntegrityLabel +} + +// LabeledResource represents a resource with DIFC labels +// This can be a simple label pair or a complex nested structure for fine-grained filtering +type LabeledResource struct { + Description string // Human-readable description of the resource + Secrecy SecrecyLabel // Secrecy requirements for this resource + Integrity IntegrityLabel // Integrity requirements for this resource + + // Structure is an optional nested map for fine-grained labeling of response fields + // Maps JSON paths to their labels (e.g., "items[*].private" -> specific labels) + // If nil, labels apply uniformly to entire resource + Structure *ResourceStructure +} + +// ResourceStructure defines fine-grained labels for nested data structures +type ResourceStructure struct { + // Fields maps field names/paths to their labels + // For collections, use "items[*]" to indicate per-item labeling + Fields map[string]*FieldLabels +} + +// FieldLabels defines labels for a specific field in the response +type FieldLabels struct { + Secrecy *SecrecyLabel + Integrity *IntegrityLabel + + // Predicate is an optional function to determine labels based on field value + // For example: label repo as private if repo.Private == true + Predicate func(value interface{}) (*SecrecyLabel, *IntegrityLabel) +} + +// Capabilities represents the global set of tags available to agents +type Capabilities struct { + tags map[Tag]struct{} + mu sync.RWMutex +} + +// AccessDecision represents the result of a DIFC evaluation +type AccessDecision int + +const ( + AccessAllow AccessDecision = iota + AccessDeny +) + +// EvaluationResult contains the decision and required label changes +type EvaluationResult struct { + Decision AccessDecision + SecrecyToAdd []Tag // Secrecy tags agent must add to proceed + IntegrityToDrop []Tag // Integrity tags agent must drop to proceed + Reason string // Human-readable reason for denial +} + +// Evaluator performs DIFC policy evaluation +type Evaluator struct{} + +// Evaluate checks if an agent can perform an operation on a resource +func (e *Evaluator) Evaluate( + agent *AgentLabels, + resource *LabeledResource, + operation OperationType, +) *EvaluationResult { + result := &EvaluationResult{ + Decision: AccessAllow, + SecrecyToAdd: []Tag{}, + IntegrityToDrop: []Tag{}, + } + + switch operation { + case OperationRead: + // For reads: resource integrity must flow to agent (trust check) + ok, missingTags := resource.Integrity.CheckFlow(agent.Integrity) + if !ok { + result.Decision = AccessDeny + result.IntegrityToDrop = missingTags + result.Reason = fmt.Sprintf("Resource '%s' has lower integrity than agent requires", resource.Description) + return result + } + + // For reads: check if agent can handle resource's secrecy + ok, extraTags := agent.Secrecy.CheckFlow(&resource.Secrecy) + if !ok { + result.Decision = AccessDeny + result.SecrecyToAdd = extraTags + result.Reason = fmt.Sprintf("Resource '%s' requires additional secrecy handling", resource.Description) + return result + } + + case OperationWrite: + // For writes: agent integrity must flow to resource (agent must be trustworthy enough) + ok, missingTags := agent.Integrity.CheckFlow(&resource.Integrity) + if !ok { + result.Decision = AccessDeny + result.IntegrityToDrop = missingTags + result.Reason = fmt.Sprintf("Agent lacks required integrity to write to '%s'", resource.Description) + return result + } + + // For writes: agent secrecy must flow to resource secrecy + ok, extraTags := agent.Secrecy.CheckFlow(&resource.Secrecy) + if !ok { + result.Decision = AccessDeny + result.SecrecyToAdd = extraTags + result.Reason = fmt.Sprintf("Agent has secrecy tags that cannot flow to '%s'", resource.Description) + return result + } + + case OperationReadWrite: + // For read-write, must satisfy both read and write constraints + readResult := e.Evaluate(agent, resource, OperationRead) + if readResult.Decision == AccessDeny { + return readResult + } + + writeResult := e.Evaluate(agent, resource, OperationWrite) + if writeResult.Decision == AccessDeny { + return writeResult + } + } + + return result +} + +// FormatViolationError creates a detailed error message explaining the violation and its implications +func FormatViolationError(result *EvaluationResult, agent *AgentLabels, resource *LabeledResource) error { + if result.Decision == AccessAllow { + return nil + } + + var msg strings.Builder + msg.WriteString(fmt.Sprintf("DIFC Violation: %s\n\n", result.Reason)) + + if len(result.SecrecyToAdd) > 0 { + msg.WriteString(fmt.Sprintf("Required Action: Add secrecy tags %v\n", result.SecrecyToAdd)) + msg.WriteString("\nImplications of adding secrecy tags:\n") + msg.WriteString(" - Agent will be restricted from writing to resources that lack these tags\n") + msg.WriteString(" - This includes public resources (e.g., public repositories, public internet)\n") + msg.WriteString(" - Agent will be marked as handling sensitive information\n") + msg.WriteString(fmt.Sprintf(" - Future writes must target resources with tags: %v\n", result.SecrecyToAdd)) + } + + if len(result.IntegrityToDrop) > 0 { + msg.WriteString(fmt.Sprintf("\nRequired Action: Drop integrity tags %v\n", result.IntegrityToDrop)) + msg.WriteString("\nImplications of dropping integrity tags:\n") + msg.WriteString(" - Agent will no longer be able to write to high-integrity resources\n") + msg.WriteString(fmt.Sprintf(" - Specifically, agent cannot write to resources requiring tags: %v\n", result.IntegrityToDrop)) + msg.WriteString(" - This action acknowledges that agent has been influenced by lower-integrity data\n") + msg.WriteString(" - Agent's outputs will be considered less trustworthy\n") + } + + msg.WriteString("\nCurrent Agent Labels:\n") + msg.WriteString(fmt.Sprintf(" Secrecy: %v\n", agent.Secrecy.GetTags())) + msg.WriteString(fmt.Sprintf(" Integrity: %v\n", agent.Integrity.GetTags())) + + msg.WriteString("\nResource Requirements:\n") + msg.WriteString(fmt.Sprintf(" Secrecy: %v\n", resource.Secrecy.GetTags())) + msg.WriteString(fmt.Sprintf(" Integrity: %v\n", resource.Integrity.GetTags())) + + return errors.New(msg.String()) +} + +// Methods for creating and manipulating labels +func NewLabel() *Label { + return &Label{tags: make(map[Tag]struct{})} +} + +func (l *Label) Add(tag Tag) { + l.mu.Lock() + defer l.mu.Unlock() + l.tags[tag] = struct{}{} +} + +func (l *Label) Contains(tag Tag) bool { + l.mu.RLock() + defer l.mu.RUnlock() + _, ok := l.tags[tag] + return ok +} + +func (l *Label) Union(other *Label) { + other.mu.RLock() + defer other.mu.RUnlock() + l.mu.Lock() + defer l.mu.Unlock() + for tag := range other.tags { + l.tags[tag] = struct{}{} + } +} + +func (l *Label) Clone() *Label { + l.mu.RLock() + defer l.mu.RUnlock() + newLabel := NewLabel() + for tag := range l.tags { + newLabel.tags[tag] = struct{}{} + } + return newLabel +} + +// SecrecyLabel wraps Label with secrecy-specific flow semantics +type SecrecyLabel struct { + *Label +} + +// IntegrityLabel wraps Label with integrity-specific flow semantics +type IntegrityLabel struct { + *Label +} + +// NewSecrecyLabel creates a new secrecy label +func NewSecrecyLabel() *SecrecyLabel { + return &SecrecyLabel{Label: NewLabel()} +} + +// NewIntegrityLabel creates a new integrity label +func NewIntegrityLabel() *IntegrityLabel { + return &IntegrityLabel{Label: NewLabel()} +} + +// CanFlowTo checks if this secrecy label can flow to target +// Secrecy semantics: l ⊆ target (this has no tags that target doesn't have) +// Data can only flow to contexts with equal or more secrecy tags +func (l *SecrecyLabel) CanFlowTo(target *SecrecyLabel) bool { + l.mu.RLock() + defer l.mu.RUnlock() + target.mu.RLock() + defer target.mu.RUnlock() + + // Check if all tags in l are in target + for tag := range l.tags { + if _, ok := target.tags[tag]; !ok { + return false + } + } + return true +} + +// CheckFlow checks if this secrecy label can flow to target and returns violation details if not +func (l *SecrecyLabel) CheckFlow(target *SecrecyLabel) (bool, []Tag) { + l.mu.RLock() + defer l.mu.RUnlock() + target.mu.RLock() + defer target.mu.RUnlock() + + var extraTags []Tag + // Check if all tags in l are in target + for tag := range l.tags { + if _, ok := target.tags[tag]; !ok { + extraTags = append(extraTags, tag) + } + } + + return len(extraTags) == 0, extraTags +} + +// GetTags returns all tags in this label +func (l *SecrecyLabel) GetTags() []Tag { + l.mu.RLock() + defer l.mu.RUnlock() + + tags := make([]Tag, 0, len(l.tags)) + for tag := range l.tags { + tags = append(tags, tag) + } + return tags +} + +// CanFlowTo checks if this integrity label can flow to target +// Integrity semantics: l ⊇ target (this has all tags that target has) +// For writes: agent must have >= integrity than endpoint +// For reads: endpoint must have >= integrity than agent +func (l *IntegrityLabel) CanFlowTo(target *IntegrityLabel) bool { + l.mu.RLock() + defer l.mu.RUnlock() + target.mu.RLock() + defer target.mu.RUnlock() + + // Check if all tags in target are in l + for tag := range target.tags { + if _, ok := l.tags[tag]; !ok { + return false + } + } + return true +} + +// CheckFlow checks if this integrity label can flow to target and returns violation details if not +func (l *IntegrityLabel) CheckFlow(target *IntegrityLabel) (bool, []Tag) { + l.mu.RLock() + defer l.mu.RUnlock() + target.mu.RLock() + defer target.mu.RUnlock() + + var missingTags []Tag + // Check if all tags in target are in l + for tag := range target.tags { + if _, ok := l.tags[tag]; !ok { + missingTags = append(missingTags, tag) + } + } + + return len(missingTags) == 0, missingTags +} + +// GetTags returns all tags in this label +func (l *IntegrityLabel) GetTags() []Tag { + l.mu.RLock() + defer l.mu.RUnlock() + + tags := make([]Tag, 0, len(l.tags)) + for tag := range l.tags { + tags = append(tags, tag) + } + return tags +} + +// Clone creates a copy of the secrecy label +func (l *SecrecyLabel) Clone() *SecrecyLabel { + return &SecrecyLabel{Label: l.Label.Clone()} +} + +// Clone creates a copy of the integrity label +func (l *IntegrityLabel) Clone() *IntegrityLabel { + return &IntegrityLabel{Label: l.Label.Clone()} +} + +// NewResource creates a new resource with the given description +func NewResource(description string) *Resource { + return &Resource{ + Description: description, + Secrecy: *NewSecrecyLabel(), + Integrity: *NewIntegrityLabel(), + } +} + +// Empty returns a resource with no label requirements +func (r *Resource) Empty() *Resource { + return &Resource{ + Description: "empty resource", + secrecy: *NewSecrecyLabel(), + integrity: *NewIntegrityLabel(), + } +} + +// ViolationType indicates what kind of DIFC violation occurred +type ViolationType string + +const ( + SecrecyViolation ViolationType = "secrecy" + IntegrityViolation ViolationType = "integrity" +) + +// ViolationError provides detailed information about a DIFC violation +type ViolationError struct { + Type ViolationType + Resource string // Resource description + IsWrite bool // true for write, false for read + MissingTags []Tag // Tags the agent needs but doesn't have + ExtraTags []Tag // Tags the agent has but shouldn't + AgentTags []Tag // All agent tags (for context) + ResourceTags []Tag // All resource tags (for context) +} + +func (e *ViolationError) Error() string { + var msg string + + if e.Type == SecrecyViolation { + msg = fmt.Sprintf("Secrecy violation for resource '%s': ", e.Resource) + if len(e.ExtraTags) > 0 { + msg += fmt.Sprintf("agent has secrecy tags %v that cannot flow to resource. ", e.ExtraTags) + msg += fmt.Sprintf("Remediation: remove these tags from agent's secrecy label or add them to the resource's secrecy requirements.") + } + } else { + if e.IsWrite { + msg = fmt.Sprintf("Integrity violation for write to resource '%s': ", e.Resource) + if len(e.MissingTags) > 0 { + msg += fmt.Sprintf("agent is missing required integrity tags %v. ", e.MissingTags) + msg += fmt.Sprintf("Remediation: agent must gain integrity tags %v to write to this resource.", e.MissingTags) + } + } else { + msg = fmt.Sprintf("Integrity violation for read from resource '%s': ", e.Resource) + if len(e.MissingTags) > 0 { + msg += fmt.Sprintf("resource is missing integrity tags %v that agent requires. ", e.MissingTags) + msg += fmt.Sprintf("Remediation: agent should drop integrity tags %v to trust this resource, or verify resource has higher integrity.", e.MissingTags) + } + } + } + + return msg +} + +// Detailed returns a detailed error message with full context +func (e *ViolationError) Detailed() string { + msg := e.Error() + msg += fmt.Sprintf("\n Agent %s tags: %v", e.Type, e.AgentTags) + msg += fmt.Sprintf("\n Resource %s tags: %v", e.Type, e.ResourceTags) + return msg +} +``` + +#### Agent Labels + +```go +// AgentLabels associates each agent with their DIFC labels +type AgentLabels struct { + AgentID string + Secrecy *SecrecyLabel + Integrity *IntegrityLabel +} + +// AgentRegistry manages agent labels +type AgentRegistry struct { + agents map[string]*AgentLabels + mu sync.RWMutex +} + +func (r *AgentRegistry) GetOrCreate(agentID string) *AgentLabels { + r.mu.Lock() + defer r.mu.Unlock() + + if labels, ok := r.agents[agentID]; ok { + return labels + } + + // Initialize new agent with empty labels + labels := &AgentLabels{ + AgentID: agentID, + Secrecy: NewSecrecyLabel(), + Integrity: NewIntegrityLabel(), + } + r.agents[agentID] = labels + return labels +} +``` + +### 3. Noop Guard Implementation + +```go +package guard + +import ( + "context" + "github.com/githubnext/gh-aw-mcpg/internal/difc" + sdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// NoopGuard is the default guard that performs no DIFC labeling +type NoopGuard struct{} + +func NewNoopGuard() *NoopGuard { + return &NoopGuard{} +} + +func (g *NoopGuard) Name() string { + return "noop" +} + +func (g *NoopGuard) LabelResource(ctx context.Context, req *sdk.CallToolRequest, backend BackendCaller, caps *difc.Capabilities) (*difc.LabeledResource, difc.OperationType, error) { + // Empty resource = no label requirements + // Conservatively assume all operations are writes + resource := &difc.LabeledResource{ + Description: "noop resource (no restrictions)", + Secrecy: *difc.NewSecrecyLabel(), + Integrity: *difc.NewIntegrityLabel(), + Structure: nil, // No fine-grained labeling + } + return resource, difc.OperationWrite, nil +} +``` + +### 4. GitHub Guard Example + +```go +package github + +import ( + "context" + "encoding/json" + "fmt" + "github.com/githubnext/gh-aw-mcpg/internal/difc" + "github.com/githubnext/gh-aw-mcpg/internal/guard" + sdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type GitHubGuard struct{} + +func NewGitHubGuard() *GitHubGuard { + return &GitHubGuard{} +} + +func (g *GitHubGuard) Name() string { + return "github" +} + +func (g *GitHubGuard) LabelRequest(ctx context.Context, req *sdk.CallToolRequest, backend BackendCaller, caps *difc.Capabilities) (*difc.Resource, bool, guard.RequestState, error) { + // Parse based on tool name + switch req.Params.Name { + case "get_file_contents": + return g.labelGetFileContentsRequest(ctx, req, backend, caps) + case "get_issue": + return g.labelGetIssueRequest(ctx, req, backend, caps) + case "list_repos": + return g.labelListReposRequest(ctx, req, backend, caps) + case "push_files": + return g.labelPushFilesRequest(ctx, req, backend, caps) + // ... other tools + default: + // Unknown tool, treat as write with basic repo labeling + return g.labelUnknownToolRequest(ctx, req, backend, caps) + } +} + +func (g *GitHubGuard) labelGetFileContentsRequest(ctx context.Context, req *sdk.CallToolRequest, backend BackendCaller, caps *difc.Capabilities) (*difc.Resource, bool, guard.RequestState, error) { + // Parse request args + var args struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Path string `json:"path"` + Ref string `json:"ref"` + } + if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { + return nil, false, nil, err + } + + repoName := fmt.Sprintf("%s/%s", args.Owner, args.Repo) + resource := difc.NewResource(fmt.Sprintf("GitHub file %s in %s@%s", args.Path, repoName, args.Ref)) + + // Add repo tag - data comes from this repository + repoTag := difc.Tag(fmt.Sprintf("repo:%s", repoName)) + resource.Integrity.Add(repoTag) + + // Could optionally call backend here to check if file contains secrets, etc. + // and add additional secrecy labels + + return resource, false, args, nil +} + +func (g *GitHubGuard) labelGetIssueRequest(ctx context.Context, req *sdk.CallToolRequest, backend BackendCaller, caps *difc.Capabilities) (*difc.Resource, bool, guard.RequestState, error) { + // Parse request args + var args struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"issue_number"` + } + if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { + return nil, false, nil, err + } + + repoName := fmt.Sprintf("%s/%s", args.Owner, args.Repo) + + // **KEY POINT**: Call backend to get issue metadata for labeling + // This happens BEFORE the DIFC check, but we only fetch metadata, not the full content + issueResp, err := backend.CallTool(ctx, "get_issue", map[string]interface{}{ + "owner": args.Owner, + "repo": args.Repo, + "issue_number": args.Number, + }) + if err != nil { + return nil, false, nil, fmt.Errorf("failed to fetch issue metadata: %w", err) + } + + // Parse issue to extract author + var issue struct { + Author string `json:"author"` + Title string `json:"title"` + } + if err := json.Unmarshal(issueResp.Content, &issue); err != nil { + return nil, false, nil, err + } + + resource := difc.NewResource(fmt.Sprintf("GitHub issue #%d in %s", args.Number, repoName)) + + // Label with repo and author + repoTag := difc.Tag(fmt.Sprintf("repo:%s", repoName)) + authorTag := difc.Tag(fmt.Sprintf("user:%s", issue.Author)) + resource.Integrity.Add(repoTag) + resource.Integrity.Add(authorTag) + + // Cache the issue data in state so we don't fetch it again + state := &IssueRequestState{ + Args: args, + IssueData: issueResp, + } + + return resource, false, state, nil +} + +func (g *GitHubGuard) labelListReposRequest(ctx context.Context, req *sdk.CallToolRequest, backend BackendCaller, caps *difc.Capabilities) (*difc.Resource, bool, guard.RequestState, error) { + var args struct { + Username string `json:"username"` + } + if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { + return nil, false, nil, err + } + + // For list operations, the resource represents the query itself, not individual items + // Individual items will be labeled and filtered by the reference monitor + resource := difc.NewResource(fmt.Sprintf("GitHub repositories for user %s", args.Username)) + + // No specific integrity requirements for listing (but items may be filtered) + return resource, false, args, nil +} + +func (g *GitHubGuard) labelPushFilesRequest(ctx context.Context, req *sdk.CallToolRequest, backend BackendCaller, caps *difc.Capabilities) (*difc.Resource, bool, guard.RequestState, error) { + var args struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Branch string `json:"branch"` + } + if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { + return nil, false, nil, err + } + + repoName := fmt.Sprintf("%s/%s", args.Owner, args.Repo) + resource := difc.NewResource(fmt.Sprintf("GitHub repository %s (branch %s)", repoName, args.Branch)) + + // Writing requires repo integrity + repoTag := difc.Tag(fmt.Sprintf("repo:%s", repoName)) + resource.Integrity.Add(repoTag) + + return resource, true, args, nil +} + +type IssueRequestState struct { + Args interface{} + IssueData *sdk.CallToolResult // Cached issue data from labeling phase +} + +// LabelResponse labels the data returned from the backend +func (g *GitHubGuard) LabelResponse(ctx context.Context, resp *sdk.CallToolResult, resource *difc.Resource, state guard.RequestState) (*guard.LabeledData, error) { + // Check if we cached the response during request labeling + if issueState, ok := state.(*IssueRequestState); ok && issueState.IssueData != nil { + // We already fetched the issue during labeling, use cached data + resp = issueState.IssueData + } + + // Check if this is a list_repos response (collection that can be filtered) + if listReposArgs, ok := state.(struct{ Username string }); ok { + return g.labelListReposResponse(ctx, resp, listReposArgs.Username) + } + + // For single-item responses, return with resource labels + return &SimpleLabeledData{ + result: resp, + labels: &difc.Labels{ + Secrecy: resource.Secrecy.Clone(), + Integrity: resource.Integrity.Clone(), + }, + }, nil +} + +// labelListReposResponse creates a labeled collection for repository lists +func (g *GitHubGuard) labelListReposResponse(ctx context.Context, resp *sdk.CallToolResult, username string) (*guard.LabeledData, error) { + // Parse response to get list of repos + var repos []struct { + Name string `json:"name"` + Owner string `json:"owner"` + Private bool `json:"private"` + // ... other fields + } + + if err := json.Unmarshal(resp.Content, &repos); err != nil { + return nil, fmt.Errorf("failed to parse repos response: %w", err) + } + + // Create labeled items + items := make([]LabeledItem, len(repos)) + for i, repo := range repos { + repoName := fmt.Sprintf("%s/%s", repo.Owner, repo.Name) + + // Create labels for this specific repo + secrecy := difc.NewSecrecyLabel() + integrity := difc.NewIntegrityLabel() + + // Add repo tag + repoTag := difc.Tag(fmt.Sprintf("repo:%s", repoName)) + integrity.Add(repoTag) + + // Private repos need secrecy label + if repo.Private { + privateTag := difc.Tag(fmt.Sprintf("private:%s", repoName)) + secrecy.Add(privateTag) + } + + items[i] = LabeledItem{ + Data: repo, + Labels: &difc.Labels{ + Secrecy: secrecy, + Integrity: integrity, + }, + Description: fmt.Sprintf("repo %s (private=%v)", repoName, repo.Private), + } + } + + return &CollectionLabeledData{ + items: items, + }, nil +} + +// SimpleLabeledData represents non-collection data with uniform labels +type SimpleLabeledData struct { + result *sdk.CallToolResult + labels *difc.Labels +} + +func (d *SimpleLabeledData) Overall() *difc.Labels { + return d.labels +} + +func (d *SimpleLabeledData) IsCollection() bool { + return false +} + +func (d *SimpleLabeledData) FilterCollection(agentLabels *difc.Labels) (interface{}, []string, error) { + return nil, nil, fmt.Errorf("not a collection") +} + +func (d *SimpleLabeledData) ToResult() (*sdk.CallToolResult, error) { + return d.result, nil +} + +// LabeledItem represents a single item in a collection with its labels +type LabeledItem struct { + Data interface{} + Labels *difc.Labels + Description string // For audit logging +} + +// CollectionLabeledData represents a collection of individually labeled items +type CollectionLabeledData struct { + items []LabeledItem +} + +func (d *CollectionLabeledData) Overall() *difc.Labels { + // Union of all item labels + overall := &difc.Labels{ + Secrecy: difc.NewSecrecyLabel(), + Integrity: difc.NewIntegrityLabel(), + } + for _, item := range d.items { + overall.Secrecy.Union(item.Labels.Secrecy.Label) + overall.Integrity.Union(item.Labels.Integrity.Label) + } + return overall +} + +func (d *CollectionLabeledData) IsCollection() bool { + return true +} + +func (d *CollectionLabeledData) FilterCollection(agentLabels *difc.Labels) (interface{}, []string, error) { + var filtered []interface{} + var violations []string + + for _, item := range d.items { + // Check if agent can access this item + // For reads: item integrity must flow to agent (trust check) + canAccessIntegrity, _ := item.Labels.Integrity.CheckFlow(agentLabels.Integrity) + + // For reads: agent secrecy must flow to item secrecy (can the agent handle this data?) + canAccessSecrecy, _ := agentLabels.Secrecy.CheckFlow(item.Labels.Secrecy) + + if canAccessIntegrity && canAccessSecrecy { + filtered = append(filtered, item.Data) + } else { + violations = append(violations, item.Description) + } + } + + return filtered, violations, nil +} + +func (d *CollectionLabeledData) ToResult() (*sdk.CallToolResult, error) { + // Collect all item data + data := make([]interface{}, len(d.items)) + for i, item := range d.items { + data[i] = item.Data + } + + content, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to marshal collection: %w", err) + } + + return &sdk.CallToolResult{ + Content: content, + IsError: false, + }, nil +} +``` + +### 5. Integration into MCP Server + +```go +package server + +import ( + "context" + "fmt" + "log" + "github.com/githubnext/gh-aw-mcpg/internal/difc" + "github.com/githubnext/gh-aw-mcpg/internal/guard" + sdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type UnifiedServer struct { + // ... existing fields + + // DIFC additions + guards map[string]guard.Guard // serverID -> Guard + agentRegistry *difc.AgentRegistry + capabilities *difc.Capabilities +} + +func NewUnified(ctx context.Context, cfg *config.Config) (*UnifiedServer, error) { + // ... existing initialization + + us := &UnifiedServer{ + // ... existing fields + + guards: make(map[string]guard.Guard), + agentRegistry: difc.NewAgentRegistry(), + capabilities: difc.NewCapabilities(), + } + + // Register guards for each backend + for _, serverID := range cfg.ServerIDs() { + us.registerGuard(serverID) + } + + return us, nil +} + +func (us *UnifiedServer) registerGuard(serverID string) { + // Look up guard implementation, default to noop + var g guard.Guard + + switch serverID { + case "github": + g = github.NewGitHubGuard() + // ... other guards + default: + g = guard.NewNoopGuard() + log.Printf("No guard implementation for %s, using noop guard", serverID) + } + + us.guards[serverID] = g + log.Printf("Registered guard %s for server %s", g.Name(), serverID) +} + +// Modified tool call handler with DIFC checks +func (us *UnifiedServer) callBackendTool(ctx context.Context, serverID, toolName string, args interface{}) (*sdk.CallToolResult, interface{}, error) { + // Get agent ID from context (from Authorization header) + agentID := getAgentIDFromContext(ctx) + agentLabels := us.agentRegistry.GetOrCreate(agentID) + + // Get guard for this backend + g := us.guards[serverID] + + // Create MCP request + mcpReq := &sdk.CallToolRequest{ + Params: sdk.CallToolRequestParams{ + Name: toolName, + Arguments: args, + }, + } + + // Create backend caller for the guard + backendCaller := &guardBackendCaller{ + server: us, + serverID: serverID, + ctx: ctx, + } + + // **Phase 1: Label the request resource (guard labels, doesn't decide)** + resource, isWrite, state, err := g.LabelRequest(ctx, mcpReq, backendCaller, us.capabilities) + if err != nil { + return &sdk.CallToolResult{IsError: true}, nil, fmt.Errorf("failed to label request: %w", err) + } + + // **Phase 2: Reference monitor checks DIFC constraints (coarse-grained)** + if err := us.checkDIFCConstraints(agentLabels, resource, isWrite); err != nil { + return &sdk.CallToolResult{IsError: true}, nil, err + } + + // **Phase 3: Call backend (unless cached in state)** + var mcpResp *sdk.CallToolResult + + // Check if response is cached in state from request labeling + if cachedResp := extractCachedResponse(state); cachedResp != nil { + mcpResp = cachedResp + } else { + // Make the actual backend call + conn, err := launcher.GetOrLaunch(us.launcher, serverID) + if err != nil { + return &sdk.CallToolResult{IsError: true}, nil, err + } + + result, err := conn.SendRequest("tools/call", mcpReq) + if err != nil { + return &sdk.CallToolResult{IsError: true}, nil, err + } + mcpResp = parseCallToolResult(result) + } + + // **Phase 4: Guard labels the response data** + labeledData, err := g.LabelResponse(ctx, mcpResp, resource, state) + if err != nil { + return &sdk.CallToolResult{IsError: true}, nil, fmt.Errorf("failed to label response: %w", err) + } + + // **Phase 5: Reference monitor filters/validates response based on labels** + var finalResp *sdk.CallToolResult + + if labeledData.IsCollection() { + // Filter collection items based on agent labels + filteredData, violations, err := labeledData.FilterCollection(&difc.Labels{ + Secrecy: agentLabels.Secrecy, + Integrity: agentLabels.Integrity, + }) + if err != nil { + return &sdk.CallToolResult{IsError: true}, nil, fmt.Errorf("failed to filter collection: %w", err) + } + + // Log filtered items for audit + if len(violations) > 0 { + log.Printf("Agent %s: Filtered %d items: %v", agentID, len(violations), violations) + } + + // Create response with filtered data + filteredContent, err := json.Marshal(filteredData) + if err != nil { + return &sdk.CallToolResult{IsError: true}, nil, fmt.Errorf("failed to marshal filtered data: %w", err) + } + + finalResp = &sdk.CallToolResult{ + Content: filteredContent, + IsError: false, + } + } else { + // Single item - already passed coarse-grained check + finalResp, err = labeledData.ToResult() + if err != nil { + return &sdk.CallToolResult{IsError: true}, nil, fmt.Errorf("failed to convert labeled data: %w", err) + } + } + + // **Phase 6: Accumulate labels from this operation** + // For reads, agent gains labels from the data they read + if !isWrite { + overall := labeledData.Overall() + agentLabels.Integrity.Union(overall.Integrity.Label) + agentLabels.Secrecy.Union(overall.Secrecy.Label) + } + + return finalResp, nil, nil +} + +// guardBackendCaller implements BackendCaller for guards +type guardBackendCaller struct { + server *UnifiedServer + serverID string + ctx context.Context +} + +func (gbc *guardBackendCaller) CallTool(ctx context.Context, toolName string, args interface{}) (*sdk.CallToolResult, error) { + // Make a direct backend call without DIFC checks + // This is only used by guards for metadata gathering during labeling + conn, err := launcher.GetOrLaunch(gbc.server.launcher, gbc.serverID) + if err != nil { + return nil, err + } + + mcpReq := &sdk.CallToolRequest{ + Params: sdk.CallToolRequestParams{ + Name: toolName, + Arguments: args, + }, + } + + result, err := conn.SendRequest("tools/call", mcpReq) + if err != nil { + return nil, err + } + + return parseCallToolResult(result), nil +} + +func extractCachedResponse(state guard.RequestState) *sdk.CallToolResult { + // Check common state types for cached responses + type cacheableState interface { + CachedResponse() *sdk.CallToolResult + } + + if cs, ok := state.(cacheableState); ok { + return cs.CachedResponse() + } + return nil +} + +func (us *UnifiedServer) checkDIFCConstraints(agent *difc.AgentLabels, resource *difc.Resource, isWrite bool) error { + if isWrite { + // Write operation: agent can only write to resources with lower or equal integrity + // (agent's integrity must be >= resource's integrity) + ok, missingTags := agent.Integrity.CheckFlow(&resource.Integrity) + if !ok { + return &difc.ViolationError{ + Type: difc.IntegrityViolation, + Resource: resource.Description, + IsWrite: true, + MissingTags: missingTags, + AgentTags: agent.Integrity.GetTags(), + ResourceTags: resource.Integrity.GetTags(), + } + } + } else { + // Read operation: agent can only read from resources with higher or equal integrity + // (resource's integrity must be >= agent's integrity to trust the data) + ok, missingTags := resource.Integrity.CheckFlow(agent.Integrity) + if !ok { + return &difc.ViolationError{ + Type: difc.IntegrityViolation, + Resource: resource.Description, + IsWrite: false, + MissingTags: missingTags, + AgentTags: agent.Integrity.GetTags(), + ResourceTags: resource.Integrity.GetTags(), + } + } + } + + // Secrecy check: data can only flow where secrecy allows + ok, extraTags := agent.Secrecy.CheckFlow(&resource.Secrecy) + if !ok { + return &difc.ViolationError{ + Type: difc.SecrecyViolation, + Resource: resource.Description, + IsWrite: isWrite, + ExtraTags: extraTags, + AgentTags: agent.Secrecy.GetTags(), + ResourceTags: resource.Secrecy.GetTags(), + } + } + + return nil +} +``` + +## Implementation Phases + +### Phase 1: Foundation (2-3 weeks) +1. Implement `internal/difc` package + - Label types (Secrecy, Integrity) + - Endpoint representation + - Capabilities + - Label operations (union, intersection, canFlowTo) + +2. Implement `internal/guard` package + - Guard interface + - Noop guard implementation + - Guard registry + +3. Add agent label management + - AgentLabels struct + - AgentRegistry for tracking agents + +### Phase 2: Integration (2-3 weeks) +1. Integrate guards into UnifiedServer + - Register guards for each backend + - Add DIFC checks to callBackendTool + - Pass agent context through requests + +2. Extract agent ID from requests + - Parse Authorization header + - Create/retrieve AgentLabels + +3. Add DIFC constraint checking + - Read/write operation detection + - Integrity and secrecy flow checks + +### Phase 3: Guard Implementations (3-4 weeks) +1. Implement GitHub guard + - Parse common tools (get_file_contents, push_files, etc.) + - Add repo labels + - Implement policies + +2. Implement other guards as needed + - Filesystem guard + - Memory guard + - Custom guards + +3. Testing and refinement + - Unit tests for guards + - Integration tests for DIFC flow + - Policy validation + +### Phase 4: Configuration (1-2 weeks) +1. Add agent configuration + - Initial labels per agent + - Label inheritance rules + - Policy overrides + +2. Add guard configuration + - Per-server guard selection + - Guard-specific settings + +3. Logging and debugging + - Log DIFC decisions + - Debug mode for label tracking + - Violation reporting + +## Configuration Format + +```toml +# config.toml + +[agents] +[agents.default] +secrecy_labels = [] +integrity_labels = [] + +[agents."demo-agent"] +secrecy_labels = ["agent:demo-agent"] +integrity_labels = ["agent:demo-agent"] + +[agents."production-agent"] +secrecy_labels = ["agent:production-agent", "env:production"] +integrity_labels = ["agent:production-agent", "env:production"] + +[servers.github] +command = "docker" +args = ["run", "--rm", "-i", "ghcr.io/github/github-mcp-server:latest"] +guard = "github" # Use github guard + +[servers.github.guard_config] +# GitHub-specific guard configuration + +[servers.custom] +command = "node" +args = ["custom-server.js"] +guard = "noop" # Explicitly use noop guard +``` + +## Benefits + +1. **Security**: Tracks information flow through the system +2. **Transparency**: Clear labeling of data sources and integrity +3. **Flexibility**: Easy to add new guards for new MCP servers +4. **Compatibility**: Works with existing MCP servers via noop guard +5. **Gradual Adoption**: Can deploy without guards, add them incrementally +6. **Fine-Grained Filtering**: Can filter individual items in collections based on labels + +## Fine-Grained Filtering + +The DIFC system supports two levels of access control, both enforced by the **Reference Monitor**: + +### 1. Coarse-Grained (Resource-Level) +Guard's `LabelRequest` returns labels for the entire operation. The **reference monitor** checks these labels before allowing the operation. If the check fails, the entire operation is rejected. + +**Example**: Reading a specific issue requires integrity tag `user:issue-author`. If the agent lacks this tag, the **reference monitor** rejects the request. + +**Responsibility Split**: +- **Guard**: Labels the resource (e.g., "issue requires `user:alice` tag") +- **Reference Monitor**: Checks if agent has required tags, rejects if not + +### 2. Fine-Grained (Item-Level) +Guard's `LabelResponse` returns a `LabeledData` structure where individual items in a collection have their own labels. The **reference monitor** filters items based on agent labels. + +**Example**: Listing repositories for a user returns a mix of public and private repos: +1. **Guard** `LabelRequest`: Returns minimal-requirement resource representing the list query +2. Backend call fetches all repos +3. **Reference Monitor**: Checks coarse-grained labels (passes because listing is allowed) +4. **Guard** `LabelResponse`: Returns `CollectionLabeledData` with per-repo labels: + - Public repos: No secrecy requirements + - Private repos: Require `private:owner/repo` secrecy tag +5. **Reference Monitor** `FilterCollection`: Iterates through items, includes accessible ones, filters out inaccessible ones, logs violations for audit + +**Responsibility Split**: +- **Guard**: Labels each item in the collection (e.g., "this repo requires `private:foo/bar` tag") +- **Reference Monitor**: Decides which items agent can access, filters accordingly, logs audit trail + +### When to Use Each Approach + +**Coarse-Grained (fail entire request)**: +- Single-item operations (get specific file, get specific issue) +- Write operations (push files, create issue) +- Operations where partial access doesn't make sense + +**Fine-Grained (filter items)**: +- List operations (list repos, list issues, search) +- Aggregate operations (get repository statistics) +- Any operation returning collections where partial access is acceptable + +### Key Principle: Guard Labels, Reference Monitor Decides + +The guard is a **domain expert** that understands how to label resources and data in its domain (e.g., GitHub knows that private repos need privacy labels, issues should be labeled with author tags). + +The reference monitor is the **security policy enforcer** that makes all access control decisions based on those labels. This separation ensures: +- Guards can be written by domain experts without security expertise +- Security policy is centralized and consistent +- Guards are simpler and easier to test +- Policy changes don't require modifying guards + +### Implementation Notes + +1. **Audit Logging**: Reference monitor logs all filtered items for security audit +2. **Performance**: Filtering happens after backend call, so all data is fetched but only accessible items are returned +3. **Metadata**: Reference monitor can include filtered count in response metadata (e.g., "showing 5 of 12 repositories") +4. **Configuration**: Reference monitor can be configured per-agent for strict mode (reject if any item inaccessible) vs filter mode (remove inaccessible items) + +## Migration Path + +1. **Initial**: Deploy with all noop guards +2. **Add Guards**: Implement guards for critical servers (e.g., GitHub) +3. **Configure Agents**: Set initial labels for different agent types +4. **Enforce Policies**: Enable DIFC constraint checking +5. **Refine**: Adjust policies based on usage patterns + +## Testing Strategy + +1. **Unit Tests**: Test each guard implementation independently +2. **Integration Tests**: Test DIFC flow through full request cycle +3. **Policy Tests**: Verify constraints are enforced correctly +4. **Performance Tests**: Ensure DIFC overhead is acceptable +5. **Compatibility Tests**: Ensure existing functionality works with noop guards + +## Open Questions + +1. **Label Persistence**: Should agent labels persist across sessions? +2. **Label Discovery**: How do agents discover available labels? +3. **Policy Language**: Should we support a DSL for complex policies? +4. **Audit Logging**: What level of DIFC decision logging is needed? +5. **Performance**: What is acceptable overhead for DIFC checks? diff --git a/go.mod b/go.mod index 7abeb185..4d0a28c6 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,9 @@ module github.com/githubnext/gh-aw-mcpg -go 1.23.0 +go 1.25.0 require ( github.com/BurntSushi/toml v1.5.0 github.com/modelcontextprotocol/go-sdk v1.1.0 github.com/spf13/cobra v1.8.0 ) - -require ( - github.com/google/jsonschema-go v0.3.0 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - golang.org/x/oauth2 v0.30.0 // indirect -) diff --git a/go.sum b/go.sum index 13b3d49b..4ecfb5d0 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,9 @@ +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= @@ -16,9 +19,81 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/difc/agent.go b/internal/difc/agent.go new file mode 100644 index 00000000..7d05ff5d --- /dev/null +++ b/internal/difc/agent.go @@ -0,0 +1,218 @@ +package difc + +import ( + "log" + "sync" +) + +// AgentLabels associates each agent with their DIFC labels +// Tracks what secrecy and integrity tags an agent has accumulated +type AgentLabels struct { + AgentID string + Secrecy *SecrecyLabel + Integrity *IntegrityLabel + mu sync.RWMutex +} + +// NewAgentLabels creates a new agent with empty labels +func NewAgentLabels(agentID string) *AgentLabels { + return &AgentLabels{ + AgentID: agentID, + Secrecy: NewSecrecyLabel(), + Integrity: NewIntegrityLabel(), + } +} + +// NewAgentLabelsWithTags creates a new agent with initial tags +func NewAgentLabelsWithTags(agentID string, secrecyTags []Tag, integrityTags []Tag) *AgentLabels { + return &AgentLabels{ + AgentID: agentID, + Secrecy: NewSecrecyLabelWithTags(secrecyTags), + Integrity: NewIntegrityLabelWithTags(integrityTags), + } +} + +// AddSecrecyTag adds a secrecy tag to the agent +func (a *AgentLabels) AddSecrecyTag(tag Tag) { + a.mu.Lock() + defer a.mu.Unlock() + a.Secrecy.Label.Add(tag) + log.Printf("[DIFC] Agent %s gained secrecy tag: %s", a.AgentID, tag) +} + +// AddIntegrityTag adds an integrity tag to the agent +func (a *AgentLabels) AddIntegrityTag(tag Tag) { + a.mu.Lock() + defer a.mu.Unlock() + a.Integrity.Label.Add(tag) + log.Printf("[DIFC] Agent %s gained integrity tag: %s", a.AgentID, tag) +} + +// DropIntegrityTag removes an integrity tag from the agent +func (a *AgentLabels) DropIntegrityTag(tag Tag) { + a.mu.Lock() + defer a.mu.Unlock() + // Remove from the underlying label + delete(a.Integrity.Label.tags, tag) + log.Printf("[DIFC] Agent %s dropped integrity tag: %s", a.AgentID, tag) +} + +// AccumulateFromRead updates agent labels after reading data +// Agent gains secrecy and integrity tags from what they read +func (a *AgentLabels) AccumulateFromRead(resource *LabeledResource) { + a.mu.Lock() + defer a.mu.Unlock() + + // Gain secrecy tags from the data we read + if resource.Secrecy.Label != nil && !resource.Secrecy.Label.IsEmpty() { + a.Secrecy.Label.Union(resource.Secrecy.Label) + log.Printf("[DIFC] Agent %s accumulated secrecy tags from read: %v", a.AgentID, resource.Secrecy.Label.GetTags()) + } + + // Gain integrity tags from the data we read (we're influenced by it) + if resource.Integrity.Label != nil && !resource.Integrity.Label.IsEmpty() { + a.Integrity.Label.Union(resource.Integrity.Label) + log.Printf("[DIFC] Agent %s accumulated integrity tags from read: %v", a.AgentID, resource.Integrity.Label.GetTags()) + } +} + +// Clone creates a copy of the agent labels +func (a *AgentLabels) Clone() *AgentLabels { + a.mu.RLock() + defer a.mu.RUnlock() + + return &AgentLabels{ + AgentID: a.AgentID, + Secrecy: a.Secrecy.Clone(), + Integrity: a.Integrity.Clone(), + } +} + +// GetSecrecyTags returns a copy of secrecy tags (thread-safe) +func (a *AgentLabels) GetSecrecyTags() []Tag { + a.mu.RLock() + defer a.mu.RUnlock() + return a.Secrecy.Label.GetTags() +} + +// GetIntegrityTags returns a copy of integrity tags (thread-safe) +func (a *AgentLabels) GetIntegrityTags() []Tag { + a.mu.RLock() + defer a.mu.RUnlock() + return a.Integrity.Label.GetTags() +} + +// AgentRegistry manages agent labels across all agents +type AgentRegistry struct { + agents map[string]*AgentLabels + mu sync.RWMutex + + // Default labels for new agents + defaultSecrecy []Tag + defaultIntegrity []Tag +} + +// NewAgentRegistry creates a new agent registry +func NewAgentRegistry() *AgentRegistry { + return &AgentRegistry{ + agents: make(map[string]*AgentLabels), + defaultSecrecy: []Tag{}, + defaultIntegrity: []Tag{}, + } +} + +// NewAgentRegistryWithDefaults creates a registry with default labels for new agents +func NewAgentRegistryWithDefaults(defaultSecrecy []Tag, defaultIntegrity []Tag) *AgentRegistry { + return &AgentRegistry{ + agents: make(map[string]*AgentLabels), + defaultSecrecy: defaultSecrecy, + defaultIntegrity: defaultIntegrity, + } +} + +// GetOrCreate gets an existing agent or creates a new one with default labels +func (r *AgentRegistry) GetOrCreate(agentID string) *AgentLabels { + // Try to get existing agent first (read lock) + r.mu.RLock() + if labels, ok := r.agents[agentID]; ok { + r.mu.RUnlock() + return labels + } + r.mu.RUnlock() + + // Need to create new agent (write lock) + r.mu.Lock() + defer r.mu.Unlock() + + // Double-check after acquiring write lock + if labels, ok := r.agents[agentID]; ok { + return labels + } + + // Initialize new agent with default labels + labels := NewAgentLabelsWithTags(agentID, r.defaultSecrecy, r.defaultIntegrity) + r.agents[agentID] = labels + + log.Printf("[DIFC] Created new agent: %s with default labels (secrecy: %v, integrity: %v)", + agentID, r.defaultSecrecy, r.defaultIntegrity) + + return labels +} + +// Get retrieves an agent's labels if they exist +func (r *AgentRegistry) Get(agentID string) (*AgentLabels, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + labels, ok := r.agents[agentID] + return labels, ok +} + +// Register creates a new agent with specific initial labels +func (r *AgentRegistry) Register(agentID string, secrecyTags []Tag, integrityTags []Tag) *AgentLabels { + r.mu.Lock() + defer r.mu.Unlock() + + labels := NewAgentLabelsWithTags(agentID, secrecyTags, integrityTags) + r.agents[agentID] = labels + + log.Printf("[DIFC] Registered agent: %s with labels (secrecy: %v, integrity: %v)", + agentID, secrecyTags, integrityTags) + + return labels +} + +// Remove removes an agent from the registry +func (r *AgentRegistry) Remove(agentID string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.agents, agentID) + log.Printf("[DIFC] Removed agent: %s", agentID) +} + +// Count returns the number of registered agents +func (r *AgentRegistry) Count() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.agents) +} + +// GetAllAgentIDs returns all registered agent IDs +func (r *AgentRegistry) GetAllAgentIDs() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + ids := make([]string, 0, len(r.agents)) + for id := range r.agents { + ids = append(ids, id) + } + return ids +} + +// SetDefaultLabels sets the default labels for new agents +func (r *AgentRegistry) SetDefaultLabels(secrecy []Tag, integrity []Tag) { + r.mu.Lock() + defer r.mu.Unlock() + r.defaultSecrecy = secrecy + r.defaultIntegrity = integrity + log.Printf("[DIFC] Updated default agent labels (secrecy: %v, integrity: %v)", secrecy, integrity) +} diff --git a/internal/difc/capabilities.go b/internal/difc/capabilities.go new file mode 100644 index 00000000..3654f4cc --- /dev/null +++ b/internal/difc/capabilities.go @@ -0,0 +1,73 @@ +package difc + +import "sync" + +// Capabilities represents the global set of tags available in the system +// This is used to validate and discover available DIFC tags +type Capabilities struct { + tags map[Tag]struct{} + mu sync.RWMutex +} + +// NewCapabilities creates a new empty capabilities set +func NewCapabilities() *Capabilities { + return &Capabilities{ + tags: make(map[Tag]struct{}), + } +} + +// Add adds a tag to the capabilities +func (c *Capabilities) Add(tag Tag) { + c.mu.Lock() + defer c.mu.Unlock() + c.tags[tag] = struct{}{} +} + +// AddAll adds multiple tags to the capabilities +func (c *Capabilities) AddAll(tags []Tag) { + c.mu.Lock() + defer c.mu.Unlock() + for _, tag := range tags { + c.tags[tag] = struct{}{} + } +} + +// Contains checks if a tag is available in the capabilities +func (c *Capabilities) Contains(tag Tag) bool { + c.mu.RLock() + defer c.mu.RUnlock() + _, ok := c.tags[tag] + return ok +} + +// GetAll returns all available tags +func (c *Capabilities) GetAll() []Tag { + c.mu.RLock() + defer c.mu.RUnlock() + tags := make([]Tag, 0, len(c.tags)) + for tag := range c.tags { + tags = append(tags, tag) + } + return tags +} + +// Remove removes a tag from the capabilities +func (c *Capabilities) Remove(tag Tag) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.tags, tag) +} + +// Clear removes all tags from the capabilities +func (c *Capabilities) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.tags = make(map[Tag]struct{}) +} + +// Count returns the number of available tags +func (c *Capabilities) Count() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.tags) +} diff --git a/internal/difc/difc_test.go b/internal/difc/difc_test.go new file mode 100644 index 00000000..438070c5 --- /dev/null +++ b/internal/difc/difc_test.go @@ -0,0 +1,251 @@ +package difc + +import ( + "testing" +) + +func TestLabelOperations(t *testing.T) { + t.Run("SecrecyLabel flow checks", func(t *testing.T) { + // Create labels + l1 := NewSecrecyLabel() + l1.Label.Add("tag1") + l1.Label.Add("tag2") + + l2 := NewSecrecyLabel() + l2.Label.Add("tag1") + l2.Label.Add("tag2") + l2.Label.Add("tag3") + + // l1 should flow to l2 (l1 ⊆ l2) + if !l1.CanFlowTo(l2) { + t.Errorf("Expected l1 to flow to l2") + } + + // l2 should NOT flow to l1 (l2 has extra tags) + if l2.CanFlowTo(l1) { + t.Errorf("Expected l2 NOT to flow to l1") + } + }) + + t.Run("IntegrityLabel flow checks", func(t *testing.T) { + // Create labels + l1 := NewIntegrityLabel() + l1.Label.Add("trust1") + l1.Label.Add("trust2") + + l2 := NewIntegrityLabel() + l2.Label.Add("trust1") + + // l1 should flow to l2 (l1 ⊇ l2) + if !l1.CanFlowTo(l2) { + t.Errorf("Expected l1 to flow to l2") + } + + // l2 should NOT flow to l1 (l2 missing trust2) + if l2.CanFlowTo(l1) { + t.Errorf("Expected l2 NOT to flow to l1") + } + }) + + t.Run("Empty labels flow to everything", func(t *testing.T) { + empty := NewSecrecyLabel() + withTags := NewSecrecyLabel() + withTags.Label.Add("tag1") + + // Empty should flow to anything + if !empty.CanFlowTo(withTags) { + t.Errorf("Expected empty to flow to withTags") + } + + // withTags should NOT flow to empty + if withTags.CanFlowTo(empty) { + t.Errorf("Expected withTags NOT to flow to empty") + } + }) +} + +func TestEvaluator(t *testing.T) { + eval := NewEvaluator() + + t.Run("Read operation - secrecy check", func(t *testing.T) { + // Agent with no secrecy tags tries to read data with secrecy requirements + agentSecrecy := NewSecrecyLabel() + agentIntegrity := NewIntegrityLabel() + + resource := NewLabeledResource("private-file") + resource.Secrecy.Label.Add("private") + + result := eval.Evaluate(agentSecrecy, agentIntegrity, resource, OperationRead) + + if result.IsAllowed() { + t.Errorf("Expected access to be denied for read with insufficient secrecy") + } + + if len(result.SecrecyToAdd) == 0 { + t.Errorf("Expected SecrecyToAdd to contain required tags") + } + }) + + t.Run("Read operation - allowed with matching labels", func(t *testing.T) { + // Agent with secrecy tag can read data with that tag + agentSecrecy := NewSecrecyLabel() + agentSecrecy.Label.Add("private") + agentIntegrity := NewIntegrityLabel() + + resource := NewLabeledResource("private-file") + resource.Secrecy.Label.Add("private") + + result := eval.Evaluate(agentSecrecy, agentIntegrity, resource, OperationRead) + + if !result.IsAllowed() { + t.Errorf("Expected access to be allowed: %s", result.Reason) + } + }) + + t.Run("Write operation - integrity check", func(t *testing.T) { + // Agent without integrity tries to write to high-integrity resource + agentSecrecy := NewSecrecyLabel() + agentIntegrity := NewIntegrityLabel() + + resource := NewLabeledResource("production-database") + resource.Integrity.Label.Add("production") + + result := eval.Evaluate(agentSecrecy, agentIntegrity, resource, OperationWrite) + + if result.IsAllowed() { + t.Errorf("Expected access to be denied for write with insufficient integrity") + } + + if len(result.IntegrityToDrop) == 0 { + t.Errorf("Expected IntegrityToDrop to contain required tags") + } + }) + + t.Run("Write operation - allowed with matching integrity", func(t *testing.T) { + // Agent with production integrity can write to production resource + agentSecrecy := NewSecrecyLabel() + agentIntegrity := NewIntegrityLabel() + agentIntegrity.Label.Add("production") + + resource := NewLabeledResource("production-database") + resource.Integrity.Label.Add("production") + + result := eval.Evaluate(agentSecrecy, agentIntegrity, resource, OperationWrite) + + if !result.IsAllowed() { + t.Errorf("Expected access to be allowed: %s", result.Reason) + } + }) + + t.Run("Empty resource allows all operations", func(t *testing.T) { + // NoopGuard returns empty labels - should allow everything + agentSecrecy := NewSecrecyLabel() + agentIntegrity := NewIntegrityLabel() + + resource := NewLabeledResource("noop-resource") + // No tags added = no restrictions + + // Both read and write should be allowed + readResult := eval.Evaluate(agentSecrecy, agentIntegrity, resource, OperationRead) + writeResult := eval.Evaluate(agentSecrecy, agentIntegrity, resource, OperationWrite) + + if !readResult.IsAllowed() { + t.Errorf("Expected read to be allowed for empty resource: %s", readResult.Reason) + } + if !writeResult.IsAllowed() { + t.Errorf("Expected write to be allowed for empty resource: %s", writeResult.Reason) + } + }) +} + +func TestAgentRegistry(t *testing.T) { + registry := NewAgentRegistry() + + t.Run("GetOrCreate creates new agent", func(t *testing.T) { + agent := registry.GetOrCreate("agent-1") + if agent.AgentID != "agent-1" { + t.Errorf("Expected agent ID to be 'agent-1', got %s", agent.AgentID) + } + + // Should have empty labels initially + if !agent.Secrecy.Label.IsEmpty() { + t.Errorf("Expected new agent to have empty secrecy labels") + } + if !agent.Integrity.Label.IsEmpty() { + t.Errorf("Expected new agent to have empty integrity labels") + } + }) + + t.Run("GetOrCreate returns existing agent", func(t *testing.T) { + agent1 := registry.GetOrCreate("agent-2") + agent1.Secrecy.Label.Add("secret") + + agent2 := registry.GetOrCreate("agent-2") + if agent1 != agent2 { + t.Errorf("Expected to get same agent instance") + } + + if !agent2.Secrecy.Label.Contains("secret") { + t.Errorf("Expected agent to retain added tags") + } + }) + + t.Run("AccumulateFromRead updates agent labels", func(t *testing.T) { + agent := registry.GetOrCreate("agent-3") + + resource := NewLabeledResource("data-source") + resource.Secrecy.Label.Add("confidential") + resource.Integrity.Label.Add("verified") + + agent.AccumulateFromRead(resource) + + if !agent.Secrecy.Label.Contains("confidential") { + t.Errorf("Expected agent to gain secrecy tag from read") + } + if !agent.Integrity.Label.Contains("verified") { + t.Errorf("Expected agent to gain integrity tag from read") + } + }) +} + +func TestCollectionFiltering(t *testing.T) { + eval := NewEvaluator() + + t.Run("FilterCollection filters inaccessible items", func(t *testing.T) { + // Agent with limited clearance + agentSecrecy := NewSecrecyLabel() + agentSecrecy.Label.Add("public") + agentIntegrity := NewIntegrityLabel() + + // Create collection with mixed access + collection := &CollectionLabeledData{ + Items: []LabeledItem{ + { + Data: map[string]string{"name": "public-item"}, + Labels: &LabeledResource{ + Description: "public item", + Secrecy: *NewSecrecyLabelWithTags([]Tag{"public"}), + Integrity: *NewIntegrityLabel(), + }, + }, + { + Data: map[string]string{"name": "secret-item"}, + Labels: &LabeledResource{ + Description: "secret item", + Secrecy: *NewSecrecyLabelWithTags([]Tag{"secret"}), + Integrity: *NewIntegrityLabel(), + }, + }, + }, + } + + filtered := eval.FilterCollection(agentSecrecy, agentIntegrity, collection, OperationRead) + + if filtered.GetAccessibleCount() != 1 { + t.Errorf("Expected 1 accessible item, got %d", filtered.GetAccessibleCount()) + } + if filtered.GetFilteredCount() != 1 { + t.Errorf("Expected 1 filtered item, got %d", filtered.GetFilteredCount()) + } + }) +} diff --git a/internal/difc/evaluator.go b/internal/difc/evaluator.go new file mode 100644 index 00000000..90b059c3 --- /dev/null +++ b/internal/difc/evaluator.go @@ -0,0 +1,248 @@ +package difc + +import ( + "fmt" + "strings" +) + +// OperationType indicates the nature of the resource access +type OperationType int + +const ( + OperationRead OperationType = iota + OperationWrite + OperationReadWrite +) + +func (o OperationType) String() string { + switch o { + case OperationRead: + return "read" + case OperationWrite: + return "write" + case OperationReadWrite: + return "read-write" + default: + return "unknown" + } +} + +// AccessDecision represents the result of a DIFC evaluation +type AccessDecision int + +const ( + AccessAllow AccessDecision = iota + AccessDeny +) + +func (a AccessDecision) String() string { + switch a { + case AccessAllow: + return "allow" + case AccessDeny: + return "deny" + default: + return "unknown" + } +} + +// EvaluationResult contains the decision and required label changes +type EvaluationResult struct { + Decision AccessDecision + SecrecyToAdd []Tag // Secrecy tags agent must add to proceed + IntegrityToDrop []Tag // Integrity tags agent must drop to proceed + Reason string // Human-readable reason for denial +} + +// IsAllowed returns true if access is allowed +func (e *EvaluationResult) IsAllowed() bool { + return e.Decision == AccessAllow +} + +// Evaluator performs DIFC policy evaluation +type Evaluator struct{} + +// NewEvaluator creates a new DIFC evaluator +func NewEvaluator() *Evaluator { + return &Evaluator{} +} + +// Evaluate checks if an agent can perform an operation on a resource +func (e *Evaluator) Evaluate( + agentSecrecy *SecrecyLabel, + agentIntegrity *IntegrityLabel, + resource *LabeledResource, + operation OperationType, +) *EvaluationResult { + result := &EvaluationResult{ + Decision: AccessAllow, + SecrecyToAdd: []Tag{}, + IntegrityToDrop: []Tag{}, + } + + switch operation { + case OperationRead: + return e.evaluateRead(agentSecrecy, agentIntegrity, resource) + + case OperationWrite: + return e.evaluateWrite(agentSecrecy, agentIntegrity, resource) + + case OperationReadWrite: + // For read-write, must satisfy both read and write constraints + readResult := e.evaluateRead(agentSecrecy, agentIntegrity, resource) + if !readResult.IsAllowed() { + return readResult + } + + writeResult := e.evaluateWrite(agentSecrecy, agentIntegrity, resource) + if !writeResult.IsAllowed() { + return writeResult + } + } + + return result +} + +// evaluateRead checks if agent can read from resource +func (e *Evaluator) evaluateRead( + agentSecrecy *SecrecyLabel, + agentIntegrity *IntegrityLabel, + resource *LabeledResource, +) *EvaluationResult { + result := &EvaluationResult{ + Decision: AccessAllow, + SecrecyToAdd: []Tag{}, + IntegrityToDrop: []Tag{}, + } + + // For reads: resource integrity must flow to agent (trust check) + // Agent must trust the resource (resource has all integrity tags agent requires) + ok, missingTags := resource.Integrity.CheckFlow(agentIntegrity) + if !ok { + result.Decision = AccessDeny + result.IntegrityToDrop = missingTags + result.Reason = fmt.Sprintf("Resource '%s' has lower integrity than agent requires. "+ + "Agent would need to drop integrity tags %v to trust this resource.", + resource.Description, missingTags) + return result + } + + // For reads: agent must be able to handle resource's secrecy + // All resource secrecy tags must be present in agent secrecy + ok, extraTags := resource.Secrecy.CheckFlow(agentSecrecy) + if !ok { + result.Decision = AccessDeny + result.SecrecyToAdd = extraTags + result.Reason = fmt.Sprintf("Resource '%s' has secrecy requirements that agent doesn't meet. "+ + "Agent would need to add secrecy tags %v to read this resource.", + resource.Description, extraTags) + return result + } + + return result +} + +// evaluateWrite checks if agent can write to resource +func (e *Evaluator) evaluateWrite( + agentSecrecy *SecrecyLabel, + agentIntegrity *IntegrityLabel, + resource *LabeledResource, +) *EvaluationResult { + result := &EvaluationResult{ + Decision: AccessAllow, + SecrecyToAdd: []Tag{}, + IntegrityToDrop: []Tag{}, + } + + // For writes: agent integrity must flow to resource + // Agent must be trustworthy enough (agent has all integrity tags resource requires) + ok, missingTags := agentIntegrity.CheckFlow(&resource.Integrity) + if !ok { + result.Decision = AccessDeny + result.IntegrityToDrop = missingTags + result.Reason = fmt.Sprintf("Agent lacks required integrity to write to '%s'. "+ + "Resource requires integrity tags %v that agent doesn't have.", + resource.Description, missingTags) + return result + } + + // For writes: agent secrecy must flow to resource secrecy + // All agent secrecy tags must be present in resource secrecy + ok, extraTags := agentSecrecy.CheckFlow(&resource.Secrecy) + if !ok { + result.Decision = AccessDeny + result.SecrecyToAdd = extraTags + result.Reason = fmt.Sprintf("Agent has secrecy tags %v that cannot flow to '%s'. "+ + "Agent would need resource to have these secrecy requirements too.", + extraTags, resource.Description) + return result + } + + return result +} + +// FormatViolationError creates a detailed error message explaining the violation and its implications +func FormatViolationError(result *EvaluationResult, agentSecrecy *SecrecyLabel, agentIntegrity *IntegrityLabel, resource *LabeledResource) error { + if result.Decision == AccessAllow { + return nil + } + + var msg strings.Builder + msg.WriteString(fmt.Sprintf("DIFC Violation: %s\n\n", result.Reason)) + + if len(result.SecrecyToAdd) > 0 { + msg.WriteString(fmt.Sprintf("Required Action: Add secrecy tags %v\n", result.SecrecyToAdd)) + msg.WriteString("\nImplications of adding secrecy tags:\n") + msg.WriteString(" - Agent will be restricted from writing to resources that lack these tags\n") + msg.WriteString(" - This includes public resources (e.g., public repositories, public internet)\n") + msg.WriteString(" - Agent will be marked as handling sensitive information\n") + msg.WriteString(fmt.Sprintf(" - Future writes must target resources with tags: %v\n", result.SecrecyToAdd)) + } + + if len(result.IntegrityToDrop) > 0 { + msg.WriteString(fmt.Sprintf("\nRequired Action: Drop integrity tags %v\n", result.IntegrityToDrop)) + msg.WriteString("\nImplications of dropping integrity tags:\n") + msg.WriteString(" - Agent will no longer be able to write to high-integrity resources\n") + msg.WriteString(fmt.Sprintf(" - Specifically, agent cannot write to resources requiring tags: %v\n", result.IntegrityToDrop)) + msg.WriteString(" - This action acknowledges that agent has been influenced by lower-integrity data\n") + msg.WriteString(" - Agent's outputs will be considered less trustworthy\n") + } + + msg.WriteString("\nCurrent Agent Labels:\n") + msg.WriteString(fmt.Sprintf(" Secrecy: %v\n", agentSecrecy.Label.GetTags())) + msg.WriteString(fmt.Sprintf(" Integrity: %v\n", agentIntegrity.Label.GetTags())) + + msg.WriteString("\nResource Requirements:\n") + msg.WriteString(fmt.Sprintf(" Secrecy: %v\n", resource.Secrecy.Label.GetTags())) + msg.WriteString(fmt.Sprintf(" Integrity: %v\n", resource.Integrity.Label.GetTags())) + + return fmt.Errorf("%s", msg.String()) +} + +// FilterCollection filters a collection based on agent labels +// Returns accessible items and filtered items separately +func (e *Evaluator) FilterCollection( + agentSecrecy *SecrecyLabel, + agentIntegrity *IntegrityLabel, + collection *CollectionLabeledData, + operation OperationType, +) *FilteredCollectionLabeledData { + filtered := &FilteredCollectionLabeledData{ + Accessible: []LabeledItem{}, + Filtered: []LabeledItem{}, + TotalCount: len(collection.Items), + FilterReason: "DIFC policy", + } + + for _, item := range collection.Items { + // Evaluate access for this item + result := e.Evaluate(agentSecrecy, agentIntegrity, item.Labels, operation) + if result.IsAllowed() { + filtered.Accessible = append(filtered.Accessible, item) + } else { + filtered.Filtered = append(filtered.Filtered, item) + } + } + + return filtered +} diff --git a/internal/difc/labels.go b/internal/difc/labels.go new file mode 100644 index 00000000..ee9d7fa7 --- /dev/null +++ b/internal/difc/labels.go @@ -0,0 +1,303 @@ +package difc + +import ( + "fmt" + "sync" +) + +// Tag represents a single DIFC tag (e.g., "repo:owner/name", "agent:demo-agent") +type Tag string + +// Label represents a set of DIFC tags +type Label struct { + tags map[Tag]struct{} + mu sync.RWMutex +} + +// NewLabel creates a new empty label +func NewLabel() *Label { + return &Label{tags: make(map[Tag]struct{})} +} + +// Add adds a tag to this label +func (l *Label) Add(tag Tag) { + l.mu.Lock() + defer l.mu.Unlock() + l.tags[tag] = struct{}{} +} + +// AddAll adds multiple tags to this label +func (l *Label) AddAll(tags []Tag) { + l.mu.Lock() + defer l.mu.Unlock() + for _, tag := range tags { + l.tags[tag] = struct{}{} + } +} + +// Contains checks if this label contains a specific tag +func (l *Label) Contains(tag Tag) bool { + l.mu.RLock() + defer l.mu.RUnlock() + _, ok := l.tags[tag] + return ok +} + +// Union merges another label into this label +func (l *Label) Union(other *Label) { + if other == nil { + return + } + other.mu.RLock() + defer other.mu.RUnlock() + l.mu.Lock() + defer l.mu.Unlock() + for tag := range other.tags { + l.tags[tag] = struct{}{} + } +} + +// Clone creates a copy of this label +func (l *Label) Clone() *Label { + l.mu.RLock() + defer l.mu.RUnlock() + newLabel := NewLabel() + for tag := range l.tags { + newLabel.tags[tag] = struct{}{} + } + return newLabel +} + +// GetTags returns all tags in this label as a slice +func (l *Label) GetTags() []Tag { + l.mu.RLock() + defer l.mu.RUnlock() + tags := make([]Tag, 0, len(l.tags)) + for tag := range l.tags { + tags = append(tags, tag) + } + return tags +} + +// IsEmpty returns true if this label has no tags +func (l *Label) IsEmpty() bool { + l.mu.RLock() + defer l.mu.RUnlock() + return len(l.tags) == 0 +} + +// SecrecyLabel wraps Label with secrecy-specific flow semantics +// Secrecy flow: data can only flow to contexts with equal or more secrecy tags +// l ⊆ target (this has no tags that target doesn't have) +type SecrecyLabel struct { + Label *Label +} + +// NewSecrecyLabel creates a new empty secrecy label +func NewSecrecyLabel() *SecrecyLabel { + return &SecrecyLabel{Label: NewLabel()} +} + +// NewSecrecyLabelWithTags creates a secrecy label with the given tags +func NewSecrecyLabelWithTags(tags []Tag) *SecrecyLabel { + label := NewSecrecyLabel() + label.Label.AddAll(tags) + return label +} + +// CanFlowTo checks if this secrecy label can flow to target +// Secrecy semantics: l ⊆ target (this has no tags that target doesn't have) +// Data can only flow to contexts with equal or more secrecy tags +func (l *SecrecyLabel) CanFlowTo(target *SecrecyLabel) bool { + if l == nil || l.Label == nil { + return true + } + if target == nil || target.Label == nil { + return l.Label.IsEmpty() + } + + l.Label.mu.RLock() + defer l.Label.mu.RUnlock() + target.Label.mu.RLock() + defer target.Label.mu.RUnlock() + + // Check if all tags in l are in target + for tag := range l.Label.tags { + if _, ok := target.Label.tags[tag]; !ok { + return false + } + } + return true +} + +// CheckFlow checks if this secrecy label can flow to target and returns violation details if not +func (l *SecrecyLabel) CheckFlow(target *SecrecyLabel) (bool, []Tag) { + if l == nil || l.Label == nil { + return true, nil + } + if target == nil || target.Label == nil { + if l.Label.IsEmpty() { + return true, nil + } + return false, l.Label.GetTags() + } + + l.Label.mu.RLock() + defer l.Label.mu.RUnlock() + target.Label.mu.RLock() + defer target.Label.mu.RUnlock() + + var extraTags []Tag + // Check if all tags in l are in target + for tag := range l.Label.tags { + if _, ok := target.Label.tags[tag]; !ok { + extraTags = append(extraTags, tag) + } + } + + return len(extraTags) == 0, extraTags +} + +// Clone creates a copy of the secrecy label +func (l *SecrecyLabel) Clone() *SecrecyLabel { + if l == nil || l.Label == nil { + return NewSecrecyLabel() + } + return &SecrecyLabel{Label: l.Label.Clone()} +} + +// IntegrityLabel wraps Label with integrity-specific flow semantics +// Integrity flow: data can flow from high integrity to low integrity +// l ⊇ target (this has all tags that target has) +type IntegrityLabel struct { + Label *Label +} + +// NewIntegrityLabel creates a new empty integrity label +func NewIntegrityLabel() *IntegrityLabel { + return &IntegrityLabel{Label: NewLabel()} +} + +// NewIntegrityLabelWithTags creates an integrity label with the given tags +func NewIntegrityLabelWithTags(tags []Tag) *IntegrityLabel { + label := NewIntegrityLabel() + label.Label.AddAll(tags) + return label +} + +// CanFlowTo checks if this integrity label can flow to target +// Integrity semantics: l ⊇ target (this has all tags that target has) +// For writes: agent must have >= integrity than endpoint +// For reads: endpoint must have >= integrity than agent +func (l *IntegrityLabel) CanFlowTo(target *IntegrityLabel) bool { + if l == nil || l.Label == nil { + return target == nil || target.Label == nil || target.Label.IsEmpty() + } + if target == nil || target.Label == nil { + return true + } + + l.Label.mu.RLock() + defer l.Label.mu.RUnlock() + target.Label.mu.RLock() + defer target.Label.mu.RUnlock() + + // Check if all tags in target are in l + for tag := range target.Label.tags { + if _, ok := l.Label.tags[tag]; !ok { + return false + } + } + return true +} + +// CheckFlow checks if this integrity label can flow to target and returns violation details if not +func (l *IntegrityLabel) CheckFlow(target *IntegrityLabel) (bool, []Tag) { + if l == nil || l.Label == nil { + if target == nil || target.Label == nil || target.Label.IsEmpty() { + return true, nil + } + return false, target.Label.GetTags() + } + if target == nil || target.Label == nil { + return true, nil + } + + l.Label.mu.RLock() + defer l.Label.mu.RUnlock() + target.Label.mu.RLock() + defer target.Label.mu.RUnlock() + + var missingTags []Tag + // Check if all tags in target are in l + for tag := range target.Label.tags { + if _, ok := l.Label.tags[tag]; !ok { + missingTags = append(missingTags, tag) + } + } + + return len(missingTags) == 0, missingTags +} + +// Clone creates a copy of the integrity label +func (l *IntegrityLabel) Clone() *IntegrityLabel { + if l == nil || l.Label == nil { + return NewIntegrityLabel() + } + return &IntegrityLabel{Label: l.Label.Clone()} +} + +// ViolationType indicates what kind of DIFC violation occurred +type ViolationType string + +const ( + SecrecyViolation ViolationType = "secrecy" + IntegrityViolation ViolationType = "integrity" +) + +// ViolationError provides detailed information about a DIFC violation +type ViolationError struct { + Type ViolationType + Resource string // Resource description + IsWrite bool // true for write, false for read + MissingTags []Tag // Tags the agent needs but doesn't have + ExtraTags []Tag // Tags the agent has but shouldn't + AgentTags []Tag // All agent tags (for context) + ResourceTags []Tag // All resource tags (for context) +} + +func (e *ViolationError) Error() string { + var msg string + + if e.Type == SecrecyViolation { + msg = fmt.Sprintf("Secrecy violation for resource '%s': ", e.Resource) + if len(e.ExtraTags) > 0 { + msg += fmt.Sprintf("agent has secrecy tags %v that cannot flow to resource. ", e.ExtraTags) + msg += "Remediation: remove these tags from agent's secrecy label or add them to the resource's secrecy requirements." + } + } else { + if e.IsWrite { + msg = fmt.Sprintf("Integrity violation for write to resource '%s': ", e.Resource) + if len(e.MissingTags) > 0 { + msg += fmt.Sprintf("agent is missing required integrity tags %v. ", e.MissingTags) + msg += fmt.Sprintf("Remediation: agent must gain integrity tags %v to write to this resource.", e.MissingTags) + } + } else { + msg = fmt.Sprintf("Integrity violation for read from resource '%s': ", e.Resource) + if len(e.MissingTags) > 0 { + msg += fmt.Sprintf("resource is missing integrity tags %v that agent requires. ", e.MissingTags) + msg += fmt.Sprintf("Remediation: agent should drop integrity tags %v to trust this resource, or verify resource has higher integrity.", e.MissingTags) + } + } + } + + return msg +} + +// Detailed returns a detailed error message with full context +func (e *ViolationError) Detailed() string { + msg := e.Error() + msg += fmt.Sprintf("\n Agent %s tags: %v", e.Type, e.AgentTags) + msg += fmt.Sprintf("\n Resource %s tags: %v", e.Type, e.ResourceTags) + return msg +} diff --git a/internal/difc/resource.go b/internal/difc/resource.go new file mode 100644 index 00000000..cc84ad19 --- /dev/null +++ b/internal/difc/resource.go @@ -0,0 +1,174 @@ +package difc + +// Resource represents an external system with label requirements (deprecated - use LabeledResource) +type Resource struct { + Description string + Secrecy SecrecyLabel + Integrity IntegrityLabel +} + +// NewResource creates a new resource with the given description +func NewResource(description string) *Resource { + return &Resource{ + Description: description, + Secrecy: *NewSecrecyLabel(), + Integrity: *NewIntegrityLabel(), + } +} + +// Empty returns a resource with no label requirements +func EmptyResource() *Resource { + return &Resource{ + Description: "empty resource", + Secrecy: *NewSecrecyLabel(), + Integrity: *NewIntegrityLabel(), + } +} + +// LabeledResource represents a resource with DIFC labels +// This can be a simple label pair or a complex nested structure for fine-grained filtering +type LabeledResource struct { + Description string // Human-readable description of the resource + Secrecy SecrecyLabel // Secrecy requirements for this resource + Integrity IntegrityLabel // Integrity requirements for this resource + + // Structure is an optional nested map for fine-grained labeling of response fields + // Maps JSON paths to their labels (e.g., "items[*].private" -> specific labels) + // If nil, labels apply uniformly to entire resource + Structure *ResourceStructure +} + +// NewLabeledResource creates a new labeled resource with the given description +func NewLabeledResource(description string) *LabeledResource { + return &LabeledResource{ + Description: description, + Secrecy: *NewSecrecyLabel(), + Integrity: *NewIntegrityLabel(), + Structure: nil, + } +} + +// ResourceStructure defines fine-grained labels for nested data structures +type ResourceStructure struct { + // Fields maps field names/paths to their labels + // For collections, use "items[*]" to indicate per-item labeling + Fields map[string]*FieldLabels +} + +// FieldLabels defines labels for a specific field in the response +type FieldLabels struct { + Secrecy *SecrecyLabel + Integrity *IntegrityLabel + + // Predicate is an optional function to determine labels based on field value + // For example: label repo as private if repo.Private == true + Predicate func(value interface{}) (*SecrecyLabel, *IntegrityLabel) +} + +// LabeledData represents response data with associated labels +// Used for fine-grained filtering in the reference monitor +type LabeledData interface { + // Overall returns the aggregate labels for all data + Overall() *LabeledResource + + // ToResult converts the labeled data to an MCP result + // This may filter out inaccessible items + ToResult() (interface{}, error) +} + +// SimpleLabeledData represents a single piece of data with uniform labels +type SimpleLabeledData struct { + Data interface{} + Labels *LabeledResource +} + +func (s *SimpleLabeledData) Overall() *LabeledResource { + return s.Labels +} + +func (s *SimpleLabeledData) ToResult() (interface{}, error) { + return s.Data, nil +} + +// CollectionLabeledData represents a collection where each item has its own labels +type CollectionLabeledData struct { + Items []LabeledItem +} + +// LabeledItem represents a single item in a collection with its labels +type LabeledItem struct { + Data interface{} + Labels *LabeledResource +} + +func (c *CollectionLabeledData) Overall() *LabeledResource { + // Aggregate labels from all items - most restrictive + if len(c.Items) == 0 { + return NewLabeledResource("empty collection") + } + + overall := NewLabeledResource("collection") + for _, item := range c.Items { + if item.Labels != nil { + // Union all secrecy tags (most restrictive) + overall.Secrecy.Label.Union(item.Labels.Secrecy.Label) + // Union all integrity tags (most restrictive) + overall.Integrity.Label.Union(item.Labels.Integrity.Label) + } + } + + return overall +} + +func (c *CollectionLabeledData) ToResult() (interface{}, error) { + // Return all items as a slice + result := make([]interface{}, 0, len(c.Items)) + for _, item := range c.Items { + result = append(result, item.Data) + } + return result, nil +} + +// FilteredCollectionLabeledData represents a collection with some items filtered out +type FilteredCollectionLabeledData struct { + Accessible []LabeledItem + Filtered []LabeledItem + TotalCount int + FilterReason string +} + +func (f *FilteredCollectionLabeledData) Overall() *LabeledResource { + // Only aggregate labels from accessible items + if len(f.Accessible) == 0 { + return NewLabeledResource("empty filtered collection") + } + + overall := NewLabeledResource("filtered collection") + for _, item := range f.Accessible { + if item.Labels != nil { + overall.Secrecy.Label.Union(item.Labels.Secrecy.Label) + overall.Integrity.Label.Union(item.Labels.Integrity.Label) + } + } + + return overall +} + +func (f *FilteredCollectionLabeledData) ToResult() (interface{}, error) { + // Return only accessible items + result := make([]interface{}, 0, len(f.Accessible)) + for _, item := range f.Accessible { + result = append(result, item.Data) + } + return result, nil +} + +// GetAccessibleCount returns the number of accessible items +func (f *FilteredCollectionLabeledData) GetAccessibleCount() int { + return len(f.Accessible) +} + +// GetFilteredCount returns the number of filtered items +func (f *FilteredCollectionLabeledData) GetFilteredCount() int { + return len(f.Filtered) +} diff --git a/internal/guard/context.go b/internal/guard/context.go new file mode 100644 index 00000000..5be35abf --- /dev/null +++ b/internal/guard/context.go @@ -0,0 +1,71 @@ +package guard + +import ( + "context" + "strings" +) + +// ContextKey is used for storing values in context +type ContextKey string + +const ( + // AgentIDContextKey stores the agent ID in the request context + AgentIDContextKey ContextKey = "difc-agent-id" + + // RequestStateContextKey stores guard-specific request state + RequestStateContextKey ContextKey = "difc-request-state" +) + +// GetAgentIDFromContext extracts the agent ID from the context +// Returns "default" if not found +func GetAgentIDFromContext(ctx context.Context) string { + if agentID, ok := ctx.Value(AgentIDContextKey).(string); ok && agentID != "" { + return agentID + } + return "default" +} + +// SetAgentIDInContext sets the agent ID in the context +func SetAgentIDInContext(ctx context.Context, agentID string) context.Context { + return context.WithValue(ctx, AgentIDContextKey, agentID) +} + +// ExtractAgentIDFromAuthHeader extracts agent ID from Authorization header +// Supports formats: +// - "Bearer " - uses token as agent ID +// - "Agent " - uses agent-id directly +// - Any other format - uses the entire value as agent ID +func ExtractAgentIDFromAuthHeader(authHeader string) string { + if authHeader == "" { + return "default" + } + + // Handle "Bearer " format + if strings.HasPrefix(authHeader, "Bearer ") { + token := strings.TrimPrefix(authHeader, "Bearer ") + // Use the token as the agent ID + // In production, you might want to validate/decode the token + return token + } + + // Handle "Agent " format + if strings.HasPrefix(authHeader, "Agent ") { + return strings.TrimPrefix(authHeader, "Agent ") + } + + // Use the entire header value as agent ID + return authHeader +} + +// GetRequestStateFromContext retrieves guard request state from context +func GetRequestStateFromContext(ctx context.Context) RequestState { + if state, ok := ctx.Value(RequestStateContextKey).(RequestState); ok { + return state + } + return nil +} + +// SetRequestStateInContext stores guard request state in context +func SetRequestStateInContext(ctx context.Context, state RequestState) context.Context { + return context.WithValue(ctx, RequestStateContextKey, state) +} diff --git a/internal/guard/guard.go b/internal/guard/guard.go new file mode 100644 index 00000000..36c0fdc0 --- /dev/null +++ b/internal/guard/guard.go @@ -0,0 +1,44 @@ +package guard + +import ( + "context" + + "github.com/githubnext/gh-aw-mcpg/internal/difc" +) + +// BackendCaller provides a way for guards to make read-only calls to the backend +// to gather information needed for labeling (e.g., fetching issue author) +type BackendCaller interface { + // CallTool makes a read-only call to the backend MCP server + // This is used by guards to gather metadata for labeling + CallTool(ctx context.Context, toolName string, args interface{}) (interface{}, error) +} + +// Guard handles DIFC labeling for a specific MCP server +// Guards ONLY label resources - they do NOT make access control decisions +// The Reference Monitor (in the server) uses guard-provided labels to enforce DIFC policies +type Guard interface { + // Name returns the identifier for this guard (e.g., "github", "noop") + Name() string + + // LabelResource determines the resource being accessed and its labels + // This may call the backend (via BackendCaller) to gather metadata needed for labeling + // Returns: + // - resource: The labeled resource (simple or nested structure for fine-grained filtering) + // - operation: The type of operation (Read, Write, or ReadWrite) + // - error: Any error that occurred during labeling + LabelResource(ctx context.Context, toolName string, args interface{}, backend BackendCaller, caps *difc.Capabilities) (*difc.LabeledResource, difc.OperationType, error) + + // LabelResponse labels the response data after a successful backend call + // This is used for fine-grained filtering of collections + // Returns: + // - labeledData: The response data with per-item labels (if applicable) + // - error: Any error that occurred during labeling + // If the guard returns nil for labeledData, the reference monitor will use the + // resource labels from LabelResource for the entire response + LabelResponse(ctx context.Context, toolName string, result interface{}, backend BackendCaller, caps *difc.Capabilities) (difc.LabeledData, error) +} + +// RequestState represents any state that the guard needs to pass from request to response +// This is useful when the guard needs to carry information from LabelResource to LabelResponse +type RequestState interface{} diff --git a/internal/guard/guard_test.go b/internal/guard/guard_test.go new file mode 100644 index 00000000..a9f7a478 --- /dev/null +++ b/internal/guard/guard_test.go @@ -0,0 +1,190 @@ +package guard + +import ( + "context" + "testing" + + "github.com/githubnext/gh-aw-mcpg/internal/difc" +) + +func TestNoopGuard(t *testing.T) { + guard := NewNoopGuard() + + t.Run("Name returns noop", func(t *testing.T) { + if guard.Name() != "noop" { + t.Errorf("Expected name to be 'noop', got %s", guard.Name()) + } + }) + + t.Run("LabelResource returns empty labels", func(t *testing.T) { + ctx := context.Background() + caps := difc.NewCapabilities() + + resource, operation, err := guard.LabelResource(ctx, "test_tool", map[string]interface{}{}, nil, caps) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if resource == nil { + t.Fatal("Expected resource to be non-nil") + } + + if !resource.Secrecy.Label.IsEmpty() { + t.Errorf("Expected empty secrecy labels") + } + + if !resource.Integrity.Label.IsEmpty() { + t.Errorf("Expected empty integrity labels") + } + + if operation != difc.OperationWrite { + t.Errorf("Expected OperationWrite, got %v", operation) + } + }) + + t.Run("LabelResponse returns nil", func(t *testing.T) { + ctx := context.Background() + caps := difc.NewCapabilities() + + labeledData, err := guard.LabelResponse(ctx, "test_tool", map[string]interface{}{}, nil, caps) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if labeledData != nil { + t.Errorf("Expected nil labeled data") + } + }) +} + +func TestGuardRegistry(t *testing.T) { + t.Run("Register and Get guard", func(t *testing.T) { + registry := NewRegistry() + guard := NewNoopGuard() + + registry.Register("test-server", guard) + + retrieved := registry.Get("test-server") + if retrieved != guard { + t.Errorf("Expected to retrieve same guard instance") + } + }) + + t.Run("Get non-existent guard returns noop", func(t *testing.T) { + registry := NewRegistry() + + guard := registry.Get("non-existent") + if guard.Name() != "noop" { + t.Errorf("Expected noop guard for non-existent server, got %s", guard.Name()) + } + }) + + t.Run("Has checks guard existence", func(t *testing.T) { + registry := NewRegistry() + guard := NewNoopGuard() + + if registry.Has("test-server") { + t.Errorf("Expected Has to return false for non-existent guard") + } + + registry.Register("test-server", guard) + + if !registry.Has("test-server") { + t.Errorf("Expected Has to return true for registered guard") + } + }) + + t.Run("List returns all server IDs", func(t *testing.T) { + registry := NewRegistry() + registry.Register("server1", NewNoopGuard()) + registry.Register("server2", NewNoopGuard()) + + list := registry.List() + if len(list) != 2 { + t.Errorf("Expected 2 servers, got %d", len(list)) + } + }) + + t.Run("GetGuardInfo returns guard names", func(t *testing.T) { + registry := NewRegistry() + registry.Register("server1", NewNoopGuard()) + + info := registry.GetGuardInfo() + if info["server1"] != "noop" { + t.Errorf("Expected guard name 'noop', got %s", info["server1"]) + } + }) +} + +func TestCreateGuard(t *testing.T) { + t.Run("Create noop guard", func(t *testing.T) { + guard, err := CreateGuard("noop") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if guard.Name() != "noop" { + t.Errorf("Expected noop guard, got %s", guard.Name()) + } + }) + + t.Run("Create empty string returns noop", func(t *testing.T) { + guard, err := CreateGuard("") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if guard.Name() != "noop" { + t.Errorf("Expected noop guard, got %s", guard.Name()) + } + }) + + t.Run("Create unknown guard returns error", func(t *testing.T) { + _, err := CreateGuard("unknown-guard-type") + if err == nil { + t.Errorf("Expected error for unknown guard type") + } + }) +} + +func TestContextHelpers(t *testing.T) { + t.Run("GetAgentIDFromContext returns default", func(t *testing.T) { + ctx := context.Background() + agentID := GetAgentIDFromContext(ctx) + + if agentID != "default" { + t.Errorf("Expected 'default', got %s", agentID) + } + }) + + t.Run("SetAgentIDInContext and retrieve", func(t *testing.T) { + ctx := context.Background() + ctx = SetAgentIDInContext(ctx, "test-agent") + + agentID := GetAgentIDFromContext(ctx) + if agentID != "test-agent" { + t.Errorf("Expected 'test-agent', got %s", agentID) + } + }) + + t.Run("ExtractAgentIDFromAuthHeader Bearer", func(t *testing.T) { + agentID := ExtractAgentIDFromAuthHeader("Bearer test-token-123") + if agentID != "test-token-123" { + t.Errorf("Expected 'test-token-123', got %s", agentID) + } + }) + + t.Run("ExtractAgentIDFromAuthHeader Agent", func(t *testing.T) { + agentID := ExtractAgentIDFromAuthHeader("Agent my-agent-id") + if agentID != "my-agent-id" { + t.Errorf("Expected 'my-agent-id', got %s", agentID) + } + }) + + t.Run("ExtractAgentIDFromAuthHeader empty", func(t *testing.T) { + agentID := ExtractAgentIDFromAuthHeader("") + if agentID != "default" { + t.Errorf("Expected 'default', got %s", agentID) + } + }) +} diff --git a/internal/guard/noop.go b/internal/guard/noop.go new file mode 100644 index 00000000..b10423a5 --- /dev/null +++ b/internal/guard/noop.go @@ -0,0 +1,45 @@ +package guard + +import ( + "context" + + "github.com/githubnext/gh-aw-mcpg/internal/difc" +) + +// NoopGuard is the default guard that performs no DIFC labeling +// It allows all operations by returning empty labels (no restrictions) +type NoopGuard struct{} + +// NewNoopGuard creates a new noop guard +func NewNoopGuard() *NoopGuard { + return &NoopGuard{} +} + +// Name returns the identifier for this guard +func (g *NoopGuard) Name() string { + return "noop" +} + +// LabelResource returns an empty resource with no label requirements +// Conservatively assumes all operations could be writes +func (g *NoopGuard) LabelResource(ctx context.Context, toolName string, args interface{}, backend BackendCaller, caps *difc.Capabilities) (*difc.LabeledResource, difc.OperationType, error) { + // Empty resource = no label requirements = all operations allowed + resource := &difc.LabeledResource{ + Description: "noop resource (no restrictions)", + Secrecy: *difc.NewSecrecyLabel(), + Integrity: *difc.NewIntegrityLabel(), + Structure: nil, // No fine-grained labeling + } + + // Conservatively treat as write to be safe + // (writes are more restrictive than reads in DIFC) + return resource, difc.OperationWrite, nil +} + +// LabelResponse returns nil, indicating no fine-grained labeling +// The reference monitor will use the resource labels for the entire response +func (g *NoopGuard) LabelResponse(ctx context.Context, toolName string, result interface{}, backend BackendCaller, caps *difc.Capabilities) (difc.LabeledData, error) { + // No fine-grained labeling - return nil + // Reference monitor will use LabelResource result for entire response + return nil, nil +} diff --git a/internal/guard/registry.go b/internal/guard/registry.go new file mode 100644 index 00000000..888f7fb2 --- /dev/null +++ b/internal/guard/registry.go @@ -0,0 +1,129 @@ +package guard + +import ( + "fmt" + "log" + "sync" +) + +// Registry manages guard instances for different MCP servers +type Registry struct { + guards map[string]Guard // serverID -> guard + mu sync.RWMutex +} + +// NewRegistry creates a new guard registry +func NewRegistry() *Registry { + return &Registry{ + guards: make(map[string]Guard), + } +} + +// Register registers a guard for a specific server +func (r *Registry) Register(serverID string, guard Guard) { + r.mu.Lock() + defer r.mu.Unlock() + + r.guards[serverID] = guard + log.Printf("[Guard] Registered guard '%s' for server '%s'", guard.Name(), serverID) +} + +// Get retrieves the guard for a server, or returns a noop guard if not found +func (r *Registry) Get(serverID string) Guard { + r.mu.RLock() + defer r.mu.RUnlock() + + if guard, ok := r.guards[serverID]; ok { + return guard + } + + // Return noop guard as default + log.Printf("[Guard] No guard registered for server '%s', using noop guard", serverID) + return NewNoopGuard() +} + +// Has checks if a guard is registered for a server +func (r *Registry) Has(serverID string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + _, ok := r.guards[serverID] + return ok +} + +// Remove removes a guard registration +func (r *Registry) Remove(serverID string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.guards, serverID) + log.Printf("[Guard] Removed guard for server '%s'", serverID) +} + +// List returns all registered server IDs +func (r *Registry) List() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + serverIDs := make([]string, 0, len(r.guards)) + for id := range r.guards { + serverIDs = append(serverIDs, id) + } + return serverIDs +} + +// GetGuardInfo returns information about all registered guards +func (r *Registry) GetGuardInfo() map[string]string { + r.mu.RLock() + defer r.mu.RUnlock() + + info := make(map[string]string) + for serverID, guard := range r.guards { + info[serverID] = guard.Name() + } + return info +} + +// GuardFactory is a function that creates a guard instance +type GuardFactory func() (Guard, error) + +// RegisteredGuards maps guard names to their factory functions +var registeredGuards = make(map[string]GuardFactory) +var registeredGuardsMu sync.RWMutex + +// RegisterGuardType registers a guard type with a factory function +// This allows dynamic guard creation by name +func RegisterGuardType(name string, factory GuardFactory) { + registeredGuardsMu.Lock() + defer registeredGuardsMu.Unlock() + registeredGuards[name] = factory + log.Printf("[Guard] Registered guard type: %s", name) +} + +// CreateGuard creates a guard instance by name using registered factories +func CreateGuard(name string) (Guard, error) { + registeredGuardsMu.RLock() + defer registeredGuardsMu.RUnlock() + + // Handle built-in guards + if name == "noop" || name == "" { + return NewNoopGuard(), nil + } + + // Try to find in registered factories + if factory, ok := registeredGuards[name]; ok { + return factory() + } + + return nil, fmt.Errorf("unknown guard type: %s", name) +} + +// GetRegisteredGuardTypes returns all registered guard type names +func GetRegisteredGuardTypes() []string { + registeredGuardsMu.RLock() + defer registeredGuardsMu.RUnlock() + + types := []string{"noop"} // Always include noop + for name := range registeredGuards { + types = append(types, name) + } + return types +} diff --git a/internal/server/unified.go b/internal/server/unified.go index d8907721..28663ebb 100644 --- a/internal/server/unified.go +++ b/internal/server/unified.go @@ -8,6 +8,8 @@ import ( "sync" "github.com/githubnext/gh-aw-mcpg/internal/config" + "github.com/githubnext/gh-aw-mcpg/internal/difc" + "github.com/githubnext/gh-aw-mcpg/internal/guard" "github.com/githubnext/gh-aw-mcpg/internal/launcher" "github.com/githubnext/gh-aw-mcpg/internal/sys" sdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -44,6 +46,12 @@ type UnifiedServer struct { sessionMu sync.RWMutex tools map[string]*ToolInfo // prefixed tool name -> tool info toolsMu sync.RWMutex + + // DIFC components + guardRegistry *guard.Registry + agentRegistry *difc.AgentRegistry + capabilities *difc.Capabilities + evaluator *difc.Evaluator } // NewUnified creates a new unified MCP server @@ -56,6 +64,12 @@ func NewUnified(ctx context.Context, cfg *config.Config) (*UnifiedServer, error) ctx: ctx, sessions: make(map[string]*Session), tools: make(map[string]*ToolInfo), + + // Initialize DIFC components + guardRegistry: guard.NewRegistry(), + agentRegistry: difc.NewAgentRegistry(), + capabilities: difc.NewCapabilities(), + evaluator: difc.NewEvaluator(), } // Create MCP server @@ -66,6 +80,11 @@ func NewUnified(ctx context.Context, cfg *config.Config) (*UnifiedServer, error) us.server = server + // Register guards for all backends + for _, serverID := range l.ServerIDs() { + us.registerGuard(serverID) + } + // Register aggregated tools from all backends if err := us.registerAllTools(); err != nil { return nil, fmt.Errorf("failed to register tools: %w", err) @@ -295,19 +314,108 @@ func (us *UnifiedServer) registerSysTools() error { return nil } -// callBackendTool calls a tool on a backend server +// registerGuard registers a guard for a specific backend server +func (us *UnifiedServer) registerGuard(serverID string) { + // For now, use noop guards for all servers + // In the future, this will load guards based on configuration + // or use guard.CreateGuard() with a guard name from config + g := guard.NewNoopGuard() + us.guardRegistry.Register(serverID, g) + log.Printf("[DIFC] Registered guard '%s' for server '%s'", g.Name(), serverID) +} + +// guardBackendCaller implements guard.BackendCaller for guards to query backend metadata +type guardBackendCaller struct { + server *UnifiedServer + serverID string + ctx context.Context +} + +func (g *guardBackendCaller) CallTool(ctx context.Context, toolName string, args interface{}) (interface{}, error) { + // Make a read-only call to the backend for metadata + // This bypasses DIFC checks since it's internal to the guard + log.Printf("[DIFC] Guard calling backend %s tool %s for metadata", g.serverID, toolName) + + conn, err := launcher.GetOrLaunch(g.server.launcher, g.serverID) + if err != nil { + return nil, fmt.Errorf("failed to connect: %w", err) + } + + response, err := conn.SendRequest("tools/call", map[string]interface{}{ + "name": toolName, + "arguments": args, + }) + if err != nil { + return nil, err + } + + // Parse the result + var result interface{} + if err := json.Unmarshal(response.Result, &result); err != nil { + return nil, fmt.Errorf("failed to parse result: %w", err) + } + + return result, nil +} + +// callBackendTool calls a tool on a backend server with DIFC enforcement func (us *UnifiedServer) callBackendTool(ctx context.Context, serverID, toolName string, args interface{}) (*sdk.CallToolResult, interface{}, error) { // Note: Session validation happens at the tool registration level via closures // The closure captures the request and validates before calling this method - log.Printf("Calling tool on %s: %s", serverID, toolName) + log.Printf("Calling tool on %s: %s with DIFC enforcement", serverID, toolName) + + // **Phase 0: Extract agent ID and get/create agent labels** + agentID := guard.GetAgentIDFromContext(ctx) + agentLabels := us.agentRegistry.GetOrCreate(agentID) + log.Printf("[DIFC] Agent %s | Secrecy: %v | Integrity: %v", + agentID, agentLabels.GetSecrecyTags(), agentLabels.GetIntegrityTags()) + + // Get guard for this backend + g := us.guardRegistry.Get(serverID) + + // Create backend caller for the guard + backendCaller := &guardBackendCaller{ + server: us, + serverID: serverID, + ctx: ctx, + } - // Get connection to backend + // **Phase 1: Guard labels the resource** + resource, operation, err := g.LabelResource(ctx, toolName, args, backendCaller, us.capabilities) + if err != nil { + log.Printf("[DIFC] Guard labeling failed: %v", err) + return &sdk.CallToolResult{IsError: true}, nil, fmt.Errorf("guard labeling failed: %w", err) + } + + log.Printf("[DIFC] Resource: %s | Operation: %s | Secrecy: %v | Integrity: %v", + resource.Description, operation, resource.Secrecy.Label.GetTags(), resource.Integrity.Label.GetTags()) + + // **Phase 2: Reference Monitor performs coarse-grained access check** + isWrite := (operation == difc.OperationWrite || operation == difc.OperationReadWrite) + result := us.evaluator.Evaluate(agentLabels.Secrecy, agentLabels.Integrity, resource, operation) + + if !result.IsAllowed() { + // Access denied - log and return detailed error + log.Printf("[DIFC] Access DENIED for agent %s to %s: %s", agentID, resource.Description, result.Reason) + detailedErr := difc.FormatViolationError(result, agentLabels.Secrecy, agentLabels.Integrity, resource) + return &sdk.CallToolResult{ + Content: []sdk.Content{ + &sdk.TextContent{ + Text: detailedErr.Error(), + }, + }, + IsError: true, + }, nil, detailedErr + } + + log.Printf("[DIFC] Access ALLOWED for agent %s to %s", agentID, resource.Description) + + // **Phase 3: Execute the backend call** conn, err := launcher.GetOrLaunch(us.launcher, serverID) if err != nil { return &sdk.CallToolResult{IsError: true}, nil, fmt.Errorf("failed to connect: %w", err) } - // Call the tool response, err := conn.SendRequest("tools/call", map[string]interface{}{ "name": toolName, "arguments": args, @@ -316,13 +424,67 @@ func (us *UnifiedServer) callBackendTool(ctx context.Context, serverID, toolName return &sdk.CallToolResult{IsError: true}, nil, err } - // Parse the result - var result interface{} - if err := json.Unmarshal(response.Result, &result); err != nil { + // Parse the backend result + var backendResult interface{} + if err := json.Unmarshal(response.Result, &backendResult); err != nil { return &sdk.CallToolResult{IsError: true}, nil, fmt.Errorf("failed to parse result: %w", err) } - return nil, result, nil + // **Phase 4: Guard labels the response data (for fine-grained filtering)** + labeledData, err := g.LabelResponse(ctx, toolName, backendResult, backendCaller, us.capabilities) + if err != nil { + log.Printf("[DIFC] Response labeling failed: %v", err) + return &sdk.CallToolResult{IsError: true}, nil, fmt.Errorf("response labeling failed: %w", err) + } + + // **Phase 5: Reference Monitor performs fine-grained filtering (if applicable)** + var finalResult interface{} + if labeledData != nil { + // Guard provided fine-grained labels - check if it's a collection + if collection, ok := labeledData.(*difc.CollectionLabeledData); ok { + // Filter collection based on agent labels + filtered := us.evaluator.FilterCollection(agentLabels.Secrecy, agentLabels.Integrity, collection, operation) + + log.Printf("[DIFC] Filtered collection: %d/%d items accessible", + filtered.GetAccessibleCount(), filtered.TotalCount) + + if filtered.GetFilteredCount() > 0 { + log.Printf("[DIFC] Filtered out %d items due to DIFC policy", filtered.GetFilteredCount()) + } + + // Convert filtered data to result + finalResult, err = filtered.ToResult() + if err != nil { + return &sdk.CallToolResult{IsError: true}, nil, fmt.Errorf("failed to convert filtered data: %w", err) + } + } else { + // Simple labeled data - already passed coarse-grained check + finalResult, err = labeledData.ToResult() + if err != nil { + return &sdk.CallToolResult{IsError: true}, nil, fmt.Errorf("failed to convert labeled data: %w", err) + } + } + + // **Phase 6: Accumulate labels from this operation (for reads)** + if !isWrite { + overall := labeledData.Overall() + agentLabels.AccumulateFromRead(overall) + log.Printf("[DIFC] Agent %s accumulated labels | Secrecy: %v | Integrity: %v", + agentID, agentLabels.GetSecrecyTags(), agentLabels.GetIntegrityTags()) + } + } else { + // No fine-grained labeling - use original backend result + finalResult = backendResult + + // **Phase 6: Accumulate labels from resource (for reads)** + if !isWrite { + agentLabels.AccumulateFromRead(resource) + log.Printf("[DIFC] Agent %s accumulated labels | Secrecy: %v | Integrity: %v", + agentID, agentLabels.GetSecrecyTags(), agentLabels.GetIntegrityTags()) + } + } + + return nil, finalResult, nil } // Run starts the unified MCP server on the specified transport