From bfac1ff91b9ff526a4c164aef8714fe62f7b325e Mon Sep 17 00:00:00 2001 From: Cuihtlauac ALVARADO Date: Thu, 18 Jun 2026 18:15:05 +0200 Subject: [PATCH 1/2] docs: add 30-minute slide deck on sudo-proxy Markdown (Marp) deck covering the problem, context/constraints, how the trusted-gate role determines the architecture, and the assurance-ladder verification effort. Speaker notes inline. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/slides-sudo-proxy.md | 561 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 561 insertions(+) create mode 100644 docs/slides-sudo-proxy.md diff --git a/docs/slides-sudo-proxy.md b/docs/slides-sudo-proxy.md new file mode 100644 index 0000000..2a79beb --- /dev/null +++ b/docs/slides-sudo-proxy.md @@ -0,0 +1,561 @@ +--- +marp: true +theme: default +paginate: true +title: "sudo-proxy: a human keypress between an AI and root" +--- + + + +# sudo-proxy + +### A human keypress between an AI and `root` + +A privileged-command proxy for the age of AI agents + +Tarides · MIT · v0.12.0 + + + +--- + +## The setup: AI agents now *do things* + +- Coding assistants no longer just suggest — they **run commands** on your machine. +- Real work needs **privilege**: install a package, edit `/etc`, restart a service. +- The agent will happily try. The question is **who says yes**. + +> An AI agent needs to act as an administrator — but you cannot trust it to act +> *unsupervised*. + + + +--- + +## Why "just let it run sudo" is the wrong answer + +Two existing options, both bad: + +1. **Block all privilege.** The agent is useless for real admin work. +2. **Let it escalate freely.** One bad instruction = root on your box. + +And agents *can* be fed bad instructions — **prompt injection** is the +#1 risk for LLM applications (OWASP). A web page, a file, a git commit message +can carry hidden commands the model dutifully obeys. + + + +--- + +## The gap sudo-proxy fills + +> sudo-proxy fills the gap when a model needs to install packages, edit system +> files, manage services, or run any other command — **with the human always in +> the loop, even when the agent runs with `--dangerously-skip-permissions`.** + +The bet: keep the agent powerful, but make **one human keypress** non-negotiable +on every privileged command. + + + +--- + +## The one invariant + +Everything in the project reduces to a single sentence: + +# “Nothing privileged runs without a human deliberately approving the exact command shown.” + +Keep this sentence in mind. Every design choice and every proof exists to defend it. + + + +--- + +## Who it's for + +- An **operator** running an AI agent against **their own machine** — local, or + remote over SSH. +- Philosophy: **consent-first** — *every call is a keypress*. +- Not a fleet/RBAC governance tool for shared services (that's *policy-first*, + a different product). + +| | Bash tool | sudo-proxy | +|---|---|---| +| Privilege escalation | impossible | sudo + approval | +| Human review | none — runs immediately | **Y/N on every command** | +| Remote hosts | unsupported | SSH tunnel | +| Environment | inherits your shell | sanitized allowlist | + + + +--- + +## Context: where it lives + +sudo-proxy plugs into the **Model Context Protocol (MCP)** — the standard way +agents like Claude Code get tools. + +``` +AI model ──► sudo-proxy-mcp ──► Unix socket ──► sudo-proxy ──► TUI Y/N ──► sudo + (the shim) (the daemon) + local socket, or SSH tunnel to a remote host +``` + +The model is handed a tool called `execute`. Calling it *requests* a command. +A human pressing `y` is what makes it *happen*. + + + +--- + +## The threat model, in plain terms + +We assume **three kinds of attacker**: + +- **A1 — the agent itself**, or whoever is injecting instructions into it. + Controls the command, its arguments, its environment, the description shown. +- **A2 — another program running as you.** It can talk to the daemon's socket + directly, skipping the agent entirely. +- **A3 — a network attacker** on the path to a remote host. + +The design must hold even if the agent is **fully adversarial**. + + + +--- + +## Constraints we set ourselves + +- **Minimal dependencies.** Core is a handful of crates; signal handling and + peer checks use raw OS calls, not libraries. Smaller code = smaller attack + surface = *provable*. +- **Single static binary** you can `scp` to a host. No runtime, no installer. +- **No policy engine — on purpose.** Every call is an interactive human decision. + We *rejected* auth-caching (e.g. polkit) because silent execution defeats the + whole point. +- **Most-restrictive defaults.** Forget a field? You get the *more* secure + behavior, not less. + + + +--- + +## Requirements that follow + +From "the agent is untrusted" and "consent on every command," we *must* have: + +1. A human approval step the agent **cannot reach or fake**. +2. The approval must show the **exact command** that will run — no substitution, + no spoofing. +3. Security checks that **can't be bypassed** by going around the agent. +4. The daemon itself runs **unprivileged** — it borrows root per-command via + `sudo`, it doesn't *hold* root. + +These four requirements basically *dictate* the architecture. Let's see how. + + + +--- + +# How the *role* shapes the *architecture* + +sudo-proxy's job is to be a **trusted gate** between something untrusted and root. + +That single fact forces nearly every design decision. + + + +--- + +## Decision 1 — Split the shim from the daemon + +Two separate programs: + +- **`sudo-proxy-mcp`** (the shim) — talks to the model. **Untrusted. Holds no power.** +- **`sudo-proxy`** (the daemon) — listens on a socket, does *all* the checking, + owns the approval prompt. + +**Why:** attacker A2 can bypass the shim and hit the socket directly. So no +security check may live in the shim — it would be optional. **Every gate lives in +the daemon**, where it's unavoidable. + + + +--- + +## Decision 2 — Approve on a channel the AI can't touch + +The Y/N prompt is shown on **`/dev/tty`** — the physical terminal — **not** through +the model's connection. + +- The model can *ask*. It **cannot answer its own question**. +- The keypress comes from a human at a real keyboard, out of band. + +**Why:** requirement #1 — the approval must be unreachable and unfakeable by the +agent. A separate channel is the only way. + + + +--- + +## Decision 3 — Never build a shell command + +The command travels as a **structured list** — program, then arguments — and is +run *directly*. There is **no shell** on the privileged path. + +- A classic attack: smuggle `; rm -rf /` inside an argument. With a shell, that's + a second command. Here, it's just a literal string passed to one program. +- No prefix-matching, no wildcard expansion, **nothing to trick**. + +**Why:** requirement #2 — what's approved is *exactly* what runs. + + + +--- + +## Decision 4 — Make the prompt impossible to spoof + +The human must trust what's on screen. So before displaying anything, the daemon +**rejects** any field containing: + +- control characters, invisible/zero-width characters, +- **bidirectional-override** characters (the trick that makes text display in a + different order than it runs). + +It also shows the program's **real resolved path** (following symlinks), and caps +the displayed length so a giant argument can't push `[y/N]` off-screen. + +**Why:** "approve the exact command *shown*" is only meaningful if the screen +can't lie. + + + +--- + +## Decision 5 — Don't trust the environment either + +Environment variables can escalate privilege silently (`LD_PRELOAD`, `PATH`, …). +So the daemon uses a tiny **allowlist** (`LANG`, `TZ`, `HOME`, a few more). + +- Anything dangerous is an **explicit error** — *nothing is silently stripped*. +- Requested `SSH_AUTH_SOCK` is refused; agent-forwarding is a separate, deliberate, + unprivileged-only option. + +Plus replay protection: every request has a unique id and a timestamp, and the +daemon **rejects duplicates and stale requests** so a captured "yes" can't be +re-sent. + + + +--- + +## Decision 6 — Borrow root, don't hold it + +The daemon runs as **you**, the normal user. It escalates **per command** via +`sudo`, then drops back. + +- It doesn't sit there owning root waiting to be abused. +- No policy or request field can *ever* pre-grant the privileged path — even an + internal "always approve" signal is **treated as a denial** for privileged + commands. + +**Why:** least privilege. The blast radius of the daemon itself is bounded. + + + +--- + +## The piece that ties it together: a "validated" type + +A command can only reach execution if it has passed validation — and we make that +a **compile-time guarantee**, not a hopeful runtime check. + +- There's a special wrapper type. The *only* way to create it is to run the + validator. +- The execution and prompt functions accept **only** that wrapper. + +> "Reaching execution with an unvalidated request is a **compile error**, not a +> runtime gap." + + + +--- + +# Proving it actually holds + +A security gate that's *probably* correct isn't a security gate. + +So: a **ladder of assurance** — each rung re-proves the *same* invariant with +*stronger evidence*. + + + +--- + +## The ladder — borrow the rigor, not the bureaucracy + +We borrow the **Common Criteria** assurance ladder (the EAL 1→7 scale) — *without +pursuing certification* — and hold it together with a written **assurance case** +(one goal, decomposed, each leaf citing its evidence). + +| Rung | What | Status | +|---|---|---| +| 0 | Threat model (STRIDE + attack trees) | ✅ | +| 1 | Code review + static analysis, in CI | ✅ | +| 2 | Property tests as written specifications | ✅ | +| 3 | Automated proofs (Kani) + the validated-type | ✅ | +| 4 | Protocol proofs (TLA+ / ProVerif) | ✅ | +| 5 | Deductive proofs of the core | planned | +| 6 | Full machine-checked refinement | aspirational | + + + +--- + +## Rungs 0–2: model the threat, gate the build, write specs + +- **Rung 0 — Threat model.** Walk every trust boundary (STRIDE) and build an + *attack tree* whose root is "a root command runs that the human didn't approve." + This tells us **where the higher rungs need to be load-bearing**. +- **Rung 1 — Static analysis in CI.** Lint, dependency-audit, license/ban checks + run on every push. A regression that drops a safety check now **fails the build** + instead of waiting for the next manual audit. +- **Rung 2 — Property tests as specs.** Five named properties read like spec + clauses, e.g. *"an approved request has no dangerous character in any displayed + field"* and *"privileged ⇒ a keypress happened."* + + + +--- + +## Rung 3: from *sampling* to *proof* + +**Kani** is a bounded model checker — where a test *samples* inputs, Kani *proves* +a property for **every** input in range. + +Three proofs, aimed where attacker-controlled parsing bit us before: + +- The freshness-timestamp math is **total and panic-free** over its entire input + space — closing a real past integer-underflow bug, for *all* inputs, not samples. +- That math is **monotone** — an older timestamp can never be judged "fresher." +- The dangerous-character scanner **matches its spec, character by character.** + + + +--- + +## Rung 4: prove the *protocol*, not just the code + +Two formal tools, two questions: + +- **TLA+** models the approval state machine and checks, across *every* interleaving + of attacker forgeries and human keypresses: + - **no execution without a `y`**, **no replay runs twice**, + - the "remember" flag **flips only on a real keypress**, and + - **the privileged gate never even reads that flag.** + - It also proved our retention window is *more* conservative than needed — and + surfaced a **new** finding (uncapped far-future timestamps), now on the backlog. +- **ProVerif** models the SSH path against a network attacker. + + + +--- + +## Rung 4: what ProVerif tells us about SSH + +It models real SSH structure (host key + client key) against a network attacker, +and **derives** — not assumes — the answers: + +- ✅ **Command authenticity holds even on first contact** — an eavesdropper still + can't forge or alter a command (it rides on client authentication). +- ✅ **Separation theorem:** breaking the SSH channel **cannot fabricate an + approval** — the local human gate is out of a network attacker's reach. +- ⚠️ **First-contact secrecy fails *if* the host isn't pinned** — so "pin the host + key first" becomes an **explicit, load-bearing assumption**, not a hidden one. + + + +--- + +## Why bother with all this? + +Because the **assurance is matched to the criticality** — we climb the ladder +*exactly where the threat model says risk concentrates:* + +- Attacker-controlled **arithmetic** → strongest automatic proof (Kani). +- The **policy-flip** risk → model-checked. +- The **SSH gap** → derived and bounded, with its assumption made explicit. +- Routine hygiene stays at Rung 1. + +And every artifact ships a **scope statement** and **negative controls** — +honest about what's *proven* vs. *tested*, with documented mutations that must +break each proof. + + + +--- + +## Takeaways + +1. **The role makes the design.** "Be a trusted gate to root" forces the + shim/daemon split, the out-of-band keypress, the no-shell exec, the spoof-proof + prompt. +2. **One invariant, defended everywhere:** *nothing privileged runs without a human + approving the exact command shown.* +3. **Assurance is a ladder, not a checkbox** — same claim, stronger evidence each + rung, rigor aimed where the threat is. +4. **Honesty is a feature:** documented residuals and proofs with teeth beat + silent confidence. + +### Thank you — questions? + + From 8dec269a6ce2faff139a40eaf090fb7fbab91735 Mon Sep 17 00:00:00 2001 From: Cuihtlauac ALVARADO Date: Fri, 19 Jun 2026 19:36:27 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat:=201.0.0=20=E2=80=94=20default=20mcp?= =?UTF-8?q?=20feature,=20MCP=20Registry=20publishing,=20private=20lib?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepare sudo-proxy for the official MCP Registry and a 1.0 release. - Make `mcp` a default Cargo feature so `cargo install sudo-proxy` builds the `sudo-proxy-mcp` binary (the registry's cargo install path uses no extra flags). Build core-only binaries with `--no-default-features`. - Mark the Rust library `#![doc(hidden)]` with a disclaimer: it is an internal implementation detail, not a stable public API. The versioned contract is the MCP tools, the JSON-line wire protocol, and the CLI — which lets 1.0.0 promise stability of that surface without freezing the Rust types. - Add server.json (name io.github.tarides/sudo-proxy, cargo package) and a visible `mcp-name:` ownership marker in the README so the registry can verify crate ownership. - Add publish.yml: on a `v*` tag, publish to crates.io via Trusted Publishing (OIDC, no stored token), wait for the crate to be served, then register the server in the MCP Registry via GitHub Actions OIDC. - Update ci.yml/release.yml core-only guards to `--no-default-features`; drop now-redundant `--features mcp` from build/test/install commands in docs. - Bump version to 1.0.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 9 +++--- .github/workflows/publish.yml | 61 +++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 4 +-- CLAUDE.md | 4 +-- Cargo.lock | 2 +- Cargo.toml | 6 +++- README.md | 7 +++- docs/install.md | 21 ++++++------ docs/slides-sudo-proxy.md | 2 +- server.json | 22 +++++++++++++ src/lib.rs | 10 ++++++ 11 files changed, 126 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 server.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aabae05..bbf09ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,14 +32,15 @@ jobs: steps: - uses: actions/checkout@v6 - uses: Swatinem/rust-cache@v2 - # Core build (no MCP) guards the no-feature path used by release.yml. + # Core build (no MCP) guards the --no-default-features path used by release.yml. + - run: cargo build --release --no-default-features + # Default build now includes `mcp`, so this builds all binaries. - run: cargo build --release - - run: cargo build --release --features mcp - # --features mcp is required for tests/hosts.rs. + # `mcp` is a default feature, so tests/hosts.rs runs without an extra flag. # TODO: drop the --skip once "Stabilize the flaky S2 resource-cap test" # (backlog.md) lands — burst_connections_above_cap_get_busy_response is # timing-flaky in its setup assertion, not its security assertion. - - run: cargo test --features mcp -- --skip burst_connections_above_cap_get_busy_response + - run: cargo test -- --skip burst_connections_above_cap_get_busy_response audit: name: cargo audit diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..193c956 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,61 @@ +name: Publish + +# Cut a release by pushing a `v*` tag (see CLAUDE.md). This publishes the crate +# to crates.io via Trusted Publishing (OIDC, no stored token) and then registers +# the server in the official MCP Registry via GitHub Actions OIDC, which grants +# the `io.github.tarides` namespace from the repo owner. release.yml builds the +# static tarball + GitHub release on the same tag, in parallel. + +on: + push: + tags: + - "v*" + +permissions: + id-token: write # OIDC for both crates.io Trusted Publishing and mcp-publisher + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: Swatinem/rust-cache@v2 + + # crates.io Trusted Publishing: exchange the OIDC token for a short-lived + # publish token (configured at crates.io -> sudo-proxy -> Trusted Publishing). + - name: Authenticate to crates.io + uses: rust-lang/crates-io-auth-action@v1 + id: auth + + - name: Publish crate to crates.io + run: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + + # The MCP Registry validates ownership by reading the `mcp-name:` marker + # and the version from the crate on crates.io, so the crate must be served + # before we publish the server. Poll until the index catches up. + - name: Wait for crate to be live on crates.io + run: | + VERSION="${GITHUB_REF_NAME#v}" + for i in $(seq 1 30); do + if curl -fsS "https://crates.io/api/v1/crates/sudo-proxy/${VERSION}" >/dev/null; then + echo "sudo-proxy ${VERSION} is live" + exit 0 + fi + echo "waiting for sudo-proxy ${VERSION} (attempt ${i})..." + sleep 10 + done + echo "crate ${VERSION} did not appear on crates.io in time" >&2 + exit 1 + + - 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 + + - name: Authenticate to MCP Registry + run: ./mcp-publisher login github-oidc + + - name: Publish server to MCP Registry + run: ./mcp-publisher publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dee25b3..847976e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,10 +23,10 @@ jobs: run: sudo apt-get update && sudo apt-get install -y musl-tools - name: Build core binaries (no MCP) - run: cargo build --release --target x86_64-unknown-linux-musl + run: cargo build --release --target x86_64-unknown-linux-musl --no-default-features - name: Build all binaries (with MCP) - run: cargo build --release --target x86_64-unknown-linux-musl --features mcp + run: cargo build --release --target x86_64-unknown-linux-musl - name: Package tarball run: | diff --git a/CLAUDE.md b/CLAUDE.md index 659bf74..607e2e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,8 +3,8 @@ ## Build ```bash -cargo build --release --features mcp # all binaries -cargo build --release # core only (no MCP server) +cargo build --release # all binaries (mcp is a default feature) +cargo build --release --no-default-features # core only (no MCP server) ``` ## Version bumps diff --git a/Cargo.lock b/Cargo.lock index eeaa9d1..b4cca7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -573,7 +573,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "sudo-proxy" -version = "0.12.0" +version = "1.0.0" dependencies = [ "base64", "libc", diff --git a/Cargo.toml b/Cargo.toml index b5c6014..c0be131 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sudo-proxy" -version = "0.12.0" +version = "1.0.0" edition = "2021" license = "MIT" description = "Privileged command execution proxy with human approval via pkexec or sudo" @@ -31,7 +31,11 @@ required-features = ["mcp"] name = "sudo_proxy" path = "src/lib.rs" +# `mcp` is on by default so `cargo install sudo-proxy` builds the +# `sudo-proxy-mcp` binary (the MCP Registry's cargo install path uses no extra +# flags). Build the core binaries alone with `--no-default-features`. [features] +default = ["mcp"] mcp = ["dep:rmcp", "dep:tokio", "dep:schemars"] [lints.rust] diff --git a/README.md b/README.md index 229ef7c..197af9f 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Install the binaries (needs a Rust toolchain; prebuilt static binaries are on [Releases](https://github.com/tarides/sudo-proxy/releases)): ```bash -cargo install sudo-proxy --features mcp +cargo install sudo-proxy ``` Point your MCP client at the server — add to the project's `.mcp.json` or @@ -77,6 +77,11 @@ Each `execute` shows the exact command in the TUI; press `y` to approve, `N` (default) to deny. For remote hosts, pass `host` to `start_server` and `execute`. +## MCP Registry + +Listed in the official [MCP Registry](https://registry.modelcontextprotocol.io) +as `mcp-name: io.github.tarides/sudo-proxy`. + ## Documentation - [docs/install.md](docs/install.md) — install variants, remote deploy, building from source diff --git a/docs/install.md b/docs/install.md index 74a254b..3cd6074 100644 --- a/docs/install.md +++ b/docs/install.md @@ -5,22 +5,23 @@ ## From crates.io (with Rust toolchain) ```bash -# Core binaries only (sudo-proxy, sudo-request, pkexec-cache) +# Everything including the MCP server (mcp is a default feature) cargo install sudo-proxy -# Everything including the MCP server -cargo install sudo-proxy --features mcp +# Core binaries only (sudo-proxy, sudo-request, pkexec-cache) +cargo install sudo-proxy --no-default-features ``` ## From git (development version) ```bash -cargo install --git ssh://git@github.com/tarides/sudo-proxy.git --features mcp +cargo install --git ssh://git@github.com/tarides/sudo-proxy.git ``` -The MCP server depends on `rmcp`, `tokio`, and `schemars`. These are -behind the `mcp` Cargo feature so the core binaries stay lean and compile -fast. +The MCP server depends on `rmcp`, `tokio`, and `schemars`. These are behind +the `mcp` Cargo feature, which is **on by default**. Pass +`--no-default-features` to build only the core binaries (no `rmcp`/`tokio`/ +`schemars`), e.g. on a remote host that just needs the `sudo-proxy` server. | Binary | Feature | Purpose | |---|---|---| @@ -54,7 +55,7 @@ remote side are SSH access and the `sudo-proxy` binary in `$PATH`. Install all binaries and optionally set up polkit auth caching: ```bash -cargo install sudo-proxy --features mcp +cargo install sudo-proxy # Optional: cache pkexec auth for ~5 minutes (like sudo) sudo pkexec-cache --create @@ -65,8 +66,8 @@ Then configure your MCP client — see [MCP server](mcp.md). ## Building locally ```bash -cargo build --release # core only -cargo build --release --features mcp # all +cargo build --release # all binaries (mcp is default) +cargo build --release --no-default-features # core only ``` ## Cargo dependencies diff --git a/docs/slides-sudo-proxy.md b/docs/slides-sudo-proxy.md index 2a79beb..8b1c642 100644 --- a/docs/slides-sudo-proxy.md +++ b/docs/slides-sudo-proxy.md @@ -21,7 +21,7 @@ Speaker notes are in HTML comments under each slide. A privileged-command proxy for the age of AI agents -Tarides · MIT · v0.12.0 +Tarides · MIT · v1.0.0