From be2524399f84bcfc3f015045290ce5b23883d3ba Mon Sep 17 00:00:00 2001 From: jettwang Date: Sun, 16 Aug 2026 13:13:01 +0800 Subject: [PATCH] feat(mcp): add stdio MCP server, Windows CI, keychain DX, and release v0.7.0 - Add 'sshx mcp': stdio Model Context Protocol server on the official go-sdk; 7 tools map 1:1 to the CLI execution contract, every call re-enters sshx as a one-shot audited child process (entry=mcp), password management is never exposed - Emit machine-readable sshx.hosts.v1 from --host-list --json - Add windows-latest unit-test/build job to CI - Add make test-keychain-macos + scripts/macos-dev-keychain.sh for prompt-free real-keyring E2E on macOS - Add keyringstore unit coverage (system + sshx_e2e backends) - Add CONTRIBUTING.md; correct coverage badge (48.4% measured) and SECURITY.md supported-versions table; document the release-time SECURITY.md bump in RELEASE.md - Revise AGENT.md/roadmap non-goals: stdio MCP in scope, HTTP/SSE and resident services remain out Closes #47, closes #48, closes #49, closes #50, closes #51 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 39 ++ AGENT.md | 27 +- CHANGELOG.md | 31 + CONTRIBUTING.md | 88 +++ Makefile | 4 + README.md | 34 +- README_CN.md | 25 +- RELEASE.md | 4 + SECURITY.md | 6 +- docs/SUMMARY.md | 1 + docs/mcp.md | 76 +++ docs/roadmap.md | 9 +- docs/troubleshooting.md | 41 ++ go.mod | 8 + go.sum | 22 + internal/app/app.go | 8 + internal/app/audit.go | 19 + internal/app/config.go | 3 + internal/app/host_manager.go | 51 ++ internal/app/mcp.go | 584 +++++++++++++++++++ internal/app/mcp_test.go | 261 +++++++++ internal/app/usage.go | 1 + internal/keyringstore/backend_e2e_test.go | 87 +++ internal/keyringstore/backend_system_test.go | 55 ++ scripts/macos-dev-keychain.sh | 56 ++ tests/e2e/mcp_e2e_test.go | 276 +++++++++ 26 files changed, 1801 insertions(+), 15 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/mcp.md create mode 100644 internal/app/mcp.go create mode 100644 internal/app/mcp_test.go create mode 100644 internal/keyringstore/backend_e2e_test.go create mode 100644 internal/keyringstore/backend_system_test.go create mode 100755 scripts/macos-dev-keychain.sh create mode 100644 tests/e2e/mcp_e2e_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39245ec..4d6e7f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,45 @@ jobs: - name: Build run: go build -v ./cmd/sshx + test-windows: + name: Test (windows-latest) + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25.13" + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~\AppData\Local\go-build + ~\go\pkg\mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + run: go mod download + + - name: Run unit tests (cross-platform packages) + # Full-suite Windows enablement is tracked in issue #50: several + # pre-existing app/plugin/skillinstall/sshclient tests assume POSIX + # permission and symlink semantics. Packages listed here must stay + # green; grow this list as tests are ported. + run: go test -short ./cmd/... ./internal/execution/... ./internal/keyringstore/... ./internal/sqlsafe/... ./pkg/... + + - name: Vet + run: go vet ./... + + - name: Build + run: go build -v ./cmd/sshx + e2e: name: E2E (${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/AGENT.md b/AGENT.md index 1c6b406..aa3212e 100644 --- a/AGENT.md +++ b/AGENT.md @@ -63,8 +63,11 @@ the project's mission: **Out of scope (will not be accepted by default):** -- ❌ **MCP server / Model Context Protocol** — removed on purpose. `sshx` is - CLI-only. Do not reintroduce an `mcp-stdio` mode or MCP tools. +- ❌ **HTTP/SSE MCP server, daemons, or resident protocol services** — the + stdio MCP server (`sshx mcp`) is in scope: it is spawned and owned by an MCP + client, lives for exactly one client session, and re-enters sshx as one-shot + child processes per tool call. Do not add an HTTP/SSE transport, a listening + socket, or any server that outlives its client. - ❌ **Daemons / long-running services / connection pools** — every command opens a connection, does its work, and exits. There is no background process. - ❌ **Resident remote agent / control plane** — do not require a service to be @@ -90,7 +93,11 @@ correctness. Read-only host inspection, local plugin lifecycle, explicit plugin trust, and bounded observation reuse are also in scope. Guarded SQL execution (`sshx sql`) and guarded file apply (`sshx apply`) are deliberate scope expansions: they absorb mutation risk (classify → precondition → backup → -atomic change → structured result) without becoming a workflow engine. +atomic change → structured result) without becoming a workflow engine. The +stdio MCP server (`sshx mcp`) is a thin adapter over the same contract: tools +map 1:1 to CLI verbs, results are the CLI's versioned JSON, every call is a +one-shot child invocation audited with `entry=mcp`, and password management is +never exposed as a tool. **Convergence test:** every new sshx feature must remove an Agent judgment, not add a command the Agent has to learn. Absorb remote tax (host, credential, @@ -118,6 +125,7 @@ internal/app/ → CLI surface (argument parsing, routing, sub-comman inspect.go → one-shot capability execution + observation caching sql.go → sshx sql: guarded SQL pipeline (classify → gate → explain → backup → execute) apply.go → sshx apply: guarded single-file mutation (hash → backup → atomic write) + mcp.go → sshx mcp: stdio MCP server; tools self-exec sshx as one-shot children internal/execution/ → versioned request/result model, selectors, executor internal/plugin/ → manifests, schemas, scaffolds, trust, built-ins internal/runtimepath/ → ~/.sshx / SSHX_HOME runtime-root resolution @@ -362,7 +370,9 @@ Items must respect the boundaries in §3. **Now / recently shipped** -- ✅ CLI-only refactor (MCP server + connection pool removed). +- ✅ CLI-only refactor (resident MCP server + connection pool removed), later + followed by the deliberate reintroduction of a **stdio-only** MCP adapter + (`sshx mcp`) over the same one-shot execution contract. - ✅ Per-host SSH keys and per-host password keys. - ✅ Strict host-key verification with opt-in overrides. - ✅ Hardened sudo password handling (stdin), atomic config writes, secure @@ -388,8 +398,9 @@ Items must respect the boundaries in §3. - ⬜ Pluggable secret backends behind the existing keyring abstraction. -Anything implying a daemon, MCP, tunneling, or a GUI is explicitly **rejected** -unless the mission in §1–§3 is formally revised. +Anything implying a daemon, a resident protocol server (including HTTP/SSE +MCP), tunneling, or a GUI is explicitly **rejected** unless the mission in +§1–§3 is formally revised. ## 11. Release Process @@ -406,8 +417,8 @@ unless the mission in §1–§3 is formally revised. When working in this repo: 1. **Stay within the mission.** Re-read §3 before adding features. Default to a - smaller change. Never reintroduce MCP, a daemon, a connection pool, tunneling, - or a GUI. + smaller change. Never introduce a daemon, a connection pool, an HTTP/SSE + protocol server, tunneling, or a GUI. 2. **Hold the toolchain line.** Keep `go.mod` at `go 1.25.13`. If a dependency forces a newer directive, pin an older compatible version instead of bumping the directive (CI runs Go 1.25.13). diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ee21a..b76f699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.0] - 2026-08-16 + +### Added + +- Add `sshx mcp`, a stdio Model Context Protocol server built on the official + `modelcontextprotocol/go-sdk`. Tools (`sshx_run`, `sshx_sql`, `sshx_apply`, + `sshx_inspect`, `sshx_sftp`, `sshx_transfer`, `sshx_host_list`) map 1:1 to + the CLI execution contract; every tool call re-enters sshx as a one-shot + child process and returns the CLI's versioned JSON verbatim. The server is + spawned and owned by an MCP client, holds no connections, and exits with its + client — HTTP/SSE transports and resident services remain out of scope. +- `--host-list --json` now emits a machine-readable `sshx.hosts.v1` document + (names, addresses, groups, tags, and credential key references only). +- Audit events record an `entry` field (currently `mcp`) so MCP-originated + executions are distinguishable from direct CLI use. The marker is metadata + only and never affects trust, safety, or credential decisions. +- Add Windows CI coverage: build, vet, and unit tests for the cross-platform + core packages (`cmd`, `execution`, `keyringstore`, `sqlsafe`, `pkg`); + full-suite Windows enablement is tracked in issue #50. +- Add `make test-keychain-macos` and `scripts/macos-dev-keychain.sh`: run the + real-keyring E2E suite locally inside an ephemeral macOS Keychain with no + GUI authorization prompts, restoring the original keychain afterwards. +- Add `CONTRIBUTING.md` and unit coverage for `internal/keyringstore` (system + and `sshx_e2e` backends). + +### Security + +- Password management is deliberately not exposed over MCP; secret set/get + remains CLI-only. `force` / `no_safety_check` require an explicit + `bypass_reason` tool parameter, mirroring the CLI contract. + ## [0.6.0] - 2026-08-16 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..355d400 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,88 @@ +# Contributing to sshx + +Thanks for your interest in improving sshx. This guide covers the practical +workflow; **read [AGENT.md](./AGENT.md) first** — it defines the project's +mission, scope boundaries (non-goals), and architecture, and every change is +reviewed against it. + +## Ground Rules + +- **Scope discipline**: features listed as non-goals in AGENT.md §3 (daemons, + resident remote agents, orchestration, GUI/TUI, plaintext secrets, …) will + not be accepted without a prior discussion issue. When in doubt, open an + issue before writing code. +- **Convergence test**: a new feature must remove an Agent judgment, not add a + command an Agent has to learn. +- **Security first**: never weaken host-key verification, keyring-only secret + storage, safety-check, or audit semantics for convenience. + +## Development Setup + +Requirements: Go (version pinned in `go.mod`), `make`, and optionally +`golangci-lint`. + +```bash +git clone https://github.com/talkincode/sshx.git +cd sshx +make setup-hooks # install pre-commit/pre-push hooks (fmt, vet, tests) +make build # build ./bin/sshx +``` + +## Testing + +| Command | What it runs | +| --- | --- | +| `make test-short` | unit tests only (`-short`) | +| `make test` | all Go tests including the E2E package | +| `make test-e2e` | compiled-binary E2E suite against an in-process SSH/SFTP server | +| `make test-keychain-macos` | E2E with the real macOS Keychain, in an ephemeral keychain, no GUI prompts | +| `make check` | fmt + vet + tests | + +Notes: + +- **`sshx_e2e` build tag**: tests and E2E binaries built with `-tags sshx_e2e` + swap the OS keyring for a file-backed isolated keyring + (`internal/keyringstore/backend_e2e.go`, keyed by `SSHX_E2E_KEYRING_FILE`). + This keeps routine test runs off your real Keychain/Credential Manager. The + real OS keyring path is only exercised when `SSHX_E2E_REAL_KEYRING=1`. +- The E2E suite compiles the actual binary and talks real TCP SSH/SFTP to an + isolated in-process server; it observes exit codes, stdout/stderr JSON, + remote state, `known_hosts`, settings, keyring, and audit JSONL. +- macOS contributors: see "macOS Keychain Prompts During Development" in + [docs/troubleshooting.md](./docs/troubleshooting.md). + +## Acceptance-Matrix Rule (required for new first-level features) + +`docs/roadmap.md` defines hard coverage minimums. Any new first-level +capability must ship with: + +1. at least one happy-path E2E through the compiled binary, +2. at least one failure-path E2E if the feature is high-risk, +3. two role/permission states if the feature touches permissions, +4. one failure-recovery/rollback proof if the feature mutates state, +5. an updated acceptance matrix row in `docs/roadmap.md`. + +Component tests alone do not count as completion evidence. + +## Pull Requests + +- Use conventional commit titles (`feat(scope): …`, `fix: …`, `docs: …`, + `ci: …`, `chore: …`), matching the existing history. +- Keep PRs focused; separate refactors from behavior changes. +- Update user-facing docs in the same PR: `README.md`, `README_CN.md`, + `docs/`, `internal/app/usage.go` (help text must stay in sync with flags), + and `CHANGELOG.md` under `[Unreleased]`. +- CI must be green: unit tests (Linux/macOS/Windows), E2E (Linux/macOS), lint, + and security scans. + +## Reporting Issues + +- Bugs: include the sshx version, OS, the exact command (redact hosts and + secrets), and `--json` output when possible. +- Security vulnerabilities: **do not open a public issue** — follow + [SECURITY.md](./SECURITY.md). + +## License + +By contributing you agree that your contributions are licensed under the +[MIT License](./LICENSE). diff --git a/Makefile b/Makefile index 1fb4208..f7b1771 100644 --- a/Makefile +++ b/Makefile @@ -75,6 +75,10 @@ test-e2e: ## Run compiled-binary SSH/SFTP E2E tests (native keyring is opt-in) @echo "Running compiled-binary E2E tests..." $(GOTEST) -v ./tests/e2e +test-keychain-macos: ## Run real-keyring E2E in an ephemeral macOS Keychain (no GUI prompts) + @echo "Running real-keyring E2E tests in an ephemeral macOS Keychain..." + bash scripts/macos-dev-keychain.sh + test-verbose: ## Run verbose tests @echo "Running verbose tests..." $(GOTEST) -v -race ./... diff --git a/README.md b/README.md index dbee154..aa385d2 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Agent-Native Remote Execution over SSH [![Release](https://img.shields.io/github/v/release/talkincode/sshx?style=flat-square&logo=github)](https://github.com/talkincode/sshx/releases) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://github.com/talkincode/sshx/blob/main/LICENSE) [![Go Report Card](https://goreportcard.com/badge/github.com/talkincode/sshx?style=flat-square)](https://goreportcard.com/report/github.com/talkincode/sshx) -[![Coverage](https://img.shields.io/badge/coverage-20.0%25-yellow?style=flat-square&logo=go)](https://github.com/talkincode/sshx) +[![Coverage](https://img.shields.io/badge/coverage-48.4%25-yellowgreen?style=flat-square&logo=go)](https://github.com/talkincode/sshx) [![GitHub Stars](https://img.shields.io/github/stars/talkincode/sshx?style=flat-square&logo=github)](https://github.com/talkincode/sshx/stargazers) [![GitHub Forks](https://img.shields.io/github/forks/talkincode/sshx?style=flat-square&logo=github)](https://github.com/talkincode/sshx/network/members) @@ -73,6 +73,8 @@ It remains a single binary with one-shot invocations and no resident component o 9. One-shot host inspection with built-in system/network capabilities, local sshx-owned plugins, explicit digest trust, and freshness-bounded observations. 10. Guarded single-file apply: hash precondition, backup, and atomic replace. +11. Built-in stdio MCP server (`sshx mcp`): the same execution contract, safety + gates, and audit trail exposed as Model Context Protocol tools. ## Installation @@ -344,6 +346,29 @@ sshx -h=prod-web --pty "top -b -n1" The timeout can also be set via the `SSH_TIMEOUT` environment variable. +### MCP server (stdio) + +MCP-capable agents can consume the same execution contract as native tools: + +```bash +sshx mcp +``` + +```json +{ + "mcpServers": { + "sshx": { "command": "sshx", "args": ["mcp"] } + } +} +``` + +The server speaks MCP over stdio only, is spawned and owned by the client, and +re-enters sshx as a one-shot child process per tool call — identical safety +gates, keyring roles, and audit trail (events carry `entry: "mcp"`). Exposed +tools: `sshx_run`, `sshx_sql`, `sshx_apply`, `sshx_inspect`, `sshx_sftp`, +`sshx_transfer`, `sshx_host_list`. Password management is deliberately not +exposed over MCP. See [docs/mcp.md](docs/mcp.md). + ## Guarded SQL Execution Use `sshx sql` instead of sending raw `psql` or `sqlite3` commands through @@ -870,6 +895,13 @@ make lint The normal E2E run uses an isolated, test-only keyring provider. CI additionally checks the production binary against an ephemeral macOS Keychain. +## Contributing + +Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) for the +development workflow, testing requirements (including the acceptance-matrix +rule for new features), and PR expectations, and [AGENT.md](AGENT.md) for the +project's mission and scope boundaries. + ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/README_CN.md b/README_CN.md index a103185..56e4bd1 100644 --- a/README_CN.md +++ b/README_CN.md @@ -20,7 +20,7 @@ $$\ $$ |$$\ $$ |$$ | $$ |$$ /\$$\ [![Release](https://img.shields.io/github/v/release/talkincode/sshx?style=flat-square&logo=github)](https://github.com/talkincode/sshx/releases) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://github.com/talkincode/sshx/blob/main/LICENSE) [![Go Report Card](https://goreportcard.com/badge/github.com/talkincode/sshx?style=flat-square)](https://goreportcard.com/report/github.com/talkincode/sshx) -[![Coverage](https://img.shields.io/badge/coverage-20.0%25-yellow?style=flat-square&logo=go)](https://github.com/talkincode/sshx) +[![Coverage](https://img.shields.io/badge/coverage-48.4%25-yellowgreen?style=flat-square&logo=go)](https://github.com/talkincode/sshx) [![GitHub Stars](https://img.shields.io/github/stars/talkincode/sshx?style=flat-square&logo=github)](https://github.com/talkincode/sshx/stargazers) [![GitHub Forks](https://img.shields.io/github/forks/talkincode/sshx?style=flat-square&logo=github)](https://github.com/talkincode/sshx/network/members) @@ -73,6 +73,7 @@ Agent 需要的不是另一个交互式 SSH shell,而是一份稳定、可组 9. 单次主机环境探测:内置系统/网络能力,应用级插件归 sshx 本地运行目录管理, 支持摘要信任和有有效期的观察快照。 10. 受控单文件 apply:哈希前置条件、备份和原子替换。 +11. 内置 stdio MCP server(`sshx mcp`):以 Model Context Protocol 工具形式暴露同一套执行契约、安全门禁与审计留痕。 ## 安装 @@ -337,6 +338,24 @@ sshx -h=prod-web --pty "top -b -n1" 超时也可以通过环境变量 `SSH_TIMEOUT` 设置。 +### MCP server(stdio) + +支持 MCP 的 Agent 可以把同一套执行契约当作原生工具消费: + +```bash +sshx mcp +``` + +```json +{ + "mcpServers": { + "sshx": { "command": "sshx", "args": ["mcp"] } + } +} +``` + +server 仅通过 stdio 通信,由 MCP 客户端拉起并随之退出;每个 tool call 都以一次性子进程重新进入 sshx——安全门禁、keyring 凭据角色与审计留痕完全一致(审计事件带 `entry: "mcp"` 标记)。暴露的工具:`sshx_run`、`sshx_sql`、`sshx_apply`、`sshx_inspect`、`sshx_sftp`、`sshx_transfer`、`sshx_host_list`。密码管理刻意不经 MCP 暴露。详见 [docs/mcp.md](docs/mcp.md)。 + ## 主机探测与本地插件 面对陌生服务器时,用一次结构化探测替代多轮零散命令: @@ -703,6 +722,10 @@ make lint 常规 E2E 使用仅供测试的隔离 keyring 后端;CI 还会让生产构建连接临时 macOS Keychain,验证真实系统 keyring 生命周期。 +## 贡献 + +欢迎贡献。请阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 了解开发流程、测试要求(包括新功能的验收矩阵规则)与 PR 规范,并阅读 [AGENT.md](AGENT.md) 了解项目使命与边界。 + ## 许可证 本项目采用 MIT 许可证 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。 diff --git a/RELEASE.md b/RELEASE.md index 99947e6..db1626b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -26,6 +26,10 @@ First, update `CHANGELOG.md` to record the changes for this release: - Bug fixes description ``` +Then update the supported-versions table in `SECURITY.md` so it matches the +N / N-1 policy for the new release (e.g. releasing `v0.7.0` means `0.7.x` and +`0.6.x` are supported and everything older is not). + ### 2. Commit Changes ```bash diff --git a/SECURITY.md b/SECURITY.md index 49d5f14..c081ada 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,9 +6,9 @@ We take security seriously. The following versions of SSHX are currently support | Version | Supported | | ------- | ------------------ | -| 0.5.x | :white_check_mark: | -| 0.4.x | :white_check_mark: | -| < 0.4.0 | :x: | +| 0.7.x | :white_check_mark: | +| 0.6.x | :white_check_mark: | +| < 0.6.0 | :x: | Security updates are provided for the latest minor release and the previous minor release (N-1). Older lines do not receive patches; please upgrade. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 21f866d..d40eb46 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -7,6 +7,7 @@ - [Guarded File Apply](apply.md) - [Agent and Script Mode](agent-scripting.md) - [Inspection Capabilities and Local Plugins](inspection-plugins.md) + - [MCP Server (stdio)](mcp.md) - [Usage Scenarios](usage-scenarios.md) - [Security Guidelines](security-guidelines.md) - [Troubleshooting](troubleshooting.md) diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..942810a --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,76 @@ +# MCP Server (stdio) + +`sshx mcp` serves the sshx execution contract over the Model Context Protocol +so MCP-capable agents (Claude Desktop, IDE agents, custom clients) can call +sshx as native tools instead of shelling out. + +```bash +sshx mcp +``` + +The server speaks MCP over stdio only. It is spawned and owned by the MCP +client, holds no SSH connections, keeps no state, and exits when the client +closes the stream. Every tool call re-enters the sshx binary as a one-shot +child process — the same process model, safety gates, keyring access, and +audit trail as the CLI. + +## Client Configuration + +Claude Desktop / generic MCP client entry: + +```json +{ + "mcpServers": { + "sshx": { + "command": "sshx", + "args": ["mcp"] + } + } +} +``` + +## Tools + +| Tool | Maps to | Notes | +| --- | --- | --- | +| `sshx_run` | `sshx run --json` | Selectors, command or byte-preserving script, bounded fan-out, dry-run, force + bypass_reason | +| `sshx_sql` | `sshx sql --json` | Guarded single-statement SQL via remote psql/sqlite3 | +| `sshx_apply` | `sshx apply --json` | Guarded single-file replace; accepts `from_path` or inline `content` | +| `sshx_inspect` | `sshx inspect --json` | Built-in capabilities and trusted local plugins | +| `sshx_sftp` | SFTP flags | upload / download / list / mkdir / remove | +| `sshx_transfer` | `--transfer` | Server-to-server streaming through the local machine | +| `sshx_host_list` | `--host-list --json` | Read-only `sshx.hosts.v1` inventory | + +Tool results contain the CLI's versioned JSON verbatim (for example +`sshx.result.v1` from `sshx_run`), so `success`, `error_kind`, `completion`, +and retry guidance keep exactly the semantics documented for the CLI. A +non-zero child exit marks the MCP result as a tool error while preserving the +structured payload. + +## Security Model + +- **Same gates, same evidence.** Safety checks, host-key verification, keyring + credential roles, and audit all run in the child process exactly as in + direct CLI use. `force` / `no_safety_check` require an explicit + `bypass_reason` argument. +- **Audit attribution.** Child invocations carry `entry: "mcp"` in their audit + events, so MCP-originated executions are distinguishable from interactive + CLI use. The marker is metadata only — it never changes trust or safety + decisions. +- **No secret surface.** Password management (`--password-set` and friends) is + deliberately not exposed as a tool. Configure credentials with the CLI + first; MCP tools only ever reference keyring keys. +- **No trust relaxations by omission.** Accepting unknown host keys is not a + tool parameter. Trust hosts explicitly beforehand (for example with + `sshx --host-test` or one supervised CLI run). +- **stdio only.** There is no HTTP/SSE transport, no listening socket, and no + resident service; this boundary is documented in AGENT.md §3. + +## Typical Flow + +1. Configure and trust hosts with the CLI (`--host-add`, `--host-import`, + `--host-test`). +2. Store credentials in the OS keyring (`--password-set=...`). +3. Point the MCP client at `sshx mcp`. +4. The agent discovers inventory (`sshx_host_list`), previews with + `dry_run: true`, executes, and branches on the structured result. diff --git a/docs/roadmap.md b/docs/roadmap.md index e1c2891..fabc64a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -125,6 +125,10 @@ Agent / 自动化 / 人类运维者 `sshx apply` 替换一个远程正则文件:绝对路径门闩、可选 `--expect-sha256` 前置条件、默认 owner-only 备份、同目录临时文件 + rename、保留权限/所有者。`--sudo` 先经 SFTP 暂存再特权安装。不包含 nginx -t 或 reload。证据:`internal/app/apply.go`、`internal/sshclient/apply.go`、`tests/e2e/apply_e2e_test.go`。 +- **stdio MCP server** + + `sshx mcp` 通过 stdio 提供 Model Context Protocol 工具面:`sshx_run`、`sshx_sql`、`sshx_apply`、`sshx_inspect`、`sshx_sftp`、`sshx_transfer`、`sshx_host_list` 与 CLI 契约 1:1 映射,每次 tool call 以一次性子进程重新进入 sshx,结果就是 CLI 的版本化 JSON;force/bypass_reason 必须显式传参,密码管理不暴露,审计事件带 `entry=mcp` 标记。证据:`internal/app/mcp.go`、`internal/app/mcp_test.go`、`tests/e2e/mcp_e2e_test.go`。 + - **本地结构化审计** 非 dry-run 调用默认写入本地 JSONL 审计事件,记录目标、动作、安全上下文、结果和耗时,排除 stdout/stderr,并对命令中的 secret-like 参数做尽力脱敏。证据:`internal/app/audit.go`、`internal/app/audit_test.go`。 @@ -153,7 +157,7 @@ Agent / 自动化 / 人类运维者 - **不成为 Ansible、Salt 或工作流引擎。** 可以提供有界的多主机执行,但不引入期望状态语言、playbook 生态、调度系统或长期任务编排。 -- **不在核心二进制内重新引入 MCP server。** CLI 和进程级结构化契约是稳定集成面;需要 MCP 或其他协议时,应由外部适配层调用 sshx,而不是扩张核心运行模型。 +- **不做 HTTP/SSE MCP server、守护进程或常驻协议服务。** stdio MCP server(`sshx mcp`)在范围内:它由 MCP 客户端拉起并随会话生灭,每个 tool call 都以一次性子进程重新进入 sshx,复用同一套契约、安全门禁与审计。不得添加 HTTP/SSE 传输、监听端口或任何寿命超过其客户端的服务。 - **不把危险命令防护宣传成沙箱。** sshx 降低误操作和凭据泄露风险,但不承诺安全执行恶意或不可信命令。 @@ -213,7 +217,7 @@ Agent / 自动化 / 人类运维者 - 明显危险动作默认受阻,特权执行与安全绕过显式可见;secret 不出现在普通配置、命令拼接、审计记录或默认终端回显中。 - 多主机执行即使部分失败,也能逐主机说明状态,并避免不受控并发和盲目重试。 - 会修改远端状态的操作能够说明是否执行、是否部分完成以及下一步如何安全判断,而不是只返回一个模糊 EOF 或通用错误。 -- 项目继续保持单二进制、无远端驻留组件、无核心 MCP server、无长期控制面的轻量边界。 +- 项目继续保持单二进制、无远端驻留组件、无常驻协议服务(stdio MCP 随客户端会话生灭)、无长期控制面的轻量边界。 - Agent 能在 sshx 运行目录快速创建、测试和信任应用探测插件;skill 只维护调用方法,不维护插件脚本。 - 常见系统/网络或应用部署探索可在一次调用中形成可复用观察,且陈旧、身份漂移或不可信缓存不会被静默采用。 - 每项一级能力都有覆盖真实 CLI 与真实 SSH/SFTP 边界的验收证据;安全与状态修改路径同时覆盖失败和恢复语义。 @@ -250,5 +254,6 @@ Agent / 自动化 / 人类运维者 | 可解释执行治理 | 高 | 是 | 可能 | ✅ run 契约 dry-run/digest/intent/bypass_reason | ✅ blocked、uncertain completion、typed error.kind | ✅ SSH login vs sudo key 分离 | ✅ completion 指导 verify_first/unsafe | `tests/e2e/run_e2e_test.go`、`internal/app/run.go`、`internal/execution` | | 受控 SQL 执行(PostgreSQL / SQLite) | 高 | 是 | 是,远端库 | ✅ sqlite 只读查询与带备份 UPDATE | ✅ 直连客户端阻断、ATTACH 分类拒绝、缺路径 | ✅ operator 密码角色 | ✅ UPDATE 前 CSV 可还原旧值 | `tests/e2e/sql_sqlite_e2e_test.go`、`internal/sqlsafe/*_test.go`、`internal/app/sql_test.go` | | 受控文件 Apply | 高 | 是 | 是,远端文件 | ✅ 创建/覆盖/幂等 | ✅ 哈希不匹配、符号链接、只读端 | ✅ operator/reader | ✅ 覆盖前备份可还原旧值 | `tests/e2e/apply_e2e_test.go`、`internal/app/apply_test.go`、`internal/sshclient/apply_test.go` | +| stdio MCP 工具面 | 高 | 是 | 可能,经子进程 | ✅ initialize/tools/list/tools/call 真实执行 | ✅ force 缺 bypass_reason 被拒、非法输入本地拒绝 | ✅ operator 密码角色 | ✅ dry-run 零连接;审计 `entry=mcp` 可追溯 | `tests/e2e/mcp_e2e_test.go`、`internal/app/mcp_test.go` | 当前已达到已实现一级能力的覆盖底线。表中的剩余红项属于尚未实现的方向能力,而不是用组件测试掩盖的既有质量债。未来任何一级能力不得只以参数解析或组件测试作为完成依据;必须沿用编译后二进制边界补充 E2E,并同步更新本矩阵。 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index fbcf976..6901fe2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -142,3 +142,44 @@ sshx --version ``` If installed with Go, confirm `~/go/bin` or your `GOPATH/bin` is in `PATH`. + +## macOS Keychain Prompts During Development + +Symptoms (contributors building sshx from source on macOS): + +- Every rebuilt binary triggers a Keychain authorization dialog when it reads + a stored password. +- Real-keyring E2E runs interrupt with GUI prompts. + +Cause: Keychain item ACLs are bound to the binary's code signature. Each +rebuild produces a new ad-hoc signature, so previously granted access no +longer matches. macOS has no global per-app allowlist; the two supported +mechanisms are a stable signing identity or an ephemeral test keychain. + +Fix 1 — ephemeral test keychain for E2E runs (recommended for tests): + +```bash +make test-keychain-macos +``` + +This mirrors CI: it creates a throwaway keychain, makes it the user default, +sets the key partition list so command-line tools need no GUI approval, runs +the E2E suite with `SSHX_E2E_REAL_KEYRING=1`, and always restores your +original keychain configuration afterwards. + +Fix 2 — stable self-signed identity for day-to-day manual use: + +1. Open Keychain Access → Certificate Assistant → Create a Certificate. + Name it `sshx-dev`, set Certificate Type to `Code Signing`. +2. Sign every dev build with it: + + ```bash + codesign -f -s sshx-dev ./bin/sshx + ``` + +3. On the next Keychain prompt choose "Always Allow". Because the signing + identity now stays constant across rebuilds, the approval persists. + +Note: routine unit tests never touch the real Keychain — the `sshx_e2e` build +tag swaps in a file-backed isolated keyring, and the E2E harness only uses the +OS keyring when `SSHX_E2E_REAL_KEYRING=1` is set. diff --git a/go.mod b/go.mod index c5fc7d8..66c1c07 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/talkincode/sshx go 1.25.13 require ( + github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/pkg/sftp v1.13.10 github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 github.com/stretchr/testify v1.11.1 @@ -16,9 +17,16 @@ require ( github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/kr/fs v0.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.15.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c75dae9..f983e90 100644 --- a/go.sum +++ b/go.sum @@ -8,30 +8,52 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/app/app.go b/internal/app/app.go index 58e237e..fdff42a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -77,6 +77,14 @@ func Run(args []string) (err error) { if config.Mode == "run" && config.Timeout == 0 { config.Timeout = 60 * time.Second } + + // Serve the Model Context Protocol over stdio. The server writes no audit + // event itself: every tool call re-enters sshx as a one-shot child process + // that records its own audit trail with the MCP entry marker. + if config.Mode == "mcp" { + return RunMCPServer() + } + audit := newAuditRecorder(config) defer func() { if auditErr := audit.finish(config, err); auditErr != nil { diff --git a/internal/app/audit.go b/internal/app/audit.go index ac0ae88..60a40a0 100644 --- a/internal/app/audit.go +++ b/internal/app/audit.go @@ -42,6 +42,7 @@ type auditEvent struct { Timestamp string `json:"timestamp"` Version string `json:"version,omitempty"` Actor string `json:"actor,omitempty"` + Entry string `json:"entry,omitempty"` OS string `json:"os"` Arch string `json:"arch"` @@ -171,6 +172,7 @@ func newAuditRecorder(config *sshclient.Config) *auditRecorder { Timestamp: started.UTC().Format(time.RFC3339Nano), Version: Version, Actor: currentActor(), + Entry: currentEntry(), OS: runtime.GOOS, Arch: runtime.GOARCH, HostInput: config.Host, @@ -627,6 +629,23 @@ func currentActor() string { return "" } +// currentEntry reports the invocation entry point declared by a wrapping sshx +// process (currently "mcp" for the stdio MCP server). It is audit metadata +// only: the value never participates in trust, safety, or credential +// decisions, and anything but a short lowercase token is ignored. +func currentEntry() string { + value := os.Getenv("SSHX_ENTRY") + if value == "" || len(value) > 32 { + return "" + } + for _, r := range value { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' && r != '_' { + return "" + } + } + return value +} + func intPtr(value int) *int { return &value } diff --git a/internal/app/config.go b/internal/app/config.go index bdcd1c5..6424c25 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -105,6 +105,9 @@ func ParseArgs(args []string) *sshclient.Config { case "skill": parseSkillArgs(config, args[2:]) return config + case "mcp": + parseMCPArgs(config, args[2:]) + return config case "inspect": parseInspectArgs(config, args[2:]) return config diff --git a/internal/app/host_manager.go b/internal/app/host_manager.go index b23d050..55cd4de 100644 --- a/internal/app/host_manager.go +++ b/internal/app/host_manager.go @@ -2,6 +2,7 @@ package app import ( "bufio" + "encoding/json" "fmt" "os" "strconv" @@ -406,6 +407,10 @@ func handleHostList(config *sshclient.Config) error { hosts := ListHosts(settings) + if config.JSONOutput { + return printHostListJSON(hosts) + } + if len(hosts) == 0 { fmt.Println("No hosts configured.") fmt.Println("\nTo add hosts:") @@ -456,6 +461,52 @@ func handleHostList(config *sshclient.Config) error { return nil } +// hostListJSONEntry is the machine-readable projection of one configured +// host. It references credential keys but never secret values. +type hostListJSONEntry struct { + Name string `json:"name"` + Host string `json:"host"` + Port string `json:"port,omitempty"` + User string `json:"user,omitempty"` + Description string `json:"description,omitempty"` + KeyPath string `json:"key_path,omitempty"` + SSHPasswordKey string `json:"ssh_password_key,omitempty"` + SudoPasswordKey string `json:"sudo_password_key,omitempty"` + Groups []string `json:"groups,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + Type string `json:"type,omitempty"` +} + +func printHostListJSON(hosts []HostConfig) error { + entries := make([]hostListJSONEntry, 0, len(hosts)) + for _, host := range hosts { + entries = append(entries, hostListJSONEntry{ + Name: host.Name, + Host: host.Host, + Port: host.Port, + User: host.User, + Description: host.Description, + KeyPath: host.Key, + SSHPasswordKey: host.EffectiveSSHPasswordKey(), + SudoPasswordKey: host.EffectiveSudoPasswordKey(), + Groups: host.Groups, + Tags: host.Tags, + Type: host.Type, + }) + } + doc := struct { + SchemaVersion string `json:"schema_version"` + Count int `json:"count"` + Hosts []hostListJSONEntry `json:"hosts"` + }{SchemaVersion: "sshx.hosts.v1", Count: len(entries), Hosts: entries} + data, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return fmt.Errorf("encode host list: %w", err) + } + fmt.Println(string(data)) + return nil +} + // handleHostTest tests host connection func handleHostTest(config *sshclient.Config) error { // Load settings diff --git a/internal/app/mcp.go b/internal/app/mcp.go new file mode 100644 index 0000000..fbe517c --- /dev/null +++ b/internal/app/mcp.go @@ -0,0 +1,584 @@ +package app + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/talkincode/sshx/internal/sshclient" +) + +// mcpEntryEnv marks child invocations spawned by the MCP server so audit +// events can attribute them to the MCP entry point. It is metadata only and +// never participates in trust or safety decisions. +const mcpEntryEnv = "SSHX_ENTRY=mcp" + +// mcpDefaultProcessTimeout bounds a child invocation when the caller does not +// provide an explicit timeout. +const mcpDefaultProcessTimeout = 30 * time.Minute + +// mcpProcessGrace is added on top of an explicit remote timeout so the child +// process can classify the remote timeout itself before being killed. +const mcpProcessGrace = 2 * time.Minute + +// parseMCPArgs configures the stdio MCP server mode. The subcommand takes no +// flags: the server is spawned and owned by an MCP client over stdio. +func parseMCPArgs(config *sshclient.Config, args []string) { + config.Mode = "mcp" + for _, arg := range args { + config.ArgumentError = fmt.Sprintf("sshx mcp accepts no arguments, got %q", arg) + return + } +} + +// RunMCPServer serves the Model Context Protocol over stdio. Every tool call +// self-executes the sshx binary as a one-shot child process with --json, so +// the MCP surface exposes exactly the CLI execution contract: same schemas, +// same safety gates, same audit trail. The server holds no connections and no +// state; it lives and dies with the client that spawned it. +func RunMCPServer() error { + server := mcp.NewServer(&mcp.Implementation{ + Name: "sshx", + Title: "sshx — agent-native remote execution over SSH", + Version: Version, + }, nil) + registerMCPTools(server) + return server.Run(context.Background(), &mcp.StdioTransport{}) +} + +// selfExecResult captures one child invocation outcome. +type selfExecResult struct { + ExitCode int + Stdout string + Stderr string +} + +// execSelf runs the current sshx binary with args as a one-shot child +// process, marking it as MCP-originated for audit purposes. +func execSelf(ctx context.Context, args []string, stdin string, remoteTimeout time.Duration) (*selfExecResult, error) { + exe, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("resolve sshx executable: %w", err) + } + + processTimeout := mcpDefaultProcessTimeout + if remoteTimeout > 0 && remoteTimeout+mcpProcessGrace < processTimeout { + processTimeout = remoteTimeout + mcpProcessGrace + } + ctx, cancel := context.WithTimeout(ctx, processTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, exe, args...) // #nosec G204 -- args are built from schema-validated tool input for our own binary. + cmd.Env = append(os.Environ(), mcpEntryEnv) + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + runErr := cmd.Run() + result := &selfExecResult{Stdout: stdout.String(), Stderr: stderr.String()} + if runErr == nil { + return result, nil + } + var exitErr *exec.ExitError + if errors.As(runErr, &exitErr) { + result.ExitCode = exitErr.ExitCode() + if ctx.Err() != nil { + result.Stderr = strings.TrimSpace(result.Stderr + "\nsshx mcp: child invocation killed by local process timeout") + } + return result, nil + } + return nil, fmt.Errorf("spawn sshx child process: %w", runErr) +} + +// mcpToolResult converts a child invocation into an MCP tool result. The +// child's stdout (a single JSON document in --json mode) is the tool content; +// a non-zero exit marks the result as a tool error while preserving the +// structured payload so agents can still branch on error_kind and friends. +func mcpToolResult(res *selfExecResult) *mcp.CallToolResult { + content := []mcp.Content{} + if strings.TrimSpace(res.Stdout) != "" { + content = append(content, &mcp.TextContent{Text: res.Stdout}) + } + if strings.TrimSpace(res.Stderr) != "" { + content = append(content, &mcp.TextContent{Text: "stderr:\n" + res.Stderr}) + } + if len(content) == 0 { + content = append(content, &mcp.TextContent{Text: fmt.Sprintf(`{"success":%t,"exit_code":%d}`, res.ExitCode == 0, res.ExitCode)}) + } + return &mcp.CallToolResult{Content: content, IsError: res.ExitCode != 0} +} + +func runMCPTool(ctx context.Context, args []string, stdin string, remoteTimeout time.Duration) (*mcp.CallToolResult, any, error) { + res, err := execSelf(ctx, args, stdin, remoteTimeout) + if err != nil { + return nil, nil, err + } + return mcpToolResult(res), nil, nil +} + +func timeoutSeconds(seconds int) time.Duration { + if seconds <= 0 { + return 0 + } + return time.Duration(seconds) * time.Second +} + +// --- tool inputs ----------------------------------------------------------- + +type mcpRunInput struct { + Targets []string `json:"targets,omitempty" jsonschema:"Configured host names to execute on (strict aliases, never DNS)."` + Groups []string `json:"groups,omitempty" jsonschema:"Configured host groups; union with targets."` + Tags []string `json:"tags,omitempty" jsonschema:"Tag filters in key=value form, combined with AND."` + AllHosts bool `json:"all_hosts,omitempty" jsonschema:"Select all configured hosts before tag filters."` + Address string `json:"address,omitempty" jsonschema:"Explicit single literal address (not for fan-out)."` + Command string `json:"command,omitempty" jsonschema:"Remote command line. Exactly one of command or script is required."` + Script string `json:"script,omitempty" jsonschema:"Byte-preserving script payload delivered over stdin. Exactly one of command or script is required."` + TimeoutSecs int `json:"timeout_seconds,omitempty" jsonschema:"Remote execution timeout in seconds (default 60)."` + Concurrency int `json:"concurrency,omitempty" jsonschema:"Bounded fan-out (default 4, hard max 32)."` + FailureMode string `json:"failure_mode,omitempty" jsonschema:"continue or fail_fast (default continue)."` + Intent string `json:"intent,omitempty" jsonschema:"Declared action intent: read, change, or unknown."` + DryRun bool `json:"dry_run,omitempty" jsonschema:"Preview the local execution plan without connecting or executing."` + Force bool `json:"force,omitempty" jsonschema:"Bypass safety checks; requires bypass_reason and is recorded in results and audit."` + NoSafetyCheck bool `json:"no_safety_check,omitempty" jsonschema:"Disable safety checks entirely; requires bypass_reason."` + BypassReason string `json:"bypass_reason,omitempty" jsonschema:"Mandatory justification when force or no_safety_check is set."` +} + +type mcpSQLInput struct { + Target string `json:"target" jsonschema:"Configured host name or address to reach over SSH."` + Statement string `json:"statement" jsonschema:"Exactly one SQL statement; multi-statement input is blocked fail-closed."` + Engine string `json:"engine,omitempty" jsonschema:"postgres (default) or sqlite."` + DB string `json:"db,omitempty" jsonschema:"PostgreSQL database name."` + DBFile string `json:"db_file,omitempty" jsonschema:"Absolute SQLite database file path (required for engine=sqlite)."` + DBUser string `json:"db_user,omitempty" jsonschema:"Database role."` + DBHost string `json:"db_host,omitempty" jsonschema:"Database host as seen from the remote host."` + DBPort string `json:"db_port,omitempty" jsonschema:"Database port."` + DBPasswordKey string `json:"db_password_key,omitempty" jsonschema:"OS-keyring key holding the DB password; delivered via stdin, never argv."` + Docker string `json:"docker,omitempty" jsonschema:"Run the database client inside this container via docker exec -i."` + DBCredFrom string `json:"db_cred_from,omitempty" jsonschema:"Resolve credentials on the remote host: docker: or env-file:."` + CredCache string `json:"cred_cache,omitempty" jsonschema:"off or a duration for caching remotely resolved credentials (default 15m)."` + CredRefresh bool `json:"cred_refresh,omitempty" jsonschema:"Drop the cached credential entry and re-resolve."` + Explain bool `json:"explain,omitempty" jsonschema:"Run EXPLAIN only; never executes the statement."` + RowThreshold int `json:"row_threshold,omitempty" jsonschema:"EXPLAIN row estimate that upgrades a row backup to a full-table snapshot (default 1000)."` + AllowFullTable bool `json:"allow_full_table,omitempty" jsonschema:"Required for UPDATE/DELETE without a WHERE clause."` + NoBackup bool `json:"no_backup,omitempty" jsonschema:"Skip the pre-change backup; requires force."` + BackupDir string `json:"backup_dir,omitempty" jsonschema:"Remote backup directory (default ~/.sshx/sql-backups)."` + Force bool `json:"force,omitempty" jsonschema:"Confirms DDL; destructive DDL also requires no_backup."` + DryRun bool `json:"dry_run,omitempty" jsonschema:"Preview the guarded SQL plan without connecting."` + TimeoutSecs int `json:"timeout_seconds,omitempty" jsonschema:"Remote execution timeout in seconds."` +} + +type mcpApplyInput struct { + Target string `json:"target" jsonschema:"Configured host name or address."` + Path string `json:"path" jsonschema:"Absolute remote file path to replace."` + FromPath string `json:"from_path,omitempty" jsonschema:"Local source file. Exactly one of from_path or content is required."` + Content string `json:"content,omitempty" jsonschema:"Inline file content written to a private temp file. Exactly one of from_path or content is required."` + ExpectSHA256 string `json:"expect_sha256,omitempty" jsonschema:"Fail closed unless the current remote hash matches."` + NoBackup bool `json:"no_backup,omitempty" jsonschema:"Skip the pre-change backup; requires force."` + BackupDir string `json:"backup_dir,omitempty" jsonschema:"Remote backup directory (default ~/.sshx/file-backups)."` + Sudo bool `json:"sudo,omitempty" jsonschema:"Stage over SFTP, then install with a privileged stdin script."` + Force bool `json:"force,omitempty" jsonschema:"Skip the hash precondition; required with no_backup."` + BypassReason string `json:"bypass_reason,omitempty" jsonschema:"Required with force when overwriting critical identity files."` + DryRun bool `json:"dry_run,omitempty" jsonschema:"Preview the apply plan without connecting."` + TimeoutSecs int `json:"timeout_seconds,omitempty" jsonschema:"Remote execution timeout in seconds."` +} + +type mcpInspectInput struct { + Target string `json:"target" jsonschema:"Configured host name or address."` + Capability string `json:"capability" jsonschema:"Capability id, e.g. system.baseline, network.listeners, or a trusted local plugin id."` + Cache string `json:"cache,omitempty" jsonschema:"off (default) or remote-prefer to reuse/write a redacted remote observation."` + Refresh bool `json:"refresh,omitempty" jsonschema:"Ignore a reusable observation and run the collector."` + MaxAge string `json:"max_age,omitempty" jsonschema:"Require observations no older than this duration (e.g. 30m)."` + AllowStale bool `json:"allow_stale,omitempty" jsonschema:"Explicitly allow an expired observation."` + Sudo bool `json:"sudo,omitempty" jsonschema:"Use sudo for an optional-privilege plugin."` + TimeoutSecs int `json:"timeout_seconds,omitempty" jsonschema:"Remote execution timeout in seconds."` +} + +type mcpSFTPInput struct { + Target string `json:"target" jsonschema:"Configured host name or address."` + Action string `json:"action" jsonschema:"upload, download, list, mkdir, or remove."` + LocalPath string `json:"local_path,omitempty" jsonschema:"Local file path (required for upload and download)."` + RemotePath string `json:"remote_path" jsonschema:"Remote path the action operates on."` + DryRun bool `json:"dry_run,omitempty" jsonschema:"Preview the SFTP plan without connecting."` + TimeoutSecs int `json:"timeout_seconds,omitempty" jsonschema:"Remote execution timeout in seconds."` +} + +type mcpTransferInput struct { + SourceHost string `json:"source_host" jsonschema:"Configured host name or address holding the source path."` + SourcePath string `json:"source_path" jsonschema:"Source file or directory path."` + DestHost string `json:"dest_host" jsonschema:"Configured host name or address receiving the data."` + DestPath string `json:"dest_path" jsonschema:"Destination path."` + DryRun bool `json:"dry_run,omitempty" jsonschema:"Preview the transfer plan without connecting."` + TimeoutSecs int `json:"timeout_seconds,omitempty" jsonschema:"Timeout in seconds for the streamed transfer."` +} + +type mcpHostListInput struct{} + +// --- argument builders (unit-tested) ---------------------------------------- + +func buildRunArgs(in mcpRunInput) ([]string, string, error) { + hasCommand := strings.TrimSpace(in.Command) != "" + hasScript := in.Script != "" + if hasCommand == hasScript { + return nil, "", fmt.Errorf("exactly one of command or script is required") + } + args := []string{"run", "--json"} + for _, t := range in.Targets { + args = append(args, "--target="+t) + } + for _, g := range in.Groups { + args = append(args, "--group="+g) + } + for _, tag := range in.Tags { + args = append(args, "--tag="+tag) + } + if in.AllHosts { + args = append(args, "--all-hosts") + } + if in.Address != "" { + args = append(args, "--address="+in.Address) + } + if in.TimeoutSecs > 0 { + args = append(args, "--timeout="+strconv.Itoa(in.TimeoutSecs)+"s") + } + if in.Concurrency > 0 { + args = append(args, "--concurrency="+strconv.Itoa(in.Concurrency)) + } + if in.FailureMode != "" { + args = append(args, "--failure-mode="+in.FailureMode) + } + if in.Intent != "" { + args = append(args, "--intent="+in.Intent) + } + if in.DryRun { + args = append(args, "--dry-run") + } + if in.Force { + args = append(args, "--force") + } + if in.NoSafetyCheck { + args = append(args, "--no-safety-check") + } + if in.BypassReason != "" { + args = append(args, "--bypass-reason="+in.BypassReason) + } + stdin := "" + if hasScript { + args = append(args, "--script-stdin") + stdin = in.Script + } else { + args = append(args, "--", in.Command) + } + return args, stdin, nil +} + +func buildSQLArgs(in mcpSQLInput) ([]string, error) { + if strings.TrimSpace(in.Target) == "" { + return nil, fmt.Errorf("target is required") + } + if strings.TrimSpace(in.Statement) == "" { + return nil, fmt.Errorf("statement is required") + } + args := []string{"sql", "--json", "-h=" + in.Target} + if in.Engine != "" { + args = append(args, "--engine="+in.Engine) + } + if in.DB != "" { + args = append(args, "--db="+in.DB) + } + if in.DBFile != "" { + args = append(args, "--db-file="+in.DBFile) + } + if in.DBUser != "" { + args = append(args, "--db-user="+in.DBUser) + } + if in.DBHost != "" { + args = append(args, "--db-host="+in.DBHost) + } + if in.DBPort != "" { + args = append(args, "--db-port="+in.DBPort) + } + if in.DBPasswordKey != "" { + args = append(args, "--db-password-key="+in.DBPasswordKey) + } + if in.Docker != "" { + args = append(args, "--docker="+in.Docker) + } + if in.DBCredFrom != "" { + args = append(args, "--db-cred-from="+in.DBCredFrom) + } + if in.CredCache != "" { + args = append(args, "--cred-cache="+in.CredCache) + } + if in.CredRefresh { + args = append(args, "--cred-refresh") + } + if in.Explain { + args = append(args, "--explain") + } + if in.RowThreshold > 0 { + args = append(args, "--row-threshold="+strconv.Itoa(in.RowThreshold)) + } + if in.AllowFullTable { + args = append(args, "--allow-full-table") + } + if in.NoBackup { + args = append(args, "--no-backup") + } + if in.BackupDir != "" { + args = append(args, "--backup-dir="+in.BackupDir) + } + if in.Force { + args = append(args, "--force") + } + if in.DryRun { + args = append(args, "--dry-run") + } + if in.TimeoutSecs > 0 { + args = append(args, "--timeout="+strconv.Itoa(in.TimeoutSecs)+"s") + } + args = append(args, "--", in.Statement) + return args, nil +} + +func buildApplyArgs(in mcpApplyInput, fromPath string) ([]string, error) { + if strings.TrimSpace(in.Target) == "" { + return nil, fmt.Errorf("target is required") + } + if strings.TrimSpace(in.Path) == "" { + return nil, fmt.Errorf("path is required") + } + args := []string{"apply", "--json", "-h=" + in.Target, "--path=" + in.Path, "--from=" + fromPath} + if in.ExpectSHA256 != "" { + args = append(args, "--expect-sha256="+in.ExpectSHA256) + } + if in.NoBackup { + args = append(args, "--no-backup") + } + if in.BackupDir != "" { + args = append(args, "--backup-dir="+in.BackupDir) + } + if in.Sudo { + args = append(args, "--sudo") + } + if in.Force { + args = append(args, "--force") + } + if in.BypassReason != "" { + args = append(args, "--bypass-reason="+in.BypassReason) + } + if in.DryRun { + args = append(args, "--dry-run") + } + if in.TimeoutSecs > 0 { + args = append(args, "--timeout="+strconv.Itoa(in.TimeoutSecs)+"s") + } + return args, nil +} + +func buildInspectArgs(in mcpInspectInput) ([]string, error) { + if strings.TrimSpace(in.Target) == "" { + return nil, fmt.Errorf("target is required") + } + if strings.TrimSpace(in.Capability) == "" { + return nil, fmt.Errorf("capability is required") + } + args := []string{"inspect", "--json", "-h=" + in.Target} + if in.Cache != "" { + args = append(args, "--cache="+in.Cache) + } + if in.Refresh { + args = append(args, "--refresh") + } + if in.MaxAge != "" { + args = append(args, "--max-age="+in.MaxAge) + } + if in.AllowStale { + args = append(args, "--allow-stale") + } + if in.Sudo { + args = append(args, "--sudo") + } + if in.TimeoutSecs > 0 { + args = append(args, "--timeout="+strconv.Itoa(in.TimeoutSecs)+"s") + } + args = append(args, in.Capability) + return args, nil +} + +func buildSFTPArgs(in mcpSFTPInput) ([]string, error) { + if strings.TrimSpace(in.Target) == "" { + return nil, fmt.Errorf("target is required") + } + if strings.TrimSpace(in.RemotePath) == "" { + return nil, fmt.Errorf("remote_path is required") + } + args := []string{"--json", "-h=" + in.Target} + switch in.Action { + case "upload": + if in.LocalPath == "" { + return nil, fmt.Errorf("local_path is required for upload") + } + args = append(args, "--upload="+in.LocalPath, "--to="+in.RemotePath) + case "download": + if in.LocalPath == "" { + return nil, fmt.Errorf("local_path is required for download") + } + args = append(args, "--download="+in.RemotePath, "--to="+in.LocalPath) + case "list": + args = append(args, "--list="+in.RemotePath) + case "mkdir": + args = append(args, "--mkdir="+in.RemotePath) + case "remove": + args = append(args, "--rm="+in.RemotePath) + default: + return nil, fmt.Errorf("action must be one of upload, download, list, mkdir, remove") + } + if in.DryRun { + args = append(args, "--dry-run") + } + if in.TimeoutSecs > 0 { + args = append(args, "--timeout="+strconv.Itoa(in.TimeoutSecs)+"s") + } + return args, nil +} + +func buildTransferArgs(in mcpTransferInput) ([]string, error) { + for name, value := range map[string]string{ + "source_host": in.SourceHost, "source_path": in.SourcePath, + "dest_host": in.DestHost, "dest_path": in.DestPath, + } { + if strings.TrimSpace(value) == "" { + return nil, fmt.Errorf("%s is required", name) + } + } + args := []string{"--json", + "--transfer=" + in.SourceHost + ":" + in.SourcePath, + "--to=" + in.DestHost + ":" + in.DestPath, + } + if in.DryRun { + args = append(args, "--dry-run") + } + if in.TimeoutSecs > 0 { + args = append(args, "--timeout="+strconv.Itoa(in.TimeoutSecs)+"s") + } + return args, nil +} + +// --- registration ----------------------------------------------------------- + +func registerMCPTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "sshx_run", + Description: "Execute one command or byte-preserving script on configured SSH hosts through the canonical sshx run contract: " + + "strict selectors, bounded fan-out, dry-run preview, safety gates, versioned JSON result with per-target status, " + + "completion certainty, error kind, and retry guidance. Destructive commands are blocked unless force plus bypass_reason is explicit.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in mcpRunInput) (*mcp.CallToolResult, any, error) { + args, stdin, err := buildRunArgs(in) + if err != nil { + return nil, nil, err + } + return runMCPTool(ctx, args, stdin, timeoutSeconds(in.TimeoutSecs)) + }) + + mcp.AddTool(server, &mcp.Tool{ + Name: "sshx_sql", + Description: "Run exactly one guarded SQL statement through the remote psql or sqlite3 client: fail-closed classification, " + + "policy gates, mandatory EXPLAIN and automatic row/table backups for data changes, structured JSON result, and audit. " + + "Reads run read-only. Use this instead of invoking database clients via sshx_run (which blocks them).", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in mcpSQLInput) (*mcp.CallToolResult, any, error) { + args, err := buildSQLArgs(in) + if err != nil { + return nil, nil, err + } + return runMCPTool(ctx, args, "", timeoutSeconds(in.TimeoutSecs)) + }) + + mcp.AddTool(server, &mcp.Tool{ + Name: "sshx_apply", + Description: "Replace exactly one remote regular file with a guarded pipeline: optional expect_sha256 precondition, " + + "owner-only backup, atomic same-directory rename preserving mode and owner, and a JSON result with changed, hashes, " + + "and rollback_available. Reload/restart is deliberately out of scope — run it separately via sshx_run.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in mcpApplyInput) (*mcp.CallToolResult, any, error) { + hasFrom := in.FromPath != "" + hasContent := in.Content != "" + if hasFrom == hasContent { + return nil, nil, fmt.Errorf("exactly one of from_path or content is required") + } + fromPath := in.FromPath + if hasContent { + dir, err := os.MkdirTemp("", "sshx-mcp-apply-") + if err != nil { + return nil, nil, fmt.Errorf("create temp payload dir: %w", err) + } + defer func() { + _ = os.RemoveAll(dir) //nolint:errcheck // best-effort temp payload cleanup + }() + fromPath = filepath.Join(dir, "payload") + if err := os.WriteFile(fromPath, []byte(in.Content), 0o600); err != nil { + return nil, nil, fmt.Errorf("write temp payload: %w", err) + } + } + args, err := buildApplyArgs(in, fromPath) + if err != nil { + return nil, nil, err + } + return runMCPTool(ctx, args, "", timeoutSeconds(in.TimeoutSecs)) + }) + + mcp.AddTool(server, &mcp.Tool{ + Name: "sshx_inspect", + Description: "Run one structured host inspection over a single SSH connection: built-in system/network capabilities " + + "(system.identity, system.resources, system.baseline, network.*) or trusted local plugins, with provenance, " + + "freshness, and optional bounded observation reuse. Read-only on the remote host unless caching is enabled.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in mcpInspectInput) (*mcp.CallToolResult, any, error) { + args, err := buildInspectArgs(in) + if err != nil { + return nil, nil, err + } + return runMCPTool(ctx, args, "", timeoutSeconds(in.TimeoutSecs)) + }) + + mcp.AddTool(server, &mcp.Tool{ + Name: "sshx_sftp", + Description: "One SFTP file action on a configured host: upload, download, list, mkdir, or remove, " + + "with the same JSON result, dry-run, and audit semantics as the CLI.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in mcpSFTPInput) (*mcp.CallToolResult, any, error) { + args, err := buildSFTPArgs(in) + if err != nil { + return nil, nil, err + } + return runMCPTool(ctx, args, "", timeoutSeconds(in.TimeoutSecs)) + }) + + mcp.AddTool(server, &mcp.Tool{ + Name: "sshx_transfer", + Description: "Stream a file or directory directly from one SSH host to another through the local machine " + + "without touching local disk, preserving permission bits.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in mcpTransferInput) (*mcp.CallToolResult, any, error) { + args, err := buildTransferArgs(in) + if err != nil { + return nil, nil, err + } + return runMCPTool(ctx, args, "", timeoutSeconds(in.TimeoutSecs)) + }) + + mcp.AddTool(server, &mcp.Tool{ + Name: "sshx_host_list", + Description: "List configured named hosts (aliases, addresses, groups, tags, credential references) from " + + "~/.sshx/settings.json. Read-only discovery; secrets never appear in the output.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, _ mcpHostListInput) (*mcp.CallToolResult, any, error) { + return runMCPTool(ctx, []string{"--host-list", "--json"}, "", 0) + }) +} diff --git a/internal/app/mcp_test.go b/internal/app/mcp_test.go new file mode 100644 index 0000000..45fc553 --- /dev/null +++ b/internal/app/mcp_test.go @@ -0,0 +1,261 @@ +package app + +import ( + "strings" + "testing" +) + +func TestBuildRunArgsCommand(t *testing.T) { + args, stdin, err := buildRunArgs(mcpRunInput{ + Targets: []string{"web-1", "web-2"}, + Groups: []string{"prod"}, + Tags: []string{"env=prod"}, + Command: "systemctl is-active nginx", + TimeoutSecs: 30, + Concurrency: 8, + FailureMode: "fail_fast", + Intent: "read", + DryRun: true, + Force: true, + BypassReason: "maintenance window", + }) + if err != nil { + t.Fatalf("buildRunArgs: %v", err) + } + if stdin != "" { + t.Fatalf("stdin = %q, want empty for command mode", stdin) + } + want := []string{ + "run", "--json", + "--target=web-1", "--target=web-2", + "--group=prod", + "--tag=env=prod", + "--timeout=30s", + "--concurrency=8", + "--failure-mode=fail_fast", + "--intent=read", + "--dry-run", + "--force", + "--bypass-reason=maintenance window", + "--", "systemctl is-active nginx", + } + assertArgs(t, args, want) +} + +func TestBuildRunArgsScriptStdin(t *testing.T) { + script := "#!/bin/sh\necho hello\n" + args, stdin, err := buildRunArgs(mcpRunInput{Targets: []string{"web-1"}, Script: script}) + if err != nil { + t.Fatalf("buildRunArgs: %v", err) + } + if stdin != script { + t.Fatalf("stdin = %q, want the byte-preserved script", stdin) + } + assertArgs(t, args, []string{"run", "--json", "--target=web-1", "--script-stdin"}) +} + +func TestBuildRunArgsRequiresExactlyOnePayload(t *testing.T) { + if _, _, err := buildRunArgs(mcpRunInput{Targets: []string{"a"}}); err == nil { + t.Fatal("expected error when neither command nor script is set") + } + if _, _, err := buildRunArgs(mcpRunInput{Targets: []string{"a"}, Command: "x", Script: "y"}); err == nil { + t.Fatal("expected error when both command and script are set") + } +} + +func TestBuildSQLArgs(t *testing.T) { + args, err := buildSQLArgs(mcpSQLInput{ + Target: "db-1", + Statement: "SELECT count(*) FROM users", + Engine: "postgres", + DB: "app", + DBUser: "app", + DBPasswordKey: "app-db", + Explain: true, + RowThreshold: 500, + DryRun: true, + }) + if err != nil { + t.Fatalf("buildSQLArgs: %v", err) + } + want := []string{ + "sql", "--json", "-h=db-1", + "--engine=postgres", "--db=app", "--db-user=app", + "--db-password-key=app-db", "--explain", "--row-threshold=500", + "--dry-run", + "--", "SELECT count(*) FROM users", + } + assertArgs(t, args, want) +} + +func TestBuildSQLArgsRequiredFields(t *testing.T) { + if _, err := buildSQLArgs(mcpSQLInput{Statement: "SELECT 1"}); err == nil { + t.Fatal("expected error without target") + } + if _, err := buildSQLArgs(mcpSQLInput{Target: "db-1"}); err == nil { + t.Fatal("expected error without statement") + } +} + +func TestBuildApplyArgs(t *testing.T) { + args, err := buildApplyArgs(mcpApplyInput{ + Target: "prod", + Path: "/etc/nginx/nginx.conf", + ExpectSHA256: "abc123", + Sudo: true, + Force: true, + BypassReason: "planned change", + TimeoutSecs: 45, + }, "/tmp/payload") + if err != nil { + t.Fatalf("buildApplyArgs: %v", err) + } + want := []string{ + "apply", "--json", "-h=prod", + "--path=/etc/nginx/nginx.conf", "--from=/tmp/payload", + "--expect-sha256=abc123", "--sudo", "--force", + "--bypass-reason=planned change", "--timeout=45s", + } + assertArgs(t, args, want) +} + +func TestBuildApplyArgsRequiredFields(t *testing.T) { + if _, err := buildApplyArgs(mcpApplyInput{Path: "/x"}, "/tmp/p"); err == nil { + t.Fatal("expected error without target") + } + if _, err := buildApplyArgs(mcpApplyInput{Target: "h"}, "/tmp/p"); err == nil { + t.Fatal("expected error without path") + } +} + +func TestBuildInspectArgs(t *testing.T) { + args, err := buildInspectArgs(mcpInspectInput{ + Target: "prod", + Capability: "system.baseline", + Cache: "remote-prefer", + MaxAge: "30m", + Sudo: true, + }) + if err != nil { + t.Fatalf("buildInspectArgs: %v", err) + } + want := []string{ + "inspect", "--json", "-h=prod", + "--cache=remote-prefer", "--max-age=30m", "--sudo", + "system.baseline", + } + assertArgs(t, args, want) +} + +func TestBuildSFTPArgs(t *testing.T) { + cases := []struct { + name string + in mcpSFTPInput + want []string + fails bool + }{ + { + name: "upload", + in: mcpSFTPInput{Target: "h", Action: "upload", LocalPath: "/l", RemotePath: "/r"}, + want: []string{"--json", "-h=h", "--upload=/l", "--to=/r"}, + }, + { + name: "download", + in: mcpSFTPInput{Target: "h", Action: "download", LocalPath: "/l", RemotePath: "/r"}, + want: []string{"--json", "-h=h", "--download=/r", "--to=/l"}, + }, + { + name: "list", + in: mcpSFTPInput{Target: "h", Action: "list", RemotePath: "/r"}, + want: []string{"--json", "-h=h", "--list=/r"}, + }, + { + name: "mkdir", + in: mcpSFTPInput{Target: "h", Action: "mkdir", RemotePath: "/r"}, + want: []string{"--json", "-h=h", "--mkdir=/r"}, + }, + { + name: "remove", + in: mcpSFTPInput{Target: "h", Action: "remove", RemotePath: "/r"}, + want: []string{"--json", "-h=h", "--rm=/r"}, + }, + {name: "upload without local path", in: mcpSFTPInput{Target: "h", Action: "upload", RemotePath: "/r"}, fails: true}, + {name: "unknown action", in: mcpSFTPInput{Target: "h", Action: "chmod", RemotePath: "/r"}, fails: true}, + {name: "missing target", in: mcpSFTPInput{Action: "list", RemotePath: "/r"}, fails: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args, err := buildSFTPArgs(tc.in) + if tc.fails { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("buildSFTPArgs: %v", err) + } + assertArgs(t, args, tc.want) + }) + } +} + +func TestBuildTransferArgs(t *testing.T) { + args, err := buildTransferArgs(mcpTransferInput{ + SourceHost: "a", SourcePath: "/src", DestHost: "b", DestPath: "/dst", DryRun: true, + }) + if err != nil { + t.Fatalf("buildTransferArgs: %v", err) + } + assertArgs(t, args, []string{"--json", "--transfer=a:/src", "--to=b:/dst", "--dry-run"}) + + if _, err := buildTransferArgs(mcpTransferInput{SourceHost: "a", SourcePath: "/s", DestHost: "b"}); err == nil { + t.Fatal("expected error for missing dest_path") + } +} + +func TestParseMCPArgs(t *testing.T) { + config := ParseArgs([]string{"sshx", "mcp"}) + if config.Mode != "mcp" { + t.Fatalf("Mode = %q, want mcp", config.Mode) + } + if config.ArgumentError != "" { + t.Fatalf("unexpected argument error: %s", config.ArgumentError) + } + + config = ParseArgs([]string{"sshx", "mcp", "--port=8080"}) + if config.ArgumentError == "" { + t.Fatal("expected argument error for unsupported mcp flag") + } +} + +func TestCurrentEntrySanitizes(t *testing.T) { + cases := map[string]string{ + "mcp": "mcp", + "ci-runner_1": "ci-runner_1", + "": "", + "MCP": "", + "mcp;rm -rf /": "", + strings.Repeat("a", 33): "", + "with space": "", + "unicode-\u4f60\u597d": "", + } + for input, want := range cases { + t.Setenv("SSHX_ENTRY", input) + if got := currentEntry(); got != want { + t.Fatalf("currentEntry(%q) = %q, want %q", input, got, want) + } + } +} + +func assertArgs(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("args mismatch:\n got: %q\n want: %q", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("args[%d] = %q, want %q\n got: %q\n want: %q", i, got[i], want[i], got, want) + } + } +} diff --git a/internal/app/usage.go b/internal/app/usage.go index 0c41212..0ea2963 100644 --- a/internal/app/usage.go +++ b/internal/app/usage.go @@ -33,6 +33,7 @@ Usage: sshx inspect -h= [options] # Run one structured host inspection sshx sql -h= --db= [options] "SQL" # Guarded SQL via remote psql/sqlite3 sshx apply -h= --path= --from= # Guarded remote file apply + sshx mcp # Serve the execution contract over stdio (MCP) SSH Options: -h, --host=HOST Remote host address (required in compatibility mode) diff --git a/internal/keyringstore/backend_e2e_test.go b/internal/keyringstore/backend_e2e_test.go new file mode 100644 index 0000000..724648e --- /dev/null +++ b/internal/keyringstore/backend_e2e_test.go @@ -0,0 +1,87 @@ +//go:build sshx_e2e + +package keyringstore + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "testing" +) + +func withKeyringFile(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "nested", "keyring.json") + t.Setenv("SSHX_E2E_KEYRING_FILE", path) + return path +} + +func TestE2EBackendRoundtrip(t *testing.T) { + path := withKeyringFile(t) + + if err := Set("svc", "acct", "value-1"); err != nil { + t.Fatalf("Set: %v", err) + } + + got, err := Get("svc", "acct") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != "value-1" { + t.Fatalf("Get returned %q, want %q", got, "value-1") + } + + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat keyring file: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("keyring file permissions = %o, want 600", perm) + } + } + + if err := Delete("svc", "acct"); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := Get("svc", "acct"); !errors.Is(err, ErrNotFound) { + t.Fatalf("Get after Delete returned %v, want ErrNotFound", err) + } +} + +func TestE2EBackendMissingEntry(t *testing.T) { + withKeyringFile(t) + + if _, err := Get("svc", "missing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("Get returned %v, want ErrNotFound", err) + } + if err := Delete("svc", "missing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("Delete returned %v, want ErrNotFound", err) + } +} + +func TestE2EBackendRequiresEnv(t *testing.T) { + t.Setenv("SSHX_E2E_KEYRING_FILE", "") + + if err := Set("svc", "acct", "v"); err == nil { + t.Fatal("Set succeeded without SSHX_E2E_KEYRING_FILE, want error") + } + if _, err := Get("svc", "acct"); err == nil { + t.Fatal("Get succeeded without SSHX_E2E_KEYRING_FILE, want error") + } +} + +func TestE2EBackendRejectsCorruptFile(t *testing.T) { + path := withKeyringFile(t) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte("not-json"), 0o600); err != nil { + t.Fatalf("write corrupt file: %v", err) + } + + if _, err := Get("svc", "acct"); err == nil { + t.Fatal("Get succeeded on corrupt keyring file, want error") + } +} diff --git a/internal/keyringstore/backend_system_test.go b/internal/keyringstore/backend_system_test.go new file mode 100644 index 0000000..f835fd6 --- /dev/null +++ b/internal/keyringstore/backend_system_test.go @@ -0,0 +1,55 @@ +//go:build !sshx_e2e + +package keyringstore + +import ( + "errors" + "testing" + + "github.com/zalando/go-keyring" +) + +// TestSystemBackendRoundtrip exercises Set/Get/Delete against the in-memory +// mock provider so the test never touches a real OS keyring. +func TestSystemBackendRoundtrip(t *testing.T) { + keyring.MockInit() + + const ( + service = "sshx-test-service" + account = "sshx-test-account" + secret = "s3cret-value" + ) + + if err := Set(service, account, secret); err != nil { + t.Fatalf("Set: %v", err) + } + + got, err := Get(service, account) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != secret { + t.Fatalf("Get returned %q, want %q", got, secret) + } + + if err := Delete(service, account); err != nil { + t.Fatalf("Delete: %v", err) + } + + if _, err := Get(service, account); !errors.Is(err, ErrNotFound) { + t.Fatalf("Get after Delete returned %v, want ErrNotFound", err) + } +} + +// TestSystemBackendMissingKey verifies the package-level ErrNotFound maps to +// the provider's not-found error for keys that were never stored. +func TestSystemBackendMissingKey(t *testing.T) { + keyring.MockInit() + + if _, err := Get("sshx-test-service", "never-stored"); !errors.Is(err, ErrNotFound) { + t.Fatalf("Get returned %v, want ErrNotFound", err) + } + if err := Delete("sshx-test-service", "never-stored"); !errors.Is(err, ErrNotFound) { + t.Fatalf("Delete returned %v, want ErrNotFound", err) + } +} diff --git a/scripts/macos-dev-keychain.sh b/scripts/macos-dev-keychain.sh new file mode 100755 index 0000000..2558826 --- /dev/null +++ b/scripts/macos-dev-keychain.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# macos-dev-keychain.sh — run the real-keyring E2E suite locally on macOS +# without Keychain authorization prompts. +# +# Mirrors the "Prepare isolated macOS keychain" step in .github/workflows/ci.yml: +# creates an ephemeral keychain, makes it the user default, sets the key +# partition list so command-line tools can read items without GUI prompts, +# runs the E2E suite with SSHX_E2E_REAL_KEYRING=1, and always restores the +# original keychain search list on exit. +# +# Usage: +# scripts/macos-dev-keychain.sh # run the full E2E suite +# scripts/macos-dev-keychain.sh -run Name # extra args are passed to go test + +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "error: this script only makes sense on macOS" >&2 + exit 1 +fi + +keychain_path="$(mktemp -d)/sshx-dev.keychain-db" +keychain_password="sshx-dev-local" + +original_default="$(security default-keychain -d user | sed 's/^[[:space:]]*"//; s/"[[:space:]]*$//')" +original_keychains=() +while IFS= read -r item; do + item="$(echo "$item" | sed 's/^[[:space:]]*"//; s/"[[:space:]]*$//')" + if [[ -n "$item" ]]; then original_keychains+=("$item"); fi +done < <(security list-keychains -d user) + +cleanup() { + security default-keychain -d user -s "$original_default" || true + if [[ ${#original_keychains[@]} -gt 0 ]]; then + security list-keychains -d user -s "${original_keychains[@]}" || true + fi + security delete-keychain "$keychain_path" 2>/dev/null || true + rm -rf "$(dirname "$keychain_path")" +} +trap cleanup EXIT + +echo "==> Creating ephemeral keychain: $keychain_path" +security create-keychain -p "$keychain_password" "$keychain_path" +security set-keychain-settings -lut 3600 "$keychain_path" +security unlock-keychain -p "$keychain_password" "$keychain_path" +security list-keychains -d user -s "$keychain_path" "${original_keychains[@]}" +security default-keychain -d user -s "$keychain_path" + +# Allow command-line tools (go test binaries, the compiled sshx binary) to +# access items in this keychain without a GUI authorization prompt. +security set-key-partition-list -S "apple-tool:,apple:,codesign:" -s -k "$keychain_password" "$keychain_path" >/dev/null + +echo "==> Running real-keyring E2E suite" +SSHX_E2E_REAL_KEYRING=1 go test -v ./tests/e2e "$@" + +echo "==> Done (original keychain will be restored)" diff --git a/tests/e2e/mcp_e2e_test.go b/tests/e2e/mcp_e2e_test.go new file mode 100644 index 0000000..d546fae --- /dev/null +++ b/tests/e2e/mcp_e2e_test.go @@ -0,0 +1,276 @@ +package e2e + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mcpClient drives the compiled sshx binary in `sshx mcp` mode over stdio +// with newline-delimited JSON-RPC, the same way a real MCP client would. +type mcpClient struct { + t *testing.T + cmd *exec.Cmd + stdin *json.Encoder + reader *bufio.Reader + nextID int +} + +func startMCPClient(t *testing.T, home string, extraEnv map[string]string) *mcpClient { + t.Helper() + if testing.Short() { + t.Skip("skipping compiled-binary E2E in short mode") + } + + cmd := exec.Command(testBinary, "mcp") // #nosec G204 -- harness-built sshx binary. + cmd.Dir = home + cmd.Env = isolatedEnvironment(home, extraEnv) + stdinPipe, err := cmd.StdinPipe() + require.NoError(t, err) + stdoutPipe, err := cmd.StdoutPipe() + require.NoError(t, err) + cmd.Stderr = os.Stderr + require.NoError(t, cmd.Start()) + + client := &mcpClient{ + t: t, + cmd: cmd, + stdin: json.NewEncoder(stdinPipe), + reader: bufio.NewReaderSize(stdoutPipe, 1<<20), + } + t.Cleanup(func() { + _ = stdinPipe.Close() //nolint:errcheck // best-effort shutdown + done := make(chan struct{}) + go func() { + _ = cmd.Wait() //nolint:errcheck // exit status is irrelevant at cleanup + close(done) + }() + select { + case <-done: + case <-time.After(10 * time.Second): + _ = cmd.Process.Kill() //nolint:errcheck // last-resort cleanup + <-done + } + }) + + client.initialize() + return client +} + +func (c *mcpClient) initialize() { + c.t.Helper() + response := c.call("initialize", map[string]any{ + "protocolVersion": "2025-06-18", + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "sshx-e2e", "version": "0"}, + }) + serverInfo, ok := response["serverInfo"].(map[string]any) + require.True(c.t, ok, "initialize response missing serverInfo: %v", response) + require.Equal(c.t, "sshx", serverInfo["name"]) + c.notify("notifications/initialized", map[string]any{}) +} + +func (c *mcpClient) notify(method string, params map[string]any) { + c.t.Helper() + require.NoError(c.t, c.stdin.Encode(map[string]any{ + "jsonrpc": "2.0", + "method": method, + "params": params, + })) +} + +// call sends one request and blocks until its matching response arrives, +// skipping any server-initiated notifications. +func (c *mcpClient) call(method string, params map[string]any) map[string]any { + c.t.Helper() + c.nextID++ + id := c.nextID + require.NoError(c.t, c.stdin.Encode(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + })) + + deadline := time.Now().Add(60 * time.Second) + for { + require.True(c.t, time.Now().Before(deadline), "timed out waiting for %s response", method) + line, err := c.reader.ReadString('\n') + require.NoError(c.t, err, "read MCP response for %s", method) + line = strings.TrimSpace(line) + if line == "" { + continue + } + var message map[string]any + require.NoError(c.t, json.Unmarshal([]byte(line), &message), "parse MCP message: %s", line) + rawID, hasID := message["id"] + if !hasID { + continue // notification + } + gotID, ok := rawID.(float64) + if !ok || int(gotID) != id { + continue + } + if errObj, isErr := message["error"]; isErr { + c.t.Fatalf("MCP %s returned protocol error: %v", method, errObj) + } + result, ok := message["result"].(map[string]any) + require.True(c.t, ok, "MCP %s result is not an object: %s", method, line) + return result + } +} + +// callTool invokes tools/call and returns isError plus the first text content. +func (c *mcpClient) callTool(name string, arguments map[string]any) (bool, string) { + c.t.Helper() + result := c.call("tools/call", map[string]any{"name": name, "arguments": arguments}) + isError, ok := result["isError"].(bool) + if !ok { + isError = false + } + content, ok := result["content"].([]any) + require.True(c.t, ok, "tools/call %s returned no content: %v", name, result) + require.NotEmpty(c.t, content) + first, ok := content[0].(map[string]any) + require.True(c.t, ok) + text, ok := first["text"].(string) + require.True(c.t, ok, "tools/call %s first content has no text: %v", name, first) + return isError, text +} + +func TestMCPServerContract(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + home := t.TempDir() + writeSettings(t, home, map[string]any{"hosts": []map[string]any{{ + "name": "mcp-target", + "host": server.host, + "port": server.port, + "user": "operator", + }}}) + + // Pre-trust the harness host key once so MCP-originated child processes + // connect with strict host-key verification and no trust relaxations. + trust := runSSHX(t, home, []string{ + "-h=mcp-target", "--no-key", "--accept-unknown-host", "probe", + }, map[string]string{"SSH_PASSWORD": operatorPassword}) + require.Equal(t, 0, trust.exitCode, "pre-trust failed: stderr=%s stdout=%s", trust.stderr, trust.stdout) + + client := startMCPClient(t, home, map[string]string{ + "SSH_PASSWORD": operatorPassword, + "SSHX_NO_AUDIT": "false", + }) + + t.Run("tools list exposes execution contract only", func(t *testing.T) { + result := client.call("tools/list", map[string]any{}) + rawTools, ok := result["tools"].([]any) + require.True(t, ok) + names := make([]string, 0, len(rawTools)) + for _, raw := range rawTools { + tool, ok := raw.(map[string]any) + require.True(t, ok) + name, ok := tool["name"].(string) + require.True(t, ok, "tool entry missing name: %v", tool) + names = append(names, name) + } + assert.ElementsMatch(t, []string{ + "sshx_run", "sshx_sql", "sshx_apply", "sshx_inspect", + "sshx_sftp", "sshx_transfer", "sshx_host_list", + }, names) + for _, name := range names { + assert.NotContains(t, name, "password", "secret management must not be exposed over MCP") + } + }) + + t.Run("host list returns versioned JSON", func(t *testing.T) { + isError, text := client.callTool("sshx_host_list", map[string]any{}) + assert.False(t, isError, "sshx_host_list failed: %s", text) + var doc struct { + SchemaVersion string `json:"schema_version"` + Count int `json:"count"` + Hosts []struct { + Name string `json:"name"` + } `json:"hosts"` + } + require.NoError(t, json.Unmarshal([]byte(text), &doc), "host list output: %s", text) + assert.Equal(t, "sshx.hosts.v1", doc.SchemaVersion) + require.Equal(t, 1, doc.Count) + assert.Equal(t, "mcp-target", doc.Hosts[0].Name) + }) + + t.Run("run dry-run previews without executing", func(t *testing.T) { + before := server.connections.Load() + isError, text := client.callTool("sshx_run", map[string]any{ + "targets": []any{"mcp-target"}, + "command": "probe", + "dry_run": true, + }) + assert.False(t, isError, "dry-run failed: %s", text) + var plan map[string]any + require.NoError(t, json.Unmarshal([]byte(text), &plan), "dry-run output: %s", text) + assert.Equal(t, "sshx.request.v1", plan["schema_version"]) + assert.Equal(t, true, plan["valid"]) + assert.Equal(t, before, server.connections.Load(), "dry-run must not connect") + }) + + t.Run("run executes and audits with mcp entry", func(t *testing.T) { + isError, text := client.callTool("sshx_run", map[string]any{ + "targets": []any{"mcp-target"}, + "command": "probe", + }) + assert.False(t, isError, "run failed: %s", text) + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(text), &result), "run output: %s", text) + assert.Equal(t, "sshx.result.v1", result["schema_version"]) + assert.Equal(t, "succeeded", result["status"]) + + auditDir := filepath.Join(home, ".sshx", "audit") + entries, err := os.ReadDir(auditDir) + require.NoError(t, err, "audit directory must exist for MCP child invocations") + var found bool + for _, entry := range entries { + data, readErr := os.ReadFile(filepath.Join(auditDir, entry.Name())) // #nosec G304 -- isolated E2E audit fixture. + require.NoError(t, readErr) + if strings.Contains(string(data), `"entry":"mcp"`) { + found = true + break + } + } + assert.True(t, found, "audit events must record entry=mcp for MCP-originated executions") + }) + + t.Run("safety gates hold over MCP", func(t *testing.T) { + isError, text := client.callTool("sshx_run", map[string]any{ + "targets": []any{"mcp-target"}, + "command": "probe", + "force": true, // force without bypass_reason must be rejected + }) + assert.True(t, isError, "force without bypass_reason must fail, got: %s", text) + assert.Contains(t, text, "bypass", "error should explain the missing bypass reason: %s", text) + }) + + t.Run("invalid tool input is rejected locally", func(t *testing.T) { + isError, text := client.callTool("sshx_run", map[string]any{ + "targets": []any{"mcp-target"}, + }) + assert.True(t, isError, "missing command and script must fail") + assert.Contains(t, text, "exactly one of command or script", "got: %s", text) + }) +} + +func TestMCPRejectsArguments(t *testing.T) { + home := t.TempDir() + result := runSSHX(t, home, []string{"mcp", "--port=1"}, nil) + require.NotEqual(t, 0, result.exitCode) + combined := result.stdout + result.stderr + require.True(t, strings.Contains(combined, "accepts no arguments"), + fmt.Sprintf("unexpected output: %s", combined)) +}