Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,32 @@ jobs:
echo "crate ${VERSION} did not appear on crates.io in time" >&2
exit 1

# The MCPB package entry in server.json points at a GitHub release asset
# (built + uploaded by release.yml on the same tag) and must carry that
# file's sha256. release.yml runs in parallel, so wait for the asset, then
# compute and inject the hash. The registry validates the URL with a HEAD
# request at publish time, so the asset must be live before we publish.
- name: Wait for MCPB asset and inject sha256 into server.json
run: |
URL=$(jq -r '.packages[] | select(.registryType=="mcpb") | .identifier' server.json)
echo "MCPB asset: $URL"
TMP=$(mktemp)
for i in $(seq 1 60); do
if curl -fsSL -A "$CRATES_UA" "$URL" -o "$TMP"; then
echo "MCPB asset is live"
break
fi
echo "waiting for MCPB asset (attempt ${i})..."
sleep 10
[ "$i" -eq 60 ] && { echo "MCPB asset did not appear in time" >&2; exit 1; }
done
SHA=$(sha256sum "$TMP" | awk '{print $1}')
echo "sha256=$SHA"
jq --arg h "$SHA" \
'(.packages[] | select(.registryType=="mcpb") | .fileSha256) = $h' \
server.json > server.json.tmp
mv server.json.tmp server.json

- name: Install mcp-publisher
run: |
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
Expand Down
27 changes: 20 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,27 +41,40 @@ jobs:
tar czf "${DIR}.tar.gz" "$DIR"
echo "TARBALL=${DIR}.tar.gz" >> "$GITHUB_ENV"

- name: Build MCPB bundle
run: |
VERSION="${GITHUB_REF#refs/tags/v}"
MCPB="sudo-proxy-mcp-v${VERSION}-x86_64-linux.mcpb"
packaging/mcpb/build-mcpb.sh \
"$VERSION" \
target/x86_64-unknown-linux-musl/release \
.
echo "MCPB=${MCPB}" >> "$GITHUB_ENV"

- name: Install rsign2 (minisign signer)
run: cargo install rsign2 --locked

- name: Sign tarball (minisign)
- name: Sign release assets (minisign)
run: |
umask 077
printf '%s\n' "$MINISIGN_SECRET_KEY" > minisign.key
rsign sign -W -s minisign.key -x "${TARBALL}.sig" \
-t "sudo-proxy ${GITHUB_REF#refs/tags/} release" \
"$TARBALL"
for f in "$TARBALL" "$MCPB"; do
rsign sign -W -s minisign.key -x "${f}.sig" \
-t "sudo-proxy ${GITHUB_REF#refs/tags/} release" \
"$f"
rsign verify -P "$MINISIGN_PUBKEY" -x "${f}.sig" "$f"
done
rm -f minisign.key
rsign verify -P "$MINISIGN_PUBKEY" -x "${TARBALL}.sig" "$TARBALL"
echo "SIGNATURE=${TARBALL}.sig" >> "$GITHUB_ENV"
env:
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
MINISIGN_PUBKEY: RWT7gwtBU0v4puI76u0oYwMAT9nmYwGimSOnqJJ+kHExsjTDQj1eZkMW

- name: Create GitHub Release
run: |
TAG="${GITHUB_REF#refs/tags/}"
gh release create "$TAG" "$TARBALL" "$SIGNATURE" \
gh release create "$TAG" \
"$TARBALL" "${TARBALL}.sig" \
"$MCPB" "${MCPB}.sig" \
--title "$TAG" \
--generate-notes
env:
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "sudo-proxy"
version = "1.0.0"
version = "1.1.0"
edition = "2021"
license = "MIT"
description = "Privileged command execution proxy with human approval via pkexec or sudo"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ as `mcp-name: io.github.tarides/sudo-proxy`.
## Documentation

- [docs/install.md](docs/install.md) — install variants, remote deploy, building from source
- [docs/mcp.md](docs/mcp.md) — MCP tools (`start_server`, `execute`, `update_host`), config, known hosts
- [docs/mcp.md](docs/mcp.md) — MCP tools (`start_server`, `execute`, `status`, `stop_server`, `update_host`), config, known hosts
- [docs/usage.md](docs/usage.md) — CLI flags, non-privileged mode, SSH tunnels, agent forwarding
- [docs/protocol.md](docs/protocol.md) — JSON-line wire protocol over the Unix socket
- [docs/security.md](docs/security.md) — security model; [docs/security-audit.md](docs/security-audit.md) — point-in-time audit; [docs/threat-model.md](docs/threat-model.md) — STRIDE + attack tree; [docs/formalisation-roadmap.md](docs/formalisation-roadmap.md) — graduated-assurance plan; [docs/assurance-case.md](docs/assurance-case.md) — GSN argument
Expand Down
25 changes: 22 additions & 3 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ sudo-proxy as tools over stdio JSON-RPC. Any MCP-capable AI client
- `host` (required): hostname to update.
- `description`: human-readable description (e.g. "CI server").
- `os`: operating system info (e.g. "Ubuntu 24.04").
- Partial update: only the fields provided are changed; a host not yet in the
registry is added automatically.

**`stop_server`** — stop a running sudo-proxy daemon.
- `host`: which daemon to stop (omit for the local one).
- Sends a `stop` control request over the socket. The daemon prints a
shutdown notice on its terminal (no approval prompt), exits, and its
terminal window — and SSH tunnel, for remote hosts — closes.
- "Not running" is reported as a normal (non-error) result.
- Daemons older than 1.1 don't understand `stop`; the tool reports their
version and asks for a manual `q`/Ctrl+C in the daemon's terminal.

**`status`** — report daemon status without executing anything.
- `host`: check one daemon; omit to check the local daemon plus every host in
the registry.
- Per host: socket presence, readiness, live daemon version (via a `ping`
control request that needs no human approval), and registry metadata.
- Read-only except for refreshing the registry's last-connected/version cache.

## Claude Code configuration

Expand Down Expand Up @@ -106,7 +124,8 @@ correctly (no Docker needed):
cargo test --test mcp_introspection
```

It should enumerate `execute`, `start_server`, and `update_host`.
It should enumerate `execute`, `start_server`, `status`, `stop_server`, and
`update_host`.

## Glama terminology

Expand All @@ -116,7 +135,7 @@ sudo-proxy:
| Glama term | sudo-proxy |
| ------------- | ---------- |
| **Server** | the `sudo-proxy-mcp` binary — the stdio MCP server, listed as `tarides/sudo-proxy`. |
| **Tools** | `execute`, `start_server`, `update_host`. |
| **Tools** | `execute`, `start_server`, `status`, `stop_server`, `update_host`. |
| **Connector** | *none* — a connector is a **remote/hosted** MCP server (a managed HTTP endpoint). sudo-proxy is local-only, so it is a server but never a connector. |

Two caveats:
Expand All @@ -130,5 +149,5 @@ Two caveats:
process. sudo-proxy's own *server* — what the `start_server` tool spawns — is
the `sudo-proxy` host daemon (Unix socket + TUI) that the MCP server proxies
to. That daemon, `sudo-request`, `pkexec-cache`, and target *hosts* all sit
below Glama's vocabulary; in MCP terms sudo-proxy is one server exposing three
below Glama's vocabulary; in MCP terms sudo-proxy is one server exposing five
tools.
18 changes: 17 additions & 1 deletion docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ The wire shape is always a list of stages: a single command is
to `a | b | c`). The MCP `execute` tool accepts a convenience `argv`
field and wraps it for you.

Field defaults (every field except `pipeline` is optional on the wire):
Field defaults (every field is optional on the wire, but an `exec`
request with an empty or missing `pipeline` is rejected at validation):

- `id` — defaults to a fresh UUIDv4.
- `host`, `session`, `time`, `reason`, `env` — empty if omitted, but
Expand All @@ -37,6 +38,21 @@ Field defaults (every field except `pipeline` is optional on the wire):
client that forgets the field still goes through approval + sudo).
- `forward_agent` — defaults to `false`. Setting `true` is only valid
when `privileged: false`.
- `action` — defaults to `"exec"` (and is omitted from the wire for exec
requests, keeping them byte-identical to the pre-1.1 format). See
*Control actions* below.

**Control actions** (since 1.1): `"action": "stop"` asks the daemon to
shut down cleanly — it prints a notice on its TTY (no approval prompt),
replies `ok`, and exits; `"action": "ping"` replies `ok` with
`"message": "pong"` without prompting. Both are sent with
`"pipeline": []` and pass every gate an exec request does (same-UID
check, field sanitization, freshness, replay protection), so a captured
stop request cannot be replayed later. Compatibility: a pre-1.1 daemon
ignores the unknown `action` key and rejects the empty pipeline at
validation — before any prompt — with `"pipeline must not be empty"`;
since every response carries the daemon's `version`, new clients use
that reply to detect peers that predate control actions.

**Response:**
```jsonc
Expand Down
46 changes: 46 additions & 0 deletions packaging/mcpb/build-mcpb.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env sh
# Build an MCPB bundle (.mcpb) for the sudo-proxy-mcp MCP server.
#
# An .mcpb file is a zip of a manifest.json (which pins `sudo-proxy-mcp` as the
# entry point) plus the binary itself. Unlike the crates.io package, this lets
# MCP clients and directory probes launch the *MCP server* binary directly —
# `cargo install sudo-proxy` installs four binaries and the registry has no way
# to say "run sudo-proxy-mcp, not sudo-proxy". See docs/mcp.md.
#
# Usage: build-mcpb.sh <version> <bin_dir> <out_dir>
# version release version, e.g. 1.1.0 (injected into the manifest)
# bin_dir directory containing the built `sudo-proxy-mcp` binary
# out_dir where to write the .mcpb (and its .sha256)
#
# Prints the sha256 to stdout and writes <out_dir>/<name>.mcpb.sha256.
set -eu

VERSION="${1:?version required}"
BIN_DIR="${2:?bin_dir required}"
OUT_DIR="${3:?out_dir required}"

HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
MANIFEST_SRC="$HERE/manifest.json"
BIN="$BIN_DIR/sudo-proxy-mcp"
NAME="sudo-proxy-mcp-v${VERSION}-x86_64-linux.mcpb"
OUT="$OUT_DIR/$NAME"

[ -f "$BIN" ] || { echo "error: $BIN not found" >&2; exit 1; }

mkdir -p "$OUT_DIR"
STAGE=$(mktemp -d)
trap 'rm -rf "$STAGE"' EXIT

# Canonical manifest content lives in manifest.json; only the version is injected
# so it always tracks the release tag (single source of truth = Cargo.toml/tag).
jq --arg v "$VERSION" '.version = $v' "$MANIFEST_SRC" > "$STAGE/manifest.json"
cp "$BIN" "$STAGE/sudo-proxy-mcp"
chmod +x "$STAGE/sudo-proxy-mcp"

rm -f "$OUT"
# -X: no extra file attributes/timestamps -> reproducible-ish archive.
( cd "$STAGE" && zip -qX "$OUT" manifest.json sudo-proxy-mcp )

SHA=$(sha256sum "$OUT" | awk '{print $1}')
printf '%s' "$SHA" > "$OUT.sha256"
echo "$SHA"
22 changes: 22 additions & 0 deletions packaging/mcpb/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"manifest_version": "0.3",
"name": "sudo-proxy",
"version": "1.1.0",
"description": "Privileged command execution proxy with human approval via pkexec or sudo",
"author": {
"name": "Tarides"
},
"homepage": "https://github.com/tarides/sudo-proxy",
"documentation": "https://github.com/tarides/sudo-proxy/blob/main/docs/mcp.md",
"server": {
"type": "binary",
"entry_point": "sudo-proxy-mcp",
"mcp_config": {
"command": "${__dirname}/sudo-proxy-mcp",
"args": []
}
},
"compatibility": {
"platforms": ["linux"]
}
}
13 changes: 11 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "io.github.tarides/sudo-proxy",
"title": "sudo-proxy",
"description": "Privileged command execution proxy with human approval via pkexec or sudo",
"version": "1.0.0",
"version": "1.1.0",
"repository": {
"url": "https://github.com/tarides/sudo-proxy",
"source": "github"
Expand All @@ -13,7 +13,16 @@
"registryType": "cargo",
"registryBaseUrl": "https://crates.io",
"identifier": "sudo-proxy",
"version": "1.0.0",
"version": "1.1.0",
"transport": {
"type": "stdio"
}
},
{
"registryType": "mcpb",
"identifier": "https://github.com/tarides/sudo-proxy/releases/download/v1.1.0/sudo-proxy-mcp-v1.1.0-x86_64-linux.mcpb",
"version": "1.1.0",
"fileSha256": "SHA256-INJECTED-BY-PUBLISH-WORKFLOW",
"transport": {
"type": "stdio"
}
Expand Down
31 changes: 20 additions & 11 deletions src/bin/sudo-proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ fn main() {

let prompter: Arc<dyn Prompter> = Arc::new(TtyPrompter);
let sink: Arc<dyn ResultSink> = Arc::new(TtyResultSink);
let shutdown = AtomicBool::new(false);
let shutdown = Arc::new(AtomicBool::new(false));
let in_flight = Arc::new(AtomicUsize::new(0));
let tty_lock = Arc::new(Mutex::new(()));

Expand All @@ -94,25 +94,34 @@ fn main() {
..Default::default()
};

if let Err(e) = server::run(
match server::run(
&socket_path,
config,
prompter,
sink,
&shutdown,
Arc::clone(&shutdown),
in_flight,
tty_lock,
) {
eprintln!("error: {e}");
// Only remove the socket file if it is ours. AddrInUse means
// another sudo-proxy is already bound there — deleting that
// file would silently break the live daemon's reachability for
// every subsequent client without taking it down, leaving a
// running-but-unreachable process behind.
if e.kind() != std::io::ErrorKind::AddrInUse {
// Clean return: only reachable via a stop request. Remove our
// socket so the next start doesn't find a stale file, then exit 0
// — the terminal window closes with us, and for a remote daemon
// the command-mode SSH session (and its tunnel) ends too.
Ok(()) => {
let _ = std::fs::remove_file(&socket_path);
}
process::exit(1);
Err(e) => {
eprintln!("error: {e}");
// Only remove the socket file if it is ours. AddrInUse means
// another sudo-proxy is already bound there — deleting that
// file would silently break the live daemon's reachability for
// every subsequent client without taking it down, leaving a
// running-but-unreachable process behind.
if e.kind() != std::io::ErrorKind::AddrInUse {
let _ = std::fs::remove_file(&socket_path);
}
process::exit(1);
}
}
}

Expand Down
Loading
Loading