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
42 changes: 33 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,24 +238,46 @@ For the complete JSON configuration specification with all validation rules, see

##### write-sink (output servers)

Marks a server as a write-only output channel. When an agent reads from a guarded server
(e.g., GitHub with `allow-only`), it acquires secrecy and integrity labels. Writing to an
unguarded server would fail DIFC checks. The write-sink guard solves this by accepting
writes from agents whose secrecy labels match the configured `accept` patterns.
Marks a server as a write-only output channel. **Write-sink is required for ALL output
servers** (e.g., `safeoutputs`) when DIFC guards are enabled on any other server. Without
it, the output server gets a noop guard that classifies operations as reads with empty
labels, causing integrity violations when the agent has integrity tags from other guards.

When an agent reads from a guarded server (e.g., GitHub with `allow-only`), it acquires
secrecy and integrity labels. The write-sink guard solves this by classifying all
operations as writes and accepting writes from agents whose secrecy labels match the
configured `accept` patterns.

For scoped repos (`repos=["owner/repo"]`, `repos=["owner/*"]`, etc.):
```json
"guard-policies": {
"write-sink": {
"accept": ["private:github/gh-aw*"]
}
}
```
TOML equivalent:

For broad access (`repos="all"` or `repos="public"`):
```json
"guard-policies": {
"write-sink": {
"accept": ["*"]
}
}
```

TOML equivalents:
```toml
# Scoped repos
[servers.safeoutputs.guard_policies.write-sink]
Accept = ["private:github/gh-aw*"]

# Broad access (repos="all" or repos="public")
[servers.safeoutputs.guard_policies.write-sink]
Accept = ["*"]
```
- **`accept`**: Array of secrecy label patterns the sink accepts
- `"*"` - **Wildcard**: Accept writes from agents with any secrecy (must be the sole entry). Use for `repos="all"` or `repos="public"`.
- `"private:owner/repo"` - Accept writes from agents that accessed a specific private repo
- `"private:owner/prefix*"` - Accept writes from agents that accessed private repos matching the prefix
- `"private:owner"` - Accept writes from agents that accessed any repo in the owner's org (bare owner format)
Expand All @@ -264,7 +286,7 @@ For the complete JSON configuration specification with all validation rules, see
- **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
- Resource integrity is left empty → no integrity requirements for writes
- **When to use**: For servers like `safeoutputs` that buffer agent outputs for review, or any server that only receives data from the agent
- **When to use**: Required for **all** output servers (`safeoutputs`, etc.) when DIFC guards are enabled on any server in the configuration

##### Mapping allow-only repos to write-sink accept

Expand All @@ -273,16 +295,18 @@ For the complete JSON configuration specification with all validation rules, see

| `allow-only.repos` | Agent secrecy tags | `write-sink.accept` |
|---|---|---|
| `"all"` | `[]` (none) | Not needed |
| `"public"` | `[]` (none) | Not needed |
| `"all"` | `[]` (none) | `["*"]` (wildcard) |
| `"public"` | `[]` (none) | `["*"]` (wildcard) |
| `["owner/repo"]` | `["private:owner/repo"]` | `["private:owner/repo"]` |
| `["owner/*"]` | `["private:owner"]` | `["private:owner"]` |
| `["owner/prefix*"]` | `["private:owner/prefix*"]` | `["private:owner/prefix*"]` |
| `["O/R1", "O/R2"]` | `["private:O/R1", "private:O/R2"]` | `["private:O/R1", "private:O/R2"]` |
| `["O1/*", "O2/R"]` | `["private:O1", "private:O2/R"]` | `["private:O1", "private:O2/R"]` |

**Key rules**:
- `repos="all"` or `repos="public"` → no secrecy tags → write-sink not required
- `repos="all"` or `repos="public"` → no secrecy tags → use `accept: ["*"]` (wildcard)
- Write-sink is **required for ALL output servers** when DIFC guards are enabled (prevents noop guard integrity violations)
- `accept: ["*"]` is a special wildcard that accepts writes from agents with any secrecy; it must be the sole entry
- `repos=["owner/*"]` (owner wildcard) → bare owner tag `"private:owner"` (no `/*` suffix)
- `repos=["owner/prefix*"]` (prefix wildcard) → `"private:owner/prefix*"` (suffix preserved)
- `repos=["owner/repo"]` (exact) → `"private:owner/repo"`
Expand Down
26 changes: 20 additions & 6 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -88,21 +88,35 @@ GITHUB_PERSONAL_ACCESS_TOKEN = "" # Pass through from host
# repos = ["myorg/api-*", "myorg/web-*"] # Prefix matching with wildcards

# Example 2: Safe Outputs Server (write-sink for buffered updates)
# IMPORTANT: Write-sink is REQUIRED for ALL output servers when DIFC guards are
# enabled on any other server. Without it, the output server gets a noop guard
# that causes integrity violations when the agent has tags from other guards.
# [servers.safeoutputs]
# command = "docker"
# args = ["run", "--rm", "-i", "ghcr.io/github/safe-outputs:latest"]
#
# Optional: Write-sink guard policy for output servers
# When the agent reads from a guarded server (e.g., GitHub with allow-only),
# it acquires secrecy labels. The write-sink accepts writes from agents
# whose secrecy labels match the configured accept patterns.
# Write-sink guard policy for output servers
# 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):
# [servers.safeoutputs.guard_policies.write-sink]
# Accept = ["*"]
#
# For scoped repos (agent has secrecy tags matching the scope):
# [servers.safeoutputs.guard_policies.write-sink]
# Accept = ["private:myorg/api-*", "private:myorg/web-*"]
#
# Accept pattern format: "visibility:owner/repo-pattern"
# Visibility prefixes: private, public, internal
# Example: Accept writes from agents that accessed any private myorg repo:
# Accept = ["private:myorg/*"]
# Special value: "*" (wildcard) accepts writes from any agent (must be sole entry)
#
# Quick reference (see README for full table):
# repos = "all" → Accept = ["*"]
# repos = "public" → Accept = ["*"]
# repos = ["org/*"] → Accept = ["private:org"]
# repos = ["org/repo"] → Accept = ["private:org/repo"]
# repos = ["org/prefix*"] → Accept = ["private:org/prefix*"]

# Example 3: Memory MCP Server (stdio via Docker)
[servers.memory]
Expand Down
25 changes: 25 additions & 0 deletions internal/config/config_guardpolicies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -631,3 +631,28 @@ func TestValidateWriteSinkPolicy_AllScopeAcceptFormats(t *testing.T) {
})
}
}

// TestValidateWriteSinkPolicy_WildcardAccept tests that accept=["*"] passes validation.
func TestValidateWriteSinkPolicy_WildcardAccept(t *testing.T) {
policy := &WriteSinkPolicy{Accept: []string{"*"}}
err := ValidateWriteSinkPolicy(policy)
assert.NoError(t, err, `accept=["*"] should be valid (wildcard)`)
}

// TestValidateWriteSinkPolicy_WildcardWithOtherEntries tests that "*" cannot
// be mixed with other accept entries.
func TestValidateWriteSinkPolicy_WildcardWithOtherEntries(t *testing.T) {
policy := &WriteSinkPolicy{Accept: []string{"*", "private:org/repo"}}
err := ValidateWriteSinkPolicy(policy)
assert.Error(t, err, `accept=["*", "private:org/repo"] should be invalid`)
assert.Contains(t, err.Error(), "wildcard")
}

// TestValidateWriteSinkPolicy_WildcardNotFirst tests that "*" anywhere in a
// multi-entry list is rejected.
func TestValidateWriteSinkPolicy_WildcardNotFirst(t *testing.T) {
policy := &WriteSinkPolicy{Accept: []string{"private:org/repo", "*"}}
err := ValidateWriteSinkPolicy(policy)
assert.Error(t, err, `accept=["private:org/repo", "*"] should be invalid`)
assert.Contains(t, err.Error(), "wildcard")
}
19 changes: 17 additions & 2 deletions internal/config/guard_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,12 +164,19 @@ func ValidateWriteSinkPolicy(ws *WriteSinkPolicy) error {
if len(ws.Accept) == 0 {
return fmt.Errorf("write-sink.accept must contain at least one entry")
}
// Special case: ["*"] is a valid wildcard that accepts all writes
if len(ws.Accept) == 1 && strings.TrimSpace(ws.Accept[0]) == "*" {
return nil
}
seen := make(map[string]struct{})
for _, entry := range ws.Accept {
entry = strings.TrimSpace(entry)
if entry == "" {
return fmt.Errorf("write-sink.accept entries must not be empty")
}
if entry == "*" {
return fmt.Errorf("write-sink.accept wildcard \"*\" must be the only entry")
}
if _, exists := seen[entry]; exists {
return fmt.Errorf("write-sink.accept must not contain duplicates")
}
Expand Down Expand Up @@ -217,8 +224,8 @@ func validateAcceptEntry(entry string) error {
// The write-sink accept field must be a superset of the agent's secrecy tags,
// which are determined by the allow-only repos configuration:
//
// repos = "all" → agent secrecy = [] → write-sink not required
// repos = "public" → agent secrecy = [] → write-sink not required
// repos = "all" → agent secrecy = [] → accept = ["*"] (wildcard)
// repos = "public" → agent secrecy = [] → accept = ["*"] (wildcard)
// repos = ["O/R"] → agent secrecy = ["private:O/R"]
// accept = ["private:O/R"]
// repos = ["O/*"] → agent secrecy = ["private:O"]
Expand All @@ -236,6 +243,14 @@ func validateAcceptEntry(entry string) error {
// repos entry "O/P*" (prefix wildcard) → accept "private:O/P*" (prefix preserved)
// repos entry "O/R" (exact repo) → accept "private:O/R" (exact preserved)
//
// Wildcard accept:
//
// accept = ["*"] means "accept writes from any agent regardless of secrecy".
// This is the correct configuration for repos="all" and repos="public" where
// the agent has no secrecy tags. The write-sink is still required to prevent
// the noop guard integrity violation (see WriteSinkGuard godoc).
// The wildcard "*" must be the sole entry — it cannot be mixed with other patterns.
//
// Note: min-integrity has no effect on these rules (it only affects integrity labels).
var WriteSinkAcceptRules = "see godoc" // exists for documentation only

Expand Down
29 changes: 29 additions & 0 deletions internal/difc/labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ var logLabels = logger.New("difc:labels")
// Tag represents a single DIFC tag (e.g., "repo:owner/name", "agent:demo-agent")
type Tag string

// WildcardTag is a special tag that matches all other tags in subset checks.
// When the "superset" side of a flow check contains WildcardTag, the check
// passes regardless of what tags the other side has. This is used by write-sink
// guards with accept=["*"] to allow writes from agents with any secrecy.
const WildcardTag = Tag("*")

// Label represents a set of DIFC tags
type Label struct {
tags map[Tag]struct{}
Expand Down Expand Up @@ -177,6 +183,11 @@ func (l *SecrecyLabel) CanFlowTo(target *SecrecyLabel) bool {
target.Label.mu.RLock()
defer target.Label.mu.RUnlock()

// Wildcard: if target contains "*", it accepts all secrecy tags
if _, ok := target.Label.tags[WildcardTag]; ok {
return true
}

// Check if all tags in l are in target
for tag := range l.Label.tags {
if _, ok := target.Label.tags[tag]; !ok {
Expand Down Expand Up @@ -236,6 +247,19 @@ func checkFlowHelper(srcLabel *Label, targetLabel *Label, checkSubset bool, labe
targetLabel.mu.RLock()
defer targetLabel.mu.RUnlock()

// Wildcard: "*" in the superset side means "accept all"
if checkSubset {
// Secrecy: src ⊆ target — wildcard in target means target accepts all
if _, ok := targetLabel.tags[WildcardTag]; ok {
return true, nil
}
} else {
// Integrity: target ⊆ src — wildcard in src means src has all
if _, ok := srcLabel.tags[WildcardTag]; ok {
return true, nil
}
}

var violatingTags []Tag
if checkSubset {
// Secrecy semantics: Check if all tags in source are in target (source ⊆ target)
Expand Down Expand Up @@ -316,6 +340,11 @@ func (l *IntegrityLabel) CanFlowTo(target *IntegrityLabel) bool {
target.Label.mu.RLock()
defer target.Label.mu.RUnlock()

// Wildcard: if l (superset side) contains "*", it satisfies all target tags
if _, ok := l.Label.tags[WildcardTag]; ok {
return true
}

// Check if all tags in target are in l
for tag := range target.Label.tags {
if _, ok := l.Label.tags[tag]; !ok {
Expand Down
Loading
Loading