opsgate

module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 29, 2026 License: MIT

README

opsgate

The safety gate between AI agents and your servers.

An MCP server that lets Claude, Cursor, or any AI agent operate real infrastructure — without handing it a root shell.

CI Go Report Card Go Reference MCP

What it looks like · 30-second try · Break it yourself · Tools · Security model · Contributing


What it looks like

You ask a question. The agent investigates with read-only tools and answers.

You: Something's listening on port 8000 on my server and I don't remember what. Check it.

▸ listening_ports(host: "hetzner")

  Netid State  Recv-Q Send-Q  Local Address:Port  Process
  tcp   LISTEN 0      511         0.0.0.0:443     users:(("nginx",pid=249619),…)
  tcp   LISTEN 0      511         0.0.0.0:80      users:(("nginx",pid=249619),…)
  tcp   LISTEN 0      4096        0.0.0.0:22      users:(("sshd",pid=237597),…)
  tcp   LISTEN 0      151       127.0.0.1:3306    users:(("mysqld",pid=237695),…)
  tcp   LISTEN 0      5         127.0.0.1:8000    users:(("python3",pid=237608),…)

▸ service_list(host: "hetzner", state: "running")

  docker.service       loaded active running  Docker Application Container Engine
  mysql.service        loaded active running  MySQL Community Server
  nginx.service        loaded active running  A high performance web server…
  php8.3-fpm.service   loaded active running  The PHP 8.3 FastCGI Process Manager
  pm2-root.service     loaded active running  PM2 process manager

Claude: Port 8000 is a bare python3 process (pid 237608) bound to localhost only, so it isn't reachable from outside. It's not managed by systemd — no unit owns it — which means it won't survive a reboot. It's most likely something started by hand in a shell.

Now something that changes state. In operate mode the agent cannot just do it:

You: OK, nginx config changed. Reload it.

▸ nginx_test(host: "hetzner")
  nginx: configuration file /etc/nginx/nginx.conf test is successful

▸ nginx_reload(host: "hetzner")

  ┌─ opsgate: approval required ─────────────────────────────────┐
  │ allow nginx_reload on host "hetzner"?                        │
  │                                                             │
  │ The nginx configuration passed validation.                  │
  │ This will reload nginx.                                     │
  │                                                             │
  │ Command: 'nginx' '-s' 'reload'                               │
  │                                        [ Approve ]  [ Deny ] │
  └─────────────────────────────────────────────────────────────┘

You click Deny, and nothing happens — the agent is told the operator refused. Approve, and it runs and lands in the audit log. Either way you saw the exact command first.

(Tool output above is real output from a live server; the prompt is rendered by your MCP client, so it looks however that client draws it.)

Try it in 30 seconds (no server needed)

opsgate runs with no config file at all — local machine, read-only:

go install github.com/polymatx/opsgate/cmd/opsgate@latest
opsgate serve
opsgate dev | mode=observe | default_host=local | audit=~/.opsgate/audit.jsonl

That's it. No config, no SSH, and it defaults to observe — so it can read your disk usage and journal, and it will refuse to change anything:

▸ disk_usage()          → works
▸ service_restart(...)  → Refused by opsgate policy: host is in observe mode

Point your client at it and ask "what's using disk space on this machine?" When you're ready for real hosts, add a config with opsgate init.

Wire it up to your client
Claude Code
claude mcp add opsgate -- opsgate serve --config ~/.opsgate/config.yaml
Claude Desktop / Cursor (claude_desktop_config.json or .cursor/mcp.json)
{
  "mcpServers": {
    "opsgate": {
      "command": "opsgate",
      "args": ["serve", "--config", "/absolute/path/to/opsgate.yaml"]
    }
  }
}
Any client, over HTTP
http:
  addr: "127.0.0.1:8080"
  auth_token: "a-long-random-token"   # sent as: Authorization: Bearer <token>
opsgate serve --http 127.0.0.1:8080

Terminate TLS upstream if the port leaves localhost.

The problem this solves

Letting an AI agent run your servers today means giving it exec("<any string>") over SSH. The usual mitigation is a denylist of commands that look dangerous.

That does not work. Denylists are guesswork against an adversary with infinite phrasings:

rm -rf /                              →  blocked
r''m -rf /                            →  not blocked
echo cm0gLXJmIC8K | base64 -d | sh    →  not blocked

And "dangerous-looking" isn't even the hard part. systemctl restart nginx is a perfectly ordinary command that is catastrophic at 3pm on Black Friday.

The approach

opsgate does not filter strings. It never gives the agent a string to fill in.

The agent gets 25 typed tools — service_restart(name), docker_logs(container), journal_tail(unit, since, priority) — and opsgate builds the command itself as an argv array. There is no shell to inject into, because no shell string is ever assembled from agent input.

// What opsgate does — the name is one argv element, whatever it contains
argv := []string{"systemctl", "restart", in.Name}

// What it never does
argv := []string{"sh", "-c", "systemctl restart " + in.Name}

Send nginx; rm -rf / as the service name and systemctl receives it as a single literal argument and fails as an unknown unit. Nothing else runs.

Every call then passes through four gates:

flowchart LR
    A[Agent calls a tool] --> B{Policy<br/>mode + allowlist}
    B -->|deny| X[Refused<br/>+ audited]
    B -->|allow| D[Build argv]
    B -->|needs approval| C{Human}
    C -->|deny| X
    C -->|approve| D
    D --> E[Execute]
    E --> F[Audit<br/>hash-chained]
Layer What it does
Structured tools Commands are built as argv, never string-concatenated. Injection is impossible by construction, not by filtering.
Deny-by-default policy Three modes — observe (read-only), operate (writes need approval), full. Per-host overrides, per-tool target allowlists.
Human approval gates Mutating calls pause and ask you, showing the exact command. Needs a client that supports MCP elicitation; if it cannot ask, the call is refused rather than run.
Tamper-evident audit Every call — including every refusal — appended to a hash-chained JSONL log. Editing or deleting any line is detectable.

The default mode is observe. Out of the box, an agent can diagnose your servers and change nothing.

Break it yourself

Don't take the claims on faith. Start opsgate in observe mode with files.allow_paths: [/var/log] and try these — every one is refused, and the refusal is logged:

Try this What happens
service_status(name: "nginx; touch /tmp/pwned") Refused — invalid characters in a target name
file_read(path: "/var/log/../../etc/shadow") Refused — outside the allowed paths
file_read(path: "/root/.ssh/id_rsa") Refused — outside the allowed paths
file_grep(pattern: "x$(touch /tmp/pwned)") Searched as a literal string; nothing executes
service_restart(name: "nginx") Refused — observe mode cannot mutate
journal_tail() with allow_targets set Refused — omitting the unit would read the whole journal
shell_exec(command: "id") Refused — the tool isn't even registered unless you opt in

Then confirm nothing landed:

$ ls /tmp/pwned
ls: cannot access '/tmp/pwned': No such file or directory

$ opsgate audit verify
audit chain OK: 6 records verified in ~/.opsgate/audit.jsonl

(Six, not seven: shell_exec isn't a registered tool at all when it's disabled, so that call is rejected by the protocol before it reaches a gate.)

Found something that does get through? That's a security bug — please report it.

Modes

mode: observe   # read-only. Mutating tools are refused outright.
mode: operate   # reads run freely; every write asks you first.  ← recommended
mode: full      # writes run unprompted. Still fully audited.

Pin production tighter than everything else:

mode: operate
hosts:
  prod:
    addr: 10.0.0.1
    mode: observe        # prod is read-only even though the default is operate
  staging:
    addr: 10.0.0.2       # inherits operate

Restrict what a tool may touch:

tools:
  service_restart:
    allow_targets: ["nginx", "myapp*"]   # postgresql is not restartable, period
  service_stop:
    enabled: false                       # remove the tool entirely
  shell_exec:
    enabled: true                        # opt in to freeform shell (still approval-gated)

Setting allow_targets also makes the target mandatory for that tool: omitting it is usually broader than any allowed value — journal_tail with no unit reads the whole journal — so an unconstrained call is refused rather than silently permitted.

See configs/opsgate.example.yaml for every option.

Tools

Observe — always available

system_info · disk_usage · process_top · listening_ports · service_list · service_status · docker_ps · docker_logs · docker_inspect · docker_stats · compose_ps · journal_tail · journal_errors · file_read · dir_list · file_grep · nginx_test

Mutate — refused in observe, approval-gated in operate

service_restart · service_start · service_stop · service_reload · docker_restart · docker_start · docker_stop · nginx_reload

Opt-in

shell_exec — freeform shell. Disabled unless you enable it; always approval-gated. It exists for the genuine long tail, and everything above exists so you rarely need it.

nginx_reload runs nginx -t first and refuses to reload a config that does not parse.

Things worth asking your agent
  • "Why is nginx throwing 502s on web1?" — correlates service_status, journal_tail, docker_ps
  • "What filled up the disk on prod?"disk_usage then dir_list under allowed paths
  • "Did anything crash overnight?"journal_errors across hosts
  • "Which containers are unhealthy and how many times have they restarted?"docker_inspect
  • "Compare staging and prod: what's running on one but not the other?"service_list on both

The audit log

Every call appends one JSON line embedding the hash of the previous record:

{"seq":41,"time":"2026-07-29T11:02:14Z","host":"web1","tool":"service_status","decision":"allow","exit_code":0,…,"prev_hash":"9f2c…","hash":"41ab…"}
{"seq":42,"time":"2026-07-29T11:02:31Z","host":"web1","tool":"file_read","args":{"path":"/root/.ssh/id_rsa"},"decision":"deny","error":"outside the allowed paths",…}
{"seq":43,"time":"2026-07-29T11:03:02Z","host":"web1","tool":"service_restart","phase":"intent","decision":"needs_approval","approved":true,…}
{"seq":44,"time":"2026-07-29T11:03:02Z","host":"web1","tool":"service_restart","phase":"outcome","decision":"needs_approval","approved":true,"exit_code":0,…}

Refusals are recorded too — a blocked attempt to read a private key is exactly what you want to find later.

A state-changing call writes two records: phase: intent before the command runs and phase: outcome after. If the intent record cannot be written, opsgate refuses to act — so an action that happened can never be missing from the log.

$ opsgate audit verify
audit chain OK: 143 records verified in ~/.opsgate/audit.jsonl

Edit, delete, or reorder any line and verification fails at exactly that point:

$ opsgate audit verify
opsgate: audit chain INVALID after 41 records: line 42: record tampered (hash mismatch)

Splicing in a forged field doesn't work either — verification decodes strictly:

opsgate: audit chain INVALID after 2 records: line 3: json: unknown field "approved_by"

Values under keys like password and token are redacted before they are written, and only the SHA-256 of command output is stored — never the output itself.

How this compares

Other SSH-over-MCP servers exist and are useful; they make a different trade.

Freeform-shell MCP servers opsgate
What the agent sends a command string typed arguments to a fixed tool
Injection defense regex denylists on the string no shell string is built — argv only
Approval none, or client-side auto-approve per-call, server-side, shows the exact command
Read-only guarantee convention observe mode has no code path to mutation
Audit varies hash-chained, includes refusals, intent-before-execution
Runtime usually Node single static Go binary, no runtime

The honest trade: opsgate's tool set is finite, so something you need may not be covered yet. That's what shell_exec and contributions are for.

Security model

opsgate reduces blast radius. It is not a sandbox, and it does not make an untrusted agent safe to point at production.

What it guarantees

  • Agent input never becomes shell syntax. Commands are argv; the SSH transport single-quotes every element, so each argument stays one literal word.
  • observe mode cannot mutate. There is no code path from a read-only mode to a state-changing command.
  • File tools cannot escape files.allow_paths — paths are cleaned before the prefix check, so /var/log/../../etc/shadow is refused.
  • SSH host keys are verified against known_hosts. Unknown keys are refused, never trusted on first use.
  • Every decision is audited, including refusals, and a mutating action is recorded before it runs.

What it does not

  • Tools run with the privileges of your SSH user. If that is root, an approved call is a root call. Give opsgate its own least-privilege user.
  • shell_exec, once enabled and approved, is arbitrary code execution. That is its purpose.
  • full mode removes the human. Use it only where you accept that.
  • A prompt-injected agent can still call allowed tools with plausible arguments. The policy is what bounds the damage — keep allow_targets tight.
  • The path check is lexical: a symlink inside an allowed directory pointing outside it will be followed.

See docs/threat-model.md for adversaries, controls, and limits in detail.

Why this exists

I run my own infrastructure and I wanted to point Claude at it. Every existing option asked me to choose between "useless" and "here is a root shell, good luck." opsgate is the middle: the agent gets real capability, I keep the veto and the receipts.

Contributing

Contributions are genuinely welcome, and the architecture makes new tools easy — most are ~20 lines. Read CONTRIBUTING.md for the one rule that matters (commands are argv, never concatenated strings).

Good first contributions

  • More structured tools — Kubernetes (kubectl get/describe/logs), PostgreSQL and MySQL status, Caddy, Traefik, ufw/iptables status, certificate expiry, apt/dnf pending updates
  • Policy primitives — time windows ("no restarts during business hours"), rate limits, per-tool concurrency caps
  • Audit sinks — syslog, append-only S3, webhook
  • Client compatibility reports — tell us how the approval prompt renders in your MCP client
go test -race ./...
go vet ./...
gofmt -l .          # must print nothing

If opsgate is useful to you, a ⭐ helps other people running their own infrastructure find it.

License

MIT — see LICENSE.

Directories

Path Synopsis
cmd
opsgate command
Command opsgate is a safety gate between AI agents and your servers: an MCP server exposing structured, policy-checked, audited operations.
Command opsgate is a safety gate between AI agents and your servers: an MCP server exposing structured, policy-checked, audited operations.
internal
audit
Package audit writes a tamper-evident JSONL audit log.
Package audit writes a tamper-evident JSONL audit log.
config
Package config loads and validates the opsgate YAML configuration.
Package config loads and validates the opsgate YAML configuration.
executor
Package executor runs commands locally or over SSH.
Package executor runs commands locally or over SSH.
policy
Package policy decides whether a tool call is allowed, denied, or needs human approval.
Package policy decides whether a tool call is allowed, denied, or needs human approval.
tools
Package tools defines opsgate's structured MCP tools and the gate every call passes through: policy check -> human approval -> execute -> audit.
Package tools defines opsgate's structured MCP tools and the gate every call passes through: policy check -> human approval -> execute -> audit.
version
Package version holds build metadata, injected via -ldflags at release time.
Package version holds build metadata, injected via -ldflags at release time.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL