diff --git a/config.example.toml b/config.example.toml index 7755f6cca..a5a2896d1 100644 --- a/config.example.toml +++ b/config.example.toml @@ -118,13 +118,23 @@ GITHUB_PERSONAL_ACCESS_TOKEN = "" # Pass through from host # The accept patterns must match the agent's secrecy tags, which depend on # the GitHub guard's allow-only.repos configuration. # -# For repos="all" or repos="public" (agent has no secrecy tags): +# CRITICAL: The gh-aw compiler MUST set sink-visibility based on the target +# repo's visibility to prevent data exfiltration (GitLost vulnerability class). +# +# For a PUBLIC target repo (blocks tainted agents): # [servers.safeoutputs.guard_policies.write-sink] # accept = ["*"] +# sink-visibility = "public" # -# For scoped repos (agent has secrecy tags matching the scope): +# For a PRIVATE target repo with scoped repos: # [servers.safeoutputs.guard_policies.write-sink] # accept = ["private:myorg/api-*", "private:myorg/web-*"] +# sink-visibility = "private" +# +# For a PRIVATE target repo with broad access (repos="all"): +# [servers.safeoutputs.guard_policies.write-sink] +# accept = ["*"] +# sink-visibility = "private" # # Accept pattern format: "visibility:owner/repo-pattern" # Visibility prefixes: private, public, internal diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index bdee47219..0e68ef753 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -38,6 +38,7 @@ args = ["run", "--rm", "-i", "ghcr.io/github/safe-outputs:latest"] [servers.safeoutputs.guard_policies.write-sink] accept = ["private:github/gh-aw-mcpg", "private:github/gh-aw"] +sink-visibility = "private" ``` **Important**: Per [MCP Gateway Specification Section 3.2.1](https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/mcp-gateway.md#321-containerization-requirement), all stdio-based MCP servers MUST be containerized. The gateway rejects configurations where `command` is not `"docker"`. @@ -77,7 +78,8 @@ JSON configuration is the primary format for containerized deployments. Pass via "container": "ghcr.io/github/safe-outputs:latest", "guard-policies": { "write-sink": { - "accept": ["private:github/gh-aw-mcpg", "private:github/gh-aw"] + "accept": ["private:github/gh-aw-mcpg", "private:github/gh-aw"], + "sink-visibility": "private" } } } @@ -297,11 +299,52 @@ secrecy and integrity labels. The write-sink guard solves this by classifying al operations as writes and accepting writes from agents whose secrecy labels match the configured `accept` patterns. +#### sink-visibility (CRITICAL for security) + +The `sink-visibility` field declares the visibility of the output channel's target +repository. **The gh-aw compiler MUST check the safe-outputs target repository visibility +and set this field accordingly** to prevent the GitLost vulnerability class — data +exfiltration from private repos to public outputs via prompt injection. + +| `sink-visibility` | Behavior | Use when | +|---|---|---| +| `"public"` | Blocks any agent with non-empty secrecy from writing | Target repo is public | +| `"private"` | Standard accept-pattern matching | Target repo is private | +| `"internal"` | Same as `"private"` | Target repo is org-internal | +| *(omitted)* | Standard accept-pattern matching (backward compatible) | Legacy configs | + +When `sink-visibility` is `"public"`, the guard sets resource secrecy to EMPTY regardless +of `accept` patterns. The DIFC evaluator's write check (`agentSecrecy ⊆ resourceSecrecy`) +then fails for any agent with non-empty secrecy — because no non-empty set is a subset +of the empty set. This blocks exfiltration even if the agent was tricked into reading +private data via prompt injection. + +**Example: Public repo target (blocks tainted agents):** +```json +"guard-policies": { + "write-sink": { + "accept": ["*"], + "sink-visibility": "public" + } +} +``` + +**Example: Private repo target (standard matching):** +```json +"guard-policies": { + "write-sink": { + "accept": ["private:owner/repo1", "private:owner/repo2"], + "sink-visibility": "private" + } +} +``` + For exact repos (`repos=["owner/repo1", "owner/repo2"]`): ```json "guard-policies": { "write-sink": { - "accept": ["private:owner/repo1", "private:owner/repo2"] + "accept": ["private:owner/repo1", "private:owner/repo2"], + "sink-visibility": "private" } } ``` @@ -310,33 +353,43 @@ For prefix wildcard repos (`repos=["owner/prefix*"]`): ```json "guard-policies": { "write-sink": { - "accept": ["private:owner/prefix*"] + "accept": ["private:owner/prefix*"], + "sink-visibility": "private" + } +} +``` + +For broad access (`repos="all"` or `repos="public"`) with a **public** target repo: +```json +"guard-policies": { + "write-sink": { + "accept": ["*"], + "sink-visibility": "public" } } ``` -For broad access (`repos="all"` or `repos="public"`): +For broad access (`repos="all"` or `repos="public"`) with a **private** target repo: ```json "guard-policies": { "write-sink": { - "accept": ["*"] + "accept": ["*"], + "sink-visibility": "private" } } ``` TOML equivalents: ```toml -# Exact repos +# Public sink (blocks tainted agents) [servers.safeoutputs.guard_policies.write-sink] -accept = ["private:owner/repo1", "private:owner/repo2"] - -# Prefix wildcard repos -[servers.safeoutputs.guard_policies.write-sink] -accept = ["private:owner/prefix*"] +accept = ["*"] +sink-visibility = "public" -# Broad access (repos="all" or repos="public") +# Private sink (standard matching) [servers.safeoutputs.guard_policies.write-sink] -accept = ["*"] +accept = ["private:owner/repo1", "private:owner/repo2"] +sink-visibility = "private" ``` - **`accept`**: Array of secrecy tags the sink accepts (exact string match against agent secrecy tags — not glob patterns) @@ -347,8 +400,15 @@ accept = ["*"] - `"public:owner/repo*"` - Matches agent secrecy tag for public repos matching a prefix - `"internal:owner/repo*"` - Matches agent secrecy tag for internal repos matching a prefix +- **`sink-visibility`** *(optional, strongly recommended)*: Declares the visibility of the safe-outputs target repository. + - `"public"` — Target is a public repository. **Overrides accept patterns**: resource secrecy is set to empty, blocking any tainted agent. + - `"private"` — Target is a private repository. Standard accept-pattern matching applies. + - `"internal"` — Target is an org-internal repository. Same behavior as `"private"`. + - When omitted, falls back to accept-pattern matching only (backward compatible but less secure for public targets). + - **How it works**: The write-sink classifies all operations as writes. For DIFC write checks: - - Resource secrecy is set to the `accept` patterns → agent secrecy ⊆ resource secrecy passes + - If `sink-visibility` is `"public"`: resource secrecy = `[]` → blocks all tainted agents + - Otherwise: resource secrecy is set to the `accept` patterns → agent secrecy ⊆ resource secrecy passes - Resource integrity is left empty → no integrity requirements for writes - **When to use**: Required for **all** output servers (`safeoutputs`, etc.) when DIFC guards are enabled on any server in the configuration @@ -379,6 +439,13 @@ to the agent via `label_agent`. The mapping depends on the `repos` configuration - `accept` can be a superset of the agent's secrecy (extra entries are harmless) - `min-integrity` does not affect these rules (it only changes integrity labels) +**Sink visibility (CRITICAL for gh-aw compiler)**: +- The gh-aw compiler **MUST** check the safe-outputs target repository visibility and set `sink-visibility` accordingly +- Public target repo → `sink-visibility: "public"` — blocks tainted agents regardless of `accept` +- Private target repo → `sink-visibility: "private"` — standard accept matching +- Internal target repo → `sink-visibility: "internal"` — same as private +- Without `sink-visibility: "public"`, an agent tricked into reading private data (via prompt injection) can exfiltrate it to a public repo comment (GitLost vulnerability) + ## Custom Schemas (`customSchemas`) The `customSchemas` top-level field allows you to define custom server types beyond the built-in `"stdio"` and `"http"` types. Each custom type maps to an HTTPS schema URL that describes its configuration format. diff --git a/internal/config/config_guardpolicies_test.go b/internal/config/config_guardpolicies_test.go index e00b18cdc..8dd6e44ac 100644 --- a/internal/config/config_guardpolicies_test.go +++ b/internal/config/config_guardpolicies_test.go @@ -632,3 +632,69 @@ func TestValidateWriteSinkPolicy_WildcardNotFirst(t *testing.T) { assert.Error(t, err, `accept=["private:org/repo", "*"] should be invalid`) assert.ErrorContains(t, err, "wildcard") } + +// TestValidateWriteSinkPolicy_SinkVisibility_Valid tests that valid sink-visibility +// values ("public", "private", "internal") pass validation. +func TestValidateWriteSinkPolicy_SinkVisibility_Valid(t *testing.T) { + tests := []struct { + name string + visibility string + }{ + {name: "public", visibility: "public"}, + {name: "private", visibility: "private"}, + {name: "internal", visibility: "internal"}, + {name: "empty (omitted)", visibility: ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + policy := &WriteSinkPolicy{Accept: []string{"*"}, SinkVisibility: tc.visibility} + err := ValidateWriteSinkPolicy(policy) + assert.NoError(t, err, "sink-visibility=%q should be valid", tc.visibility) + }) + } +} + +// TestValidateWriteSinkPolicy_SinkVisibility_Invalid tests that invalid sink-visibility +// values are rejected. +func TestValidateWriteSinkPolicy_SinkVisibility_Invalid(t *testing.T) { + tests := []struct { + name string + visibility string + }{ + {name: "unknown value", visibility: "unknown"}, + {name: "typo", visibility: "publi"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + policy := &WriteSinkPolicy{Accept: []string{"*"}, SinkVisibility: tc.visibility} + err := ValidateWriteSinkPolicy(policy) + assert.Error(t, err, "sink-visibility=%q should be invalid", tc.visibility) + assert.ErrorContains(t, err, "sink-visibility") + }) + } +} + +// TestValidateWriteSinkPolicy_SinkVisibility_CaseInsensitive tests that +// sink-visibility validation is case-insensitive. +func TestValidateWriteSinkPolicy_SinkVisibility_CaseInsensitive(t *testing.T) { + tests := []string{"PUBLIC", "Private", "INTERNAL", "Public"} + for _, v := range tests { + t.Run(v, func(t *testing.T) { + policy := &WriteSinkPolicy{Accept: []string{"*"}, SinkVisibility: v} + err := ValidateWriteSinkPolicy(policy) + assert.NoError(t, err, "sink-visibility=%q should be valid (case-insensitive)", v) + }) + } +} + +// TestValidateWriteSinkPolicy_SinkVisibility_PublicWithScopedAccept tests that +// sink-visibility="public" can be combined with any accept pattern (the guard +// overrides accept behavior at runtime when visibility is public). +func TestValidateWriteSinkPolicy_SinkVisibility_PublicWithScopedAccept(t *testing.T) { + policy := &WriteSinkPolicy{ + Accept: []string{"private:github/gh-aw*"}, + SinkVisibility: "public", + } + err := ValidateWriteSinkPolicy(policy) + assert.NoError(t, err, "sink-visibility=public with scoped accept should be valid") +} diff --git a/internal/config/guard_policy.go b/internal/config/guard_policy.go index b0eb1ca20..faf7c9b40 100644 --- a/internal/config/guard_policy.go +++ b/internal/config/guard_policy.go @@ -57,8 +57,25 @@ type GuardPolicy struct { // WriteSinkPolicy configures a write-sink guard that accepts writes from // agents carrying the listed secrecy labels. +// +// The optional SinkVisibility field declares the visibility of the output +// channel's target repository. When set to "public", the write-sink guard +// blocks any agent with a non-empty secrecy label from writing — regardless +// of the accept patterns — because public outputs are readable by anyone on +// the internet and would leak private data. +// +// Valid values: +// - "public" — target is a public repo; agents with non-empty secrecy are BLOCKED +// - "private" — target is a private repo; standard accept-pattern matching applies +// - "internal" — target is an org-internal repo; same behavior as "private" +// +// When omitted, the guard falls back to accept-pattern matching only (backward +// compatible). The gh-aw compiler MUST check the safe-outputs target repository +// visibility and set this field accordingly to prevent data exfiltration from +// private repos to public outputs (see: GitLost vulnerability class). type WriteSinkPolicy struct { - Accept []string `toml:"accept" json:"accept"` + Accept []string `toml:"accept" json:"accept"` + SinkVisibility string `toml:"sink-visibility" json:"sink-visibility,omitempty"` } // AllowOnlyPolicy configures scope and minimum required integrity. @@ -348,6 +365,18 @@ func unmarshalStringListOrExpression(raw json.RawMessage) ([]string, error) { // the noop guard integrity violation (see WriteSinkGuard godoc). // The wildcard "*" must be the sole entry — it cannot be mixed with other patterns. // +// Sink Visibility: +// +// When sink-visibility is "public", the write-sink guard ignores accept patterns +// entirely and blocks ANY agent with non-empty secrecy. This prevents data +// exfiltration from private repos to public outputs (GitLost vulnerability class). +// +// The gh-aw compiler MUST check the safe-outputs target repository visibility +// and set sink-visibility accordingly: +// - Public repo target → sink-visibility = "public" +// - Private repo target → sink-visibility = "private" +// - Internal repo target → sink-visibility = "internal" +// // Note: min-integrity has no effect on these rules (it only affects integrity labels). var WriteSinkAcceptRules = "see godoc" // exists for documentation only diff --git a/internal/config/guard_policy_validation.go b/internal/config/guard_policy_validation.go index 05b8653ba..7729ae7f6 100644 --- a/internal/config/guard_policy_validation.go +++ b/internal/config/guard_policy_validation.go @@ -18,7 +18,7 @@ func ValidateGuardPolicy(policy *GuardPolicy) error { return errors.New(errMsgPolicyMissingKey) } if policy.WriteSink != nil { - logGuardPolicy.Printf("ValidateGuardPolicy: delegating to write-sink validation, acceptCount=%d", len(policy.WriteSink.Accept)) + logGuardPolicy.Printf("ValidateGuardPolicy: delegating to write-sink validation, acceptCount=%d, sinkVisibility=%q", len(policy.WriteSink.Accept), policy.WriteSink.SinkVisibility) return ValidateWriteSinkPolicy(policy.WriteSink) } logGuardPolicy.Print("ValidateGuardPolicy: delegating to allow-only normalization") @@ -26,15 +26,29 @@ func ValidateGuardPolicy(policy *GuardPolicy) error { return err } +// validSinkVisibilityValues defines the allowed values for sink-visibility. +var validSinkVisibilityValues = map[string]bool{ + "public": true, + "private": true, + "internal": true, +} + // ValidateWriteSinkPolicy validates a write-sink policy. func ValidateWriteSinkPolicy(ws *WriteSinkPolicy) error { if ws == nil { return fmt.Errorf("write-sink policy must not be nil") } - logGuardPolicy.Printf("ValidateWriteSinkPolicy: acceptCount=%d", len(ws.Accept)) + logGuardPolicy.Printf("ValidateWriteSinkPolicy: acceptCount=%d, sinkVisibility=%q", len(ws.Accept), ws.SinkVisibility) if len(ws.Accept) == 0 { return fmt.Errorf("write-sink.accept must contain at least one entry") } + // Validate sink-visibility if provided + if ws.SinkVisibility != "" { + normalized := strings.ToLower(strings.TrimSpace(ws.SinkVisibility)) + if !validSinkVisibilityValues[normalized] { + return fmt.Errorf("write-sink.sink-visibility must be one of: public, private, internal; got %q", ws.SinkVisibility) + } + } // Special case: ["*"] is a valid wildcard that accepts all writes if len(ws.Accept) == 1 && strings.TrimSpace(ws.Accept[0]) == "*" { logGuardPolicy.Print("ValidateWriteSinkPolicy: wildcard accept, policy is valid") diff --git a/internal/guard/write_sink.go b/internal/guard/write_sink.go index 2682fcd19..14b02ec94 100644 --- a/internal/guard/write_sink.go +++ b/internal/guard/write_sink.go @@ -2,6 +2,7 @@ package guard import ( "context" + "strings" "github.com/github/gh-aw-mcpg/internal/difc" "github.com/github/gh-aw-mcpg/internal/logger" @@ -10,6 +11,13 @@ import ( var logWriteSink = logger.New("guard:write-sink") +// SinkVisibility constants for the write-sink guard. +const ( + SinkVisibilityPublic = "public" + SinkVisibilityPrivate = "private" + SinkVisibilityInternal = "internal" +) + // WriteSinkGuard is a guard for write-only output channels (e.g., safe-outputs). // // When DIFC is enabled, an agent that reads from a guarded server (like GitHub) @@ -31,35 +39,69 @@ var logWriteSink = logger.New("guard:write-sink") // assigns OperationRead + empty labels, causing integrity violations when the // agent has integrity tags from other guards. // +// # Sink Visibility +// +// When sink-visibility is set to "public", the guard sets resource secrecy to +// EMPTY regardless of accept patterns. This means any agent with non-empty +// secrecy will be blocked by the DIFC evaluator (agentSecrecy ⊆ {} fails for +// any non-empty agent secrecy). This prevents data exfiltration from private +// repos to public outputs — the core defense against the GitLost vulnerability +// class where prompt injection causes an agent to read private data and then +// write it to a public location. +// +// When sink-visibility is "private", "internal", or omitted, the guard uses +// the configured accept patterns (existing behavior). +// // Configuration examples: // -// // For repos="all" or repos="public" (agent has no secrecy): +// // Public sink — blocks tainted agents: // "guard-policies": { // "write-sink": { -// "accept": ["*"] +// "accept": ["*"], +// "sink-visibility": "public" // } // } // -// // For scoped repos (agent has secrecy tags): +// // Private sink — standard accept matching: // "guard-policies": { // "write-sink": { -// "accept": ["private:github/gh-aw*"] +// "accept": ["private:github/gh-aw*"], +// "sink-visibility": "private" +// } +// } +// +// // Backward compatible (no visibility specified): +// "guard-policies": { +// "write-sink": { +// "accept": ["*"] // } // } type WriteSinkGuard struct { - acceptTags []difc.Tag + acceptTags []difc.Tag + sinkVisibility string } // NewWriteSinkGuard creates a new write-sink guard with the specified accept patterns. // Each pattern becomes a secrecy tag on the resource, allowing agents with // matching secrecy to write to this sink. func NewWriteSinkGuard(accept []string) *WriteSinkGuard { + return NewWriteSinkGuardWithVisibility(accept, "") +} + +// NewWriteSinkGuardWithVisibility creates a new write-sink guard with accept +// patterns and an explicit sink visibility declaration. +// +// When sinkVisibility is "public", the guard will block any agent with +// non-empty secrecy from writing, regardless of accept patterns. This prevents +// exfiltration of private data to public outputs. +func NewWriteSinkGuardWithVisibility(accept []string, sinkVisibility string) *WriteSinkGuard { tags := make([]difc.Tag, len(accept)) for i, a := range accept { tags[i] = difc.Tag(a) } - logWriteSink.Printf("Creating write-sink guard with %d accept patterns", len(tags)) - return &WriteSinkGuard{acceptTags: tags} + normalized := strings.ToLower(strings.TrimSpace(sinkVisibility)) + logWriteSink.Printf("Creating write-sink guard with %d accept patterns, sink-visibility=%q", len(tags), normalized) + return &WriteSinkGuard{acceptTags: tags, sinkVisibility: normalized} } // Name returns the identifier for this guard @@ -74,10 +116,16 @@ func (g *WriteSinkGuard) LabelAgent(_ context.Context, _ interface{}, _ BackendC return emptyAgentLabelsResult(difc.ModeFilter), nil } -// LabelResource sets the resource's secrecy to the configured accept patterns -// and classifies the operation as a write. +// LabelResource sets the resource's secrecy based on sink visibility and +// configured accept patterns, classifying the operation as a write. // -// For writes the DIFC evaluator checks: +// When sink-visibility is "public", resource secrecy is left EMPTY. This means +// the DIFC evaluator's write check (agentSecrecy ⊆ resourceSecrecy) will fail +// for any agent with non-empty secrecy — blocking exfiltration of private data +// to public outputs. +// +// When sink-visibility is "private", "internal", or unset, the resource secrecy +// is set to the configured accept patterns (standard behavior): // - agentSecrecy ⊆ resource.Secrecy (no secret information leak) // - resource.Integrity ⊆ agentIntegrity (agent is trusted enough) // @@ -86,15 +134,37 @@ func (g *WriteSinkGuard) LabelAgent(_ context.Context, _ interface{}, _ BackendC // By leaving the resource integrity empty, the second check also passes // because the agent has all zero of the (empty) required integrity tags. func (g *WriteSinkGuard) LabelResource(_ context.Context, toolName string, toolArgs interface{}, _ BackendCaller, _ *difc.Capabilities) (*difc.LabeledResource, difc.OperationType, error) { - logWriteSink.Printf("LabelResource: tool=%s, operation=write, accept_tags=%d", toolName, len(g.acceptTags)) g.auditURLsInBody(toolName, toolArgs) + if g.sinkVisibility == SinkVisibilityPublic { + // Public sink: resource secrecy stays EMPTY. + // Any agent with non-empty secrecy will be blocked by the evaluator. + logWriteSink.Printf("LabelResource: tool=%s, operation=write, sink-visibility=public (empty resource secrecy — tainted agents blocked)", toolName) + resource := difc.NewLabeledResource("write-sink:public (" + toolName + ")") + return resource, difc.OperationWrite, nil + } + + // Private/internal/unset: use configured accept patterns + logWriteSink.Printf("LabelResource: tool=%s, operation=write, sink-visibility=%s, accept_tags=%d", toolName, g.effectiveVisibility(), len(g.acceptTags)) resource := difc.NewLabeledResource("write-sink (" + toolName + ")") resource.Secrecy = *difc.NewSecrecyLabel(g.acceptTags...) return resource, difc.OperationWrite, nil } +// effectiveVisibility returns the sink visibility for logging, defaulting to "unset". +func (g *WriteSinkGuard) effectiveVisibility() string { + if g.sinkVisibility == "" { + return "unset" + } + return g.sinkVisibility +} + +// SinkVisibility returns the configured sink visibility value. +func (g *WriteSinkGuard) SinkVisibility() string { + return g.sinkVisibility +} + func (g *WriteSinkGuard) auditURLsInBody(toolName string, args interface{}) { if !logger.URLDomainAuditEnabled() || args == nil { return diff --git a/internal/guard/write_sink_test.go b/internal/guard/write_sink_test.go index b0875331b..82cbf5f13 100644 --- a/internal/guard/write_sink_test.go +++ b/internal/guard/write_sink_test.go @@ -548,3 +548,178 @@ func TestWriteSinkGuard_WildcardAccept_TaintedAgent(t *testing.T) { assert.True(t, result.IsAllowed(), "wildcard accept should allow writes from any tainted agent; got: %s", result.Reason) } + +// ============================================================================= +// Sink Visibility Tests +// +// These tests verify the sink-visibility feature that blocks tainted agents +// from writing to public output channels. This is the core defense against +// the GitLost vulnerability class. +// ============================================================================= + +// TestWriteSinkGuard_SinkVisibility_Public_BlocksTaintedAgent tests the key +// security property: when sink-visibility is "public", any agent with non-empty +// secrecy is BLOCKED from writing. +func TestWriteSinkGuard_SinkVisibility_Public_BlocksTaintedAgent(t *testing.T) { + g := NewWriteSinkGuardWithVisibility([]string{"*"}, "public") + + // Agent tainted with private secrecy (e.g., read from private repo) + agentSecrecy := difc.NewSecrecyLabel([]difc.Tag{"private:github/secret-repo"}...) + agentIntegrity := difc.NewIntegrityLabel() + + resource, operation, err := g.LabelResource(context.Background(), "create_issue_comment", nil, nil, nil) + require.NoError(t, err) + assert.Equal(t, difc.OperationWrite, operation) + + // Resource secrecy should be EMPTY for public sinks + secrecyTags := resource.Secrecy.Label.GetTags() + assert.Empty(t, secrecyTags, "public sink resource should have empty secrecy") + + evaluator := difc.NewEvaluatorWithMode(difc.EnforcementFilter) + result := evaluator.Evaluate(agentSecrecy, agentIntegrity, resource, operation) + assert.False(t, result.IsAllowed(), + "public sink must BLOCK tainted agents (GitLost defense)") + assert.Contains(t, result.Reason, "secrecy", + "denial reason should mention secrecy constraint") +} + +// TestWriteSinkGuard_SinkVisibility_Public_AllowsCleanAgent tests that an +// agent with empty secrecy (no private data read) CAN write to public sinks. +func TestWriteSinkGuard_SinkVisibility_Public_AllowsCleanAgent(t *testing.T) { + g := NewWriteSinkGuardWithVisibility([]string{"*"}, "public") + + // Agent with empty secrecy (hasn't read any private repos) + agentSecrecy := difc.NewSecrecyLabel() + agentIntegrity := difc.NewIntegrityLabel() + + resource, operation, err := g.LabelResource(context.Background(), "create_issue_comment", nil, nil, nil) + require.NoError(t, err) + + evaluator := difc.NewEvaluatorWithMode(difc.EnforcementFilter) + result := evaluator.Evaluate(agentSecrecy, agentIntegrity, resource, operation) + assert.True(t, result.IsAllowed(), + "public sink should allow writes from clean (untainted) agents; got: %s", result.Reason) +} + +// TestWriteSinkGuard_SinkVisibility_Private_AllowsMatchingAgent tests that +// sink-visibility="private" uses standard accept-pattern matching. +func TestWriteSinkGuard_SinkVisibility_Private_AllowsMatchingAgent(t *testing.T) { + g := NewWriteSinkGuardWithVisibility([]string{"private:github/gh-aw*"}, "private") + + agentSecrecy := difc.NewSecrecyLabel([]difc.Tag{"private:github/gh-aw*"}...) + agentIntegrity := difc.NewIntegrityLabel() + + resource, operation, err := g.LabelResource(context.Background(), "create_issue", nil, nil, nil) + require.NoError(t, err) + + evaluator := difc.NewEvaluatorWithMode(difc.EnforcementFilter) + result := evaluator.Evaluate(agentSecrecy, agentIntegrity, resource, operation) + assert.True(t, result.IsAllowed(), + "private sink should allow writes from agents with matching secrecy; got: %s", result.Reason) +} + +// TestWriteSinkGuard_SinkVisibility_Private_BlocksMismatchedAgent tests that +// sink-visibility="private" still blocks agents with extra secrecy tags. +func TestWriteSinkGuard_SinkVisibility_Private_BlocksMismatchedAgent(t *testing.T) { + g := NewWriteSinkGuardWithVisibility([]string{"private:github/gh-aw*"}, "private") + + // Agent has secrecy from a repo NOT covered by accept + agentSecrecy := difc.NewSecrecyLabel([]difc.Tag{"private:github/gh-aw*", "private:other-org/secret"}...) + agentIntegrity := difc.NewIntegrityLabel() + + resource, operation, err := g.LabelResource(context.Background(), "create_issue", nil, nil, nil) + require.NoError(t, err) + + evaluator := difc.NewEvaluatorWithMode(difc.EnforcementFilter) + result := evaluator.Evaluate(agentSecrecy, agentIntegrity, resource, operation) + assert.False(t, result.IsAllowed(), + "private sink should block agents with uncovered secrecy tags") +} + +// TestWriteSinkGuard_SinkVisibility_Internal_BehavesLikePrivate tests that +// sink-visibility="internal" has the same behavior as "private". +func TestWriteSinkGuard_SinkVisibility_Internal_BehavesLikePrivate(t *testing.T) { + g := NewWriteSinkGuardWithVisibility([]string{"private:github/gh-aw*"}, "internal") + + agentSecrecy := difc.NewSecrecyLabel([]difc.Tag{"private:github/gh-aw*"}...) + agentIntegrity := difc.NewIntegrityLabel() + + resource, operation, err := g.LabelResource(context.Background(), "create_issue", nil, nil, nil) + require.NoError(t, err) + + evaluator := difc.NewEvaluatorWithMode(difc.EnforcementFilter) + result := evaluator.Evaluate(agentSecrecy, agentIntegrity, resource, operation) + assert.True(t, result.IsAllowed(), + "internal sink should behave like private (allow matching secrecy); got: %s", result.Reason) +} + +// TestWriteSinkGuard_SinkVisibility_Unset_BackwardCompatible tests that +// omitting sink-visibility preserves backward-compatible behavior (wildcard +// accept allows all writes). +func TestWriteSinkGuard_SinkVisibility_Unset_BackwardCompatible(t *testing.T) { + // No visibility specified — same as NewWriteSinkGuard + g := NewWriteSinkGuardWithVisibility([]string{"*"}, "") + + // Tainted agent — should be allowed (backward compat) + agentSecrecy := difc.NewSecrecyLabel([]difc.Tag{"private:github/secret-repo"}...) + agentIntegrity := difc.NewIntegrityLabel() + + resource, operation, err := g.LabelResource(context.Background(), "create_issue_comment", nil, nil, nil) + require.NoError(t, err) + + evaluator := difc.NewEvaluatorWithMode(difc.EnforcementFilter) + result := evaluator.Evaluate(agentSecrecy, agentIntegrity, resource, operation) + assert.True(t, result.IsAllowed(), + "unset sink-visibility with wildcard accept should preserve backward compat; got: %s", result.Reason) +} + +// TestWriteSinkGuard_SinkVisibility_Public_ResourceDescription tests that +// the resource description includes visibility info for debugging. +func TestWriteSinkGuard_SinkVisibility_Public_ResourceDescription(t *testing.T) { + g := NewWriteSinkGuardWithVisibility([]string{"*"}, "public") + + resource, _, err := g.LabelResource(context.Background(), "create_issue", nil, nil, nil) + require.NoError(t, err) + assert.Contains(t, resource.Description, "public", + "resource description should indicate public sink for debugging") +} + +// TestWriteSinkGuard_SinkVisibility_Accessor tests the SinkVisibility() accessor. +func TestWriteSinkGuard_SinkVisibility_Accessor(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"public", "public"}, + {"private", "private"}, + {"internal", "internal"}, + {"", ""}, + {" Public ", "public"}, // normalized + } + for _, tc := range tests { + g := NewWriteSinkGuardWithVisibility([]string{"*"}, tc.input) + assert.Equal(t, tc.expected, g.SinkVisibility()) + } +} + +// TestWriteSinkGuard_SinkVisibility_Public_MultipleTaintedTags tests that +// even an agent with many private secrecy tags is blocked by public sink. +func TestWriteSinkGuard_SinkVisibility_Public_MultipleTaintedTags(t *testing.T) { + g := NewWriteSinkGuardWithVisibility([]string{"*"}, "public") + + // Agent accumulated secrecy from multiple private repos + agentSecrecy := difc.NewSecrecyLabel([]difc.Tag{ + "private:org1/repo-a", + "private:org2/repo-b", + "private:org3/repo-c", + }...) + agentIntegrity := difc.NewIntegrityLabel() + + resource, operation, err := g.LabelResource(context.Background(), "create_issue_comment", nil, nil, nil) + require.NoError(t, err) + + evaluator := difc.NewEvaluatorWithMode(difc.EnforcementFilter) + result := evaluator.Evaluate(agentSecrecy, agentIntegrity, resource, operation) + assert.False(t, result.IsAllowed(), + "public sink must block agents with ANY non-empty secrecy (GitLost defense)") +} diff --git a/internal/server/guard_init.go b/internal/server/guard_init.go index fa51e0b30..852768ad5 100644 --- a/internal/server/guard_init.go +++ b/internal/server/guard_init.go @@ -61,8 +61,8 @@ func (us *UnifiedServer) registerGuard(serverID string) error { if g == nil { // Check if server has a write-sink policy — create WriteSinkGuard directly if ws := us.resolveWriteSinkPolicy(serverID); ws != nil { - g = guard.NewWriteSinkGuard(ws.Accept) - logger.LogInfoToServer(serverID, "difc", "Created write-sink guard with %d accept patterns", len(ws.Accept)) + g = guard.NewWriteSinkGuardWithVisibility(ws.Accept, ws.SinkVisibility) + logger.LogInfoToServer(serverID, "difc", "Created write-sink guard with %d accept patterns, sink-visibility=%q", len(ws.Accept), ws.SinkVisibility) } }