From d7cd57f330665f3550b747608838867ca49c7be6 Mon Sep 17 00:00:00 2001 From: jx12n Date: Sat, 5 Sep 2026 14:03:34 -0600 Subject: [PATCH] serve the MCP install instructions at /mcp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Using hush from an agent needed a clone and docs/MCP.md. It now needs one command, and the instructions are served by the deployment itself. `go install github.com/orchard9/hush/cmd/hush-mcp@latest` is the whole install: cmd/hush-mcp imports only the standard library, so module graph pruning never reaches the private go-chassis dependency cmd/hushd needs. Verified against an empty module cache and the public proxy, then create -> reveal end to end against production with the resulting binary. The page carries the per-client configuration for Claude Code, Codex CLI, Gemini CLI, VS Code, Claude Desktop, Cursor and omp. Each command was run against the installed client rather than copied from documentation, which is how the differences on it are there at all: VS Code's wrapper key is `servers`, not `mcpServers`; gemini defaults to project scope, not user; Claude Code rejects `--env` immediately before the server name. The shared browser crypto moves from base.html into templates/crypto.html, which the two pages that encrypt parse and this one does not. An empty `{{define}}` cannot replace a non-empty one — text/template reads an empty body as no definition — so the shell holds the call and the partial holds the code, and the docs page ships no script at all. Three things this exposed, fixed here: - The public Ingress enumerates paths, so a handler without one 404s at the edge while working in `make dev`. The Ingress is now its own manifest: hush.yaml pins a `:bootstrap` image that does not exist, so re-applying it to publish a path would roll the workload onto an unpullable image. `make deploy-ingress` applies the route alone. - release.sh guarded HEAD against `@{upstream}`, which is the GitHub mirror here, while Kaniko clones Gitea. A commit pushed to one and not the other would have built the previous commit silently. It now fetches and compares the branch that actually gets built. - smoke.sh checks that /mcp serves the install command, so a stale rollout or an unexecutable template fails the release instead of being found later. Confirmed it fails: against production before this deploy it reported 404. --- Makefile | 12 +- README.md | 12 +- cmd/hushd/config.go | 2 +- cmd/hushd/handlers.go | 8 +- cmd/hushd/handlers_test.go | 28 +++- cmd/hushd/main.go | 5 +- deployments/k8s/hush.yaml | 48 +------ deployments/k8s/ingress.yaml | 57 ++++++++ docs/ARCHITECTURE.md | 17 ++- docs/DEPLOY.md | 9 +- docs/MCP.md | 28 ++-- internal/web/templates/base.html | 52 ++------ internal/web/templates/create.html | 2 + internal/web/templates/crypto.html | 47 +++++++ internal/web/templates/mcp.html | 204 +++++++++++++++++++++++++++++ internal/web/web.go | 38 ++++-- scripts/release.sh | 21 ++- scripts/smoke.sh | 11 ++ 18 files changed, 475 insertions(+), 126 deletions(-) create mode 100644 deployments/k8s/ingress.yaml create mode 100644 internal/web/templates/crypto.html create mode 100644 internal/web/templates/mcp.html diff --git a/Makefile b/Makefile index b0159a3..e3758b5 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := help .PHONY: help fmt vet test test-redis build run dev dev-stop smoke vendor verify ci \ - mcp mcp-install release deploy-manifests deploy-status logs alerts-check + mcp mcp-install release deploy-manifests deploy-ingress deploy-status logs alerts-check # Local development Redis. A real server, not a mock: the one-time guarantee # rests on GETDEL being atomic, and a fake cannot prove that. @@ -76,8 +76,14 @@ mcp: ## Build and install the MCP server, then register it with omp mcp-install: @./scripts/install-mcp.sh -deploy-manifests: ## Apply the k8s manifests (do this BEFORE the first push) - @KUBECONFIG=$(KUBECONFIG_FILE) kubectl apply -f deployments/k8s/hush.yaml +deploy-manifests: ## Apply every k8s manifest (do this BEFORE the first push) + @KUBECONFIG=$(KUBECONFIG_FILE) kubectl apply -f deployments/k8s/ + +# A new public route is a handler AND an Ingress path. This applies only the +# Ingress: hush.yaml pins a `:bootstrap` image that does not exist, so applying +# the whole directory to publish a path would roll the workload onto it. +deploy-ingress: ## Apply just the public route + @KUBECONFIG=$(KUBECONFIG_FILE) kubectl apply -f deployments/k8s/ingress.yaml deploy-status: ## Rollout, pods, ingress and certificate @KUBECONFIG=$(KUBECONFIG_FILE) kubectl -n $(NS) rollout status deployment/hush --timeout=90s diff --git a/README.md b/README.md index d3171ed..1a513e5 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,15 @@ public ingress** — they are reachable in-cluster only. `cmd/hush-mcp` is a stdio MCP server exposing two tools, `hush_create` and `hush_reveal`. It runs **locally** and does the encryption on your machine, so using hush from an agent preserves the same zero-knowledge property as using it -from a browser. See [docs/MCP.md](docs/MCP.md). +from a browser. + +```bash +go install github.com/orchard9/hush/cmd/hush-mcp@latest +``` + +Per-client configuration — Claude Code, Codex, Gemini, VS Code, Claude Desktop, +Cursor, omp — is served by the deployment at +. [docs/MCP.md](docs/MCP.md) covers the design. ## Limits @@ -105,7 +113,7 @@ from a browser. See [docs/MCP.md](docs/MCP.md). - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how it works and why each choice - [docs/DEPLOY.md](docs/DEPLOY.md) — pipeline, DNS, credentials, first deploy - [docs/OPERATIONS.md](docs/OPERATIONS.md) — alert runbook, log queries, failure modes -- [docs/MCP.md](docs/MCP.md) — the MCP server and how to install it +- [docs/MCP.md](docs/MCP.md) — the MCP server, its install, and why it is local ## Development diff --git a/cmd/hushd/config.go b/cmd/hushd/config.go index e3621f6..a33a197 100644 --- a/cmd/hushd/config.go +++ b/cmd/hushd/config.go @@ -34,7 +34,7 @@ type Config struct { TrustedProxyHops int // AllowOrigins is the CORS allowlist. Empty is correct for the deployed - // service: both pages are same-origin, so no cross-origin caller is + // service: every page is same-origin, so no cross-origin caller is // legitimate. AllowOrigins []string diff --git a/cmd/hushd/handlers.go b/cmd/hushd/handlers.go index 04ed1ad..792cf70 100644 --- a/cmd/hushd/handlers.go +++ b/cmd/hushd/handlers.go @@ -23,7 +23,7 @@ type Server struct { // pageData is the same for every render: the ciphertext cap, so the browser // enforces what the server enforces, and the default lifetime, so the page // states the TTL the server will apply. It carries nothing request-specific, -// which is why both pages are safely static. +// which is why every page is safely static. func (s *Server) pageData() web.Data { return web.Data{ MaxCiphertextBytes: secret.MaxCiphertextBytes, @@ -50,6 +50,12 @@ func (s *Server) handleRevealPage(c *chassis.Context) error { return s.pages.Reveal(c.Writer(), s.pageData()) } +// handleMCPPage serves GET /mcp: how to install the local MCP server and wire +// it into a client. Static prose, no storage, no script. +func (s *Server) handleMCPPage(c *chassis.Context) error { + return s.pages.MCP(c.Writer(), s.pageData()) +} + type createRequest struct { Ciphertext string `json:"ciphertext"` TTLSeconds int64 `json:"ttl_seconds"` diff --git a/cmd/hushd/handlers_test.go b/cmd/hushd/handlers_test.go index 3a4375e..750fc51 100644 --- a/cmd/hushd/handlers_test.go +++ b/cmd/hushd/handlers_test.go @@ -39,6 +39,7 @@ func testApp(t *testing.T) (http.Handler, *store.Memory) { app := chassis.New(chassis.Config{Service: "hush", Env: "dev", MaxBodyBytes: 128 * 1024}, log) app.Get("/", srv.handleCreatePage) app.Get("/s/{id}", srv.handleRevealPage) + app.Get("/mcp", srv.handleMCPPage) app.Route("/api", func(r *chassis.Router) { r.Post("/secrets", srv.handleCreate) r.Post("/secrets/{id}/reveal", srv.handleReveal) @@ -306,7 +307,7 @@ func TestPagesShipTheClientSideCrypto(t *testing.T) { func TestPagesAreNotCacheable(t *testing.T) { h, _ := testApp(t) - for _, path := range []string{"/", "/s/" + strings.Repeat("A", 43)} { + for _, path := range []string{"/", "/s/" + strings.Repeat("A", 43), "/mcp"} { r := httptest.NewRequest(http.MethodGet, path, nil) w := httptest.NewRecorder() h.ServeHTTP(w, r) @@ -328,7 +329,7 @@ func TestPagesAreNotCacheable(t *testing.T) { func TestPagesSendOneNonceCSPThatPermitsTheirOwnInlineCode(t *testing.T) { h, _ := testApp(t) - for _, path := range []string{"/", "/s/" + strings.Repeat("A", 43)} { + for _, path := range []string{"/", "/s/" + strings.Repeat("A", 43), "/mcp"} { r := httptest.NewRequest(http.MethodGet, path, nil) w := httptest.NewRecorder() h.ServeHTTP(w, r) @@ -408,3 +409,26 @@ func cspNonce(t *testing.T, path, policy string) string { } return rest[:j] } + +// The MCP page is prose: it tells a reader how to install a binary and what to +// paste into a client config. It executes the same template shell as the two +// product pages, so a broken block override renders a 500 or a half page, and +// it is the one page whose CSP has no script to permit. Both are the point: +// nothing on this page can read anything. +func TestTheMCPPageIsProseWithNoScript(t *testing.T) { + h, _ := testApp(t) + + r := httptest.NewRequest(http.MethodGet, "/mcp", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("GET /mcp = %d, want 200: %s", w.Code, w.Body.String()) + } + if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Fatalf("GET /mcp Content-Type = %q, want text/html", ct) + } + if body := w.Body.String(); strings.Contains(body, " — it is prose, and the shell's crypto belongs to the pages that encrypt") + } +} diff --git a/cmd/hushd/main.go b/cmd/hushd/main.go index e2c90d9..744e243 100644 --- a/cmd/hushd/main.go +++ b/cmd/hushd/main.go @@ -104,10 +104,11 @@ func run() error { return nil }) - // Pages: no storage access, no rate limit. A link previewer hitting either - // of these must be free and harmless. + // Pages: no storage access, no rate limit. A link previewer hitting any of + // these must be free and harmless. app.Get("/", srv.handleCreatePage) app.Get("/s/{id}", srv.handleRevealPage) + app.Get("/mcp", srv.handleMCPPage) limiter := &redisLimiter{store: rdb, cfg: cfg, metrics: metrics} app.Route("/api", func(r *chassis.Router) { diff --git a/deployments/k8s/hush.yaml b/deployments/k8s/hush.yaml index feceeca..2a78854 100644 --- a/deployments/k8s/hush.yaml +++ b/deployments/k8s/hush.yaml @@ -2,10 +2,12 @@ # the pipeline's deploy step runs `kubectl set image` and needs a Deployment to # set it on. # -# KUBECONFIG=~/.kube/orchard9-k3sf.yaml kubectl apply -f deployments/k8s/hush.yaml +# KUBECONFIG=~/.kube/orchard9-k3sf.yaml kubectl apply -f deployments/k8s/ # -# Everything hush needs is here: the credential, the workload, the Service, the -# network boundary and the public route. +# The credential, the workload, the Service and the network boundary. The public +# route is deployments/k8s/ingress.yaml, kept separate because the Deployment +# below pins a `:bootstrap` image that does not exist — re-applying this file to +# publish a new path would roll the workload onto an unpullable image. --- # The Redis credential. hush connects as its OWN Redis ACL user, scoped to # `~hush:*` with a minimal command set (+ping +set +getdel +incr +pexpire @@ -207,43 +209,3 @@ spec: kubernetes.io/metadata.name: databases ports: - { protocol: TCP, port: 6379 } ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: hush - namespace: projects - annotations: - cert-manager.io/cluster-issuer: letsencrypt-prod -spec: - tls: - - hosts: [hush.threesix.ai] - secretName: hush-tls - rules: - - host: hush.threesix.ai - http: - paths: - # The paths are enumerated deliberately, and `/` is Exact rather than - # Prefix. A Prefix `/` would route EVERYTHING, publishing /metrics, - # /healthz and /readyz to the internet. /metrics leaks how many - # secrets are created and when; the others are just noise. Enumerating - # instead means Traefik 404s them at the edge and there is no - # basic-auth middleware to maintain and get wrong. - - path: / - pathType: Exact - backend: - service: - name: hush - port: { name: http } - - path: /s/ - pathType: Prefix - backend: - service: - name: hush - port: { name: http } - - path: /api/ - pathType: Prefix - backend: - service: - name: hush - port: { name: http } diff --git a/deployments/k8s/ingress.yaml b/deployments/k8s/ingress.yaml new file mode 100644 index 0000000..1667847 --- /dev/null +++ b/deployments/k8s/ingress.yaml @@ -0,0 +1,57 @@ +# hush's public route. Split out of hush.yaml on purpose: that file pins the +# Deployment's image to a `:bootstrap` tag that does not exist, so re-applying +# it to publish a new path would roll the workload onto an unpullable image. +# Adding a route is therefore: +# +# make deploy-ingress +# +# and it touches nothing but this object. +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: hush + namespace: projects + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + tls: + - hosts: [hush.threesix.ai] + secretName: hush-tls + rules: + - host: hush.threesix.ai + http: + paths: + # The paths are enumerated deliberately, and `/` is Exact rather than + # Prefix. A Prefix `/` would route EVERYTHING, publishing /metrics, + # /healthz and /readyz to the internet. /metrics leaks how many + # secrets are created and when; the others are just noise. Enumerating + # instead means Traefik 404s them at the edge and there is no + # basic-auth middleware to maintain and get wrong. + # + # A handler without a path here is a 404 at the edge on a route that + # works in `make dev`. Adding one is two changes, not one. + - path: / + pathType: Exact + backend: + service: + name: hush + port: { name: http } + - path: /mcp + pathType: Exact + backend: + service: + name: hush + port: { name: http } + - path: /s/ + pathType: Prefix + backend: + service: + name: hush + port: { name: http } + - path: /api/ + pathType: Prefix + backend: + service: + name: hush + port: { name: http } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 33de0f2..3b78c08 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -120,6 +120,7 @@ backstop. ``` GET / create page (static HTML+JS, no storage access) GET /s/{id} reveal page (static HTML+JS, no storage access) +GET /mcp MCP install instructions (static HTML, no script) POST /api/secrets store ciphertext rate limited POST /api/secrets/{id}/reveal GETDEL, destroy, return once rate limited GET /healthz liveness — 200 while draining @@ -135,9 +136,9 @@ rate limiter and templates — not a framework. ### The pages override the chassis CSP The chassis policy is written for a JSON API: `default-src 'none'; -frame-ancestors 'none'`. The two pages are HTML with inline script and inline -style, so `internal/web.render` replaces that header with a per-response -nonce policy: +frame-ancestors 'none'`. The pages are HTML with inline style, and the two that +encrypt also carry inline script, so `internal/web.render` replaces that header +with a per-response nonce policy: ``` default-src 'none'; script-src 'nonce-'; style-src 'nonce-'; @@ -163,10 +164,12 @@ Three decisions, each with a failure it prevents: page — `TestTheRevealPageDoesNotDiscloseWhetherASecretExists` compares the page with it masked and asserts constant length. -The public Ingress routes `/` (exact), `/s/` and `/api/` only. `/metrics`, -`/healthz` and `/readyz` share the port but are unreachable from the internet; -vmagent scrapes the pod IP directly. This is why there is no metrics basic-auth -middleware to maintain. +The public Ingress routes `/` (exact), `/mcp` (exact), `/s/` and `/api/` only. +`/metrics`, `/healthz` and `/readyz` share the port but are unreachable from +the internet; vmagent scrapes the pod IP directly. This is why there is no +metrics basic-auth middleware to maintain. A new public route is therefore two +changes — the handler and an Ingress path — and forgetting the second one is a +404 at the edge on a route that works in `make dev`. ## Abuse posture diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 980a42f..60c533d 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -112,7 +112,7 @@ first. But the committed image tag cannot be `:latest`: the cluster's cannot pin a rollback. So the manifest carries `:bootstrap`, which is policy-legal and does not exist. -Apply it, then build once by hand: +Apply everything, then build once by hand: ```bash make deploy-manifests # pod sits in ImagePullBackOff — expected @@ -122,6 +122,13 @@ kubectl -n projects create job hush-build-$SHA --dry-run=client -o yaml ... # se kubectl -n projects set image deployment/hush hushd=registry.threesix.ai/hush/api:$SHA ``` +That `:bootstrap` tag is also why the public route lives in its own file, +`deployments/k8s/ingress.yaml`. A new public path — every handler needs one, or +it 404s at the edge while working fine in `make dev` — is +`make deploy-ingress`, which applies that object alone. Applying the whole +directory to publish a path would roll the workload back onto the unpullable +bootstrap image. + The build Job, which is what Woodpecker's Kaniko step does by hand: ```yaml diff --git a/docs/MCP.md b/docs/MCP.md index f5f2968..8735953 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -10,20 +10,24 @@ ## Install ```bash -make mcp +go install github.com/orchard9/hush/cmd/hush-mcp@latest ``` -That builds the binary to `~/.local/bin/hush-mcp`, proves the MCP handshake -works before wiring anything, and adds a `hush` entry to -`~/.omp/agent/mcp.json` — backing the file up first and leaving every other -server alone. Restart omp to pick it up. +That is the whole install, from anywhere, with no clone: `cmd/hush-mcp` imports +only the standard library, so the module graph never reaches the private +`go-chassis` dependency that `cmd/hushd` needs. + +From a clone, `make mcp` does the omp case end to end — it builds to +`~/.local/bin/hush-mcp`, proves the MCP handshake works before wiring anything, +then adds a `hush` entry to `~/.omp/agent/mcp.json`, backing the file up first +and leaving every other server alone. Restart omp to pick it up. ```json { "mcpServers": { "hush": { "type": "stdio", - "command": "/Users/you/.local/bin/hush-mcp", + "command": "/Users/you/go/bin/hush-mcp", "env": { "HUSH_BASE_URL": "https://hush.threesix.ai" }, "timeout": 20000 } @@ -31,8 +35,16 @@ server alone. Restart omp to pick it up. } ``` -The same file shape works for Claude Code (`~/.claude.json`), Cursor and VS -Code — the stdio transport is the portable part. +That shape is what Claude Desktop, Cursor and omp read. VS Code spells the +wrapper key `servers`, Codex uses TOML (`[mcp_servers.hush]`), and Claude Code, +Codex and Gemini each have an `mcp add` subcommand that writes it for you. The +stdio transport is the portable part. + +**The user-facing copy of all of that is served by the deployment itself at +**, rendered from +`internal/web/templates/mcp.html` and checked on every release by +`scripts/smoke.sh`. A client-specific change belongs in that template; this +file keeps what a reader of the repo needs and the page does not. ## Why it runs locally instead of being an endpoint on hushd diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html index 8c9cff6..813cfe7 100644 --- a/internal/web/templates/base.html +++ b/internal/web/templates/base.html @@ -80,8 +80,13 @@ button.ghost:hover{background:none;color:var(--fg);border-color:var(--line-lit)} .hide{display:none} footer{margin:14px 2px 0;color:var(--faint);font-size:11.5px;line-height:1.5} footer code{font:11px var(--mono);color:var(--dim)} +footer a{color:var(--dim);text-decoration:none;border-bottom:1px solid var(--line)} +footer a:hover{color:var(--fg);border-color:var(--line-lit)} @media (prefers-reduced-motion:reduce){*{transition:none!important}} + +{{block "styles" .}}{{end}}
@@ -90,51 +95,12 @@ footer code{font:11px var(--mono);color:var(--dim)}
Encrypted in your browser. The key travels in the link's #fragment, - which browsers never send to a server. + which browsers never send to a server.{{block "nav" .}}{{end}}
- + +{{template "crypto" .}} {{template "script" .}} {{end}} diff --git a/internal/web/templates/create.html b/internal/web/templates/create.html index 25cc66f..6918f18 100644 --- a/internal/web/templates/create.html +++ b/internal/web/templates/create.html @@ -20,6 +20,8 @@ {{end}} +{{define "nav"}} · use it from an agent{{end}} + {{define "script"}} {{end}} diff --git a/internal/web/templates/mcp.html b/internal/web/templates/mcp.html new file mode 100644 index 0000000..4b4c370 --- /dev/null +++ b/internal/web/templates/mcp.html @@ -0,0 +1,204 @@ +{{define "styles"}} + +{{end}} + +{{define "content"}} +

hush. from an agent

+

Two MCP tools. The encryption happens on your machine, so an + agent using hush gets the same guarantee a browser does.

+ + + + + +
ToolWhat it does
hush_createEncrypts a secret locally, stores the ciphertext, returns a link that works exactly once
hush_revealOpens a link and destroys it
+ +

It is a local binary rather than an endpoint on this server for one reason: if + the server did the encrypting, the server could read every secret an agent + created, and hush's claim would hold for browser users while quietly not + holding for you. hush-mcp is a peer of the browser — it mints the + AES-256 key, encrypts, posts only ciphertext, and assembles the + #fragment link itself.

+ +

1. Install it

+
go install github.com/orchard9/hush/cmd/hush-mcp@latest
+

Needs Go 1.26 or newer, and nothing else: the binary imports only the + standard library, so there is no dependency to resolve and no service to run.

+ +

Most clients do not expand ~, so get the absolute path once and + paste that everywhere below:

+
echo "$(go env GOPATH)/bin/hush-mcp"
+ +

2. Register it with your client

+

Every client here launches the same binary over stdio. The JSON shape is the + portable part; only VS Code spells the wrapper key differently.

+ +

Claude Code

+
claude mcp add hush -e HUSH_BASE_URL=https://hush.threesix.ai \
+  -- /Users/you/go/bin/hush-mcp
+

The -- is load-bearing: everything after it is the command to + launch, so Claude Code stops reading those arguments as its own. Add + -s user to get the server in every project rather than this one. + claude mcp list then prints + hush: … ✔ Connected.

+ +

Codex CLI

+
codex mcp add hush --env HUSH_BASE_URL=https://hush.threesix.ai \
+  -- /Users/you/go/bin/hush-mcp
+

That writes ~/.codex/config.toml. The same thing by hand:

+
[mcp_servers.hush]
+command = "/Users/you/go/bin/hush-mcp"
+env = { "HUSH_BASE_URL" = "https://hush.threesix.ai" }
+

Confirm with codex mcp get hush, or /mcp in a + session.

+ +

Gemini CLI

+
gemini mcp add hush /Users/you/go/bin/hush-mcp \
+  -e HUSH_BASE_URL=https://hush.threesix.ai -s user
+

Without -s user the entry lands in the current project's + .gemini/settings.json instead of + ~/.gemini/settings.json. Confirm with /mcp in a + session.

+ +

VS Code

+
code --add-mcp '{"name":"hush","type":"stdio","command":"/Users/you/go/bin/hush-mcp","env":{"HUSH_BASE_URL":"https://hush.threesix.ai"}}'
+

By hand the file is .vscode/mcp.json for one workspace, or the + user-level mcp.json that MCP: Open User + Configuration opens — and its wrapper key is servers, + not mcpServers:

+
{
+  "servers": {
+    "hush": {
+      "type": "stdio",
+      "command": "/Users/you/go/bin/hush-mcp",
+      "env": { "HUSH_BASE_URL": "https://hush.threesix.ai" }
+    }
+  }
+}
+ +

Claude Desktop, Cursor, omp, anything else

+

One block, in that client's config file:

+
{
+  "mcpServers": {
+    "hush": {
+      "type": "stdio",
+      "command": "/Users/you/go/bin/hush-mcp",
+      "env": { "HUSH_BASE_URL": "https://hush.threesix.ai" }
+    }
+  }
+}
+ + + + + +
ClientFile
Claude DesktopmacOS ~/Library/Application Support/Claude/claude_desktop_config.json, Windows %APPDATA%\Claude\claude_desktop_config.json — then quit the app completely and reopen it
Cursor~/.cursor/mcp.json for every project, .cursor/mcp.json for one
omp~/.omp/agent/mcp.json
+

From a clone of the repo, make mcp does the omp case for you: it + builds the binary, proves the handshake before wiring anything, then rewrites + only hush's entry — backing the file up and leaving every other server + alone.

+ +

3. Prove it before you trust it

+

A misconfigured stdio server reaches you as an opaque “server disconnected”. + Run the handshake yourself instead, where the error is legible:

+
printf '%s\n' \
+  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}}' \
+  '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
+  | /Users/you/go/bin/hush-mcp
+

Two JSON lines come back: the first names the server hush, the + second lists hush_create and hush_reveal. That is + the same binary, launched the same way, that your client will run.

+ +

4. Use it

+

Ask in words; the agent picks the tool.

+
    +
  • “Put this in a hush link so I can send it: <the credential>
  • +
  • “Open this hush link: https://hush.threesix.ai/s/…#…
  • +
+

Three behaviours worth knowing before an agent calls either tool:

+
    +
  • hush_reveal destroys the link. After it + returns, the intended recipient cannot open it. An agent that reveals a link + “just to check” has burned it.
  • +
  • The link is shown once. hush cannot rebuild it, because + the key it carries was never sent to the server.
  • +
  • The #fragment is the key. Chat clients and + mail rewriters truncate fragments, and a link without one carries no key. + The tool says exactly that, without touching the secret.
  • +
+ +

Configuration

+ + + + +
VariableMeaning
HUSH_BASE_URLWhich deployment hush_create posts to. Defaults to https://hush.threesix.ai
HUSH_CREATE_TOKENOnly for a deployment that has closed anonymous create. Unset is the normal case
+

hush_reveal ignores both and reveals against the link's own + origin. A link minted by another hush deployment would be meaningless here, + and reporting it gone would be a lie about a secret nobody had + touched.

+ +

When a client will not connect

+
    +
  • Use the absolute path: most clients do not expand ~.
  • +
  • Restart the client. Claude Desktop needs a full quit, not a window close.
  • +
  • Check the wrapper key — servers in VS Code, + mcpServers everywhere else.
  • +
  • Ask the client: claude mcp list, + codex mcp get hush, or /mcp in a session. VS Code + logs to Output → MCP, Claude Desktop to + ~/Library/Logs/Claude/mcp*.log.
  • +
  • A gone error is not a connection fault: that link was already + opened, expired, or never existed. If you did not open it, assume someone + else did and rotate the secret.
  • +
+ +

Source: github.com/orchard9/hush.

+{{end}} + +{{define "nav"}} · create a secret{{end}} + +{{/* This page runs no script. It does not parse templates/crypto.html, and it + defines the shell's two script hooks as nothing, so what is served here is + prose and only prose — no code on this page can reach a key. */}} +{{define "crypto"}}{{end}} +{{define "script"}}{{end}} diff --git a/internal/web/web.go b/internal/web/web.go index 6b94c7b..11c9875 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -1,5 +1,5 @@ -// Package web serves hush's two pages. Both are static: they read no storage, -// so a link previewer fetching either one cannot destroy a secret. +// Package web serves hush's pages. All of them are static: they read no +// storage, so a link previewer fetching any of them cannot destroy a secret. package web import ( @@ -15,11 +15,12 @@ import ( //go:embed templates/*.html var files embed.FS -// Pages renders the create and reveal pages. Templates are embedded, so the -// container carries no template directory to go missing at runtime. +// Pages renders the create, reveal and MCP pages. Templates are embedded, so +// the container carries no template directory to go missing at runtime. type Pages struct { create *template.Template reveal *template.Template + mcp *template.Template } // Data is everything a page needs. MaxCiphertextBytes is passed through so the @@ -44,16 +45,27 @@ type view struct { // New parses the embedded templates. It fails at boot rather than on first // request: a template error is a build defect and should not wait for traffic // to surface. +// +// The two pages that encrypt parse crypto.html; the MCP page does not, and +// defines the shell's "crypto" block empty instead. An empty definition cannot +// REPLACE a non-empty one — text/template treats an empty body as no +// definition — so the shell holds the call and the partial holds the code. func New() (*Pages, error) { - create, err := template.ParseFS(files, "templates/base.html", "templates/create.html") + create, err := template.ParseFS(files, + "templates/base.html", "templates/crypto.html", "templates/create.html") if err != nil { return nil, fmt.Errorf("parse create template: %w", err) } - reveal, err := template.ParseFS(files, "templates/base.html", "templates/reveal.html") + reveal, err := template.ParseFS(files, + "templates/base.html", "templates/crypto.html", "templates/reveal.html") if err != nil { return nil, fmt.Errorf("parse reveal template: %w", err) } - return &Pages{create: create, reveal: reveal}, nil + mcp, err := template.ParseFS(files, "templates/base.html", "templates/mcp.html") + if err != nil { + return nil, fmt.Errorf("parse mcp template: %w", err) + } + return &Pages{create: create, reveal: reveal, mcp: mcp}, nil } // Create writes the create page. @@ -72,6 +84,14 @@ func (p *Pages) Reveal(w http.ResponseWriter, d Data) error { return render(w, p.reveal, d) } +// MCP writes the page documenting the MCP server: how to install it, how to +// register it with a client, and what the two tools do. It is prose only — the +// template overrides the shell's script blocks to nothing, so this page ships +// no JavaScript at all. +func (p *Pages) MCP(w http.ResponseWriter, d Data) error { + return render(w, p.mcp, d) +} + // contentSecurityPolicy is the page policy, keyed to one per-response nonce. // // It is sent as a HEADER and the pages carry no CSP , which is not a @@ -97,7 +117,7 @@ func contentSecurityPolicy(nonce string) string { // The pages fetch /api/secrets and /api/secrets/{id}/reveal. Same-origin // only: there is no other host this page may ever talk to. "; connect-src 'self'" + - // No image, font, media or frame is loaded by either page, so every + // No image, font, media or frame is loaded by any page, so every // remaining fetch directive stays at default-src 'none'. "; form-action 'none'" + "; base-uri 'none'" + @@ -113,7 +133,7 @@ func render(w http.ResponseWriter, t *template.Template, d Data) error { } h := w.Header() - // no-store on both pages: a cached create page is harmless, but a cached + // no-store everywhere: a cached create page is harmless, but a cached // reveal page in a shared proxy would be a copy of a one-time URL. h.Set("Cache-Control", "no-store, max-age=0") h.Set("Content-Type", "text/html; charset=utf-8") diff --git a/scripts/release.sh b/scripts/release.sh index f5a652c..5f27048 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -17,6 +17,12 @@ set -euo pipefail export KUBECONFIG="${KUBECONFIG:-$HOME/.kube/orchard9-k3sf.yaml}" NS="${NS:-projects}" HOST="${HOST:-hush.threesix.ai}" +# The Gitea repo Kaniko clones, and the remote that points at it. Both are +# named once: the guard below has to check the ref that gets BUILT, and a +# guard that checks a different remote is worse than no guard. +GIT_CONTEXT="${GIT_CONTEXT:-git://git.threesix.ai/jordan/hush.git#refs/heads/main}" +GIT_REMOTE="${GIT_REMOTE:-origin}" +GIT_BRANCH="${GIT_BRANCH:-main}" ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" @@ -29,9 +35,16 @@ if [ -n "$(git status --porcelain)" ]; then git status --short >&2 exit 1 fi -if [ -n "$(git log --oneline @{upstream}..HEAD 2>/dev/null)" ]; then - echo "refusing: HEAD is not pushed to origin (Gitea). Kaniko clones from there." >&2 - git log --oneline '@{upstream}..HEAD' >&2 +# `@{upstream}` is NOT the right comparison: this checkout tracks a mirror, so +# HEAD can be pushed there while Gitea — the repo Kaniko clones — is behind, +# and the build would silently produce the previous commit. Compare against the +# branch that actually gets built. +git fetch --quiet "$GIT_REMOTE" "$GIT_BRANCH" +if [ "$(git rev-parse HEAD)" != "$(git rev-parse FETCH_HEAD)" ]; then + echo "refusing: HEAD is not what $GIT_REMOTE/$GIT_BRANCH points at, and Kaniko clones from there." >&2 + echo " HEAD $(git rev-parse --short=8 HEAD) $(git log -1 --format=%s HEAD)" >&2 + echo " $GIT_REMOTE/$GIT_BRANCH $(git rev-parse --short=8 FETCH_HEAD) $(git log -1 --format=%s FETCH_HEAD)" >&2 + echo "Push to $GIT_REMOTE first: git push $GIT_REMOTE $GIT_BRANCH" >&2 exit 1 fi @@ -63,7 +76,7 @@ spec: image: gcr.io/kaniko-project/executor:v1.23.2 args: # The Gitea repo is public, so the git context needs no credential. - - --context=git://git.threesix.ai/jordan/hush.git#refs/heads/main + - --context=$GIT_CONTEXT - --dockerfile=Dockerfile - --destination=$IMAGE # The internal Zot registry serves a self-signed cert. diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 0cdb0e3..712646c 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -90,5 +90,16 @@ code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$BASE/api/secrets" \ [ "$code" != "201" ] || fail "the server ACCEPTED a plaintext field — the zero-knowledge claim is broken" pass "a plaintext field is refused ($code)" +# --- the agent instructions are actually served ---------------------------- +# A release that rolls a stale image, or a template that fails to execute, +# shows up here rather than as a 404 someone finds later. The install command +# is the page's whole point, so that is what is checked. +MCP_PAGE=$(curl -sS -w '\n%{http_code}' "$BASE/mcp") +[ "$(printf '%s' "$MCP_PAGE" | tail -n1)" = "200" ] || fail "GET /mcp returned $(printf '%s' "$MCP_PAGE" | tail -n1)" +for want in 'go install github.com/orchard9/hush/cmd/hush-mcp' 'hush_create' 'hush_reveal'; do + printf '%s' "$MCP_PAGE" | grep -qF "$want" || fail "/mcp no longer contains '$want'" +done +pass "/mcp serves the MCP install instructions" + echo printf '\033[32mall smoke checks passed\033[0m\n'