diff --git a/SPEC.md b/SPEC.md index 248d36e..d80020b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2130,3 +2130,18 @@ no credential, no token, no token digest, no provider account and no content. reports `provider-grant-may-persist`. 7. Revoking or quarantining a grant deletes no record of a pending action. An action that the revoked grant would have read back stays `unknown`. +8. A customer MCP binding may declare its publisher: `{"kind": "registry" | "repository" | + "domain", "name"}`, a domain being the registrable domain, never a host under it or on a shared + platform. Its liveness is read at registration and on the operator's schedule, and + kept beside the engine configuration under its lock, with its mode and owner: `maintained`, + `unmaintained` (evidence of a lapse: deprecated, archived, expired, in redemption or pending + deletion, or a domain its registry's RDAP service answers is not registered), `unverifiable` (a + package or repository the public source does not show, or a top-level domain with no RDAP + service), `unknown` or `undeclared`, with + the time it was checked. An unmaintained publisher is reported as `publisher-unmaintained`, an + unverifiable one as `publisher-unverifiable`, which never refuses. An inconclusive reading + (`unknown` or `unverifiable`) never replaces a conclusive one (`maintained` or `unmaintained`) + of the same declared publisher: the conclusive state is kept and the failed read recorded beside + it. Under the operator's `refuse` policy, an unmaintained publisher refuses the registration + and each acquisition and action of that binding. A registration whose name closely matches an + integration the gateway ships, under another publisher, is flagged `name-lookalike`. diff --git a/adapters/connections/grant_renewal_test.go b/adapters/connections/grant_renewal_test.go new file mode 100644 index 0000000..0089bc0 --- /dev/null +++ b/adapters/connections/grant_renewal_test.go @@ -0,0 +1,93 @@ +//go:build linux || darwin + +package connections + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" +) + +// N6a: the state is read again once a token is held after a renewal, on +// every renewal path: another process's renewal taken in place of a failed +// one, and Notion's. + +// ownedQuarantinable gives the grant an owner whose issuing host answers +// current, so that a removal can quarantine it. +func ownedQuarantinable(t *testing.T, dir, principal, provider, connection string) { + t.Helper() + standing := `{"standing":"current"}` + _, cfg := ownerHost(t, &standing, quarantineRemoval) + if _, err := grantOp(t, dir, principal, "", "owner-reads", cfg); err != nil { + t.Fatal(err) + } + if _, err := grantOp(t, dir, principal, "", "set-owner", map[string]any{"provider": provider, "connection": connection, "owner": owner, "ownerHost": "desk"}); err != nil { + t.Fatal(err) + } +} + +func quarantine(t *testing.T, dir, principal string) { + if _, err := GrantOperation(context.Background(), dir, principal, "", "remove-owner", mustJSON(map[string]any{"owner": owner, "ownerHost": "desk", "removal": "quarantine"})); err != nil { + t.Error(err) + } +} + +func TestQuarantineDuringAnotherProcessRenewalRefusesTheCall(t *testing.T) { + b, count, dir, r := driveGrant(t) + ownedQuarantinable(t, dir, "alice", "google-drive", "google-drive") + if err := b.store.locked(func(v *state) error { v.Connection.Expires = 1; return b.store.write("state.json", v) }); err != nil { + t.Fatal(err) + } + // This renewal fails: another process renewed first, and the grant is + // quarantined meanwhile. + renewal := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := b.store.locked(func(v *state) error { + v.Connection.Access, v.Connection.Refresh, v.Connection.Expires = "google-access-private", "renewed-elsewhere", time.Now().Add(time.Hour).Unix() + if err := b.store.write("state.json", v); err != nil { + return err + } + return b.store.recordConsent(v.Connection, driveScope) + }); err != nil { + t.Error(err) + } + quarantine(t, dir, "alice") + http.Error(w, `{"error":"invalid_grant"}`, 400) + })) + defer renewal.Close() + b.provider.token = renewal.URL + inner := b.provider.client + b.provider.client = &http.Client{Transport: testTransportFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Host == renewal.Listener.Addr().String() { + return renewal.Client().Transport.RoundTrip(req) + } + return inner.Transport.RoundTrip(req) + }), CheckRedirect: inner.CheckRedirect} + if err := driveRead(b, r.Selections[0]); err != ErrGrantQuarantined { + t.Fatalf("a read whose renewal another process made while the grant was quarantined: %v", err) + } + if count.Load() != 0 { + t.Fatal("the file was asked for") + } +} + +func TestQuarantineDuringNotionRenewalRefusesTheCall(t *testing.T) { + b, _ := notionFixture(t) + connectNotion(t, b) + dir := filepath.Dir(filepath.Dir(b.store.root.Name())) + ownedQuarantinable(t, dir, "test-person", "notion", "notion") + b.store.locked(func(v *state) error { v.Connection.Expires = 1; return b.store.write("state.json", v) }) + client, c, epoch, err := b.connectedSnapshot() + if err != nil { + t.Fatal(err) + } + b.provider.client.Transport = testTransportFunc(func(r *http.Request) (*http.Response, error) { + quarantine(t, dir, "test-person") + return testReply(200, `{"access_token":"notion-renewed","refresh_token":"notion-refresh-renewed","expires_in":3600,"token_type":"Bearer"}`), nil + }) + if _, err := b.provider.access(context.Background(), b.store, client, c, epoch); err != ErrGrantQuarantined { + t.Fatalf("a Notion renewal during which the grant was quarantined: %v", err) + } +} diff --git a/adapters/connections/provider.go b/adapters/connections/provider.go index 8121a11..d18b5ce 100644 --- a/adapters/connections/provider.go +++ b/adapters/connections/provider.go @@ -154,7 +154,11 @@ func (p provider) access(ctx context.Context, s *Store, client Client, c credent return "", err } if p.notion { - return p.notionAccess(ctx, s, client, c, epoch) + token, err := p.notionAccess(ctx, s, client, c, epoch) + if err != nil { + return "", err + } + return p.regate(s, c, token) } // A renewal need not say what scope its token is of, so what a Drive // connection may do is held to the record of its consent, and not to @@ -211,7 +215,7 @@ func (p provider) access(ctx context.Context, s *Store, client Client, c credent }) != nil { return "", ErrRevoked } - return access, nil + return p.regate(s, c, access) } var access string err = s.locked(func(v *state) error { @@ -256,13 +260,18 @@ func (p provider) access(ctx context.Context, s *Store, client Client, c credent if err != nil { return "", err } - // The grant's state is read again once the token is renewed, - // immediately before it is used: a quarantine made while the renewal - // was under way refuses this call too. + return p.regate(s, c, access) +} + +// regate reads the grant's state again once a token is held after a +// renewal -- this call's, another process's, or Notion's -- immediately +// before it is used: a quarantine made while the renewal was under way +// refuses this call too. +func (p provider) regate(s *Store, c credential, token string) (string, error) { if err := s.locked(func(v *state) error { return s.grantGate(p.kind(), v, p.kind(), c.ID) }); err != nil { return "", err } - return access, nil + return token, nil } func (p provider) scope() string { if p.notion { diff --git a/docs/design/connection-grants.md b/docs/design/connection-grants.md index cbd1e40..8fea000 100644 --- a/docs/design/connection-grants.md +++ b/docs/design/connection-grants.md @@ -23,8 +23,8 @@ These have no record in this release: - an object store's access keys; - a search service's or a processing service's API key. -The gateway has no GitHub connection. A customer MCP server's publisher is a separate record (the -publisher-trust change). +The gateway has no GitHub connection. A customer MCP server's publisher has its own record, kept by +the core beside the engine configuration ([below](#publisher-trust-for-customer-mcp)). ## The record @@ -155,7 +155,8 @@ provider's custody for the principal, is then revoked or quarantined: - without either, the grant is revoked (open question 13). A quarantined grant keeps its credential, admits no call, and is refused as `grant-quarantined` -before its credential is read. Its state is read again once a Google token is renewed or a Databricks +before its credential is read. Its state is read again once a token is held after a renewal (a Google +renewal of this call's, another process's renewal taken in its place, or Notion's) or a Databricks token is held, immediately before the call is sent. The gate holds the ledger to custody before it reads a grant's state, so a connection made again under its id after its grant was revoked is a new grant there, as in the report. It leaves quarantine only when the operator revokes it. @@ -214,6 +215,133 @@ A finding's sentence names no path, no account and no token, as `/posture`'s pub not (#350). The report's members are the owner reference and scope names, and never a provider account. +## Publisher trust for customer MCP + +A customer binding (`"customer": true`, #348) may declare its server's publisher with a binding +member: + +```json +"publisher": {"kind": "registry", "name": "npm:@acme/tickets-mcp"} +"publisher": {"kind": "repository", "name": "github.com/acme/tickets-mcp"} +"publisher": {"kind": "domain", "name": "acme.example"} +``` + +The member is refused on a binding that is not a customer's. + +**A domain publisher is a registrable domain:** the name a registrant holds at its registry, such +as `acme.example` or `acme.co.uk`, never a host under it. The core reads no public-suffix list, so +the rule at parse is this: + +- the name has exactly two labels, or exactly three under a two-label public suffix of a fixed, + partial list (`co.uk`, `nhs.uk`, `com.au`, `co.jp`, `com.es`, `on.ca` and so on); +- a host under a domain of that list's suffixes is refused, and the message names the domain to + declare (`tickets.acme.co.uk` → declare `acme.co.uk`); +- any other name of three labels or more is refused without naming a domain: the gateway cannot + tell a host under a registrable domain (`tickets.acme.example`) from a registrable domain under a + public suffix it does not list (`acme.com.zz`), and the name it would suggest could be another's + domain or a suffix; such a server declares its repository or package, or no publisher; +- a listed public suffix itself (`co.uk`, `nhs.uk`) is refused. The list is partial: a public + suffix it does not name, declared as a two-label domain, parses, and is read as that suffix's + registry object, which says nothing of a publisher. Such a declaration is meaningless, and + is the operator's to avoid; +- a shared platform's domain, under which anyone may be given a host (`vercel.app`, `netlify.app`, + `github.io`, `pages.dev`, `herokuapp.com`, `azurewebsites.net` and others), is refused, and so + is any host on one. + +Such a server declares its repository or its package as its publisher instead. The binding digest covers it, as it +covers every member. + +**Liveness.** The gateway reads it from public sources: + +| Kind | Source | Unmaintained when (evidence of a lapse) | Unverifiable when | Maintained when | +| --- | --- | --- | --- | --- | +| `registry` | the npm registry | the package's latest version is deprecated (a message, or `true`) | the registry shows no public package of that name (private, or never published) | the latest version is listed and not deprecated | +| `repository` | GitHub's repository API | the repository is archived or disabled | no public repository of that name answers (private, or not there) | it is neither | +| `domain` | the registry's own RDAP service, as the IANA RDAP bootstrap names it for the top-level domain | the registry answers that the domain is not registered (it lapsed and was deleted, or was never registered), the registration has expired (equal is expired), or the domain is in its redemption period or pending deletion | the bootstrap names no RDAP service for the top-level domain | the registration is current | + +The 404 cases are these, exactly: + +- **npm and GitHub:** a 404 is `unverifiable`. An unauthenticated read cannot tell a private + package or repository from one unpublished or deleted. +- **RDAP:** a 404 from the registry's own service for the registrable domain declared is + `unmaintained`, with the reason "the registry's RDAP service answers that the domain is not + registered". That is the window in which another can register the name. +- **No service:** a top-level domain absent from the bootstrap (for example `.io`) is + `unverifiable`. A bootstrap that cannot be read, is empty, or lists the top-level domain with no + HTTPS service, is inconclusive (`unknown`). +- **Never the name above:** the gateway asks for the declared name alone. It never reads the + object of a name above it, such as a public suffix's own object. + +Any other answer leaves the state `unknown`. + +**An inconclusive reading never lifts a conclusive one.** `maintained` and `unmaintained` are +conclusive; `unknown` (a read that failed, timed out, was refused or rate-limited, or answered +what cannot be read) and `unverifiable` are not. When a re-check or a recapture reads a publisher +inconclusively, and its record holds a conclusive reading of the same declared publisher: + +- the record keeps that state and its time; +- the failed read is recorded beside it (`lastAttempt`, `attemptState` and `attemptReason`) and + reported as `publisher-read-inconclusive`; +- `gateway publishers --check` warns on stderr and exits 3, after writing the record. + +So under `refuse`, an unmaintained publisher stays refused until a conclusive `maintained` +reading, and a recapture whose reading fails is refused as before. A platform with no conclusive +reading yet takes the inconclusive state, and is not refused. A customer binding that declares no publisher is +`undeclared`. The reads disclose to those public services which servers an operator uses, so their +schedule is the operator's. + +**Where it is read and kept.** The read happens at registration: `gateway connect` for a customer +binding, `--replace` (a recapture) included. It happens before any adapter is run, and its lines +are shown with the approval. It happens again on the operator's schedule, with +`gateway publishers --config --check`. What was read goes in +`.publishers.json`, by platform: the binding reference it was read for, the +publisher, its state, when it was checked, and a fixed reason. The reason names no path, no +account and no token. + +Every read-modify-write of the record is made under the configuration's own lock +(`.lock`, the one `connect` holds). `connect` writes the record under its lock once +the configuration is in place. `gateway publishers --check` makes its liveness reads without the +lock, then merges what it read into the record as it stands under the lock. A policy set +meanwhile is kept. So is a platform's record written meanwhile, by a `connect` or a recapture: a +record whose binding or reading time differs from the one the re-check started from is left as +written, and is read again at the next re-check. A `gateway publishers` that finds a `connect` holding the lock +waits up to ten seconds, then refuses. The record is given the configuration's own mode and +owner, as a replaced configuration is: a root `connect` or a root-run `gateway publishers` over a +configuration the signer owns leaves a record the signer can read. + +**Policy.** `gateway publishers --config --policy report|refuse` sets the operator's +policy; `report` is the default (open question 15). An unmaintained publisher is always reported +as `publisher-unmaintained`, and an unverifiable one as `publisher-unverifiable`. Under `refuse`, +a record of the platform's own binding that says `unmaintained` (evidence of a lapse, never a +404) also refuses three things: + +- its registration; +- each acquisition, before the source starts; +- each action, before an executor runs. + +A record that cannot be read refuses as `publisher-record-unreadable`; the reason answered to +`/acquire` and `/act` names no path. An `unverifiable`, `unknown` or `undeclared` publisher is not +refused, under either policy. + +**Name lookalike.** A registration is flagged `name-lookalike` when its platform name, or its +binding's platform name, closely matches one of the integrations the gateway ships while its +declared publisher is not that integration's. The integrations are `azure-devops`, `databricks`, +`postgres`, `google-drive`, `gmail`, `notion`, `obsidian` and `aws-s3`. Names are compared after +folding case, punctuation and look-alike characters: fullwidth letters to their +ASCII forms, Cyrillic and Greek letters drawn like a Latin letter to that letter (capitals and +small letters, from a fixed table), Latin letters with a diacritic to their base letter (the +Latin-1 Supplement and Latin Extended-A, and a decomposed spelling, whose combining mark is +dropped), the mathematical alphanumeric symbols, small capitals and a few Armenian letters +(`օ`, `ս`, `հ`, `ո`, `ց`), and `0`→`o`, `1`→`l`, `3`→`e`, `5`→`s`, `rn`→`m`, `vv`→`w`. The core +has no Unicode normalization or confusables data, so letters outside these tables are known +misses. A close match is any of these, after that folding: + +- the name equals the integration's; +- the name is one edit away from it (two edits for a name of ten characters or more); +- the name contains it. + +The flag is shown with the approval and recorded. It is a report, never a refusal. + ## Departures from RFC 0030 §5 - **More members.** The record adds `provider`, `createdAt`, `excess`, `wider` and `revocation`, and keeps @@ -236,6 +364,24 @@ account. are the operator's configuration, held in custody. - **Not built.** Delegated calls and the admission read carrying the owner (spec #284 NC2) belong to #356. Reassignment out of quarantine, and acceptance, are not built. +- **Where the publisher record lives.** It is kept beside the engine configuration and not in the + connection grant record. A customer MCP server is registered by the core (`gateway connect`), not + by a connection custody. A customer binding without a declared publisher is `undeclared`. The + RFC has `null` for "otherwise". +- **Publisher sources.** Liveness is read from npm, GitHub and RDAP only. The `registry` and + `repository` kinds accept only those, and another registry or forge is refused at parse. +- **No policy refuses a lookalike.** The RFC lets an operator policy make `name-lookalike` a + refusal. This release reports it only; no policy refuses it. +- **Unverifiable is its own state.** The RFC's states are `maintained`, `unmaintained` and + `unknown`. A package or repository the public source does not show (a 404), and a top-level + domain with no RDAP service, are `unverifiable`: reported, never refused. A registry's own 404 + for a declared domain is `unmaintained`. +- **A domain publisher is a registrable domain, by a partial list.** Without a public-suffix + list, a domain under a two-label public suffix the gateway does not list cannot be declared as a + domain publisher. Hosts, including hosts on shared platforms, cannot be declared at all; such a + server declares its repository or package. +- **Name lookalike against the gateway's own integrations.** The known integrations are the ones + the gateway ships, a fixed list. The operator cannot extend it in this release. - **Owner removal at use.** An owner read that answers `removed` changes the grants in the custody being used. The owner's grants in other providers' custody are refused at their own next use of any kind, by their own owner read, and changed then, by a `sweep`, or by `remove-owner`. diff --git a/docs/design/engine-config.md b/docs/design/engine-config.md index 9771087..d57b663 100644 --- a/docs/design/engine-config.md +++ b/docs/design/engine-config.md @@ -992,6 +992,17 @@ approval path; subsequent acquisition refuses a version downgrade or descriptor drift before `tools/call`. A customer binding is not an exemption from per-platform OS users, credential separation, write approval or source shape `mcp`. +A customer binding may also declare its server's publisher, `"publisher": {"kind": "registry" | +"repository" | "domain", "name": ...}`, which is refused on a binding that is not a customer's. +`connect` reads its liveness before it runs anything and shows it with the approval. It also flags +a name that closely matches an integration the gateway ships under another publisher +(`name-lookalike`). What it read is kept in `.publishers.json`, under the configuration's lock and +with its mode and owner, which +`gateway publishers --check` reads again on the operator's schedule. Under +`gateway publishers --policy refuse`, an unmaintained publisher (evidence of a lapse, never an +unverifiable one) refuses the registration and each +acquisition and action. See [connection grants](connection-grants.md#publisher-trust-for-customer-mcp). + Local commands use `workspace-write` by default. The optional policy file has `policyVersion: "1"`, a `defaultMode`, and a `sources` object whose exact `platform/operation` keys override that default. Supported mode names are diff --git a/go/act.go b/go/act.go index 4c0a41e..ca8f8a4 100644 --- a/go/act.go +++ b/go/act.go @@ -71,6 +71,9 @@ func (g *gatewayService) act(sessionRaw, platformRaw, toolRaw, argumentsRaw, dec if !contains(spec.tools, tool) { return nil, actRefusal{"tool", fmt.Sprintf("tool %q is not one the platform's write binding names", requestText(tool))} } + if err := publisherRefusal(spec.publishers, platform, spec.binding); err != nil { + return nil, actRefusal{"platform", err.Error()} + } // The executor is started for this tool alone (executor.md): a binding // naming several write tools offers each request one of them. spec = narrowTools(spec, tool) diff --git a/go/connect.go b/go/connect.go index 4b5b31e..63ba339 100644 --- a/go/connect.go +++ b/go/connect.go @@ -46,6 +46,10 @@ type connectRequest struct { // nothing written (--preview-descriptors). preview string previewOperation string + // afterWrite is run once the configuration is in place, under the + // lock connect holds (the publisher record, publisher.go); nil for + // none. Its error is connect's, the configuration written. + afterWrite func(*configFile) error } // connectOutcome is what connect has to say: the statements the @@ -105,7 +109,7 @@ func cmdConnect(args []string) int { closeInheritedDescriptors() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - outcome, err := connect(ctx, req, osEngineHost(), runCheck) + outcome, err := connectPublisher(ctx, req, osEngineHost(), runCheck, defaultPublisherChecker()) return printOutcome(os.Stdout, os.Stderr, req.platform, outcome, err) } @@ -511,6 +515,11 @@ func connect(ctx context.Context, req connectRequest, host engineHost, check fun return out, err } out.written = req.config + if req.afterWrite != nil { + if err := req.afterWrite(file); err != nil { + return out, err + } + } if previous != nil { written.descriptors = pin out.after = restartLines(*previous, written) diff --git a/go/engine.go b/go/engine.go index 94dfdcf..8f1e3fb 100644 --- a/go/engine.go +++ b/go/engine.go @@ -161,7 +161,10 @@ type identitySpec struct { // binding is a catalog entry: which pinned artifact serves which operation // of a platform, with the licence of each stated. type binding struct { - customer bool + customer bool + // publisher is who publishes a customer server, as its binding declares + // (publisher.go); nil where it declares none. + publisher *publisherDecl version string actionRecovery map[string]*actionRecovery @@ -813,7 +816,7 @@ func loadBinding(catalog, ref string) (binding, error) { return b, nil } -var bindingMembers = map[string]bool{"bindingVersion": true, "platform": true, "operations": true, "customer": false, "actionRecovery": false} +var bindingMembers = map[string]bool{"bindingVersion": true, "platform": true, "operations": true, "customer": false, "actionRecovery": false, "publisher": false} // parseBinding holds a catalog entry to its shape: one entry per operation, // each naming its shape, a digest-pinned artifact, and its licence. @@ -841,6 +844,14 @@ func parseBinding(data []byte) (binding, error) { } b.customer = true } + if raw, present := obj.get("publisher"); present { + if !b.customer { + return binding{}, errors.New("publisher is declared only on a customer binding") + } + if b.publisher, err = parsePublisher(raw); err != nil { + return binding{}, err + } + } if b.platform, err = requireString(obj, "platform"); err != nil || b.platform == "" { return binding{}, errors.New("platform must be a non-empty string") } @@ -1132,6 +1143,16 @@ func isPinnedImage(ref string) bool { return ok && imageReference.MatchString(name) && isDigest(digest) } +// customerPublishers is the publisher record a customer binding's sources +// are held to (publisher.go): the file beside the configuration it was +// loaded from; none for another binding, or a configuration with no path. +func customerPublishers(cfg engineConfig, b binding) string { + if !b.customer || cfg.configPath == "" { + return "" + } + return publishersPath(cfg.configPath) +} + // deriveSources turns platforms and their bindings into the sources `serve` // runs: `/history` through adapter-airbyte, `/live` // through adapter-mcp, each with the adapter's command line, the platform's @@ -1176,7 +1197,7 @@ func deriveSources(cfg engineConfig, bindings map[string]binding) map[string]sou if len(b.live.command) > 0 { argv = append(append(argv, "--"), b.live.command...) } - sources[p.name+"/live"] = sourceSpec{argv: argv, env: env, user: p.user, shape: "mcp", binding: p.binding, tools: b.live.tools, check: check, endpoint: b.live.url, descriptorProvenance: platformProvenance(p, b, "live"), approval: approvalOf(p, b.live)} + sources[p.name+"/live"] = sourceSpec{argv: argv, env: env, user: p.user, shape: "mcp", binding: p.binding, tools: b.live.tools, check: check, endpoint: b.live.url, descriptorProvenance: platformProvenance(p, b, "live"), approval: approvalOf(p, b.live), publishers: customerPublishers(cfg, b)} } // The write operation, for a platform that allows writes: the // executor is adapter-mcp on the binding's write server with the @@ -1202,7 +1223,7 @@ func deriveSources(cfg engineConfig, bindings map[string]binding) map[string]sou if b.write.url != "" { endpoint, transport = b.write.url, "http" } - sources[p.name+"/write"] = sourceSpec{argv: argv, env: env, user: p.user, shape: "mcp", tools: b.write.tools, endpoint: endpoint, transport: transport, policies: p.policies, binding: p.binding, actionRecovery: b.actionRecovery, approval: approvalOf(p, b.write)} + sources[p.name+"/write"] = sourceSpec{argv: argv, env: env, user: p.user, shape: "mcp", tools: b.write.tools, endpoint: endpoint, transport: transport, policies: p.policies, binding: p.binding, actionRecovery: b.actionRecovery, approval: approvalOf(p, b.write), publishers: customerPublishers(cfg, b)} } } if cfg.sandbox != nil { diff --git a/go/main.go b/go/main.go index c59839c..e83411f 100644 --- a/go/main.go +++ b/go/main.go @@ -18,6 +18,7 @@ package main // gateway keygen [seedfile] // gateway rotate-key --seed --registry // gateway posture [--json] [--config | [serve] [serve options]] +// gateway publishers --config [--check] [--policy report|refuse] // gateway version import ( @@ -42,7 +43,7 @@ func main() { os.Exit(2) } if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: gateway canon | verify | export | conform | adapter-conform | witness | serve | connect | keygen | rotate-key | mcp | posture | version") + fmt.Fprintln(os.Stderr, "usage: gateway canon | verify | export | conform | adapter-conform | witness | serve | connect | publishers | keygen | rotate-key | mcp | posture | version") os.Exit(2) } switch os.Args[1] { @@ -62,6 +63,8 @@ func main() { os.Exit(cmdServe(os.Args[2:])) case "connect": os.Exit(cmdConnect(os.Args[2:])) + case "publishers": + os.Exit(cmdPublishers(os.Args[2:], os.Stdout, os.Stderr)) case "keygen": os.Exit(cmdKeygen(os.Args[2:])) case "rotate-key": diff --git a/go/operation_descriptors.go b/go/operation_descriptors.go index c9e80b4..ed10702 100644 --- a/go/operation_descriptors.go +++ b/go/operation_descriptors.go @@ -227,6 +227,11 @@ func connectTaxonomyOperations(ctx context.Context, req connectRequest, file *co return out, err } out.written = req.config + if req.afterWrite != nil { + if err := req.afterWrite(file); err != nil { + return out, err + } + } written.operationDescriptors = pins if previous != nil { out.after = restartLines(*previous, written) diff --git a/go/publisher.go b/go/publisher.go new file mode 100644 index 0000000..79c8384 --- /dev/null +++ b/go/publisher.go @@ -0,0 +1,976 @@ +package main + +// Publisher trust for customer MCP servers (gateway #357; spec RFC 0030 §5 +// rule 7, a Draft). A customer binding may declare who publishes its server: +// a package in a registry, a source repository, or a domain. At +// registration (connect, a recapture with --replace included) and on the +// operator's schedule (gateway publishers --check), the gateway reads that +// publisher's liveness from public sources and keeps what it read beside +// the engine configuration (.publishers.json): +// maintained, unmaintained or unknown, with when. A lapsed or archived +// publisher is reported as publisher-unmaintained, and the operator's +// policy may make that a refusal. A registration whose name closely matches +// one of the integrations the gateway ships, under another publisher, is +// flagged name-lookalike. Neither the record nor its check grants anything: +// a customer binding is held to every rule it was held to before. + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +const ( + publisherMaintained = "maintained" + publisherUnmaintained = "unmaintained" + // unverifiable is a publisher the public source does not show: a + // package or a repository that is private or unpublished, a domain no + // RDAP service answers for. It is reported, and never refused: only + // evidence of a lapse is unmaintained. + publisherUnverifiable = "unverifiable" + publisherUnknown = "unknown" + publisherUndeclared = "undeclared" + + publisherPolicyReport = "report" + publisherPolicyRefuse = "refuse" + + publishersSuffix = ".publishers.json" + publisherReadBytes = 8 << 20 + publisherTimeout = 15 * time.Second + publisherTimeForm = "2006-01-02T15:04:05.000Z" + maxPublishersBytes = 1 << 20 +) + +// publisherDecl is the publisher a customer binding declares. +type publisherDecl struct { + kind, name string +} + +var ( + npmName = regexp.MustCompile(`^(@[a-z0-9][a-z0-9._~-]{0,213}/)?[a-z0-9][a-z0-9._~-]{0,213}$`) + githubRepo = regexp.MustCompile(`^github\.com/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/([A-Za-z0-9._-]{1,100})$`) + domainName = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$`) +) + +// parsePublisher holds a binding's publisher member to its form: +// {"kind": "registry", "name": "npm:"}, {"kind": "repository", +// "name": "github.com//"} or {"kind": "domain", "name": +// ""}. The registries and repository hosts named are the ones +// whose liveness the gateway can read. +func parsePublisher(v value) (*publisherDecl, error) { + obj, err := requireObject(v, "publisher") + if err != nil { + return nil, err + } + if err := exactlyMembers(obj, map[string]bool{"kind": true, "name": true}, "publisher"); err != nil { + return nil, err + } + kind, err := requireString(obj, "kind") + if err != nil { + return nil, err + } + name, err := requireString(obj, "name") + if err != nil { + return nil, err + } + switch kind { + case "registry": + pkg, ok := strings.CutPrefix(name, "npm:") + if !ok || len(pkg) > 214 || !npmName.MatchString(pkg) { + return nil, errors.New(`publisher: a registry publisher is "npm:"`) + } + case "repository": + m := githubRepo.FindStringSubmatch(name) + if m == nil || m[2] == "." || m[2] == ".." || strings.HasSuffix(m[1], "-") { + return nil, errors.New(`publisher: a repository publisher is "github.com//"`) + } + case "domain": + if len(name) > 253 || !domainName.MatchString(name) { + return nil, errors.New("publisher: a domain publisher is a lower-case domain name") + } + if err := registrableDomain(name); err != nil { + return nil, err + } + default: + return nil, errors.New(`publisher: kind is "registry", "repository" or "domain"`) + } + return &publisherDecl{kind, name}, nil +} + +// sharedPlatforms are domains under which many unrelated parties are given a +// host. The platform's registration says nothing of who publishes a host +// under it, so neither the platform's domain nor a host under it is a +// domain publisher. +var sharedPlatforms = []string{ + "vercel.app", "now.sh", "netlify.app", "netlify.com", "github.io", "gitlab.io", "herokuapp.com", "pages.dev", + "workers.dev", "web.app", "firebaseapp.com", "appspot.com", "run.app", "azurewebsites.net", "azurestaticapps.net", + "cloudfront.net", "amazonaws.com", "onrender.com", "fly.dev", "railway.app", "glitch.me", "repl.co", "replit.app", + "ngrok.io", "ngrok-free.app", "ngrok.app", "deno.dev", "surge.sh", "modal.run", "hf.space", "streamlit.app", +} + +// multiLabelSuffixes are public suffixes of two labels, under which the +// registrable domain has three. The core reads no public-suffix list, so +// this is a fixed, partial list: a domain under a two-label suffix it does +// not name is refused (its third label is taken for a host), and is +// declared by its repository or package instead. +var multiLabelSuffixes = map[string]bool{ + "nhs.uk": true, "police.uk": true, "sch.uk": true, "mod.uk": true, + "com.es": true, "org.es": true, "nom.es": true, "edu.es": true, "gob.es": true, + "ab.ca": true, "bc.ca": true, "mb.ca": true, "nb.ca": true, "nf.ca": true, "nl.ca": true, "ns.ca": true, "nt.ca": true, + "nu.ca": true, "on.ca": true, "pe.ca": true, "qc.ca": true, "sk.ca": true, "yk.ca": true, + "co.uk": true, "org.uk": true, "ac.uk": true, "gov.uk": true, "ltd.uk": true, "plc.uk": true, "me.uk": true, "net.uk": true, + "com.au": true, "net.au": true, "org.au": true, "edu.au": true, "gov.au": true, "co.nz": true, "org.nz": true, "net.nz": true, + "co.jp": true, "ne.jp": true, "or.jp": true, "ac.jp": true, "com.br": true, "net.br": true, "org.br": true, + "com.cn": true, "net.cn": true, "org.cn": true, "com.mx": true, "org.mx": true, "co.in": true, "net.in": true, "org.in": true, + "co.za": true, "org.za": true, "com.sg": true, "com.hk": true, "com.tw": true, "co.kr": true, "or.kr": true, + "com.tr": true, "com.ar": true, "co.il": true, "com.my": true, "com.ph": true, "com.vn": true, "com.pl": true, "co.id": true, +} + +// registrableDomain refuses a domain publisher that is not itself a +// registrable domain: a host under one, a public suffix, or a shared +// platform or a host on it. +func registrableDomain(name string) error { + for _, platform := range sharedPlatforms { + if name == platform || strings.HasSuffix(name, "."+platform) { + return fmt.Errorf("publisher: %s is a shared platform's domain, under which anyone may be given a host; declare the server's repository or package as its publisher instead", platform) + } + } + labels := strings.Split(name, ".") + want := 2 + if multiLabelSuffixes[strings.Join(labels[len(labels)-2:], ".")] { + want = 3 + } + switch { + case len(labels) < want: + return fmt.Errorf("publisher: %s is a public suffix, not a registrable domain", name) + case len(labels) > want && want == 3: + // The tail is a listed suffix: the registrable domain is known. + return fmt.Errorf("publisher: a domain publisher is the registrable domain, not a host under it: declare %q", strings.Join(labels[len(labels)-3:], ".")) + case len(labels) > want: + // The gateway cannot tell a host under a registrable domain from a + // registrable domain under a public suffix it does not list, and so + // names no domain to declare: either might be another's. + return fmt.Errorf("publisher: %s cannot be verified here as a registrable domain: it is a host, or it is under a public suffix the gateway does not list; declare the server's repository or package as its publisher, or none", name) + } + return nil +} + +// publisherRecord is what the gateway read of one platform's publisher. +// It names the binding it was read for, so a record of another binding is +// not taken for this one. +type publisherRecord struct { + Binding string `json:"binding"` + Kind string `json:"kind"` + Name string `json:"name"` + State string `json:"state"` + CheckedAt string `json:"checkedAt"` + Reason string `json:"reason"` + Lookalike string `json:"lookalike,omitempty"` + // LastAttempt, AttemptState and AttemptReason are a later reading that + // was inconclusive (unknown, or unverifiable) and so did not replace + // the conclusive one above (mergeReading). + LastAttempt string `json:"lastAttempt,omitempty"` + AttemptState string `json:"attemptState,omitempty"` + AttemptReason string `json:"attemptReason,omitempty"` +} + +// conclusive is whether a state is evidence: maintained, or unmaintained. +func conclusive(state string) bool { + return state == publisherMaintained || state == publisherUnmaintained +} + +// mergeReading is the record a reading leaves. An inconclusive reading -- +// a read that failed, timed out or was refused, an answer that could not +// be read, or a publisher the public source no longer shows -- never lifts +// a conclusive one of the same declared publisher: the last conclusive +// state and its time are kept, and the failed read is recorded beside it, +// under the reading's binding. A conclusive reading, a first reading, and +// a reading of another declared publisher replace the record. +func mergeReading(prev publisherRecord, had bool, reading publisherRecord) publisherRecord { + if conclusive(reading.State) || !had || !conclusive(prev.State) || reading.Kind == "" || prev.Kind != reading.Kind || prev.Name != reading.Name { + return reading + } + kept := prev + kept.Binding, kept.Lookalike = reading.Binding, reading.Lookalike + kept.LastAttempt, kept.AttemptState, kept.AttemptReason = reading.CheckedAt, reading.State, reading.Reason + return kept +} + +// inconclusiveRead is whether a reading was inconclusive where it should +// not be: a read that failed (unknown), or one that kept a conclusive +// record (mergeReading). A publisher that was never shown (unverifiable, +// with nothing conclusive before it) is its standing state, not a failure. +func inconclusiveRead(reading, merged publisherRecord) bool { + return reading.State == publisherUnknown || merged.LastAttempt != "" && merged.LastAttempt == reading.CheckedAt +} + +// publisherFile is the record of every customer platform's publisher, and +// the operator's policy: report, the default, or refuse, under which an +// unmaintained publisher refuses its registration, its acquisitions and its +// actions. +type publisherFile struct { + Version int `json:"version"` + Policy string `json:"policy"` + Publishers map[string]publisherRecord `json:"publishers"` +} + +func publishersPath(config string) string { return config + publishersSuffix } + +// readPublishers is the record beside a configuration; an absent one is +// empty, under the report policy. +func readPublishers(path string) (publisherFile, error) { + f := publisherFile{Version: 1, Policy: publisherPolicyReport, Publishers: map[string]publisherRecord{}} + data, err := readBounded(path, maxPublishersBytes) + if errors.Is(err, os.ErrNotExist) { + return f, nil + } + if err != nil { + return f, err + } + d := json.NewDecoder(strings.NewReader(string(data))) + d.DisallowUnknownFields() + if d.Decode(&f) != nil || f.Version != 1 || f.Policy != publisherPolicyReport && f.Policy != publisherPolicyRefuse || f.Publishers == nil { + return f, fmt.Errorf("%s is not a publisher record", filepath.Base(path)) + } + return f, nil +} + +// writePublishers replaces the record beside the configuration, under the +// configuration's lock, which the caller holds (withConfigLock, or connect +// through afterWrite): every read-modify-write of the record is made under +// it, so no writer's change is lost to another's. The record is given the +// configuration's own mode and owner, through the descriptor, as connect +// gives a replaced configuration (configFile.replace): a root connect, or a +// root-run gateway publishers, over a configuration the signer owns leaves +// a record the signer can read. It is written to a file made beside it and +// renamed over it, and the directory synced. +func (f *configFile) writePublishers(pf publisherFile) error { + data, err := json.MarshalIndent(pf, "", " ") + if err != nil { + return err + } + name := f.base + publishersSuffix + var nonce [8]byte + if _, err := rand.Read(nonce[:]); err != nil { + return err + } + tmp := "." + name + ".tmp-" + hex.EncodeToString(nonce[:]) + file, err := f.dir.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, f.mode) + if err != nil { + return err + } + defer f.dir.Remove(tmp) + fail := func(err error) error { + file.Close() + return err + } + if _, err := file.Write(append(data, '\n')); err != nil { + return fail(err) + } + if err := file.Chmod(f.mode); err != nil { + return fail(err) + } + if err := keepOwner(file, f.owner); err != nil { + return fail(err) + } + if err := syncFile(file); err != nil { + return fail(err) + } + if err := file.Close(); err != nil { + return err + } + if err := f.dir.Rename(tmp, name); err != nil { + return err + } + return f.syncDir(".") +} + +// withConfigLock runs fn holding the configuration's lock, the one connect +// holds (lockBeside): a connect that holds it is waited for up to ten +// seconds, then refused. +func withConfigLock(config string, fn func(*configFile) error) error { + deadline := time.Now().Add(10 * time.Second) + for { + f, err := openConfigFile(config) + if err == nil { + defer f.close() + return fn(f) + } + if !strings.Contains(err.Error(), "another connect holds") || time.Now().After(deadline) { + return err + } + time.Sleep(50 * time.Millisecond) + } +} + +// publisherChecker reads a publisher's liveness from the npm registry, +// GitHub's repository API and RDAP; a test points it elsewhere. +type publisherChecker struct { + client *http.Client + npm, github string + // bootstrap is the IANA RDAP bootstrap for DNS: which RDAP service + // answers for each top-level domain. + bootstrap string + now func() time.Time +} + +func defaultPublisherChecker() publisherChecker { + t := http.DefaultTransport.(*http.Transport).Clone() + t.Proxy = http.ProxyFromEnvironment + t.ResponseHeaderTimeout = publisherTimeout + t.TLSHandshakeTimeout = publisherTimeout + t.DialContext = (&net.Dialer{Timeout: publisherTimeout}).DialContext + return publisherChecker{ + client: &http.Client{Transport: t, Timeout: publisherTimeout}, + npm: "https://registry.npmjs.org/", github: "https://api.github.com/repos/", bootstrap: "https://data.iana.org/rdap/dns.json", + now: time.Now, + } +} + +// The reasons, fixed sentences that name no path, account or token. +const ( + reasonPackageDeprecated = "the package's latest version is deprecated in its registry" + reasonPackageMissing = "the registry shows no public package of that name: it may be private, or not published" + reasonPackageLive = "the registry lists the package, and its latest version is not deprecated" + reasonRepoArchived = "the repository is archived or disabled" + reasonRepoMissing = "no public repository of that name was found: it may be private, or not there" + reasonRepoLive = "the repository is neither archived nor disabled" + reasonDomainExpired = "the domain's registration has expired" + reasonDomainPending = "the domain is in its redemption period or pending deletion" + reasonDomainNoService = "no RDAP service is published for the domain's top-level domain" + reasonDomainNotRegistered = "the registry's RDAP service answers that the domain is not registered: it lapsed and was deleted, or was never registered" + reasonDomainLive = "the domain's registration is current" + reasonUnreadable = "the publisher's liveness could not be read" + reasonUndeclared = "the binding declares no publisher" +) + +// rdapService is the HTTPS base of the RDAP service the IANA bootstrap +// names for a top-level domain, and whether it names one. +func (c publisherChecker) rdapService(ctx context.Context, tld string) (string, bool, error) { + status, body, err := c.get(ctx, c.bootstrap, "application/json") + if err != nil || status != 200 { + return "", false, errors.New("the RDAP bootstrap could not be read") + } + var boot struct { + Services [][][]string `json:"services"` + } + if json.Unmarshal(body, &boot) != nil || len(boot.Services) == 0 { + return "", false, errors.New("the RDAP bootstrap could not be read") + } + for _, service := range boot.Services { + if len(service) != 2 { + continue + } + for _, entry := range service[0] { + if !strings.EqualFold(entry, tld) { + continue + } + for _, u := range service[1] { + if strings.HasPrefix(u, "https://") { + if !strings.HasSuffix(u, "/") { + u += "/" + } + return u, true, nil + } + } + // The top-level domain is listed, and no service of it can be + // read over HTTPS: inconclusive, never unverifiable. + return "", false, errors.New("the RDAP bootstrap names no HTTPS service for the top-level domain") + } + } + return "", false, nil +} + +// get is one bounded GET; the status, and the body of a 200. +func (c publisherChecker) get(ctx context.Context, endpoint, accept string) (int, []byte, error) { + ctx, cancel := context.WithTimeout(ctx, publisherTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return 0, nil, err + } + req.Header.Set("Accept", accept) + req.Header.Set("User-Agent", "judgment-pack-gateway publisher check") + resp, err := c.client.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return resp.StatusCode, nil, nil + } + body, err := io.ReadAll(io.LimitReader(resp.Body, publisherReadBytes+1)) + if err != nil || len(body) > publisherReadBytes { + return 0, nil, errors.New("unreadable") + } + return 200, body, nil +} + +// check reads a declared publisher's liveness: its state, and why. +func (c publisherChecker) check(ctx context.Context, d *publisherDecl) (string, string) { + if d == nil { + return publisherUndeclared, reasonUndeclared + } + switch d.kind { + case "registry": + pkg := strings.TrimPrefix(d.name, "npm:") + status, body, err := c.get(ctx, c.npm+strings.Replace(pkg, "/", "%2f", 1), "application/vnd.npm.install-v1+json") + switch { + case err != nil: + case status == 404: + return publisherUnverifiable, reasonPackageMissing + case status == 200: + var doc struct { + Tags map[string]string `json:"dist-tags"` + Versions map[string]struct { + Deprecated any `json:"deprecated"` + } `json:"versions"` + } + if json.Unmarshal(body, &doc) != nil { + break + } + latest, ok := doc.Versions[doc.Tags["latest"]] + if !ok { + break + } + if s, isText := latest.Deprecated.(string); isText && s != "" || latest.Deprecated == true { + return publisherUnmaintained, reasonPackageDeprecated + } + return publisherMaintained, reasonPackageLive + } + case "repository": + status, body, err := c.get(ctx, c.github+strings.TrimPrefix(d.name, "github.com/"), "application/vnd.github+json") + switch { + case err != nil: + case status == 404: + return publisherUnverifiable, reasonRepoMissing + case status == 200: + var repo struct { + Archived *bool `json:"archived"` + Disabled bool `json:"disabled"` + } + if json.Unmarshal(body, &repo) != nil || repo.Archived == nil { + break + } + if *repo.Archived || repo.Disabled { + return publisherUnmaintained, reasonRepoArchived + } + return publisherMaintained, reasonRepoLive + } + case "domain": + // The registry's own RDAP service is asked, as the IANA bootstrap + // names it for the top-level domain. A top-level domain with no + // service is unverifiable; the registry's 404 for the registrable + // domain declared is evidence of a lapse: the name is free to be + // registered by another. + name := d.name + service, known, err := c.rdapService(ctx, name[strings.LastIndex(name, ".")+1:]) + if err != nil { + break + } + if !known { + return publisherUnverifiable, reasonDomainNoService + } + status, body, err := c.get(ctx, service+"domain/"+name, "application/rdap+json") + switch { + case err != nil: + case status == 404: + return publisherUnmaintained, reasonDomainNotRegistered + case status == 200: + var reply struct { + Name string `json:"ldhName"` + Status []string `json:"status"` + Events []struct { + Action string `json:"eventAction"` + Date string `json:"eventDate"` + } `json:"events"` + } + if json.Unmarshal(body, &reply) != nil || !strings.EqualFold(reply.Name, name) { + break + } + for _, s := range reply.Status { + switch strings.ToLower(s) { + case "redemption period", "pending delete": + return publisherUnmaintained, reasonDomainPending + } + } + for _, e := range reply.Events { + if e.Action != "expiration" { + continue + } + t, err := time.Parse(time.RFC3339, e.Date) + if err != nil { + return publisherUnknown, reasonUnreadable + } + if !c.now().Before(t) { + return publisherUnmaintained, reasonDomainExpired + } + } + return publisherMaintained, reasonDomainLive + } + } + return publisherUnknown, reasonUnreadable +} + +// knownIntegration is an integration the gateway ships, by its name, and +// the publishers it is published under: a domain (and its subdomains), a +// repository owner on GitHub, or an npm scope. +type knownIntegration struct { + name string + publishers []string +} + +// knownIntegrations are the gateway's own: the catalog's bindings and the +// connection providers. +var knownIntegrations = []knownIntegration{ + {"azure-devops", []string{"domain:microsoft.com", "domain:azure.com", "domain:visualstudio.com", "repository:microsoft", "registry:@azure-devops/"}}, + {"databricks", []string{"domain:databricks.com", "repository:databricks", "registry:@databricks/"}}, + {"postgres", []string{"domain:postgresql.org"}}, + {"google-drive", []string{"domain:google.com", "domain:googleapis.com", "repository:google", "repository:googleapis"}}, + {"gmail", []string{"domain:google.com", "domain:googleapis.com", "repository:google", "repository:googleapis"}}, + {"notion", []string{"domain:notion.so", "domain:notion.com", "repository:makenotion", "registry:@notionhq/"}}, + {"obsidian", []string{"domain:obsidian.md", "repository:obsidianmd"}}, + {"aws-s3", []string{"domain:amazon.com", "domain:amazonaws.com", "repository:aws", "repository:awslabs", "registry:@aws-sdk/"}}, +} + +// publishedBy reports whether a declared publisher is one of a known +// integration's. +func publishedBy(d *publisherDecl, k knownIntegration) bool { + if d == nil { + return false + } + for _, p := range k.publishers { + kind, value, _ := strings.Cut(p, ":") + switch { + case kind == "domain" && d.kind == "domain" && (d.name == value || strings.HasSuffix(d.name, "."+value)): + return true + case kind == "repository" && d.kind == "repository" && strings.EqualFold(strings.Split(d.name, "/")[1], value): + return true + case kind == "registry" && d.kind == "registry" && strings.HasPrefix(strings.TrimPrefix(d.name, "npm:"), value): + return true + } + } + return false +} + +// homoglyphs are lower-case letters of the Cyrillic and Greek scripts, and +// a few Latin ones, that are drawn like a Latin letter, with that letter. +var homoglyphs = map[rune]rune{ + // Cyrillic + 'а': 'a', 'в': 'b', 'е': 'e', 'ё': 'e', 'һ': 'h', 'н': 'h', 'і': 'i', 'ї': 'i', 'ј': 'j', 'к': 'k', + 'ӏ': 'l', 'м': 'm', 'о': 'o', 'р': 'p', 'ԛ': 'q', 'г': 'r', 'ѕ': 's', 'т': 't', 'ц': 'u', 'ѵ': 'v', + 'ԝ': 'w', 'х': 'x', 'у': 'y', 'ԁ': 'd', 'ɡ': 'g', 'ԍ': 'g', 'с': 'c', 'ѡ': 'w', + // Greek + 'α': 'a', 'β': 'b', 'ε': 'e', 'η': 'n', 'ι': 'i', 'κ': 'k', 'μ': 'u', 'ν': 'v', 'ο': 'o', 'ρ': 'p', + 'τ': 't', 'υ': 'u', 'χ': 'x', 'γ': 'y', 'ζ': 'z', 'ϲ': 'c', 'ϳ': 'j', + // Latin look-alikes + 'ı': 'i', 'ł': 'l', 'ℓ': 'l', 'ß': 's', 'ɑ': 'a', +} + +// A decomposed spelling (a letter, then a combining mark) needs no fold: the +// mark is not a letter or digit, and confusable drops it. +// accented is each Latin letter with a diacritic, from the Latin-1 +// Supplement and Latin Extended-A blocks, by its base letter: what a +// Unicode decomposition would leave once its combining marks were dropped, +// which the core's standard library cannot compute. +var accented = map[rune]rune{} + +func init() { + for base, letters := range map[rune]string{ + 'a': "àáâãäåāăą", 'c': "çćĉċč", 'd': "ďđ", 'e': "èéêëēĕėęě", 'g': "ĝğġģ", 'h': "ĥħ", + 'i': "ìíîïĩīĭįİ", 'j': "ĵ", 'k': "ķ", 'l': "ĺļľŀ", 'n': "ñńņňʼn", 'o': "òóôõöøōŏő", + 'r': "ŕŗř", 's': "śŝşš", 't': "ţťŧ", 'u': "ùúûüũūŭůűų", 'w': "ŵ", 'y': "ýÿŷ", 'z': "źżž", + } { + for _, r := range letters { + accented[r] = base + } + } +} + +// smallCapitals and armenian are letters drawn like a Latin letter. +var smallCapitals = map[rune]rune{ + 'ᴀ': 'a', 'ʙ': 'b', 'ᴄ': 'c', 'ᴅ': 'd', 'ᴇ': 'e', 'ꜰ': 'f', 'ɢ': 'g', 'ʜ': 'h', 'ɪ': 'i', 'ᴊ': 'j', 'ᴋ': 'k', + 'ʟ': 'l', 'ᴍ': 'm', 'ɴ': 'n', 'ᴏ': 'o', 'ᴘ': 'p', 'ǫ': 'q', 'ʀ': 'r', 'ꜱ': 's', 'ᴛ': 't', 'ᴜ': 'u', 'ᴠ': 'v', + 'ᴡ': 'w', 'ʏ': 'y', 'ᴢ': 'z', + // Armenian + 'օ': 'o', 'ս': 'u', 'հ': 'h', 'ո': 'n', 'ց': 'g', 'զ': 'q', 'ա': 'w', +} + +// foldLetter is the Latin letter a letter with a diacritic, a small +// capital, an Armenian look-alike or a mathematical alphanumeric symbol +// (U+1D400 to U+1D7FF) is drawn as. +func foldLetter(r rune) (rune, bool) { + if base, ok := accented[r]; ok { + return base, true + } + if latin, ok := smallCapitals[r]; ok { + return latin, true + } + switch { + case r >= 0x1D400 && r <= 0x1D6A3: + // Thirteen alphabets of 52 letters, capitals then small letters. + i := (r - 0x1D400) % 52 + if i >= 26 { + i -= 26 + } + return 'a' + i, true + case r >= 0x1D7CE && r <= 0x1D7FF: + return '0' + (r-0x1D7CE)%10, true + } + return 0, false +} + +// capitalHomoglyphs are Cyrillic and Greek capitals drawn like a Latin +// capital, with that letter. +var capitalHomoglyphs = map[rune]rune{ + 'А': 'a', 'В': 'b', 'Е': 'e', 'К': 'k', 'М': 'm', 'Н': 'h', 'О': 'o', 'Р': 'p', 'С': 'c', 'Т': 't', + 'Х': 'x', 'І': 'i', 'Ј': 'j', 'Ѕ': 's', 'Ү': 'y', + 'Α': 'a', 'Β': 'b', 'Ε': 'e', 'Ζ': 'z', 'Η': 'h', 'Ι': 'i', 'Κ': 'k', 'Μ': 'm', 'Ν': 'n', 'Ο': 'o', + 'Ρ': 'p', 'Τ': 't', 'Υ': 'y', 'Χ': 'x', +} + +// confusable folds the characters a name is commonly spoofed with, and +// drops everything but letters and digits. +func confusable(s string) string { + // Capitals first: a capital of another script can look like a Latin + // capital its own lower case does not look like (Greek Ν, ν). + s = strings.Map(func(r rune) rune { + if latin, ok := capitalHomoglyphs[r]; ok { + return latin + } + return r + }, s) + s = strings.ToLower(s) + // Letters of other scripts that a Latin letter is imitated with, and + // the fullwidth forms, are taken for the Latin letter first. + s = strings.Map(func(r rune) rune { + if r >= 0xFF01 && r <= 0xFF5E { + r -= 0xFEE0 + if r >= 'A' && r <= 'Z' { + r += 'a' - 'A' + } + return r + } + if latin, ok := homoglyphs[r]; ok { + return latin + } + if latin, ok := foldLetter(r); ok { + return latin + } + return r + }, s) + s = strings.NewReplacer("rn", "m", "vv", "w", "0", "o", "1", "l", "3", "e", "5", "s", "@", "a").Replace(s) + var b strings.Builder + for _, r := range s { + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + b.WriteRune(r) + } + } + return b.String() +} + +// editDistance is the optimal string alignment distance: insertions, +// deletions, substitutions and adjacent transpositions. +func editDistance(a, b string) int { + ra, rb := []rune(a), []rune(b) + d := make([][]int, len(ra)+1) + for i := range d { + d[i] = make([]int, len(rb)+1) + d[i][0] = i + } + for j := range d[0] { + d[0][j] = j + } + for i := 1; i <= len(ra); i++ { + for j := 1; j <= len(rb); j++ { + cost := 1 + if ra[i-1] == rb[j-1] { + cost = 0 + } + d[i][j] = min(d[i-1][j]+1, d[i][j-1]+1, d[i-1][j-1]+cost) + if i > 1 && j > 1 && ra[i-1] == rb[j-2] && ra[i-2] == rb[j-1] { + d[i][j] = min(d[i][j], d[i-2][j-2]+1) + } + } + } + return d[len(ra)][len(rb)] +} + +// lookalike is the known integration a registration's names closely match +// under another publisher, or "". A name matches when, folded, it equals +// the integration's, is within one edit of it (two for a name of ten +// characters or more), or contains it. +func lookalike(names []string, d *publisherDecl) string { + for _, k := range knownIntegrations { + known := confusable(k.name) + for _, name := range names { + n := confusable(name) + if n == "" { + continue + } + bound := 1 + if len(known) >= 10 { + bound = 2 + } + close := n == known || editDistance(n, known) <= bound || len(known) >= 5 && strings.Contains(n, known) + if close && !publishedBy(d, k) { + return k.name + } + } + } + return "" +} + +// publisherLines are what a registration shows of its publisher, with its +// approval: the state read, and each flag. +func publisherLines(platform string, r publisherRecord) []string { + what := "none declared" + if r.Kind != "" { + what = r.Kind + " " + r.Name + } + lines := []string{fmt.Sprintf("%s: publisher %s: %s at %s: %s", platform, what, r.State, r.CheckedAt, r.Reason)} + if r.LastAttempt != "" { + lines = append(lines, fmt.Sprintf("%s: publisher-read-inconclusive: %s at %s: %s; the last conclusive reading, %s at %s, is kept", platform, r.AttemptState, r.LastAttempt, r.AttemptReason, r.State, r.CheckedAt)) + } + switch r.State { + case publisherUnmaintained: + lines = append(lines, fmt.Sprintf("%s: publisher-unmaintained: %s", platform, r.Reason)) + case publisherUnverifiable: + lines = append(lines, fmt.Sprintf("%s: publisher-unverifiable: %s", platform, r.Reason)) + } + if r.Lookalike != "" { + lines = append(lines, fmt.Sprintf("%s: name-lookalike: the name closely matches the integration %s, which another publisher publishes", platform, r.Lookalike)) + } + return lines +} + +// readPublisher reads one customer binding's publisher now. +func (c publisherChecker) read(ctx context.Context, platform, ref string, b binding) publisherRecord { + state, reason := c.check(ctx, b.publisher) + r := publisherRecord{Binding: ref, State: state, Reason: reason, CheckedAt: c.now().UTC().Format(publisherTimeForm)} + if b.publisher != nil { + r.Kind, r.Name = b.publisher.kind, b.publisher.name + } + r.Lookalike = lookalike([]string{platform, b.platform}, b.publisher) + return r +} + +// errPublisherUnmaintained is the refusal the refuse policy makes. +func errPublisherUnmaintained(platform string) error { + return fmt.Errorf("publisher-unmaintained: platform %s: the operator's publisher policy refuses a server whose publisher is unmaintained", platform) +} + +// publisherRefusal is the refusal of a platform's call under the +// operator's policy: refuse, and a record of this binding saying its +// publisher is unmaintained. A record that cannot be read refuses under +// nothing it does not say; one of another binding is not this one's. +func publisherRefusal(path, platform, ref string) error { + if path == "" { + return nil + } + f, err := readPublishers(path) + if err != nil { + // The reason names no path: it is answered to /acquire and /act. + return fmt.Errorf("publisher-record-unreadable: platform %s: the publisher record could not be read", platform) + } + r, ok := f.Publishers[platform] + if f.Policy == publisherPolicyRefuse && ok && r.Binding == ref && r.State == publisherUnmaintained { + return errPublisherUnmaintained(platform) + } + return nil +} + +// connectPublisher is connect with its publisher read: a customer +// binding's publisher is read before anything is run, shown with the +// approval, refused under the refuse policy where it is unmaintained, and +// recorded once the configuration is written. +func connectPublisher(ctx context.Context, req connectRequest, host engineHost, check func(context.Context, sourceSpec) ([]byte, error), checker publisherChecker) (connectOutcome, error) { + if req.preview != "" { + return connect(ctx, req, host, check) + } + data, err := readBounded(req.config, maxEngineConfigBytes) + if err != nil { + return connect(ctx, req, host, check) + } + cfg, err := parseEngineConfig(data) + if err != nil { + return connect(ctx, req, host, check) + } + ref, b, err := pinBinding(cfg.catalog, req.binding) + if err != nil || !b.customer { + return connect(ctx, req, host, check) + } + path := publishersPath(req.config) + f, err := readPublishers(path) + if err != nil { + return connectOutcome{}, err + } + reading := checker.read(ctx, req.platform, ref, b) + prev, had := f.Publishers[req.platform] + record := mergeReading(prev, had, reading) + lines := publisherLines(req.platform, record) + if f.Policy == publisherPolicyRefuse && record.State == publisherUnmaintained { + return connectOutcome{statements: lines}, errPublisherUnmaintained(req.platform) + } + // The record is written under the lock connect holds, once the + // configuration is in place, read again there so that no other + // writer's change is lost. + req.afterWrite = func(held *configFile) error { + f, err := readPublishers(path) + if err != nil { + return err + } + prev, had := f.Publishers[req.platform] + f.Publishers[req.platform] = mergeReading(prev, had, reading) + if err := held.writePublishers(f); err != nil { + return fmt.Errorf("the configuration was written, and the publisher record was not: %v", err) + } + return nil + } + out, err := connect(ctx, req, host, check) + out.statements = append(out.statements, lines...) + return out, err +} + +const publishersUsage = "usage: gateway publishers --config [--check] [--policy report|refuse]" + +// cmdPublishers is the operator's view of the publisher record: printed as +// it stands, read again for every customer platform with --check (the +// schedule is the operator's), and its policy set with --policy. +func cmdPublishers(args []string, stdout, stderr io.Writer) int { + return runPublishers(context.Background(), args, stdout, stderr, defaultPublisherChecker()) +} + +func runPublishers(ctx context.Context, args []string, stdout, stderr io.Writer, checker publisherChecker) int { + var config, policy string + recheck := false + for len(args) > 0 { + a := args[0] + args = args[1:] + switch { + case a == "--check" && !recheck: + recheck = true + case (a == "--config" || a == "--policy") && len(args) > 0: + target := &config + if a == "--policy" { + target = &policy + } + if *target != "" { + fmt.Fprintln(stderr, publishersUsage) + return 2 + } + *target, args = args[0], args[1:] + default: + fmt.Fprintln(stderr, publishersUsage) + return 2 + } + } + if config == "" || policy != "" && policy != publisherPolicyReport && policy != publisherPolicyRefuse { + fmt.Fprintln(stderr, publishersUsage) + return 2 + } + path := publishersPath(config) + f, err := readPublishers(path) + if err != nil { + fmt.Fprintln(stderr, "publishers:", printable(err.Error())) + return 1 + } + // The liveness reads are made without the lock, which a connect would + // otherwise wait for; what they read is merged into the record as it + // stands under the lock, the policy included, so a policy set or a + // record written meanwhile is kept. + checked := map[string]publisherRecord{} + if recheck { + data, err := readBounded(config, maxEngineConfigBytes) + if err != nil { + fmt.Fprintln(stderr, "publishers:", printable(err.Error())) + return 1 + } + cfg, err := parseEngineConfig(data) + if err != nil { + fmt.Fprintln(stderr, "publishers:", printable(err.Error())) + return 1 + } + for _, p := range cfg.platforms { + b, err := loadBinding(cfg.catalog, p.binding) + if err != nil { + fmt.Fprintln(stderr, "publishers:", printable(err.Error())) + return 1 + } + if !b.customer { + continue + } + checked[p.name] = checker.read(ctx, p.name, p.binding, b) + } + } + var inconclusive []string + if recheck || policy != "" { + err := withConfigLock(config, func(held *configFile) error { + current, err := readPublishers(path) + if err != nil { + return err + } + if policy != "" { + current.Policy = policy + } + for name, r := range checked { + // A record written since this re-check read the record -- + // by a connect or a recapture, of another binding or at + // another time -- is kept as written: this reading is of + // what stood before it. + before, had := f.Publishers[name] + now, has := current.Publishers[name] + if has && (!had || now.Binding != before.Binding || now.CheckedAt != before.CheckedAt || now.LastAttempt != before.LastAttempt || now.Binding != r.Binding) { + continue + } + current.Publishers[name] = mergeReading(now, has, r) + if inconclusiveRead(r, current.Publishers[name]) { + inconclusive = append(inconclusive, name) + } + } + if err := held.writePublishers(current); err != nil { + return err + } + f = current + return nil + }) + if err != nil { + fmt.Fprintln(stderr, "publishers:", printable(err.Error())) + return 1 + } + } + names := make([]string, 0, len(f.Publishers)) + for name := range f.Publishers { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + for _, line := range publisherLines(name, f.Publishers[name]) { + fmt.Fprintln(stdout, printable(line)) + } + } + fmt.Fprintf(stdout, "policy: %s\n", f.Policy) + // A re-check whose reading of a publisher was inconclusive says so, and + // exits 3: the record keeps the last conclusive reading, and the + // operator's schedule should run the re-check again. + sort.Strings(inconclusive) + for _, name := range inconclusive { + fmt.Fprintf(stderr, "publishers: warning: %s: the publisher's liveness could not be read conclusively; the last conclusive reading, if any, is kept\n", printable(name)) + } + if len(inconclusive) > 0 { + return 3 + } + return 0 +} diff --git a/go/publisher_test.go b/go/publisher_test.go new file mode 100644 index 0000000..84ae49f --- /dev/null +++ b/go/publisher_test.go @@ -0,0 +1,587 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +// Publisher trust for customer MCP servers (#357): the declaration, the +// liveness read, the name-lookalike flag, and the operator's policy. + +var publisherT0 = time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) + +// publisherStandIn answers the registry, repository and RDAP reads from a +// table of path to status and body. +func publisherStandIn(t *testing.T, answers map[string][2]string) publisherChecker { + t.Helper() + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/bootstrap.json" { + // The IANA bootstrap: example and uk have a service, io none. + w.Write([]byte(`{"version":"1.0","services":[[["example","uk"],["http://plain.invalid/","https://` + r.Host + `/rdap"]]]}`)) + return + } + a, ok := answers[r.URL.EscapedPath()] + if !ok { + http.NotFound(w, r) + return + } + switch a[0] { + case "200": + w.Write([]byte(a[1])) + case "404": + http.NotFound(w, r) + default: + http.Error(w, "unavailable", 503) + } + })) + t.Cleanup(server.Close) + return publisherChecker{client: server.Client(), npm: server.URL + "/npm/", github: server.URL + "/gh/", bootstrap: server.URL + "/bootstrap.json", now: func() time.Time { return publisherT0 }} +} + +func TestPublisherDeclarationForm(t *testing.T) { + for _, ok := range []string{ + `{"kind":"registry","name":"npm:@acme/tickets-mcp"}`, `{"kind":"registry","name":"npm:tickets"}`, + `{"kind":"repository","name":"github.com/acme/tickets-mcp"}`, `{"kind":"domain","name":"acme.example"}`, `{"kind":"domain","name":"acme.co.uk"}`, + } { + v, _ := parseJSON([]byte(ok)) + if _, err := parsePublisher(v); err != nil { + t.Errorf("%s: %v", ok, err) + } + } + for _, bad := range []string{ + `{"kind":"registry","name":"pypi:tickets"}`, `{"kind":"registry","name":"npm:Tickets"}`, + `{"kind":"repository","name":"gitlab.com/acme/x"}`, `{"kind":"repository","name":"github.com/acme/.."}`, + `{"kind":"domain","name":"Acme.example"}`, `{"kind":"domain","name":"localhost"}`, + // A host under a registrable domain, a public suffix, a shared + // platform or a host on one (N1). + `{"kind":"domain","name":"tickets.acme.example"}`, `{"kind":"domain","name":"tickets.acme.co.uk"}`, `{"kind":"domain","name":"co.uk"}`, + `{"kind":"domain","name":"tickets-mcp.vercel.app"}`, `{"kind":"domain","name":"vercel.app"}`, `{"kind":"domain","name":"acme.github.io"}`, + `{"kind":"person","name":"x"}`, `{"kind":"domain"}`, `{"kind":"domain","name":"a.example","extra":1}`, `"acme"`, + } { + v, _ := parseJSON([]byte(bad)) + if _, err := parsePublisher(v); err == nil { + t.Errorf("%s was taken", bad) + } + } + customer := `{"bindingVersion":"2","customer":true,"publisher":{"kind":"domain","name":"acme.example"},"platform":"tickets","operations":{"live":{"shape":"mcp","server":{"url":"https://tickets.acme.example/mcp","bearer":"token"},"tools":["query"],"toolTaxonomies":{"query":{"operation":"read","effect":"read-external"}},"licence":"fixture"}}}` + b, err := parseBinding([]byte(customer)) + if err != nil || b.publisher == nil || b.publisher.name != "acme.example" { + t.Fatalf("a customer binding's publisher: %+v %v", b.publisher, err) + } + if _, err := parseBinding([]byte(strings.Replace(customer, `"customer":true,`, "", 1))); err == nil { + t.Fatal("a publisher was taken on a binding that is not a customer's") + } +} + +// A lapsed or archived publisher is unmaintained; a live one maintained; +// one whose liveness cannot be read unknown. +func TestPublisherLiveness(t *testing.T) { + c := publisherStandIn(t, map[string][2]string{ + "/npm/live-pkg": {"200", `{"dist-tags":{"latest":"2.0.0"},"versions":{"1.0.0":{"deprecated":"old"},"2.0.0":{}}}`}, + "/npm/@acme%2fold": {"200", `{"dist-tags":{"latest":"1.0.0"},"versions":{"1.0.0":{"deprecated":"no longer maintained"}}}`}, + "/npm/garbled": {"200", `{"dist-tags":{},"versions":{}}`}, + "/npm/flagged": {"200", `{"dist-tags":{"latest":"3.0.0"},"versions":{"3.0.0":{"deprecated":true}}}`}, + "/rdap/domain/redeemed.example": {"200", `{"ldhName":"redeemed.example","status":["active","Redemption Period"],"events":[]}`}, + "/gh/acme/live": {"200", `{"archived":false,"disabled":false}`}, + "/gh/acme/archived": {"200", `{"archived":true,"disabled":false}`}, + "/gh/acme/silent": {"200", `{"disabled":false}`}, + "/gh/acme/down": {"503", ""}, + "/rdap/domain/co.uk": {"200", `{"ldhName":"co.uk","status":["active"],"events":[]}`}, + "/rdap/domain/acme.example": {"200", `{"ldhName":"acme.example","status":["active"],"events":[{"eventAction":"expiration","eventDate":"2027-01-01T00:00:00Z"}]}`}, + "/rdap/domain/lapsed.example": {"200", `{"ldhName":"lapsed.example","status":["active"],"events":[{"eventAction":"expiration","eventDate":"2026-10-30T12:00:00Z"}]}`}, + "/rdap/domain/pending.example": {"200", `{"ldhName":"pending.example","status":["pending delete"],"events":[]}`}, + "/rdap/domain/other-name.example": {"200", `{"ldhName":"another.example","status":["active"],"events":[]}`}, + }) + for _, k := range []struct { + kind, name, state string + }{ + {"registry", "npm:live-pkg", publisherMaintained}, + {"registry", "npm:@acme/old", publisherUnmaintained}, + {"registry", "npm:gone", publisherUnverifiable}, + {"registry", "npm:flagged", publisherUnmaintained}, + {"registry", "npm:garbled", publisherUnknown}, + {"repository", "github.com/acme/live", publisherMaintained}, + {"repository", "github.com/acme/archived", publisherUnmaintained}, + {"repository", "github.com/acme/missing", publisherUnverifiable}, + {"repository", "github.com/acme/silent", publisherUnknown}, + {"repository", "github.com/acme/down", publisherUnknown}, + {"domain", "acme.example", publisherMaintained}, + {"domain", "lapsed.example", publisherUnmaintained}, + {"domain", "pending.example", publisherUnmaintained}, + {"domain", "unregistered.example", publisherUnmaintained}, + // A lapsed registration under a two-label suffix whose own object + // the registry answers for (N1, N2): not registered, never the + // suffix's object taken for it. + {"domain", "acme.co.uk", publisherUnmaintained}, + {"domain", "tool.io", publisherUnverifiable}, + {"domain", "redeemed.example", publisherUnmaintained}, + {"domain", "other-name.example", publisherUnknown}, + } { + if state, reason := c.check(context.Background(), &publisherDecl{k.kind, k.name}); state != k.state || reason == "" { + t.Errorf("%s %s: %s (%s), not %s", k.kind, k.name, state, reason, k.state) + } + } + if state, _ := c.check(context.Background(), nil); state != publisherUndeclared { + t.Fatalf("no publisher: %s", state) + } +} + +// A name that closely matches a shipped integration's under another +// publisher is flagged; under that integration's own publisher it is not. +func TestNameLookalike(t *testing.T) { + stranger := &publisherDecl{"domain", "tools.stranger.example"} + for _, name := range []string{"databricks", "databrick", "databricsk", "Data-Bricks", "databricks-mcp", "n0tion", "n0t1on", "azure-dev0ps", "gmai1", + "n\u043eti\u043en", "\uff4e\uff4f\uff54\uff49\uff4f\uff4e", "gm\u0430\u0456l", "d\u0430t\u0430br\u0456\u0441ks", "\u043ebsidi\u0430n", "p\u043estgr\u0435s", "\u039d\u039f\u03a4\u0399\u039f\u039d", + "n\u00f6ti\u00f6n", "n\u00f3t\u00edon", "no\u0308tion", "\U0001d427\U0001d428\U0001d42d\U0001d422\U0001d428\U0001d427", "\u0274\u1d0f\u1d1b\u026a\u1d0f\u0274", "n\u0585ti\u0585n"} { + if got := lookalike([]string{name}, stranger); got == "" { + t.Errorf("%s was not flagged", name) + } + if got := lookalike([]string{name}, nil); got == "" { + t.Errorf("%s with no publisher was not flagged", name) + } + } + for _, name := range []string{"tickets", "weather", "notes", "gml", "posture"} { + if got := lookalike([]string{name}, stranger); got != "" { + t.Errorf("%s was flagged as %s", name, got) + } + } + for _, own := range []*publisherDecl{{"domain", "databricks.com"}, {"domain", "mcp.databricks.com"}, {"repository", "github.com/databricks/mcp"}, {"registry", "npm:@databricks/mcp"}} { + if got := lookalike([]string{"databricks-mcp"}, own); got != "" { + t.Errorf("databricks' own publisher %+v was flagged", own) + } + } + if got := lookalike([]string{"databricks"}, &publisherDecl{"domain", "databricks.com.stranger.example"}); got == "" { + t.Fatal("a domain ending in another's name was taken for it") + } +} + +// The publisher of a customer registration is read before anything runs, +// shown with the approval and recorded; under the refuse policy an +// unmaintained publisher refuses the registration, its acquisitions and +// its actions, and a scheduled re-check reads it again. +func TestCustomerRegistrationRecordsPublisherAndPolicyRefuses(t *testing.T) { + bin, _ := buildMCPStandIns(t, t.TempDir()) + target := newWriteStandIn(t, customerTools) + answers := map[string][2]string{"/gh/acme/tickets-mcp": {"200", `{"archived":false,"disabled":false}`}} + checker := publisherStandIn(t, answers) + binding := strings.Replace(customerBinding(target.URL, ""), `"bindingVersion":"2"`, `"bindingVersion":"2","customer":true,"publisher":{"kind":"repository","name":"github.com/acme/tickets-mcp"}`, 1) + f, req, check, adapt := customerFixture(t, bin, binding, false) + // The record takes the configuration's own mode and owner (M5). + if err := os.Chmod(f.config, 0o640); err != nil { + t.Fatal(err) + } + // Where this user is in a group besides its own, the configuration is + // given that group, which a record made by this process would not have + // unless it keeps the configuration's owner. + if groups, err := os.Getgroups(); err == nil && runtime.GOOS != "windows" { + for _, g := range groups { + if g != os.Getgid() && os.Chown(f.config, -1, g) == nil { + break + } + } + } + out, err := connectPublisher(context.Background(), req, f.host, check, checker) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(out.statements, "\n"), req.platform+": publisher repository github.com/acme/tickets-mcp: maintained at 2026-10-30T12:00:00.000Z") { + t.Fatalf("the approval does not show the publisher: %q", out.statements) + } + path := publishersPath(f.config) + record, err := readPublishers(path) + if err != nil || record.Publishers[req.platform].State != publisherMaintained || !strings.HasPrefix(record.Publishers[req.platform].Binding, "tickets@sha256:") { + t.Fatalf("%+v %v", record, err) + } + // Windows has no Unix modes or owners to keep. + info, err := os.Stat(path) + configInfo, _ := os.Stat(f.config) + if err != nil || runtime.GOOS != "windows" && (info.Mode().Perm() != 0o640 || ownerIDsOf(info) != ownerIDsOf(configInfo)) { + t.Fatalf("the record's mode and owner are not the configuration's: %v %v", info, err) + } + + cfg, bindings, err := loadEngineConfig(f.config, stubAccounts(stubUsers)) + if err != nil { + t.Fatal(err) + } + service, server := connectedTestService(t, cfg) + name := req.platform + "/live" + service.sources[name] = adapt(deriveSources(cfg, bindings)[name]) + if service.sources[name].publishers != path { + t.Fatalf("the source is not held to the record: %q", service.sources[name].publishers) + } + acquire := func(session string) (int, map[string]any) { + return post(t, server, "/acquire", `{"session":"`+session+`","source":"`+name+`","arguments":{"tool":"query","arguments":{}}}`) + } + if code, result := acquire("maintained"); code != http.StatusOK { + t.Fatalf("a maintained publisher: %d %v", code, result) + } + + // The repository is archived: the scheduled re-check reads it, and the + // report policy reports it and refuses nothing. + answers["/gh/acme/tickets-mcp"] = [2]string{"200", `{"archived":true,"disabled":false}`} + var stdout, stderr bytes.Buffer + if code := runPublishers(context.Background(), []string{"--config", f.config, "--check"}, &stdout, &stderr, checker); code != 0 { + t.Fatalf("re-check: %d %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), req.platform+": publisher-unmaintained: "+reasonRepoArchived) || !strings.Contains(stdout.String(), "policy: report") { + t.Fatalf("%s", stdout.String()) + } + before := targetCalls(target) + if code, result := acquire("reported"); code != http.StatusOK { + t.Fatalf("an unmaintained publisher under the report policy: %d %v", code, result) + } + + // Under the refuse policy it refuses, before the source is started. + stdout.Reset() + if code := runPublishers(context.Background(), []string{"--config", f.config, "--policy", "refuse"}, &stdout, &stderr, checker); code != 0 || !strings.Contains(stdout.String(), "policy: refuse") { + t.Fatalf("policy: %d %s %s", code, stdout.String(), stderr.String()) + } + calls := targetCalls(target) + if code, result := acquire("refused"); code == http.StatusOK || !strings.Contains(strings.ToLower(result["error"].(string)), "publisher-unmaintained") { + t.Fatalf("an unmaintained publisher under the refuse policy: %d %v", code, result) + } + if targetCalls(target) != calls || calls <= before { + t.Fatal("the refused acquisition reached the server") + } + + // And a registration again (a recapture) is refused before anything + // is run, the configuration unchanged. + configured, _ := os.ReadFile(f.config) + req.replace = true + out, err = connectPublisher(context.Background(), req, f.host, check, checker) + if err == nil || !strings.Contains(err.Error(), "publisher-unmaintained") || !strings.Contains(strings.Join(out.statements, "\n"), "publisher-unmaintained") { + t.Fatalf("a recapture under the refuse policy: %v %q", err, out.statements) + } + if now, _ := os.ReadFile(f.config); !bytes.Equal(now, configured) { + t.Fatal("the refused recapture changed the configuration") + } +} + +// The refusal is of the record's own binding, under the refuse policy, +// and a record that cannot be read refuses rather than admits. +func TestPublisherRefusalReadsTheRecord(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "engine.json"+publishersSuffix) + if err := publisherRefusal(path, "tickets", "tickets@sha256:aa"); err != nil { + t.Fatalf("no record: %v", err) + } + f := publisherFile{Version: 1, Policy: publisherPolicyRefuse, Publishers: map[string]publisherRecord{"tickets": {Binding: "tickets@sha256:aa", State: publisherUnmaintained}}} + if err := writeRecord(t, path, f); err != nil { + t.Fatal(err) + } + if err := publisherRefusal(path, "tickets", "tickets@sha256:aa"); err == nil { + t.Fatal("an unmaintained publisher under the refuse policy was admitted") + } + if err := publisherRefusal(path, "tickets", "tickets@sha256:bb"); err != nil { + t.Fatalf("another binding's record: %v", err) + } + f.Publishers["tickets"] = publisherRecord{Binding: "tickets@sha256:aa", State: publisherUnknown} + writeRecord(t, path, f) + if err := publisherRefusal(path, "tickets", "tickets@sha256:aa"); err != nil { + t.Fatalf("an unknown publisher: %v", err) + } + f.Policy = publisherPolicyReport + f.Publishers["tickets"] = publisherRecord{Binding: "tickets@sha256:aa", State: publisherUnmaintained} + writeRecord(t, path, f) + if err := publisherRefusal(path, "tickets", "tickets@sha256:aa"); err != nil { + t.Fatalf("the report policy: %v", err) + } + os.WriteFile(path, []byte(`{"version":1,"policy":"refuse","publishers":{},"extra":true}`), 0o600) + if err := publisherRefusal(path, "tickets", "tickets@sha256:aa"); err == nil || !strings.Contains(err.Error(), "publisher-record-unreadable") { + t.Fatalf("an unreadable record: %v", err) + } + var stdout, stderr bytes.Buffer + for _, args := range [][]string{{}, {"--config"}, {"--config", "a", "--policy", "deny"}, {"--config", "a", "--config", "b"}, {"--check", "--check", "--config", "a"}} { + if code := runPublishers(context.Background(), args, &stdout, &stderr, publisherChecker{}); code != 2 { + t.Errorf("%v: %d", args, code) + } + } +} + +// An action through a customer platform whose publisher the record says is +// unmaintained is refused under the refuse policy before an executor runs. +func TestActionRefusedForUnmaintainedPublisher(t *testing.T) { + service, _ := testService(t) + service.receiptVersion = receiptVersion3 + path := filepath.Join(t.TempDir(), "engine.json"+publishersSuffix) + ref := "tickets@sha256:" + strings.Repeat("a", 64) + if err := writeRecord(t, path, publisherFile{Version: 1, Policy: publisherPolicyRefuse, Publishers: map[string]publisherRecord{"tickets": {Binding: ref, State: publisherUnmaintained}}}); err != nil { + t.Fatal(err) + } + service.sources["tickets/write"] = sourceSpec{shape: "mcp", tools: []string{"update"}, binding: ref, publishers: path, argv: []string{"executor-that-is-not-there"}} + who := &caller{issuer: "https://issuer.example", subject: "operator"} + _, err := service.act([]byte(`"publisher-session"`), []byte(`"tickets"`), []byte(`"update"`), nil, nil, nil, who) + refusal, ok := err.(actRefusal) + if !ok || refusal.step != "platform" || !strings.Contains(refusal.reason, "publisher-unmaintained") { + t.Fatalf("an action under the refuse policy: %v", err) + } +} + +// writeRecord puts a publisher record in place as a test sets it. +func writeRecord(t *testing.T, path string, f publisherFile) error { + t.Helper() + data, err := json.Marshal(f) + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +// M3: under the refuse policy, a publisher the public source does not show +// is reported, and refuses nothing; only evidence of a lapse refuses. +func TestUnverifiablePublisherIsNeverRefused(t *testing.T) { + path := filepath.Join(t.TempDir(), "engine.json"+publishersSuffix) + ref := "tickets@sha256:" + strings.Repeat("a", 64) + writeRecord(t, path, publisherFile{Version: 1, Policy: publisherPolicyRefuse, Publishers: map[string]publisherRecord{"tickets": {Binding: ref, State: publisherUnverifiable, Reason: reasonRepoMissing}}}) + if err := publisherRefusal(path, "tickets", ref); err != nil { + t.Fatalf("an unverifiable publisher under the refuse policy: %v", err) + } + if lines := publisherLines("tickets", publisherRecord{Kind: "repository", Name: "github.com/acme/private", State: publisherUnverifiable, Reason: reasonRepoMissing}); len(lines) != 2 || !strings.Contains(lines[1], "publisher-unverifiable") { + t.Fatalf("%q", lines) + } +} + +// M4: a refusal answered to /acquire or /act names no path. +func TestUnreadableRecordRefusalNamesNoPath(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "engine.json"+publishersSuffix) + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + err := publisherRefusal(path, "tickets", "tickets@sha256:aa") + if err == nil || strings.Contains(err.Error(), dir) || strings.Contains(err.Error(), "engine.json") || strings.Contains(err.Error(), string(filepath.Separator)) { + t.Fatalf("the refusal: %v", err) + } +} + +// H3: a policy set while a scheduled re-check reads the publishers is not +// lost when the re-check writes what it read. +func TestPolicySetDuringRecheckIsKept(t *testing.T) { + bin, _ := buildMCPStandIns(t, t.TempDir()) + target := newWriteStandIn(t, customerTools) + answers := map[string][2]string{"/gh/acme/tickets-mcp": {"200", `{"archived":false,"disabled":false}`}} + checker := publisherStandIn(t, answers) + binding := strings.Replace(customerBinding(target.URL, ""), `"bindingVersion":"2"`, `"bindingVersion":"2","customer":true,"publisher":{"kind":"repository","name":"github.com/acme/tickets-mcp"}`, 1) + f, req, check, _ := customerFixture(t, bin, binding, false) + if _, err := connectPublisher(context.Background(), req, f.host, check, checker); err != nil { + t.Fatal(err) + } + // The re-check's read is held until the policy has been set. + entered, release := make(chan struct{}), make(chan struct{}) + slow := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(entered) + <-release + w.Write([]byte(`{"archived":true,"disabled":false}`)) + })) + defer slow.Close() + held := publisherChecker{client: slow.Client(), github: slow.URL + "/gh/", now: func() time.Time { return publisherT0 }} + done := make(chan int) + go func() { + var stdout, stderr bytes.Buffer + done <- runPublishers(context.Background(), []string{"--config", f.config, "--check"}, &stdout, &stderr, held) + }() + <-entered + var stdout, stderr bytes.Buffer + if code := runPublishers(context.Background(), []string{"--config", f.config, "--policy", "refuse"}, &stdout, &stderr, checker); code != 0 { + t.Fatalf("the policy: %d %s", code, stderr.String()) + } + close(release) + if code := <-done; code != 0 { + t.Fatal("the re-check failed") + } + record, err := readPublishers(publishersPath(f.config)) + if err != nil || record.Policy != publisherPolicyRefuse || record.Publishers[req.platform].State != publisherUnmaintained { + t.Fatalf("the policy set during the re-check was lost: %+v %v", record, err) + } + if err := publisherRefusal(publishersPath(f.config), req.platform, record.Publishers[req.platform].Binding); err == nil { + t.Fatal("the unmaintained publisher was admitted") + } +} + +// N1: the registrable-domain refusal names what to declare. +func TestDomainPublisherIsTheRegistrableDomain(t *testing.T) { + for name, want := range map[string]string{ + "tickets.acme.example": "declare the server's repository or package", + "tickets.acme.co.uk": `declare "acme.co.uk"`, + "tickets-mcp.vercel.app": "declare the server's repository or package", + "co.uk": "a public suffix", + "nhs.uk": "a public suffix", + // N8: an unlisted two-label suffix: no domain is named to declare. + "acme.com.zz": "cannot be verified here as a registrable domain", + } { + if err := registrableDomain(name); err != nil && strings.Contains(err.Error(), `declare "`) && !strings.HasSuffix(name, ".co.uk") { + t.Errorf("%s: the refusal names a domain to declare: %v", name, err) + } + if err := registrableDomain(name); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: %v", name, err) + } + } + for _, name := range []string{"acme.example", "acme.co.uk", "vercel-tools.example", "leedsth.nhs.uk", "acme.on.ca", "acme.com.es"} { + if err := registrableDomain(name); err != nil { + t.Errorf("%s: %v", name, err) + } + } +} + +// N3: a record a recapture wrote while a re-check read liveness is kept; +// the re-check's reading of the earlier binding does not replace it. +func TestRecaptureDuringRecheckIsKept(t *testing.T) { + bin, _ := buildMCPStandIns(t, t.TempDir()) + target := newWriteStandIn(t, customerTools) + answers := map[string][2]string{ + "/gh/acme/tickets-mcp": {"200", `{"archived":false,"disabled":false}`}, + "/gh/acme/tickets-mcp-v2": {"200", `{"archived":true,"disabled":false}`}, + } + checker := publisherStandIn(t, answers) + v1 := strings.Replace(customerBinding(target.URL, ""), `"bindingVersion":"2"`, `"bindingVersion":"2","customer":true,"publisher":{"kind":"repository","name":"github.com/acme/tickets-mcp"}`, 1) + f, req, check, _ := customerFixture(t, bin, v1, false) + if _, err := connectPublisher(context.Background(), req, f.host, check, checker); err != nil { + t.Fatal(err) + } + entered, release := make(chan struct{}), make(chan struct{}) + slow := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(entered) + <-release + w.Write([]byte(`{"archived":false,"disabled":false}`)) + })) + defer slow.Close() + held := publisherChecker{client: slow.Client(), github: slow.URL + "/gh/", now: func() time.Time { return publisherT0.Add(time.Hour) }} + done := make(chan int) + go func() { + var stdout, stderr bytes.Buffer + done <- runPublishers(context.Background(), []string{"--config", f.config, "--check"}, &stdout, &stderr, held) + }() + <-entered + // The recapture: the binding now names v2, whose repository is archived. + v2 := strings.Replace(v1, "github.com/acme/tickets-mcp", "github.com/acme/tickets-mcp-v2", 1) + if err := os.WriteFile(filepath.Join(f.catalog, "tickets.json"), []byte(v2), 0o600); err != nil { + t.Fatal(err) + } + req.replace = true + if _, err := connectPublisher(context.Background(), req, f.host, check, checker); err != nil { + t.Fatal(err) + } + written, _ := readPublishers(publishersPath(f.config)) + close(release) + if code := <-done; code != 0 { + t.Fatal("the re-check failed") + } + record, err := readPublishers(publishersPath(f.config)) + if err != nil || record.Publishers[req.platform] != written.Publishers[req.platform] || record.Publishers[req.platform].State != publisherUnmaintained { + t.Fatalf("the recapture's record was replaced: %+v, written %+v, %v", record.Publishers[req.platform], written.Publishers[req.platform], err) + } + var stdout, stderr bytes.Buffer + if code := runPublishers(context.Background(), []string{"--config", f.config, "--policy", "refuse"}, &stdout, &stderr, checker); code != 0 { + t.Fatal(stderr.String()) + } + if err := publisherRefusal(publishersPath(f.config), req.platform, written.Publishers[req.platform].Binding); err == nil { + t.Fatal("the archived v2 publisher was admitted") + } +} + +// N9: a reading that is inconclusive -- a failed read, or a publisher the +// public source no longer shows -- never lifts a conclusive one. Under the +// refuse policy, an unmaintained publisher stays refused until a +// conclusive maintained reading; the re-check says so and exits 3. +func TestInconclusiveReadingKeepsTheRefusal(t *testing.T) { + bin, _ := buildMCPStandIns(t, t.TempDir()) + target := newWriteStandIn(t, customerTools) + answers := map[string][2]string{"/gh/acme/tickets-mcp": {"200", `{"archived":true,"disabled":false}`}} + checker := publisherStandIn(t, answers) + binding := strings.Replace(customerBinding(target.URL, ""), `"bindingVersion":"2"`, `"bindingVersion":"2","customer":true,"publisher":{"kind":"repository","name":"github.com/acme/tickets-mcp"}`, 1) + f, req, check, _ := customerFixture(t, bin, binding, false) + if _, err := connectPublisher(context.Background(), req, f.host, check, checker); err != nil { + t.Fatal(err) + } + path := publishersPath(f.config) + var stdout, stderr bytes.Buffer + if code := runPublishers(context.Background(), []string{"--config", f.config, "--policy", "refuse"}, &stdout, &stderr, checker); code != 0 { + t.Fatal(stderr.String()) + } + refused := func() bool { + record, _ := readPublishers(path) + return publisherRefusal(path, req.platform, record.Publishers[req.platform].Binding) != nil + } + if !refused() { + t.Fatal("an archived publisher under the refuse policy was admitted") + } + before, _ := readPublishers(path) + for _, k := range []struct { + answer [2]string + attempt string + }{ + {[2]string{"503", ""}, publisherUnknown}, + {[2]string{"200", `{"archived":"maybe"}`}, publisherUnknown}, + {[2]string{"404", ""}, publisherUnverifiable}, + } { + answers["/gh/acme/tickets-mcp"] = k.answer + stdout.Reset() + stderr.Reset() + if code := runPublishers(context.Background(), []string{"--config", f.config, "--check"}, &stdout, &stderr, checker); code != 3 || !strings.Contains(stderr.String(), "warning") { + t.Fatalf("%v: a re-check whose reading was inconclusive: %d %q", k.answer, code, stderr.String()) + } + record, _ := readPublishers(path) + r := record.Publishers[req.platform] + if r.State != publisherUnmaintained || r.CheckedAt != before.Publishers[req.platform].CheckedAt || r.AttemptState != k.attempt || r.LastAttempt == "" || !strings.Contains(stdout.String(), "publisher-read-inconclusive") { + t.Fatalf("%v: the conclusive reading was not kept: %+v %s", k.answer, r, stdout.String()) + } + if !refused() { + t.Fatalf("%v: an inconclusive reading lifted the refusal", k.answer) + } + } + // A recapture whose reading is inconclusive is refused too. + answers["/gh/acme/tickets-mcp"] = [2]string{"503", ""} + req.replace = true + if _, err := connectPublisher(context.Background(), req, f.host, check, checker); err == nil || !strings.Contains(err.Error(), "publisher-unmaintained") { + t.Fatalf("a recapture over an unmaintained publisher whose reading failed: %v", err) + } + // A conclusive maintained reading lifts it. + answers["/gh/acme/tickets-mcp"] = [2]string{"200", `{"archived":false,"disabled":false}`} + if code := runPublishers(context.Background(), []string{"--config", f.config, "--check"}, &stdout, &stderr, checker); code != 0 { + t.Fatalf("a conclusive re-check: %d", code) + } + if record, _ := readPublishers(path); refused() || record.Publishers[req.platform].LastAttempt != "" { + t.Fatalf("a conclusive maintained reading did not lift the refusal: %+v", record.Publishers[req.platform]) + } +} + +// N9: with no conclusive reading yet, an inconclusive one is the record's +// state, and refuses nothing. +func TestFirstInconclusiveReadingIsUnknown(t *testing.T) { + reading := publisherRecord{Binding: "b", Kind: "repository", Name: "github.com/acme/x", State: publisherUnknown, CheckedAt: "t1"} + if got := mergeReading(publisherRecord{}, false, reading); got != reading { + t.Fatalf("%+v", got) + } + prev := publisherRecord{Binding: "b", Kind: "repository", Name: "github.com/acme/other", State: publisherUnmaintained, CheckedAt: "t0"} + if got := mergeReading(prev, true, reading); got != reading { + t.Fatalf("a reading of another declared publisher kept the earlier one: %+v", got) + } +} + +// N10: a bootstrap that is empty, or names no HTTPS service for the +// top-level domain it lists, is inconclusive (unknown); only a top-level +// domain absent from a bootstrap that lists others is unverifiable. +func TestRDAPBootstrapWithoutAnHTTPSServiceIsUnknown(t *testing.T) { + for body, want := range map[string]string{ + `{}`: publisherUnknown, + `{"services":[]}`: publisherUnknown, + `{"services":[[["example"],["http://rdap.example/"]]]}`: publisherUnknown, + `{"services":[[["other"],["https://rdap.other/"]]]}`: publisherUnverifiable, + } { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(body)) })) + c := publisherChecker{client: server.Client(), bootstrap: server.URL + "/bootstrap.json", now: func() time.Time { return publisherT0 }} + if state, _ := c.check(context.Background(), &publisherDecl{"domain", "acme.example"}); state != want { + t.Errorf("%s: %s, not %s", body, state, want) + } + server.Close() + } +} diff --git a/go/serve.go b/go/serve.go index 1ad27ea..af5f20e 100644 --- a/go/serve.go +++ b/go/serve.go @@ -73,9 +73,13 @@ type sourceSpec struct { // run, by named field (sourceApproval): what a keyed action's identity // binds instead of the command line below; nil for other sources. approval *sourceApproval - argv []string - env []string - user string + // publishers is the publisher record a customer platform's calls are + // held to under the operator's publisher policy (publisher.go); "" for + // every other source. + publishers string + argv []string + env []string + user string // shape is the adapter shape declared with --source-shape (SPEC.md §1.2a); // empty for a bare command, whose stdout is the result itself. shape string @@ -768,6 +772,11 @@ func (g *gatewayService) acquireWithReconciliation(sessionID, source, trail stri if spec.shape != "" && g.receiptVersion != receiptVersion3 { return nil, fmt.Errorf("source %s is an adapter and needs receipt version 3", source) } + if platform, _, _ := strings.Cut(source, "/"); spec.publishers != "" { + if err := publisherRefusal(spec.publishers, platform, spec.binding); err != nil { + return nil, err + } + } if p := spec.descriptorProvenance; p != nil { // Re-read before every source execution, including after a restart. // Registry rotation keys are reached by the same verifier as receipts,