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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,14 @@ Restricts which repositories a guard allows and at what integrity level:

**`trusted-users`** *(optional)* — Array of GitHub usernames whose content is unconditionally elevated to `approved` integrity. Useful for granting specific external contributors (e.g., trusted open-source maintainers) the same treatment as repository members, without lowering `min-integrity` globally. Uses `max(base, approved)` so it never lowers integrity. Does not override `blocked-users`.

**`tool-call-limits`** *(optional)* — Map of tool names to per-session call limits enforced by the gateway before the backend is invoked. Positive values hard-limit that tool for the session, while `0` or an omitted entry leaves the tool unlimited.

```json
"guard-policies": {
"allow-only": {
"repos": ["myorg/*"],
"min-integrity": "approved",
"tool-call-limits": {"issue_read": 1},
"blocked-users": ["spam-bot", "compromised-user"],
"approval-labels": ["human-reviewed", "safe-for-agent"],
"trusted-users": ["alice", "trusted-contributor"]
Expand Down
2 changes: 2 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ min-integrity = "unapproved"

- **`trusted-users`** *(optional)*: Array of GitHub usernames whose content is unconditionally elevated to `approved` integrity. Useful for granting specific external contributors the same treatment as repository members without lowering `min-integrity` globally. Uses `max(base, approved)` so it never lowers integrity. Does not override `blocked-users`.

- **`tool-call-limits`** *(optional)*: Map of tool names to per-session call limits enforced by the gateway. Positive values cap how many times that tool may be called in one session; `0` or an omitted entry leaves the tool unlimited.

- **Meaning**: Restricts the GitHub MCP server to only access specified repositories. Tools like `get_file_contents`, `search_code`, etc. will only work on allowed repositories. Attempts to access other repositories will be denied by the guard policy.

### write-sink (output servers)
Expand Down
75 changes: 41 additions & 34 deletions internal/config/guard_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,33 +38,35 @@ type WriteSinkPolicy struct {

// AllowOnlyPolicy configures scope and minimum required integrity.
type AllowOnlyPolicy struct {
Repos interface{} `toml:"repos" json:"repos"`
MinIntegrity string `toml:"min-integrity" json:"min-integrity"`
BlockedUsers []string `toml:"blocked-users" json:"blocked-users,omitempty"`
ApprovalLabels []string `toml:"approval-labels" json:"approval-labels,omitempty"`
TrustedUsers []string `toml:"trusted-users" json:"trusted-users,omitempty"`
EndorsementReactions []string `toml:"endorsement-reactions" json:"endorsement-reactions,omitempty"`
DisapprovalReactions []string `toml:"disapproval-reactions" json:"disapproval-reactions,omitempty"`
DisapprovalIntegrity string `toml:"disapproval-integrity" json:"disapproval-integrity,omitempty"`
EndorserMinIntegrity string `toml:"endorser-min-integrity" json:"endorser-min-integrity,omitempty"`
PromotionLabel string `toml:"promotion-label" json:"promotion-label,omitempty"`
DemotionLabel string `toml:"demotion-label" json:"demotion-label,omitempty"`
Repos interface{} `toml:"repos" json:"repos"`
MinIntegrity string `toml:"min-integrity" json:"min-integrity"`
ToolCallLimits map[string]int `toml:"tool-call-limits" json:"tool-call-limits,omitempty"`
BlockedUsers []string `toml:"blocked-users" json:"blocked-users,omitempty"`
ApprovalLabels []string `toml:"approval-labels" json:"approval-labels,omitempty"`
TrustedUsers []string `toml:"trusted-users" json:"trusted-users,omitempty"`
EndorsementReactions []string `toml:"endorsement-reactions" json:"endorsement-reactions,omitempty"`
DisapprovalReactions []string `toml:"disapproval-reactions" json:"disapproval-reactions,omitempty"`
DisapprovalIntegrity string `toml:"disapproval-integrity" json:"disapproval-integrity,omitempty"`
EndorserMinIntegrity string `toml:"endorser-min-integrity" json:"endorser-min-integrity,omitempty"`
PromotionLabel string `toml:"promotion-label" json:"promotion-label,omitempty"`
DemotionLabel string `toml:"demotion-label" json:"demotion-label,omitempty"`
}

// NormalizedGuardPolicy is a canonical policy representation for caching and observability.
type NormalizedGuardPolicy struct {
ScopeKind string `json:"scope_kind"`
ScopeValues []string `json:"scope_values,omitempty"`
MinIntegrity string `json:"min-integrity"`
BlockedUsers []string `json:"blocked-users,omitempty"`
ApprovalLabels []string `json:"approval-labels,omitempty"`
TrustedUsers []string `json:"trusted-users,omitempty"`
EndorsementReactions []string `json:"endorsement-reactions,omitempty"`
DisapprovalReactions []string `json:"disapproval-reactions,omitempty"`
DisapprovalIntegrity string `json:"disapproval-integrity,omitempty"`
EndorserMinIntegrity string `json:"endorser-min-integrity,omitempty"`
PromotionLabel string `json:"promotion-label,omitempty"`
DemotionLabel string `json:"demotion-label,omitempty"`
ScopeKind string `json:"scope_kind"`
ScopeValues []string `json:"scope_values,omitempty"`
MinIntegrity string `json:"min-integrity"`
ToolCallLimits map[string]int `json:"tool-call-limits,omitempty"`
BlockedUsers []string `json:"blocked-users,omitempty"`
ApprovalLabels []string `json:"approval-labels,omitempty"`
TrustedUsers []string `json:"trusted-users,omitempty"`
EndorsementReactions []string `json:"endorsement-reactions,omitempty"`
DisapprovalReactions []string `json:"disapproval-reactions,omitempty"`
DisapprovalIntegrity string `json:"disapproval-integrity,omitempty"`
EndorserMinIntegrity string `json:"endorser-min-integrity,omitempty"`
PromotionLabel string `json:"promotion-label,omitempty"`
DemotionLabel string `json:"demotion-label,omitempty"`
}

func (p *GuardPolicy) UnmarshalJSON(data []byte) error {
Expand Down Expand Up @@ -144,6 +146,10 @@ func (p *AllowOnlyPolicy) UnmarshalJSON(data []byte) error {
if err := json.Unmarshal(value, &p.MinIntegrity); err != nil {
return fmt.Errorf("invalid allow-only.min-integrity: %w", err)
}
case "tool-call-limits":
if err := json.Unmarshal(value, &p.ToolCallLimits); err != nil {
return fmt.Errorf("invalid allow-only.tool-call-limits: %w", err)
}
case "blocked-users":
if err := json.Unmarshal(value, &p.BlockedUsers); err != nil {
return fmt.Errorf("invalid allow-only.blocked-users: %w", err)
Expand Down Expand Up @@ -198,17 +204,18 @@ func (p *AllowOnlyPolicy) UnmarshalJSON(data []byte) error {

func (p AllowOnlyPolicy) MarshalJSON() ([]byte, error) {
type serializedAllowOnly struct {
Repos interface{} `json:"repos"`
MinIntegrity string `json:"min-integrity"`
BlockedUsers []string `json:"blocked-users,omitempty"`
ApprovalLabels []string `json:"approval-labels,omitempty"`
TrustedUsers []string `json:"trusted-users,omitempty"`
EndorsementReactions []string `json:"endorsement-reactions,omitempty"`
DisapprovalReactions []string `json:"disapproval-reactions,omitempty"`
DisapprovalIntegrity string `json:"disapproval-integrity,omitempty"`
EndorserMinIntegrity string `json:"endorser-min-integrity,omitempty"`
PromotionLabel string `json:"promotion-label,omitempty"`
DemotionLabel string `json:"demotion-label,omitempty"`
Repos interface{} `json:"repos"`
MinIntegrity string `json:"min-integrity"`
ToolCallLimits map[string]int `json:"tool-call-limits,omitempty"`
BlockedUsers []string `json:"blocked-users,omitempty"`
ApprovalLabels []string `json:"approval-labels,omitempty"`
TrustedUsers []string `json:"trusted-users,omitempty"`
EndorsementReactions []string `json:"endorsement-reactions,omitempty"`
DisapprovalReactions []string `json:"disapproval-reactions,omitempty"`
DisapprovalIntegrity string `json:"disapproval-integrity,omitempty"`
EndorserMinIntegrity string `json:"endorser-min-integrity,omitempty"`
PromotionLabel string `json:"promotion-label,omitempty"`
DemotionLabel string `json:"demotion-label,omitempty"`
}

return json.Marshal(serializedAllowOnly(p))
Expand Down
54 changes: 54 additions & 0 deletions internal/config/guard_policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,13 @@ func TestAllowOnlyPolicyUnmarshalJSON(t *testing.T) {
assert.Equal(t, []string{"evil-bot", "bad-actor"}, p.BlockedUsers)
},
},
{
name: "tool-call-limits parsed correctly",
json: `{"repos":"public","min-integrity":"none","tool-call-limits":{"issue_read":1,"list_issues":2}}`,
check: func(t *testing.T, p *AllowOnlyPolicy) {
assert.Equal(t, map[string]int{"issue_read": 1, "list_issues": 2}, p.ToolCallLimits)
},
},
{
name: "approval-labels parsed correctly",
json: `{"repos":"public","min-integrity":"none","approval-labels":["approved","human-reviewed"]}`,
Expand Down Expand Up @@ -829,6 +836,21 @@ func TestAllowOnlyPolicyMarshalJSON(t *testing.T) {
assert.Contains(t, jsonStr, `"human-reviewed"`)
})

t.Run("tool-call-limits is included when set", func(t *testing.T) {
policy := AllowOnlyPolicy{
Repos: "public",
MinIntegrity: "none",
ToolCallLimits: map[string]int{"issue_read": 1},
}

data, err := json.Marshal(policy)
require.NoError(t, err)

jsonStr := string(data)
assert.Contains(t, jsonStr, `"tool-call-limits"`)
assert.Contains(t, jsonStr, `"issue_read"`)
})

t.Run("nil blocked-users and approval-labels are omitted", func(t *testing.T) {
policy := AllowOnlyPolicy{
Repos: "public",
Expand Down Expand Up @@ -966,6 +988,16 @@ func TestValidateGuardPolicy(t *testing.T) {
require.NoError(t, err)
})

t.Run("zero tool-call-limit is treated as unlimited", func(t *testing.T) {
policy := &GuardPolicy{AllowOnly: &AllowOnlyPolicy{
Repos: "all",
MinIntegrity: "none",
ToolCallLimits: map[string]int{"issue_read": 0},
}}
err := ValidateGuardPolicy(policy)
require.NoError(t, err)
})

t.Run("invalid policy returns error", func(t *testing.T) {
policy := &GuardPolicy{AllowOnly: &AllowOnlyPolicy{
Repos: "all",
Expand All @@ -974,6 +1006,17 @@ func TestValidateGuardPolicy(t *testing.T) {
err := ValidateGuardPolicy(policy)
require.Error(t, err)
})

t.Run("negative tool-call-limit returns error", func(t *testing.T) {
policy := &GuardPolicy{AllowOnly: &AllowOnlyPolicy{
Repos: "all",
MinIntegrity: "none",
ToolCallLimits: map[string]int{"issue_read": -1},
}}
err := ValidateGuardPolicy(policy)
require.Error(t, err)
assert.ErrorContains(t, err, `allow-only.tool-call-limits["issue_read"] must be >= 0`)
})
}

// TestIsScopeTokenChar tests valid and invalid characters for scope tokens.
Expand Down Expand Up @@ -1004,6 +1047,17 @@ func TestNormalizeGuardPolicyReactionEndorsement(t *testing.T) {
assert.Equal(t, []string{"THUMBS_UP", "HEART"}, got.EndorsementReactions)
})

t.Run("tool-call-limits propagated to normalized policy", func(t *testing.T) {
policy := &GuardPolicy{AllowOnly: &AllowOnlyPolicy{
Repos: "public",
MinIntegrity: "approved",
ToolCallLimits: map[string]int{"issue_read": 1, "list_issues": 0},
}}
got, err := NormalizeGuardPolicy(policy)
require.NoError(t, err)
assert.Equal(t, map[string]int{"issue_read": 1, "list_issues": 0}, got.ToolCallLimits)
})

t.Run("disapproval-reactions propagated and normalized to uppercase", func(t *testing.T) {
policy := &GuardPolicy{AllowOnly: &AllowOnlyPolicy{
Repos: "public",
Expand Down
7 changes: 7 additions & 0 deletions internal/config/guard_policy_unmarshal_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,11 @@ func TestAllowOnlyPolicyUnmarshalJSON_FieldErrorPaths(t *testing.T) {
json: `{"repos": "all", "min-integrity": "none", "blocked-users": "notanarray"}`,
wantErr: "invalid allow-only.blocked-users",
},
{
name: "tool-call-limits field invalid JSON type",
json: `{"repos": "all", "min-integrity": "none", "tool-call-limits": "notamap"}`,
wantErr: "invalid allow-only.tool-call-limits",
},
{
name: "approval-labels field invalid JSON type",
json: `{"repos": "all", "min-integrity": "none", "approval-labels": 42}`,
Expand Down Expand Up @@ -416,6 +421,7 @@ func TestAllowOnlyPolicyUnmarshalJSON_FullRoundTrip(t *testing.T) {
BlockedUsers: []string{"bad-actor"},
ApprovalLabels: []string{"approved"},
TrustedUsers: []string{"contractor"},
ToolCallLimits: map[string]int{"issue_read": 1},
EndorsementReactions: []string{"THUMBS_UP"},
DisapprovalReactions: []string{"THUMBS_DOWN"},
DisapprovalIntegrity: "none",
Expand All @@ -434,6 +440,7 @@ func TestAllowOnlyPolicyUnmarshalJSON_FullRoundTrip(t *testing.T) {
assert.Equal(t, original.BlockedUsers, parsed.BlockedUsers)
assert.Equal(t, original.ApprovalLabels, parsed.ApprovalLabels)
assert.Equal(t, original.TrustedUsers, parsed.TrustedUsers)
assert.Equal(t, original.ToolCallLimits, parsed.ToolCallLimits)
assert.Equal(t, original.EndorsementReactions, parsed.EndorsementReactions)
assert.Equal(t, original.DisapprovalReactions, parsed.DisapprovalReactions)
assert.Equal(t, original.DisapprovalIntegrity, parsed.DisapprovalIntegrity)
Expand Down
24 changes: 24 additions & 0 deletions internal/config/guard_policy_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ func NormalizeGuardPolicy(policy *GuardPolicy) (*NormalizedGuardPolicy, error) {

var err error

normalized.ToolCallLimits, err = normalizeToolCallLimits(policy.AllowOnly.ToolCallLimits)
if err != nil {
return nil, err
}

// Validate and normalize blocked-users, approval-labels, trusted-users.
// Dedup uses lowercased keys; original trimmed values are stored.
normalized.BlockedUsers, err = normalizeStringSlice("blocked-users", policy.AllowOnly.BlockedUsers, strings.ToLower, false)
Expand Down Expand Up @@ -332,3 +337,22 @@ func normalizeStringSlice(field string, input []string, caseNorm func(string) st
}
return out, nil
}

func normalizeToolCallLimits(input map[string]int) (map[string]int, error) {
if len(input) == 0 {
return nil, nil
}

out := make(map[string]int, len(input))
for toolName, limit := range input {
toolName = strings.TrimSpace(toolName)
if toolName == "" {
return nil, fmt.Errorf("allow-only.tool-call-limits keys must not be empty")
}
if limit < 0 {
return nil, fmt.Errorf("allow-only.tool-call-limits[%q] must be >= 0", toolName)
}
out[toolName] = limit
}
return out, nil
}
Loading
Loading