Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 81 additions & 14 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`.
Expand Down Expand Up @@ -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"
}
}
}
Expand Down Expand Up @@ -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"
}
}
```
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
66 changes: 66 additions & 0 deletions internal/config/config_guardpolicies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
31 changes: 30 additions & 1 deletion internal/config/guard_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
18 changes: 16 additions & 2 deletions internal/config/guard_policy_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,37 @@ 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")
_, err := NormalizeGuardPolicy(policy)
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")
Expand Down
Loading
Loading