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
47 changes: 4 additions & 43 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 7 additions & 8 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,20 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] }

[workspace.dependencies]
eyre = "0.6.12"
rmcp = "0.14"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tempfile = "3.24"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util", "signal"] }

[package]
name = "service-gator"
version = "0.2.0"
edition = "2021"
description = "Scope-restricted CLI wrapper for sandboxed AI agents"
description = "Scope-restricted MCP server for sandboxed AI agents"
license = "MIT OR Apache-2.0"
repository = "https://github.com/cgwalters/service-gator"
keywords = ["ai", "security", "sandbox", "mcp", "cli"]
repository = "https://github.com/LobsterTrap/service-gator"
keywords = ["ai", "security", "sandbox", "mcp"]
categories = ["command-line-utilities", "development-tools"]
rust-version = "1.75"

Expand All @@ -32,13 +34,11 @@ serde = { workspace = true }
serde_json = { workspace = true }
toml = "0.8"
clap = { version = "4", features = ["derive"] }
rmcp = { version = "0.14", features = ["server", "transport-streamable-http-server"] }
rmcp = { workspace = true, features = ["server", "transport-streamable-http-server"] }
tokio = { workspace = true }
tokio-util = "0.7"
axum = "0.8"
http = "1"
# HTTP proxy functionality
hyper-util = { version = "0.1", features = ["client", "client-legacy", "http1", "http2", "tokio"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "cors"] }
eyre = { workspace = true }
Expand All @@ -61,15 +61,14 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
rustix = { version = "1", features = ["fs"] }
itertools = "0.14"
camino = { version = "1.2.2", features = ["serde1"] }
tempfile = "3.24.0"
tempfile = { workspace = true }
# Pin time to avoid 0.3.47 which has compile-time assertions that fail on older Rust
# See: https://github.com/time-rs/time/issues/836
time = ">=0.3,<0.3.47"
urlencoding = "2.1"

[dev-dependencies]
cap-std-ext = "4.0.7"
itertools = "0.14"
kani-verifier = "0.67"

[lints]
Expand Down
2 changes: 1 addition & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ test-integration *ARGS: build-release
# ============================================================================

# Container image name
CONTAINER_IMAGE := "ghcr.io/cgwalters/service-gator"
CONTAINER_IMAGE := "ghcr.io/lobstertrap/service-gator"

# Build the container image
[group('container')]
Expand Down
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ The recommended deployment is the container image with CLI-based scope configura
# Note: GITHUB_TOKEN is also accepted as an alternative to GH_TOKEN
podman run --rm -p 8080:8080 \
-e GH_TOKEN \
ghcr.io/cgwalters/service-gator:latest \
ghcr.io/lobstertrap/service-gator:latest \
--mcp-server 0.0.0.0:8080 \
--gh-repo myorg/myrepo:read

# Multiple repos with different permissions
podman run --rm -p 8080:8080 \
-e GH_TOKEN -e JIRA_API_TOKEN \
ghcr.io/cgwalters/service-gator:latest \
ghcr.io/lobstertrap/service-gator:latest \
--mcp-server 0.0.0.0:8080 \
--gh-repo myorg/myrepo:read,push-new-branch,create-draft \
--gh-repo myorg/other:read \
Expand Down Expand Up @@ -111,7 +111,7 @@ The separation of `push-new-branch` and `create-draft` permissions provides seve
# Allow branch pushing to user's fork, PR creation on upstream repo
podman run --rm -p 8080:8080 \
-e GH_TOKEN \
ghcr.io/cgwalters/service-gator:latest \
ghcr.io/lobstertrap/service-gator:latest \
--mcp-server 0.0.0.0:8080 \
--gh-repo upstream/project:read,create-draft \
--gh-repo user/project:read,push-new-branch
Expand All @@ -122,7 +122,7 @@ podman run --rm -p 8080:8080 \
# Both branch operations and PR creation on the same repo
podman run --rm -p 8080:8080 \
-e GH_TOKEN \
ghcr.io/cgwalters/service-gator:latest \
ghcr.io/lobstertrap/service-gator:latest \
--mcp-server 0.0.0.0:8080 \
--gh-repo myorg/project:read,push-new-branch,create-draft
```
Expand All @@ -132,7 +132,7 @@ podman run --rm -p 8080:8080 \
# Can create draft PRs but cannot push branches
podman run --rm -p 8080:8080 \
-e GH_TOKEN \
ghcr.io/cgwalters/service-gator:latest \
ghcr.io/lobstertrap/service-gator:latest \
--mcp-server 0.0.0.0:8080 \
--gh-repo myorg/project:read,create-draft
```
Expand Down Expand Up @@ -326,7 +326,7 @@ podman run --rm -p 8080:8080 \
--secret sg_secret \
-e GH_TOKEN_FILE=/run/secrets/gh_token \
-e SERVICE_GATOR_SECRET_FILE=/run/secrets/sg_secret \
ghcr.io/cgwalters/service-gator:latest \
ghcr.io/lobstertrap/service-gator:latest \
--mcp-server 0.0.0.0:8080 \
--gh-repo myorg/myrepo:read
```
Expand All @@ -352,7 +352,7 @@ metadata:
spec:
containers:
- name: service-gator
image: ghcr.io/cgwalters/service-gator:latest
image: ghcr.io/lobstertrap/service-gator:latest
args:
- --mcp-server
- 0.0.0.0:8080
Expand Down Expand Up @@ -425,7 +425,7 @@ spec:
spec:
containers:
- name: service-gator
image: ghcr.io/cgwalters/service-gator:latest
image: ghcr.io/lobstertrap/service-gator:latest
args:
- --mcp-server
- 0.0.0.0:8080
Expand Down Expand Up @@ -477,7 +477,7 @@ podman run --rm -p 8080:8080 \
-e GH_TOKEN \
-e SERVICE_GATOR_SECRET="your-256-bit-secret" \
-e SERVICE_GATOR_ADMIN_KEY="admin-secret" \
ghcr.io/cgwalters/service-gator:latest \
ghcr.io/lobstertrap/service-gator:latest \
--mcp-server 0.0.0.0:8080 \
--scope '{"server":{"mode":"required"}}'
```
Expand Down Expand Up @@ -598,7 +598,7 @@ agent), etc.
### Container (recommended)

```bash
podman pull ghcr.io/cgwalters/service-gator:latest
podman pull ghcr.io/lobstertrap/service-gator:latest
```

### From source
Expand Down
58 changes: 58 additions & 0 deletions docs/todo/openpolicyagent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Evaluation: Open Policy Agent for service-gator

🤖 Heavily generated by Claude Opus 4.6
Reviewed-by: Colin Walters

Should service-gator support OPA/Rego policies as an alternative to the current scope system?

## Current system

The custom scope system in `src/scope.rs` is a declarative, typed permission model. `ScopeConfig` nests per-service scopes (GitHub, GitLab, Forgejo, JIRA) with per-resource granularity: repo-level permissions (read, create-draft, pending-review, push-new-branch, write) and resource-level permissions for specific PRs/issues/MRs. Pattern matching supports trailing wildcards (`owner/*`) with specificity-based resolution — exact matches beat prefixes. The permission hierarchy is well-defined: `write` implies all lower permissions, and each `can_*` method encodes these implications.

This works well for several reasons. The policy language we have is simple. It's easy to serialize/deserialize so it flows naturally through TOML files, CLI flags, JSON, and JWT token claims without any impedance mismatch. Type safety catches misconfiguration at compile time (e.g., you can't accidentally assign a `GhRepoPermission` to a GitLab project). The live-reload mechanism via `--scope-file` and inotify is simple because the entire config is one `ScopeConfig` value sent through a `watch::Receiver`. The binary stays small with zero external policy dependencies.

## What OPA would add

The current system is purely structural — it answers "does this identity have this permission on this resource?" but cannot express conditional or contextual policies. OPA with Rego would enable rules like: allow writes only during business hours, restrict PR creation to branches matching a naming convention, enforce approval requirements based on file paths changed, rate-limit operations per agent, or require specific labels before allowing merge. Rego policies are version-controlled, testable with `opa test`, and composable through imports, which matters for organizations managing policies across many service-gator deployments. OPA also has a mature ecosystem: shared policy libraries (e.g., Styra DAS), decision logging, and partial evaluation for explainable denials.

### GraphQL scoping — the strongest argument for OPA

The current scope system has a known gap with GitHub's GraphQL API: it cannot enforce per-repo restrictions on GraphQL queries because a single query can span multiple repositories (e.g., `repository(owner: "x", name: "y")` alongside `repository(owner: "a", name: "b")`). The code acknowledges this — GraphQL is gated as a global on/off (`graphql = "read"` or `graphql = true`), and an agent with GraphQL access can read any repo the underlying GitHub token has access to, completely bypassing the per-repo scope restrictions the REST API enforces.

OPA has [built-in GraphQL support](https://www.openpolicyagent.org/docs/latest/policy-reference/#graphql) via `graphql.parse` and `graphql.is_valid` Rego builtins, plus the ability to walk the parsed AST. A Rego policy could inspect the parsed query tree, extract repository arguments from `repository(owner:, name:)` fields, and check them against the allowed-repo list — something that would be painful to implement in hand-written Rust because of the variety of ways repos can be referenced in GitHub's GraphQL schema (direct queries, `node(id:)`, `search`, nested connections, aliases, fragments, variables).

That said, the difficulty is real regardless of the policy engine. GitHub's GraphQL schema is large and repo access is implicit in many queries (organization members, user activity, cross-references). Any enforcement that parses the AST would be incomplete unless it whitelists known-safe query patterns rather than trying to block all unsafe ones. OPA makes the parsing easier but doesn't solve the fundamental problem of GitHub's schema being hard to restrict.

## What OPA would cost

The deployment model would be OPA as a second binary in the container image, queried over localhost HTTP. This is straightforward — OPA is a single static binary (~50MB), and the container already bundles `gh`, `glab`, and `tea`. Users deploying from the container image get OPA support automatically; users running the bare `service-gator` binary don't, and that's fine. The localhost HTTP call adds ~1-5ms per policy decision, which is negligible for MCP tool calls (which shell out to CLI tools anyway).

The real costs are in the integration work and the user-facing complexity:

- **Input document construction**: Every permission check site in `src/mcp.rs`, `src/github.rs`, etc. would need to build a structured input document with the right context — method, resource, identity, timestamp, and for GraphQL, the parsed query AST. This is a nontrivial refactor across four forge backends.
- **Policy authoring burden**: Most service-gator users want something like `"owner/repo" = { read = true, create-draft = true }`. They do not want to learn Rego. Rego's syntax is unintuitive for anyone who hasn't used it (implicit iteration, set comprehensions, partial rules). The TOML config should remain the primary interface, with OPA as an optional layer for users who need it.
- **Debugging**: An unexpected denial from a Rego policy is harder to debug than reading a TOML file. OPA's decision log helps, but it's another thing to configure.

## Integration approach

OPA runs as a sidecar binary in the container image, queried via `POST /v1/data/{policy}` over localhost. service-gator would:

1. **Check if OPA is available** at startup (configurable endpoint, e.g. `--opa-url http://127.0.0.1:8181`). If not configured or not reachable, fall back to the built-in scope system only.
2. **Construct an input document** at each permission check with the relevant context (service, method, resource, identity, and for GraphQL, the parsed query).
3. **Call OPA** and use the decision alongside (not instead of) the built-in scope checks. The built-in TOML scopes remain the baseline; OPA policies can further restrict but not override them (defense in depth).

The container image would bundle the `opa` binary and start it alongside service-gator, loading policies from a mounted volume or ConfigMap. The Containerfile change is minimal — add the OPA binary and an entrypoint wrapper.

For users running the bare binary outside a container, OPA is simply not available. The TOML scope system continues to work exactly as it does today.

## Recommendation

Worth building, but as an optional layer on top of the existing scope system — not a replacement.

**Near-term (without OPA)**: Tighten GraphQL handling in Rust by parsing queries with `graphql-parser` (already a dependency) and extracting `repository(owner:, name:)` arguments to check against the allowed-repo list. This handles the common case without any new dependencies. Queries that can't be statically scoped (cross-repo searches, `node(id:)` lookups) should be denied unless the agent has global read.

**Medium-term**: Add optional OPA sidecar support. The deployment cost is low (just another binary in the container), and the integration is clean: service-gator calls OPA over localhost HTTP when configured, falls back to built-in scopes when not. The built-in TOML scopes remain the baseline and the primary user interface. OPA policies layer on top for users who need conditional logic, GraphQL AST inspection, or cross-cutting policy concerns.

**Design principle**: OPA policies can further restrict access but cannot grant access beyond what the TOML scopes allow. This means the simple TOML config is always the security boundary — OPA adds precision, not privilege. Users who don't need OPA never see it.

---
4 changes: 2 additions & 2 deletions integration-tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ tokio = { workspace = true }
libtest-mimic = "0.8"
linkme = "0.3"
paste = "1.0"
tempfile = "3"
tempfile = { workspace = true }
# HTTP client for admin endpoints and raw requests
reqwest = { version = "0.12", features = ["json", "blocking"] }
# MCP client for proper protocol handling
rmcp = { version = "0.13", features = ["client", "transport-streamable-http-client-reqwest"] }
rmcp = { workspace = true, features = ["client", "transport-streamable-http-client-reqwest"] }

[lints]
workspace = true
5 changes: 3 additions & 2 deletions integration-tests/src/mcp_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use eyre::{Context, Result};
use rmcp::{
model::{CallToolRequestParam, CallToolResult, ClientInfo, Implementation},
model::{CallToolRequestParams, CallToolResult, ClientInfo, Implementation},
service::RunningService,
transport::streamable_http_client::{
StreamableHttpClientTransport, StreamableHttpClientTransportConfig,
Expand Down Expand Up @@ -78,9 +78,10 @@ impl RmcpSession {
let args = arguments.as_object().cloned();

self.service
.call_tool(CallToolRequestParam {
.call_tool(CallToolRequestParams {
name: name.to_string().into(),
arguments: args,
meta: None,
task: None,
})
.await
Expand Down
2 changes: 1 addition & 1 deletion integration-tests/src/tests/mcp_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ fn get_denied_repo() -> String {
let owner = get_test_owner();
// Use a different repo under the same owner, or a well-known public repo
if owner == "cgwalters" {
"cgwalters/service-gator".to_string()
"LobsterTrap/service-gator".to_string()
} else {
format!("{}/nonexistent-test-repo", owner)
}
Expand Down
2 changes: 1 addition & 1 deletion integration-tests/src/tests/rest_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ fn get_denied_repo() -> String {
let test_repo = get_test_repo();
let owner = test_repo.split('/').next().unwrap_or("cgwalters");
if owner == "cgwalters" {
"cgwalters/service-gator".to_string()
"LobsterTrap/service-gator".to_string()
} else {
format!("{}/nonexistent-test-repo", owner)
}
Expand Down
Loading
Loading