Skip to content

Publisher trust and the name-lookalike check for customer MCP (#357, part b) - #361

Merged
kikashy merged 12 commits into
mainfrom
grant-hygiene-357-publisher
Oct 30, 2026
Merged

kikashy merged 12 commits into
mainfrom
grant-hygiene-357-publisher

Conversation

@kikashy

@kikashy kikashy commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Closes #357, with #360 (merged). This is part (b), milestone v0.24.0. It covers publisher trust and the name-lookalike check for customer MCP. It also carries one #360 residual: the grant's state is now re-read after every token-renewal path.

Out of scope: delegated per-run credentials (#356) and #352 M6. The receipt delegation member is not touched.

What changed

Declaration. A customer binding (#348) may declare "publisher": {"kind": "registry"|"repository"|"domain", "name": ...}. The accepted names are npm:<package>, github.com/<owner>/<repository>, or a registrable domain. The member is refused on a non-customer binding, and the binding digest covers it.

What a domain publisher may be. It is a registrable domain (acme.example, acme.co.uk), refused at parse otherwise:

  • a host under a domain of a listed two-label suffix is refused, and the message names the domain to declare;
  • any other name of three labels or more is refused without naming a domain, since the gateway cannot tell a host from a domain under an unlisted suffix; it says to declare the repository or package;
  • a listed public suffix is refused; the list is partial (co.uk, nhs.uk, com.es, on.ca, …), and declaring an unlisted suffix is meaningless;
  • a shared platform's domain (vercel.app, github.io and others), or any host on one, is refused; such a server declares its repository or package.

Liveness:

  • registry (npm): a deprecated latest version (a message or true) is unmaintained. A 404 is unverifiable, since the package may be private or unpublished.
  • repository (GitHub): archived or disabled is unmaintained. A 404 is unverifiable.
  • domain: the registry's own RDAP service is read, as the IANA bootstrap names it for the top-level domain.
    • Unmaintained when the registry answers 404 ("not registered", which is the window in which another can register the name), when the registration has expired (equal counts as expired), or in redemption or pending deletion.
    • A top-level domain absent from the bootstrap is unverifiable. A bootstrap that cannot be read, is empty, or lists the TLD without an HTTPS service is unknown.
    • Only the declared name is read; a public suffix's object is never read for it.
  • Anything else is unknown. A binding with no publisher is undeclared.

Recorded, shown with the approval, and re-checked:

  • gateway connect reads the publisher before any adapter runs (recapture included) and shows the lines with the approval.
  • The reading is recorded in <engine.json>.publishers.json, with the configuration's own mode and owner, so a signer that can read the configuration can read the record.
  • gateway publishers --check reads every customer platform again, on the operator's schedule.
  • Every read-modify-write of the record is made under the configuration's lock (<engine.json>.lock). --check merges under the lock, keeping a policy set meanwhile, and a platform's record written meanwhile by a connect or recapture.

An inconclusive reading never lifts a conclusive one.

  • An unknown or unverifiable reading of the same declared publisher keeps the last maintained or unmaintained state and its time.
  • The failed read is recorded beside it (lastAttempt, attemptState, attemptReason) and reported as publisher-read-inconclusive.
  • --check warns and exits 3.
  • Under refuse, an unmaintained publisher stays refused, a recapture included, until a conclusive maintained reading.

Policy. publisher-unmaintained and publisher-unverifiable are always reported. Under gateway publishers --policy refuse, a record of the platform's own binding that says unmaintained refuses:

  • the registration;
  • each /acquire, before the source starts;
  • each /act, before an executor runs.

unverifiable, unknown and undeclared are never refused. An unreadable record refuses as publisher-record-unreadable, with no path in the reason. report is the default (open question 15).

name-lookalike. A registration is flagged when its platform name, or its binding's platform name, closely matches a shipped integration under another publisher. Names are compared after folding:

  • case and punctuation;
  • fullwidth letters;
  • Cyrillic and Greek look-alikes (capitals and small letters);
  • Latin letters with diacritics (Latin-1 and Latin Extended-A, and decomposed spellings);
  • mathematical alphanumerics, small capitals and some Armenian letters;
  • 0→o, 1→l, 3→e, 5→s, rn→m, vv→w.

A close match is equal to the integration's name, one edit away (two for a name of ten characters or more), or containing it. It is never a refusal. Letters outside the fold tables are documented as known misses.

Grant renewals (from #360's review). The grant's state is re-read once a token is held after another process's renewal, or after Notion's renewal, as it already was after a Google renewal and a Databricks mint (adapters/connections/provider.go).

Public surfaces changed:

  • the binding member publisher;
  • the command gateway publishers;
  • the record file <engine.json>.publishers.json;
  • the refusals publisher-unmaintained and publisher-record-unreadable;
  • the reports publisher-unverifiable, publisher-read-inconclusive and name-lookalike;
  • gateway publishers --check exit status 3 on an inconclusive reading;
  • the connect lines.

These are documented in SPEC.md §10 rule 8, docs/design/connection-grants.md and docs/design/engine-config.md. No receipt, seal, registry line, statement, corpus vector or catalog binding changes, and no boundary path is touched.

Departures from RFC 0030 §5 rule 7

  1. Where the record lives. It is kept beside the engine configuration, not in the connection grant record.
  2. An undeclared publisher is undeclared; the RFC has null.
  3. Unverifiable is its own state: reported, never refused.
  4. Sources. Liveness is read from npm, GitHub and the registries' RDAP services (through the IANA bootstrap) only.
  5. A domain publisher is a registrable domain, by a partial list. No public-suffix list is read, so hosts, shared-platform hosts, and domains under two-label suffixes outside the list are not declarable. An unlisted public suffix declared as a domain is read as its registry object, which is meaningless.
  6. Inconclusive readings keep the conclusive state. The RFC does not say what a failed re-check does. Here it never lifts a conclusive reading.
  7. No policy refuses a lookalike. The known integrations are the gateway's own fixed list.
  8. Disclosure. Liveness reads disclose to those services which servers an operator uses, so the schedule is left to the operator.

Review

The security clean room and its re-read are posted on this pull request. All findings the build took on are fixed, with mutation-checked tests:

  • HIGH:
    • H3: the record is locked;
    • N1: no walk up from a host; a domain is its registrable domain, read at its registry.
  • MEDIUM:
    • M3: 404 handling;
    • M4: no path in the refusal;
    • M5: the record takes the configuration's mode and owner;
    • M6: homoglyph folding;
    • M7: deprecated true and redemption are pinned;
    • N2: the registry's 404 is a lapse;
    • N3: the per-binding merge.
    • N8: no domain is suggested for an unlisted suffix.
  • HIGH (confirmation): N9, an inconclusive reading keeps the conclusive one.
  • LOW:
    • L9: the parse message and the departures;
    • N4: diacritics, mathematical alphanumerics, small capitals and Armenian;
    • N10: a bootstrap without an HTTPS service is unknown;
    • N6a: renewal re-reads.

Mutants: 15 in the build, 11 in the first fix round, 8 in the second, and 9 in the confirmation round (7 core, 2 adapters). All were caught. One further second-round mutant, combining marks kept, showed that code redundant, and the code was removed.

Material-decision impact: public-surface, documented-claim, security; review: same-vendor clean room and re-read (Claude Opus), not a cross-vendor record.

Checks

At the head:

  • go test ./... passed in full in go/.
  • In adapters/, every package passed except the two pre-existing adapters/internal/sandbox socket-mask tests, which fail identically on main on the build host.
  • -race passed on the publisher tests.
  • CI is green: DCO, check, ubuntu, macos and windows.

v0.24.0 release-note text (part b)

Publisher trust for customer MCP servers (#357, part b).

  • A customer binding may declare its server's publisher: an npm package, a GitHub repository, or a registrable domain. A host, or a host on a shared platform such as vercel.app, is not a domain publisher; declare the repository or package instead.
  • gateway connect reads the publisher's liveness before anything runs, shows it with the approval and records it beside the configuration. gateway publishers --check reads it again on the operator's schedule.
  • A deprecated, archived, expired, redemption-period, pending-delete, or no-longer-registered publisher is reported as publisher-unmaintained. Under gateway publishers --policy refuse, it refuses the registration and each acquisition and action.
  • A publisher the public source cannot show (a private package or repository, or a top-level domain without RDAP) is reported as publisher-unverifiable and never refused.
  • A reading that fails never lifts a conclusive one. An unmaintained publisher stays refused until a conclusive maintained reading, and gateway publishers --check warns and exits 3.
  • A registration whose name closely matches an integration the gateway ships, under another publisher, is flagged name-lookalike.

What operators need to do. Nothing is required.

  • A binding without publisher is undeclared and is not refused, and the default policy is report.
  • The record <engine.json>.publishers.json takes the configuration's own mode and owner. If it cannot be read, every customer platform is refused as publisher-record-unreadable. Keep it beside the configuration when the configuration is moved or restored.

🤖 Generated with Claude Code

kikashy and others added 3 commits October 30, 2026 15:51
State the connection grant record that the custody keeps (spec RFC 0030
§5, a Draft): its members, its least-privilege, owner, idle and
revocation rules, the operator's commands, and the departures from the
RFC. The RFC led. This section and the design state what the
implementation in the next commit keeps. It is not a receipt and changes
no signed record.

Refs #357

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
…dle lifecycle (#357)

Each Drive, Gmail, Notion, Databricks and Azure DevOps connection has a
grant record in its custody (connection-grants.json, kept beside the
state). The record holds the owner and its issuing host, the required,
granted and used scopes, the last use, an optional idle expiry, and the
state: active, quarantined or revoked. It carries no credential, token,
account or content.

- excess-scope: a Google token stating more than the scope requested is
  refused by name, at consent and at renewal (drift). So is a Databricks
  service principal configured with a scope its operations do not
  declare, and a connection holding one is refused at each use.
- Each dispatched acquisition or action records the declared scopes it
  used. The report lists the granted scopes that were not used.
- Before each acquisition or action through an owned grant, an owner
  read goes to the issuing host. removed applies the removal and
  refuses (owner-removed); no answer refuses (owner-unknown). A grant
  with no owner record is used as before and reported as owner-missing.
- An idle expiry is enforced at the next use and by a sweep; equal time
  counts as expired. Owner removal revokes or quarantines, as the
  operator chooses. A quarantined grant admits no call.
- Revocation deletes the credential and calls Google's RFC 7009
  endpoint. Elsewhere, or until the provider confirms, the record says
  provider-grant-may-persist. No pending-change journal is touched.
- gateway-connections --grants OPERATION gives the operator report,
  set-owner, set-idle-expiry, owner-reads, remove-owner, revoke and
  sweep.

Refs #357

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
A customer binding may declare its server's publisher: an npm package, a
GitHub repository or a domain. The binding digest covers it. Its
liveness is read when the server is registered (gateway connect, a
--replace recapture included) and on the operator's schedule (gateway
publishers --check):

- registry: a deprecated latest version or a missing package is
  unmaintained;
- repository: an archived, disabled or missing repository is
  unmaintained;
- domain: an expired (equal time counts), pending-delete or
  unregistered domain is unmaintained.

What was read is kept in <engine.json>.publishers.json (mode 0600), by
platform and by binding. It is shown with the approval, and an
unmaintained publisher is reported as publisher-unmaintained. Under the
operator's refuse policy (gateway publishers --policy refuse), the
registration is refused, and so is each acquisition before its source
starts and each action before an executor runs. A record that cannot be
read refuses.

A registration whose name closely matches one of the gateway's own
integrations under another publisher is flagged name-lookalike, after
folding case, punctuation and look-alike characters: one edit away (two
for a long name), or containing the name.

RFC 0030 §5 rule 7 led; SPEC §10 rule 8, the connection-grants design
and engine-config state it in this commit.

Refs #357

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
…#357)

Windows reports no Unix permission bits, so the 0600 assertion holds on
Linux and macOS only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
@kikashy

kikashy commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Verdict: #360 — changes required before merge (2 HIGH: H1, H2; 2 MEDIUM; 4 LOW). #361 — changes required before merge (1 HIGH: H3; 5 MEDIUM; 5 LOW).

Security clean room: gateway #360 and #361 (#357, parts a and b)

Method

  1. Read first: issue Connection grant hygiene: least-privilege scopes, granted-vs-used, owner and idle lifecycle, publisher trust (agent-identity) #357; spec RFC 0030 §§4–5 and the security, conformance and open-question sections; spec ADR-0017 accepted #284 (NC1–NC5); both PR bodies and their departure lists; SPEC.md §10; docs/design/connection-grants.md; the Confine source programs and report measured sandbox posture (#314) #350 M3 posture rule.
  2. Read both diffs in full. For Connection grants: least privilege, granted against used, owner and idle lifecycle (#357, part a) #360: grant.go, grant_ops.go, every insertion of grantUse and grantGate, the broker and provider changes, and the CLI. For Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361: publisher.go and its hooks in connect, serve (/acquire) and act. I mapped every credential read and every acquisition entry point, and checked that each acquisition passes grantUse.
  3. Ran the builder's targeted tests at the heads. They pass: the grant, scope, publisher and lookalike tests.
  4. Wrote probes as scratch tests in the review clone, and removed them afterwards. They used the production owner-read client and the production publisher client, each trusting only a stand-in's certificate.
  5. Ran six mutants of my own in a scratch copy made with git archive. Each was reverted, and the revert was verified by checksum. The copy was then deleted.
  6. Caps: Go ran under ulimit -v 8000000 and timeout, with GOCACHE, GOMODCACHE and TMPDIR inside the room; all were emptied at the end. No full suite, no corpus and no sandbox test was run.
  7. Public provider documentation read:
    • Databricks: the OAuth M2M page, the workspace API scopes reference, and the Databricks SQL MCP page.
    • Microsoft: the Azure DevOps REST pages for WIQL and Runs – Get.
    • rdap.org's about page, and Verisign's RDAP answers for google.com and www.google.com.

Findings

HIGH

  • H1 (Connection grants: least privilege, granted against used, owner and idle lifecycle (#357, part a) #360) — a grant past its idle expiry still serves the control operations that call the provider.

    • Where: provider.access gates Drive, Gmail and Notion search with grantGate, which checks quarantine only. The Databricks and Azure DevOps connection tests and MCP tests reach the credential through the same gate. None of these evaluates idleExpired.
    • Probe:
      • A Drive grant with a 60 s bound, searched 61 s after creation, reached the provider with the grant's token and returned the listing.
      • The report already said idle: expired (idle-expired finding).
      • Only the next acquisition revoked the grant (grant-idle-expired).
      • The Databricks credential gate (databricksSnapshot) admits a grant past its bound.
    • Against the docs and the RFC:
      • The design note says "An expired grant is revoked at its next use, which is refused as grant-idle-expired, or by the next sweep". A search is a use that is neither refused nor revoking.
      • RFC §5 rule 4 has the grant revoked once the bound is reached.
      • This is not among the PR's departures.
    • Fix: evaluate idle expiry, and revoke, in the gate every credential read passes, or route search and test through grantUse without recording a use.
  • H2 (Connection grants: least privilege, granted against used, owner and idle lifecycle (#357, part a) #360) — a grant whose owner the issuing host has removed still serves the same control operations. They make no owner read.

    • Probe:
      • With the issuing host answering {"standing":"removed"}, a Drive search made no owner read (0 reads).
      • It reached the provider with the grant's token and returned a file name from the removed owner's Drive.
      • The next acquisition made the read, refused with owner-removed, and quarantined the grant; only then was search refused.
    • Other custodies: the same owner's grant in another provider's custody stays active after the removal is learned in one custody. Its own next acquisition makes its own read and is refused (probe: a Databricks acquisition learned the removal, and the Azure DevOps acquisition was then refused by its own read). Until then, its connection test passes the credential gate.
    • Against the RFC:
      • Acquisitions and actions meet RFC §5 rule 5 ("before every use of a grant"); control operations do not.
      • "No grant remains usable with a removed owner" does not hold for search and test.
    • Disclosed: the owner-read part is departure 6. Under this review's rubric it is still a use by a removed owner. If the maintainer keeps the departure, the docs must state the consequence: a removed owner's grant keeps serving searches and tests until the next acquisition, sweep or remove-owner.
    • Suggested fixes:
      • make the owner read on search and test;
      • apply a removal learned at use to every custody of the principal, as remove-owner does.
  • H3 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — the operator's refuse policy can be silently lost. The publisher record (<engine.json>.publishers.json) is read, modified and rewritten with no lock:

    • gateway publishers --policy and --check take none;
    • connect writes the record after its <engine.json>.lock is released.

    A scheduled --check reads the record at its start, makes its liveness reads, and then writes back what it read. Each read has a 15 s timeout, and the reads run one after another.

    • Probe:
      • gateway publishers --policy refuse ran while a re-check was in flight. It exited 0 and printed policy: refuse.
      • When the re-check finished, the record said policy: report.
      • The acquisition check for the platform, whose publisher the record says is unmaintained, then admitted it (publisherRefusal returned nil).
    • A record that a concurrent connect writes is lost the same way.
    • Fix: serialize every read-modify-write of the sidecar under one lock (for example, the configuration's lock, held through the record write), or re-read and merge under a lock before writing.

MEDIUM

  • M1 (Connection grants: least privilege, granted against used, owner and idle lifecycle (#357, part a) #360) — the Databricks declarations defeat least privilege for the default scope.

    • The declarations: every Databricks operation declares all-apis as an alternative (statement sql|all-apis, catalog unity-catalog|all-apis, and each managed MCP server except unity-gateway). So all-apis, the broadest scope and the default for a blank scope, is always in required. It is never excess, and once any statement runs it is recorded as used, so unused never suggests narrowing.
    • Probe:
      • With a blank scope (all-apis), the record had granted [all-apis] and excess [].
      • After one statement it had used [all-apis], unused [], and no finding but owner-missing.
      • configure accepted all-apis sql, and refused all-apis jobs as excess-scope: jobs.
    • Public documentation:
      • Databricks' API scopes reference lists sql as covering Statement Execution and unity-catalog as covering Unity Catalog catalogs.
      • The M2M page says all-apis reaches any REST API the principal may use.
      • So all-apis is not a minimum, and RFC §5 defines required as "the union of the minimum scopes".
    • Inconsistency: the MCP declarations treat all-apis as sufficient, while the gateway's own adapters README says "whether all-apis covers the MCP servers, no page states".
    • Declarations I could confirm:
      • Azure DevOps: vso.work for WIQL and vso.build for pipeline runs, against Microsoft's REST reference.
      • Databricks: sql for /api/2.0/mcp/sql and ai-gateway for system.ai services, against Databricks' SQL MCP page.
      • Google: drive and gmail.readonly, as ADR-0010 and the read-only profile state.
    • Fix: take required as the narrowest scope per operation, and report a broader alternative held (all-apis) as excess or as "wider than needed".
  • M2 (Connection grants: least privilege, granted against used, owner and idle lifecycle (#357, part a) #360) — three fail-closed safeguards are unpinned. These mutants survived the grant tests:

    • MX1: the owner read follows redirects;
    • MX2: the owner read admits any 2xx status;
    • MX3: an unreadable last-use time is taken as not expired.

    The tests replace the production owner-read client with one that sets its own redirect policy, so the production client's policy is never exercised.

    • Probe of the production client: today's behaviour is correct. Each of these is unknown:

      • a 302 or 307 redirect;
      • a 201 or 204 status;
      • a duplicate standing member, a member in the wrong case, or trailing data;
      • a 7 s stall (bounded at 5 s);
      • an untrusted certificate.

      Configuring http, userinfo, a query, a fragment, an empty host or ftp is refused.

  • M3 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — a publisher the public source cannot see reads as unmaintained, and under refuse is refused. Every 404 is taken as "unmaintained".

    • A host name as a domain publisher: the parser accepts "a lower-case host name", and the design note's own example is tickets.acme.example. A registry's RDAP answers 404 for a name that is not itself a registration (verified: Verisign answers 200 for google.com and 404 for www.google.com). Probe: the declared tickets.acme.example read "unmaintained (the domain is not registered)".
    • A TLD rdap.org does not know: rdap.org answers 404 when it knows no RDAP service for the TLD (its about page).
    • Private repositories and packages: GitHub and npm answer 404 to unauthenticated reads of them, which is plausible for a customer's own server.

    Under refuse, each of these refuses the registration and every acquisition and action. "Not registered" and "not found" are inexact here.

  • M4 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — an unreadable record's refusal carries the record's absolute path to the /acquire and /act caller.

    • Probes:
      • publisher-record-unreadable: platform tickets: open /…/engine.json.publishers.json: permission denied;
      • with the record a directory: …: /…/engine.json.publishers.json is not a regular file, also as the /act refusal at the platform step.
    • Rule: under the Confine source programs and report measured sandbox posture (#314) #350 M3 posture rule, public reasons carry no paths.
  • M5 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — existing deployments can break, and the release notes say "Nothing is required".

    • The deployment the docs describe: the signer runs as its own non-root user, and must not hold CAP_DAC_OVERRIDE or CAP_DAC_READ_SEARCH. A root connect over a signer-owned configuration is supported (keepOwner: "a root connect over a file the signer owns leaves it the signer's").
    • The sidecar: writePublishers creates it as the running user, mode 0600, and does not keep the configuration's owner.
    • The consequence: after a root connect of a customer binding, or a root-run gateway publishers, serve cannot read the record. By the unreadable-record rule, every customer platform's acquisition and action is then refused, under the default report policy too.
    • Probe: an unreadable record refused every platform under report. Root ownership is from code reading; nothing was run as root.
  • M6 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — the name-lookalike check misses visual homoglyphs.

    • Why: confusable drops non-ASCII letters instead of folding them, and a platform name may hold any graphic character. printable keeps graphic characters, so the approval line shows a name visually identical to the integration's.
    • Probe — not flagged:
      • nоtiоn (two Cyrillic о);
      • fullwidth notion;
      • gmаіl, dаtаbrісks, оbsidiаn and pоstgrеs.
    • Probe — flagged: a single Cyrillic letter (nоtion) and case (NOTION).
    • Inexact statements: SPEC §10 rule 8's "closely matches" and the PR's "folding … look-alike characters". The design note's list of folds is exact, but it is ASCII only.
    • Confirmed: the flag is reported only and never refused, in the code, the design note ("a report, never a refusal") and SPEC §10.
  • M7 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — two documented liveness rules are unpinned. These mutants survived the publisher tests:

    • MX5: npm's boolean deprecated: true ignored;
    • MX6: RDAP's redemption period ignored.

LOW

  • L1 (Connection grants: least privilege, granted against used, owner and idle lifecycle (#357, part a) #360) — Notion's renewal path does not match the statements.
    • A Notion renewal stating a wider scope is refused by notionAccess's own check as provider-unavailable, not excess-scope, and no drift is recorded. This fails closed. SPEC §10 rule 1 ("at a renewal that states it") and the release note ("at a renewal that adds one") are inexact for Notion.
    • The release note gives the provider-unavailable → excess-scope change for Google only, but the shared exchange applies it to Notion at connect too.
  • L2 (Connection grants: least privilege, granted against used, owner and idle lifecycle (#357, part a) #360) — quarantine and the owner live only in connection-grants.json. If that file is lost, the quarantined credential is admitted again with no owner. Examples: the file is deleted to clear a corrupt one, or state.json is restored without it. Reconciliation then makes a fresh, ownerless, active record for the credential that is still held. The only signal is owner-missing.
  • L3 (Connection grants: least privilege, granted against used, owner and idle lifecycle (#357, part a) #360) — set-owner is irreversible and unverified. A second owner is refused (owner-recorded), there is no reassignment, and no owner read is made when the owner is set. A mistyped owner leaves the grant refused (owner-unknown), or revoked at its next use if the host answers removed for an unknown principal. The only exit is revocation and a new connection.
  • L4 (Connection grants: least privilege, granted against used, owner and idle lifecycle (#357, part a) #360) — no re-check between grantUse and dispatch on the Databricks and Azure DevOps paths. Their dispatch uses the snapshot taken before grantUse. A quarantine that lands between grantUse and dispatch, for example while a service principal's token is minted, is not seen by that call. RFC §4 expects a call that was checked but not dispatched to be "refused at its re-check". This is from code reading only.
  • L5 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — the liveness reads follow redirects without constraint, and only the RDAP answer is bound to the declared name.
    • Probe with the production client:
      • a GitHub 301 to another repository was answered as maintained for the declared name;
      • an HTTPS → HTTP redirect was followed for both GitHub and RDAP.
    • Trust root: rdap.org is a third-party bootstrap redirector, operated by an individual per its about page. It is the trust root for every domain read; the docs say only "RDAP".
  • L6 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — the declared publisher is self-asserted, and not tied to the server's URL host or command. Declaring the integration's own publisher suppresses the lookalike flag, and the declaration is shown with the approval as if it had been established. Examples: domain notion.so, or npm:@notionhq/…. undeclared is never refused, so refuse is opt-in per binding (documented).
  • L7 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — under refuse, an uncertain action cannot be reconciled. Its read-back runs through the platform's live source (acquireWithReconciliation), so it is refused too, and the action stays uncertain. This fails closed, and is not documented.
  • L8 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — an absent record means report. The refuse policy lives only in the sidecar, so a configuration moved or restored without it loses refuse silently. So does one served under a different path spelling: serve uses the absolute path and resolves no link.
  • L9 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — an unlisted departure and an inverted message.
    • RFC §Security lets an operator policy refuse a lookalike; the gateway never does, and this is not among the departures.
    • The parse error "publisher is declared on a customer binding" reads inverted ("only on" is meant).

Established as holding

  • The owner read fails closed on every case tried:
    • TLS failure, timeout (5 s), redirect, and any status but 200;
    • a malformed, duplicate, wrong-case or trailing body, and an unknown standing;
    • no configuration.
  • The owner read cannot be pointed elsewhere: its address is HTTPS only, follows no redirect and uses no proxy. It is set only through the operator CLI, into a custody document of mode 0600.
  • The owner read carries only what it should: the request holds only {host, owner} and the owner-read bearer, and no provider credential or content. The owner-reads answer repeats no address or credential.
  • Least privilege at connect and use:
    • a Google token wider than the scope asked is refused as excess-scope, at consent and at renewal;
    • Databricks configure refuses an undeclared scope, and use refuses a saved one;
    • personal access tokens are reported scopes-unreadable.
  • Quarantine:
    • it keeps the credential;
    • every credential read refuses it, search included (my mutant MX4 was caught);
    • it is left only by revocation (operator revoke, a disconnect or a sweep).
  • Idle expiry: equal time is expired.
  • Revocation:
    • it deletes the credential (state checked);
    • Google's RFC 7009 revocation is recorded as confirmed only on a 2xx answer;
    • otherwise the record says provider-grant-may-persist.
  • Pending actions: no code path removes storage-intent.json.
  • Earlier grants are reported owner-missing.
  • Coverage: every acquisition entry point (Drive read, observe and storage; Gmail; Notion; Databricks; Azure DevOps, MCP included) passes grantUse.
  • The ledger:
    • it holds no token, refresh token, account id, email, client secret or owner-read credential;
    • connection-grants.json and owner-reads.json are 0600, and the custody directory is 0700;
    • every ledger write is under the custody flock.
  • The report: its findings are fixed sentences, with no path, token or account.
  • Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361 refusals:
    • refuse refuses the registration (before anything runs, with the configuration unchanged), /acquire (before the source starts) and /act (before an executor);
    • the record is bound to the binding digest, re-read at every call and replaced by rename;
    • customer bindings carry only MCP operations, so no other source escapes the check.

Mutants (mine, beyond the builder's 32)

# PR Safeguard broken Result
MX1 #360 the owner read follows redirects (production client) survived
MX2 #360 the owner read admits any 2xx survived
MX3 #360 an unreadable last-use time is not expired survived
MX4 #360 the quarantine gate is removed from the credential read (search) caught (TestOwnerRemovalRevokesOrQuarantines)
MX5 #361 npm's boolean deprecated: true is ignored survived
MX6 #361 RDAP's redemption period is ignored survived

Not established

  • Root-owned sidecar (M5): established from code reading and an unreadable-record probe; nothing was run as root.
  • Live provider behaviour:
    • whether Google ever states an added scope at renewal;
    • Notion's token scope string;
    • whether Databricks' token endpoint accepts ai-gateway (it is absent from the workspace API scopes reference, but named on the SQL MCP page);
    • whether all-apis covers the managed MCP servers.
  • Public-source behaviour:
    • GitHub's 301 on a transfer and its 404 for a private repository, and npm's 404 for a private package, are from the providers' known behaviour, not from live reads;
    • the gateway's code was run only against stand-ins;
    • a direct fetch of rdap.org was refused (403), so its 404 semantics are from its about page.
  • Platforms and suites:
    • Windows and macOS behaviour (file modes, locking);
    • the full suites, the corpus and the sandbox tests, which were not run (by the brief).
  • Unmeasured:
    • the L4 race (code reading only);
    • lock contention from one ledger write per acquisition under load.
  • Issuing-host reachability: whether an issuing host on loopback http or a private CA can answer the owner read. The read needs HTTPS with the system roots, which a local Desk (desk #679) may not offer. Every owned grant would then be refused (owner-unknown).
  • Out of scope: delegated calls (Delegated, scoped, short-lived credentials per agent run, with actor and subject in receipts (agent-identity) #356) and the admission read carrying the owner (spec ADR-0017 accepted #284 NC2).

kikashy and others added 3 commits October 30, 2026 18:00
…iew H1, H2, M1, M2, L1, L4)

The review of record found that idle expiry and the owner read gated
acquisitions only. A search, a selection's read or a connection test
reached the provider with an expired grant, or with a grant whose owner
had been removed.

- H1, H2: every read of a grant's credential now passes the full check
  (grantCredential):
  - in provider.access for Drive, Gmail and Notion (search, storage and
    reads);
  - at the Databricks token, covering reads, connection tests and MCP
    tests;
  - at each Azure DevOps read and test.
  The full check covers state, idle expiry (which revokes), excess and
  the owner read. An acquisition's check marks its call, so its
  credential read re-reads the state without a second owner read. An
  owner's grants in other providers' custody are refused at their own
  next use of any kind.
- M1: Databricks declares the narrowest scopes: sql for a statement,
  unity-catalog for catalogs, and each managed MCP server's page scope.
  all-apis is admitted but recorded in wider and reported as
  wider-than-needed. It is never taken as used by an MCP call.
- M2: the production owner-read client is pinned (no redirect, 200
  only), and so is an unreadable grant time counting as expired.
- L4: the Databricks grant state is re-read once a token is held,
  immediately before dispatch.
- L1: the docs now say that Notion is held to excess-scope at connect,
  and that its renewal refuses as provider-unavailable.

SPEC §10 and the design note change with the code. The review led.

Refs #357

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
Bring the review fixes of #360 into #361.

Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iew H3, M3-M7, L9)

- H3: every read-modify-write of the publisher record is made under the
  configuration's lock, the one connect holds. connect writes the record
  under its lock once the configuration is in place (afterWrite).
  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 during a re-check is no longer lost.
- M3: a 404 is no longer taken as a lapse. A package or repository that
  is not public, or a domain no RDAP service answers for, is
  unverifiable, reported as publisher-unverifiable and never refused. A
  host name is read at the first registration above it, so the design
  note's own example (tickets.acme.example) reads as maintained.
- M4: the unreadable-record refusal answered to /acquire and /act names
  no path.
- M5: the record is given the configuration's mode and owner through the
  descriptor, as a replaced configuration is. A root connect over a
  configuration the signer owns leaves a record the signer can read.
- M6: fullwidth letters, and Cyrillic and Greek letters (capitals and
  small letters) drawn like a Latin letter, are folded before the
  lookalike comparison.
- M7: npm's deprecated: true and RDAP's redemption period are pinned by
  tests.
- L9: the parse error now reads "publisher is declared only on a
  customer binding". The departure list says that no policy refuses a
  lookalike.

The review led. SPEC §10 rule 8, the design note and engine-config
change with the code.

Refs #357

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
@kikashy

kikashy commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Re-read after the fix round

Final verdict:

The re-read

The first review's findings

New findings

HIGH

  • N1 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — the RDAP walk-up reads a registration above the publisher's own as maintained. On a 404 the check walks up "down to two labels", with no public-suffix boundary.
    • Nominet (verified): it answers 404 for an unregistered name under co.uk, but answers for co.uk itself, with no expiration event and no pending status.
    • So:
      • a publisher acme.co.uk whose registration lapsed and was deleted reads maintained ("the domain's registration is current"), and so does tickets.acme.co.uk. Probe with a stand-in shaped on those answers: both maintained;
      • a host on a shared platform reads the platform's own registration (probe: tickets-mcp.vercel.app read vercel.app, maintained);
      • the record and the approval line show the declared name, not the name that was read.
    • The regression: under refuse, a lapsed .co.uk publisher that the first round refused is now admitted. A refusing policy is bypassed for the whole class of registrable domains under multi-label public suffixes.
    • Fix:
      • stop the walk at the registrable domain (Public Suffix List);
      • or walk only from a name declared as a host name, and record which name was read;
      • and never take a public suffix's own object for a registration.

MEDIUM

  • N2 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — a lapsed, deleted registration hides as unverifiable.
    • What it reads as: a registrable domain that the authoritative registry answers 404 for is unverifiable, and never refused. It is either never registered, or lapsed and deleted, which is exactly the window in which someone else can register it. Probe: gone.example read unverifiable.
    • Inexact text: the reason "no RDAP service answered for the name or a domain above it" is inexact, because the registry answered "not registered".
    • It can be told apart: this case is distinguishable from rdap.org's own 404 for a TLD without a service. The bootstrap redirect is followed blindly, so the code cannot tell which one answered.
    • The docs' list is incomplete: they say "A 404 is never taken as a lapse" and list private packages and repositories, host names and unknown TLDs, but not deleted registrations.
    • Repositories and packages: a deleted repository and an unpublished package also read unverifiable. The docs do say "private, or not there", and with unauthenticated reads that is a trade-off.
  • N3 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — the re-check's merge keeps a policy set meanwhile, but not a record written meanwhile. --check reads the configuration at its start, without the lock, and under the lock overwrites each platform's record with what it read.
    • Probe:
      1. A recapture of the same platform under another binding (v2, its repository archived, under report) ran while a re-check was blocked, and recorded v2 as unmaintained.
      2. The re-check's merge then replaced that record with its stale reading of v1 (maintained, v1's binding).
      3. After --policy refuse, the acquisition's check for the configured v2 binding admitted it.
    • Scope: this lasts until the next re-check. The design note says "a record written meanwhile is kept".
    • Fix: under the lock, merge a reading only where the record's binding (or the platform's configured binding) is the one that was read.
  • N7 (Connection grants: least privilege, granted against used, owner and idle lifecycle (#357, part a) #360) — a re-added connection is refused until a sweep. After a revocation is recorded (operator revoke, idle expiry or owner removal), a Databricks or Azure DevOps connection made again under the same id is refused as grant-revoked at the snapshot, the connection test and every acquisition, until a sweep saves the reconciled ledger.
    • Why: the fix makes grantGate refuse a revoked record, but grantGate reads the saved ledger without reconciling it. For these providers the generation is the reused connection id.
    • Probe: after revoke and the same configure, all three were refused as grant-revoked, while the report (which reconciles in memory and saves nothing) showed the re-added grant active. A sweep cleared it, and Azure DevOps behaved the same.
    • Docs: they say "a connection made again after a revocation becomes a new grant".
    • Related (pre-existing): the Desk's own disconnect writes no ledger entry, so a re-add before any reconciliation inherits the old record — owner, idle bound and quarantine. That fails closed.
    • Fix: reconcile in the gate, or give these connections a generation drawn at creation.

LOW

Not established in the re-read

  • The owner change in M5 (needs root).
  • Live npm, GitHub and rdap.org reads by the gateway's own code: stand-ins only, shaped on the verified Nominet answers.
  • Which other registries answer RDAP for their public suffixes.
  • Windows and macOS behaviour, and the full suites (by the brief).

kikashy and others added 3 commits October 30, 2026 18:43
…l (re-read N7, N6, N5)

- N7: the credential gate first holds the ledger to custody, and saves
  it where reconciling changes it. A Databricks or Azure DevOps
  connection made again under its id, after its grant was revoked, is
  then a new grant at its gate, as it already is in the report. Before
  this, it stayed grant-revoked until a sweep.
- N6: a Google renewal now re-reads the grant's state once the token is
  renewed, immediately before the token is used, as Databricks does
  after its mint. A quarantine made during the renewal refuses that call.
- N5: SPEC §10 rule 2 now says that a scope admitted as wider than
  needed is recorded as used for an operation it covers when none of
  that operation's declared scopes is granted. That is Databricks'
  all-apis, for a statement or a catalog listing, and never for an MCP
  call.

Refs #357

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
Bring the second review round of #360 into #361.

Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…stry (re-read N1-N4)

- N1, N2: the walk up from a host is gone. A domain publisher is now the
  registrable domain, refused at parse otherwise.
  - The name has exactly two labels, or three under a two-label public
    suffix of a fixed, partial list.
  - A host under a registrable domain is refused, and the message names
    the domain to declare. A public suffix itself is refused too.
  - A shared platform's domain (vercel.app and others), and any host on
    one, is refused; such a server declares its repository or package.
  - Liveness is read from the registry's own RDAP service, as the IANA
    bootstrap names it, not through a redirector.
  - The registry's 404 for the declared domain is unmaintained ("not
    registered"): the name is free for another to register. Only a
    top-level domain with no RDAP service is unverifiable. A public
    suffix's own object is never read for a registration under it.
- N3: under the lock, gateway publishers --check keeps a platform's
  record written since the re-check read it, whether by a connect or a
  recapture, of another binding or at another time. Its stale reading no
  longer replaces that record.
- N4: Latin letters with a diacritic (Latin-1 Supplement and Latin
  Extended-A), mathematical alphanumerics, small capitals and a few
  Armenian letters are now folded before the lookalike comparison. A
  decomposed spelling's combining mark was already dropped by the final
  letter filter. Other letters are listed as known misses.

The re-read led. SPEC §10 rule 8 and the design note change with the
code.

Refs #357

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
@kikashy

kikashy commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Confirmation of the second fix round

Final verdict:

The confirmation

Items

  • N7 (Connection grants: least privilege, granted against used, owner and idle lifecycle (#357, part a) #360) — closed. A Databricks or Azure DevOps connection re-added under its id after a revocation now passes the gate, and its test and acquisition reach the provider (probe). The gate reconciles the ledger first, so the gate and the report agree.
  • N6 (Connection grants: least privilege, granted against used, owner and idle lifecycle (#357, part a) #360) — closed for the main Google renewal. The state is re-read after the renewal is committed.
    • N6a — residual, LOW. Two renewal paths still return a token without that re-read, so a quarantine made during the renewal is not seen by that one call (code reading):
      • the fallback that uses another process's renewal after a failed exchange;
      • Notion's renewal (notionAccess returns before the re-read).
  • N5 (Connection grants: least privilege, granted against used, owner and idle lifecycle (#357, part a) #360) — closed. SPEC §10 rule 2 now covers all-apis, and both PR bodies are rewritten (the departures match the code).
  • N1 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — closed for what was reported.
    • There is no walk-up any more, and the registry is read through the IANA bootstrap.
    • A lapsed acme.co.uk whose registry answers 404 reads unmaintained (probe).
    • A host (tickets.acme.co.uk), a listed public suffix (co.uk) and listed shared platforms (vercel.app, and hosts on it) are refused at parse.
    • Residual: N8.
  • N2 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — closed.
    • A registry 404 for the declared domain reads unmaintained, with an accurate reason (probe: gone.example).
    • Only a TLD with no bootstrap service is unverifiable (.io).
    • A re-registered domain still reads maintained. That is inherent to liveness by RDAP; recording the registration date would be needed to tell.
  • N3 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — closed.
    • Probe: a recapture under another binding during a blocked re-check is kept, so the record's binding matches the configured one. Under refuse, the recaptured binding's archived publisher is then refused.
    • No record is dropped: the merge only sets or skips per platform, and deletes nothing. A record whose binding or reading time changed, or that appeared meanwhile, is left as written.
  • N4 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — closed as documented. All my examples are now flagged: two diacritics, decomposed marks, mathematical bold and script, small capitals, Armenian. Letters outside the tables, such as Latin Extended Additional ọ, are documented as known misses.
  • Wrongful refusal at parse (the question asked). A legitimate registrable domain under a two-label public suffix missing from the partial list is refused at parse. It is a refusal, not a false unmaintained.
    • Examples (PSL suffixes): *.nhs.uk, *.police.uk, *.com.es, *.on.ca.
    • Operator impact: connect refuses the binding with a message, so the operator declares a repository or package, or no publisher (undeclared, never refused).
    • Docs: departure 5 and the design note say so. But the message misdirects (N8).

New findings

HIGH

  • N9 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — a re-check whose read fails lifts a recorded refusal. This has been present since the first head.
    • Mechanism: --check writes whatever it reads. A read that fails becomes unknown, replacing a recorded unmaintained, and refuse admits unknown. A read that fails includes:

      • the IANA bootstrap unreachable or malformed;
      • an RDAP 429 or 5xx;
      • GitHub's unauthenticated rate limit (403 or 429);
      • a timeout.

      So the bootstrap read fails open for a standing refusal.

    • Probe:

      1. With the record at unmaintained under refuse, the acquisition was refused.
      2. A scheduled --check whose read answered 503 exited 0, and recorded unknown.
      3. The acquisition was then admitted.
    • The same, by code reading: an unverifiable reading (an archived repository later deleted, a deprecated package later unpublished) replaces unmaintained the same way.

    • Fix: keep the last conclusive state when a reading is unknown (or unverifiable after unmaintained), and report the failed read.

MEDIUM

  • N8 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — the parse message can lead an operator to declare a public suffix or a platform's domain, and that reads maintained.
    • The misdirection: for a three-label name whose last two labels are not a listed suffix, the parse refusal says "declare <last two labels>". When those labels are an unlisted public suffix (leedsth.nhs.uk → "declare nhs.uk"; acme.com.es → com.es; acme.on.ca → on.ca) or an unlisted shared platform, that is someone else's domain. It parses, since it has two labels and is not listed.
    • The result: it is read at the registry as the registry's or platform's own registration. Nominet answers for nhs.uk (verified) with no expiration, so probes of nhs.uk and police.uk read maintained; so does an unlisted shared-platform domain.
    • Inexact docs: they say "a public suffix itself (co.uk) is refused", which holds only for listed suffixes.
    • Fix: never suggest a domain unless the tail is a listed suffix. Say instead that the gateway cannot tell whether the tail is a public suffix, and to declare the repository or package.

LOW

Not established in the confirmation

  • Live reads by the gateway's own code (stand-ins only).
  • Which other registries answer RDAP for their public suffixes.
  • Behaviour at rate limits in production.

Disposition

#360 merges at ac9bead with CI green. The record above lists the round-1 findings (H1, H2, M1, M2, L1, L4) and the re-read findings (N5, N6, N7); all are fixed and confirmed. N6a (LOW) is folded into #361's next round, since the two PRs share the renewal code. L2 and L3 ride.

#361 is held for N9 (HIGH). N8 (MEDIUM) and N10 (LOW) are fixed in the same round, and the same reviewer confirms that round before merge.

This is a Claude Opus review of a Claude Opus build, not a cross-vendor claim.

Base automatically changed from grant-hygiene-357 to main October 30, 2026 23:10
@github-actions

Copy link
Copy Markdown
Contributor

Carry needed from base commit f37fe41.

kikashy and others added 2 commits October 30, 2026 19:11
Bring #360's squash (f37fe41) into #361. Its content equals the
grant-hygiene-357 head (ac9bead) already merged here, so the conflicts
on SPEC.md and docs/design/connection-grants.md resolve to this branch's
versions, which hold that content and #361's own lines.

Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…newal re-reads (confirmation N9, N8, N10, N6a)

- N9: an inconclusive reading (a failed, timed-out, refused or
  rate-limited read, an answer that cannot be read, or unverifiable)
  never lifts a conclusive one of the same declared publisher. The
  record keeps the last conclusive state and its time, and records the
  failed read beside it (lastAttempt, attemptState, attemptReason),
  reported as publisher-read-inconclusive. gateway publishers --check
  warns on stderr and exits 3. Under refuse, an unmaintained publisher
  stays refused, a recapture included, until a conclusive maintained
  reading. A platform with no conclusive reading yet takes the
  inconclusive state, and is not refused.
- N8: a name of three labels or more under a suffix the gateway does not
  list is refused without naming a domain to declare; the message says
  to declare the repository or package, or none. The suffix list adds
  nhs.uk, police.uk, sch.uk, mod.uk, the Spanish com.es-style suffixes
  and the Canadian provinces (leedsth.nhs.uk is accepted, nhs.uk
  refused). The docs say the list is partial, and that declaring an
  unlisted suffix is meaningless.
- N10: an empty bootstrap, or one that lists the top-level domain with
  no HTTPS service, is unknown (inconclusive), not unverifiable.
- N6a: the grant's state is re-read once a token is held after the
  remaining renewal paths: another process's renewal taken in place of
  a failed one, and Notion's renewal.

The confirmation led. SPEC §10 rule 8 and the design note change with
the code.

Refs #357

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com>
@kikashy

kikashy commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Final confirmation (#361)

Final verdict:

Method

  • Vendor: a Claude Opus confirmation of a Claude Opus fix; not a cross-vendor claim.
  • CI: green, as stated by the coordinator.
  • Reading: I read the fix commit (6192e53), and the PR body's Review and release-note text.
  • Spot-check of the builder's claims — both hold:
    • ac9bead..f37fe41 is empty: the two trees are identical (8bd8aff…);
    • git diff main 4235321 is byte-identical to git diff ac9bead 0dbd348.
  • Probes: I re-ran my N9 probe and added probes for the questions asked, as scratch tests in the review clone, removed afterwards. Caches were in the room, emptied at the end.
  • Public sources: Nominet answers 404 for kent.sch.uk; it answers for mod.uk as a Nominet-managed object, with no expiration, like nhs.uk.

Items

  • N9 — closed, for the same declared publisher. Probe, under refuse, with the record unmaintained:
    • A re-check whose read fails (503) keeps unmaintained, records lastAttempt, attemptState (unknown) and attemptReason, prints publisher-read-inconclusive, warns on stderr and exits 3. The acquisition stays refused.
    • With the repository deleted (404, unverifiable), the state is kept the same way, with exit 3.
    • A recapture whose read fails is refused, with the inconclusive line shown with the approval.
    • A conclusive maintained reading lifts the refusal, with exit 0.
  • Paths that could still lift a refusal:
    • A changed declaration: this resets the state, as documented ("of the same declared publisher"). It needs an operator recapture of a changed binding, and dropping the declaration (undeclared) already does as much by design (L6). See N12 for a case-only change.
    • A new platform id: this lifts nothing on the existing platform. A new platform starts with no conclusive reading, and if its first read fails it is not refused. My probe showed it passing the publisher step; the docs say "A platform with no conclusive reading yet takes the inconclusive state". The conclusive state is kept per platform, not per publisher. Not a finding.
    • Record deletion: no code path deletes a record; the merges and connect only set. Deleting the file is L8.
  • N8 — closed for unlisted tails.
    • Any name of three or more labels whose tail is not a listed suffix is refused without naming a domain ("declare the server's repository or package as its publisher, or none"). Probes: tickets.acme.example, acme.co.th.
    • The list is extended: leedsth.nhs.uk, acme.com.es and acme.on.ca parse, and nhs.uk and mod.uk are refused as suffixes.
    • The docs now say plainly that an unlisted public suffix declared as a two-label name parses and is meaningless (co.th parses).
    • Residual: N11.
  • N10 — closed. These bootstrap answers read unknown, never unverifiable: {}, {"services":[]}, and a listed TLD with only HTTP services. A TLD absent from a valid bootstrap is unverifiable, as documented.
  • N6a — closed. The shared regate re-reads the state after another process's renewal and after Notion's renewal. The builder's TestQuarantineDuringAnotherProcessRenewalRefusesTheCall and TestQuarantineDuringNotionRenewalRefusesTheCall pass.

New findings (LOW)

  • N11 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — the sch.uk entry still names a public suffix. sch.uk is listed as a two-label suffix, but the Public Suffix List has *.sch.uk, so a school's registrable domain has four labels.
    • myschool.kent.sch.uk is refused with "declare kent.sch.uk", which is an LEA-level public suffix.
    • That name parses and is read at Nominet, which answers 404 for kent.sch.uk (verified). So it reads unmaintained, and under refuse it is refused.
    • This is narrow and fails closed, but the message names a suffix.
    • Fix: drop sch.uk from the list, so such names take the no-domain refusal, or treat it as a wildcard.
  • N12 (Publisher trust and the name-lookalike check for customer MCP (#357, part b) #361) — the "same declared publisher" test is a case-sensitive string match. GitHub owners and repositories are not case-sensitive.
    • Probe: a recapture whose binding changes only the case of the owner (github.com/ACME/tickets-mcp), with a failed read, took unknown and lifted the refusal.
    • It needs an operator recapture of a changed binding, and dropping the declaration does as much (L6).
    • Fix: compare repository names without regard to case.

Disposition

#361 merges at 6192e53 with CI green and no HIGH or MEDIUM open. The LOW findings (L2, L3, L5–L8, N11, N12) ride and are recorded in #362. This is a Claude Opus review of a Claude Opus build, not a cross-vendor claim.

@kikashy
kikashy merged commit f673114 into main Oct 30, 2026
6 checks passed
@kikashy
kikashy deleted the grant-hygiene-357-publisher branch October 30, 2026 23:36
@github-actions

Copy link
Copy Markdown
Contributor

🎉 Thank you for your contribution to Judgment Pack!

If you’d like another task, choose a path that interests you:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Connection grant hygiene: least-privilege scopes, granted-vs-used, owner and idle lifecycle, publisher trust (agent-identity)

1 participant