opsgate

io.github.polymatx由 GitHub 帳號 polymatx 發布v0.1.3更新於 Oct 26, 2026

The safety gate between AI agents and your servers: typed, policy-checked, audited ops tools.

概覽

AI 產生的概覽

具備政策檢查的 MCP 伺服器,讓 AI 代理以型別化、可稽核的工具檢視並操作 Linux 伺服器,而不必給它 shell。

可以做什麼
  • 檢視主機:系統資訊、磁碟用量、耗用資源的行程、監聽埠、服務狀態、journal 日誌、檔案讀取與 nginx 設定測試
  • 檢視 Docker:列出容器、讀取日誌、查看健康狀態與重啟次數、資源統計與 compose 狀態
  • 變更狀態:重啟、啟動、停止或重新載入服務、容器與 nginx;observe 模式直接拒絕,operate 模式需人工核准
  • 僅在明確啟用時提供自由 shell,且一律需要核准
  • 將每次呼叫(包含被拒絕的呼叫)寫入可驗證的雜湊鏈稽核日誌
適用情境
適合想讓代理透過 SSH 或本機診斷並處理真實伺服器、但不願交出 root shell 的情況。預設為唯讀排查,需要重啟或重新載入時逐次人工確認。
執行需求
以本機行程執行(Go 二進位檔或已發布的容器映像),透過 stdio 連線;沙箱類用戶端需改用 HTTP。需要設定檔(通常由初始化指令寫入使用者家目錄),遠端主機另需 SSH 存取與金鑰。HTTP 模式需在設定中指定驗證權杖。
安裝前請注意
工具以 SSH 使用者的權限執行,若該使用者為 root,則獲准的呼叫即為 root 操作。啟用自由 shell 後,經核准即為任意程式碼執行。full 模式取消人工確認。HTTP 端點等於暴露一個僅靠 bearer 權杖保護的基礎設施管理 API。除名稱類似 password 或 token 的參數外,其餘參數依原樣記錄,請勿在工具參數中放入機密。核准是否可信取決於所用的 MCP 用戶端。

安裝

在 SourceWeft 中

  1. 開啟 儀表板中的 opsgate,將其新增到工作區。
  2. 為需要使用其工具的對話啟用該服務。

Desktop only,透過 STDIO。 STDIO 服務會啟動本機處理程序,因此需要 SourceWeft 桌面主機。

其他 MCP 客戶端

參照 儲存庫 中的啟動說明。

README


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:

bash
go install github.com/polymatx/opsgate/cmd/opsgate@latestopsgate serve
opsgate v0.1.3 | 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, opsgate init writes a starter config to ~/.opsgate/config.yaml.

Which clients can reach it

opsgate runs on your machine (or on the server itself) and holds your SSH key, so the transport you need depends on where your client actually executes:

ClientTransportWorks because
Claude Code (terminal)stdioRuns natively on your machine and launches the binary
Claude Desktop, Cursor, VS CodestdioSame — a local process
Cloud or sandboxed surfaces (web chat, VM-backed agents)HTTPThey cannot launch a local process, or even see your SSH key — they can only reach a URL

If your client reports it has no access to your servers, it is almost certainly one of the sandboxed kind. Serve over HTTP instead of stdio.

Before publishing that endpoint, read the warning there: it puts an infrastructure-management API on whatever network you expose it to.

Wire it up to your client

Claude Code

As a plugin: the MCP server, a server-ops skill that teaches the agent to diagnose before it acts and to treat refusals as final, and a read-only /opsgate:health-check [host] command.

/plugin marketplace add polymatx/opsgate/plugin install opsgate@opsgate

The plugin runs opsgate serve, which reads ~/.opsgate/config.yaml (opsgate init writes a starter one). It never reads a config from the project you have open. Or add just the MCP server:

bash
claude mcp add opsgate -- opsgate serve --config ~/.opsgate/config.yaml
Claude Desktop / Cursor (claude_desktop_config.json or .cursor/mcp.json)
json
{  "mcpServers": {    "opsgate": {      "command": "opsgate",      "args": ["serve", "--config", "/absolute/path/to/opsgate.yaml"]    }  }}
Docker (ghcr.io/polymatx/opsgate)

For hosts reached over SSH. Inside a container, local is the container itself.

bash
docker run -i --rm \  -v ~/.opsgate:/root/.opsgate \  -v ~/.ssh:/root/.ssh:ro \  ghcr.io/polymatx/opsgate serve

The image is also how opsgate is listed in the MCP Registry as io.github.polymatx/opsgate.

Any client, over HTTP (needed for cloud/sandboxed clients)
yaml
http:  addr: "127.0.0.1:8080"  auth_token: "a-long-random-token"   # sent as: Authorization: Bearer <token>
bash
opsgate serve --http 127.0.0.1:8080

The tidiest deployment is to run opsgate on the server it manages — then it needs no SSH key at all (default_host: local) — behind a TLS-terminating reverse proxy:

client ──HTTPS──▶ nginx / Caddy ──▶ 127.0.0.1:8080  opsgate                  + Bearer token

opsgate refuses to serve HTTP on a non-loopback address unless http.auth_token is set.

[!WARNING] Exposing this endpoint puts an infrastructure-management API on that network, and the bearer token becomes the only thing protecting it. If you must publish it: keep mode: observe, generate 32+ bytes of randomness for the token, require TLS, and add an IP allowlist at the proxy. Binding it to a VPN interface (WireGuard, Tailscale) instead of the public internet is strictly safer — your own devices reach it, nobody else can.

Note also that operate mode expects a human who can answer an approval prompt. An unattended HTTP deployment usually means observe.

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 /                              →  blockedr''m -rf /                            →  not blockedecho 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.

go
// What opsgate does — the name is one argv element, whatever it containsargv := []string{"systemctl", "restart", in.Name}
// What it never doesargv := []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:

mermaid
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]
LayerWhat it does
Structured toolsCommands are built as argv, never string-concatenated. Injection is impossible by construction, not by filtering.
Deny-by-default policyThree modes — observe (read-only), operate (writes need approval), full. Per-host overrides, per-tool target allowlists.
Human approval gatesMutating 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 auditEvery 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 thisWhat 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 setRefused — 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:

bash
$ ls /tmp/pwnedls: cannot access '/tmp/pwned': No such file or directory
$ opsgate audit verifyaudit 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

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

Pin production tighter than everything else:

yaml
mode: operatehosts:  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:

yaml
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.

Docker tools accept only exact container names, never IDs or ID prefixes, so a pattern like db* matches what you see in docker ps and nothing else. files.allow_paths: [] turns file access off entirely; leaving the key out keeps the defaults.

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:

jsonl
{"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.

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

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

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

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

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

The chain cannot show records cut off the end of the file, or a log rewritten from scratch by someone with write access; ship it off the box or to append-only storage if that matters (see the threat model).

Arguments named like password or token are redacted before they are written; everything else, including a shell_exec command, is logged as sent, so keep secrets out of tool arguments. Very long values are cut to a short prefix plus their length and SHA-256. 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 serversopsgate
What the agent sendsa command stringtyped arguments to a fixed tool
Injection defenseregex denylists on the stringno shell string is built — argv only
Approvalnone, or client-side auto-approveper-call, server-side, shows the exact command
Read-only guaranteeconventionobserve mode has no code path to mutation
Auditvarieshash-chained, includes refusals, intent-before-execution
Runtimeusually Nodesingle 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. Target names that start with - are refused, so they cannot turn into command-line options.
  • 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.
  • An approval answers one prompt for one exact call (host, tool, command) and is used once; a client cannot pre-approve a call it was never asked about.
  • opsgate reads its config only from --config or ~/.opsgate/config.yaml, never from the directory it starts in, so a repository cannot ship a config that widens the policy.

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.
  • Approval is only as honest as the MCP client that shows the prompt. A client that answers "accept" on its own defeats it; use a client you trust.

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
bash
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.

來源:README.md,提交 2f33e86

工具

0
SourceWeft 尚未在沙箱中啟動過該軟體包,因此其工具未知。

版本歷史

2
  1. v0.1.3最新Oct 18, 2026
  2. v0.1.2Oct 18, 2026

評分

已滿足適用檢查項的 50%

該評分衡量此收錄條目的完整程度,依據註冊中心條目、端點與公開來源計算得出。它不是對伺服器本身的評價,也不表示伺服器安全或目前可用:可連通性是帶日期的檢查結果,評分也不包含安裝量或使用者評價。

  • 近 30 天內可連通25 分

    請保持端點能夠回應 MCP 握手;SourceWeft 每 30 天探測一次。

  • 已宣告認證方式10 分

    請在註冊中心條目中宣告憑證請求標頭,或保持端點能夠回應探測,以便判定其認證方式。

  • 已列出工具15 分

    請讓伺服器在無憑證時回應 tools/list,或發布可在沙箱中無憑證啟動的軟體包。

  • 描述必需15 分
  • 圖示10 分
  • 文件15 分
  • 授權條款10 分