serve the MCP install instructions at /mcp
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

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.
This commit is contained in:
jx12n 2026-09-05 14:03:34 -06:00
parent b6098c28be
commit d7cd57f330
18 changed files with 475 additions and 126 deletions

View File

@ -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

View File

@ -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
<https://hush.threesix.ai/mcp>. [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

View File

@ -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

View File

@ -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"`

View File

@ -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, "<script") {
t.Fatal("the MCP page ships a <script> — it is prose, and the shell's crypto belongs to the pages that encrypt")
}
}

View File

@ -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) {

View File

@ -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 }

View File

@ -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 }

View File

@ -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-<r>'; style-src 'nonce-<r>';
@ -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

View File

@ -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

View File

@ -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
<https://hush.threesix.ai/mcp>**, 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

View File

@ -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}}
</style>
<!-- Page-specific CSS, nonced by the page that defines it. Empty for the two
product pages, which need nothing beyond the shell above. -->
{{block "styles" .}}{{end}}
</head>
<body>
<main>
@ -90,51 +95,12 @@ footer code{font:11px var(--mono);color:var(--dim)}
</div>
<footer>
Encrypted in your browser. The key travels in the link's <code>#fragment</code>,
which browsers never send to a server.
which browsers never send to a server.{{block "nav" .}}{{end}}
</footer>
</main>
<script nonce="{{.Nonce}}">
// Shared crypto. AES-256-GCM via WebCrypto; the key is generated here, never
// transmitted, and carried only in the URL fragment.
//
// b64u: base64url without padding, matching Go's base64.RawURLEncoding on the
// server so the wire format has exactly one spelling.
const b64u = {
enc(bytes) {
let s = ""; for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
},
dec(str) {
const p = str.replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(p + "=".repeat((4 - (p.length % 4)) % 4));
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
},
};
async function seal(plaintext) {
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
// 96-bit nonce is the AES-GCM standard size and is generated per secret. It
// is prepended to the ciphertext rather than sent separately so a stored blob
// is self-contained.
const nonce = crypto.getRandomValues(new Uint8Array(12));
const body = new Uint8Array(await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce }, key, new TextEncoder().encode(plaintext)));
const blob = new Uint8Array(nonce.length + body.length);
blob.set(nonce, 0); blob.set(body, nonce.length);
const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));
return { ciphertext: b64u.enc(blob), key: b64u.enc(rawKey) };
}
async function open(ciphertext, keyStr) {
const blob = b64u.dec(ciphertext);
const key = await crypto.subtle.importKey("raw", b64u.dec(keyStr), "AES-GCM", false, ["decrypt"]);
const plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: blob.slice(0, 12) }, key, blob.slice(12));
return new TextDecoder().decode(plain);
}
</script>
<!-- The pages that encrypt pull in templates/crypto.html, which defines this.
The MCP page defines it empty: it runs no script at all. -->
{{template "crypto" .}}
{{template "script" .}}
</body>
</html>{{end}}

View File

@ -20,6 +20,8 @@
</div>
{{end}}
{{define "nav"}} · <a href="/mcp">use it from an agent</a>{{end}}
{{define "script"}}
<script nonce="{{.Nonce}}">
const $ = (id) => document.getElementById(id);

View File

@ -0,0 +1,47 @@
{{define "crypto"}}<script nonce="{{.Nonce}}">
// Shared crypto. AES-256-GCM via WebCrypto; the key is generated here, never
// transmitted, and carried only in the URL fragment.
//
// It lives in its own partial rather than in base.html so that a page which
// encrypts nothing — the MCP instructions — carries none of it. That page
// satisfies the shell's {{`{{template "crypto"}}`}} call with an empty
// definition instead of parsing this file.
//
// b64u: base64url without padding, matching Go's base64.RawURLEncoding on the
// server so the wire format has exactly one spelling.
const b64u = {
enc(bytes) {
let s = ""; for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
},
dec(str) {
const p = str.replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(p + "=".repeat((4 - (p.length % 4)) % 4));
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
},
};
async function seal(plaintext) {
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
// 96-bit nonce is the AES-GCM standard size and is generated per secret. It
// is prepended to the ciphertext rather than sent separately so a stored blob
// is self-contained.
const nonce = crypto.getRandomValues(new Uint8Array(12));
const body = new Uint8Array(await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce }, key, new TextEncoder().encode(plaintext)));
const blob = new Uint8Array(nonce.length + body.length);
blob.set(nonce, 0); blob.set(body, nonce.length);
const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));
return { ciphertext: b64u.enc(blob), key: b64u.enc(rawKey) };
}
async function open(ciphertext, keyStr) {
const blob = b64u.dec(ciphertext);
const key = await crypto.subtle.importKey("raw", b64u.dec(keyStr), "AES-GCM", false, ["decrypt"]);
const plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: blob.slice(0, 12) }, key, blob.slice(12));
return new TextDecoder().decode(plain);
}
</script>{{end}}

View File

@ -0,0 +1,204 @@
{{define "styles"}}
<style nonce="{{.Nonce}}">
/* Doc-page-only CSS. It lives here rather than in base.html so the create and
reveal pages ship no bytes for prose they do not contain — and so these
selectors can name bare elements without touching those pages. */
main{
max-width:720px;
/* body centres its one item vertically; a page taller than the viewport has
to start at the top instead. */
align-self:flex-start;margin:8px 0 28px;
}
.card{padding:30px 30px 26px}
h2{
font-size:14px;font-weight:600;letter-spacing:-.01em;color:var(--fg);
margin:28px 0 10px;padding-top:22px;border-top:1px solid var(--line);
}
h3{font-size:13px;font-weight:600;margin:20px 0 7px;color:var(--fg)}
p{color:var(--dim);font-size:13.5px;margin:0 0 11px}
ul{margin:0 0 12px;padding-left:17px;color:var(--dim);font-size:13.5px}
li{margin:0 0 7px}
pre{
background:var(--inset);border:1px solid var(--line);border-radius:10px;
color:var(--fg);font:12.5px/1.65 var(--mono);padding:12px 13px;margin:0 0 12px;
overflow-x:auto;user-select:all;
}
code{font:12.5px var(--mono);color:var(--fg)}
table{width:100%;border-collapse:collapse;font-size:13px;margin:0 0 14px}
th{
color:var(--faint);font:500 11px/1.5 -apple-system,system-ui,sans-serif;
text-transform:uppercase;letter-spacing:.07em;text-align:left;
padding:0 12px 7px 0;border-bottom:1px solid var(--line);
}
td{
color:var(--dim);padding:8px 12px 8px 0;border-bottom:1px solid var(--line);
vertical-align:top;
}
td:first-child{color:var(--fg);font:12.5px var(--mono);white-space:nowrap}
.card a{color:var(--accent);text-decoration:none;border-bottom:1px solid #8ce0b840}
.card a:hover{border-bottom-color:var(--accent)}
.destructive{color:var(--warn)}
</style>
{{end}}
{{define "content"}}
<h1>hush<span>.</span> from an agent</h1>
<p class="lede">Two MCP tools. The encryption happens on your machine, so an
agent using hush gets the same guarantee a browser does.</p>
<table>
<tr><th>Tool</th><th>What it does</th></tr>
<tr><td>hush_create</td><td>Encrypts a secret locally, stores the ciphertext, returns a link that works exactly once</td></tr>
<tr><td>hush_reveal</td><td>Opens a link and destroys it</td></tr>
</table>
<p>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. <code>hush-mcp</code> is a peer of the browser — it mints the
AES-256 key, encrypts, posts only ciphertext, and assembles the
<code>#fragment</code> link itself.</p>
<h2>1. Install it</h2>
<pre>go install github.com/orchard9/hush/cmd/hush-mcp@latest</pre>
<p>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.</p>
<p>Most clients do not expand <code>~</code>, so get the absolute path once and
paste that everywhere below:</p>
<pre>echo "$(go env GOPATH)/bin/hush-mcp"</pre>
<h2>2. Register it with your client</h2>
<p>Every client here launches the same binary over stdio. The JSON shape is the
portable part; only VS Code spells the wrapper key differently.</p>
<h3>Claude Code</h3>
<pre>claude mcp add hush -e HUSH_BASE_URL=https://hush.threesix.ai \
-- /Users/you/go/bin/hush-mcp</pre>
<p>The <code>--</code> is load-bearing: everything after it is the command to
launch, so Claude Code stops reading those arguments as its own. Add
<code>-s user</code> to get the server in every project rather than this one.
<code>claude mcp list</code> then prints
<code>hush: … ✔ Connected</code>.</p>
<h3>Codex CLI</h3>
<pre>codex mcp add hush --env HUSH_BASE_URL=https://hush.threesix.ai \
-- /Users/you/go/bin/hush-mcp</pre>
<p>That writes <code>~/.codex/config.toml</code>. The same thing by hand:</p>
<pre>[mcp_servers.hush]
command = "/Users/you/go/bin/hush-mcp"
env = { "HUSH_BASE_URL" = "https://hush.threesix.ai" }</pre>
<p>Confirm with <code>codex mcp get hush</code>, or <code>/mcp</code> in a
session.</p>
<h3>Gemini CLI</h3>
<pre>gemini mcp add hush /Users/you/go/bin/hush-mcp \
-e HUSH_BASE_URL=https://hush.threesix.ai -s user</pre>
<p>Without <code>-s user</code> the entry lands in the current project's
<code>.gemini/settings.json</code> instead of
<code>~/.gemini/settings.json</code>. Confirm with <code>/mcp</code> in a
session.</p>
<h3>VS Code</h3>
<pre>code --add-mcp '{"name":"hush","type":"stdio","command":"/Users/you/go/bin/hush-mcp","env":{"HUSH_BASE_URL":"https://hush.threesix.ai"}}'</pre>
<p>By hand the file is <code>.vscode/mcp.json</code> for one workspace, or the
user-level <code>mcp.json</code> that <strong>MCP: Open User
Configuration</strong> opens — and its wrapper key is <code>servers</code>,
not <code>mcpServers</code>:</p>
<pre>{
"servers": {
"hush": {
"type": "stdio",
"command": "/Users/you/go/bin/hush-mcp",
"env": { "HUSH_BASE_URL": "https://hush.threesix.ai" }
}
}
}</pre>
<h3>Claude Desktop, Cursor, omp, anything else</h3>
<p>One block, in that client's config file:</p>
<pre>{
"mcpServers": {
"hush": {
"type": "stdio",
"command": "/Users/you/go/bin/hush-mcp",
"env": { "HUSH_BASE_URL": "https://hush.threesix.ai" }
}
}
}</pre>
<table>
<tr><th>Client</th><th>File</th></tr>
<tr><td>Claude Desktop</td><td>macOS <code>~/Library/Application Support/Claude/claude_desktop_config.json</code>, Windows <code>%APPDATA%\Claude\claude_desktop_config.json</code> — then quit the app completely and reopen it</td></tr>
<tr><td>Cursor</td><td><code>~/.cursor/mcp.json</code> for every project, <code>.cursor/mcp.json</code> for one</td></tr>
<tr><td>omp</td><td><code>~/.omp/agent/mcp.json</code></td></tr>
</table>
<p>From a clone of the repo, <code>make mcp</code> 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.</p>
<h2>3. Prove it before you trust it</h2>
<p>A misconfigured stdio server reaches you as an opaque “server disconnected”.
Run the handshake yourself instead, where the error is legible:</p>
<pre>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</pre>
<p>Two JSON lines come back: the first names the server <code>hush</code>, the
second lists <code>hush_create</code> and <code>hush_reveal</code>. That is
the same binary, launched the same way, that your client will run.</p>
<h2>4. Use it</h2>
<p>Ask in words; the agent picks the tool.</p>
<ul>
<li>“Put this in a hush link so I can send it: <em>&lt;the credential&gt;</em></li>
<li>“Open this hush link: <em>https://hush.threesix.ai/s/…#…</em></li>
</ul>
<p>Three behaviours worth knowing before an agent calls either tool:</p>
<ul>
<li><span class="destructive">hush_reveal destroys the link.</span> After it
returns, the intended recipient cannot open it. An agent that reveals a link
“just to check” has burned it.</li>
<li><strong>The link is shown once.</strong> hush cannot rebuild it, because
the key it carries was never sent to the server.</li>
<li><strong>The <code>#fragment</code> is the key.</strong> Chat clients and
mail rewriters truncate fragments, and a link without one carries no key.
The tool says exactly that, without touching the secret.</li>
</ul>
<h2>Configuration</h2>
<table>
<tr><th>Variable</th><th>Meaning</th></tr>
<tr><td>HUSH_BASE_URL</td><td>Which deployment <code>hush_create</code> posts to. Defaults to <code>https://hush.threesix.ai</code></td></tr>
<tr><td>HUSH_CREATE_TOKEN</td><td>Only for a deployment that has closed anonymous create. Unset is the normal case</td></tr>
</table>
<p><code>hush_reveal</code> ignores both and reveals against the link's own
origin. A link minted by another hush deployment would be meaningless here,
and reporting it <em>gone</em> would be a lie about a secret nobody had
touched.</p>
<h2>When a client will not connect</h2>
<ul>
<li>Use the absolute path: most clients do not expand <code>~</code>.</li>
<li>Restart the client. Claude Desktop needs a full quit, not a window close.</li>
<li>Check the wrapper key — <code>servers</code> in VS Code,
<code>mcpServers</code> everywhere else.</li>
<li>Ask the client: <code>claude mcp list</code>,
<code>codex mcp get hush</code>, or <code>/mcp</code> in a session. VS Code
logs to <strong>Output → MCP</strong>, Claude Desktop to
<code>~/Library/Logs/Claude/mcp*.log</code>.</li>
<li>A <em>gone</em> 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.</li>
</ul>
<p>Source: <a href="https://github.com/orchard9/hush">github.com/orchard9/hush</a>.</p>
{{end}}
{{define "nav"}} · <a href="/">create a secret</a>{{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}}

View File

@ -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 <meta>, 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")

View File

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

View File

@ -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'