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'