serve the MCP install instructions at /mcp
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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:
parent
b6098c28be
commit
d7cd57f330
12
Makefile
12
Makefile
@ -1,6 +1,6 @@
|
|||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
.PHONY: help fmt vet test test-redis build run dev dev-stop smoke vendor verify ci \
|
.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
|
# Local development Redis. A real server, not a mock: the one-time guarantee
|
||||||
# rests on GETDEL being atomic, and a fake cannot prove that.
|
# 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:
|
mcp-install:
|
||||||
@./scripts/install-mcp.sh
|
@./scripts/install-mcp.sh
|
||||||
|
|
||||||
deploy-manifests: ## Apply the k8s manifests (do this BEFORE the first push)
|
deploy-manifests: ## Apply every k8s manifest (do this BEFORE the first push)
|
||||||
@KUBECONFIG=$(KUBECONFIG_FILE) kubectl apply -f deployments/k8s/hush.yaml
|
@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
|
deploy-status: ## Rollout, pods, ingress and certificate
|
||||||
@KUBECONFIG=$(KUBECONFIG_FILE) kubectl -n $(NS) rollout status deployment/hush --timeout=90s
|
@KUBECONFIG=$(KUBECONFIG_FILE) kubectl -n $(NS) rollout status deployment/hush --timeout=90s
|
||||||
|
|||||||
12
README.md
12
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
|
`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
|
`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
|
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
|
## 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/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how it works and why each choice
|
||||||
- [docs/DEPLOY.md](docs/DEPLOY.md) — pipeline, DNS, credentials, first deploy
|
- [docs/DEPLOY.md](docs/DEPLOY.md) — pipeline, DNS, credentials, first deploy
|
||||||
- [docs/OPERATIONS.md](docs/OPERATIONS.md) — alert runbook, log queries, failure modes
|
- [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
|
## Development
|
||||||
|
|
||||||
|
|||||||
@ -34,7 +34,7 @@ type Config struct {
|
|||||||
TrustedProxyHops int
|
TrustedProxyHops int
|
||||||
|
|
||||||
// AllowOrigins is the CORS allowlist. Empty is correct for the deployed
|
// 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.
|
// legitimate.
|
||||||
AllowOrigins []string
|
AllowOrigins []string
|
||||||
|
|
||||||
|
|||||||
@ -23,7 +23,7 @@ type Server struct {
|
|||||||
// pageData is the same for every render: the ciphertext cap, so the browser
|
// 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
|
// enforces what the server enforces, and the default lifetime, so the page
|
||||||
// states the TTL the server will apply. It carries nothing request-specific,
|
// 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 {
|
func (s *Server) pageData() web.Data {
|
||||||
return web.Data{
|
return web.Data{
|
||||||
MaxCiphertextBytes: secret.MaxCiphertextBytes,
|
MaxCiphertextBytes: secret.MaxCiphertextBytes,
|
||||||
@ -50,6 +50,12 @@ func (s *Server) handleRevealPage(c *chassis.Context) error {
|
|||||||
return s.pages.Reveal(c.Writer(), s.pageData())
|
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 {
|
type createRequest struct {
|
||||||
Ciphertext string `json:"ciphertext"`
|
Ciphertext string `json:"ciphertext"`
|
||||||
TTLSeconds int64 `json:"ttl_seconds"`
|
TTLSeconds int64 `json:"ttl_seconds"`
|
||||||
|
|||||||
@ -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 := chassis.New(chassis.Config{Service: "hush", Env: "dev", MaxBodyBytes: 128 * 1024}, log)
|
||||||
app.Get("/", srv.handleCreatePage)
|
app.Get("/", srv.handleCreatePage)
|
||||||
app.Get("/s/{id}", srv.handleRevealPage)
|
app.Get("/s/{id}", srv.handleRevealPage)
|
||||||
|
app.Get("/mcp", srv.handleMCPPage)
|
||||||
app.Route("/api", func(r *chassis.Router) {
|
app.Route("/api", func(r *chassis.Router) {
|
||||||
r.Post("/secrets", srv.handleCreate)
|
r.Post("/secrets", srv.handleCreate)
|
||||||
r.Post("/secrets/{id}/reveal", srv.handleReveal)
|
r.Post("/secrets/{id}/reveal", srv.handleReveal)
|
||||||
@ -306,7 +307,7 @@ func TestPagesShipTheClientSideCrypto(t *testing.T) {
|
|||||||
|
|
||||||
func TestPagesAreNotCacheable(t *testing.T) {
|
func TestPagesAreNotCacheable(t *testing.T) {
|
||||||
h, _ := testApp(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)
|
r := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
h.ServeHTTP(w, r)
|
h.ServeHTTP(w, r)
|
||||||
@ -328,7 +329,7 @@ func TestPagesAreNotCacheable(t *testing.T) {
|
|||||||
func TestPagesSendOneNonceCSPThatPermitsTheirOwnInlineCode(t *testing.T) {
|
func TestPagesSendOneNonceCSPThatPermitsTheirOwnInlineCode(t *testing.T) {
|
||||||
h, _ := testApp(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)
|
r := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
h.ServeHTTP(w, r)
|
h.ServeHTTP(w, r)
|
||||||
@ -408,3 +409,26 @@ func cspNonce(t *testing.T, path, policy string) string {
|
|||||||
}
|
}
|
||||||
return rest[:j]
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -104,10 +104,11 @@ func run() error {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// Pages: no storage access, no rate limit. A link previewer hitting either
|
// Pages: no storage access, no rate limit. A link previewer hitting any of
|
||||||
// of these must be free and harmless.
|
// these must be free and harmless.
|
||||||
app.Get("/", srv.handleCreatePage)
|
app.Get("/", srv.handleCreatePage)
|
||||||
app.Get("/s/{id}", srv.handleRevealPage)
|
app.Get("/s/{id}", srv.handleRevealPage)
|
||||||
|
app.Get("/mcp", srv.handleMCPPage)
|
||||||
|
|
||||||
limiter := &redisLimiter{store: rdb, cfg: cfg, metrics: metrics}
|
limiter := &redisLimiter{store: rdb, cfg: cfg, metrics: metrics}
|
||||||
app.Route("/api", func(r *chassis.Router) {
|
app.Route("/api", func(r *chassis.Router) {
|
||||||
|
|||||||
@ -2,10 +2,12 @@
|
|||||||
# the pipeline's deploy step runs `kubectl set image` and needs a Deployment to
|
# the pipeline's deploy step runs `kubectl set image` and needs a Deployment to
|
||||||
# set it on.
|
# 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
|
# The credential, the workload, the Service and the network boundary. The public
|
||||||
# network boundary and the public route.
|
# 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
|
# The Redis credential. hush connects as its OWN Redis ACL user, scoped to
|
||||||
# `~hush:*` with a minimal command set (+ping +set +getdel +incr +pexpire
|
# `~hush:*` with a minimal command set (+ping +set +getdel +incr +pexpire
|
||||||
@ -207,43 +209,3 @@ spec:
|
|||||||
kubernetes.io/metadata.name: databases
|
kubernetes.io/metadata.name: databases
|
||||||
ports:
|
ports:
|
||||||
- { protocol: TCP, port: 6379 }
|
- { 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 }
|
|
||||||
|
|||||||
57
deployments/k8s/ingress.yaml
Normal file
57
deployments/k8s/ingress.yaml
Normal 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 }
|
||||||
@ -120,6 +120,7 @@ backstop.
|
|||||||
```
|
```
|
||||||
GET / create page (static HTML+JS, no storage access)
|
GET / create page (static HTML+JS, no storage access)
|
||||||
GET /s/{id} reveal 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 store ciphertext rate limited
|
||||||
POST /api/secrets/{id}/reveal GETDEL, destroy, return once rate limited
|
POST /api/secrets/{id}/reveal GETDEL, destroy, return once rate limited
|
||||||
GET /healthz liveness — 200 while draining
|
GET /healthz liveness — 200 while draining
|
||||||
@ -135,9 +136,9 @@ rate limiter and templates — not a framework.
|
|||||||
### The pages override the chassis CSP
|
### The pages override the chassis CSP
|
||||||
|
|
||||||
The chassis policy is written for a JSON API: `default-src 'none';
|
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
|
frame-ancestors 'none'`. The pages are HTML with inline style, and the two that
|
||||||
style, so `internal/web.render` replaces that header with a per-response
|
encrypt also carry inline script, so `internal/web.render` replaces that header
|
||||||
nonce policy:
|
with a per-response nonce policy:
|
||||||
|
|
||||||
```
|
```
|
||||||
default-src 'none'; script-src 'nonce-<r>'; style-src 'nonce-<r>';
|
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 — `TestTheRevealPageDoesNotDiscloseWhetherASecretExists` compares the
|
||||||
page with it masked and asserts constant length.
|
page with it masked and asserts constant length.
|
||||||
|
|
||||||
The public Ingress routes `/` (exact), `/s/` and `/api/` only. `/metrics`,
|
The public Ingress routes `/` (exact), `/mcp` (exact), `/s/` and `/api/` only.
|
||||||
`/healthz` and `/readyz` share the port but are unreachable from the internet;
|
`/metrics`, `/healthz` and `/readyz` share the port but are unreachable from
|
||||||
vmagent scrapes the pod IP directly. This is why there is no metrics basic-auth
|
the internet; vmagent scrapes the pod IP directly. This is why there is no
|
||||||
middleware to maintain.
|
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
|
## Abuse posture
|
||||||
|
|
||||||
|
|||||||
@ -112,7 +112,7 @@ first. But the committed image tag cannot be `:latest`: the cluster's
|
|||||||
cannot pin a rollback.
|
cannot pin a rollback.
|
||||||
|
|
||||||
So the manifest carries `:bootstrap`, which is policy-legal and does not exist.
|
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
|
```bash
|
||||||
make deploy-manifests # pod sits in ImagePullBackOff — expected
|
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
|
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:
|
The build Job, which is what Woodpecker's Kaniko step does by hand:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
28
docs/MCP.md
28
docs/MCP.md
@ -10,20 +10,24 @@
|
|||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```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
|
That is the whole install, from anywhere, with no clone: `cmd/hush-mcp` imports
|
||||||
works before wiring anything, and adds a `hush` entry to
|
only the standard library, so the module graph never reaches the private
|
||||||
`~/.omp/agent/mcp.json` — backing the file up first and leaving every other
|
`go-chassis` dependency that `cmd/hushd` needs.
|
||||||
server alone. Restart omp to pick it up.
|
|
||||||
|
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
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"hush": {
|
"hush": {
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "/Users/you/.local/bin/hush-mcp",
|
"command": "/Users/you/go/bin/hush-mcp",
|
||||||
"env": { "HUSH_BASE_URL": "https://hush.threesix.ai" },
|
"env": { "HUSH_BASE_URL": "https://hush.threesix.ai" },
|
||||||
"timeout": 20000
|
"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
|
That shape is what Claude Desktop, Cursor and omp read. VS Code spells the
|
||||||
Code — the stdio transport is the portable part.
|
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
|
## Why it runs locally instead of being an endpoint on hushd
|
||||||
|
|
||||||
|
|||||||
@ -80,8 +80,13 @@ button.ghost:hover{background:none;color:var(--fg);border-color:var(--line-lit)}
|
|||||||
.hide{display:none}
|
.hide{display:none}
|
||||||
footer{margin:14px 2px 0;color:var(--faint);font-size:11.5px;line-height:1.5}
|
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 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}}
|
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
|
||||||
</style>
|
</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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
@ -90,51 +95,12 @@ footer code{font:11px var(--mono);color:var(--dim)}
|
|||||||
</div>
|
</div>
|
||||||
<footer>
|
<footer>
|
||||||
Encrypted in your browser. The key travels in the link's <code>#fragment</code>,
|
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>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
<script nonce="{{.Nonce}}">
|
<!-- The pages that encrypt pull in templates/crypto.html, which defines this.
|
||||||
// Shared crypto. AES-256-GCM via WebCrypto; the key is generated here, never
|
The MCP page defines it empty: it runs no script at all. -->
|
||||||
// transmitted, and carried only in the URL fragment.
|
{{template "crypto" .}}
|
||||||
//
|
|
||||||
// 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>
|
|
||||||
{{template "script" .}}
|
{{template "script" .}}
|
||||||
</body>
|
</body>
|
||||||
</html>{{end}}
|
</html>{{end}}
|
||||||
|
|||||||
@ -20,6 +20,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{define "nav"}} · <a href="/mcp">use it from an agent</a>{{end}}
|
||||||
|
|
||||||
{{define "script"}}
|
{{define "script"}}
|
||||||
<script nonce="{{.Nonce}}">
|
<script nonce="{{.Nonce}}">
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|||||||
47
internal/web/templates/crypto.html
Normal file
47
internal/web/templates/crypto.html
Normal 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}}
|
||||||
204
internal/web/templates/mcp.html
Normal file
204
internal/web/templates/mcp.html
Normal 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><the credential></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}}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
// Package web serves hush's two pages. Both are static: they read no storage,
|
// Package web serves hush's pages. All of them are static: they read no
|
||||||
// so a link previewer fetching either one cannot destroy a secret.
|
// storage, so a link previewer fetching any of them cannot destroy a secret.
|
||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -15,11 +15,12 @@ import (
|
|||||||
//go:embed templates/*.html
|
//go:embed templates/*.html
|
||||||
var files embed.FS
|
var files embed.FS
|
||||||
|
|
||||||
// Pages renders the create and reveal pages. Templates are embedded, so the
|
// Pages renders the create, reveal and MCP pages. Templates are embedded, so
|
||||||
// container carries no template directory to go missing at runtime.
|
// the container carries no template directory to go missing at runtime.
|
||||||
type Pages struct {
|
type Pages struct {
|
||||||
create *template.Template
|
create *template.Template
|
||||||
reveal *template.Template
|
reveal *template.Template
|
||||||
|
mcp *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data is everything a page needs. MaxCiphertextBytes is passed through so the
|
// 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
|
// 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
|
// request: a template error is a build defect and should not wait for traffic
|
||||||
// to surface.
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse create template: %w", err)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse reveal template: %w", err)
|
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.
|
// 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)
|
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.
|
// 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
|
// 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
|
// The pages fetch /api/secrets and /api/secrets/{id}/reveal. Same-origin
|
||||||
// only: there is no other host this page may ever talk to.
|
// only: there is no other host this page may ever talk to.
|
||||||
"; connect-src 'self'" +
|
"; 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'.
|
// remaining fetch directive stays at default-src 'none'.
|
||||||
"; form-action 'none'" +
|
"; form-action 'none'" +
|
||||||
"; base-uri 'none'" +
|
"; base-uri 'none'" +
|
||||||
@ -113,7 +133,7 @@ func render(w http.ResponseWriter, t *template.Template, d Data) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h := w.Header()
|
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.
|
// 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("Cache-Control", "no-store, max-age=0")
|
||||||
h.Set("Content-Type", "text/html; charset=utf-8")
|
h.Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|||||||
@ -17,6 +17,12 @@ set -euo pipefail
|
|||||||
export KUBECONFIG="${KUBECONFIG:-$HOME/.kube/orchard9-k3sf.yaml}"
|
export KUBECONFIG="${KUBECONFIG:-$HOME/.kube/orchard9-k3sf.yaml}"
|
||||||
NS="${NS:-projects}"
|
NS="${NS:-projects}"
|
||||||
HOST="${HOST:-hush.threesix.ai}"
|
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)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
@ -29,9 +35,16 @@ if [ -n "$(git status --porcelain)" ]; then
|
|||||||
git status --short >&2
|
git status --short >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ -n "$(git log --oneline @{upstream}..HEAD 2>/dev/null)" ]; then
|
# `@{upstream}` is NOT the right comparison: this checkout tracks a mirror, so
|
||||||
echo "refusing: HEAD is not pushed to origin (Gitea). Kaniko clones from there." >&2
|
# HEAD can be pushed there while Gitea — the repo Kaniko clones — is behind,
|
||||||
git log --oneline '@{upstream}..HEAD' >&2
|
# 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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@ -63,7 +76,7 @@ spec:
|
|||||||
image: gcr.io/kaniko-project/executor:v1.23.2
|
image: gcr.io/kaniko-project/executor:v1.23.2
|
||||||
args:
|
args:
|
||||||
# The Gitea repo is public, so the git context needs no credential.
|
# 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
|
- --dockerfile=Dockerfile
|
||||||
- --destination=$IMAGE
|
- --destination=$IMAGE
|
||||||
# The internal Zot registry serves a self-signed cert.
|
# The internal Zot registry serves a self-signed cert.
|
||||||
|
|||||||
@ -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"
|
[ "$code" != "201" ] || fail "the server ACCEPTED a plaintext field — the zero-knowledge claim is broken"
|
||||||
pass "a plaintext field is refused ($code)"
|
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
|
echo
|
||||||
printf '\033[32mall smoke checks passed\033[0m\n'
|
printf '\033[32mall smoke checks passed\033[0m\n'
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user